4. Binlog Event 详解(上):FORMAT_DESCRIPTION_EVENT、QUERY_EVENT、TABLE_MAP_EVENT

各位同学,欢迎来到 Binlog 解析实战的核心章节。从今天开始,我们要真正动手拆解 Binlog 里的每一个 Event 了。

我个人习惯把 Binlog 比作一本「操作日记」。日记嘛,总得有个格式说明、正文内容,还得有个索引。今天要讲的三个 Event,正好对应了这三个角色:FORMAT_DESCRIPTION_EVENT 是日记的「格式说明页」,QUERY_EVENT 是「直接记录的语句」,而 TABLE_MAP_EVENT 则是「操作对象的索引表」。

你想想看,没有格式说明,你连日记的编码都搞不清楚;没有 QUERY_EVENT,你就不知道执行了什么 SQL;没有 TABLE_MAP_EVENT,Row-Based 的 Binlog 根本不知道改的是哪张表。所以,这三个 Event 是解析 Binlog 的基石。

核心观点: 理解这三个 Event 的结构,你就掌握了 Binlog 解析的 60% 的难点。剩下的 40% 是 Row_Event 的具体数据格式。

4.1 FORMAT_DESCRIPTION_EVENT:Binlog 的「版本说明书」

FORMAT_DESCRIPTION_EVENT,简称 FDE。它是 Binlog 文件的第一个 Event,而且每个 Binlog 文件只有一个。它告诉解析器:这个 Binlog 文件是用什么版本的格式写的,服务器版本是多少,以及后面每个 Event 的固定头部长度是多少。

嗯,这里要注意。MySQL 不同版本之间,Event 的头部长度可能不一样。比如 MySQL 5.6 和 5.7 的某些 Event 头部长度就有差异。如果你写解析器时硬编码了长度,那换一个版本可能就全崩了。

我曾经在做一个数据同步项目时,就踩过这个坑。当时测试环境是 MySQL 5.7,一切正常。上线后客户用的是 MySQL 8.0,结果解析出来的数据全是乱码。查了半天,发现就是 FDE 里声明的头部长度变了,而我的解析器没动态读取这个值。

FDE 的结构其实很简单,我们直接看它的二进制布局:

+=====================================+
| Event Header (固定 19 字节)          |
+=====================================+
| binlog_version (2字节)              |  -- 通常是 4
| mysql_server_version (50字节)       |  -- 如 "8.0.32"
| create_timestamp (4字节)            |  -- 创建时间戳
| event_header_length (1字节)         |  -- 头部长度,通常是 19
| event_type_header_lengths (变长)    |  -- 每种 Event 的额外头部长度数组
+=====================================+

这里面最关键的是 event_header_length 和 event_type_header_lengths。前者告诉解析器每个 Event 的公共头部有多长,后者则是一个数组,按 Event Type 的顺序,列出了每种 Event 的私有头部长度。

字段 字节数 说明
binlog_version 2 Binlog 格式版本,目前都是 4
mysql_server_version 50 MySQL 服务器版本号,定长 50 字节
create_timestamp 4 Binlog 文件创建时的 Unix 时间戳
event_header_length 1 Event 公共头部长度,通常为 19
event_type_header_lengths 变长 每种 Event 的私有头部长度数组
解析技巧: 在解析任何 Event 之前,先读取 FDE 中的 event_type_header_lengths 数组。这样你就能知道每个 Event 的私有头部有多长,从而准确定位到 payload 数据。

4.2 QUERY_EVENT:记录 SQL 语句的「直接证据」

QUERY_EVENT,顾名思义,就是记录了一条 SQL 语句。在 Statement-Based 模式下,所有的 DDL 和 DML 都会生成 QUERY_EVENT。在 Row-Based 模式下,DDL 仍然使用 QUERY_EVENT,而 DML 则改用 Row_Event。

说白了,只要你在 MySQL 里执行了一条 CREATE TABLE、ALTER TABLE、DROP TABLE,或者 BEGIN、COMMIT,Binlog 里就会有一条对应的 QUERY_EVENT。

它的结构分为两部分:私有头部 和 Payload。

+=====================================+
| Event Header (19 字节)              |
+=====================================+
| 私有头部                             |
|   slave_proxy_id (4字节)            |
|   execution_time (4字节)            |
|   schema_length (1字节)             |
|   error_code (2字节)                |
|   status_vars_length (2字节)        |
+=====================================+
| Payload                              |
|   status_vars (变长)                 |
|   schema (变长)                      |  -- 当前数据库名
|   query (变长)                       |  -- 实际执行的 SQL
+=====================================+

这里有个容易忽略的点:status_vars。它里面包含了一些会话级别的变量,比如 sql_mode、character_set_client、collation_connection 等。这些变量会影响 SQL 的执行结果。如果你在回放 Binlog 时不考虑这些变量,可能会遇到字符集乱码或者 SQL 执行失败的问题。

我记得有一次,一个同事在解析 Binlog 做数据恢复时,发现恢复出来的数据某些字段变成了乱码。排查下来,就是因为原始执行时的 character_set_client 是 utf8mb4,而回放时默认是 latin1。QUERY_EVENT 的 status_vars 里其实已经记录了这些信息,只是他没解析。

避坑指南: 解析 QUERY_EVENT 时,一定要读取 status_vars 中的字符集信息。否则,回放 DDL 或 DML 时可能出现字符集不匹配导致的乱码或数据截断。

