Kafka 消费端 max.poll.records 批量拉取后部分成功、部分失败:重试整批时已成功的那 N 条怎么防重,有没有不依赖 Redis 的纯 DB 方案?
该问题的正文与回答。
背景
我负责一条订单履约链路:Kafka 3.5(3 broker KRaft),topic order-fulfill 16 分区,消费组 6 实例(K8s Deployment,maxSurge=1 / maxUnavailable=0),max.poll.records=200,enable.auto.commit=false,isolation.level=read_committed。单条消息处理逻辑:写 MySQL 8.0.32 履约表(InnoDB,REQUIRES_NEW)→ 调下游库存服务 RPC(P99 ≈ 350ms,偶发 2s 超时)→ 提交 offset。日均约 1200 万条,峰值约 5800 msg/s。
现象
max.poll.records=200 拉出一批后逐条处理。假设第 37 条调库存 RPC 超时(2s 后抛异常),此时前 36 条已经写完 DB 且各自 commit 了。我的处理策略是:catch 异常 → 整批不 commit offset → 等下次 poll 重新拉这 200 条。但前 36 条已经写入了,重新处理时就会重复写。
目前的做法是:每条消息处理前 SELECT id FROM t_msg_dedup WHERE msg_id = ?(UNIQUE KEY uk_msg_id (msg_id)),查到就跳过。但实际跑下来有两个问题:
- 幂等表 SELECT 本身成了瓶颈:200 条/批 × 6 实例,高峰期 QPS 峰值 5800 × 2(SELECT + INSERT)≈ 11600 QPS 全打在幂等表上,InnoDB 唯一索引点查虽然走 PK,但 buffer pool 命中率从 99.2% 掉到 97.8%,P99 从 0.3ms 涨到 1.8ms;
- 先查后写不是原子的:两条相同 msg_id 的消息在不同实例并发到达时(rebalance 后短暂重叠),可能都通过了 SELECT 检查再各自 INSERT。社区 #1562 已经把并发竞态的根因讲得很清楚了,我确认这是真实风险。
已尝试的排查与结果
| 方案 | 结果 |
|------|------|
| Redis SETNX 做幂等 | SELECT 瓶颈解决了,但引入 Redis 单点依赖;TTL 设多长没有共识(#1612 讨论过 TTL 窗口问题,24h / 72h / 7d 三种定法各埋各的雷);且 Redis 挂了整条链路降级策略没有现成方案 |
| INSERT ... ON DUPLICATE KEY UPDATE affected_rows | 把 SELECT + INSERT 合成一条 SQL,原子性没问题;但 200 条批量时每条都要走一次 INSERT,DB 写入量翻倍,undo 日志和 redo 日志压力明显上升 |
| 整批事务化(200 条一个大事务) | 任何一条失败全部回滚——但要求下游 RPC 也在事务里,而 RPC 超时 2s × 200 条串行 = 400s,远超 max.poll.interval.ms(默认 5min,实际设的 3min),实例会被踢出消费组触发 rebalance,反而放大问题 |
| 逐条 commit + 失败单独标记到 DLQ topic | 思路对,但 offset 怎么提交成了难题:不能整批跳(会丢失败那条),也不能只提成功的(Kafka offset 是分区级别的,只能提到一个连续位置) |
期望得到的答案形态
- 批量场景下"部分成功 + 部分失败"的处理策略,社区里有没有比较成熟的模式?比如"逐条 commit + 失败单独标记"vs"整批回滚"vs"失败消息单独发 DLQ 后整批 commit",各自的边界条件是什么?
- 如果走"失败消息单独发 DLQ 后整批 commit offset"这条路,DLQ 的可靠性怎么保证(DLQ 发送本身失败怎么办)?
- 不依赖 Redis、不靠 SELECT-then-INSERT 两步的纯 DB 防重方案,有没有在高并发(>10k QPS)下验证过的实践?
INSERT ... ON DUPLICATE KEY UPDATE的写入放大在什么量级下会成为问题? - offset 提交粒度:Kafka 分区级 offset 的约束下,"200 条里 199 条成功、1 条失败"这个中间态,有没有比"整批回滚"更精细的提交策略?
引用依据
- #1562《幂等的「先查后写」为什么在并发下必翻车》:先查后写并发竞态的根因分析,我确认了这是真实风险;
- #2240《消费端幂等表与非幂等副作用非原子》:先写幂等后调 API 会漏操作、先调 API 后写幂等会重复,我目前卡在类似的两难里。