Theory · Microservice · Idempotency

幂等性设计

分布式系统的重试与重投不可避免 —— 幂等是让"再来一次"变得无害的全部技术

一句话本质

同一操作执行一次与执行 N 次,结果相同:f(f(x)) = f(x)——重复请求被识别并安全消化

为什么必考

超时重试、MQ 至少一次投递、用户重复点击、失败补偿重放——所有可靠性机制都以幂等为前提

手段谱系

天然幂等(条件更新/状态机)· 唯一约束 · 幂等令牌 · 去重表 · 分布式锁——按"防的是什么"选择

这份 deck 回答"重复执行怎么办":从为什么必然出现重复讲起,给出幂等定义与天然幂等操作,然后逐个展开防重手段——唯一约束、幂等令牌、状态机、乐观锁、分布式锁、消息消费幂等,最后用下单/支付综合案例串起来。核心观点:幂等不是单个技术,是"识别重复 + 消化重复"的设计组合。

Sources of Duplicates

重复从哪来:四个必然发生的源头

1 超时重试

调用超时但对方可能已成功(响应丢失)——客户端重试 = 服务端执行两次。gRPC/HTTP 重试策略自动放大这一点(见容错 deck)

2 MQ 重投

at-least-once 投递语义:生产者重试、broker 重发、消费者超时 rebalance——重复消费是常态不是异常(Kafka 亦然)

3 用户重复操作

双击提交、刷新回退重复提交、弱网下 App 自动重发——前端防抖只是体验优化,服务端幂等才是底线

4 补偿/对账重放

Saga 补偿重试、对账系统发现差异后重放修复动作——修复本身也会被重复执行

核心逻辑链(背下来)

网络不可靠 → 超时不知道成功与否 → 必须重试 → 重试导致重复 → 服务端必须幂等。换句话说:分布式系统的可靠性(重试机制)是建立在幂等性之上的——两者是一体设计,单独谈重试或单独谈幂等都不完整。

重复的代价分类

  • 资损类(重复扣款/重复发货):必须零容忍 → 强防重手段
  • 数据类(重复消息/重复记录):影响数据质量 → 去重约束
  • 体验类(重复通知/重复积分):可容忍少量 → 弱幂等+对账
  • 先分级再设计,不是所有接口都要上最重的方案
重复的四个源头:超时重试(响应丢失最阴险)、MQ 至少一次重投、用户重复操作、补偿对账重放。核心逻辑链必背:网络不可靠→超时→重试→重复→服务端幂等,重试与幂等是一体设计。重复代价三级分类(资损/数据/体验)决定防重强度——先分级再设计。

Definition

幂等的定义:哪些操作天然安全

数学定义与工程判据

  • 数学:f(f(x)) = f(x)——执行多次与一次效果相同
  • 工程判据:任意次重复执行后,系统终态一致,且副作用(扣款/发货/计数)只发生一次
  • 注意区分:结果幂等(终态同)vs 过程幂等(中间副作用也不重复)——支付场景要过程幂等(不能先扣了再退)

天然幂等 vs 非幂等(HTTP 语义 + 数据库)

天然幂等 ✓非幂等 ✗
GET(只读)、HEADPOST(创建,每次新资源)
PUT(整体覆盖写)PATCH(增量修改,部分实现非幂等)
DELETE(删除幂等:删 N 次等同 1 次)递增/递减(UPDATE n=n+1)
条件更新(WHERE status=待处理)无条件 INSERT
SET 固定值(status='paid')追加日志/发通知

设计的第一技巧:把非幂等改造成幂等

① 扣库存 UPDATE n=n-1(非幂等)→ 条件更新 WHERE n>0 + 流水表 requestId 唯一索引;② 下单 POST → 带客户端订单号(幂等键)的 PUT 语义;③ 转账 → 状态机(init→deducted→credited)每步条件更新。思路:引入"业务唯一键 + 条件执行"。

幂等键的来源(贯穿所有方案)

