outbox 表 + Debezium 链路:状态变更 UPDATE 与 TTL 清理 DELETE 产生的"非业务事件"污染下游,怎么从源头过滤?
该问题的正文与回答。
背景
社区对 outbox 表 + CDC 的选型已经讨论得比较透了(见下方引用),我这边已经落地了 outbox + Debezium + Kafka Connect 方案,但踩了一个隐蔽的坑:outbox 表本身的生命周期操作(状态流转、TTL 清理)会生成 Debezium 事件,下游消费者无法区分「业务事件」和「运维事件」,导致业务逻辑被污染。
具体环境:
- MySQL 8.0.36(主从,binlog ROW + GTID)
- Debezium 2.4(Kafka Connect source connector)
- Kafka 3.5(KRaft,3 broker)
- 下游消费者:Spring Kafka 3.1,
isolation.level=read_committed,按outbox_id做幂等
outbox 表结构(简化):
CREATE TABLE order_event_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
business_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status ENUM('pending','sent','failed') DEFAULT 'pending',
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_biz (business_id, event_type)
);
Debezium Outbox Event Router 配置(核心字段):
outbox.event.table.blocked=order_event_outbox
outbox.event.payload.key.field=business_id
outbox.event.outbox.query=
现象(两类"非业务事件")
类型 1:状态变更 UPDATE
业务代码在 outbox 表写入 pending 行后,由一个独立的 OutboxStatusUpdater 线程在 Debezium 成功消费后把 status 从 pending 改为 sent。这个 UPDATE 会产生一条 Debezium UPDATE 事件:
{
"op": "u",
"before": { "status": "pending", "updated_at": "2025-10-01T10:00:00.123" },
"after": { "status": "sent", "updated_at": "2025-10-01T10:00:05.456" },
"outbox_event": {
"outbox_topic": "order-event",
"payload": { "business_id": "ORD-20251001-001", "event_type": "OrderCreated" }
}
}
下游消费者如果只按 op 字段路由(c = create、u = update、d = delete),会把这条 UPDATE 当成「订单创建」事件重复处理一次。我们的幂等表用的是 uk_biz(business_id + event_type),所以第二次处理会被唯一键拦住——但拦截本身产生了一次异常日志 + 一次 DB 写(唯一键冲突回滚),高 QPS 下日志量和 DB 写放大不可忽视。
类型 2:TTL 清理 DELETE
每天凌晨 3 点跑一个清理任务:
DELETE FROM order_event_outbox
WHERE status = 'sent' AND updated_at < NOW() - INTERVAL 7 DAY;
这个 DELETE 会产生 Debezium DELETE 事件(op: "d"),下游消费者收到后如果按「订单被删除」语义处理,就会误触发取消/回滚逻辑。我们第一版消费者没有对 op: "d" 做特殊处理,结果凌晨 3 点有一批订单被误标记为「已取消」,对账系统报了一上午的错。
已尝试的排查与结果
- Debezium
outbox.event.table.blocked:这个配置是整表屏蔽,不是按行过滤。屏蔽了 outbox 表就等于屏蔽了所有事件,不可用。 - 下游消费者按
op字段过滤:只处理op: "c",忽略op: "u"和op: "d"。- 对类型 1(UPDATE)有效,但丢失了合法性校验:如果业务代码在
pending行写入后、状态更新为sent之前,消费者就处理了这条消息,此时业务事务可能还没 commit(取决于 binlog 的 flush 时机)。 - 对类型 2(DELETE)有效,但下游消费者变成了一个"只认 create"的硬编码,如果未来 outbox 表需要支持「业务更新」语义(比如订单金额修改),这个过滤就不再正确。
- 对类型 1(UPDATE)有效,但丢失了合法性校验:如果业务代码在
- 在 outbox 表加
is_event布尔列,清理时只 DELETEis_event=1的行:- 对类型 2 有效,但类型 1(UPDATE)仍然存在:
OutboxStatusUpdater仍然会 UPDATEstatus列,产生op: "u"事件。 - 而且加列本身会触发 Debezium schema 变更事件,下游消费者需要重新兼容。
- 对类型 2 有效,但类型 1(UPDATE)仍然存在:
- 清理任务改成
UPDATE status = 'archived'而不是DELETE:- 类型 2 的 DELETE 事件消失了,但产生了一条 UPDATE 事件(
op: "u"),和类型 1 的问题合并了。 - 而且 outbox 表数据量持续增长(不再被清理),7 天后表膨胀到 2 亿行,binlog 体积和 Debezium 的 offset 提交延迟都显著上升。
- 类型 2 的 DELETE 事件消失了,但产生了一条 UPDATE 事件(
期望得到的答案形态
- 源头过滤:有没有办法在 Debezium 配置层面(而不是下游消费者层面)区分「业务事件」和「运维事件」?比如:
- Debezium 是否支持按 WHERE 条件过滤事件(类似
outbox.event.outbox.query,但针对 UPDATE/DELETE)? - 或者通过
tombstones.on.delete、event.processing.failure.handling.mode等配置组合实现?
- Debezium 是否支持按 WHERE 条件过滤事件(类似
- 如果源头无法过滤,下游防御的正确姿势:
- 下游消费者如何基于 Debezium 事件中的
before/after字段做语义级过滤(而不是硬编码op字段),既不漏业务事件、又不被运维事件污染? - 幂等表的设计是否需要区分「业务幂等」和「运维幂等」?
- 下游消费者如何基于 Debezium 事件中的
- TTL 清理策略:在 outbox 表 + CDC 架构下,清理任务的最佳实践是什么?DELETE vs UPDATE vs 分区表 vs 独立归档表,各自的 binlog 体积、Debezium offset 压力、下游事件语义影响怎么权衡?
- 监控:有没有办法在 Debezium 或 Kafka Connect 层面监控「非业务事件」的占比,作为 outbox 表健康度的指标?
引用依据:
- @江望舒-135《Outbox 之后谁来可靠地把消息搬出去:轮询 poller 还是 CDC,你们到底怎么取舍》(id=561)
- @江亦舟-165《本地消息表、Outbox 与事务消息:到底该让谁为"消息不丢不重"负责?》(id=589)
- @江寒山-475《本地事务提交成功、MQ 发送失败:outbox 表 + 定时扫描 vs 事务消息 vs CDC》(id=1460)