什么是幂等性?分布式系统中如何保证幂等?
简化版
幂等性指同一业务意图执行一次或多次,业务副作用与执行一次等价;每次响应文本不必完全相同。分布式系统无法避免超时重试和消息重投,因此下单、扣款、库存、回调等写操作要用稳定业务键识别重复,并让“去重判定、业务写入、结果记录”处在可靠的原子边界内。
详细版
幂等设计的关键是找到“同一个请求”的稳定标识,比如订单号、支付流水号、请求 ID、消息 ID。然后用可靠机制阻止重复副作用:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 唯一索引 | 对业务唯一键建唯一约束 | 防重复创建订单、流水 |
| 去重表 | 处理前插入 request_id,重复插入失败 | MQ 消费、回调处理 |
| Token | 提交前发一次性 token,提交时原子校验删除 | 表单重复提交 |
| 状态机 | 只允许合法状态流转 | 订单支付、退款、发货 |
| 乐观锁 | 更新时带 version 或条件 | 防重复更新、并发更新 |
| Redis 原子操作 | SET NX、Lua 原子校验 | 短窗口削峰或防重复提交,核心副作用仍需持久约束 |
注意“先查有没有,再插入/更新”不是可靠幂等,因为并发下两个请求可能同时查到没有。幂等判断要落在唯一约束、条件更新、Lua、事务等原子机制上。
完整版教学
一、为什么分布式系统离不开幂等
分布式调用最大的特点是结果不确定。你调用支付服务超时,可能支付服务没收到,也可能扣款成功但响应丢了。为了避免误判失败,调用方通常会重试;MQ 为了保证消息不丢,也常采用至少投递一次,这意味着消费者可能收到重复消息。重试和重投是可靠性的朋友,却是重复副作用的来源。
如果接口不幂等,重试一次可能变成重复扣款、重复发货、重复创建订单。幂等就是给重试兜底,让系统敢重试,又不会重复产生业务影响。
二、幂等关注的是副作用,不是返回值
同一个请求第一次返回“创建成功”,第二次返回已保存的订单结果,响应元数据可能不同,但系统只有一张订单且只扣一次款,所以业务效果仍然幂等。反过来,接口每次都返回 200,却重复发券或重复记账,绝不算幂等。
这点很重要,因为很多接口第二次调用时天然无法返回完全一样的上下文。工程上只要第二次不会再次改变业务状态,通常就可以认为幂等。
三、幂等 key 必须稳定且有业务含义
没有幂等 key,就不知道两个请求是不是同一个操作。随机 UUID 只有在一次业务意图创建时生成、所有重试都复用才有效;每次尝试重建 UUID 或时间戳都会绕过去重。服务端还应保存请求参数摘要,同一 key 携带不同金额或收款方时必须报冲突,不能复用旧结果掩盖调用错误。
对于支付回调,要用第三方交易号或商户订单号;对于 MQ 消费,要用消息 ID 或业务事件 ID;对于下单,要用订单号或提交 token。
四、几种常用方案怎么落地
唯一索引是最硬的兜底。订单表对 order_no 建唯一索引,重复插入会失败,服务捕获唯一键冲突后查询并返回已有订单。它并发安全,因为约束在数据库里。
| 方案 | 原子边界 | 适用范围 | 主要故障边界 |
|---|---|---|---|
| 业务表唯一键 | 业务写本身 | 创建订单、账务流水 | 需把唯一键定义到正确业务作用域 |
| 去重表 + 本地事务 | 去重记录与业务更新 | MQ 消费、回调 | 跨外部系统副作用仍不能原子提交 |
| 状态机条件更新 | 当前状态与目标状态 | 支付、退款、发货 | 必须拒绝非法逆向迁移 |
| Redis Token | Token 消费 | 表单防连点、短窗口削峰 | 过期、故障切换与业务失败后的恢复 |
去重表适合消息消费。消费者先在 processed_message 表插入 message_id,插入成功才执行业务,插入失败说明处理过。关键是去重记录和业务更新最好放在同一个本地事务里,否则可能业务成功但去重记录失败,下一次又重复执行。
BEGIN;
INSERT INTO processed_message(consumer, event_id)
VALUES ('points-service', 'evt-9001'); -- 联合唯一键
INSERT INTO points_ledger(user_id, event_id, amount)
VALUES (42, 'evt-9001', 100); -- event_id 同样唯一
COMMIT;
-- 重复事件在唯一约束处失败,不能再次增加积分
Token 适合减少表单重复提交。用户进入提交页先申请 token,提交时用 Lua 原子消费;但 token 被消费后若业务事务失败,还要有明确失败状态或重新申请流程。Redis 数据丢失、主从切换和 token 过期也决定它不应成为核心账务唯一防线,最终仍应依赖业务唯一约束或账务流水。
状态机适合订单类业务。订单只能从待支付到已支付,再到已发货。重复支付回调到来时,发现订单已经已支付,就直接返回成功,不再重复发积分或发消息。
五、分布式锁不是幂等的替代品
分布式锁能让同一时刻只有一个请求处理,但锁释放后,重复请求再次到来仍可能再次执行。它解决的是并发互斥,不解决历史去重。正确设计通常是锁 + 状态/去重配合:锁降低并发冲突,去重表或状态机保证重复请求不产生第二次副作用。
六、面试常见坑
先查后写是最典型坑:两个请求同时查都没有,然后都插入成功。另一个坑是幂等记录和业务操作不在同一个事务中,导致中间失败后重复执行。还有一个坑是幂等 key 粒度过粗或过细,过粗会误杀不同请求,过细会挡不住重复请求。
七、去重窗口、失败状态和存储成本
幂等记录要保留多久取决于最大重试窗口、消息保留期和人工重放周期。若消息可能在 7 天内重放,而去重记录只保存 1 小时,第 2 天的重放仍会重复生效;对永久唯一的支付流水,直接让业务表唯一键长期承担约束通常比短 TTL 去重缓存更可靠。
假设每天处理 1 亿个事件,每条去重记录连索引约 200B,保留 7 天的原始量约为 100000000 × 200 × 7 = 140GB,还未计算副本。可以按日期分区、归档或复用业务结果表,但不能通过缩短窗口把重复风险偷偷转嫁给业务。
PROCESSING 记录也不能永久卡住。恢复任务应按业务键查询真实副作用:确认已完成就补写结果,确认未执行才安全重试,无法判断则进入人工处理;直接删除记录再执行会把未知状态变成重复副作用。
记忆钩子:幂等的安全边界不是“挡住并发那一刻”,而是覆盖这次业务意图可能被重新送达的整个生命周期。
八、常见误区与追问
- 误区:幂等就是接口不报错。 幂等关注同一业务意图的副作用等价,不是吞异常或一律返回成功。
- 误区:前端禁止重复点击就够了。 网络重试、网关重放、MQ 重投和服务端恢复仍会产生重复请求。
- 误区:先查去重表再执行业务即可。 检查与占位必须原子,去重状态与本地业务写也应在同一事务提交。
- 误区:Redis
SET NX能独立保护核心账务。 过期、故障切换和持久性边界仍存在,核心事实需要数据库唯一约束或账务状态机兜底。 - 追问:同一幂等键参数不同怎么办? 比较规范化请求摘要并返回冲突,不能继续执行,也不能静默返回旧结果。
- 追问:幂等记录一直处于处理中怎么办? 查询业务事实或外部系统结果,由状态机接管,未经确认不能删记录重做。
- 追问:幂等和分布式锁有什么区别? 锁只限制某段时间的并发进入,幂等还要识别锁释放后再次到达的历史重复。
九、加强记忆
幂等就是让同一业务操作重复执行也只产生一次副作用。分布式里因为超时、重试、MQ 重投无法避免,所以写接口必须幂等。核心前提是稳定业务唯一标识,核心手段是唯一索引、去重表、Token、状态机、乐观锁和原子操作。记住两条底线:先查后写不可靠,分布式锁不等于幂等。