4.3 TABLE_MAP_EVENT:Row-Based 模式的「表映射表」

TABLE_MAP_EVENT 是 Row-Based 模式下最重要的辅助 Event。它出现在每个事务的 Row_Event 之前,告诉解析器:接下来的 Row_Event 操作的是哪张表,表的列结构是什么样的。

你想想看,Row_Event 里只记录了列的值,没有列名,也没有表名。如果没有 TABLE_MAP_EVENT,你根本不知道这些值对应哪个字段。

TABLE_MAP_EVENT 的结构如下:

+=====================================+
| Event Header (19 字节)              |
+=====================================+
| 私有头部                             |
|   table_id (6字节)                  |  -- 表 ID,与 Row_Event 中的 table_id 对应
|   flags (2字节)                     |
+=====================================+
| Payload                              |
|   schema_name_length (1字节)        |
|   schema_name (变长)                 |
|   [00] (1字节)                      |  -- 结束符
|   table_name_length (1字节)         |
|   table_name (变长)                  |
|   [00] (1字节)                      |  -- 结束符
|   column_count (packed integer)     |  -- 列数
|   column_type_def (变长)            |  -- 每列的类型
|   column_meta_def (变长)            |  -- 每列的元数据
|   null_bitmap (变长)                |  -- 可为空的列位图
+=====================================+

这里最核心的是 column_type_def 和 column_meta_def。前者定义了每列的数据类型(如 MYSQL_TYPE_LONG、MYSQL_TYPE_VARCHAR),后者则提供了类型的额外信息(如 VARCHAR 的长度、DECIMAL 的精度和小数位数)。

举个例子,如果有一列是 VARCHAR(255),那么 column_type_def 里会记录 MYSQL_TYPE_VARCHAR,而 column_meta_def 里会记录长度 255。这样解析 Row_Event 时,你就知道该读多少字节。

字段 说明
table_id 表的唯一标识,与 Row_Event 中的 table_id 匹配
schema_name 数据库名,如 "test"
table_name 表名,如 "user"
column_count 表的列数
column_type_def 每列的数据类型编码
column_meta_def 每列的元数据,如长度、精度
null_bitmap 标记哪些列允许为 NULL
关键点: TABLE_MAP_EVENT 中的 table_id 是一个 6 字节的整数,它在同一个 Binlog 文件中是唯一的。Row_Event 通过这个 table_id 来找到对应的表结构信息。

4.4 三者关系:一次完整事务的 Binlog 结构

为了让你更直观地理解这三个 Event 是如何协同工作的,我画了一张流程图。它展示了一个典型的 Row-Based 模式下,一个包含 INSERT 操作的事务在 Binlog 中的 Event 序列。

Binlog Event 序列图:一个 INSERT 事务 Binlog Event 序列图:一个 INSERT 事务 GTID_EVENT QUERY_EVENT (BEGIN) TABLE_MAP_EVENT (table_id=123) WRITE_ROWS_EVENT (table_id=123) QUERY_EVENT (COMMIT) 说明:TABLE_MAP_EVENT 中的 table_id 与 WRITE_ROWS_EVENT 中的 table_id 必须一致,解析器通过它找到表结构

从这张图你可以看到,一个事务的 Event 序列是:GTID → BEGIN → TABLE_MAP → ROWS → COMMIT。其中 TABLE_MAP_EVENT 和 WRITE_ROWS_EVENT 通过 table_id 关联。解析器先读取 TABLE_MAP_EVENT,缓存表结构信息,然后当遇到 WRITE_ROWS_EVENT 时,根据 table_id 找到对应的结构,再逐列解析数据。

我个人建议,在实现解析器时,可以维护一个 table_map 的哈希表,key 是 table_id,value 是解析后的表结构信息。这样在解析 Row_Event 时,就能快速查找。

实现建议: 由于同一个事务可能涉及多张表,所以 Binlog 中可能出现多个 TABLE_MAP_EVENT 和多个 Row_Event 交替出现的情况。解析时要注意按顺序处理,不要混淆。

4.5 实战:如何解析这三个 Event

理论讲完了,我们来看点实际的。假设你正在用 Python 写一个 Binlog 解析器,如何解析这三个 Event?

首先,所有 Event 都有一个公共的 19 字节头部。这个头部的结构是固定的:

struct EventHeader {
    uint32_t timestamp;      // 4 字节
    uint8_t  event_type;     // 1 字节
    uint32_t server_id;      // 4 字节
    uint32_t event_size;     // 4 字节
    uint32_t log_pos;        // 4 字节
    uint16_t flags;          // 2 字节
};

解析时,先读取这 19 字节,得到 event_type 和 event_size。然后根据 event_type 决定如何解析剩余的字节。

对于 FORMAT_DESCRIPTION_EVENT,它的 Event Type 是 15。解析时,先跳过 19 字节头部,然后读取 2 字节的 binlog_version,接着是 50 字节的 mysql_server_version,以此类推。

对于 QUERY_EVENT,Event Type 是 2。解析私有头部后,读取 status_vars,然后读取 schema 和 query。

对于 TABLE_MAP_EVENT,Event Type 是 19。解析私有头部后,读取 schema_name、table_name,然后解析 column_count、column_type_def 和 column_meta_def。

注意: column_count 使用的是 MySQL 的 packed integer 编码,不是固定长度的。解析时要注意处理变长整数,否则会导致后续所有字段的偏移量错误。