ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

接口幂等性设计实战:从数据库约束到分布式锁的完整方案

接口幂等性设计实战:从数据库约束到分布式锁的完整方案

1. 为什么"重复请求"能把系统打穿:幂等问题的真实战场

先讲个我亲身经历的事。某天半夜被值班电话叫醒,说支付回调处理服务一直报错,数据库里出现了同一笔订单被扣了两次库存的记录。排查下来,是上游支付平台在超时后自动重发了三次回调,而我们的回调接口没有做幂等控制——每收到一次请求,就执行一次"改订单状态 + 扣库存 + 加积分"的完整流程。三次请求,库存扣了三份,积分加了三份,唯独订单只应该支付一次。

这种问题在互联网系统里太经典了。本质上,幂等性(Idempotency)指的是:同一个操作无论执行多少次,产生的结果都和只执行一次完全相同。放到接口层面来说,就是客户端因超时、重试、网络抖动等原因,把同一个请求发了好几遍,服务端必须保证只生效一次。

这个需求不是某个系统的特殊要求,而是几乎所有涉及资金、库存、状态流转的业务系统都绕不开的硬约束。凡是有"扣减""增加""状态变更"这类操作的接口,一旦缺少幂等保护,就可能出现重复扣款、超发、数据错乱等事故。后面我会从原理到实现,把主流方案逐个拆开讲清楚。

2. 幂等性的底层逻辑:请求去重到底在去什么"重"

要理解幂等实现方案,先得想明白一个问题:服务端面对两个内容一模一样的请求时,靠什么判断"这俩是同一个请求"?

2.1 核心本质:关键字段的唯一标识

答案是对请求打上唯一标识。每个业务请求都携带一个全局唯一的"请求ID"(或者用业务自身的唯一键,比如订单号、交易流水号),服务端在处理前先检查这个ID是否已经处理过。处理过就直接返回上一次的结果,没处理过才真正执行逻辑。

打个比方,这就好比小区门禁系统。每个住户有一张唯一的门禁卡,刷卡进门时,门禁系统会记录"卡号12345在14:03已进入"。如果这个人刷完卡又在门口连着刷了三次,系统看到卡号已经存在记录,就不会再重复开门。幂等系统的"门禁记录",就是那张记录请求处理状态的表或存储。

2.2 两个前提条件:唯一键 + 处理状态

所有幂等方案都建立在两个基本前提上:

  • 能找到唯一标识:无论这个标识是客户端生成的UUID,还是业务订单号,它必须在整个业务周期内全局唯一。
  • 能记录处理状态:服务端必须保存"这个ID处理过没有、处理结果是什么"的信息,否则无法判断重复。

这两个条件缺一不可。很多幂等方案失效,问题就出在:要么唯一键选错了(比如用了一个会出现重复的字段),要么状态记录不完整(比如只记了"已处理"却没保存返回值,导致重放时无法返回正确结果)。

2.3 常见误区:只靠前端限制不等于幂等

很多人以为在前端做个防重复提交按钮、或者禁用按钮几秒,就解决幂等了。这完全不够。前端限制只能挡住"常规用户手滑点两次",但挡不住以下情况:

  • 客户端超时后自动重试
  • 网络层自身重发(比如TCP层重传、HTTP层的重试机制)
  • 消息队列消费者的自动重投
  • 恶意或异常脚本的重复调用

任何一个环节的重复,最终都汇聚到服务端接口上。所以,幂等保护必须做在后端接口层面,前端限制只是锦上添花的体验优化。

3. 方案一:数据库唯一约束与去重表——最朴素也最可靠的兜底

在所有幂等方案里,利用数据库的唯一索引来兜底,是我个人最推荐先做起来的方案。它不需要引入额外组件,依赖的是数据库本身的原子性保证,可靠度极高。

3.1 核心思路:让数据库替你"拒绝"重复插入

假设你要对一条业务记录做处理,比如"给订单归属用户加100积分"。你可以在积分流水表里设计一个字段biz_id(业务唯一ID),并给它加上唯一索引。处理逻辑变成:

  1. 先往积分流水表插入一条记录,biz_id= 本次操作的唯一ID。
  2. 如果插入成功,说明这个操作是第一次,继续执行业务逻辑。
  3. 如果插入时报了"唯一键冲突"错误,说明之前已经处理过了,直接返回上次结果,不再执行业务逻辑。

