什么是分布式幂等

核心定义: 在分布式系统中,一个接口或操作被多次执行所产生的影响,与仅执行一次所产生的影响是完全相同的。

简单来说: 无论客户端因为何种原因(如网络超时、服务抖动等)发起了多次重复的请求,服务器端都只处理一次真实的业务逻辑,并返回相同的结果。

为什么需要幂等
在分布式环境下,网络是不可靠的。一个常见的场景是:客户端调用一个服务接口后,没有及时收到响应(可能是网络延迟、请求已经到达服务器但处理耗时较长导致客户端超时等),客户端会尝试重试。如果这个接口不是幂等的,那么这次重试就可能导致数据被错误地重复处理(例如:重复扣款、重复创建订单、重复发放优惠券等)。

一个生动的例子:支付

  • 非幂等操作: 订单支付 接口。如果客户端连续调用两次 pay(订单ID),并且没有幂等控制,那么用户可能会被扣款两次。
  • 幂等操作: 查询订单状态 接口。无论你调用多少次 getOrderStatus(订单ID),它都不会改变订单的状态,只会返回相同的结果。这个操作天生就是幂等的。

幂等的关键点:

  1. 副作用: 幂等关注的是多次执行的副作用是否一致,而不仅仅是返回值。即使第二次请求返回操作已执行 而第一次返回成功,只要数据库状态和第一次执行后完全一致,这个操作也是幂等的。
  2. 客户端行为: 幂等性是服务端承诺给调用方的。服务端需要保证,即使调用方发送了重复请求,系统也是安全的。

分布式幂等的设计方案

设计幂等方案的核心思想是:让服务端能够识别出重复的请求。一旦识别出是重复请求,就直接返回上一次执行的结果,而不再执行业务逻辑。

方案一:Token 机制(或唯一标识机制)

这是最常用、最直观的方案。

流程:

  1. 获取 Token: 在执行业务操作之前,客户端先向服务端申请一个全局唯一的 Token(或叫流水号、幂等号)。这个 Token 通常与本次要操作的业务主体相关联(如订单ID)。
  2. 携带 Token 执行业务: 客户端调用业务接口时,必须携带这个 Token。
  3. 服务端校验 Token:
    • 服务端在处理请求前,先检查这个 Token 在系统中(通常是 Redis 或数据库)是否存在。
    • 不存在: 视为新请求。将 Token 写入存储(设置一个合理的过期时间),然后执行业务逻辑。
    • 已存在: 视为重复请求。直接丢弃请求,并返回上一次执行的结果。

关键点: 步骤 3 的检查 Token 是否存在并写入的操作必须是原子性的,通常使用 setnx(SET if Not eXists)指令的 Redis 来实现,防止并发场景下的误判。

方案二:数据库唯一约束

利用数据库的天然特性,适用于插入操作或带有唯一业务标识的场景。

流程:

  1. 设计带唯一索引的表: 在数据库表中,为某个或某几个字段建立唯一索引。这个唯一索引应能代表一次唯一的业务操作(例如:订单ID + 操作类型)。
  2. 执行业务逻辑: 在事务中,先执行核心业务数据的更新/插入,然后向一张防重表插入一条记录,这条记录的关键字段就是那个唯一索引。
  3. 处理重复请求:
    • 如果是新请求,防重表插入成功,事务提交。
    • 如果是重复请求,在插入防重表时,会因为唯一索引冲突而失败,导致事务回滚。此时服务端捕获这个异常,直接返回上一次的结果即可。

例子: 防止重复创建订单。可以为 order_id 字段建立唯一索引。当尝试插入两个相同 order_id 的订单时,第二个插入操作会失败。

方案三:悲观锁

在业务处理阶段直接加锁,阻止并发执行。

流程:

  1. 基于业务标识加锁: 在开始处理业务逻辑前,先根据请求中的业务唯一标识(如订单ID)获取一个分布式锁。
  2. 执行业务: 获取锁成功后,执行后续业务逻辑。
  3. 释放锁: 业务逻辑执行完毕后,释放锁。

