高并发下如何做到接口幂等性

571 阅读13分钟

什么是接口幂等性

接口幂等性是指一个接口在被重复调用多次后,产生的结果是相同的,无论调用多少次,效果等同于只调用一次。幂等性是分布式系统和API设计中的一个重要概念,特别是在网络通信不可靠或可能出现重复请求的情况下。

接口幂等性的重要性

  1. 重试机制:在网络请求可能失败的情况下,客户端通常会重试请求。幂等性确保这些重试不会导致不一致的状态或重复的副作用。
  2. 数据一致性:保证数据在多次操作后保持一致,避免因重复请求导致的数据错误。
  3. 系统可靠性:提高系统的健壮性,减少因重复请求导致的错误。

产生幂等性问题原因

  1. 网络波动不稳定:网络通信中的丢包、延迟等情况可能导致客户端未收到服务端的响应或服务端未收到客户端的请求,此时客户端可能会重试发送请求,导致接口被重复调用。
  2. 用户快速重复点击导致:例如用户在等待响应时,由于不确定是否操作成功,可能会多次点击提交按钮,进而发送多次相同的请求。
  3. 定时任务:在定时任务中如果定时任务调度或逻辑设计不当,可能会导致同一任务被执行多次。
  4. 消息队列消息重复:消息队列可能会因为网络问题或服务端处理问题导致消息被重复消费。
  5. 并发控制:缺乏有效的并发控制手段,导致在并发环境下,针对同一资源的操作被多次执行。

实现接口幂等之悲观锁

实现机制

  1. 锁定资源

    • 当一个事务需要对某个资源进行操作时,首先获取该资源的锁。这个锁可以是数据库层面的行锁或表锁。
    • 其他事务在尝试访问同一资源时,会被阻塞,直到锁被释放。这确保了只有一个事务能够修改资源,避免了并发修改导致的数据不一致。
  2. 防止重复修改

    • 在更新操作中,悲观锁可以确保只有一个请求能成功修改资源,其他请求会等待或失败。这对于需要幂等性的操作非常重要,因为它防止了多次修改导致的状态变化。
  3. 事务的原子性

    • 通过使用悲观锁,确保整个事务在执行过程中资源的状态不会被其他事务改变,从而保持操作的原子性和一致性。

使用场景

  • 高并发环境:在高并发的环境中,悲观锁可以有效防止多个事务同时修改同一资源。
  • 强一致性要求:对于那些对数据一致性要求非常高的场景,悲观锁是一个合适的选择。

实现方式

  • 数据库行锁:许多关系型数据库(如MySQL、PostgreSQL)提供了行级锁,通过SELECT ... FOR UPDATE语句,可以在读取记录的同时锁定它,防止其他事务修改。
  • 表锁:在某些情况下,可能需要锁定整个表以防止并发修改。

注意事项

  • 性能影响:因为悲观锁会阻塞其他事务的执行,所以在高并发环境下可能会导致性能下降。
  • 死锁风险:如果多个事务相互等待锁释放,可能导致死锁,需要特别注意锁的获取顺序和超时处理。

实现接口幂等之乐观锁

乐观锁是一种用于控制并发访问的机制,它假设冲突是相对少见的,因此不对资源进行锁定,而是在更新时检测冲突。乐观锁通常使用版本号或时间戳来检测是否有其他事务在此期间修改了数据。

实现机制

  • 乐观锁通过在更新时检查数据的版本号或时间戳来确保数据的一致性。如果版本号或时间戳与预期不符,则说明数据在此期间已被修改,更新操作会失败。
  • 同时借助了防重表来实现幂等

实现方式

image.png

version 的作用

  • 冲突检测:版本号用于检测并发更新冲突,确保在更新数据时,数据没有被其他事务修改。
  • 数据一致性:通过比较版本号,确保只有未被修改的数据才能被更新。

transaction_id的作用

  • 请求跟踪:为每个请求分配一个唯一的transaction_id,用于识别请求。
  • 幂等性保障:在处理请求时,首先检查transaction_id是否已经存在。如果存在,说明该请求已经被处理过,可以直接返回成功响应,而无需重复执行操作。