这样一来,"防重复"这个校验动作,交给了数据库的唯一索引去完成。相比"先查询再判断"的写法(查询和插入之间有空窗期,并发场景下可能同时通过),唯一索引是在数据库层面做的原子判断,不会有并发漏洞。

3.2 代码级别的实现示范

// 幂等表结构示例 // CREATE TABLE `idempotent_record` ( // `id` bigint(20) NOT NULL AUTO_INCREMENT, // `biz_id` varchar(64) NOT NULL COMMENT '业务唯一ID', // `request_body` text COMMENT '请求参数', // `response_body` text COMMENT '响应结果', // `status` tinyint(4) COMMENT '1=处理中 2=处理完成', // `create_time` datetime DEFAULT NULL, // PRIMARY KEY (`id`), // UNIQUE KEY `uk_biz_id` (`biz_id`) // ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; public Response handleWithDbIdempotent(Request request) { String bizId = request.getBizId(); try { // 1. 先插入幂等记录,状态为"处理中" idempotentRecordDao.insert(new IdempotentRecord(bizId, "PROCESSING")); } catch (DuplicateKeyException e) { // 2. 插入冲突,说明这个bizId已存在——重复请求 IdempotentRecord old = idempotentRecordDao.selectByBizId(bizId); if (old != null && "COMPLETED".equals(old.getStatus())) { // 已经处理完成了,直接返回上次处理结果 return JSON.parseObject(old.getResponseBody(), Response.class); } // 还在处理中,可以返回"正在处理,请稍后重试" return Response.buildProcessing(); } try { // 3. 真正执行业务逻辑:改订单状态、扣库存、加积分等 Response result = doBizProcess(request); // 4. 更新幂等记录为"已完成",并保存响应结果 idempotentRecordDao.updateStatusAndResponse(bizId, "COMPLETED", JSON.toJSONString(result)); return result; } catch (Exception e) { // 5. 处理失败,删除幂等记录或标记为失败,让后续重试能够重新执行 idempotentRecordDao.deleteByBizId(bizId); throw e; } }

注意第5步:业务处理失败时一定要把幂等记录删掉或标记为失败,否则会挡住重试请求。比如因为库存不足导致扣减失败,如果幂等记录还留在表里,客户端重试时只能拿到"处理中"或"已完成"的错误结果,永远无法重新尝试。

3.3 这一方案的优缺点

优点显而易见:

  • 实现简单,不引入新组件
  • 数据库唯一索引的可靠性非常高
  • 天然支持并发场景下的去重

缺点也不可回避:

  • 每次请求都要多一次插入操作,在高并发下对数据库有一定压力
  • 如果业务逻辑本身很复杂(比如涉及多张表),幂等记录表和业务表的事务一致性需要考虑
  • 一旦记录表数据量膨胀,需要定期归档清理

注意:在这个方案里,幂等表的插入操作和业务操作最好放在同一个数据库事务里,否则可能出现幂等记录插入了但业务执行失败、数据不一致的情况。如果做不到同一事务,就要用后面讲的分布式方案来补偿。

4. 方案二:状态机驱动的业务幂等——把"状态流转"变成天然的防重网

有些业务天然自带状态机属性,最典型的就是订单:待支付 → 支付中 → 已支付 → 已发货 → 已完成 → 已取消。这种场景下,可以用状态机来保证幂等。

4.1 核心思路:状态变更必须有合法路径

状态机的幂等思路是:每个状态变更操作,都要求当前状态必须处于某个合法的前置状态;如果当前状态不满足条件,直接拒绝处理。

拿支付回调举例。订单当前的合法状态是"待支付"或"支付中",此时收到"支付成功"的回调,允许把状态推进到"已支付"。如果订单已经处于"已支付"状态,又收到一次"支付成功"回调,状态机判断"已支付 → 已支付"不是合法流转,直接返回成功(因为结果已经实现了),但不再执行扣库存、加积分等业务动作。

