订单表存放在 Postgres 中,现在另外五个地方也需要它:搜索索引、缓存、数据仓库、风控服务和报表用的读模型。由应用程序同时写入所有这些地方,迟早会在半路失败。每分钟轮询一次表会漏掉删除操作,还会增加负载。基于日志的变更数据捕获(CDC)走的是另一条路:它读取数据库自身的事务日志,也就是数据库用于崩溃恢复和复制的同一份记录,把每一次已提交的变更都变成一个事件。
本文结合一张架构图,讲解如何用 Debezium 和 Kafka 实现基于日志的 CDC:几种方案的比较、管道如何工作、变更事件包含什么、快照、顺序与投递保证、模式变更、会拖垮主库的运维陷阱,以及什么时候应该改用发件箱(outbox)模式。
捕获变更的四种方式
| 方式 | 工作原理 | 主要问题 |
|---|---|---|
| 双写 | 应用同时写入数据库和消息代理 | 可能一边写入失败、另一边成功,而且没有任何机制去对账 |
| 轮询 | 定时任务查询 updated_at 晚于上次运行时间的行 |
漏掉删除和中间状态,并增加查询负载 |
| 触发器 | 数据库触发器把变更复制到审计表 | 每次写入都增加开销,逻辑藏在数据库里 |
| 基于日志 | 连接器读取事务日志 | 需要配置数据库并谨慎运维 |
基于日志的 CDC 是唯一一种能按提交顺序看到每一次已提交变更(包括删除)的方式,而且无需改动应用,也不会拖慢写入。本文余下部分都围绕它展开。
基于日志的 CDC 如何工作
数据库在应用每次变更之前,本来就会先把它写进日志:Postgres 的预写日志(write-ahead log),MySQL 的二进制日志,SQL Server 的事务日志,以及 MongoDB 变更流背后的 oplog。CDC 连接器像副本一样读取这份日志。
对 Postgres 来说,这意味着逻辑解码:
-- postgresql.conf: wal_level = logical (requires a restart)
CREATE PUBLICATION orders_pub FOR TABLE orders, order_items;
-- Include all old column values in update and delete events
ALTER TABLE orders REPLICA IDENTITY FULL;
连接器会创建一个复制槽来记住自己读到了哪里,并使用 Postgres 内置的 pgoutput 插件解码变更。默认情况下,更新和删除事件只带旧的主键;REPLICA IDENTITY FULL 会加上完整的旧行,代价是日志更大。在 MySQL 中,对应的做法是基于行的二进制日志(当前版本的默认设置),并记录完整的行镜像。
Kafka Connect 上的 Debezium
Debezium 是一个被广泛使用的开源 CDC 引擎。它的连接器运行在 Kafka Connect 中,读取日志,并为每一行变更发布一个事件,每张表对应一个 Kafka 主题,以行的主键作为消息键。一个最简的 Postgres 连接器配置如下:
{
"name": "orders-cdc",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"plugin.name": "pgoutput",
"database.hostname": "orders-db",
"database.port": "5432",
"database.user": "debezium",
"database.dbname": "orders",
"topic.prefix": "shop",
"table.include.list": "public.orders,public.order_items",
"publication.name": "orders_pub",
"slot.name": "orders_cdc",
"heartbeat.interval.ms": "10000"
}
}
public.orders 的变更会进入主题 shop.public.orders。用密钥管理服务把密码放在文件之外。如果你没有运行 Kafka,Debezium Server 可以把同样的事件推送到其他消息代理和云端队列。
变更事件的结构
只保留有效载荷时,一个更新事件长这样:
{
"before": { "id": 1042, "status": "pending", "total": 59.90 },
"after": { "id": 1042, "status": "paid", "total": 59.90 },
"source": { "db": "orders", "table": "orders", "lsn": 24023128, "txId": 5521 },
"op": "u",
"ts_ms": 1793606400123
}
op 字段的取值为:创建是 c,更新是 u,删除是 d,快照期间读取的行是 r。删除事件之后会跟一条墓碑消息(tombstone),即键相同、值为 null 的消息,这样 Kafka 的日志压缩最终可以彻底删除这个键。source 块精确记录了变更来自日志中的哪个位置,下游正是靠它来去重。
快照:已经存在的行
新连接器面对的往往是已有数百万行的表,而日志里只有最近的变更。所以连接器会先做一次初始快照,把每一条现有行作为 r 事件发出,然后从快照开始时记录的日志位置继续流式读取,确保中间没有遗漏。
对大表来说,一次长时间、阻塞式的快照很痛苦。增量快照在流式读取继续进行的同时分块复制表,用的是 Netflix DBLog 论文中的水位线技术,Debezium 团队对此有详细说明。你可以通过信号表触发增量快照,重启后它会从中断处继续,而不是从头开始。
顺序与投递保证
- 同一行的事件保持顺序。 同一主键的所有事件共享同一个键,因此会落在同一个 Kafka 分区中。
- 跨行、跨表没有全局顺序。 在一个事务中修改的两张表,会变成两个主题中的事件。如果你需要事务边界,Debezium 可以发出事务元数据事件,让消费者重新把它们组合起来。
- 投递语义是至少一次。 崩溃或重启之后,连接器可能会重发部分事件。Debezium 的文档对此说得很明白。
对消费者的启示是:让它们具备幂等性。按键执行 upsert,如果某个事件的日志位置不比该键上次应用的更新,就忽略它。
模式变更
一旦某张表被捕获,它的模式就成了与每个消费者之间的契约。新增一个可为空的列是安全的;重命名列或修改类型则会让某些消费者出错。使用支持 Avro、Protobuf 或 JSON Schema 的模式注册中心并强制执行兼容性规则,这样破坏性变更会在 CI 中失败,而不是在生产中。更好的做法是先决定哪些表是公开的。如果服务之外没有人消费,内部表就可以自由修改。
复制槽陷阱
这是大多数团队吃过亏才学到的运维教训。Postgres 的复制槽会保留连接器尚未确认的每一个 WAL 段。如果连接器停止,或者落后太多,WAL 就会在主库上不断累积,直到磁盘写满、数据库不再接受写入。max_slot_wal_keep_size 的 Postgres 默认值是 -1,意味着复制槽可以保留无限量的 WAL。
同一个问题还有一个更隐蔽的版本:被捕获的表很空闲,而其他表很繁忙。连接器没有需要确认的内容,于是复制槽一直不前进。Debezium 的心跳设置正是为这种情况准备的。
防护措施:
-- How much WAL each slot is holding back
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS retained
FROM pg_replication_slots;
- 针对每个复制槽保留的 WAL 设置告警,而不只是监控磁盘使用率。
- 把
max_slot_wal_keep_size设成磁盘承受得起的大小。超过上限的复制槽会失效,连接器必须重新做快照,但这远比磁盘写满要好。 - 启用心跳,并把心跳表加入发布(publication)。
- 下线连接器时删除它的复制槽。
- 规划好故障切换。PostgreSQL 17 可以把逻辑复制槽同步到物理备库;检查你的部署和连接器版本是否用上了这一功能。
用 CDC 还是发件箱模式?
直接捕获表会把你的内部模式以事件的形式暴露出去。另一种做法是发件箱模式:服务在写入数据的同一个事务中,把显式的事件写入发件箱表,CDC 只发布这张表。Debezium 为此提供了 outbox event router。
| 表级 CDC | 结合 CDC 的发件箱 | |
|---|---|---|
| 事件形态 | 按表模式表示的行变更 | 为消费者设计的事件 |
| 应用改动 | 无 | 每个事务都写入发件箱 |
| 耦合 | 消费者依赖内部表 | 消费者依赖已发布的契约 |
| 最适合 | 复制、搜索、缓存、分析 | 服务之间的集成事件 |
第二列的详细内容见事务性发件箱模式一文。
CDC 管道架构图
图表内容
- Orders service
- Postgres: WAL + slot orders_cdc
- Kafka Connect
- Debezium connector
- shop.public.orders, key: id
- Schema registry
- Search index
- Cache
- Lakehouse bronze
- Read-model service
- INSERT / UPDATE / DELETE
- WAL changes
- confirm LSN
- change events
- schemas
- at least once
这张图的读法如下:
- 订单服务照常写入 Postgres,它对 CDC 一无所知。
- Kafka Connect 框架中的 Debezium 通过复制槽读取预写日志,把变更事件发布到
shop.public.orders主题,并把模式注册到注册中心。 - 四个消费者各自独立读取该主题:搜索索引、Redis 缓存、数据仓库和一个读模型服务。
- 指回 Postgres 的虚线连线表示复制槽确认,正是这条链路防止 WAL 不断堆积。
- Markdown 块展示了一个精简的变更事件和查询复制槽延迟的语句。
绘制 CDC 管道
- 不要只画数据库,也要画出日志。 CDC 的成败取决于复制槽和 WAL。
- 标出主题名和消息键。 一句“以订单 ID 为键”就能说明顺序问题。
- 把每个消费者分开画。 每个消费者都有自己的延迟、故障模式和幂等逻辑。
- 在图上注明投递保证:至少一次。
Kafka 一侧的内容见 Kafka 架构图。CDC 是为湖仓的原始层供数的常用方式,见数据管道架构图;也是为 CQRS 与事件溯源架构图构建读模型的常用方式。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加一个云节点包中的 计算实例 节点表示订单服务,再添加一个数据节点包中的 Postgres 节点。
- 为 Kafka Connect 添加一个基础 框架,在其中放一个 计算实例 节点表示 Debezium 连接器。
- 添加一个数据节点包中的 Kafka 节点表示主题,再添加一个基础节点包中的 方框 表示模式注册中心。
- 添加各个消费者:一个云节点包中的 托管数据库 表示搜索索引,一个 Redis 节点,一个 数据仓库 节点,以及一个表示读模型服务的 计算实例 节点。
- 从连接器向 Postgres 画一条虚线连线表示复制槽确认,为主题标注其消息键,再添加一个基础节点包中的 Markdown 块,写上事件示例。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。
常见问题
什么是基于日志的变更数据捕获? 读取数据库的事务日志(如 Postgres 的预写日志或 MySQL 的二进制日志),把每一次已提交的变更转换成事件。它能在不修改应用的情况下按顺序捕获插入、更新和删除。
Debezium 能保证恰好一次投递吗? 不能。正常运行时每次变更只投递一次,但发生故障后部分事件可能会被再次投递,因此 Debezium 在文档中说明的是至少一次投递。消费者应当具备幂等性。
为什么配置 CDC 之后 Postgres 的磁盘被写满了? 复制槽会一直保留 WAL,直到连接器确认为止。连接器停止,或者被捕获的表空闲而其他表在变化,都会让 WAL 不断累积。监控复制槽延迟,启用心跳,并设置 max_slot_wal_keep_size。
CDC 如何处理已有数据? 通过快照。连接器先发出每一条现有行,再从开始时记录的日志位置流式发送变更。增量快照则在流式读取继续的同时分块完成这项工作。
我应该用 CDC 还是发件箱模式? 表级 CDC 适合复制、搜索、缓存和分析。对于服务之间的事件,发件箱模式发布的是经过设计的契约,而不是内部表的行。