谁生成?① 客户端(UUID/请求指纹)——最通用;② 业务天然键(订单号/外部单号)——最可靠;③ 服务端预发(token)——防表单重复。原则:幂等键在"重试前后保持一致",换新键 = 幂等失效。

定义页三块:数学与工程定义(终态一致+副作用一次,区分结果幂等与过程幂等)、天然幂等对照表(HTTP 方法+DB 操作)、第一技巧"把非幂等改造成幂等"(条件更新/业务唯一键/状态机)。幂等键来源三选一及其"重试前后一致"原则是贯穿后面所有方案的主线。

Toolkit

防重手段总览:六种武器与适用矩阵

1 数据库唯一约束

唯一索引兜底重复插入(订单号/流水号唯一)——最可靠的最终防线,并发下也不漏(DB 保证原子)

2 条件更新/状态机

UPDATE ... WHERE status='init'——状态只能单向流转,重复请求条件不满足自动失效(天然幂等化)

3 乐观锁版本号

UPDATE ... SET v=v+1 WHERE v=旧v——ABA 之外的并发防重,适合"读-改-写"竞争场景

4 幂等令牌(token)

先取 token 再提交,服务端原子消费 token——防"表单重复提交"的标准解

5 去重表/Redis 记录

按业务键记录"已处理",处理前先查/占——灵活但要注意与业务事务的原子性

6 分布式锁

互斥执行防并发重复(不是防"先后重复")——常与其他手段叠加,见 Redis 锁 deck

防的是什么首选手段典型场景
并发同时点两次(同时性)唯一约束 / 乐观锁 / 分布式锁抢购、并发下单
先后重复提交(时序性)状态机 / 去重表 / 令牌表单重复提交、消息重投
都要防(生产标准)唯一约束兜底 + 状态机/去重前置支付、下单、开户
六种武器一览:唯一约束(最可靠防线)、条件更新/状态机、乐观锁、幂等令牌、去重表、分布式锁。选型分水岭:防"同时性"(并发)用锁/唯一约束,防"时序性"(先后重复)用状态机/去重表,生产标准是"前置快速判断 + DB 唯一约束兜底"双层结构。

Token Mechanism

幂等令牌:两段式提交防表单重复

幂等令牌两段式提交流程 第一段客户端请求服务端预发放令牌,服务端生成 token 存入 Redis 并返回;第二段客户端提交业务请求携带令牌,服务端用原子删除判断令牌是否已消费——删除成功执行业务,删除失败说明重复提交直接拒绝。重复提交的第二个请求因令牌已消费被拒绝。 第一段 · 取令牌 客户端 进入下单页 服务端 · 生成 token SET token:xid 1 EX 600 申请 token token 返回 客户端暂存 token 隐藏域/本地存储 第二段 · 携带 token 提交(DEL 原子消费) 提交请求 ×2 双击/重试携带同一token DEL token:xid 返回1=首次 → 执行业务 返回0=重复 → 拒绝 业务+token 结果:执行 1 次 第二次请求被拒绝 关键在"判断+消费"必须原子:DEL 返回值即原子判断(或 Lua)。经典错误:先 GET 再 DEL 两步操作——并发下双双通过。令牌要设过期(防囤积),业务执行失败时可视策略归还令牌。 token 机制的局限:只防"提交重复",若业务执行中途崩溃,token 已消费但业务未完成——需配合业务状态机/对账兜底
幂等令牌两段式:预发放 token(Redis SET EX)→ 提交携带 → DEL 原子消费(返回 1 执行、0 拒绝)。两个关键点:判断+消费必须原子(先 GET 再 DEL 是经典错误),token 要设过期。局限也要讲:只防提交重复,业务中途崩溃仍需状态机兜底。

Unique Constraint

唯一约束与去重表:数据库级的最终防线

业务唯一索引(首选)

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  out_trade_no VARCHAR(64) NOT NULL,
  amount DECIMAL(10,2),
  UNIQUE KEY uk_out (out_trade_no)
);
-- 重复请求 INSERT 直接报
-- Duplicate entry → 转查询返回