用一条SQL来描述这种"乐观锁 + 状态校验"的写法:

UPDATE orders SET status = 'PAID', pay_time = NOW() WHERE order_id = #{orderId} AND status IN ('PENDING', 'PAYING');

如果这条UPDATE影响的行数为1,说明状态流转成功,本次请求是首个生效请求。如果影响行数为0,说明当前状态不在允许的前置状态集合里,代表之前已经处理过或状态已流转,直接返回即可。

4.2 为什么状态机方案效率高

相比"查一下状态再判断",这条UPDATE语句把"状态校验 + 状态更新"合并成了一个原子操作。数据库的行锁保证同一时间只有一个事务能成功更新这行数据,其他并发请求会等待锁或直接因影响行数为0而返回。

这样既避免了并发下的竞态条件,也减少了代码判断逻辑。很多交易系统的支付、退款接口,底层都是这个套路。比如退款接口,会要求订单状态必须是"已支付"才能发起退款;如果订单已经"已退款"再次收到退款请求,状态机直接拒绝执行。

4.3 状态机的设计要点

需要强调的是,状态机方案要求业务上存在状态流转的合法性约束。它不是凭空给你的接口加了一层判断,而是你主动梳理出业务状态的合法流转路径,然后严格落地。具体建议是:

  • 梳理出所有状态节点和合法流转边,绘制成状态表,让开发和测试都按这个表核对
  • 在代码里用一个枚举类或者状态流转配置表维护流转关系,不要散落在各处
  • 对非法流转的处理要明确:是直接拒绝,还是返回当前结果——一般建议直接返回当前状态对应的结果,因为调用方只是想要"最终结果",不关心过程

4.4 一个常用的补充手段:乐观锁版本号

状态机方案的变种,是给数据表加一个version字段,每次更新时校验版本号并递增:

// 更新前先查出版本号 Order order = orderDao.selectById(orderId); int oldVersion = order.getVersion(); // 更新时带上旧版本号 int count = orderDao.updateStatusByIdAndVersion( orderId, newStatus, oldVersion); // count == 1 表示更新成功;count == 0 表示版本已变化,需重试

本质上,version字段就是一种额外的"状态标识"。它的优点是适用范围更广,不局限于状态机;缺点是每次更新前要多一次查询,且需要处理"更新失败后重试"的逻辑。状态机方案和版本号方案经常组合使用,比如更新语句里既校验status又校验version,双重保险。

5. 方案三:Token机制与分布式锁——无状态接口的补票手段

前面两个方案都依赖业务数据表本身(比如订单、流水表)。但有些场景,业务操作的对象不体现在数据库记录上,或者操作的副作用没法用状态字段去标识,这时候就需要引入"独立的幂等令牌"机制。

5.1 Token机制:给每个操作预发一张"一次性票据"

Token方案的流程是这样的:

  1. 客户端发起请求前,先向服务端申请一个全局唯一的token(一般是个UUID)。
  2. 服务端把这个token存起来(通常用Redis,设置合理过期时间),然后返回给客户端。
  3. 客户端正式提交业务请求时,必须携带这个token。
  4. 服务端处理请求时,先检查token是否存在且未被使用。如果token有效,立即删除token(标记为已使用),然后继续执行业务;如果token已不存在(已被使用或已过期),直接拒绝本次请求。

这个过程有点像游乐园的"快速通行证":进门发一张券,每次玩项目时检票员收走一张券,没券就不能玩。同一张券不可能用第二次,因为已经被收走了。

关键点在"检查并删除token"这个动作必须是原子操作。在Redis里通常用一条Lua脚本实现:

-- 检查并删除token,原子操作 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end

如果不用Lua脚本,先用GET判断再用DEL删除,在并发下会出现两个请求同时读到token都存在,然后都走了业务逻辑,幂等就失效了。

5.2 Token方案的适用场景与坑

适用场景典型的是:需要用户主动发起、且操作对象不是现成业务记录的场景。比如:

  • 提交一个复杂的报名表单
  • 点击一次"生成报告"按钮
  • 提交一次抽奖活动参与

