文章

高并发下如何保证接口的幂等性?

高并发下如何保证接口的幂等性?

接口幂等性问题,对于开发人员来说,是一个跟语言无关的公共问题。本文分享了一些解决这类问题常见的办法

接口幂等性和使用场景

幂等性定义

不知道你有没有遇到过这些场景:

  • 有时我们在填写某些form表单时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。
  • 我们在项目中为了解决接口超时问题,通常会引入了重试机制。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据。
  • mq消费者在读取消息时,有时候会读取到重复消息,如果处理不好,也会产生重复的数据。

没错,这些都是幂等性问题。

接口幂等性是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

简单来说,就是:$f(x) = f(f(x)) = f(f(f(x))) = \dots$

它主要关注的是对系统状态的影响。如果一个操作多次执行后,系统的状态与执行一次后系统的状态是相同的,那么这个操作就具有幂等性。

这类问题多发于接口的:

  • insert操作,这种情况下多次请求,可能会产生重复数据。
  • update操作,如果只是单纯的更新数据,比如:update user set status=1 where id=1,是没有问题的。如果还有计算,比如:update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。

需要幂等性的场景

在分布式系统、网络通信和高并发场景中,接口幂等性是非常重要的,它主要用于解决以下问题:

  • 网络抖动或超时重试:
    • 客户端发起请求后,可能因为网络原因没有收到服务端的响应,或者收到超时通知。
    • 此时,客户端通常会进行重试。如果没有幂等性保障,重试操作可能导致业务逻辑被重复执行(例如:重复扣款、重复创建订单)。
  • 用户误操作:
    • 用户在支付或提交表单时,可能会因为心急或其他原因重复点击提交按钮。
  • 消息队列重复消费:
    • 在使用消息队列时,如果消费者在处理完消息后,提交消费确认(ACK)失败,消息队列可能会重新投递该消息,导致业务逻辑重复执行。

防重和幂等区别

防重设计 $\neq$ 幂等设计

  • 防重设计:只要求不产生重复数据(不产生副作用)。(如:重复转账只扣款一次,后续操作返回失败或成功都可)。
  • 幂等设计:要求不产生副作用,并且每次请求返回的结果都一致(通常是返回第一次成功的响应)。

防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果

实现接口幂等性的方式

唯一性校验

唯一性校验: 关注的是结果(数据状态), 都是通过检查某个业务上的唯一标识(而不是仅是数据库的主键 ID)来判断当前请求是否为重复操作。

  • 通过强制要求数据的最终状态满足唯一性约束来实现幂等。依赖于数据库或存储系统的底层能力来保证数据状态的唯一性。
  • 确保数据在持久化后不会出现重复。例如: 数据库中不能有两条订单号相同的记录。
  • 擅长解决重复创建的问题

无论在哪张表上、用什么方式,只要是依赖于某个业务字段的唯一性来判断重复,都归类为 唯一性校验 的实现。

插入前重复数据查询

通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert前,先根据namecode字段select一下数据。如果该数据已存在,则执行update操作,如果不存在,才执行insert操作。

插入前重复数据查询流程

该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。

加唯一索引

绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。

1
alter table `order` add UNIQUE KEY `un_code` (`code`);

加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code异常,表示唯一索引有冲突。

虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。

如果是Java程序需要捕获:DuplicateKeyException异常,如果使用了Spring框架还需要捕获:MySQLIntegrityConstraintViolationException异常。

具体流程图如下:

唯一索引流程

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。
  2. 将该数据插入数据库
  3. 判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑)。
  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

使用防重表

有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。

针对这种情况,我们可以通过建防重表来解决问题。

该表可以只包含两个字段:id唯一索引,唯一索引可以是多个字段比如:namecode等组合起来的唯一标识,例如:username_200

具体流程图如下:

使用防重表的流程

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。
  2. 将该数据插入防重表
  3. 判断是否执行成功,如果成功,则做其他的数据库操作(可能还有其他的业务逻辑)。
  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。

