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 的基石。
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_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 里其实已经记录了这些信息,只是他没解析。
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_id 是一个 6 字节的整数,它在同一个 Binlog 文件中是唯一的。Row_Event 通过这个 table_id 来找到对应的表结构信息。
4.4 三者关系:一次完整事务的 Binlog 结构
为了让你更直观地理解这三个 Event 是如何协同工作的,我画了一张流程图。它展示了一个典型的 Row-Based 模式下,一个包含 INSERT 操作的事务在 Binlog 中的 Event 序列。
从这张图你可以看到,一个事务的 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 时,就能快速查找。
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 编码,不是固定长度的。解析时要注意处理变长整数,否则会导致后续所有字段的偏移量错误。