3. Binlog Event 类型详解:FORMAT_DESCRIPTION_EVENT、QUERY_EVENT、TABLE_MAP_EVENT、WRITE_ROWS_EVENT 等核心 Event

Binlog 说白了就是一个「事件流」。MySQL 把每一次数据变更都包装成一个 Event,顺序写入 binlog 文件。你想想看,如果不懂这些 Event 长什么样,那解析就无从下手。今天我就带你把这几个最核心的 Event 扒个底朝天。

核心观点:Binlog 不是一堆 SQL 文本,而是一串结构化的二进制事件。每个 Event 都有固定的 Header + Payload 结构,解析的关键就是按类型读取 Payload。

Binlog 文件 = 连续的事件流 FORMAT_ DESCRIPTION_EVENT 文件头,版本信息 QUERY_EVENT DDL / BEGIN 记录 SQL 语句 TABLE_MAP_EVENT 表 ID → 表结构 元数据映射 WRITE_ROWS_EVENT 实际行数据 INSERT 变更 每个 Event 的通用结构 ┌─────────────────────────────────────────────────────────────┐ │ Event Header (19 字节) │ Event Payload (变长) │ │ timestamp + type + server_id + end_log_pos + flags │ 具体数据内容 │ └─────────────────────────────────────────────────────────────┘

3.1 FORMAT_DESCRIPTION_EVENT:Binlog 的身份证

每个 binlog 文件的第一个 Event 一定是它。我刚开始写解析器时,第一件事就是先读这个 Event,不然后面的数据根本没法解。

它里面存了什么?说白了就是两样东西:

  • Binlog 版本号:MySQL 3.23、4.0、5.0 的 binlog 格式都不一样。现在主流是 v4。
  • 所有 Event 类型的固定头长度数组:每种 Event 的 Header 长度可能不同,这个数组告诉解析器「每种 Event 的 Header 占多少字节」。

我的习惯:解析时先缓存这个 Event 里的头长度数组。后面每遇到一个 Event,直接查表就能知道 Header 边界,不用每次都重新计算。

举个例子,假设你读到 Event Type = 19 (TABLE_MAP_EVENT),查数组得到它的 Header 长度是 19 字节。那你就知道:前 19 字节是 Header,后面全是 Payload。就这么简单。

3.2 QUERY_EVENT:DDL 和事务边界的记录者

这个 Event 很特殊。它不记录行数据,而是记录一条 SQL 语句。什么时候会出现?

  • DDL 语句:CREATE TABLE、ALTER TABLE、DROP TABLE 等
  • 事务控制:BEGIN、COMMIT(早期版本用 QUERY_EVENT 记录 BEGIN)
  • 部分 DML:在 STATEMENT 格式下,INSERT/UPDATE/DELETE 也走 QUERY_EVENT

它的 Payload 结构大致是这样:

+----------------------------+
|  Thread ID (4 字节)        |  // 执行线程的 ID
|  Execution Time (4 字节)   |  // SQL 执行耗时
|  Database Name Length (1 字节) |
|  Error Code (2 字节)       |  // 执行结果错误码
|  Status Variables (变长)    |  // 会话变量、字符集等
|  Database Name (变长)       |  // 当前库名
|  SQL Statement (变长)       |  // 真正的 SQL 文本
+----------------------------+

我曾经踩过的坑:QUERY_EVENT 里的 SQL 语句是原始文本,但字符集可能和当前连接不一样。如果你直接按 UTF-8 解析,遇到中文表名就可能乱码。我后来都是先读 Status Variables 里的字符集信息,再决定怎么解码。

3.3 TABLE_MAP_EVENT:行事件的「翻译官」

这是 ROW 格式 binlog 里最重要的元数据 Event。没有它,你根本不知道后面的行数据对应哪张表、哪些列。

它出现在每个事务的 DML 事件之前。比如一个 INSERT 语句,binlog 里会先写一个 TABLE_MAP_EVENT,再写一个 WRITE_ROWS_EVENT。

Payload 里包含:

  • Table ID (6 字节):表的唯一标识,和 QUERY_EVENT 里的表 ID 对应
  • Database Name + Table Name:库名和表名
  • Column Count:列的数量
  • Column Type 数组:每列的数据类型(MYSQL_TYPE_LONG、MYSQL_TYPE_VARCHAR 等)
  • Column Metadata 数组:类型的附加信息,比如 VARCHAR 的长度、DECIMAL 的精度
  • Null Bitmap:标记哪些列允许为 NULL