要点:唯一键 = 业务幂等键(外部单号/请求 ID);捕获 Duplicate 错误后查询已存在记录返回成功(对调用方幂等友好)。

去重表(通用事件去重)

CREATE TABLE dedup (
  msg_id VARCHAR(64) PRIMARY KEY,
  biz_time DATETIME
);
-- 消费前:
INSERT INTO dedup(msg_id) VALUES(?)
-- 成功 → 首次,继续处理
-- 失败(重复) → 直接 ack 跳过

关键纪律:INSERT 去重记录与业务处理在同一个本地事务——处理失败一起回滚,下次重投再来。

为什么"先查后插"是错的

查无 → 插入,两步之间并发请求同样查无 → 双双插入 = 重复。正确姿势:直接 INSERT,靠唯一索引原子裁决。同理 Redis 用 SETNX(而非 GET+SET)去重。

去重记录的生命周期

去重表会无限增长:① 按时间归档(清理 N 月前记录);② Redis + TTL(24h)+ DB 唯一约束兜底,两级兼顾性能与可靠;③ 大流量用布隆过滤器预判(误判只影响"快速跳过",正确性由 DB 兜底)。

唯一约束两形态:业务唯一索引(Duplicate 错误转查询返回成功)与去重表(INSERT 与业务同事务)。必讲的反模式:"先查后插"两步间有并发窗口,正确姿势是直接 INSERT 靠唯一索引原子裁决,Redis 对应 SETNX。生命周期管理:归档/Redis+TTL+DB 兜底两级/布隆过滤器预判。

State Machine & CAS

状态机与乐观锁:让重复请求"自动失效"

状态机幂等

-- 状态单向流转: CREATED → PAID → SHIPPED
UPDATE orders
SET status='PAID', pay_time=now()
WHERE order_id=? AND status='CREATED';
-- 行数=1 → 首次支付,执行后续
-- 行数=0 → 已支付过(重复) → 返回成功

精髓:重复请求命中"条件不满足",天然无第二次副作用;对调用方返回成功(幂等语义)。状态定义要"单向有向无环"。

乐观锁(版本号 CAS)

SELECT balance, version FROM acct
  WHERE id=1;            -- v=7
UPDATE acct
SET balance=balance-100, version=version+1
WHERE id=1 AND version=7;
-- 0 rows → 被并发改过 → 重试/失败

适合"读-算-写"的竞争防重(余额更新、库存回补);冲突率高时重试成本大 → 改悲观锁或队列串行化。

状态机设计的三个纪律

  • 状态集合封闭:所有可达状态枚举清楚,禁止"隐藏状态"(用标志位偷偷记录)
  • 流转单向可预测:合法迁移画成图,非法迁移一律拒绝并告警(数据异常的哨兵)
  • 每步迁移带条件更新:WHERE 前置状态——这就是幂等的实现本体;配合流水表记录每次流转(审计+对账依据)

与支付场景的经典配合

支付回调可能来 N 次(用户重试、渠道重发):渠道交易号唯一索引 + 订单状态机条件更新双保险。顺序:先查单(快速路径)→ 唯一约束兜底 → 状态机流转 → 幂等返回——重复被挡时用户无感、资金安全。

状态机幂等的实现本体是"条件更新":影响行数判断首次/重复,重复返回成功。三个设计纪律:状态封闭、流转单向、每步带条件更新+流水表。乐观锁 CAS 适合读算写竞争,冲突率高换悲观锁或队列串行。支付回调的三层配合(唯一索引+状态机+快速查询路径)是经典案例。

Distributed Lock

分布式锁防重:互斥的正确用法与局限

锁能防什么、不能防什么

  • 能防:并发"同时性"重复——两个请求同时到达,锁保证串行执行,第二个进来发现已处理(查状态)而跳过
  • 不能防:"时序性"重复——第一个请求执行完了、锁已释放,第二个请求再来,锁形同虚设
  • 结论:锁必须配合"锁内检查已处理标记"才有防重意义;单独用锁 = 只解决并发不解决重复

