at-least-once 消息下的幂等表设计:唯一键、版本号还是业务状态机,三种做法怎么选
帖子正文与楼层回复。
背景:消息中间件(Kafka/RocketMQ/Pulsar)绝大多数默认或推荐 at-least-once 投递,意味着「重复消费」是常态而非异常。我在这行做了 15 年,见过太多团队把幂等当成「加一个唯一索引就完事」的事,结果线上要么吞消息、要么双花、要么幂等表自己成了瓶颈。今天想抛一个开放话题:幂等的"判定依据"到底应该落在哪一层。
常见分歧大致有三种做法:
-
唯一键 / 去重表:消费前先 INSERT 一条 (msgId 或 bizKey) 到 dedup 表,成功则消费,冲突则跳过。优点是简单、可观测(dedup 表能查重复率);缺点是 dedup 表会无限膨胀,需要 TTL 或分片;且"先写 dedup 再处理业务"两步之间如果进程挂了,会出现 dedup 有记录但业务没落库,重投时被去重表吞掉——这是最隐蔽的一种丢消息。
-
乐观版本号 / CAS:业务表带 version 字段,UPDATE ... WHERE id=? AND version=?,靠行锁保证并发下不双写。适合"状态只能前进"的场景(订单、账户余额),但要求业务本身是可版本化的;对"纯事件流"(埋点、日志)就不适用了,因为没有 version 可 CAS。
-
业务状态机:把合法迁移固化下来(PENDING→PAID→SHIPPED),任何非法迁移直接拒绝。最强,但维护成本高,每次加状态都要 review 全链路;而且状态机本身不解决"同一状态内重复执行副作用"的问题(比如已 PAID 但重复触发发货)。
我的倾向:默认用"业务状态机 + 幂等键"的组合,而不是裸 dedup 表。理由有三:一是 dedup 表的"先写后处理"窗口在崩溃时会反向吞消息,除非把 dedup 写入和业务落库放进同一事务(这又要求 dedup 表和业务表同库,部署上很别扭);二是状态机把"什么算重复"这件事显式化了,代码 review 时一眼能看出边界;三是幂等键(bizKey = 业务实体 + 事件类型 + 业务流水号)比 msgId 更稳,因为 msgId 在跨 broker 重平衡、replay 时不一定稳定,而 bizKey 是上游语义保证的。
具体可复现的落地:
- bizKey 建议格式:
{entityType}:{entityId}:{eventType}:{upstreamSeq},比如order:12345:paid:7f3a9c...; - 业务表加唯一索引 uk_bizkey(bizKey),消费时 INSERT 业务事件表,冲突即重复;
- 副作用(扣款、发货)放在事件落库之后的独立步骤,且这一步本身也要幂等(调下游带 requestId);
- 不要依赖"先查后插",要依赖数据库唯一约束兜底。
想听大家在什么场景下怎么选:
- 你的业务是"状态可前进"还是"纯事件流"?两种场景的幂等粒度差多少?
- dedup 表你们是怎么做 TTL 和分片的?有没有踩过"dedup 表自身成为瓶颈"的坑?
- 跨服务调用(A→B→C)里,幂等键是上游透传还是每段自己生成?你们更看重哪一层?
- 有没有人试过用数据库的 SKIP LOCKED 或 advisory lock 做消费侧的并发幂等?效果如何?
- 你们团队对"重复消费率"有 SLA 吗?怎么监控和告警?
欢迎贴你们的真实案例,尤其是踩坑后的回滚方案。