4. MySQL Binlog 解析:Binlog 格式、事件结构与 GTID 原理
说到 CDC,Binlog 是绕不开的核心。MySQL 的 Binlog(二进制日志)记录了所有数据变更,我们做数据同步,本质上就是“读懂”这些日志。这一章,我带你彻底搞懂 Binlog 的三种格式、内部事件结构,以及 GTID 这个“全局身份证”到底怎么用。
4.1 Binlog 的三种格式:Statement、Row、Mixed
MySQL 提供了三种记录模式。选错了,轻则数据不一致,重则同步失败。我见过不少新手在这上面栽跟头。
| 格式 | 记录方式 | 优点 | 缺点 |
|---|---|---|---|
| Statement | 记录执行的 SQL 语句 | 日志量小,易于阅读 | 依赖上下文,可能产生数据不一致 |
| Row | 记录每一行数据的变化 | 精确、安全,无歧义 | 日志量大,尤其大事务 |
| Mixed | 混合模式,自动选择 | 兼顾两者优点 | 逻辑复杂,偶有意外 |
4.1.1 Statement 格式
说白了,就是记录你执行的那条 SQL。比如 UPDATE user SET age=age+1 WHERE id=1,Binlog 里就存这条 SQL。从库重放时,再执行一遍。
听起来很完美?其实坑很多。比如用了 NOW()、RAND() 这类非确定性函数,主从执行结果可能不一样。还有 LOAD DATA INFILE 这种操作,Statement 格式根本没法正确回放。
UUID() 或 SYSDATE(),数据同步大概率会出问题。我曾经排查过一个线上事故,就是因为主库用了 RAND() 做随机分配,从库重放后数据全乱了。
4.1.2 Row 格式
Row 格式就实在多了。它不记 SQL,只记“哪一行变成了什么样”。比如更新了一行,Binlog 里会记录更新前的“前镜像”和更新后的“后镜像”。
这样做的好处是——精确。无论你怎么折腾,从库都能完美还原。这也是目前 CDC 工具(如 Canal、Debezium)最推荐的格式。
4.1.3 Mixed 格式
Mixed 是 MySQL 的“智能模式”。它会自动判断:如果 SQL 是确定性的,就用 Statement;如果可能产生歧义,就自动切换成 Row。
听起来很聪明对吧?但实际用起来,有时候会“聪明反被聪明误”。比如某些复杂存储过程,MySQL 可能误判为确定性,结果还是出了问题。我个人不太推荐在生产环境用 Mixed 做 CDC 源。
4.2 Binlog 事件结构
Binlog 不是一堆文本,而是一系列事件的集合。每个事件都有固定的头部和体部。理解这个结构,你才能自己写解析器。
4.2.1 事件头部(Event Header)
每个事件开头固定 19 字节,包含以下信息:
- timestamp(4 字节):事件发生的时间戳
- event type(1 字节):事件类型编号
- server id(4 字节):产生事件的服务器 ID
- event length(4 字节):整个事件的总长度
- next position(4 字节):下一个事件的起始位置
- flags(2 字节):标志位
嗯,这里要注意:next position 这个字段特别有用。做 CDC 时,你可以通过它来定位“读到哪了”,实现断点续传。
4.2.2 常见事件类型
Binlog 里事件类型很多,但 CDC 关注的就那么几个:
| 事件类型 | 编号 | 说明 |
|---|---|---|
| FORMAT_DESCRIPTION | 15 | Binlog 文件头,描述格式版本 |
| QUERY | 2 | 记录 SQL 语句(Statement 格式) |
| TABLE_MAP | 19 | 表结构映射,Row 格式必备 |
| WRITE_ROWS | 30 | 插入行数据 |
| UPDATE_ROWS | 31 | 更新行数据 |
| DELETE_ROWS | 32 | 删除行数据 |
| XID | 16 | 事务提交 |
| GTID | 33 | GTID 事件,标识全局事务 |
你想想看,解析 Row 格式的 Binlog,核心就是:先读到 TABLE_MAP 事件,知道要操作哪张表;然后跟着 WRITE_ROWS、UPDATE_ROWS 或 DELETE_ROWS,拿到具体的数据行。
4.2.3 Row 格式的数据体
Row 事件体里,数据是按列存储的。每一列都有元数据描述:列类型、是否可为 NULL、是否为主键等。解析时,你需要根据 TABLE_MAP 提供的表结构信息,逐列解码。
-- 伪代码示例:解析 UPDATE_ROWS 事件
event = read_event()
if event.type == TABLE_MAP:
table_info = parse_table_map(event)
elif event.type == UPDATE_ROWS:
before_image = read_row(event, table_info) -- 更新前的值
after_image = read_row(event, table_info) -- 更新后的值
print("数据从", before_image, "变为", after_image)
TABLE_MAP 和 ROWS 事件是成对出现的。解析时一定要维护好表结构缓存,否则数据根本读不出来。
4.3 GTID 原理
GTID(Global Transaction Identifier)是 MySQL 5.6 引入的“全局事务身份证”。每个事务提交时,MySQL 会分配一个唯一的 GTID,格式为:server_uuid:transaction_id。
举个例子:d5b3c4e1-2a3f-11ec-8d3a-0242ac130003:12345。前面是服务器的 UUID,后面是事务序号。这个 ID 在整个复制拓扑里是唯一的。
4.3.1 GTID 解决了什么问题?
在传统 Binlog 复制中,从库靠“文件名 + 偏移量”来定位同步位置。一旦主库切换、Binlog 轮转,位置就乱了。你想想看,手动去找“从哪开始同步”,是不是很痛苦?
GTID 就不一样了。每个事务都有唯一 ID,从库只需要记住“我已经执行到哪个 GTID 了”。不管主库怎么变,从库都能自动找到正确的位置继续同步。
CHANGE MASTER TO MASTER_AUTO_POSITION=1 就搞定了。
4.3.2 GTID 的生命周期
GTID 从生成到回收,大致经历这几个阶段:
- 生成:事务提交时,MySQL 生成 GTID,写入 Binlog 的 GTID 事件
- 传播:主库将 GTID 发送给从库
- 执行:从库收到 GTID,检查是否已执行过(防止重复)
- 记录:从库将 GTID 记录到
gtid_executed表中 - 回收:当 GTID 不再需要时(如 Binlog 被清理),自动移除
4.3.3 GTID 与 CDC 的关系
做 CDC 时,GTID 的价值非常大。你可以用它来标记“已经消费到哪个事务了”。即使 CDC 进程重启,也能从上次中断的位置继续,不会重复也不会遗漏。
-- 查看当前已执行的 GTID 集合
SHOW MASTER STATUS;
-- 结果中的 Executed_Gtid_Set 字段就是
-- 从库查看 GTID 执行情况
SHOW SLAVE STATUS\G
-- 关注 Retrieved_Gtid_Set 和 Executed_Gtid_Set
CREATE TABLE ... SELECT 这种非事务性语句。另外,GTID 模式下,skip-slave-error 会失效。这些坑,我在项目里都踩过。
4.4 知识体系总览
下面这张图,帮你把本章的核心逻辑串起来:
这张图把本章的三个核心模块串在了一起。从左到右看:Binlog 格式决定了数据怎么记录;事件结构决定了数据怎么组织;GTID 决定了事务怎么追踪。三者结合,才能构建一个可靠的 CDC 同步方案。