这类场景的用户操作是"一次性动作",不好用订单状态去约束,但又要防止手滑连点。Token方案就非常合适。

常见的坑有哪些?

  • token过期时间设置不当:设置太短,用户填写表单时间长,提交时token已过期;设置太长,Redis里堆积无用token。一般建议结合业务操作的最长耗时去设定,比如30分钟到2小时之间。
  • token在业务失败时不要删除:如果业务处理失败(比如表单校验不通过),不应该把token删掉,否则用户改一下重新提交时拿不到token,就永远无法提交成功。只有业务真正成功了(或者明确不需要再重试时),才删除token。
  • 获取token的接口和执行业务的接口要分开防刷:恶意用户完全可以直接调用业务接口而不先获取token。所以业务接口里必须校验token有效性,不能依赖"获取token那个接口做了限流"来保护。

5.3 分布式锁:更通用的"互斥 + 去重"手段

分布式锁和Token机制经常被混淆,但两者的侧重点不同。分布式锁的核心是互斥:保证同一个业务ID的请求只能有一个在执行,其他的等锁释放后再重试或直接返回。

比如用Redis的SET NX EX命令实现锁:

SET lock_key:{orderId} "locked" NX EX 30

如果这个命令返回OK,说明拿到了锁,可以执行任务;如果返回空,说明别人正拿着锁,本次请求等待或返回"处理中"。处理完成后删除锁键。

在实际项目里,分布式锁通常和前面的数据库方案配合使用:用分布式锁挡住并发,用唯一索引或状态机做最终兜底。因为锁本身有超时时间、有不可靠的Master节点切换问题,不能完全替代数据库层面的约束。

经验之谈:不要把分布式锁当作唯一的幂等防线。我见过不少项目里Redis锁过期导致两个请求同时执行业务,最后还是靠数据库唯一索引兜住了。锁只是降低冲突概率,数据库约束才是最后的安全网。

6. 方案四:消息队列场景下的消费幂等——从消息ID到业务键的演进

幂等问题在消息队列场景里尤其容易出现。几乎所有的MQ都承诺"at least once"(至少一次)投递语义,也就是说消息可能在网络抖动、消费者宕机重连后重复投递。生产者的重试也会导致同一业务事件产生多条重复消息。所以,消费端的幂等设计是异步架构里必须做扎实的环节。

6.1 消费幂等的三种常见手段

第一种:依赖消息自带的唯一ID去重。很多MQ框架在消息头里带一个全局唯一的msgId,消费者把已消费的msgId存到Redis(用SETNX命令),每次消费前先SETNX,返回1才处理。

这种方案实现起来最快,但要小心两个问题:一是msgId在消息重投时会不会变化(有些MQ重投后msgId不变,有些会变,务必确认);二是如果消息里的msgId是唯一的,但业务上需要"防止重复发货",光靠msgId就不够,还得看业务数据。

第二种:用业务唯一键做去重。举个例子,用户下单成功后会发一条"创建订单"消息,这个业务里最自然的唯一键就是orderId。消费者处理前,先往Redis SETNX一个键consume:order:{orderId},返回值是1就继续处理,否则说明已经消费过。

这种方式比msgId更贴近业务:即使MQ重新生成了消息头,只要业务键不变,去重依然有效。我个人的实践是优先用业务键,msgId只作为辅助手段。

第三种的思路是在业务数据表上做文章,比如第3节讲的唯一索引方案。比如消费者要把消息内容写入某张流水表,直接在流水表上建立业务唯一索引,重复插入自然失败。

6.2 消费端幂等的完整伪代码

# 使用业务键 + Redis 去重 def consume_order_message(message): order_id = message["order_id"] # 1. 检查是否已消费,key 有效期为 24 小时 if not redis_client.setnx(f"consume:order:{order_id}", "1"): print(f"重复消息,直接丢弃: {order_id}") return redis_client.expire(f"consume:order:{order_id}", 86400) try: # 2. 真正处理消息:更新订单状态、扣库存等 process_order(order_id) except Exception as e: # 3. 处理失败,删除去重键,让消息重投时能重新处理 redis_client.delete(f"consume:order:{order_id}") raise e

