← 返回列表

阅读 —下载 —

幂等性设计


一、什么是幂等性?

同一个操作,执行一次和执行多次,对系统产生的最终结果完全一致。

理解它要注意两点本质:

  • 幂等约束的是**"最终状态"**,不是"请求次数"。哪怕请求来了 100 次,只要最终状态唯一确定,就是幂等的。
  • 幂等 ≠ 防重。防重是"拦掉重复请求",幂等是"重复请求来了也不会出错"。前者是手段,后者是目标。

二、为什么需要保持幂等?

因为分布式系统里,重复几乎无法避免:

  • 用户重复提交:手抖、连点、网络超时后重试,同一个请求被发出多次;
  • 第三方回调重发:支付宝、微信在收到 failure 或超时后会重新发送通知;
  • 消息队列重复投递:MQ 一般是至少一次投递(at-least-once),同一条消息可能被消费多次;
  • 超时重试 / 服务重启 / 定时任务重复调度:任何一次"重试"都可能造成重复执行。

而重复执行带来的后果往往是直接的资金或数据损失:

  • 用户被扣两次钱、重复下单;
  • 权益重复发放(一张券发成两张);
  • 库存被多扣、账目对不平。

所以幂等不是"可选项",而是分布式系统的底线能力——它保证无论请求被重复多少次,业务的最终结果都正确。


三、如何保证幂等性?

涉及到幂等性的场景主要有:

  • 支付单重复创建
  • 支付宝重复回调
  • 权益重复发放

整体思路按**"在哪个层次把重复挡掉"**,自外向内分为四层:

入口 → 识别 → 并发 → 一致性

下面以支付场景为例,逐层展开。


1. 入口层:幂等 Token —— 挡住用户重复提交

最快、最直接的一道闸,拦的是"用户手抖 / 网络重试"这类重复请求。

① 通过幂等 Token 防止用户重新提交

首次进入支付页面时,服务端生成一个一次性 Token payment_token:xxxx = 未消费,并保存到 Redis;

用户提交支付请求时携带 Token,服务端处理请求前,通过 Redis 原子操作 getAndDelete 校验并删除 Token;

  • 第一次请求,Token 存在,删除成功,继续创建支付单;
  • 第二次重复请求,Token 已不存在,判断为重复提交,直接返回。

Token 的一次性消费机制,防止处理用户重复提交的请求。

⚠️ 用 getAndDelete(原子删)而不是"先 get 再 del",否则并发下两个请求可能同时 get 到 Token,闸就漏了。

② 点击按钮置灰(前端配套——业务经验)

按钮置灰、防抖、请求去重,属于同一层的轻度措施,成本最低,但只能挡住"用户侧"的重复,挡不住回调重发 / MQ 重投。


2. 识别层:定义"什么是同一笔业务"

这是一切后续措施的前提——没定义清楚"什么算同一笔",后面的锁、状态、索引全是空转。

① 每个业务动作设计唯一幂等键

如:

  • 支付:tenantId + orderId

要分清两类键:

键类型 用途 例子
业务键 本地创建业务单的幂等 支付单:tenantId + orderId
渠道键 第三方回调的幂等 支付宝 trade_no / notify_id

⚠️ "防重复下单"和"防重复回调"是两回事:创建支付单用 tenantId + orderId;回调去重要用渠道流水号。

② 先根据幂等键查询业务状态和业务流水表

收到重复请求时,查询支付单状态和权益发放流水,如果支付单状态已成功或者权益流水已存在,就直接返回。

这是"读多一次、省一次写"的廉价拦截。


3. 并发层:多实例/多线程同时处理同一业务

识别层的"先查后写"在并发下有 race——两个线程同时查到"未处理",然后都去处理。所以需要并发控制。

① 通过 Redis 分布式锁解决多个服务实例同时处理同一业务的问题

基于订单维度增加 Redis 分布式锁:lock:payment:{orderId},通过 SET key value NX EX,保证同一个订单同一时间只有一个服务实例能够获取执行权限。

  • 获取锁成功 → 执行支付成功处理流程;
  • 获取锁失败 → 短暂等待后重新查一次业务状态;若已成功则返回结果,否则返回"处理中"。

⚠️ 不要写成含糊的"等待或者直接返回"。直接返回会让用户看到假失败(实际在处理中),等待又有个"等多久"的问题。正解是短暂等待 + 重查状态。

② 数据库三板斧防止重复执行

(1)第一把斧是数据库行锁 —— 解决同时处理(串行化)

支付结果处理前,会:

SELECT ... WHERE paymentNo = ? FOR UPDATE 当前读 X

查询并锁住支付单,保证同一订单同一时间只有一个线程处理,第二个线程发现订单状态为已支付之后,不会再次处理支付业务。

(2)第二把斧是状态机条件更新 —— 限制非法状态流转

支付单和订单只能从:

待支付 → 已支付

通过条件更新:

UPDATE ... SET status = '已支付' WHERE status = '待支付'

通过条件保证只有一个请求可以完成状态流转(第二个请求影响 0 行,本质是 CAS)。

(3)第三把斧是数据库生成列 + 唯一索引 —— 负责最终兜底

目标:同一租户 + 同一订单,只能存在一条"待支付"记录。

最直白的想法是加个唯一索引:UNIQUE (tenant_id, order_id)。

但这会出问题 👇

订单 order_id=1001 的用户,第一次下单支付成功(status=已支付)后,又想再买一次(比如复购)。这时会插入一条新的支付记录,同样带着 order_id=1001:

tenant_id=1, order_id=1001, status=已支付   ← 第一次,历史记录
tenant_id=1, order_id=1001, status=待支付   ← 第二次,新单