通过锁实现

基于锁的控制:关注的是过程(并发执行),保证操作的原子性和可见性。

  • 通过限制代码块的并发访问来实现幂等。通过控制线程或进程的访问权限来序列化操作,
  • 确保核心业务逻辑不会被并发执行,或者只在特定条件下执行。 例如:只有获得锁的那个线程能进入 “扣款逻辑” 这个代码块
  • 擅长解决重复计算复杂状态流转的问题

加悲观锁

在支付场景中,用户A的账号余额有150元,想转出100元,正常情况下用户A的余额只剩50元。一般情况下,sql是这样的:

1
update user amount = amount-100 where id=123;

如果出现多次相同的请求,可能会导致用户A的余额变成负数。这种情况,用户A来可能要哭了。于此同时,系统开发人员可能也要哭了,因为这是很严重的系统bug。

为了解决这个问题,可以加悲观锁,将用户A的那行数据锁住,在同一时刻只允许一个请求获得锁,更新数据,其他的请求则等待。

通常情况下通过如下sql锁住单行数据:

1
select * from user id=123 for update;

悲观锁的执行流程

具体步骤:

  1. 多个请求同时根据id查询用户信息。
  2. 判断余额是否不足100,如果余额不足,则直接返回余额不足。
  3. 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
  4. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
  5. 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
  6. 如果余额不足,说明是重复请求,则直接返回成功。

需要特别注意的是:如果使用的是Mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。

悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。

此外,悲观锁每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。

防重设计和幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果

加乐观锁

既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个timestamp或者version字段,这里以version字段为例。

在更新数据之前先查询一下数据:

1
select id,amount,version from user id=123;

如果数据存在,假设查到的version等于1,再使用idversion字段作为查询条件更新数据:

1
2
update user set amount=amount+100,version=version+1
where id=123 and version=1;

更新数据的同时version+1,然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。

由于第一次请求version等于1是可以成功的,操作成功后version变成2了。这时如果并发的请求过来,再执行相同的sql:

1
2
update user set amount=amount+100,version=version+1
where id=123 and version=1;

update操作不会真正更新数据,最终sql的执行结果影响行数是0,因为version已经变成2了,where中的version=1肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

乐观锁执行流程

具体步骤:

  1. 先根据id查询用户信息,包含version字段
  2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
  3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
  4. 如果影响0行,说明是重复请求,则直接返回成功。

加分布式锁

其实前面介绍过的加唯一索引或者加防重表,本质是使用了数据库的分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,对于一些简单的计数或状态设置操作,可以直接利用 Redis 的原子性命令来实现幂等性