仅靠版本号(version)并不能完全实现幂等性。版本号主要用于检测并发更新冲突,而幂等性需要确保即使同一个请求被多次处理,结果也是一致的。为了实现幂等性,还需要一个唯一的transaction_id来跟踪每个请求。

注意事项

  • 重试机制:由于乐观锁可能导致更新失败(版本号不匹配),需要设计重试机制。
  • 版本号管理:确保版本号或时间戳在每次更新时正确递增。

实现接口幂等性之唯一索引

这种适用范围有限,更多的用在插入数据的场景中

实现机制

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

实现方式

  • 通常给表中的业务主键加上唯一索引:alter table order add UNIQUE KEY un_code (code);

注意事项

  • 虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功(当然这取决于你的业务系统)。

实现接口幂等性之状态机

实现机制

  • 状态机可以通过明确管理请求的状态来实现接口幂等性。状态机的核心思想是将请求的处理过程分解为一系列明确的状态转换,从而确保每个请求只会按照预期的路径进行处理,即使请求被重复发送,也不会导致重复的操作。

实现方式

  1. 定义状态
  • 首先,为每个请求定义一组可能的状态,例如,对于一个支付请求,可能的状态包括:
    • INITIATED:请求已收到,但尚未处理。
    • PROCESSING:请求正在处理中。
    • COMPLETED:请求已成功处理。
    • FAILED:请求处理失败。
    • CANCELLED:请求被取消。

如果你的业务场景天然就是需要状态流转的,就不用通过请求的方式去定义状态了

  1. 设计状态转换
  • 定义每个状态之间的合法转换路径。例如:
    • INITIATED可以转换到PROCESSING
    • PROCESSING可以转换到COMPLETEDFAILED
    • INITIATEDPROCESSING可以转换到CANCELLED
  1. 实现状态管理
  • 设计 transaction 表(业务表支持状态流转就不用设计)
  1. 处理请求
  • 当接收到一个请求时,按照以下步骤处理:
    • 检查当前状态:查询数据库中该请求的当前状态。
    • 验证状态转换:根据当前状态和目标状态,验证状态转换是否合法。
    • 执行操作:如果状态转换合法,执行相应的业务逻辑。
    • 更新状态:在数据库中更新请求的状态。
  1. 幂等性保障
    • 重复请求处理:如果一个请求已经处于COMPLETEDFAILED状态,任何重复的处理请求都可以直接返回当前状态,而不重复执行业务逻辑。
    • 状态验证:通过严格的状态验证,确保只有合法的状态转换被执行,防止因重复请求导致的不一致。

注意事项

  • 对于非常复杂的流程,定义和管理状态机可能会变得复杂。需要频繁地查询和更新状态,可能会带来一定的性能开销。

实现接口幂等性之分布式锁

前面介绍的方式,本质是使用了数据库分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,我们可以改用:redis

实现机制

分布式锁的核心机制是确保在分布式系统中,某个特定资源在同一时间只能被一个请求访问或修改。这通常通过以下方式实现:

  1. 锁的获取:使用一个全局的锁服务来管理锁的获取和释放。常见的实现包括 Redis 的分布式锁(如 Redlock)、Zookeeper 的锁机制或基于数据库的锁。
  2. 锁的释放:确保在请求处理完成后,锁能够及时释放,以便其他请求可以继续处理。
  3. 锁的超时:设置锁的超时时间,以防止因请求失败或系统崩溃导致的死锁。

实现方式

  1. 获取锁
    • 在处理请求之前,尝试获取与该资源相关的锁。
    • 如果获取锁成功,继续处理请求;如果失败,则可以选择重试或返回错误。
  2. 处理请求
    • 在持有锁的情况下,执行请求的核心逻辑。
    • 确保在处理过程中检查请求是否已被处理过,以避免重复执行。
  3. 释放锁
    • 在请求处理完成后,确保锁被释放。
    • 可以通过显式释放或依赖锁的自动超时机制来实现。

附一张 Redis 实现分布式锁的原理图 image.png