如果索引是 UNIQUE(tenant_id, order_id),第二条会被误拦——但它是合法的!用户就是要再买一单。

所以不能简单约束 order_id 唯一——因为同一个订单本来就可能有多条历史支付记录(成功/失败/关闭)。

核心技巧:用「生成列」只把"待支付"挑出来

active_order_id = IF(status = 0, order_id, NULL)

status = 0 代表"待支付"。这个生成列的规则是:

记录状态 active_order_id 的值
待支付(status=0) = order_id(比如 1001)
已支付 / 已关闭 / 失败 = NULL

关键点:只有"待支付"的记录才有值,其他状态全是 NULL。

为什么是 NULL?——因为 SQL 里 NULL 不参与唯一冲突

这是整招的命门:

在唯一索引里,多行 NULL 互相不冲突(标准 SQL 行为:NULL != NULL)。

所以建了 UNIQUE (tenant_id, active_order_id) 之后:

tenant_id=1, active_order_id=1001   ← 待支付,占住"这个坑"
tenant_id=1, active_order_id=NULL   ← 已支付,不参与比较,爱有多少有多少
tenant_id=1, active_order_id=NULL   ← 已关闭,同样不管

于是这个唯一索引只约束了一件事: 同一个租户 + 同一个订单,最多只能有一条"待支付"记录。

历史记录(已支付/关闭)因为值是 NULL,完全不受影响——用户复购、留下多条历史单,全都放行 ✅

DuplicateKeyException 是怎么兜底的?

回到并发场景。假设两个请求同时要创建 order 1001 的待支付单:

时刻 T1: 请求 A 插入 → (tenant=1, active_order_id=1001) ✅ 成功
时刻 T2: 请求 B 插入 → (tenant=1, active_order_id=1001) ❌ 撞唯一索引
                       → 抛 DuplicateKeyException

请求 B 被数据库直接挡下。这是不依赖任何应用层代码的最后一道防线:

  • 前面的 Token 可能被消费掉了;
  • Redis 锁可能超时、误删、Redis 挂了;
  • 行锁只锁"已存在"的行,插入新行锁不住;
  • 但唯一索引是数据库层的硬约束,绕过不了。

只要这条约束在,无论前面几层怎么失效,"重复创建待支付单"从物理上就不可能发生。

一句话本质:用一个"只在待支付态才有值"的生成列,把"订单是否重复"这个有时效性的约束塞进唯一索引里——让唯一性只在"待支付"这个窗口内生效,靠 NULL 豁免历史数据。

  • 生成列 → 把条件(status=0)翻译成"值 or NULL"
  • 唯一索引 → 只对非 NULL 值生效
  • NULL 不冲突 → 历史记录自动豁免
  • DuplicateKeyException → 并发插入的最终兜底

⚠️ 捕获异常后:不能把 DuplicateKeyException 抛给用户当报错。正确做法是捕获它 → 视为"已存在待支付单" → 查出那条已有记录 → 正常返回。它的语义是"这笔已经有人创建了",不是"系统出错了"。


4. 一致性层:保证"不漏、不出错"

前面几层防的是"重复",这一层防的是"漏处理"和"部分成功"——处理失败/丢消息时的最后一张网。

① 通过本地事务 / 事务消息保证原子性

支付成功后,同一服务、同一库内的操作(订单状态 + 支付单状态)放在一个本地事务里保证原子性;

跨服务的部分(权益发放、支付通知)通过本地消息表 / 事务消息异步投递,各服务消费时自行保证幂等,最终达到一致。

⚠️ 通知/消息的"发送"不能放在本地事务主干里——否则发消息失败会导致事务回滚,或事务回滚了消息已发出。

② 通过重试和主动对账防止漏处理

重试(依赖回调重发):如果支付宝回调到达后,本地处理过程中发生异常,接口会向支付宝返回 failure,支付宝会重新发送支付通知。因为客户端已经实现幂等,所以支持重复回调。

定时主动对账:项目通过定时任务扫描本地待支付支付单,主动调用支付宝查询真实支付状态:

  • (1)如果支付宝显示已经支付 → 说明可能是回调丢失或处理失败 → 再次进入统一的支付成功流程;
  • (2)如果支付宝显示未支付,并且支付单已经超时 → 调用支付宝关单,同时关闭本地支付单并取消订单;
    • ⚠️ 关单接口若交易已支付成功会被支付宝拒绝——此时说明用户在对账与关单之间完成了支付,需转为走支付成功流程;
  • (3)如果支付宝显示未支付,并且处于支付有效期 → 暂时保留待支付状态,等待下一次回调或对账。

这就是最终一致性的兜底:不依赖任何一次回调成功,用定时对账把状态"拉回正轨"。


四、总结

整体思路(四层概括):

1. 入口层:幂等 Token / 点击按钮置灰 —— 挡住用户重复提交(最快最直接)

2. 识别层: (1)唯一幂等键 —— 定义什么是同一笔业务 (2)根据幂等键查状态/流水表 —— 判断是否已处理过

3. 并发层: (1)Redis 分布式锁 —— 跨实例同时来了谁先处理 (2)数据库三板斧:数据库行锁 for update 串行化 + 状态机条件更新限制非法状态流转 + 生成列 + 唯一索引最终兜底

4. 一致性层: (1)本地事务 / 事务消息 —— 要么全部成功,要么全部失败 (2)重试 + 对账 —— 防漏处理,最终一致

最终保证:

  • 同一租户、同一订单不会重复创建支付单;
  • 不会重复确认支付;
  • 也不会重复发放权益。

一句话记忆:幂等不是"防重",而是"无论重复多少次,结果都唯一"。前面几层是性能优化和快速失败,真正的保证落在"持久层唯一约束"和"对账兜底"上。