标准模式:锁 + 双重检查

lock := redis.SetNX(
  "lock:pay:"+orderID, 1, 10s)
if !lock { return 已处理/稍后再试 }
defer unlock()
// 双重检查(锁内再查一次状态)
if order.Status == PAID {
    return 幂等成功
}
doPay(order)  // 真实执行
markPaid(order)

锁方案的坑(提醒级)

① 锁超时 < 业务耗时 → 锁提前释放,并发窗口重开(看门狗续期);② 主从切换锁丢失(RedLock 争议,见 Redis 锁 deck);③ 锁是性能兜底不是正确性兜底——正确性永远交给唯一约束/状态机,锁只是减少无效竞争。

锁 vs 唯一约束的分工(面试金句)

"锁管并发,约束管正确"。锁挂了/超时了/丢了的极端情况,只要唯一索引和状态机在,数据依然不会错——只是可能多一点无效竞争。反过来(只有锁没有约束)任何一次锁故障都是资损事故。这是防重体系"纵深"的意义。

分布式锁的定位要讲清:能防同时性、不能防时序性,必须配合锁内双重检查才有防重意义。标准代码模式 SETNX+defer unlock+锁内查状态。三个坑:锁超时提前释放、主从切换丢锁、锁是性能兜底不是正确性兜底。金句:"锁管并发,约束管正确"。详细锁实现互链 Redis 分布式锁 deck。

Consumer Idempotency

消息消费幂等:至少一次投递的必配项

为什么必然重复

  • 生产者重试:broker 写入成功但 ack 超时 → 重发(producer 幂等可解单分区,跨会话仍重)
  • 消费者重平衡:处理完但 offset 未提交 → rebalance 后另一消费者再消费
  • 死信重放、人工补偿重推——运维动作也会制造重复
  • 结论:消费逻辑必须假设"同一条消息可能来 N 次"

消费幂等四件套(按优先级)

  • ① 业务天然键去重:消息里带 order_id/event_id,去重表唯一索引裁决(与业务同事务)——最可靠
  • ② 状态机:消费动作 = 状态条件更新,重复消费条件不满足自动跳过
  • ③ Redis 快速路径:SETNX msg_id(TTL 24h)前置拦截,DB 兜底——性能优化不影响正确性
  • ④ 版本号/水位:带序号的事件流(version/offset)只处理比已记录水位新的——适合状态同步类

乱序与重复的组合拳

分区键按业务 ID(同实体有序)+ 消费幂等(防重)+ 水位判断(防旧覆盖新)。如账户事件流:按 account_id 分区保序,事件带 version,消费时 WHERE version = ? 条件应用——旧事件被版本挡住,重复被水位挡住。

与 Kafka 幂等的分层(避免混淆)

producer 幂等(PID+序列号)解决生产侧单分区会话内重试;消费侧重复(rebalance/重放)它管不了——业务幂等必须在消费端自己实现。两层各管一段,别混淆(见 producer deck)。

消息重复三来源:生产者重试、消费者重平衡、死信重放。消费幂等四件套按优先级:业务键去重表(最可靠)、状态机条件更新、Redis 快速路径+DB 兜底、版本号水位。组合拳讲乱序+重复同时处理(分区键+版本条件)。分层辨析:Kafka producer 幂等管生产侧,消费侧重复必须业务自己兜——"exactly-once 是业务实现出来的"。

Case Study

综合案例:下单+支付全链路防重设计

链路与手段映射

环节重复风险防重手段
下单接口双击/弱网重发客户端生成 request_id → 唯一索引兜底 + 令牌快速路径
支付发起重复拉起收银台订单状态机(CREATED→PAYING 条件更新)
渠道回调渠道重发 N 次渠道交易号唯一索引 + 状态机(PAYING→PAID)+ 返回成功
发货消息MQ 重投去重表(msg_id 同事务)+ 状态机(PAID→SHIPPED)
退款对账对账重放退款流水号唯一索引 + 金额条件校验