注意到第3步:消息处理失败后,一定要把去重键删掉。否则消息队列重投时,去重键还在,消息会被直接丢弃,造成数据丢失。这个细节很多人容易漏掉,导致出现"明明重试了却还是缺数据"的诡异问题。

6.3 消费幂等里的一个额外挑战:重复消费 + 并发消费

如果消息队列开启了多消费者并发消费,同一个业务键的消息可能在两台机器上同时被消费。这时候Redis的SETNX能拦住一个,但存在极端情况:键还没设置成功,两个消费者同时执行到SETNX——其实SETNX本身是原子性的,不会两个都成功。但如果是"先GET检查、再SET"这种写法,就会被并发击穿。所以务必用原子操作。

另外,处理耗时比较长的场景下,A机器处理到一半,Redis键意外过期,B机器也能SETNX成功,造成重复处理。遇到这种情况,建议把业务处理去重键的过期时间设置得比最长处理时间宽裕得多,或者改用数据库唯一索引做兜底。

7. 主流方案横向对比与选型思路

前面介绍了几种方案,很多人看着看着就纠结起来:"到底用哪个?"我的回答是:别把它们当单选题,幂等设计通常是多方案组合。

先看看各方案的核心差异:

方案依赖组件典型代码量并发安全性适用范围注意事项
数据库唯一约束关系数据库低高(靠索引)有明确业务ID的写入类操作需处理冲突后的查询逻辑
状态机 + 乐观锁数据库低高(靠行锁)订单、审批等状态流转业务需先梳理合法状态流转
Token机制Redis/本地缓存中中(需Lua保证原子性)一次性用户动作token过期时间需调优
分布式锁Redis等中中(锁有超时风险)并发互斥场景需要可靠的删除锁机制
消息消费去重Redis/MQ低中(需使用原子命令)异步消息处理失败时需删除去重键

综合来看,常见的选型思路可以归纳成下面几条:

  • 写接口防重首选唯一约束或状态机:业务操作落库有明确ID的,优先用唯一索引或状态机,因为数据库方案最可靠,兜底能力最强。
  • 一次性动作接口用Token:没有天然业务ID、又是用户主动触发一次性操作时,用Token方案。
  • 并发量大的入口可加锁:在核心入口前用分布式锁挡住并发风暴,但别忘了数据库兜底。
  • 消息消费者用业务键去重:处理MQ消息优先使用业务唯一键做SETNX去重,并处理好失败删除逻辑。
  • 关键资金类操作建议组合:比如支付回调的接口,可以在入口做分布式锁防并发,在业务表上做状态机流转,再配合流水表唯一索引。三道防线各司其职,出现任何一环失效都能被其他环节兜住。

8. 踩坑实录:三个我见过的高频故障模式

光讲原理还不行,得说说实际项目中常踩的坑。以下三种故障模式,我在不同团队里至少各见过一次,希望各位能引以为戒。

8.1 幂等记录已插入,但业务执行失败导致"永久死锁"

第3节的代码里强调了业务失败时要删除幂等记录。但很多人的实现里并没有这一步。当时某个系统的注册接口就是这样:用户第一次点击注册,服务端插入幂等记录后,调用积分服务超时,整个注册流程抛异常。按理说应该失败重试,但幂等记录表里已经有这条记录,用户再怎么点注册,服务端都返回"已注册,请直接登录"。可用户实际上根本没有注册成功。

后来怎么解决的?把"插入幂等记录"和"执行业务逻辑"放进同一个数据库事务,业务失败则事务回滚,幂等记录一并消失。这是最干净的解法。

8.2 Token方案里,删除Token和执行业务不是原子的

在某个活动报名系统里,开发用的是"先删除Token,再执行报名逻辑"。结果删除成功、报名逻辑执行前服务突然重启,Token已经没了,用户重试时永远无法报名。这个问题的根因在于"删除Token"作为前置动作太早执行——如果业务还没做完,Token的使命就没有完成,不应该提前销毁。

正确的做法是把Token的消耗动作放在业务成功之后,或者至少在业务幂等保护之内。更好的做法是先用Redis Lua脚本把"删除Token + 返回业务处理结果"放到一个事务里,或者接受"业务失败不删Token,仅标记为已使用但允许重试"的方案。

