3. 基于日志的CDC实现与校验

日志解析,说白了就是CDC的"翻译官"。你得把数据库的二进制日志,翻译成业务系统能看懂的数据变更事件。我做了这么多年数据同步,见过太多因为日志解析不到位导致的丢数据、乱序问题。今天咱们就聊聊MySQL的Binlog和PostgreSQL的WAL,以及解析过程中那些关键的校验点。

3.1 MySQL Binlog解析:从原始字节到结构化事件

MySQL的Binlog有三种格式:STATEMENT、ROW和MIXED。做CDC,我强烈建议用ROW格式。为什么?因为它记录了每一行数据变更的前后镜像,校验起来最方便。

核心要点:ROW格式的Binlog,每个事件都包含完整的行数据。你拿到的是"改前长什么样,改后长什么样",而不是一条SQL语句。

解析Binlog,本质上就是按协议读取事件。每个事件都有一个固定的事件头(19字节),包含时间戳、事件类型、服务器ID、日志位置(LSN)、下一个日志位置等信息。

// 伪代码:解析Binlog事件头
struct BinlogEventHeader {
    uint32_t timestamp;      // 4字节,事件发生时间
    uint8_t  event_type;     // 1字节,事件类型
    uint32_t server_id;      // 4字节,服务器ID
    uint32_t event_size;     // 4字节,事件总大小
    uint32_t log_pos;        // 4字节,下一个事件的起始位置(LSN)
    uint16_t flags;          // 2字节,标志位
};

我个人习惯把事件类型分成几类:

  • 控制类事件:ROTATE_EVENT(日志轮换)、FORMAT_DESCRIPTION_EVENT(格式描述)
  • 事务类事件:GTID_EVENT(全局事务ID)、XID_EVENT(事务提交)
  • 数据类事件:TABLE_MAP_EVENT(表映射)、WRITE_ROWS_EVENT(插入)、UPDATE_ROWS_EVENT(更新)、DELETE_ROWS_EVENT(删除)

你想想看,解析的时候最怕什么?最怕表结构变了。TABLE_MAP_EVENT就是用来告诉你"这张表的列名、列类型是什么"。我在项目中遇到过,某次DDL操作后,Binlog里的表映射信息没及时更新,导致解析出来的数据全是乱码。嗯,这里要注意:解析器必须维护一个表结构缓存,并且监听DDL事件来刷新缓存。

3.2 PostgreSQL WAL解析:逻辑复制的核心

PostgreSQL的WAL(Write-Ahead Log)比MySQL的Binlog复杂一些。它有两种复制方式:物理复制和逻辑复制。做CDC,我们主要用逻辑复制。

逻辑复制通过"逻辑解码"插件(如pgoutput、wal2json、decoderbufs)把WAL中的物理变更翻译成逻辑变更。说白了,就是把磁盘上的二进制块,还原成INSERT、UPDATE、DELETE操作。

我的经验:wal2json插件输出的是JSON格式,调试起来很方便。但生产环境我建议用pgoutput,它是PostgreSQL原生的逻辑解码插件,性能和稳定性都更好。

解析WAL的关键是理解LSN(Log Sequence Number)。LSN是WAL中每个日志记录的字节偏移量,类似于Binlog中的log_pos。每个事务开始、提交、以及每行数据变更,都有对应的LSN。

-- PostgreSQL:查看当前WAL写入位置
SELECT pg_current_wal_lsn();

-- 查看指定LSN对应的事务
SELECT * FROM pg_logical_slot_get_changes('slot_name', '0/12345678', NULL);

解析WAL时,我建议关注以下几个关键点:

  • 事务边界:BEGIN和COMMIT事件,标记一个完整事务的开始和结束
  • 变更记录:每个INSERT/UPDATE/DELETE都包含旧值和新值(如果配置了REPLICA IDENTITY)
  • LSN连续性:每个事件的LSN必须严格递增,不能有跳跃或回退

3.3 日志解析中的校验点:事务边界与LSN序号

解析日志不是"读出来就完事"的。你得在解析过程中做校验,否则数据一致性就是空谈。我总结了三个核心校验点:

3.3.1 事务边界校验

一个事务可能包含多条数据变更。比如你批量更新了1000行数据,这1000条UPDATE事件应该属于同一个事务。如果解析器把半个事务的数据发出去,下游就会看到"数据只更新了一半"的中间状态。

