Theory · Microservice · Idempotency
分布式系统的重试与重投不可避免 —— 幂等是让"再来一次"变得无害的全部技术
同一操作执行一次与执行 N 次,结果相同:f(f(x)) = f(x)——重复请求被识别并安全消化
超时重试、MQ 至少一次投递、用户重复点击、失败补偿重放——所有可靠性机制都以幂等为前提
天然幂等(条件更新/状态机)· 唯一约束 · 幂等令牌 · 去重表 · 分布式锁——按"防的是什么"选择
Sources of Duplicates
调用超时但对方可能已成功(响应丢失)——客户端重试 = 服务端执行两次。gRPC/HTTP 重试策略自动放大这一点(见容错 deck)
at-least-once 投递语义:生产者重试、broker 重发、消费者超时 rebalance——重复消费是常态不是异常(Kafka 亦然)
双击提交、刷新回退重复提交、弱网下 App 自动重发——前端防抖只是体验优化,服务端幂等才是底线
Saga 补偿重试、对账系统发现差异后重放修复动作——修复本身也会被重复执行
网络不可靠 → 超时不知道成功与否 → 必须重试 → 重试导致重复 → 服务端必须幂等。换句话说:分布式系统的可靠性(重试机制)是建立在幂等性之上的——两者是一体设计,单独谈重试或单独谈幂等都不完整。
Definition
| 天然幂等 ✓ | 非幂等 ✗ |
|---|---|
| GET(只读)、HEAD | POST(创建,每次新资源) |
| 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)——防表单重复。原则:幂等键在"重试前后保持一致",换新键 = 幂等失效。
Toolkit
唯一索引兜底重复插入(订单号/流水号唯一)——最可靠的最终防线,并发下也不漏(DB 保证原子)
UPDATE ... WHERE status='init'——状态只能单向流转,重复请求条件不满足自动失效(天然幂等化)
UPDATE ... SET v=v+1 WHERE v=旧v——ABA 之外的并发防重,适合"读-改-写"竞争场景
先取 token 再提交,服务端原子消费 token——防"表单重复提交"的标准解
按业务键记录"已处理",处理前先查/占——灵活但要注意与业务事务的原子性
互斥执行防并发重复(不是防"先后重复")——常与其他手段叠加,见 Redis 锁 deck
| 防的是什么 | 首选手段 | 典型场景 |
|---|---|---|
| 并发同时点两次(同时性) | 唯一约束 / 乐观锁 / 分布式锁 | 抢购、并发下单 |
| 先后重复提交(时序性) | 状态机 / 去重表 / 令牌 | 表单重复提交、消息重投 |
| 都要防(生产标准) | 唯一约束兜底 + 状态机/去重前置 | 支付、下单、开户 |
Token Mechanism
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 兜底)。
State Machine & CAS
-- 状态单向流转: CREATED → PAID → SHIPPED UPDATE orders SET status='PAID', pay_time=now() WHERE order_id=? AND status='CREATED'; -- 行数=1 → 首次支付,执行后续 -- 行数=0 → 已支付过(重复) → 返回成功
精髓:重复请求命中"条件不满足",天然无第二次副作用;对调用方返回成功(幂等语义)。状态定义要"单向有向无环"。
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 → 被并发改过 → 重试/失败
适合"读-算-写"的竞争防重(余额更新、库存回补);冲突率高时重试成本大 → 改悲观锁或队列串行化。
支付回调可能来 N 次(用户重试、渠道重发):渠道交易号唯一索引 + 订单状态机条件更新双保险。顺序:先查单(快速路径)→ 唯一约束兜底 → 状态机流转 → 幂等返回——重复被挡时用户无感、资金安全。
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);③ 锁是性能兜底不是正确性兜底——正确性永远交给唯一约束/状态机,锁只是减少无效竞争。
"锁管并发,约束管正确"。锁挂了/超时了/丢了的极端情况,只要唯一索引和状态机在,数据依然不会错——只是可能多一点无效竞争。反过来(只有锁没有约束)任何一次锁故障都是资损事故。这是防重体系"纵深"的意义。
Consumer Idempotency
分区键按业务 ID(同实体有序)+ 消费幂等(防重)+ 水位判断(防旧覆盖新)。如账户事件流:按 account_id 分区保序,事件带 version,消费时 WHERE version = ? 条件应用——旧事件被版本挡住,重复被水位挡住。
producer 幂等(PID+序列号)解决生产侧单分区会话内重试;消费侧重复(rebalance/重放)它管不了——业务幂等必须在消费端自己实现。两层各管一段,别混淆(见 producer deck)。
Case Study
| 环节 | 重复风险 | 防重手段 |
|---|---|---|
| 下单接口 | 双击/弱网重发 | 客户端生成 request_id → 唯一索引兜底 + 令牌快速路径 |
| 支付发起 | 重复拉起收银台 | 订单状态机(CREATED→PAYING 条件更新) |
| 渠道回调 | 渠道重发 N 次 | 渠道交易号唯一索引 + 状态机(PAYING→PAID)+ 返回成功 |
| 发货消息 | MQ 重投 | 去重表(msg_id 同事务)+ 状态机(PAID→SHIPPED) |
| 退款对账 | 对账重放 | 退款流水号唯一索引 + 金额条件校验 |
常见翻车组合:只有前端按钮置灰(可绕过);Redis 去重设 TTL 太短(跨天重复);去重记录与业务不同事务(处理失败后重投被误判重复);状态机有"跳变"路径(PAID 直接 SHIPPED 绕过发货校验)。每个反例都对应一条纪律。
Interview QA · 1/2
同一操作执行多次与一次效果相同。必须做的原因链:网络不可靠 → 超时无法区分成功失败 → 必须重试 → 重试带来重复 → 服务端幂等消化重复。MQ 至少一次投递、用户双击、补偿重放都放大了这个问题。重试机制与幂等是一体两面。
三者都是"原子裁决"思想:唯一索引最可靠(DB 原子,永不错)但每请求耗 DB;Redis SETNX 性能高但可能丢(清空/故障);去重表是唯一索引的"事件版"用法。生产组合:SETNX 做快速路径(挡 99%),唯一索引做最终兜底(保 100%),两级配合。
查无-插入之间有时间窗,并发请求都查无 → 都插入 → 重复。这是经典 check-then-act 竞态。解法是把"判断+写入"合并成原子操作:INSERT 靠唯一索引裁决、Redis 用 SETNX、状态机用条件 UPDATE(影响行数判断)——原子性交给存储引擎而不是应用代码。
优先业务天然键(订单号/外部交易号)——跨系统可对账、可追溯;无天然键时用客户端 UUID(request_id),注意重试时必须复用同一个键(换键=幂等失效)。键的维度要对:按"一次业务动作"生成,不是按"一次 HTTP 请求";键盘(哪张表哪个字段)要全覆盖写入路径。
条件更新影响行数=0 时区分两种情况:已到目标状态(重复请求)→ 查出当前记录幂等返回成功;非法状态(数据异常)→ 告警人工介入。即"重复幂等友好、异常显式暴露"——把重复和数据损坏区分开,是状态机实现的细节分水岭。
乐观锁(版本 CAS):无锁开销、天然防并发覆盖,但冲突率高时重试成本大——适合低冲突的行级更新(余额、配置)。分布式锁:跨资源/跨服务的互斥(乐观锁管不到多个表/多个服务),有锁管理成本(超时/续期/丢失)——适合长临界区。粒度小选乐观锁,临界区长或跨资源选分布式锁。
Interview QA · 2/2
会,除非服务端幂等。我们的保证:客户端生成 request_id(进入页面时预生成,重试复用)→ 服务端"INSERT 唯一索引兜底 + Redis 快速路径";重复请求查原单返回成功。同时下发的响应要幂等友好:返回原订单号与状态,调用方拿到"成功"而不是报错。
三层:① 渠道交易号(out_trade_no)唯一索引——重复回调插入失败转查询;② 订单状态机 PAYING→PAID 条件更新——已支付的重复回调影响行数 0,幂等返回成功;③ 回调验签防伪造、金额校验防篡改、流水表留痕。另外主动查证:可疑回调不直接信,调渠道查询接口核实。
重复消费在 at-least-once 下必然发生(rebalance/超时重投)。消费幂等:消息带 event_id,去重表(与业务处理同事务)唯一索引裁决;业务侧再叠状态机条件更新双保险;Redis SETNX 做性能快路径。处理失败时去重记录随事务回滚——下次重投可正常处理,不会"卡死"。
Redis 只是快路径不是正确性来源:挂了就跳过它直接走 DB 唯一索引/去重表(慢但正确)。要避免两个坑:① 不要"Redis 失败就拒绝请求"(把优化器做成单点);② TTL 要覆盖业务重复窗口(比如支付回调至少 7 天),TTL 过短 = 快路径变摆设。
典型组合拳(下单为例):Redis SETNX 挡并发洪峰(同时性)→ 唯一索引挡重复插入(时序性兜底)→ 状态机保证流转正确(业务语义)→ 分布式锁保护跨资源临界区(如库存+订单跨表)。层次:快路径→正确性兜底→业务约束→互斥,每层故障不影响下层正确性。
① 单测:同键连调 N 次断言结果与副作用唯一;② 并发测试:N 个 goroutine 同键并发调用(-race),断言只有一次副作用;③ 混沌:重放历史消息/回调流量(录制回放),验证零重复副作用;④ 对账兜底验证:故意制造重复数据看对账能否发现。幂等代码没有并发测试 = 没测。
Related & References