CDC 中间件捕获 Binlog 全流程详解
一、本阶段在整个链路中的位置
阶段一 阶段二 阶段三
MySQL 生成 Binlog → [CDC中间件捕获Binlog] → 应用端消费处理
↑
本文详解这一段
CDC 中间件要完成的任务:连接 MySQL,读取 Binlog 二进制流,解析为结构化数据,投递到 Kafka。
这个过程涉及网络协议、二进制解析、位点管理、数据转换等多个技术环节。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、CDC 中间件连接 MySQL 的过程
2.1 MySQL 通信协议基础
MySQL 客户端与服务器之间使用一种自定义的二进制协议通信(MySQL Client/Server Protocol)。这个协议支持多种命令,CDC 中间件主要用到以下两个:
| 命令 | 编码 | 作用 |
|---|---|---|
COM_REGISTER_SLAVE |
0x15 | 向 Master 注册自己为 Slave |
COM_BINLOG_DUMP |
0x12 | 请求 Master 开始推送 Binlog |
2.2 完整握手与注册流程
CDC中间件 MySQL Master
│ │
│────── TCP连接 (3306端口) ────────────────────→│
│ │
│←──── 握手包 (Server Greeting) ──────────────│
│ (协议版本、server-id、加密盐值等) │
│ │
│────── 认证包 (Auth Response) ───────────────→│
│ (用户名、密码加密、数据库名) │
│ │
│←──── 认证结果 (OK/Error) ───────────────────│
│ │
│────── COM_REGISTER_SLAVE ───────────────────→│
│ (slave-id=12345, host, port) │
│ "我是一个Slave,我的ID是12345" │
│ │
│←──── OK ────────────────────────────────────│
│ Master记录:又多了一个Slave │
│ │
│────── COM_BINLOG_DUMP ──────────────────────→│
│ (binlog-filename, binlog-position, │
│ server-id, flags) │
│ "请从mysql-bin.000003的位置874开始推送" │
│ │
│←──── Binlog Event Stream (持续推送) ────────│
│←──── Binlog Event Stream ───────────────────│
│←──── Binlog Event Stream ───────────────────│
│←──── ... (直到断开连接) ────────────────────│
│ │
关键点:
- CDC 中间件需要一个唯一的
server-id,不能与其他 Slave 或 Master 重复 COM_BINLOG_DUMP命令告诉 Master "从哪个文件的哪个位置开始推送"- 之后 Master 的 Dump Thread 会持续推送 Binlog Event,不需要 CDC 反复请求
2.3 GTID 模式(替代文件名+偏移量)
MySQL 5.6+ 支持 GTID(Global Transaction ID),可以替代传统的文件名+偏移量定位:
传统模式位点:(mysql-bin.000003, 874)
问题:Master 切换时文件名会变,定位困难
GTID模式位点:3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23
含义:这个MySQL实例的第1到第23个事务我都消费了
优势:全局唯一,与文件名无关,主从切换友好
CDC 中间件可以使用 COM_BINLOG_DUMP_GTID(0x1E)命令按 GTID 请求 Binlog。
三、Binlog Event 二进制格式解析
3.1 Binlog 文件结构
Binlog 文件物理结构:
┌──────────────────────────────────────────────────┐
│ Magic Number (4 bytes): 0xfe626963 │ 文件头标识
├──────────────────────────────────────────────────┤
│ Format Description Event │ 描述binlog版本信息
├──────────────────────────────────────────────────┤
│ Event 1 (可能是 Query Event / Table Map 等) │
├──────────────────────────────────────────────────┤
│ Event 2 │
├──────────────────────────────────────────────────┤
│ Event 3 │
├──────────────────────────────────────────────────┤
│ ... │
├──────────────────────────────────────────────────┤
│ Event N │
├──────────────────────────────────────────────────┤
│ Rotate Event (指向下一个binlog文件) │ 文件切换标记
└──────────────────────────────────────────────────┘
3.2 单个 Binlog Event 的二进制结构
每个 Event 由 Header + Body 组成:
┌─────────────────────────────────────────────────────────────────┐
│ Binlog Event │
├─────────────────────────────────────────────────────────────────┤
│ Event Header (固定19或13字节) │
│ ┌─────────────┬───────────┬──────────┬───────────┬───────────┐ │
│ │ timestamp │ type_code │ server_id│ event_len │ next_pos │ │
│ │ (4 bytes) │ (1 byte) │ (4 bytes)│ (4 bytes) │ (4 bytes) │ │
│ │ 事件时间戳 │ 事件类型 │ Master的 │ 事件总长度 │ 下一事件 │ │
│ │ │ │ server-id│ │ 的位置 │ │
│ └─────────────┴───────────┴──────────┴───────────┴───────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Event Body (变长,取决于事件类型) │
│ │
│ 不同type_code有不同的Body格式 │
│ │
└─────────────────────────────────────────────────────────────────┘
3.3 关键 Event 类型
| type_code | 名称 | 作用 |
|---|---|---|
| 0x02 | QUERY_EVENT | 记录执行的 SQL(BEGIN/COMMIT/DDL) |
| 0x13 | TABLE_MAP_EVENT | 描述表结构(表名、列类型、列数) |
| 0x1E | WRITE_ROWS_EVENT | INSERT 的行数据 |
| 0x1F | UPDATE_ROWS_EVENT | UPDATE 前后的行数据 |
| 0x20 | DELETE_ROWS_EVENT | DELETE 的行数据 |
| 0x04 | ROTATE_EVENT | Binlog 文件切换 |
| 0x0F | FORMAT_DESCRIPTION_EVENT | Binlog 格式描述 |
| 0x21 | XID_EVENT | 事务提交标记 |
3.4 一次 INSERT 操作对应的 Event 序列
一条 INSERT INTO t_order (id, user_id, amount) VALUES (1, 100, 299.00)
在 Binlog 中会生成以下 Event 序列:
┌──────────────────────────────────────────────────────┐
│ 1. QUERY_EVENT │
│ body: "BEGIN" │
│ 含义:事务开始 │
├──────────────────────────────────────────────────────┤
│ 2. TABLE_MAP_EVENT │
│ body: table_id=108, database="order_db", │
│ table="t_order", │
│ column_count=3, │
│ column_types=[LONG, LONG, DECIMAL] │
│ 含义:接下来的行数据属于这张表,列类型是这样的 │
├──────────────────────────────────────────────────────┤
│ 3. WRITE_ROWS_EVENT │
│ body: table_id=108, │
│ rows: [[1, 100, 299.00]] │
│ 含义:往table_id=108的表插入了这些行 │
├──────────────────────────────────────────────────────┤
│ 4. XID_EVENT │
│ body: xid=12345 │
│ 含义:事务提交 │
└──────────────────────────────────────────────────────┘
3.5 CDC 中间件如何解析
CDC中间件的解析过程:
1. 读取 Event Header
→ 取出 type_code 判断事件类型
→ 取出 event_len 知道要读多少字节
2. 如果是 TABLE_MAP_EVENT:
→ 解析出 table_id、database、table、列类型
→ 缓存到内存 Map<table_id, TableInfo>
→ 后续的 ROWS_EVENT 通过 table_id 关联到表信息
3. 如果是 WRITE_ROWS_EVENT (INSERT):
→ 根据 table_id 查找表信息(列名、列类型)
→ 按列类型逐个解析二进制数据为具体值
→ 例如:LONG类型 → 读4字节 → 转为Integer
→ VARCHAR类型 → 先读长度 → 再读字符串
→ 组装为 Map<列名, 值>
4. 如果是 UPDATE_ROWS_EVENT:
→ 每行包含两组数据:before-image 和 after-image
→ 分别解析为 old 和 data
5. 如果是 XID_EVENT:
→ 表示事务提交,前面积累的所有变更可以作为一个批次输出
3.6 列类型解析细节
Binlog 中的列值是紧凑的二进制格式,需要根据列类型决定如何读取:
| MySQL 类型 | Binlog 中的存储 | 解析方式 |
|---|---|---|
| TINYINT | 1 字节 | readByte() |
| SMALLINT | 2 字节 | readShort() |
| INT | 4 字节 | readInt() |
| BIGINT | 8 字节 | readLong() |
| FLOAT | 4 字节 | readFloat() |
| DOUBLE | 8 字节 | readDouble() |
| DECIMAL | 变长(压缩BCD) | 特殊解析 |
| VARCHAR | 长度前缀 + 字符串 | readLenString() |
| DATETIME | 5-8 字节(版本不同) | 按格式解码 |
| TIMESTAMP | 4 字节 | 读取秒数 |
| TEXT/BLOB | 长度前缀 + 二进制 | readLenBytes() |
| NULL | 位图中标记 | 无需读取 |
CDC 中间件内部维护了一个完整的类型解析器,针对 MySQL 几十种列类型逐一实现解码逻辑。
四、位点管理机制
4.1 为什么需要位点管理
问题场景:
CDC中间件运行中...
→ 已成功处理到 mysql-bin.000003 的 offset 12345
→ 突然宕机
重启后:
→ 需要知道"我上次处理到哪里了"
→ 从 (mysql-bin.000003, 12345) 继续,不多不少
如果没有位点管理:
- 从头消费:大量重复数据
- 从最新消费:丢失宕机期间的数据
4.2 位点存储方案
┌────────────────────────────────────────────────────────────────┐
│ 位点信息内容 │
│ │
│ { │
│ "binlogFileName": "mysql-bin.000003", │
│ "binlogPosition": 12345, │
│ "gtid": "3E11FA47-71CA-11E1:1-23", // GTID模式时使用 │
│ "timestamp": 1707552600000, // 事件时间戳 │
│ "serverId": 1 // Master的server-id │
│ } │
└────────────────────────────────────────────────────────────────┘
存储位置(三选一):
| 存储方式 | 实现 | 适用场景 |
|---|---|---|
| 本地文件 | 写入 meta.dat 文件,定期刷盘 |
单机部署,简单可靠 |
| ZooKeeper | 写入 ZK 节点 /canal/destinations/{name}/position |
HA 部署,多节点共享位点 |
| MySQL 表 | 写入一张位点记录表 | 与业务 DB 同事务,精确一次 |
4.3 位点更新时机
正常流程:
Parser读取Event → Sink过滤 → Store存储 → Producer发送到Kafka → Kafka ACK成功
│
▼
更新并持久化位点
关键原则:只有下游确认消费成功后,才更新位点。
异常场景对比:
场景A:先更新位点,再发送Kafka
→ 如果发送Kafka失败 → 位点已更新 → 这批数据永久丢失 ❌
场景B:先发送Kafka,成功后更新位点(Canal的做法)
→ 如果发送成功但更新位点前宕机 → 重启后从旧位点重发 → 重复但不丢失 ✅
4.4 位点回溯能力
当下游出问题需要重新消费时,可以手动修改位点:
# 本地文件模式:修改 meta.dat
{"binlogFileName":"mysql-bin.000001","binlogPosition":0}
# ZooKeeper 模式:修改 ZK 节点
set /canal/destinations/example/position {"binlogFileName":"mysql-bin.000001","binlogPosition":0}
修改后重启 CDC 中间件,它会从指定位点重新开始消费。
五、数据转换与序列化
5.1 从二进制到 JSON 的转换链路
MySQL Binlog (二进制)
│
│ Step 1:协议解析
│ 读取固定头部,确定事件类型和长度
│
▼
Event 对象 (内部表示)
│
│ Step 2:列值解析
│ 根据 TABLE_MAP_EVENT 中的列类型信息
│ 将紧凑二进制解码为 Java 类型
│ INT → Integer, VARCHAR → String, DATETIME → Date
│
▼
Row 对象 (Map<列名, 值>)
│
│ Step 3:JSON 序列化
│ 将 Map 结构序列化为 JSON 字符串
│ 注意:Canal 会将所有值统一转为 String
│ Integer 1001 → "1001"
│ DateTime 2025-02-10 → "2025-02-10 14:30:00"
│
▼
JSON 字符串 (发送到Kafka的最终格式)
5.2 为什么 Canal 将所有值转为 String
| 原因 | 说明 |
|---|---|
| 通用性 | JSON 中 String 是最通用的类型,避免类型歧义 |
| 精度 | DECIMAL、BIGINT 等用数字表示可能丢失精度 |
| NULL 处理 | String 可以为 null,语义明确 |
| 简化 | 下游只需一套反序列化逻辑 |
这就是为什么在你的代码中需要做类型转换:
java
// binlog data 中所有值都是 String 或 Object
Object memberIdObj = dataMap.get("member_id"); // 实际是 "213681"
Integer memberId = Integer.valueOf(memberIdObj.toString()); // 转为 Integer
六、网络传输与心跳机制
6.1 TCP 长连接
CDC 中间件与 MySQL Master 之间是 TCP 长连接,一旦建立就持续保持,Master 有新 Binlog 就推送过来。
建立连接后的数据流:
MySQL Master CDC中间件
│ │
│───── Binlog Event ────────────────→│ 有数据时持续推送
│───── Binlog Event ────────────────→│
│───── Binlog Event ────────────────→│
│ │
│ (无数据时沉默) │
│ │
│←──── Heartbeat (心跳) ────────────│ CDC 主动发心跳探活
│───── Heartbeat Response ─────────→│
│ │
│───── Binlog Event ────────────────→│ 又有新数据了
│ │
6.2 心跳检测
为什么需要心跳:
场景:Master长时间无数据写入 → 无Binlog推送 → TCP连接静默
→ CDC中间件不知道是"没数据"还是"连接断了"
→ 如果连接已断但未感知,丢失后续所有数据
解决:定期发送心跳包
→ 对方回应 → 连接正常
→ 超时无回应 → 判定断开 → 触发重连
心跳间隔一般配置为 3-10 秒。
6.3 断线重连流程
CDC中间件检测到连接断开
│
├── 1. 记录最后成功的位点:(mysql-bin.000003, 12345)
│
├── 2. 等待一段时间后尝试重连
│ (重试间隔通常递增:1s, 2s, 4s, 8s... 最大60s)
│
├── 3. 重新走握手+认证流程
│
├── 4. 发送 COM_BINLOG_DUMP (mysql-bin.000003, 12345)
│ "从上次断开的位置继续"
│
└── 5. Master 从该位置继续推送 Binlog
→ 无缝续传,不丢数据
6.4 Master 切换(主从切换/故障转移)
原Master宕机 → 新Master选举完成
CDC中间件需要:
1. 检测到原连接断开
2. 获取新Master的地址
3. 确定在新Master上对应的Binlog位点
- GTID模式:直接用GTID定位(推荐)
- 文件名模式:需要根据时间戳或事务ID换算(复杂)
4. 连接新Master,从对应位点继续消费
这是 GTID 模式的核心优势:跨主机定位,无需文件名映射。
七、过滤策略
7.1 为什么需要过滤
一个 MySQL 实例可能有几十个库、几百张表,但 CDC 中间件可能只关心其中几张表的变更。如果全部推送到 Kafka,会造成:
- 网络带宽浪费
- Kafka 存储空间浪费
- 下游消费者大量无效过滤
7.2 过滤层级
┌─────────────────────────────────────────────────────────────┐
│ 过滤发生在哪里 │
│ │
│ ┌──────────────────┐ │
│ │ MySQL层过滤 │ binlog-do-db / binlog-ignore-db │
│ │ (my.cnf配置) │ → 控制哪些库写入Binlog │
│ │ │ → 最激进但最不灵活 │
│ └────────┬─────────┘ │
│ │ 只有指定库的变更进入Binlog │
│ ▼ │
│ ┌──────────────────┐ │
│ │ CDC中间件过滤 │ canal.instance.filter.regex │
│ │ (EventSink层) │ → 正则表达式匹配 database.table │
│ │ │ → 灵活,可动态修改 │
│ └────────┬─────────┘ │
│ │ 只有匹配的表被序列化和发送 │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 应用端过滤 │ xxx.binlog.tables 配置 │
│ │ (消费框架层) │ → 按表名路由到对应Observer │
│ │ │ → 未注册的表被忽略 │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
7.3 Canal 过滤正则示例
properties
# 监听所有库所有表
canal.instance.filter.regex = .*\\..*
# 只监听 aaa 库的所有表
canal.instance.filter.regex = aaa\\..*
# 监听多个库
canal.instance.filter.regex = aaa\\..*,bbb\\..*
# 只监听指定表
canal.instance.filter.regex = aaa\\.xx1,aaa\\.xx2
# 排除某些表(黑名单)
canal.instance.filter.black.regex = aaa\\.tmp_.*,aaa\\.log_.*
八、投递到 Kafka 的策略
8.1 Topic 策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 单Topic | 所有表的 binlog 写入同一个 topic | 消费者少、数据量小 |
| 按库分Topic | 每个 database 一个 topic | 中等规模 |
| 按表分Topic | 每张表一个 topic | 大规模、表间完全隔离 |
| 动态Topic | 配置规则映射 | 灵活但管理复杂 |
8.2 Partition 策略
| 策略 | 说明 | 顺序保证 |
|---|---|---|
| 按表名 hash | 同一张表的变更在同一 Partition | 表级有序 |
| 按主键 hash | 同一行的变更在同一 Partition | 行级有序(推荐) |
| 轮询 | 均匀分布 | 无顺序保证 |
Canal 配置示例:
properties
# 按主键hash分区(保证同一行的事件顺序)
canal.mq.partitionHash = aaa.xx1:id
# 含义:xx1 表按 id 列的值做hash决定分区
8.3 消息投递可靠性
Canal发送到Kafka的可靠性配置:
kafka.acks = all → 所有副本确认才算成功
kafka.retries = 3 → 发送失败重试3次
kafka.max.in.flight = 1 → 同时只有1个请求在途(保证严格顺序)
结果:
→ 不丢消息(至少发送成功一次)
→ 可能重复(重试时原消息可能已写入)
→ 下游需要做幂等
九、容错与高可用
9.1 常见故障场景及应对
| 故障 | 现象 | CDC中间件应对 |
|---|---|---|
| MySQL Master 临时不可用 | 连接断开 | 自动重连 + 指数退避 |
| MySQL 主从切换 | 原连接断开 | 重连新 Master + GTID续读 |
| Kafka 不可用 | 发送失败 | 重试 + 阻塞Parser + 位点不更新 |
| CDC 进程 OOM | 进程退出 | 重启后从持久化位点继续 |
| Binlog 被清理 | 请求的位点已不存在 | 告警 + 从最新位置开始(会丢中间数据) |
9.2 Binlog 过期问题
MySQL 的 binlog 不会永久保存,expire_logs_days 配置控制保留天数。
风险场景:
CDC中间件停机3天 → 重启后发现上次位点对应的binlog文件已被MySQL清理
应对:
1. 告警:检测到位点对应的文件不存在时发出告警
2. 兜底:从当前最新位置开始消费(丢失停机期间数据)
3. 预防:MySQL expire_logs_days 设置足够大(如7天)
4. 监控:持续监控 CDC 与 MySQL 的位点差距
9.3 流控(背压传导)
当下游处理不过来时,背压如何传导:
Kafka 写入慢(Broker繁忙)
→ Canal MqProducer 发送阻塞
→ EventStore 缓冲区逐渐填满
→ EventStore.put() 阻塞
→ EventSink 向 Store 写入阻塞
→ Parser到Sink的队列逐渐填满
→ Parser 被阻塞,不再从MySQL读取
结果:MySQL Dump Thread 的发送缓冲区堆积
→ Master 感知到这个 Slave 消费慢
→ 不会影响其他真正的 Slave(各自独立)
整个背压链:Kafka → Store → Sink → Parser → MySQL Dump Buffer
十、通用代码示例:Binlog 二进制协议解析模拟
java
package com.example.canal.protocol;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.*;
/*
* 说明:真实解析远比这复杂(涉及几十种Event类型、几十种列类型),
* 这里是简化版本,用于理解核心原理.
*/
public class BinlogProtocolParser {
// ----- Event 类型常量 -----
private static final int QUERY_EVENT = 0x02;
private static final int TABLE_MAP_EVENT = 0x13;
private static final int WRITE_ROWS_EVENT = 0x1E;
private static final int UPDATE_ROWS_EVENT = 0x1F;
private static final int DELETE_ROWS_EVENT = 0x20;
private static final int XID_EVENT = 0x21;
// ----- 列类型常量 -----
private static final int MYSQL_TYPE_LONG = 0x03; // INT (4字节)
private static final int MYSQL_TYPE_VARCHAR = 0x0F; // VARCHAR (变长)
private static final int MYSQL_TYPE_DATETIME = 0x0C; // DATETIME
private static final int MYSQL_TYPE_DECIMAL = 0xF6; // DECIMAL
/**
* 缓存表结构信息(TABLE_MAP_EVENT 解析后存入).
* key: table_id
* value: 表的列信息
*/
private final Map<Long, TableInfo> tableInfoCache = new HashMap<>();
/**
* 解析结果.
*/
public static class ParsedEvent {
public String database;
public String table;
public String type; // INSERT / UPDATE / DELETE
public long timestamp;
public List<Map<String, Object>> data;
public List<Map<String, Object>> old;
@Override
public String toString() {
return String.format("ParsedEvent{db=%s, table=%s, type=%s, data=%s, old=%s}",
database, table, type, data, old);
}
}
/**
* 表结构信息(从TABLE_MAP_EVENT解析).
*/
private static class TableInfo {
String database;
String table;
List<String> columnNames;
List<Integer> columnTypes;
}
/**
* 解析一个 Binlog Event(从二进制字节).
*
* @param rawBytes 原始二进制数据
* @return 解析后的事件(非行数据事件返回null)
*/
public ParsedEvent parseEvent(byte[] rawBytes) {
ByteBuffer buf = ByteBuffer.wrap(rawBytes);
buf.order(ByteOrder.LITTLE_ENDIAN); // MySQL协议使用小端序
// ===== 解析 Event Header (19字节) =====
long timestamp = Integer.toUnsignedLong(buf.getInt()); // 4字节 时间戳
int typeCode = Byte.toUnsignedInt(buf.get()); // 1字节 事件类型
long serverId = Integer.toUnsignedLong(buf.getInt()); // 4字节 server-id
long eventLength = Integer.toUnsignedLong(buf.getInt()); // 4字节 事件长度
long nextPosition = Integer.toUnsignedLong(buf.getInt()); // 4字节 下一事件位置
int flags = Short.toUnsignedInt(buf.getShort()); // 2字节 标志位
// ===== 根据类型解析 Event Body =====
switch (typeCode) {
case TABLE_MAP_EVENT:
parseTableMapEvent(buf);
return null; // 表结构事件不直接输出
case WRITE_ROWS_EVENT:
return parseRowsEvent(buf, "INSERT", timestamp, false);
case UPDATE_ROWS_EVENT:
return parseRowsEvent(buf, "UPDATE", timestamp, true);
case DELETE_ROWS_EVENT:
return parseRowsEvent(buf, "DELETE", timestamp, false);
default:
return null; // 其他事件类型不处理
}
}
/**
* 解析 TABLE_MAP_EVENT.
* 获取表的结构信息并缓存,后续 ROWS_EVENT 需要用到.
*/
private void parseTableMapEvent(ByteBuffer buf) {
// 简化解析(真实协议更复杂)
long tableId = readTableId(buf); // 6字节 table_id
buf.getShort(); // 2字节 flags
// 读取数据库名(长度前缀字符串)
int dbNameLen = Byte.toUnsignedInt(buf.get());
byte[] dbNameBytes = new byte[dbNameLen];
buf.get(dbNameBytes);
buf.get(); // 跳过 0x00 结尾符
String database = new String(dbNameBytes, StandardCharsets.UTF_8);
// 读取表名(长度前缀字符串)
int tableNameLen = Byte.toUnsignedInt(buf.get());
byte[] tableNameBytes = new byte[tableNameLen];
buf.get(tableNameBytes);
buf.get(); // 跳过 0x00 结尾符
String tableName = new String(tableNameBytes, StandardCharsets.UTF_8);
// 读取列数量
int columnCount = readPackedInt(buf);
// 读取每列的类型(每列1字节)
List<Integer> columnTypes = new ArrayList<>();
for (int i = 0; i < columnCount; i++) {
columnTypes.add(Byte.toUnsignedInt(buf.get()));
}
// 缓存表信息
TableInfo info = new TableInfo();
info.database = database;
info.table = tableName;
info.columnTypes = columnTypes;
// 注意:Binlog中不包含列名!列名需要从 information_schema 查询
// CDC中间件启动时会查询一次,之后缓存
info.columnNames = generateColumnNames(columnCount); // 简化处理
tableInfoCache.put(tableId, info);
}
/**
* 解析行数据事件 (WRITE_ROWS / UPDATE_ROWS / DELETE_ROWS).
*/
private ParsedEvent parseRowsEvent(ByteBuffer buf, String type,
long timestamp, boolean hasBeforeImage) {
long tableId = readTableId(buf); // 6字节 table_id
buf.getShort(); // 2字节 flags
// 从缓存获取表结构
TableInfo tableInfo = tableInfoCache.get(tableId);
if (tableInfo == null) {
return null; // 没有对应的 TABLE_MAP_EVENT,无法解析
}
int columnCount = readPackedInt(buf);
// 读取列位图(标识哪些列有值)
byte[] columnsPresent = readBitmap(buf, columnCount);
ParsedEvent event = new ParsedEvent();
event.database = tableInfo.database;
event.table = tableInfo.table;
event.type = type;
event.timestamp = timestamp;
event.data = new ArrayList<>();
event.old = hasBeforeImage ? new ArrayList<>() : null;
// 解析行数据(可能有多行)
while (buf.hasRemaining()) {
if (hasBeforeImage) {
// UPDATE: 先读 before-image(旧值)
Map<String, Object> oldRow = parseRow(buf, tableInfo, columnsPresent);
event.old.add(oldRow);
}
// 读 after-image(新值)或 INSERT/DELETE 的行数据
Map<String, Object> row = parseRow(buf, tableInfo, columnsPresent);
event.data.add(row);
}
return event;
}
/**
* 解析单行数据.
* 按列顺序逐个读取,根据列类型确定读取方式.
*/
private Map<String, Object> parseRow(ByteBuffer buf, TableInfo tableInfo,
byte[] columnsPresent) {
int columnCount = tableInfo.columnTypes.size();
// 读取 NULL 位图(标识哪些列为NULL)
byte[] nullBitmap = readBitmap(buf, columnCount);
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 0; i < columnCount; i++) {
String columnName = tableInfo.columnNames.get(i);
// 检查该列是否为NULL
if (isBitSet(nullBitmap, i)) {
row.put(columnName, null);
continue;
}
// 根据列类型解析值
int columnType = tableInfo.columnTypes.get(i);
Object value = parseColumnValue(buf, columnType);
// Canal 输出时统一转为 String
row.put(columnName, value != null ? value.toString() : null);
}
return row;
}
/**
* 根据列类型从字节流中读取值.
* 这是CDC中间件最核心的工作之一.
*/
private Object parseColumnValue(ByteBuffer buf, int columnType) {
switch (columnType) {
case MYSQL_TYPE_LONG: // INT: 4字节有符号整数
return buf.getInt();
case MYSQL_TYPE_VARCHAR: // VARCHAR: 2字节长度 + 字符串
int strLen = Short.toUnsignedInt(buf.getShort());
byte[] strBytes = new byte[strLen];
buf.get(strBytes);
return new String(strBytes, StandardCharsets.UTF_8);
case MYSQL_TYPE_DATETIME: // DATETIME: 按特定编码读取
// 简化:实际格式取决于MySQL版本(DATETIME vs DATETIME2)
long dtValue = buf.getLong();
return formatDateTime(dtValue);
case MYSQL_TYPE_DECIMAL: // DECIMAL: 压缩BCD编码
// 简化:实际需要根据precision和scale计算
return readDecimal(buf);
default:
// 未知类型,跳过(实际要根据metadata判断长度)
return "UNKNOWN_TYPE_" + columnType;
}
}
// ----- 辅助方法 -----
private long readTableId(ByteBuffer buf) {
// table_id 是6字节的小端整数
byte[] bytes = new byte[6];
buf.get(bytes);
long id = 0;
for (int i = 5; i >= 0; i--) {
id = (id << 8) | (bytes[i] & 0xFF);
}
return id;
}
private int readPackedInt(ByteBuffer buf) {
int first = Byte.toUnsignedInt(buf.get());
if (first < 251) return first;
if (first == 252) return Short.toUnsignedInt(buf.getShort());
if (first == 253) return buf.getInt() & 0xFFFFFF; // 3字节
return buf.getInt(); // 4字节
}
private byte[] readBitmap(ByteBuffer buf, int bits) {
int byteCount = (bits + 7) / 8;
byte[] bitmap = new byte[byteCount];
buf.get(bitmap);
return bitmap;
}
private boolean isBitSet(byte[] bitmap, int index) {
return (bitmap[index / 8] & (1 << (index % 8))) != 0;
}
private List<String> generateColumnNames(int count) {
// 简化:真实场景从 information_schema.columns 查询
List<String> names = new ArrayList<>();
for (int i = 0; i < count; i++) {
names.add("column_" + i);
}
return names;
}
private String formatDateTime(long value) {
// 简化:返回时间戳字符串
return "2025-02-10 14:30:00";
}
private String readDecimal(ByteBuffer buf) {
// 简化:实际DECIMAL解析非常复杂
return "0.00";
}
}
列名获取------为什么需要额外查询
上面代码中有个关键点:Binlog 中不包含列名,只有列类型和列值。CDC 中间件需要额外查询 MySQL 来获取列名:
java
/**
* 模拟CDC中间件启动时获取列名.
* 真实实现:连接MySQL,查询 information_schema.
*/
public class TableMetadataLoader {
/**
* 从MySQL查询表的列名.
* CDC中间件启动时执行,结果缓存.
*
* SQL: SELECT COLUMN_NAME, ORDINAL_POSITION
* FROM information_schema.COLUMNS
* WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
* ORDER BY ORDINAL_POSITION
*/
public List<String> loadColumnNames(String database, String table) {
// 模拟返回
// 真实:通过JDBC查询 information_schema.COLUMNS
if ("stock_transaction".equals(table)) {
return Arrays.asList(
"id", "member_id", "warehouse_id", "order_code",
"reference_order_code", "order_inout_type", "order_type_code",
"trans_qty", "stock_type_id", "create_time", "update_time"
);
}
return Collections.emptyList();
}
/**
* DDL变更时需要重新加载列信息.
* CDC中间件监听到DDL Event后会触发刷新.
*/
public void onDdlEvent(String database, String table, String ddlSql) {
// ALTER TABLE 可能增删改列
// 需要重新查询 information_schema 更新缓存
System.out.println("检测到DDL变更,刷新表结构缓存: "
+ database + "." + table + " DDL: " + ddlSql);
}
}
十一、完整流程示例(模拟端到端)
java
package com.example.canal;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.*;
/**
* 端到端演示:从MySQL写入到CDC输出JSON.
* 模拟整个 "阶段二" 的完整流程.
*/
public class EndToEndDemo {
public static void main(String[] args) {
System.out.println("===== CDC中间件捕获Binlog端到端演示 =====\n");
// Step 1: 模拟MySQL生成Binlog(二进制)
System.out.println("Step 1: MySQL执行 INSERT INTO aaa.xx1 ...");
System.out.println(" MySQL生成Binlog事件(二进制格式)\n");
// Step 2: CDC中间件通过Dump协议接收
System.out.println("Step 2: CDC中间件(伪装Slave)接收到Binlog二进制流");
System.out.println(" 连接信息: master=127.0.0.1:3306, slave-id=12345");
System.out.println(" 当前位点: mysql-bin.000003:874\n");
// Step 3: 解析二进制为结构化数据
System.out.println("Step 3: 解析二进制Binlog Event");
System.out.println(" Event Header: type=WRITE_ROWS(0x1E), timestamp=1707552600");
System.out.println(" TABLE_MAP: table_id=108 → ylh_stock.stock_transaction");
System.out.println(" 解析列值:");
System.out.println(" column[0] INT(4bytes) → id = 12345");
System.out.println(" column[1] INT(4bytes) → member_id = 213681");
System.out.println(" column[2] INT(4bytes) → warehouse_id = 100");
System.out.println(" column[3] VARCHAR(len+str) → order_code = 'CK20250210001'");
System.out.println(" column[4] VARCHAR(len+str) → reference_order_code = 'SO20250210001'");
System.out.println(" column[5] INT(4bytes) → trans_qty = 5\n");
// Step 4: 过滤
System.out.println("Step 4: EventSink过滤");
System.out.println(" 规则: aaa\\\\..*");
System.out.println(" 当前表: aaa.xx1 → 匹配通过 ✓\n");
// Step 5: 转换为JSON
System.out.println("Step 5: 序列化为JSON(所有值转为String)");
Map<String, Object> json = new LinkedHashMap<>();
json.put("database", "aaa");
json.put("table", "xx1");
json.put("type", "INSERT");
json.put("ts", 1707552600000L);
json.put("isDdl", false);
json.put("pkNames", Collections.singletonList("id"));
Map<String, String> row = new LinkedHashMap<>();
row.put("id", "12345");
row.put("member_id", "213681");
row.put("warehouse_id", "100");
row.put("order_code", "CK20250210001");
row.put("reference_order_code", "SO20250210001");
row.put("trans_qty", "5");
json.put("data", Collections.singletonList(row));
json.put("old", null);
String jsonStr = toJsonString(json);
System.out.println(" 输出JSON:");
System.out.println(" " + jsonStr + "\n");
// Step 6: 发送到Kafka
System.out.println("Step 6: 发送到Kafka");
System.out.println(" topic: binlog-xxx-cloud-xx1");
System.out.println(" key: aaa.xx1 (用于分区路由)");
System.out.println(" partition: hash('aaa.xx1') % 3 = 1");
System.out.println(" Kafka ACK: success, offset=45678\n");
// Step 7: 更新位点
System.out.println("Step 7: 更新消费位点");
System.out.println(" 旧位点: mysql-bin.000003:874");
System.out.println(" 新位点: mysql-bin.000003:1128");
System.out.println(" 持久化到: ZooKeeper /canal/destinations/stock/position\n");
System.out.println("===== 完成:一条INSERT从MySQL到Kafka的全流程 =====");
System.out.println("\n后续:你的应用从Kafka消费这条消息 → 路由 → xxxTransactionBinlogConsumer处理");
}
private static String toJsonString(Map<String, Object> map) {
// 简化JSON序列化
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (!first) sb.append(",");
sb.append("\"").append(entry.getKey()).append("\":");
Object val = entry.getValue();
if (val == null) {
sb.append("null");
} else if (val instanceof String) {
sb.append("\"").append(val).append("\"");
} else if (val instanceof Number || val instanceof Boolean) {
sb.append(val);
} else {
sb.append("\"").append(val).append("\"");
}
first = false;
}
sb.append("}");
return sb.toString();
}
}
十二、总结
CDC中间件"阶段二"做的事情
| 步骤 | 动作 | 技术要点 |
|---|---|---|
| 1. 连接 | 通过 MySQL 协议连接 Master | TCP 长连接、COM_REGISTER_SLAVE |
| 2. 请求 | 发送 COM_BINLOG_DUMP | 携带位点信息(文件名+offset 或 GTID) |
| 3. 接收 | 持续接收 Master 推送的 Binlog | 二进制流、事件边界划分 |
| 4. 解析 | 二进制 → 结构化对象 | Event Header 解析、列类型解码 |
| 5. 过滤 | 正则匹配 database.table | 减少无效数据传输 |
| 6. 转换 | 内部对象 → JSON | 值统一转 String、old 只含变更字段 |
| 7. 投递 | 发送到 Kafka | Topic/Partition 策略、ACK 确认 |
| 8. 记位点 | 持久化消费进度 | 文件/ZK/MySQL、At Least Once |
| 9. 容错 | 心跳、重连、背压 | 保证长期稳定运行 |
关键设计原则
| 原则 | 体现 |
|---|---|
| 零侵入 | 不修改 MySQL 配置和业务代码,只读 Binlog |
| 实时性 | Master 写入后毫秒级推送到 CDC |
| 可靠性 | 位点先存后进,保证 At Least Once |
| 可观测 | 位点、延迟、吞吐量都可监控 |
| 可回溯 | 修改位点即可重新消费历史数据 |