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。
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 (变长) | // 一行或多行的实际数据
+-----------------------------------+
行数据的解析逻辑是这样的:
- 先根据 Columns Bitmap 确定「这一行包含哪些列」
- 再根据 Null Bitmap 判断「哪些列是 NULL」
- 剩下的列按 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,事务结束
解析器的核心逻辑就是:
- 读到 GTID_EVENT 或 QUERY_EVENT("BEGIN") 时,开启一个新事务缓存
- 读到 TABLE_MAP_EVENT 时,更新当前表结构缓存
- 读到 WRITE/UPDATE/DELETE_ROWS_EVENT 时,解析行数据并追加到事务缓存
- 读到 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 都是类似的套路。