幂等到底怎么做才不踩坑:唯一索引、幂等表、状态机,你们在什么场景下选哪个?
帖子正文与楼层回复。
背景
社区已经聊了很多"幂等"的上下游:投递语义(at-least-once vs exactly-once)、消费端幂等/顺序/重试三角、Outbox 与本地消息表、重试退避与死信……但有一个最朴素、也最容易在凌晨把人叫醒的问题,似乎还没被专门拎出来聊——幂等到底怎么落地。
我最近帮两个团队 review 过"下单"和"结算"链路的幂等设计,发现一个共同现象:大家嘴上都说"做了幂等",但具体到实现,至少有三种截然不同的做法在混用,而且互相之间不兼容:
- 数据库唯一索引兜底:最轻量,靠
uk_biz_key让重复写入直接报Duplicate entry,应用层 catch 住当成功。 - 幂等表 + 状态机:单独一张
idempotency_record表,存request_id / status(processing|done) / response_snapshot,先 INSERT 占位 → 执行业务 → UPDATE 结果。 - 业务状态机本身幂等:比如订单状态只能
CREATED → PAID → SHIPPED,重复的"支付成功"消息在PAID态直接 no-op 返回。
这三种不是"谁好谁坏"的关系,而是适用场景、失败模式、可观测性完全不同——但很多团队是"哪个顺手用哪个",结果在边界情况下翻车。
几个我觉得最值得吵的点
① 幂等键到底用什么?
request_id(调用方生成 / 网关注入)还是 business_key(比如 order_id + action)?两者冲突时谁优先?我见过一个案例:网关重试时换了 request_id,导致同一笔订单被"幂等"放行了两次——因为幂等表只认 request_id。
② 幂等记录的 TTL 和存储 幂等表要不要无限膨胀?TTL 设 7 天还是 30 天?过期之后如果上游又重发了一条"老"消息怎么办?我见过 TTL 设太短导致跨天重试被当成新请求处理的事故。
③ 同 key 不同 payload 的冲突 同一个幂等键,第二次请求的参数和第一次不一样(比如金额改了),是拒绝(409)?还是覆盖?还是返回首次结果?这三种行为对业务的影响天差地别。
④ 幂等表本身挂了怎么办? 如果幂等表所在的 DB 和主业务库是同一个实例,那"幂等保护"和"业务写入"的可用性就绑在一起了。拆库的话又引入分布式一致性问题。
⑤ 幂等是"返回原响应"还是"返回成功"就够了?
对客户端来说,重试后拿到和首次一样的响应(金额、流水号等)很重要。但存 response_snapshot 意味着幂等表里要存 JSON,表会很大,查询也会慢。
我的倾向
分层,但别过度:
- 网关 / SDK 层:只做
request_id透传和业务键提取,不做幂等判断(太早期,不知道业务语义)。 - 服务层:用幂等表做"先占位后执行",key =
business_key + action(不是request_id),存status+ 必要的response_digest(不是完整 JSON,是几个关键字段)。 - 数据库层:关键表保留唯一索引作为最后兜底,防止幂等表本身故障时裸奔。
- TTL:按业务重试窗口定,一般 7 天足够(参考 Kafka 的
retention.ms默认 7 天),过期数据归档到冷存储而不是直接删。 - 同 key 不同 payload:一律 409,把决策权交回调用方,服务端不猜。
但我知道这套方案在高 QPS + 低延迟场景下可能过重——每次请求多一次幂等表 INSERT,P99 直接多 1-2ms。所以我的问题是:
想听大家聊的
- 你们在什么场景下觉得"唯一索引就够了,幂等表是过度设计"?
- 幂等表的 key 你们怎么定的?有没有被"同 key 不同 payload"坑过的?
- TTL 你们怎么定的?过期后重发怎么处理的?
- 高 QPS 场景(比如秒杀、支付回调)你们怎么把幂等开销压到最低?
- 有没有"幂等表本身成为瓶颈"或者"幂等表故障导致重复写入"的实战故事?
欢迎贴代码、贴配置、贴事故复盘,越具体越好。