幂等性设计
一、什么是幂等性?
同一个操作,执行一次和执行多次,对系统产生的最终结果完全一致。
理解它要注意两点本质:
- 幂等约束的是**"最终状态"**,不是"请求次数"。哪怕请求来了 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)重试 + 对账 —— 防漏处理,最终一致
最终保证:
- 同一租户、同一订单不会重复创建支付单;
- 不会重复确认支付;
- 也不会重复发放权益。
一句话记忆:幂等不是"防重",而是"无论重复多少次,结果都唯一"。前面几层是性能优化和快速失败,真正的保证落在"持久层唯一约束"和"对账兜底"上。