背景
- Kafka 3.4(KRaft,3 broker),topic
order-status-change 32 分区,消费组 8 实例(每实例 4 分区),峰值 QPS 3w
- 一条消息(订单状态变更)需要扇出写 3 个下游:
- MySQL 主库(核心数据,必须成功,含幂等表去重)
- Redis 缓存(加速读,可容忍秒级不一致,但业务侧有「读穿」兜底)
- Elasticsearch(搜索索引,可容忍 5s 级延迟)
- 写入顺序有依赖:MySQL 先(保证数据源一致),Redis 和 ES 可并行
- 当前方案:逐条写,任一下游失败则整条消息重试(靠 MySQL 幂等表唯一键去重),Redis/ES 侧无独立幂等
- 消费端 at-least-once,手动 commitSync
现象 / 卡点
- Redis 偶发超时(P99 从 2ms 飙到 200ms,高峰期集群内存压力大),导致整条消息重试 → MySQL 幂等检查 + 重写浪费大量 DB 资源(高峰期 3w QPS 下,5% 的 Redis 超时 = 1500 QPS 的无效 DB 读写)
- 更隐蔽的竞态:MySQL 写入成功 → Redis 写入失败 → 触发整条重试 → 重试时 MySQL 幂等表查询也超时(DB 抖动)→ 出现「MySQL 已写但幂等表未记录」的窗口 → 下一条重试可能穿透幂等
- ES 写入失败后无感知:ES 没有幂等表,重试时可能写入重复文档(ES 的 _id 是业务主键所以不会重复,但「写入成功但响应超时」的场景无法区分是成功还是失败)
- 目前靠人工巡检 + 每日 T+1 对账脚本兜底,不够工程化,发现延迟 24h
已尝试的排查与结果
| 方案 | 结果 |
|------|------|
| ① MySQL 幂等表(唯一键 group_id+topic+partition+offset) | 基本防重复,但高频下 DB 压力大;且幂等表和业务表不在同一事务时存在窗口 |
| ② 本地 LRU 缓存(Caffeine)+ 异步落盘 | 多实例间不共享,rebalance 后新实例看不到旧实例的缓存 → 重复写 |
| ③ Outbox Pattern(本地消息表) | 引入额外 DB 表,事务边界变大(业务表 + outbox 表同事务),DB 压力反而增大;且 outbox 表本身也需要清理策略 |
| ④ Redis SETNX 做分布式幂等 | Redis 重启/主从切换丢 key → 幂等失效,不可靠 |
期望得到的答案形态
- 一套「扇出写入 + 部分失败补偿」的工程方案:
- 数据模型:每个下游的写入状态如何记录?(per-partition 状态表?per-message 状态字段?)
- 状态机:PENDING → DB_DONE → CACHE_DONE → SEARCH_DONE → DONE,中间任意失败如何回退/补偿?
- 补偿触发时机:是消费端内联重试?还是独立的补偿消费者?还是定时扫描?
- 补偿机制的幂等保证:补偿本身可能失败(如补偿 Redis 时 Redis 又超时了),如何保证补偿不丢、不重?
- 可审计:每条消息对每个下游的写入状态可追溯(谁、何时、结果、重试次数),最好能落表 + 监控
- 约束:不引入额外 MQ(已有 Kafka),只用 Kafka + MySQL + Redis;补偿逻辑不能显著增加消费端 CPU/内存(8 实例,16C32G)
- 最好有:具体的表结构 DDL、状态机代码骨架(Java 或伪代码)、监控指标建议(Prometheus 指标名 + 告警阈值)
补充:和 rebalance 的交互
补偿方案需要兼容 CooperativeStickyAssignor 的分区 reassignment:
onPartitionsRevoked 时,未完成的扇出写入如何安全中断?
onPartitionsAssigned 时,新实例如何恢复「进行中」的扇出状态?
- 如果补偿消费者也是消费组形式,它的 offset 语义如何和主消费组解耦?
这个问题我查了站内现有的 Kafka 幂等、rebalance、批提交相关讨论,发现大家主要聚焦在「单下游幂等」或「rebalance 重复消费」,但多下游扇出的部分失败补偿这个方向还没有系统讨论。欢迎有生产实践的同学分享踩坑经验。