设计原则回顾

  • 幂等键贯穿全链:request_id(前端)→ order_id(服务间)→ out_trade_no(渠道)→ msg_id(MQ),每个边界都有键
  • 快速路径 + 兜底:Redis/状态检查前置(省 DB 压力),唯一索引最终裁决(保正确性)
  • 重复请求友好返回:查已有记录返回成功(带原结果),而不是报错——调用方/用户无感
  • 流水表全程留痕:每次状态流转一条流水,审计与对账的数据基础

反例警示

常见翻车组合:只有前端按钮置灰(可绕过);Redis 去重设 TTL 太短(跨天重复);去重记录与业务不同事务(处理失败后重投被误判重复);状态机有"跳变"路径(PAID 直接 SHIPPED 绕过发货校验)。每个反例都对应一条纪律。

综合案例把全链路串起来:下单(request_id 唯一索引+令牌)、支付发起(状态机)、渠道回调(渠道单号+状态机)、发货消息(去重表+状态机)、退款对账(流水号唯一)。设计原则四条:幂等键贯穿全链、快速路径+兜底、重复友好返回、流水表留痕。反例警示对应纪律,作为收尾的"避坑"清单。

Interview QA · 1/2

高频 QA(一):概念与手段

Q1 什么是幂等?为什么分布式系统必须做幂等?

f(f(x))=f(x)重试前提

同一操作执行多次与一次效果相同。必须做的原因链:网络不可靠 → 超时无法区分成功失败 → 必须重试 → 重试带来重复 → 服务端幂等消化重复。MQ 至少一次投递、用户双击、补偿重放都放大了这个问题。重试机制与幂等是一体两面。

Q2 唯一索引、去重表、Redis SETNX 三者关系?

兜底 vs 快路径

三者都是"原子裁决"思想:唯一索引最可靠(DB 原子,永不错)但每请求耗 DB;Redis SETNX 性能高但可能丢(清空/故障);去重表是唯一索引的"事件版"用法。生产组合:SETNX 做快速路径(挡 99%),唯一索引做最终兜底(保 100%),两级配合。

Q3 "先查后写"为什么不安全?

check-then-act 竞态

查无-插入之间有时间窗,并发请求都查无 → 都插入 → 重复。这是经典 check-then-act 竞态。解法是把"判断+写入"合并成原子操作:INSERT 靠唯一索引裁决、Redis 用 SETNX、状态机用条件 UPDATE(影响行数判断)——原子性交给存储引擎而不是应用代码。

Q4 幂等键怎么设计?用 UUID 还是业务号?

键的一致性

优先业务天然键(订单号/外部交易号)——跨系统可对账、可追溯;无天然键时用客户端 UUID(request_id),注意重试时必须复用同一个键(换键=幂等失效)。键的维度要对:按"一次业务动作"生成,不是按"一次 HTTP 请求";键盘(哪张表哪个字段)要全覆盖写入路径。

Q5 状态机幂等怎么实现"重复请求返回成功"?

影响行数语义

条件更新影响行数=0 时区分两种情况:已到目标状态(重复请求)→ 查出当前记录幂等返回成功;非法状态(数据异常)→ 告警人工介入。即"重复幂等友好、异常显式暴露"——把重复和数据损坏区分开,是状态机实现的细节分水岭。

Q6 乐观锁和分布式锁怎么选?

无锁 CAS vs 互斥

乐观锁(版本 CAS):无锁开销、天然防并发覆盖,但冲突率高时重试成本大——适合低冲突的行级更新(余额、配置)。分布式锁:跨资源/跨服务的互斥(乐观锁管不到多个表/多个服务),有锁管理成本(超时/续期/丢失)——适合长临界区。粒度小选乐观锁,临界区长或跨资源选分布式锁。

六题:幂等定义与原因链、三种原子裁决的关系(快路径+兜底)、check-then-act 竞态、幂等键设计(业务键优先+重试复用)、状态机的重复 vs 异常区分、乐观锁与分布式锁选型(粒度与临界区)。