怎么做?

  1. 识别事务开始:MySQL看GTID_EVENT或BEGIN_EVENT;PostgreSQL看BEGIN事件
  2. 收集事务内所有变更:把属于同一个事务的事件暂存起来
  3. 识别事务结束:MySQL看XID_EVENT;PostgreSQL看COMMIT事件
  4. 原子性提交:只有收到完整的事务结束标记,才把整个事务的数据发往下游

我曾经踩过的坑:有一次MySQL主库做了大事务(更新了500万行),Binlog解析器在解析过程中崩溃了。重启后,它从上次记录的LSN继续解析,结果那个大事务只解析了一半。因为没有做事务边界校验,下游收到了半个事务的数据,导致数据不一致。从那以后,我强制要求:事务内的数据必须全部解析完成,才能提交给下游。

3.3.2 LSN序号校验

LSN是日志的唯一标识。它必须严格递增,不能重复,不能跳跃。校验LSN,就是校验日志的完整性和顺序性。

校验项 说明 异常处理
LSN单调递增 当前事件的LSN必须大于上一个事件的LSN 如果发现LSN回退,说明日志被篡改或解析器状态异常,立即停止解析
LSN连续性 当前事件的LSN应该等于上一个事件的"下一个LSN" 如果有跳跃,说明中间有日志丢失,需要从备份或上游重新拉取
LSN与事务关联 同一个事务内的所有事件,LSN应该在一个连续区间内 如果事务内LSN不连续,说明事务被截断,需要回滚整个事务

你想想看,LSN校验其实就是"日志的CRC校验"。它不检查数据内容对不对,但能保证你读到的日志是完整的、有序的。我在项目中遇到过,某次网络抖动导致Binlog文件损坏,LSN出现了跳跃。幸亏校验逻辑及时发现了,我们直接从备份恢复了那段日志,避免了数据丢失。

3.3.3 数据内容校验

除了事务边界和LSN,我还建议做数据内容层面的校验。比如:

  • 主键校验:UPDATE和DELETE事件必须包含主键值,否则下游无法定位到具体行
  • 列数校验:解析出的列数必须与表结构定义一致
  • 数据类型校验:比如时间戳字段,解析后应该是合法的日期格式

一个小技巧:在解析器内部维护一个"校验计数器"。每解析一个事件,计数器加1;每提交一个事务,计数器归零。如果计数器长时间不归零(比如超过10000个事件还没看到事务结束),就触发告警。这能帮你快速发现"孤儿事件"问题。

3.4 日志解析的整体流程

说了这么多,咱们用一张图把整体流程串起来。这张图展示了一个典型的日志解析CDC管道:

日志解析CDC流程 日志解析CDC流程 MySQL Binlog ROW格式 / 事件流 PostgreSQL WAL 逻辑解码 / LSN序列 日志解析引擎 • 协议解析 • 事件分类 • 表结构缓存 • 数据反序列化 校验模块 ① 事务边界校验 ② LSN序号校验 ③ 数据内容校验 ④ 完整性检查 ⑤ 异常告警 结构化变更事件 事务完整、LSN连续 异常恢复流程 重试 / 回退 / 告警 关键校验点 • 事务开始/结束必须成对出现 • LSN严格递增,不能跳跃 • 事务内事件LSN连续 • 数据列数与表结构一致 • 主键字段不能为空

这张图把整个流程分成了四层:数据源、解析引擎、校验模块、输出。校验模块是承上启下的关键。它不通过,数据就不能往下游送。说白了,校验就是CDC的"守门员"。

3.5 实战建议:如何搭建可靠的日志解析管道

最后,我分享几个实战中总结的经验:

  • 使用成熟的解析库:MySQL用mysql-binlog-connector-java或canal,PostgreSQL用pgoutput或wal2json。别自己从头写解析器,太容易出错了。
  • 记录解析进度:定期把当前解析到的LSN/GTID持久化到外部存储(如ZooKeeper、数据库)。这样解析器重启后,可以从断点继续,不用从头开始。
  • 做双校验:除了日志层面的校验,还要做数据层面的校验。比如,定期对比源库和目标库的数据,确保一致性。
  • 监控告警:对解析延迟、校验失败次数、LSN跳跃等指标设置告警。我习惯用Prometheus+Grafana做监控,效果不错。

重要提醒:日志解析不是"一次配置,永久运行"的。数据库版本升级、表结构变更、字符集变化,都可能影响解析结果。我建议每次数据库变更后,都做一次全量校验,确保解析器还能正常工作。