Binlog 解析原理:事件类型、事件结构与文件格式

说实话,Binlog 解析这件事,我刚开始接触时也觉得挺枯燥的。不就是一堆二进制数据嘛,有什么好研究的?直到有一次线上数据同步出了问题,我才发现——不懂 Binlog 结构,你连问题出在哪都找不到。

这一章,咱们就把 Binlog 的底裤扒干净。我会从文件格式、事件结构、事件类型三个维度来讲。嗯,保证让你看完之后,自己也能手写一个简单的 Binlog 解析器。

Binlog 日志文件长什么样?

先看整体。Binlog 文件不是文本文件,它是二进制的。你用 cat 打开只会看到一堆乱码。MySQL 提供了 mysqlbinlog 工具来解析它。

一个 Binlog 文件由两部分组成:

  • 文件头:固定 4 字节的魔数 0xfe 0x62 0x69 0x6e,也就是 \xfe\x62\x69\x6e,翻译过来就是 "bin"。
  • 事件序列:从第 5 个字节开始,一个接一个的事件,紧密排列。

我习惯把 Binlog 比作一卷电影胶片。文件头是片头标识,后面每一帧就是一个事件。播放器(也就是解析器)按顺序读取每一帧,就能还原出完整的变更历史。

关键点:Binlog 是追加写的。每个新事件都追加在文件末尾。文件写满后,MySQL 会轮转生成下一个文件(如 mysql-bin.000001 → mysql-bin.000002)。

事件结构:每个事件都长一个样

每个 Binlog 事件都遵循相同的通用结构。我当年第一次看官方文档时,被那一堆偏移量搞晕了。其实说白了,就三部分:

偏移量 长度(字节) 字段 说明
0 4 timestamp 事件创建时间,Unix 时间戳
4 1 event_type 事件类型编码,0~35 之间
5 4 server_id 产生该事件的 MySQL 服务器 ID
9 4 event_size 整个事件的总长度(含头部)
13 4 log_pos 下一个事件的起始位置(文件偏移量)
17 2 flags 标志位,如 LOG_EVENT_BINLOG_IN_USE_F
19 可变 event_body 事件体,不同事件类型结构不同

你看,前 19 个字节是固定的事件头。后面跟着的是事件体,长度由 event_size 决定。解析时,先读 19 字节头,拿到事件类型和总长度,再根据类型去解析事件体。

我的习惯:解析时一定要校验 event_size 是否等于实际读取的字节数。我曾经遇到过因为文件损坏导致解析崩溃的情况,加个校验能提前发现问题。

事件类型:你只需要掌握这 6 种

Binlog 定义了 30 多种事件类型。但说实话,日常开发中你真正需要关心的,不超过 6 种。我列个表,你一看就明白:

类型编码 事件名称 作用 解析优先级
15 FORMAT_DESCRIPTION_EVENT 描述 Binlog 版本和服务器版本,每个文件第一个事件 必须
16 ROTATE_EVENT 指示 Binlog 文件轮转,记录下一个文件名和位置 必须
19 TABLE_MAP_EVENT 映射表 ID 到库名、表名、列信息 必须
30 WRITE_ROWS_EVENT INSERT 操作的行数据 核心
31 UPDATE_ROWS_EVENT UPDATE 操作的行数据(包含前后镜像) 核心
32 DELETE_ROWS_EVENT DELETE 操作的行数据 核心

为什么 TABLE_MAP_EVENT 这么重要?因为它告诉你后面那些行数据属于哪张表。没有它,你拿到一行二进制数据,根本不知道哪个字节对应哪个字段。

我举个例子。假设你解析到一个 UPDATE_ROWS_EVENT,里面包含两行数据:一行是更新前的值(before image),一行是更新后的值(after image)。但如果没有前面的 TABLE_MAP_EVENT,你连第一列是 id 还是 name 都分不清。

