CDC 中间件捕获 Binlog 全流程详解

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
可观测 位点、延迟、吞吐量都可监控
可回溯 修改位点即可重新消费历史数据
相关推荐
名字还没想好☜2 天前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
马里马里奥-2 天前
大模型应用开发笔记03:LangChain 中间件实战
笔记·中间件·langchain
heimeiyingwang3 天前
【架构实战】分库分表实战:ShardingSphere与中间件选型
中间件·架构
斯蒂文6683 天前
[MAF预定义ChatClient中间件-03]CachingChatClient——利用缓存省钱省时间
缓存·中间件
STDD3 天前
Traefik 中间件深度配置:限流、认证、IP 白名单与错误页面定制
网络协议·tcp/ip·中间件
纸小铭4 天前
[MAF预定义ChatClient中间件-01]LoggingChatClient——在调用LLM前后输出日志
python·中间件·flask
zandy10114 天前
从可用到好用:2026年信创改造建设之邮件系统经验分享
经验分享·中间件·操作系统·信创·国产cpu
梦想的颜色6 天前
【Docker部署插件】:使用 Docker 部署生产级 Kafka 完整版教程
安全·docker·中间件·kafka·消息队列·docker-compose·后端开发
川石课堂软件测试7 天前
性能测试|Nginx中间件监控与调优
linux·python·nginx·中间件·单元测试·压力测试·harmonyos