Interview QA · 2/2

高频 QA(二):场景实战

Q7 接口超时了,客户端重试会不会重复下单?你们怎么保证?

幂等键+唯一索引

会,除非服务端幂等。我们的保证:客户端生成 request_id(进入页面时预生成,重试复用)→ 服务端"INSERT 唯一索引兜底 + Redis 快速路径";重复请求查原单返回成功。同时下发的响应要幂等友好:返回原订单号与状态,调用方拿到"成功"而不是报错。

Q8 支付回调重复通知怎么处理?

渠道单号+状态机

三层:① 渠道交易号(out_trade_no)唯一索引——重复回调插入失败转查询;② 订单状态机 PAYING→PAID 条件更新——已支付的重复回调影响行数 0,幂等返回成功;③ 回调验签防伪造、金额校验防篡改、流水表留痕。另外主动查证:可疑回调不直接信,调渠道查询接口核实。

Q9 MQ 消费者怎么做幂等?重复消费会怎样?

去重表+状态机

重复消费在 at-least-once 下必然发生(rebalance/超时重投)。消费幂等:消息带 event_id,去重表(与业务处理同事务)唯一索引裁决;业务侧再叠状态机条件更新双保险;Redis SETNX 做性能快路径。处理失败时去重记录随事务回滚——下次重投可正常处理,不会"卡死"。

Q10 Redis 做幂等去重,Redis 挂了怎么办?

降级路径

Redis 只是快路径不是正确性来源:挂了就跳过它直接走 DB 唯一索引/去重表(慢但正确)。要避免两个坑:① 不要"Redis 失败就拒绝请求"(把优化器做成单点);② TTL 要覆盖业务重复窗口(比如支付回调至少 7 天),TTL 过短 = 快路径变摆设。

Q11 幂等和并发冲突怎么同时解决?

纵深组合

典型组合拳(下单为例):Redis SETNX 挡并发洪峰(同时性)→ 唯一索引挡重复插入(时序性兜底)→ 状态机保证流转正确(业务语义)→ 分布式锁保护跨资源临界区(如库存+订单跨表)。层次:快路径→正确性兜底→业务约束→互斥,每层故障不影响下层正确性。

Q12 怎么测试幂等实现是否正确?

并发重放测试

① 单测:同键连调 N 次断言结果与副作用唯一;② 并发测试:N 个 goroutine 同键并发调用(-race),断言只有一次副作用;③ 混沌:重放历史消息/回调流量(录制回放),验证零重复副作用;④ 对账兜底验证:故意制造重复数据看对账能否发现。幂等代码没有并发测试 = 没测。

六题实战:超时重试防重复下单(request_id 复用+两级裁决)、支付回调三层(渠道单号/状态机/主动查证)、MQ 消费幂等(同事务去重)、Redis 挂了的降级路径(跳过快路径走 DB)、幂等与并发同时解决(四层纵深)、幂等测试(并发重放+混沌+对账)。Q12 强调"幂等代码没有并发测试等于没测"。

Related & References

相关知识点与参考

本领域相关 deck

  • 分布式事务 —— 幂等是所有最终一致方案的消费端地基
  • 熔断降级容错 —— 重试机制的幂等前提;退避与预算设计
  • 服务间通信与 RPC —— 重试发生在哪一层、幂等键放 metadata
  • 分布式 ID —— 幂等键(订单号/流水号)的生成来源
  • 限流 —— Redis+Lua 原子操作同款模式(SETNX/Lua 消费 token)

跨领域相关 deck

参考链接(一手来源)

收尾链接:领域内分布式事务(消费端地基)、容错(重试前提)、RPC(重试层与键传递)、分布式 ID(键来源)、限流(Lua 模式);跨领域 Redis 锁与数据结构、Kafka 生产侧幂等、MySQL 索引与事务。参考:RFC 9110、microservices.io 的 Idempotent Consumer、Stripe 幂等键实践(业界标杆)、Kafka 与 Redis 官方文档。