目前主要有三种方式实现redis的分布式锁:

  • setNx(SET if Not eXists)命令
    • 在尝试执行业务操作前,使用 SETNX key value 命令。只有当 key 不存在时才会设置成功并返回 1
    • 如果返回 0,则说明该操作已被执行过,直接返回成功。
    • 适用场景: 只需要记录操作是否执行过的简单场景。
    • 解锁: 客户端执行 DEL lock_key

    该方案不能直接使用,因为它存在一个严重的死锁风险, ETNXEXPIRE 无法保证原子性, 客户端 A 执行 SETNX lock_key 成功获取锁,但在执行 EXPIRE lock_key timeout 设置过期时间之前,程序崩溃或网络中断。lock_key 成为一个永不过期的死锁,其他客户端永远无法获取锁。

  • set命令

    为了解决 SETNX 的非原子性问题,Redis 在 2.6.12 版本中引入了扩展的 SET 命令,允许将 设置值 (SET) 和 设置过期时间 (EXPIRE) 合并成一个原子操作。

    加锁: 客户端执行以下原子命令:

    1
    
      SET lock_key unique_value NX EX timeout_seconds
    
    • unique_value:客户端的唯一标识(如 UUID),用于防止误删。
    • NX:只在 key 不存在时才设置成功(保证互斥)。
    • EX timeout_seconds:设置过期时间(防止死锁)。

    解锁: 客户端执行下面的 Lua 脚本

    1
    2
    3
    4
    5
    
      if redis.call('GET', KEYS[1]) == ARGV[1] then 
      return redis.call('DEL', KEYS[1]) 
      else 
          return 0 
      end
    
    • 直接使用 DEL lock_key 存在误删风险(锁过期后被其他客户端持有,但原客户端 DEL 掉)。
    • 正确的做法是,先判断 lock_key 存储的 unique_value 是否与当前客户端的标识一致,只有一致才执行 DEL。Lua 脚本保证了 “读取-判断-删除” 的原子性。
  • Redission框架

    Redisson 是一个基于 Redis 的分布式、可重入锁的 Java 框架,它封装了复杂的 Redis 命令,提供了与 Java java.util.concurrent.Lock 接口一致的 API。

    Redisson 在底层仍是基于 SET 命令 + Lua 脚本,但它解决了更复杂、更实际的生产问题:

    • 可重入性 (Reentrancy): 允许持有锁的线程在不释放锁的情况下,再次获取该锁(通过 Hash 结构和计数器实现)。
    • 锁续期 (Watchdog): Redisson 实现了看门狗机制。客户端成功获取锁后,如果业务执行时间超过了锁的过期时间,看门狗会自动延长锁的过期时间,直到客户端主动释放锁。这解决了锁设置过期时间过短,导致任务未完成锁就释放的问题。
    • RedLock 算法: Redisson 内置实现了 RedLock 算法,用于在 Redis Cluster 或 Sentinel 等多节点部署下,保证锁的高可用性和最终一致性。
  • INCR/DECR 命令(轻量级并发控制,非标准分布式锁)

    INCRDECR 命令通常用于实现计数器或限流,它们本质上是利用 Redis 的单线程原子性来进行并发控制,但它们不适合作为标准的分布式互斥锁。

    仅适用于需要确保某个值只被修改一次的场景,但比乐观锁更轻量级。例如,某个任务只能被处理一次,可以用一个 Redis key 记录处理次数,只有当次数为 0 时才允许 INCR

    并发控制:

    • 客户端尝试执行 INCR key
    • 如果返回值等于 1,则代表该客户端是第一个执行操作的,可以执行核心业务逻辑。
    • 如果返回值大于 1,则说明该操作已被执行过,应拒绝或视为重复请求。

    释放/重置:

    业务完成后,需要执行 DEL keyEXPIRE key 来重置计数器,以便下一次操作可以再次抢占。

具体流程图如下:

分布式锁流程

具体步骤:

  1. 用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。
  2. 使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。
  3. 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。
  4. 如果设置失败,说明是重复请求,则直接返回成功。

需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况而定。

根据状态机

很多时候业务表是有状态的,比如订单表中有:1-下单2-已支付3-完成4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。

假如id=123的订单状态是已支付,现在要变成完成状态。

1
update `order` set status=3 where id=123 and status=2;

第一次请求时,该订单的状态是已支付,值是2,所以该update语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了3。 后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了3,再用status=2作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。但为了保证接口幂等性,影响行数是0时,接口也可以直接返回成功。

具体流程图如下:

状态机执行流程

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。
  2. 根据id当前状态作为条件,更新成下一个状态
  3. 判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作。
  4. 如果影响了0行,说明是重复请求,直接返回成功。

主要特别注意的是,该方案仅限于要更新的表有状态字段,并且刚好有更新状态字段的这种特殊情况,并非所有场景都适用。

单次请求token

除了上述方案之外,还有最后一种使用一次性token的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。

  1. 第一次请求获取token
  2. 第二次请求带着这个token,完成业务操作。

具体流程图如下:

第一步,先获取token。

获取token流程

第二步,做具体业务操作。

执行幂等业务流程

具体步骤:

  1. 用户访问页面时,浏览器自动发起获取token请求。
  2. 服务端生成token,保存到redis中,然后返回给浏览器。
  3. 用户通过浏览器发起请求时,携带该token
  4. 在redis中查询该token是否存在,如果不存在,说明是第一次请求,做则后续的数据操作。
  5. 如果存在,说明是重复请求,则直接返回成功。
  6. 在redis中token会在过期时间之后,被自动删除。

