分布式系统的接口幂等,本质上不是“锁不锁”的问题,而是“同一个请求被执行了多次,业务结果是否仍然正确”的问题。我在真实项目里最常见的高发场景就两个:一个是用户重复点击提交,多创建了订单;另一个是支付平台回调重试,结果库存被扣了两次或者订单被更新了两遍。重复提交一旦真金白银地造成了重复扣款,线上立刻就是事故,没有二话可讲。
这篇文章,我围绕分布式系统中接口幂等性的九种实现方案展开,把每种方案的设计逻辑、适用场景、典型代码和踩坑经验都梳理一遍。很适合正在做交易、订单、支付回调、库存扣减这类强一致场景的后端开发,也适合准备系统梳理幂等知识的团队参考。代码示例以伪代码形式为主,偏向思路,落到你们自己的技术栈并不难。
1. 先从“重复提交”说起,弄懂幂等要解决什么问题
1.1 重复请求到底是怎么来的
很多刚接触分布式系统的同学有个误区,以为重复提交就是用户手快多点了两下按钮。实际上线上环境的重复请求来源,远比你想象的复杂:
- 前端按钮未做防抖、未禁用,用户双击或连点提交。
- 客户端发完请求后网络超时,框架层自动重试,拿着同一个请求体再发一次。
- API 网关配置了重试策略,上游响应超时后自动转发到下游服务。
- 消息队列普遍采用 at-least-once 投递语义,消费者处理成功但未来得及提交消费位点,消息重启后又投递一次。
- 某些业务回调系统,比如第三方支付回调、银行回调,为了保证通知必达,会间隔重试直到你明确返回成功。
这几种场景叠加在分布式环境下,最棘手的一点是:请求可能被不同的机器节点分别处理,每个节点看到的数据状态可能还不一样。你如果只在单机内存里放一个“请求是否已处理”的标记,换一台机器就失效了。所以幂等设计,必须在多个服务节点之间共享的存储上做判断,比如数据库、Redis、ZooKeeper 这类中间件。
1.2 幂等不是一种“锁”,而是一种结果约束
定义说人话一点:一个接口执行一次,和执行一百次,对系统产生的业务影响是一样的,这个接口就是幂等的。举例:
- 创建订单时,同一个订单号第一次请求创建成功,第二次请求不要报错,也不要再插一条新订单,而是返回同一个订单数据。
- 支付回调时,同一笔支付通知来了五次,数据库里订单状态最终只从“待支付”变成“已支付”一次,金额只入账一次。
- 扣减库存时,同一张业务单子重复调用,库存不会因为重试而重复扣减。
我经常看到有人把幂等和“并发控制”混为一谈。实际上锁是手段,幂等是结果约束。你当然可以用悲观锁、乐观锁来实现幂等,但也完全可以用数据库唯一索引、Redis 防重标记这类不用锁的方案来解决。理解这层区别,后面选型才不容易迷糊。
另外,幂等判断必须是同一个请求。如果你的订单号相同,但请求参数里商品数量不同,这种算参数冲突,不属于幂等应该处理的问题。所以做方案时,要先明确“幂等键”的粒度:是订单号、支付流水号、消息 ID,还是用户 ID + 业务场景 + 业务 ID 的组合。这一下子就决定了方案的可靠程度。
2. 实现前先看清楚:九种方案横向对比
2.1 一套通用的幂等设计套路
不管用哪种方案,核心套路其实就三步:
- 用某个存储介质记录“这个请求的业务标识”。
- 业务执行前判断标识是否已存在,不存在就标记并执行,已存在就说明是重复请求。
- 重复请求要么直接返回上次成功结果,要么直接拒绝。
难点主要在第二步。因为你没法保证判断和业务执行是原子的。判断时没有记录,刚记录完还没执行业务,另一个节点也来判断,又认为没有记录,两边同时往下走,就出问题了。所以,真正稳妥的方案不是在“怎么做判断”上做花活,而是在“存储约束”上下功夫。下面的九种方案,其实都是围绕着这套思路展开的。
2.2 九种方案总览表
给一张我自己的对比表,方案名称、依赖组件、核心思想、适用场景、局限一次说清楚:
| 编号 | 方案 | 核心机制 | 适用场景 | 主要局限 |
|---|---|---|---|---|
| 1 | 数据库唯一主键/唯一索引 | 数据库唯一约束拦截重复插入 | 订单创建、流水记录、消费记录 | 有些场景没有天然唯一键,需要加冗余列 |
| 2 | 乐观锁(版本号字段) | 更新时校验当前版本号 | 库存扣减、金额变更 | 只能处理更新型业务,不能处理纯新增 |
| 3 | 悲观锁(行锁) | SELECT ... FOR UPDATE 串行化 | 低频高价值操作、金额处理 | 并发性能差,容易阻塞连接池 |
| 4 | 状态机控制 | 按既定状态推进,前置状态不对就拒绝 | 订单状态流转、支付回调 | 状态设计复杂,跳状态场景难处理 |
| 5 | Redis SETNX 幂等标记 | 设置成功表示首次请求 | 快速拦截重复点击 | Redis 故障或键过期会穿透到DB |
| 6 | Redis + Lua 原子操作 | 把判断和标记合并在一个脚本中执行 | 高并发下单、防重标记 | 脚本维护成本高,仍需兜底 |
| 7 | Token 令牌机制 | 前端先申请令牌,提交时消费令牌 | 表单重复提交、秒杀下单 | 需要额外一次令牌申请交互 |
| 8 | 分布式锁 | 用锁保证同一业务同一时刻只有一个执行者 | 多节点并发扣减 | 锁过期会导致并发瞬间穿透 |
| 9 | MQ 消费记录表 | 用唯一消息ID写入本地消费记录表 | 消息队列重复投递 | 要有本地事务配合,多一张表维护 |
这张表不是让你选一种用到天荒地老。我在项目里做得最多的做法是“入口用缓存挡一波,终点用数据库约束兜底”。比如下单接口,Redis 幂等键先拦住前端连点的大量重复请求,DB 层再加上一个唯一索引,防止缓存过期或 Redis 异常的极端情况,这样才是完整方案。
3. 基于数据库的四种实现方案,把最终防线先聊透
3.1 方案一:数据库唯一主键/唯一索引
这是所有幂等方案里最可靠的一种。数据库的唯一约束是数据库自身保证的,不管你多少台服务器同时来插入,最终只有一条能成功,天然就是分布式安全的。
最常见的落地方式是在业务表里增加一个“幂等键”字段,比如biz_no或order_id,然后对它建唯一索引。代码大概是:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no) );try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 说明是重复请求,查一次原订单返回,或直接返回“订单已存在” return orderMapper.selectByOrderNo(order.getOrderNo()); }这里有个非常容易踩的坑:如果你的接口是新增一条主记录、同时还要插入 N 条子明细,只对主表做唯一索引是不够的。因为两个重复请求可能都插了主记录,一个成功一个失败,失败的没关系,可子表里可能已经各插了一半。对这种场景,我强烈建议把主表和子表插入放进同一个数据库本地事务里,由主表的唯一索引去引发事务回滚,子表脏数据才能一并撤销。
另一个经验:不要随手把数据库主键当成幂等字段。自增 ID 每次插入都会生成新的,根本拦不住重复。要用业务维度的唯一编码,并且如果你担心外部传入的业务编码可能不唯一,可以在服务端自己再拼接一个来源标识,比如source_channel + "_" + out_biz_no。
3.2 方案二:乐观锁(版本号字段)
乐观锁适合“更新型”操作,典型场景是库存扣减、余额变更。它的逻辑是:更新数据前,我读到当前版本号;更新数据时,SQL 条件里带上这个版本号;如果别的请求已经把版本号改了,我的更新行数就是 0,说明本次操作无效。
UPDATE t_inventory SET stock = stock - #{buyNum}, version = version + 1 WHERE id = #{inventoryId} AND version = #{curVersion} AND stock >= #{buyNum};如果 update 返回的受影响行数为 0,不一定就是重复请求,也可能是库存不足。所以我的建议是:返回 0 以后,再查一次数据库当前状态,区分“版本变化了”和“库存不足”这两种情况,再决定返回“重复操作”还是“库存不够”。
乐观锁本质上是把重复请求中的一个判定为失败,如果第一个请求已经成功,后面重复到达的请求会因为版本号不匹配而返回失败。从结果上看,数据不会被重复扣减,副作用被抑制住了,这已经达到了幂等效果。但实际上你会发现,如果业务上要求第二次重复请求也能返回“成功”,那乐观锁还需要额外封装一层结果转换:当 update 影响行数为 0 时,去查记录当前状态,如果已经是终态,就返回成功或返回已有结果,而不是直接抛错。
3.3 方案三:悲观行锁(SELECT FOR UPDATE)
悲观锁的思路更直白:先把这个订单或库存行锁住,不让别的请求动它,然后再做判断和更新。多节点场景下,行锁是数据库层面生效的,所以分布式环境也能用。
BEGIN; SELECT * FROM t_order WHERE order_no = #{orderNo} FOR UPDATE; -- 业务判断状态是否已经处理过 -- 如果未处理,则执行更新 COMMIT;我为什么到现在仍会偶尔用悲观锁?因为它能完美解决“先查后改”这个非原子操作。举例:你做一笔退款,需要先查出订单当前已退金额,再累加上本次退款金额,最后写回。如果两个节点同时查到同一行数据,都认为可以退,就都做累加,结果必然错误。用悲观锁锁住订单行,第二个请求只能等第一个事务提交后再去查询,此时看到的已退金额已经包含前一次退款了,不会再重复退。
但使用它有严格前提:第一,必须在一个事务里;第二,查询条件必须走索引,否则数据库可能锁整张表;第三,操作时间不能长,否则会拖垮连接池。很多团队把悲观锁作为最后的并发防线,但不会把它用在超高并发的秒杀扣库存场景。短事务、低频、高价值数据处理,用它最踏实。
3.4 方案四:状态机控制
状态机其实是“条件更新”的高级玩法。它不再依赖单独的版本号字段,而是用业务状态字段本身作为更新条件。比如说订单状态只能从“待支付”变成“支付成功”,支付回调来了以后执行:
UPDATE t_order SET status = 'PAY_SUCCESS', pay_time = NOW() WHERE order_no = #{orderNo} AND status = 'WAIT_PAY';只有当订单当前状态是“待支付”时,这条 SQL 才会把状态改成“支付成功”。如果回调因为重试重复执行,第二次执行时订单状态已经是“PAY_SUCCESS”,更新行数是 0,说明这单已经处理过了,直接返回成功结果即可。
这个方案可读性非常好,因为它天然嵌入了业务规则。订单从创建到完结,每个节点能往哪些状态跳,是固定的:
- 待支付 → 支付成功
- 支付成功 → 已发货
- 已发货 → 已签收
- 支付成功 → 退款中
- 退款中 → 已退款
突然来个乱七八糟的回调想把“已退款”改成“支付成功”,底层 SQL 的条件状态不匹配,请求根本不会生效。
需要提醒的是,状态机和并发控制结合使用效果更好。如果两个请求同时想把“待支付”改成不同状态,SQL 条件都能匹配,有可能都执行成功。解决方法是把状态字段和 version 字段同时作为条件,比如WHERE status = 'WAIT_PAY' AND version = 1。状态机负责业务规则约束,版本号负责并发冲突检测,两者不冲突。
4. 基于 Redis 的缓存方案,把重复请求拦在入口
4.1 方案五:Redis SETNX 幂等标记
当业务量上来以后,不能每个重复请求都打到数据库。用 Redis 做一个前置幂等判断,是非常常见的做法。核心逻辑就是利用 SETNX(set if not exists)命令:同一个业务键只有第一次能设置成功,后面的请求会因为键已经存在而失败。
// 伪代码,gateway 或 service 入口处处理 Boolean first = redisTemplate.opsForValue() .setIfAbsent("idempotent:order:" + orderNo, "1", Duration.ofMinutes(5)); if (first == null || !first) { // 已处理过,直接返回旧的业务结果 return orderService.queryByOrderNo(orderNo); } // 继续执行业务逻辑这里两个细节一定不能漏:
第一个是 TTL 必须设置。不然某个请求第一次设置成功,后面业务处理失败,你又没有清理这个键,这个订单号永远无法再次提交,用户只能干瞪眼。TTL 设置多少需要结合你的业务重试窗口判断。支付回调的重试时间窗口往往有数小时甚至一天,缓存只放 5 分钟是不够的,这种情况就得考虑键过期后仍会穿透到 DB,由数据库兜底。
第二个是“业务失败时清理键”和“业务成功时保留键”要分清楚。最合理的写法是:先执行业务,业务成功后设置幂等键并返回结果;或者在事务内把键的状态标记为 SUCCESS。如果业务真的回滚了,幂等标记也要清理,否则用户无法重新提交同一个请求。这里又会引入顺序问题:业务还没成功,键就写上了,后续请求会看到 key 而直接返回;可业务一旦失败,key 还留着,就出问题了。所以纯用 Redis 做幂等,很少能独立撑住强一致场景,它更像是一个前置过滤器。
4.2 方案六:Redis + Lua 原子操作
有些场景你不仅需要判断“请求是否处理过”,还要在判断的同时完成状态扭转,比如把幂等键从 PROCESSING 改成 SUCCESS。如果这些操作分两步走,两个请求可能在中间交叉,导致结果错乱。这时候就需要 Lua 脚本把判断和修改合成一个原子操作。
先看一个比较典型的“状态判断 + 状态转移”脚本:
local current = redis.call("GET", KEYS[1]) if not current then return 0 -- 键不存在,说明请求太早或者已经过期,安全起见不处理 end if current == ARGV[1] then -- 当前状态等于期望的前置状态,可以执行转移 redis.call("SET", KEYS[1], ARGV[2], "EX", ARGV[3]) return 1 end return -1 -- 状态不对,可能是重复请求或非法流转Java 侧调用时,传入订单号对应的 Redis 键、期望的前置状态、变更后的状态和 TTL。这样一来,判断逻辑和更新逻辑全部在 Redis 服务端单线程执行,天然没有并发问题。
很多刚学 Redis 命令的同学会写“先 GET,再 SET”这种代码。在单机演示时没问题,但到了集群环境下,两个并发请求很有可能 GET 到同样的旧值,然后都继续处理。真正的原子操作应该交给 Lua 脚本,或者使用 Redis 给的原生命令组合。这是我踩过最多坑的地方,也是为什么我把 Lua 脚本单独作为一种方案列出来的原因。
4.3 方案七:Token 令牌机制
Token 方案适合“防表单重复提交”的场景,它跟 SETNX 的区别在于:SETNX 使用的是业务键,比如订单号,这个值大多数是客户端传给服务端的;Token 则是服务端提前发放,客户端提交时必须带着,提交后令牌立即失效。
典型的流程是:
- 前端进入下单页时,向后端申请一个 token。
- 后端把 token 存到 Redis,设置较短过期时间,并返回给前端。
- 前端点击提交时,把 token 放在请求参数或 Header 中。
- 后端收到请求后,先尝试“消费”这个 token:从 Redis 中删除并判断是否删除成功。只有在删除成功时才继续业务处理。
- 第二次点击带着相同 token 来,Redis 中已经没有这个 key 了,请求直接拒绝。
Redis 中删除并判断必须用 Lua 脚本保证原子性:
local token = redis.call("GET", KEYS[1]) if token == ARGV[1] then redis.call("DEL", KEYS[1]) return 1 end return 0用 GET 和 DEL 分开写的话,两个请求可能都 GET 到同一个 token,都认为有效,然后都继续执行业务。这种令牌方案形同虚设。
Token 方案有个隐蔽的坑:业务处理失败以后,token 已经消费掉了,用户怎么重试?我的处理办法是:如果后端业务返回明确失败,就把这个 token 重新设置回 Redis,或者在响应里让用户重新获取令牌后再提交。不要默默让用户刷新页面重来,那种体验很糟糕。
4.4 方案八:分布式锁
分布式锁本质上和 SETNX 非常像,但它追求的不是“只放一个请求进来”,而是通过锁的方式把同一个业务键的并发执行者压缩成一个。Redis 分布式锁常见使用方式:
String lockKey = "lock:deduct:order:" + orderNo; String requestId = UUID.randomUUID().toString(); boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(3)); if (!locked) { // 没抢到锁,这里要结合业务决定是提示稍后重试还是直接返回重复 } try { // 拿到锁后执行业务逻辑 } finally { // 只有持有当前锁的请求才能删除锁,防止误删其他请求的锁 String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(...); }很多人觉得分布式锁和 SETNX 幂等标记一样,其实差别很大。幂等标记更像是“布告牌”:我看到上面写了“在处理”,就不往下做了;分布式锁则是“排队入场”:我拿到锁后继续执行,没拿到锁的在门口等着或宣告失败。锁方案适合处理并发下的资源竞争,比如两个请求同时要操作同一笔订单、扣减同一个库存记录。
但经验之谈:分布式锁做不了完整幂等。因为锁总有过期时间,如果业务执行超过锁的过期时间,锁自动释放了,另一个相同请求进来又拿到锁,还是可能重复处理。这就是为什么我特别喜欢在锁方案之外,再配合一个 DB 唯一约束或者状态机做兜底。锁解决并发窗口,数据库约束解决最终一致性。
4.5 方案九:MQ 消费记录表
最后一种是消息队列场景里的经典幂等方案。前面说过,MQ 普遍做不到 exactly-once,即使像 Kafka、RocketMQ 这样的成熟消息中间件,也只能在特定条件下做到近似精确一次,现实中 at-least-once 才是常态。消费端宕机、网络抖动、位点提交失败,都会导致同一条消息被再次投递。
消费端幂等的核心做法,是维护一张“消费记录表”。消息的全局唯一 ID 或业务唯一键作为消费记录表的主键,每次消费前先插入一条消费记录,插入成功才真正处理业务:
CREATE TABLE t_mq_consume_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL COMMENT '消息全局唯一ID', biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一ID', consume_status VARCHAR(16) NOT NULL, consume_time DATETIME NOT NULL, UNIQUE KEY uk_msg_id (msg_id), UNIQUE KEY uk_biz_id (biz_id) );消费逻辑如下:
- 开启本地事务。
- 尝试往
t_mq_consume_log插入一条msg_id = 本次消息ID的记录。 - 如果插入冲突,说明消息已经消费过,直接 ACK 并跳过。
- 如果插入成功,继续执行业务更新。
- 业务更新和消费记录插入在同一个数据库事务里提交,然后向 MQ 返回 ACK。
这个方案能成功,本质上靠的还是数据库唯一约束。但它把“重复检测”做成了消息驱动场景的标准模板,比直接在业务表上加幂等键更通用:消费日志表独立存在,业务表和消费记录表通过消息 ID 关联,最终一致性也有保障。
我实际做项目时,通常不是只记录消息 ID,还会记录业务 ID。因为有些错误消息虽然消息 ID 不同,但业务 ID 相同,比如同一个订单的退款消息被重推了两次,消息 ID 变了,业务 ID 没变。这种场景只对消息 ID 做唯一约束是拦不住的,必须对业务 ID 也建唯一索引。
5. 业务关键词怎么定,决定了前面所有方案的效果
这里单独拿出一节来说幂等键设计,因为这个问题太常被忽略,却是所有方案里最核心的变量。很多团队照着方案写代码,上线以后发现还是重复了,往回一查,幂等键设计得不对。
幂等键的确定原则是:客户端同一次业务操作的重复请求,必须携带同一个稳定无歧义的标识。举例来说,用户一次下单动作,前端生成的单据号是20250601001,那么后续网关重试、前端连点、服务重试都应该带着这个单号。如果前端每次点击都重新生成一个新的 UUID,就没有幂等性可言。
幂等键一般由一个前缀和几个业务字段拼接而成,常用格式是:业务场景 + 用户标识 + 业务标识。比如:
- 下单:
order-create:{userId}:{clientRequestId} - 支付回调:
pay-callback:{paymentNo} - 退款申请:
refund-apply:{orderNo}:{refundNo} - MQ 消费:
consumer:{topic}:{msgId}
谨慎的做法是幂等键里带上租户或渠道信息。因为不同渠道的用户可能订单号规则不同,A 渠道的订单号“1001”和 B 渠道订单号“1001”如果不加渠道前缀,就会互相同步、被误判成重复请求。这种问题排查起来特别费劲,而且通常都是上线后由业务方反馈才发现的,非常尴尬。
6. 九个方案放到真实场景里,怎么组合选型
6.1 典型场景速查
拿我实际接触比较多的几个业务场景来演示一下怎么选。
创建订单、秒杀下单这类“新增”型接口,最适合的落地方案是“Token 或 Redis 幂等标记 + 数据库唯一索引”。第一次请求通过令牌校验后进入业务插入,数据库唯一索引保证同一下单单号只能插入一次。前端连点和大规模重试被 Token 机制挡住,极端情况下 Redis 故障后,DB 唯一索引仍然能兜住最终的重复插入,做到双保险。用户请求如果前面业务失败,令牌被消费掉了,我还会在业务失败时把幂等标记删除,让用户重新用同一个单号发起,或者提示前端重新获取 Token。
对于支付回调这类第三方重试频繁的接口,我的首选是“状态机 + 支付流水号唯一索引”。回调进来先按payment_no查支付流水,如果流水已经终态,直接返回成功给第三方。否则更新订单状态,SQL 里强制前置状态是“待支付”。由于第三方平台对回调响应时间有要求,这里不适合让回调线程长时间等待锁。状态机的条件更新是很短的一条 SQL,性能可以接受,而且天然具备重复保护。同时,在支付流水表上建payment_no唯一索引,就算业务代码状态机写漏了,重复的回调流水也插不进去。
库存扣减属于典型的非幂等更新,但它还要兼顾高并发性能,我不会用 Redis 分布式锁把所有扣减全串行化,因为同一商品 SKU 的扣减一旦都排队,热点商品的 TPS 会非常难看。这种场景更适合用“乐观锁版本号和stock >= num条件”的组合。SQL 中既判断版本号,又判断库存充足,更新行数为 0 时再回表查询,给用户返回合理的提示。如果确实存在极高并发峰值下的超卖风险,我才会考虑用 Redis 预扣减 + 异步对账的方案,那就进入分布式事务范畴了,复杂度再上一个级别。
消息消费端幂等,我的默认模板就是“消费记录表 + 本地事务”。这也是前面说的第九种方案。有些团队为了省事,直接用 Redis SETNX 做消费幂等,结果 Redis 主从切换丢键后消息重放,照样重复记账。既然消息都打到数据库了,再多写一张消费记录表并不算太重的成本,换来的可靠性却高很多。真正常见问题是,消费记录表所在的数据库和业务数据库不能跨库事务。跨库时,有人用“先写业务,再写消费表”,这个顺序会导致业务执行成功、消费表没写入,消息位点提交后,后续机器重启又会重复执行业务。所以我一般强调,尽量保证业务表和消费表在同一个本地事务中。确实有跨库需求时,就必须引入可靠消息或本地消息表方案,不能裸着硬写。
6.2 组合应用时,必须考虑最终防线
不管选几种串联,我最后都会问团队一个问题:当 Redis 不可用、分布式锁恰好过期、幂等键设置失败、网关重试风暴突然到来,你的数据库能不能保证不产生脏数据?
如果答案是不能,这个幂等设计就有漏洞。缓存入口是有状态的,而缓存不是强一致系统,Redis 主从切换、网络分区、内存淘汰、键过期,任何一环节都可能让前置保护失效。因此,但凡涉及资金、库存、订单这类核心数据,数据库的唯一约束或者条件更新必须作为兜底。前置缓存是“大部分时候的双保险”,数据库约束才是“无论什么时候都不会松手的那道闸”。
这个理念我建议直接写进团队的架构评审 checklist 里。评审幂等方案,不要只听你说用了什么 Redis、什么 Lua、什么锁,先问 DB 层设计了什么约束。没有 DB 兜底的方案,本质上是靠运气在防重复。
7. 实测路线上,绕不开的坑和验收方法
7.1 幂等接口上线前,至少这样测
很多团队上线前不做专门的幂等测试,往往是线上出一次重复支付事故后,才急匆匆补测试。幂等验证比想象中简单,不需要复杂平台,用接口测试工具就能做:
第一轮,单并发重复测试。同一个请求体连续发送 10 次,预期结果应该是第一次成功,后面 9 次返回同一个结果或者提示重复提交。如果后 9 次里有任何一次又执行了业务插入或更新,说明幂等没生效。
第二轮,多线程并发测试。用 50 个线程同时发送同一个业务幂等键的请求。这一轮比第一轮更能暴露问题。很多接口单次重复测试是正常的,但并发一到就会挂,因为多个请求同时通过判断,同时进入业务逻辑。如果你的实现里没有数据库约束或原子操作,这一轮大概率会翻车。
第三轮,加上“先失败后重试”的场景。第一次请求故意让下游服务返回超时,但实际数据库已经写入成功,第二次用同一个幂等键重新请求,预期结果应该是返回第一次的成功状态,而不是报“重复提交”让用户无法继续。这是幂等方案最容易忽略的分支。
第四轮,把 Redis 停掉,再发一次重复请求。如果你的接口在缓存不可用时能退化为纯数据库校验并继续保持幂等,说明你的最终防线是成立的。
我用这几轮测试捞到过非常多问题,有的接口幂等键没带全,有的 Redis 判定重复后没查询原结果就直接报错了,有的数据库唯一索引漏建。所以坦白说,幂等不幂等,不是代码 review 出来的,是高压测试压出来的。
7.2 集成过程中最容易踩的五个坑
结合过往项目,我整理了几个高频问题:
幂等键设置成固定常量。有同事直接把幂等 key 写成某个接口名,比如idempotent:pay,结果这个 key 一小时内只允许成功一次,直接把所有用户的支付挡住。这种错误容易发生在“接口要做全局防重”的误解上。幂等键一定和用户、业务单强相关,不能全接口共用。
缓存 TTL 设置过短。Redis 幂等键过期后,重复请求穿到 DB,如果 DB 没有约束,就重复成功。设置 TTL 时,必须大于系统中同一请求可能重试的最长间隔。这个最长时间从哪里来?网关超时重试次数加退避时间,加上前端按钮可点击间隔,再加上 MQ 重投延迟,再加一点余量。算出来如果确实超过 24 小时,就别依赖 Redis,走 DB 唯一索引方案更稳。
锁释放时误删别人的锁。这是分布式锁最经典的坑。A 线程锁 10 秒,业务执行 12 秒,锁自动过期了,B 线程进来拿到锁开始执行,A 执行完 finally 块把锁 DELETE,B 的锁被误删,C 线程又进来拿锁。所以释放锁之前一定要校验 value 是不是自己线程的 UUID,再用 Lua 脚本完成“校验+删除”。不要用先 GET 比较再 DEL 的方式。
无条件信任更新结果。SQL 影响行数为 0,并不必然等于重复请求,也可能是前置状态不对。反正把 0 当成重复请求返回,会造成调用方困惑。我的做法是影响行数为 0 时,再查一次数据状态,如果是终态就按“已处理过”处理,否则返回真实失败原因。
日志里看不到重复请求痕迹。我在重构一个下单接口时,因为前端传的 requestId 每次点击都重新生成,日志里看每个请求都像新请求,无从分辨哪些是重复操作。建议在入口给每个请求打上业务幂等键的 trace 日志,并记录 Redis 命中结果。事后排查时,这些日志价值极高。否则线上出了重复扣款,你连哪个环节放行的都查不出来。
7.3 分布式环境下监控幂等效果的几个指标
上线之后,幂等做得好不好可以通过指标观测。最直观的是“幂等拦截率”,也就是某接口因为幂等键重复被拦截的请求数占总请求数的比例。正常情况下,这个比例通常很低,如果突然飙升,很可能不是用户重复点击,而是网关重试策略出问题或者消息队列重复投递。此时要去查调用来源,而不是只看业务代码。
第二个指标是“幂等键冲突 TOP 排行”。把冲突最多的幂等键打点记录下来,然后去分析为什么同一个 key 被高频重复提交。我曾经遇到过一个请求量特别大的场景,TOP 幂等键全是同一个用户 ID 拼固定值的结果,最后发现是用户点击一次系统内部就重试几十次,属于上游 bug。这种问题如果没有监控,很难在第一时间发现。
第三,标记“兜底生效”次数。也就是前置 Redis 没有拦截住,最后被数据库唯一约束拦住的那部分请求。这个数字如果持续大于 0,说明前置拦截存在漏洞需要修,但它恰恰证明兜底设计是有效的。我一向把这个指标当成系统幂等设计质量的一个关键反馈,不丢人,反而很有价值。
8. 几个值得沿用的小经验
讲到这里,九种方案和组合逻辑都过了一遍。最后说几条我在多次事故里沉淀下来的经验。
幂等设计一定要在最开始做,不要等业务跑出重复数据再补。重复数据一旦出现,靠后续清洗脚本修复非常痛苦,还要对账、溯源、甚至给用户赔礼道歉。很多功能排期时,“防重复”常常被认为是一个很简单的小需求,实际上它对设计细节的要求非常高。与其后期返工,不如在表结构设计阶段就把唯一的业务键和状态机迁移规则划清楚,代码实现反而水到渠成。
不要迷信某一个组件的能力。Redis 很快,但它是 AP 系统,主从故障时可能丢数据;数据库可靠,但直接扛所有重复流量代价太大;分布式锁能让业务串行,但锁过期问题永远存在。真正落地的架构,一定是多级保护。至少分两层,缓存层负责拦流量,数据库层负责保证最终一致。
接口对外返回时,尽量设计成“重复请求也能返回成功的业务结果”。除非业务明确要求严格拒绝,否则用户再点一次,后端返回“您刚才提交的订单已成功,单号是 xxx”,比提示“重复提交”体验好得多。很多支付平台对重试请求的处理就是这个套路:相同请求返回相同成功结果,调用方不需要额外判断。这一点设计理念,建议在后端接口规范里直接定下来。
篇幅有限,但幂等这个话题可以延展的坑还有不少。如果你们团队正在做交易类系统,我强烈建议找时间把这些方案一一落到代码里做个对比实验,带着自己的业务场景去压测,感受比读十篇文章都要深刻。