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 格式根本没法正确回放。

注意: 如果你用 Statement 格式做 CDC,遇到 UUID() 或 SYSDATE(),数据同步大概率会出问题。我曾经排查过一个线上事故,就是因为主库用了 RAND() 做随机分配,从库重放后数据全乱了。

4.1.2 Row 格式

Row 格式就实在多了。它不记 SQL,只记“哪一行变成了什么样”。比如更新了一行,Binlog 里会记录更新前的“前镜像”和更新后的“后镜像”。

这样做的好处是——精确。无论你怎么折腾,从库都能完美还原。这也是目前 CDC 工具(如 Canal、Debezium)最推荐的格式。

我的建议: 做数据同步,优先选 Row 格式。虽然日志量大一点,但换来的是数据一致性。磁盘便宜,数据错了可不好修。

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)
核心要点: Row 格式的 Binlog 里,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 了”。不管主库怎么变,从库都能自动找到正确的位置继续同步。

避坑指南: 我曾经在一个多源复制场景里,主库挂了,需要把从库提升为新主库。如果用传统方式,得手动计算 Binlog 位置,稍有不慎就丢数据。后来换成 GTID 模式,一条 CHANGE MASTER TO MASTER_AUTO_POSITION=1 就搞定了。

4.3.2 GTID 的生命周期

GTID 从生成到回收,大致经历这几个阶段:

  1. 生成:事务提交时,MySQL 生成 GTID,写入 Binlog 的 GTID 事件
  2. 传播:主库将 GTID 发送给从库
  3. 执行:从库收到 GTID,检查是否已执行过(防止重复)
  4. 记录:从库将 GTID 记录到 gtid_executed 表中
  5. 回收:当 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
注意: GTID 虽然强大,但开启后有一些限制。比如不支持 CREATE TABLE ... SELECT 这种非事务性语句。另外,GTID 模式下,skip-slave-error 会失效。这些坑,我在项目里都踩过。

4.4 知识体系总览

下面这张图,帮你把本章的核心逻辑串起来:

Binlog 解析知识体系 Binlog 解析核心知识体系 Binlog 格式 事件结构 GTID 原理 Statement Row Mixed 事件头部 事件体 TABLE_MAP ROWS 事件 GTID 生成 GTID 传播 GTID 执行 GTID 记录 CDC 数据同步应用 核心结论 Row 格式 + GTID 是 CDC 的最佳实践组合 理解事件结构是自研解析器的基础

这张图把本章的三个核心模块串在了一起。从左到右看:Binlog 格式决定了数据怎么记录;事件结构决定了数据怎么组织;GTID 决定了事务怎么追踪。三者结合,才能构建一个可靠的 CDC 同步方案。

一句话总结: 做 MySQL CDC,格式选 Row,定位用 GTID,解析靠事件结构。这三样吃透了,Binlog 在你面前就是透明的。