先交代背景。做中间件这些年,我几乎每个项目都会被问一句:"幂等你们怎么做的?"答案五花八门——有人业务层每张表加唯一索引,有人用 Redis SETNX + TTL,有人在网关层按 (userId, path, bodyHash) 去重,还有人干脆把请求号(requestId / bizNo)透传到所有下游,让每一跳自己判断。热榜上那篇「超时与重试」讲得很透,但只解决了"重试会发生"的问题,没解决"重试之后谁负责不重复"的问题——这恰恰是幂等要回答的。
常见的分歧我大致归成三派:
第一派:幂等是业务语义,必须业务层做。理由很简单——"重复扣款"和"重复查询"是完全不同的事,只有业务才知道哪些操作要幂等、幂等的键是什么(订单号?流水号?用户+商品+时间窗口?)。中间件如果统一做,要么键设计得太粗导致误判,要么太细等于没做。
第二派:幂等是基础设施能力,应该下沉到 SDK 或网关。理由:让每个业务同学自己写一遍 Redis 锁 + 唯一索引 + 异常回滚,代码质量参差,出了事就是 N 套实现 N 种 bug。统一做,一套代码、一套监控、一套降级策略,可维护性高得多。
第三派:分层协作。网关层做"粗粒度去重"(同一 requestId 短时间重复直接拒绝),SDK 层提供"幂等装饰器"(业务声明 key 和 TTL,SDK 负责 Redis 交互和异常处理),业务层只定义"什么算一次操作"。
我个人的倾向是第三派,但有一个前提:幂等键的语义必须留在业务层。打个比方,网关像小区门卫,能认出"你这 30 秒内来过三次,别再刷了",但它不可能知道"你上次取的那个快递是不是同一单"。SDK 像快递柜,提供"凭码取件"的机制,但码是什么、有效期多长,得寄件人定。如果让门卫或快递柜去猜"这算不算同一单",一定会出幺蛾子。
【v2 修订·2026-10-20:补入「幂等键 TTL 与超时/重试交互」小节】
写初稿时漏了一个实操里最容易翻车的点:幂等键的 TTL 不是拍一个"30s"或"5min"就完事的,它必须和超时/重试预算联动。
打个比方:幂等键 TTL 就像快递柜的取件码有效期——设太短(比如 5s),用户第一次支付超时重试时码已经过期了,系统以为是新单,直接再扣一次款;设太长(比如 24h),用户 23 小时后想重新发起同一笔支付(比如银行扣款失败后重新授权),系统却告诉他"已处理",但实际钱根本没扣成功。
具体定法(带数据):
幂等键 TTL ≥ 最大重试窗口 + 下游最长处理时间 + 安全余量
最大重试窗口 = 重试次数 × (超时 + 退避)
= 2 × (2000ms + 500ms) = 5000ms(以 2 次重试、2s 超时、500ms 退避为例)
下游最长处理时间 = 你下游最慢的那条路径(比如异步消息消费 + 落库,可能 10~30s)
安全余量 = 10~30s(覆盖 GC、网络抖动、时钟偏移)
→ TTL ≈ 5s + 30s + 15s = 50s(读接口)
→ TTL ≈ 5s + 120s + 30s = 155s(写接口,如下单、支付)
三个容易踩的坑:
-
TTL 设得比超时还短:超时 2s、TTL 5s,看起来"够用了",但超时后客户端重试时(2s 后),Redis 里 SETNX 的 key 还在,第二次请求被拒——但第一次请求可能根本没执行到写库(超时在 connect 阶段就断了)。结果是:客户端两次都收到 429/409,但实际没有一笔落库。解法:幂等键的写入时机应该是"业务确认执行成功"而不是"请求到达"。
-
Redis 主从切换丢 key:SETNX 在主节点执行成功,客户端还没收到响应,主节点就挂了,从节点提升后 key 不存在——客户端重试时 SETNX 成功,执行了第二次写。这是 Redis 幂等的固有缺陷,资金类场景必须加 DB 唯一索引兜底(Redis 做"快速拒绝",DB 唯一索引做"最终防线")。
-
TTL 和幂等键生成时机:幂等键应该在客户端发起请求前就生成(不是服务端收到后生成),否则客户端超时重试时服务端已经生成了 key,客户端拿不到 key 就不知道用哪个去重试。这也是为什么我在上面说"幂等键语义留在业务层"——只有业务层知道"这一单"的键是什么、什么时候该生成。
想听听大家在几种具体场景下的选择:
- 支付/资金类接口:幂等键是上游生成还是本服务生成?TTL 怎么定?
- 异步消息消费(Kafka/RocketMQ):幂等是消费端做还是 broker 侧做?重投窗口多长?
- 跨服务编排(A→B→C):幂等键要不要全链路透传?B 和 C 的幂等键是同一个还是各自独立?
- 你们团队里,幂等代码是"公共组件"还是"每个服务自己写"?维护成本差异大吗?
欢迎带具体案例来聊,越具体越好。