效果: 对于同一个业务标识的请求,即使同时到达,也只有一个请求能获得到锁并执行业务,其他请求会被阻塞,直到锁释放。当它们获得锁后,可能业务状态已经改变(例如订单已支付),可以通过检查状态来实现幂等。

注意: 这种方式性能开销较大,通常用于需要强一致性且并发量不极高的场景。

方案四:乐观锁

基于数据版本(version)的机制,适用于更新操作。

流程:

  1. 数据带版本号: 在业务数据表中增加一个 version 字段(整数或时间戳)。
  2. 更新时校验版本: 更新数据时,在 SQL 语句中加上版本条件。
    1
    2
    UPDATE table_name SET amount = new_amount, version = version + 1 
    WHERE id = #id# AND version = #old_version#;
  3. 判断更新结果:
    • 如果这条 SQL 执行后影响的行数为 1,说明是第一次请求,更新成功。
    • 如果影响的行数为 0,说明在本次请求处理期间,数据已经被其他请求修改过了(version 值已变化),本次请求就是重复的或过期的。此时可以查询当前数据并直接返回。

例子: 更新账户余额。每次更新都带上本次操作基于的版本号,如果期间有其他人更新过,则本次更新会失败,从而避免覆盖他人的更新,也实现了幂等。

方案五:状态机幂等

使业务逻辑本身支持幂等,通过限制状态流转路径来实现。

流程:

  1. 定义状态流转: 业务数据有一个明确的状态字段(如订单状态:待支付 -> 已支付 -> 已发货),并且状态只能向前流转,不可逆(或严格按定义流转)。
  2. 执行业务前校验状态: 在处理业务请求时,先检查当前业务数据的状态。
    • 如果状态已经是目标状态,说明请求已处理过,直接返回成功。
    • 如果状态是允许流转的上一个状态,则正常处理。
    • 如果状态不符合流转规则(如从 待支付 直接变为 已发货),则返回错误。

例子: 支付成功后,订单状态从 待支付 变为 已支付。如果收到重复的支付成功通知,服务端检查到状态已经是 已支付,则直接返回成功,不再执行扣款等逻辑。


常用的方案

在实际生产中,最常用的方案组合是:

  1. Token 机制 + 数据库唯一约束

    • Token 机制 用于对外接口的API层防重,特别是面向用户交互的场景(如前端表单提交)。它是防御重复请求的第一道防线。
    • 数据库唯一约束 是底层最后的、最可靠的保障。即使API层的防重因为某种原因失效(如Redis宕机后Token丢失),数据库的唯一索引也能确保数据不会错乱。这是一种防御性编程的思想。
  2. 乐观锁

    • 在系统内部服务间的调用,尤其是对数据更新操作时,乐观锁 非常常用。它实现简单,在并发不高的情况下性能很好,能很好地解决更新丢失问题并实现幂等。
  3. 状态机幂等

    • 只要业务逻辑天然有状态流转,状态机幂等 是必选项。它不仅是实现幂等的手段,更是保证业务逻辑正确性的核心设计。

总结对比表

方案 原理 适用场景 优点 缺点
Token 机制 通过唯一Token识别请求 所有创建、更新操作,尤其面向用户的前端接口 通用性强,理解简单 需额外一次获取Token的调用;需依赖外部存储(如Redis)
数据库唯一约束 利用数据库唯一索引 数据插入场景,或有唯一业务号的场景 实现简单,可靠性高(数据库保证) 仅限于防重插入;索引冲突会抛异常
悲观锁 处理前先加锁 对数据一致性要求极高,且并发量不大的场景 保证强一致性 性能差,有死锁风险
乐观锁 通过版本号控制更新 数据更新场景 实现简单,性能较好 高并发下失败率高,需重试机制
状态机幂等 校验业务状态流转 有明确状态流转的业务(如订单、流程) 业务逻辑自洽,天然幂等 仅限于有状态变化的业务

在实际系统中,通常会根据具体业务场景组合使用多种方案,例如:API网关用 Token 机制做第一层防护,业务服务内部用状态机+乐观锁做第二层防护,数据库用唯一索引做最终防护。