关键点:TABLE_MAP_EVENT 只存列的类型元数据,不存列名。列名需要你自己从 INFORMATION_SCHEMA 或建表 DDL 里拿。这也是为什么很多 CDC 工具启动时要先做一次表结构快照。

3.4 WRITE_ROWS_EVENT:真正的行数据来了

这就是我们最关心的 Event——里面存着实际被插入的行数据。在 ROW 格式下,每个 INSERT 语句对应一个或多个 WRITE_ROWS_EVENT。

它的 Payload 结构:

+-----------------------------------+
|  Table ID (6 字节)                |  // 对应 TABLE_MAP_EVENT 的表 ID
|  Flags (2 字节)                   |  // 保留标志位
|  Column Count (压缩编码)          |  // 实际包含的列数
|  Columns Bitmap (变长)            |  // 标记哪些列有数据
|  Null Bitmap (变长)               |  // 标记哪些列为 NULL
|  Row Data (变长)                  |  // 一行或多行的实际数据
+-----------------------------------+

行数据的解析逻辑是这样的:

  1. 先根据 Columns Bitmap 确定「这一行包含哪些列」
  2. 再根据 Null Bitmap 判断「哪些列是 NULL」
  3. 剩下的列按 TABLE_MAP_EVENT 里的 Column Type 逐个读取

我建议:解析行数据时,一定要先缓存 TABLE_MAP_EVENT 的列类型数组。每来一个 WRITE_ROWS_EVENT,直接查缓存,不要每次都去解析表结构。我在项目中用了一个 HashMap<Long, TableMeta> 来存,性能提升很明显。

3.5 其他常用 Event 一览

Event 类型 Type Code 作用 出现场景
ROTATE_EVENT 4 binlog 文件切换时记录下一个文件名和位置 每个 binlog 文件末尾
STOP_EVENT 3 MySQL 服务停止时写入 正常关闭
XID_EVENT 16 记录事务的 XID,用于崩溃恢复 每个 COMMIT 之后
UPDATE_ROWS_EVENT 24 记录 UPDATE 操作的行数据(包含旧值和新值) ROW 格式的 UPDATE
DELETE_ROWS_EVENT 25 记录 DELETE 操作的行数据 ROW 格式的 DELETE
GTID_EVENT 33 记录全局事务 ID,用于主从复制和故障切换 开启 GTID 模式后每个事务前

3.6 实战:如何组装一个完整的事务

光知道单个 Event 还不够,你得把它们串起来。一个典型的事务在 binlog 里长这样:

GTID_EVENT          -- 事务的全局 ID
QUERY_EVENT         -- "BEGIN"
TABLE_MAP_EVENT     -- 表 ID → 表结构映射
WRITE_ROWS_EVENT    -- 插入的行数据
TABLE_MAP_EVENT     -- 另一张表的映射(如果有)
UPDATE_ROWS_EVENT   -- 更新的行数据
XID_EVENT           -- COMMIT,事务结束

解析器的核心逻辑就是:

  1. 读到 GTID_EVENT 或 QUERY_EVENT("BEGIN") 时,开启一个新事务缓存
  2. 读到 TABLE_MAP_EVENT 时,更新当前表结构缓存
  3. 读到 WRITE/UPDATE/DELETE_ROWS_EVENT 时,解析行数据并追加到事务缓存
  4. 读到 XID_EVENT 或 QUERY_EVENT("COMMIT") 时,把整个事务提交出去

我曾经遇到的一个坑:在 MySQL 5.6 之前,QUERY_EVENT 里可能没有 "BEGIN",而是直接跟 TABLE_MAP_EVENT。如果你只靠 "BEGIN" 来识别事务开始,就会漏掉一些事务。我后来改成「遇到 TABLE_MAP_EVENT 时,如果当前不在事务内,就自动开启一个新事务」。

3.7 小结

这几种 Event 是 binlog 解析的基石。FORMAT_DESCRIPTION_EVENT 告诉你「怎么读」,QUERY_EVENT 告诉你「发生了什么操作」,TABLE_MAP_EVENT 告诉你「操作哪张表」,WRITE_ROWS_EVENT 告诉你「具体改了哪些数据」。把这四个吃透了,剩下的 Event 都是类似的套路。