以上方案是针对幂等设计的。 如果是防重设计,流程图要改改:

执行防重业务流程

需要特别注意的是:token必须是全局唯一的。

前端防御机制(不可靠)

虽然幂等性主要由后端保证,但前端的防御可以有效减少重复请求的产生,降低后端的压力。

  • 按钮防抖(Debouncing)/节流(Throttling): 在用户点击提交按钮后的一小段时间(例如 5 秒)内,禁用该按钮或忽略后续的点击事件。
  • 加载状态: 在请求发送后,立即显示加载状态(如 loading 动画),直到请求返回结果才解除锁定。

前端防御机制属于优化措施,不能作为核心幂等性保障,因为恶意请求或系统重试仍然会绕过前端。

不同场景的实现方法选型

在实际项目开发中,建议按以下优先级组合使用:

下面是一些典型的接口防重和幂等场景:

序号场景类型典型接口描述核心问题推荐方案示例说明
1新增/创建用户注册、订单创建、帖子发布客户端重复点击,导致数据重复插入。唯一性校验 (唯一索引 / 防重表) 或 Token 机制唯一索引:在订单号 (order_code) 字段上加唯一索引,重复插入时抛异常,捕获后返回成功。
2支付/扣款确认支付接口 (/pay)支付结果未返回,重试导致重复扣款。分布式锁 & 状态机分布式锁:以 订单ID 为 Key 加锁,确保同一时刻只有一次扣款操作进入业务。
3库存增减购买商品,库存扣减网络超时重试或消息重复消费,导致库存超卖或重复扣减。乐观锁“乐观锁:在库存表加 version 字段,通过 UPDATE stock SET count = count - 1version = version + 1 WHERE id = 1 AND version = old_version 来保证幂等。”
4异步消息消费MQ 消费者处理订单完成消息MQ 机制(如 ACK 失败)导致消息重复投递。消息 ID 校验 (基于 Redis 或防重表)Redis 校验:提取消息中的 Message ID,尝试将其 SETNX 到 Redis,成功则处理,失败则丢弃。
5状态扭转订单确认收货、任务审核任务状态必须按特定顺序流转(如 1→2→3)。状态机流转限制状态机:UPDATE order SET status = 3 WHERE id = 123 AND status = 2。如果状态已经是 3,该更新操作将不生效(影响 0 行)。
6用户表单提交用户信息修改、复杂表单提交用户快速重复点击“提交”按钮。Token 机制 (前端防重 + 后端校验)Token 机制:用户进入页面先获取 Token,提交时携带 Token,后端校验并删除 Token。若 Token 不存在,则为重复提交。

💡 方案选择原则总结

选择哪种方法,主要取决于操作的类型和对性能、复杂度的要求:

  1. 针对 INSERT (新增) 操作:
    • 简单且推荐: 使用唯一索引。这是最简单、最可靠、最符合数据库原子性要求的方法。
    • 业务逻辑复杂/前后端分离: 使用 Token 机制。将校验提前,避免复杂业务逻辑重复执行。
    • 不适合: 乐观锁(因为它主要用于更新)。
  2. 针对 UPDATE (更新/计算) 操作:
    • 数值计算型(如 $X=X+1$): 使用乐观锁。它在不阻塞的情况下,通过版本号保证了计算的唯一性。
    • 状态流转型(如订单状态): 使用状态机流转限制。利用业务状态的特性,实现天然幂等。
    • 重要且耗时操作: 使用分布式锁。确保操作的互斥性和原子性,尤其适用于支付、资源分配等核心场景。
  3. 针对 消息队列去重:
    • 首选: 消息 ID 校验(基于 Redis防重表)。这是异步场景下最常用的方式,高效且低延迟。

参考

本文由作者按照 CC BY 4.0 进行授权