8.3 分布式锁过期导致两个线程同时扣库存

某团队做秒杀系统,用Redis的SETNX锁来保护库存扣减。单个请求处理时间正常情况下是100毫秒,于是锁的超时时间设成了5秒。某天大促,数据库突然慢查询,一个扣库存请求处理了6秒才结束——锁在第5秒过期了,另一个请求拿到锁也进来了,两个请求同时扣减库存,最终超卖。

这种问题的根源是:锁的持有时间和业务处理时间之间,永远存在不确定的间隙。解决思路有几种:

  • 把锁的过期时间设置得足够长,并且使用看门狗机制不断续期(很多分布式锁框架都已经支持)
  • 即使锁过期,也要用数据库乐观锁(如库存 > 0条件更新)做最终兜底
  • 把"扣库存"改成数据库原子操作,比如UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0

别把分布式锁当成银弹,它只是"降低并发碰撞概率"的一层辅助设施,真正保住数据正确性的,永远是底层数据库约束。

9. 幂等设计的前置梳理:动手编码前先回答四个问题

很多人上来就开始写代码,结果写出来的方案要么缺胳膊少腿,要么过度设计。我建议在动工之前,把下面四个问题想清楚。

第一,这个接口是否真的需要幂等?不是所有接口都需要。纯查询接口天然幂等,读多写少的状态上报接口也可以不做。需要做幂等的是那些会产生副作用的写操作:扣款、发券、扣库存、改状态、发消息。

第二,操作有没有天然的唯一业务ID?如果有,优先用业务ID做幂等键(比如orderId、userId+sceneId组合)。如果没有,就考虑Token方案,让服务端主动发唯一标识。

第三,重复请求到达时,系统应该怎么回应?是把第一次的结果原样返回,还是提示"正在处理中"?这两种策略在实现上会有细微差别。如果业务结果已经产生并能查询到,直接返回结果;如果第一次请求还在处理中,一般返回"处理中"状态,让客户端稍后查询。

第四,幂等服务挂了怎么办?比如Redis不可用,Token方案就面临"无法判重"的局面。这时候是降级为放行(可能导致重复),还是直接报错(保证不重复但降低可用性)?这个取舍必须有预案,常见做法是把Redis的判重降级为数据库唯一索引兜底,宁可慢一点也不能错。

想清楚这四个问题,你会发现选型根本不用纠结——需求自然会把你推到最合适的方案上去。

10. 实测下来最稳的组合拳与收尾心得

说了这么多,最后给一套我个人在多个项目里验证过、比较省心的默认组合。

对于订单、支付、退款这类关键资金链路,我一般这样做:

  • 数据库表上建立biz_id唯一索引,作为最终兜底。
  • 业务状态用状态机维护合法的流转路径,不合法流转直接返回当前结果。
  • 接口入口处加上基于Redis的分布式锁,降低并发冲突概率。
  • Redis不可用时,降级策略是"直接走到数据库唯一索引兜底",不做额外放行。

对于消息消费者,我这样做:

  • 用业务键 + Redis SETNX做第一层去重。
  • 在最终写入的流水表上建唯一索引做第二层兜底。
  • 消费失败时,删除Redis去重键并抛异常,让MQ按规则重投。

对于一次性表单/按钮类操作,我用Token方案:先取Token,提交时用Lua脚本原子地"检查 + 删除",业务失败不删Token。

这套组合看起来不花哨,但每一层都有明确职责:锁管并发,状态机管流转,唯一索引管最终一致性。与其追各种花哨的新方案,不如把这几层扎扎实实落地。

最后分享一点个人体会。幂等设计这件事,很多时候拼的不是技术难度,而是对业务的细致程度——你愿意花多少时间去梳理状态流转、去识别关键业务键、去设计异常分支的善后逻辑。任何一个看似不起眼的异常分支没处理干净,都可能在生产环境演变成一次数据事故。希望这篇能把幂等性设计的思路讲透,至少能让你在下一次面对"这个接口万一被重复调用怎么办"的问题时,心里有一套清晰的方案地图。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进