注意事项

  1. 锁操作的原子性:确保锁的获取和释放是原子的,以防止由于网络分区或其他错误导致的锁状态不一致。
  2. 处理锁超时:设置合理的锁超时时间,确保在请求处理时间内锁不会过期。
  3. 故障恢复:设计系统以处理锁获取失败的情况,如重试机制或者降级处理。

总结

悲观锁

优点:

  • 数据一致性:通过锁定资源避免并发修改,确保数据一致性。
  • 简单性:逻辑上较为简单,容易理解。

缺点:

  • 性能影响:锁定资源可能导致线程阻塞,影响系统吞吐量。
  • 死锁风险:不当使用可能导致死锁。

适用场景:

  • 对数据一致性要求高的场景,如金融交易。
  • 适合并发不高的系统,或在关键操作上需要确保一致性时使用。

乐观锁

优点:

  • 高并发支持:不锁定资源,允许并行操作,只有在提交时检查冲突。
  • 提高性能:通过版本号或时间戳来检测冲突,减少锁争用,提高系统吞吐量。

缺点:

  • 冲突重试:在高冲突场景下,可能导致频繁重试,影响性能。
  • 实现复杂:需要设计冲突检测和重试机制,可能增加复杂度。

适用场景:

  • 适用于高并发但冲突较少的场景。
  • 适合数据更新冲突概率较低的环境,能有效提高系统吞吐量。

状态机

优点:

  • 清晰的状态管理:状态机通过定义明确的状态和状态转换路径,使得复杂业务流程的控制更加清晰。
  • 幂等性支持:通过状态检查,状态机能自然地支持幂等操作,避免重复处理。

缺点:

  • 实现复杂:对于非常复杂的流程,定义和管理状态机可能会变得复杂。
  • 性能开销:需要频繁地查询和更新状态,可能会带来一定的性能开销。

适用场景:

  • 适用于需要精确控制流程并且有多个状态的复杂业务场景,如订单处理、工作流引擎。
  • 需要对业务状态进行严格控制和监控的场景。

分布式锁

优点:

  • 全局一致性:能够在分布式系统中确保特定资源的全局唯一访问。
  • 简单直观:逻辑上简单,容易理解和实现。

缺点:

  • 性能开销:获取和释放锁需要网络通信,可能带来延迟。
  • 复杂性:需要处理锁的超时、失效和故障恢复等问题。
  • 可扩展性:在高并发场景下,可能成为系统瓶颈。

适用场景:

  • 需要严格控制资源访问顺序的场景,如分布式事务、库存扣减。
  • 确保同一时间只有一个操作能进行的业务逻辑。
  • 分布式锁适用于需要严格控制资源访问顺序的场景,尽管有性能开销,但能提供全局一致性。
  • 状态机适合复杂流程管理,提供清晰的状态管理和可扩展性,但实现复杂。
  • 悲观锁适用于高一致性需求的场景,简单易用,但可能导致性能瓶颈。
  • 乐观锁适合高并发、读多写少的场景,提供高性能支持,但需要处理冲突重试。

通常情况下都是组合使用几种方式组合来共同完成接口的幂等性。比如:

  1. 库存扣减接口:组合使用:分布式锁 + 乐观锁
  • 使用分布式锁:在扣减库存前,获取商品ID对应的分布式锁,确保同一时间只有一个请求能修改库存。
  • 使用乐观锁:在更新库存时,基于版本号或库存数量进行乐观锁检查,确保库存状态没有被其他操作修改。
  • 幂等性保证:如果扣减操作失败(如乐观锁冲突),可以重试。重复请求在获取锁后检查库存状态,确保不会重复扣减。
  1. 银行转账接口:悲观锁 + 状态机
  • 使用悲观锁:在转账操作中,锁定涉及的账户,确保账户余额的读取和更新操作是原子的。
  • 使用状态机:管理转账请求的状态(如已创建、处理中、已完成)。在转账完成后,更新状态为已完成。
  • 幂等性保证:如果转账请求重复到达,检查转账请求的状态。如果状态为“已完成”,则直接返回转账成功,避免重复转账。