避坑指南:我曾经在解析多源合并时,发现不同 MySQL 实例的 TABLE_MAP_EVENT 中表 ID 可能重复。两个不同库的表 ID 都是 123,但表结构完全不同。所以多源合并时,必须用 (server_id, table_id) 作为联合键来缓存表结构。

行事件的数据格式

行事件(WRITE/UPDATE/DELETE_ROWS_EVENT)的解析是重头戏。它的结构是这样的:

  • 事件头:标准的 19 字节头
  • 事件体:
    • 6 字节:table_id(表 ID,与 TABLE_MAP_EVENT 对应)
    • 2 字节:flags(标志位)
    • 可变:列数编码(varint 格式)
    • 可变:列存在位图(每个列 1 bit,标记该列是否有值)
    • 可变:列空值位图(每个列 1 bit,标记该列是否为 NULL)
    • 可变:行数据(按列顺序排列,每列根据字段类型编码)

你想想看,为什么要有列存在位图?因为 Binlog 可以配置只记录变更的列,而不是整行。这样能减少日志量。但解析时就得小心了——你不能假设每一列都有数据。

行数据的编码规则取决于字段类型。比如:

  • 整数类型:小端字节序,固定长度(TINYINT 1 字节,INT 4 字节,BIGINT 8 字节)
  • 字符串类型:先存长度(varint),再存内容
  • 时间类型:DATETIME 存为 8 字节整数,TIMESTAMP 存为 4 字节整数
  • DECIMAL:二进制编码,按精度和标度打包

我的经验:解析 DECIMAL 类型是最容易出错的。它用了一种叫 "binary decimal" 的编码方式,每 9 位十进制数打包成 4 个字节。我第一次实现时,对着文档看了三遍才写对。建议直接用现成的解析库,或者参考 MySQL 源码的 log_event.cc。

一张图看懂 Binlog 解析流程

说了这么多,咱们用一张图把整个流程串起来。这张图是我自己画的一个解析框架,你在做多源合并时可以直接参考:

Binlog 解析流程框架 读取 Binlog 文件 校验魔数 0xfe62696e 解析事件头 19 字节固定结构 事件类型分发 根据 event_type 路由 判断事件类型 FORMAT_DESCRIPTION 记录 Binlog 版本信息 TABLE_MAP 缓存表结构映射 WRITE/UPDATE/DELETE 解析行数据 输出结构化事件 事件类型 + 库名.表名 + 行数据(before/after) 多源合并:按 server_id + 时间戳排序 输出统一的数据变更流 ⚠ 注意 多源合并时,TABLE_MAP 缓存 必须按 (server_id, table_id) 索引

解析时常见的坑

最后,我分享几个实战中踩过的坑。这些经验能帮你少走弯路:

  1. 事件边界对齐:每个事件结束后,下一个事件从 log_pos 指定的位置开始。但有些 MySQL 版本会在事件之间填充 0 字节,解析时记得跳过。
  2. 大事务拆分:一个事务可能包含多个行事件。Binlog 用 BEGIN/COMMIT 事件标记事务边界。多源合并时,必须保证同一个事务的所有事件不被打散。
  3. 字符集问题:字符串字段的编码取决于表的字符集。解析时如果拿不到字符集信息,中文可能会乱码。我建议在 TABLE_MAP_EVENT 中额外缓存字符集信息。
  4. GTID 模式:如果开启了 GTID,每个事务前会有一个 GTID_EVENT。多源合并时,可以用 GTID 来去重,避免重复消费。

我曾经踩过的坑:有一次解析 UPDATE_ROWS_EVENT,发现 before image 和 after image 的列数不一样。查了半天才发现,原来是表结构在事务执行过程中被修改了(ALTER TABLE)。从那以后,我每次解析行事件时都会校验列数是否与 TABLE_MAP 一致,不一致就报错并重新拉取表结构。

好了,Binlog 解析原理就讲到这里。你掌握了事件结构、事件类型和解析流程,后面做多源合并时就能游刃有余了。记住,解析器是数据同步的基石,这块做扎实了,上层逻辑才能稳。