Matrix 大促期间出现过一次险情。一个支付回调因为网络抖动被重试了三次,支付服务的扣款逻辑没有做幂等,结果同一笔订单扣了三次款。幸亏对账系统当天就发现了,连夜退款补救,没酿成资损事故。但那晚所有人都惊出一身冷汗,如果对账晚一天,或者用户先发现,就是实打实的资损和投诉。那次之后我们把涉及资金的接口全部过了一遍幂等改造,后续几次大促零资损,幂等防护是底层的功臣。
这件事给我的教训是,分布式系统里同一个请求被执行多次不是小概率事件,是常态。网络超时客户端重试、用户疯狂点击、前端自动重试、MQ 的至少一次投递、网关的重试机制,每一个都可能让同一个请求重复到达。接口不做幂等,重复就出事,重复扣款、重复下单、重复发货,每一个都是资损。
先理解为什么会重复
图:重复请求的四种典型来源——均为系统为可靠性主动引入的冗余,治理方向是让接口安全处理重复
重复请求的来源有好几种,搞清楚来源才能选对防护手段。
客户端重试最常见。网络超时后,客户端不知道请求到底成没成功(也许服务端处理完了但响应丢了),于是重试。这是合理的容错行为,但要求服务端能识别重复。
用户连点也高频。网络慢的时候用户没耐心,连点几次提交按钮,每次都发一个请求。前端可以加 loading 和防抖,但前端防护不可靠,后端必须有兜底。
MQ 重复投递。消息队列保证 at-least-once 语义,正常情况下不重复,但在消费者 ack 丢失、broker 故障切换、重平衡等场景下会重复投递。消费者必须按幂等处理。
网关重试。一些网关配置了超时重试,对下游的调用失败自动重试一次,如果第一次其实成功了只是响应慢,就变成重复。
这些来源的共同点是,重复不是 bug,是分布式系统为了可靠性主动引入的冗余。所以治理的方向不是消灭重复,而是让接口能安全地处理重复。这就是幂等。
幂等的定义和天然边界
幂等的准确定义是,同一个请求执行一次和执行多次,效果完全相同。查询天然幂等,SELECT 多次结果一样。删除也基本幂等,删一次和删两次,数据都没了。但创建、扣减、转账这类写操作天然不幂等,重复执行会改变结果,必须主动设计防护。
写操作的幂等不是免费的,它需要额外的机制来识别这是不是一次重复请求。下面按可靠性从高到低讲几种手段,实际工程里往往是组合使用。
唯一索引,数据库给的硬保证
唯一索引是最可靠的幂等兜底,因为它不依赖应用层逻辑,是数据库直接给的约束。
做法是给业务唯一键加唯一索引。订单表对 order_no 加唯一索引,重复下单时第二条 INSERT 触发唯一键冲突,业务捕获冲突后查询已有订单返回,对调用方而言就像第一次请求成功了一样。
-- 订单表对业务唯一键加唯一索引INSERT INTO orders (order_no, user_id, ...) VALUES (?, ?, ...);-- 唯一键冲突 → 查询已有订单返回(幂等)唯一索引的妙处在于它是兜底的铁闸。不管上游怎么重试,不管应用层逻辑有没有 bug,只要业务键相同,DB 就拒绝重复插入。涉及资金的接口,唯一索引应该是默认设计,而不是可选项。
设计幂等接口,第一步想清楚用什么做唯一键。订单号、流水号、业务键,让 DB 在最底层兜底。应用层的幂等逻辑再严谨都有出 bug 的可能,唯一索引不会。这层兜底成本最低,可靠性最高,永远值得加。
token 机制,防前端连点
图:token 机制防前端连点的时序——必须用 DEL 的原子返回值判断首次,避免 GET+DEL 的并发窗口
唯一索引防的是重复写入到 DB,但有些场景在请求到达 DB 之前就该拦住,比如前端连点。token 机制干的就是这件事。
前端在提交表单前先向后端申请一个一次性 token,后端生成 token 存 Redis 并设 TTL(比如 60 秒)。前端提交时带上这个 token,后端用 DEL 删除 token,根据 DEL 的返回值判断。返回 1 说明 token 存在是首次提交,正常处理。返回 0 说明 token 已被用过,是重复提交,拒绝。
这里有个关键细节,判断必须用 DEL 的返回值,不能先 GET 判断再 DEL。GET 再 DEL 是两步操作,存在并发窗口,两个请求都 GET 到存在,然后都 DEL,都认为自己首次,幂等失效。DEL 是原子操作,用它的返回值判断才能保证只处理一次。
token 机制的局限是它只能防同一个前端的连点,防不了 MQ 重复投递或网关重试这类没有走 token 流程的重复。所以 token 通常和唯一索引组合,token 挡前端的快速重复,唯一索引兜底所有漏网的。
状态机,用业务状态约束流转
很多业务本身就有状态,利用状态约束可以天然防重复。订单有待支付、已支付、已发货等状态,已支付的订单再收到支付请求,因为状态已经变了,不该再扣一次款。
实现上是 CAS 式更新,UPDATE 的 WHERE 条件带上状态判断。
-- 只有待支付才能改成已支付UPDATE orders SET status='PAID'WHERE order_no=? AND status='WAITING_PAY';-- affected=0 说明状态已变(已支付或已取消), 幂等跳过重复的支付回调到达时,订单已经是 PAID 状态,WHERE status='WAITING_PAY' 匹配不到,affected 等于 0,业务识别到这是重复请求直接幂等返回。状态机防的是业务流转层面的重复,它依赖业务有清晰的状态定义。
分布式锁,防并发重复
图:四种幂等手段的分工对比——各管一类重复,工程实践中组合使用
唯一索引、token、状态机防的是先后到达的重复请求,但还有一种情况是并发重复。同一笔订单的两个支付回调在同一毫秒到达两个实例,两个实例同时查询都发现订单是待支付,都执行扣款。这时候需要分布式锁串行化。
同一业务键的并发请求用分布式锁竞争,只有一个拿到锁执行,其他等待。拿锁的处理完释放锁,等待的拿到锁后双重检查,再查一次状态发现已经变了,幂等跳过。
分布式锁的代价是引入串行化,降低并发度,而且锁本身有可靠性问题(Redis 主从切换时锁可能丢失)。所以锁一般用在唯一索引和状态机不足以覆盖的并发场景,不作为第一手段。组合使用最稳,锁防并发,状态机或唯一索引防重试,各管一类。
幂等键由谁生成
幂等键的生成方有讲究,必须由产生请求的一方生成并保证唯一,接收方不能自己生成。原因是接收方生成的键无法关联两次重复请求,两次请求生成两个不同的键,识别不出是重复。
前端表单用一次性 token。支付回调用第三方的流水号(微信支付的 transaction_id)。MQ 消费用消息的 messageKey 或 msgId。API 调用用调用方生成的 requestId。每种场景的幂等键来源不同,但原则一致,由发起方生成,接收方拿着这个键去重。
重复请求要返回成功,不是错误
重复请求返回 4xx 或 5xx 是反模式。调用方收到错误会认为请求失败,于是再次重试,陷入重试循环。正确做法是识别出重复后返回成功,带上已有的结果,让调用方知道这件事已经处理过但仍视为成功。重复请求的幂等返回必须是成功语义。
这个细节容易被忽略。有人觉得重复请求应该告诉调用方这是重复,返回一个特殊错误码。但调用方的重试逻辑通常是根据 HTTP 状态码或业务码判断成功失败,收到错误就重试,结果重复请求触发了更多重试,越重试越多。返回成功是最安全的,调用方收到成功就停止重试,闭环结束。
幂等是全链路的要求
幂等不是单个接口的事,是整条链路的事。接口 A 幂等了,A 调 B 时 B 没幂等,整条链路就没幂等。MQ 消费、回调处理这些入口尤其要注意,它们是重复的高发区,必须强制 requestId 透传加幂等检查。
全链路幂等要求 requestId 从入口一直透传到最底层。网关收到请求带上 requestId,服务 A 调服务 B 时透传,B 调 C 时透传,每一环都基于这个 requestId 做幂等检查。任一环漏了,那环就可能被重复请求击穿。requestId 是整条链路的去重锚点,这个基础设施必须先建好。
写每个写接口之前,先问自己一个问题,这个接口被重复调用会怎样。如果答案是会重复扣款、重复下单、重复发货,那就必须设计幂等。设计成重复无害,就是幂等。这套从唯一索引到 token 到状态机到分布式锁的组合拳,覆盖了绝大多数场景,是分布式系统的基本功,也是涉及资金接口的安全底线。







