4. 模拟MySQL主从同步:解析Binlog事件
说实话,很多人在学主从同步时,总觉得那是DBA的事。但作为一个数据架构师,你不懂Binlog,就等于不懂MySQL的增量同步到底在干什么。我当年第一次接手数据迁移项目时,就被Binlog的格式搞得一头雾水。后来花了整整一周,才把Table_map、Write_rows这些事件彻底吃透。
今天我们就来手撕Binlog事件。你想想看,主从同步的本质是什么?说白了,就是主库把“我做了什么”记录下来,从库照着做一遍。那记录的方式,就是Binlog事件。
4.1 Binlog事件的核心结构
每个Binlog事件都有一个固定头,然后是负载数据。我习惯把事件头理解成快递单——上面写着包裹大小、类型、时间戳。而负载就是包裹里的东西。
| 字段 | 大小 | 说明 |
|---|---|---|
| timestamp | 4字节 | 事件发生的时间戳 |
| event_type | 1字节 | 事件类型编码(如19表示Table_map) |
| server_id | 4字节 | 产生事件的服务器ID |
| event_size | 4字节 | 整个事件的总字节数 |
| log_pos | 4字节 | 下一个事件的起始位置 |
| flags | 2字节 | 标志位 |
嗯,这里要注意:事件类型编码是理解Binlog的关键。Row-Based模式下,最核心的就是Table_map事件加上Write_rows、Update_rows、Delete_rows事件。它们总是成对出现。
4.2 Table_map事件:告诉从库要改哪张表
Table_map事件就像一张地图。它告诉从库:“接下来我要操作的是哪个数据库的哪张表,表的字段长什么样。”
我在项目中遇到过一个问题:主库和从库的表结构不完全一致,结果Table_map解析出来的字段数量对不上,同步直接报错。从那以后,我每次做迁移前都会先检查表结构是否对齐。
Table_map事件的核心字段:
- table_id:表的唯一标识(6字节)
- database_name:库名(以空字符结尾)
- table_name:表名(以空字符结尾)
- column_count:列的数量(压缩编码)
- column_type_def:每个列的类型定义(数组)
- column_meta_def:每个列的元信息(如字符串长度)
- null_bitmap:哪些列允许为NULL
举个例子,假设我们有一张用户表:
CREATE TABLE `user` (
`id` int(11) NOT NULL,
`name` varchar(50) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
);
当执行INSERT时,Binlog里会先出现一个Table_map事件,记录table_id=123,database='test',table='user',column_count=3,类型分别是INT、VARCHAR、INT。然后紧跟一个Write_rows事件。
4.3 Write_rows事件:记录新增的数据
Write_rows事件就是“我插入了哪些行”。它包含一个或多个行的列值。每一行的格式取决于是否记录所有列,还是只记录变化的列。
我个人习惯把Write_rows的负载结构画成一张图:
小技巧:在解析Write_rows时,一定要结合Table_map中的column_count和column_type_def。否则你拿到一堆二进制数据,根本不知道哪个字节对应哪个字段。
来看一个实际的解析过程。假设我们执行了:
INSERT INTO user VALUES (1, '张三', 25);
Binlog中Write_rows事件的负载大致是:
table_id: 123
flags: 0
column_count: 3
rows:
- id: 1 (INT, 4字节)
- name: '张三' (VARCHAR, 长度前缀+UTF8编码)
- age: 25 (INT, 4字节)
我曾经踩过一个坑:VARCHAR字段在Binlog里不是直接存字符串,而是先存一个长度(1字节或2字节),再存实际内容。如果按固定长度去读,解析出来的数据全是乱码。
4.4 Update_rows事件:记录修改前后的数据
Update_rows事件比Write_rows复杂一点。它同时记录了修改前的值和修改后的值。每一行由两部分组成:before image和after image。
你想想看,为什么需要before image?因为从库需要知道“改的是哪一行”。如果只记录修改后的值,从库怎么定位到具体行?所以Binlog会把主键或唯一索引的值放在before image里。
注意:如果表没有主键,Update_rows的before image会包含所有列的值。这会导致Binlog体积暴增,同步性能急剧下降。我曾经接手过一个项目,就是因为表没主键,Binlog一天涨了50GB。
假设我们执行:
UPDATE user SET age = 26 WHERE id = 1;
Binlog中Update_rows事件的负载大致是:
table_id: 123
flags: 0
column_count: 3
rows:
- before: id=1, name='张三', age=25
- after: id=1, name='张三', age=26
注意看,before image里age是25,after image里age变成了26。而id和name没有变化,但依然被记录下来了。这就是Row-Based模式的特性——它记录的是整行数据,而不是只记录变化的列。
4.5 Delete_rows事件:记录删除的数据
Delete_rows事件和Write_rows正好相反。它记录的是被删除的行。每一行只有before image,没有after image——因为删都删了,还记录什么after?
执行:
DELETE FROM user WHERE id = 1;
Binlog中Delete_rows事件的负载:
table_id: 123
flags: 0
column_count: 3
rows:
- id=1, name='张三', age=26
这里有个细节:Delete_rows记录的列值,和Update_rows的before image一样,通常包含所有列。但如果你配置了binlog_row_image=MINIMAL,那就只记录主键列。
binlog_row_image参数对比:
| 参数值 | Write_rows | Update_rows before | Update_rows after | Delete_rows |
|---|---|---|---|---|
| FULL | 所有列 | 所有列 | 所有列 | 所有列 |
| MINIMAL | 所有列 | 仅主键 | 仅变化的列+主键 | 仅主键 |
| NOBLOB | 除BLOB外所有列 | 除BLOB外所有列 | 除BLOB外所有列 | 除BLOB外所有列 |
我建议你在做增量同步时,如果表结构稳定且都有主键,就把binlog_row_image设为MINIMAL。这样Binlog体积能减少30%-50%,解析速度也快很多。
4.6 事件之间的协作关系
现在我们把四个事件串起来看。一次INSERT操作,Binlog里会生成两个事件:Table_map + Write_rows。一次UPDATE操作,生成Table_map + Update_rows。一次DELETE操作,生成Table_map + Delete_rows。
它们的关系可以用一张流程图来表示:
4.7 实战:用Python解析Binlog事件
光说不练假把式。我们写一段简单的Python代码,模拟解析Binlog事件的过程。这里我用的是python-mysql-replication库,它底层就是解析这些事件。
from pymysqlreplication import BinLogStreamReader
from pymysqlreplication.row_event import (
WriteRowsEvent,
UpdateRowsEvent,
DeleteRowsEvent
)
# 连接MySQL并监听Binlog
stream = BinLogStreamReader(
connection_settings={
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"passwd": "password"
},
server_id=100,
only_events=[WriteRowsEvent, UpdateRowsEvent, DeleteRowsEvent],
only_schemas=["test"],
only_tables=["user"]
)
for event in stream:
if isinstance(event, WriteRowsEvent):
print(f"[INSERT] 表: {event.table}")
for row in event.rows:
print(f" 插入数据: {row['values']}")
elif isinstance(event, UpdateRowsEvent):
print(f"[UPDATE] 表: {event.table}")
for row in event.rows:
print(f" 修改前: {row['before_values']}")
print(f" 修改后: {row['after_values']}")
elif isinstance(event, DeleteRowsEvent):
print(f"[DELETE] 表: {event.table}")
for row in event.rows:
print(f" 删除数据: {row['values']}")
stream.close()
这段代码的核心逻辑就是:监听Binlog,根据事件类型做不同的处理。WriteRowsEvent对应Write_rows事件,UpdateRowsEvent对应Update_rows事件,DeleteRowsEvent对应Delete_rows事件。每个事件里的rows字段就是实际的数据行。
经验之谈:在生产环境中,你还需要处理Table_map事件的缓存。因为同一个表可能被多次操作,每次都去解析Table_map太浪费了。我一般会建一个字典,以table_id为key,缓存表结构信息。这样后续的行事件直接查字典就行,解析速度能提升好几倍。
4.8 常见问题与避坑指南
最后,我总结几个实际项目中容易踩的坑:
- Binlog格式没设对:如果binlog_format不是ROW,你根本看不到这些事件。我曾经排查了半天,结果发现测试库用的是STATEMENT格式。
- 表结构变更导致解析失败:如果主库改了表结构(比如加了字段),而Binlog里旧的Table_map缓存还没失效,解析就会报错。解决方案是在改表结构后刷新Binlog。
- 大事务导致内存溢出:一个事务里插入了100万行数据,Write_rows事件会包含所有行。如果一次性加载到内存,很容易OOM。我建议用流式解析,逐行处理。
- 字符集不一致:主库是UTF8MB4,从库是Latin1,解析出来的中文全是乱码。确保两边的字符集一致,或者在解析时指定字符集转换。