幂等性设计方案
# 17.幂等性设计方案
本篇定位:幂等性是分布式系统的"防重保险丝"——网络抖动、客户端重试、消息重投都可能让同一个操作执行多次。本文从一次"扣两次款"的支付事故讲起,回答三个核心问题——为什么必须做幂等?业界主流方案怎么选?怎么落地一个生产级幂等体系?
# 目录介绍
# 01.一次扣两次款
# 1.1 重复扣款事故
某电商支付系统某天晚上 22:00 大促,有 3700 个用户被重复扣款——一笔订单 199 元的,账户被扣了 398 元。
sequenceDiagram
participant U as 用户
participant App as App
participant Pay as 支付服务
participant Bank as 银行
U->>App: 点支付按钮
App->>Pay: 发起支付 ¥199
Pay->>Bank: 扣款请求
Bank-->>Pay: 扣款成功(响应慢 5 秒)
Note over App: 用户等不及<br/>以为没成功<br/>再点一次
U->>App: 再点支付按钮
App->>Pay: 又发起支付 ¥199
Pay->>Bank: 又一次扣款
Bank-->>Pay: 扣款成功
Note over U: 实际扣了 ¥398
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
故障代价:
- 客服 3 天处理 3700+ 投诉
- 退款 + 补偿 ¥73 万
- App 评分掉到 2.1 星
- 监管约谈
# 1.2 故障扩散链路
flowchart TD
A[网络抖动 / 响应慢] --> B[用户重复点击]
B --> C[多次请求到达支付服务]
C --> D{有幂等保护?}
D -->|否 ❌| E[每次请求都扣款]
D -->|是 ✅| F[第二次直接返回首次结果]
E --> G[资金损失]
Cause[根因] --> R1[支付接口没幂等键]
Cause --> R2[Bank 接口快但响应慢]
Cause --> R3[App 没做按钮防抖]
Cause --> R4[支付状态机不严]
style E fill:#ffebee
style G fill:#ffebee
style F fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1.3 反思幂等设计
事后这个团队总结了三个最深刻的教训:
- 重试是分布式系统的常态——网络抖动、超时重发、用户重点击都会触发
- 幂等不是"可选优化",而是"必备保险"——尤其涉及钱、库存、消息
- 幂等要靠"业务唯一标识 + 服务端去重"——不能依赖客户端不重发
这次事故揭示一个本质:分布式系统不存在"恰好一次"的网络通信——只有"至少一次 + 服务端幂等 = 恰好一次"。
# 02.要解决的核心矛盾
# 2.1 重复请求的来源
mindmap
root((重复请求来源))
用户层
多次点击按钮
浏览器后退/刷新
表单重复提交
客户端层
超时重试
断网重连补发
App 后台进程重发
网络层
代理重试
LB 超时重试
MQ 至少一次
服务层
RPC 失败重试
定时任务重跑
补偿任务
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
关键认知:只要有重试就一定会有重复请求——而分布式系统离不开重试。
# 2.2 一致性与性能
graph LR
A[强一致幂等<br/>每次都查 DB 加锁] --> B[性能差]
B --> C[QPS 上不去]
A2[最终一致幂等<br/>缓存层去重] --> B2[性能高]
B2 --> C2[但有窗口期重复风险]
A3[分层幂等<br/>缓存 + DB 唯一约束] --> B3[兼顾]
style C fill:#fff3e0
style C2 fill:#fff3e0
style B3 fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
# 2.3 判重与时效
幂等记录不能永久保留(成本爆炸),但保留太短又会失效:
| 业务 | 保留时长 | 理由 |
|---|---|---|
| 支付下单 | 24 小时 | 重试一般几分钟内 |
| 消息消费 | 7 天 | MQ 最长重投周期 |
| 接口调用 | 1 小时 | 短期重试为主 |
| 定时任务 | 30 天 | 跨月对账 |
# 2.4 幂等的本质
幂等 = 同一操作执行 N 次和执行 1 次的结果完全相同
数学上:f(f(x)) = f(x)。
工程上:操作前先判断"这件事我做过没",做过就直接返回首次结果。
# 03.业界主流方案
# 03.1 主流方案概览
| 方案 | 核心思路 | 典型场景 |
|---|---|---|
| 唯一索引 | DB 唯一约束阻止重复 | 创建订单、注册用户 |
| Token 防重 | 调用前先取 Token,调用时消费 | 表单提交、支付 |
| 状态机 | 仅在特定状态下才执行 | 订单状态流转 |
| 乐观锁 | version 字段控制 | 库存扣减、计数器 |
| 悲观锁/分布式锁 | 串行化操作 | 高竞争场景 |
| 幂等表 | 单独存幂等键和结果 | 通用接口幂等 |
# 03.2 横向对比矩阵
| 维度 | 唯一索引 | Token | 状态机 | 乐观锁 | 分布式锁 | 幂等表 |
|---|---|---|---|---|---|---|
| 实现复杂度 | ⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐ |
| 性能 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 可靠性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 是否返回首次结果 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| 跨服务可用 | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ |
# 03.3 适用场景速查
flowchart TD
Start([需要幂等]) --> Q1{操作类型?}
Q1 -->|创建类<br/>下单/注册| UI[唯一索引<br/>+ 业务 ID]
Q1 -->|更新类<br/>状态流转| SM[状态机<br/>+ 乐观锁]
Q1 -->|查询类| Skip[天然幂等<br/>无需处理]
Q1 -->|通用接口<br/>需返回原结果| IT[幂等表<br/>+ Token]
Q1 -->|高竞争资源<br/>抢购/秒杀| DL[分布式锁]
style UI fill:#e8f5e8
style SM fill:#fff3e0
style IT fill:#e3f2fd
style DL fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
# 04.设计核心原则
# 04.1 唯一标识原则
铁律:必须有一个业务级唯一 ID 来标识这次操作。
graph LR
A[业务唯一标识]
A --> B1[订单号 orderId]
A --> B2[请求号 requestId]
A --> B3[业务键 业务 ID + 操作类型]
A --> B4[Token clientToken]
Bad[❌ 不能用]
Bad --> N1[时间戳 同一毫秒并发会撞]
Bad --> N2[随机数 不同请求可能撞]
Bad --> N3[自增 ID 服务端生成无法去重]
style A fill:#e8f5e8
style Bad fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
幂等键的好特征:
- 客户端生成(如 UUID)—— 服务端才能识别"这是同一个请求"
- 业务相关(如
userId+orderId+amount)—— 防止业务上的重复 - 可读性—— 排查问题时一眼看出是哪笔
# 04.2 先判后做原则
永远先判重,再执行业务:
// ✅ 正确:先判重
fun pay(req: PayRequest): PayResult {
val key = "pay:${req.requestId}"
val cached = cache.get(key)
if (cached != null) return cached // 重复请求,返回首次结果
val result = doPayment(req) // 真正业务
cache.set(key, result, ttl = 24.hours)
return result
}
// ❌ 错误:先做后判(资金已经动了)
fun pay(req: PayRequest): PayResult {
val result = doPayment(req) // 已经扣款
val key = "pay:${req.requestId}"
if (cache.exists(key)) {
rollback(result) // 回滚已经晚了
return cache.get(key)
}
cache.set(key, result)
return result
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 04.3 状态机原则
业务状态只能按规定路径流转:
stateDiagram-v2
[*] --> 待支付: 创建订单
待支付 --> 已支付: 支付成功
待支付 --> 已取消: 取消订单
已支付 --> 已发货: 发货
已发货 --> 已完成: 收货
已支付 --> 退款中: 退款申请
退款中 --> 已退款: 退款成功
note right of 已支付: 重复"支付成功"<br/>会被状态机拒绝
2
3
4
5
6
7
8
9
10
状态机天然幂等:因为只有"待支付 → 已支付"是允许的,第二次想再"已支付 → 已支付"会被拒绝。
-- 用 SQL 实现状态机
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_id = ?
AND status = 'WAITING_PAY'; -- 只在特定状态下才执行
-- 影响行数 = 0 → 已经执行过了
-- 影响行数 = 1 → 首次执行成功
2
3
4
5
6
7
8
# 04.4 时效边界原则
幂等不是永久的,必须有清晰的时效边界:
| 业务 | 时效 | 过期后怎么办 |
|---|---|---|
| 支付下单 | 24 小时 | 同样的 requestId 算新订单 |
| 消息消费 | 7 天 | 跟 MQ 最长重投周期对齐 |
| Token 防重 | 5-30 分钟 | 表单页面停留时间 |
# 05.方案落地实战
# 05.1 整体架构
graph TB
Client[客户端] -->|requestId| Gateway[API 网关]
Gateway -->|提取 requestId| IdmFilter[幂等过滤器]
IdmFilter --> Cache{Redis 查 requestId?}
Cache -->|命中| ReturnCached[返回首次结果]
Cache -->|未命中| Lock[获取分布式锁]
Lock --> Biz[业务处理]
Biz --> DB[(DB 唯一约束兜底)]
Biz --> SetCache[写 Redis 缓存结果]
SetCache --> ReleaseLock[释放锁]
ReleaseLock --> Resp[返回结果]
style IdmFilter fill:#fff3e0
style Cache fill:#e8f5e8
style DB fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 05.2 Token 防重方案
适用场景:表单提交、支付按钮等"用户主动触发"的操作。
sequenceDiagram
participant U as 用户
participant App as App
participant Server as 服务端
participant Redis as Redis
Note over U,Redis: 第一步:进入页面时取 Token
U->>App: 进入下单页
App->>Server: GET /token
Server->>Redis: SET token:abc123 → unused, ttl=30min
Server-->>App: token=abc123
Note over U,Redis: 第二步:提交时带上 Token
U->>App: 点击提交
App->>Server: POST /order, token=abc123
Server->>Redis: GETDEL token:abc123
alt Token 存在
Redis-->>Server: unused
Server->>Server: 处理业务
Server-->>App: 成功
else Token 不存在或已用
Redis-->>Server: nil
Server-->>App: 重复提交
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
核心逻辑:GETDEL 是原子操作——同一 Token 只能成功"消费"一次,第二次必然失败。
# 05.3 唯一索引方案
适用场景:创建类操作(订单、用户、支付单)。
-- 业务唯一键作为唯一索引
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
amount DECIMAL(10,2),
status VARCHAR(20),
UNIQUE KEY uk_request_id (request_id)
);
-- 插入:重复 requestId 会抛 DuplicateKey 异常
INSERT INTO orders(user_id, request_id, amount, status)
VALUES (?, ?, ?, 'WAITING_PAY');
2
3
4
5
6
7
8
9
10
11
12
13
捕获异常的两种处理:
fun createOrder(req: OrderRequest): Order {
return try {
orderDao.insert(req.toEntity())
} catch (e: DuplicateKeyException) {
// 重复请求,查首次记录返回
orderDao.findByRequestId(req.requestId)
?: throw IllegalStateException("数据不一致")
}
}
2
3
4
5
6
7
8
9
为什么唯一索引最可靠:DB 是数据的最后一道防线,缓存可以丢、应用可以重启,但 DB 唯一约束永远生效。
# 05.4 状态机方案
适用场景:状态流转类(订单支付、退款、发货)。
@Transactional
fun payOrder(orderId: String): PayResult {
val rows = orderDao.updateStatus(
orderId = orderId,
fromStatus = "WAITING_PAY",
toStatus = "PAID"
)
return when (rows) {
1 -> {
// 首次执行成功
triggerPaidEvents(orderId)
PayResult.SUCCESS
}
0 -> {
// 状态不对,可能已支付或已取消
val current = orderDao.findById(orderId)
when (current.status) {
"PAID" -> PayResult.ALREADY_PAID // 幂等返回
"CANCELLED" -> PayResult.CANCELLED
else -> PayResult.STATUS_ERROR
}
}
else -> error("unexpected rows: $rows")
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
关键:UPDATE 影响行数判断——这是最优雅的状态机幂等。
# 05.5 分布式锁方案
适用场景:高并发竞争资源(秒杀、抢购、防超卖)。
sequenceDiagram
participant R1 as 请求 1
participant R2 as 请求 2
participant Lock as Redis 锁
participant DB as DB
par 同时到达
R1->>Lock: SETNX key 1, ex=10
Lock-->>R1: OK 拿到锁
and
R2->>Lock: SETNX key 1, ex=10
Lock-->>R2: FAIL
R2->>R2: 直接拒绝或返回缓存
end
R1->>DB: 执行业务
R1->>Lock: DEL key
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注意:分布式锁性能差(每个请求多一次 Redis),只在真正高竞争场景用。
详见下一篇 18.分布式锁方案。
# 06.关键问题解决
# 06.1 幂等键设计
好的幂等键设计示例:
| 业务场景 | 幂等键构造 |
|---|---|
| 用户下单 | userId + orderTraceId + sku |
| 支付 | payOrderId + paymentChannel |
| 退款 | refundId + originalOrderId |
| 消息消费 | consumerGroup + topic + msgId |
| 资金转账 | srcAccount + dstAccount + serialNo |
避免的设计:
- ❌
userId + timestamp—— 同毫秒并发撞键 - ❌ 随机字符串 —— 重发时无法识别
- ❌ 自增 ID —— 客户端无法生成
# 06.2 结果回放问题
进阶问题:重复请求时,要不要返回完全一致的结果?
flowchart TD
Q[重复请求来了] --> Q1{要返回首次结果吗?}
Q1 -->|是 严格幂等| Plan1[幂等表存请求 + 响应]
Q1 -->|否 业务幂等就行| Plan2[只校验"已处理"<br/>返回当前状态]
Plan1 --> R1[需要存储响应内容]
Plan1 --> R2[空间成本]
Plan2 --> R3[实现简单]
Plan2 --> R4[但客户端要能识别"已处理"]
style Plan1 fill:#fff3e0
style Plan2 fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
幂等表设计:
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(50), -- 业务类型
idempotent_key VARCHAR(128), -- 幂等键
request_body TEXT, -- 原始请求(可选)
response_body TEXT, -- 首次响应
status VARCHAR(20), -- PROCESSING / SUCCESS / FAIL
created_at DATETIME,
expired_at DATETIME,
UNIQUE KEY uk_biz_key (biz_type, idempotent_key)
);
2
3
4
5
6
7
8
9
10
11
# 06.3 跨服务幂等
链路:A → B → C,三个服务都要幂等。
graph LR
Client -->|requestId=abc| A[服务 A]
A -->|透传 requestId=abc<br/>+生成 subId=a1| B[服务 B]
B -->|透传 requestId=abc<br/>+生成 subId=b1| C[服务 C]
A & B & C --> Note[每个服务都用 requestId 做自己的幂等]
style A fill:#e3f2fd
style B fill:#e8f5e8
style C fill:#fff3e0
2
3
4
5
6
7
8
9
10
核心:幂等键沿调用链透传——每个服务都用同一个 requestId 来识别"这是不是同一次外部请求"。
# 07.常见陷阱与反例
# 07.1 先做后判反例
反例:先扣款再判重,重复时回滚。
问题:
- 重复扣款的瞬间已经发生了资金变化
- 回滚可能失败(网络抖动)
- 多次调用银行接口浪费资源
正确:永远先判后做。
# 07.2 时间戳幂等反例
反例:用时间戳做幂等键。
// ❌ 错误
val key = "${userId}:${System.currentTimeMillis()}"
2
问题:
- 同毫秒并发撞键
- 跨机器时钟不同步
- 重试时时间戳变了,幂等失效
正确:用客户端生成的 UUID 或业务唯一 ID。
# 07.3 部分幂等反例
反例:接口"主流程"做了幂等,但"副作用"(发短信、写日志、扣积分)没做。
问题:
- 用户收到 2 条相同短信
- 积分被扣 2 次
- 业务上看起来"重复"
正确:所有副作用都要纳入幂等保护——要么全部跳过(已处理),要么打包在事务里。
mindmap
root((三大反例))
先做后判
资金已动
回滚有风险
银行调用浪费
时间戳幂等
并发撞键
时钟不同步
重试失效
部分幂等
副作用遗漏
短信重发
积分重扣
2
3
4
5
6
7
8
9
10
11
12
13
14
# 08.演进路线
# 08.1 V1 业务唯一约束
特征:业务起步、单体应用。
做法:
- 在关键表上加唯一索引
- 业务代码捕获 DuplicateKey
- 状态机控制流转
适用阶段:单体 / 小型微服务
# 08.2 V2 通用幂等中间件
特征:微服务规模化、需要标准化。
做法:
- 抽象幂等切面(注解 + AOP)
- 统一幂等表 / Redis 幂等键
- Token 防重组件
- 失败补偿机制
@Idempotent(key = "#req.requestId", expire = 24, unit = HOURS)
fun pay(req: PayRequest): PayResult { ... }
2
适用阶段:中大型微服务
# 08.3 V3 全链路幂等
特征:超大规模 / 多团队协同。
做法:
- 网关层注入 requestId 并透传
- 链路追踪整合
- 全链路幂等监控(重复率统计)
- 自动化补偿与对账
适用阶段:超大型分布式系统
flowchart LR
V1[V1 唯一约束<br/>简单业务] --> V2[V2 幂等中间件<br/>微服务标配]
V2 --> V3[V3 全链路幂等<br/>超大规模]
style V1 fill:#e3f2fd
style V2 fill:#e8f5e8
style V3 fill:#fff3e0
2
3
4
5
6
7
# 09.总结与决策
# 09.1 上线检查表
新增涉及"写"操作的接口前对照:
- [ ] 已识别幂等键(业务唯一标识)
- [ ] 幂等键由客户端生成(重试时不变)
- [ ] 选定幂等方案(唯一索引 / Token / 状态机 / 锁)
- [ ] 重复请求时返回明确语义(成功/已处理/拒绝)
- [ ] 副作用纳入幂等保护(短信、积分、消息)
- [ ] 幂等键有时效(不会无限期占用)
- [ ] DB 唯一约束作为最后一道防线
- [ ] 异常处理覆盖 DuplicateKey / 锁超时
- [ ] 客户端有按钮防抖(前端兜底)
- [ ] 监控覆盖(重复率、命中率)
- [ ] 测试覆盖(并发重发、超时重试场景)
# 09.2 选型决策树
flowchart TD
Start([我要做幂等]) --> Q1{业务类型?}
Q1 -->|创建/插入| UI{需返回首次结果?}
UI -->|否| UniqueIdx[唯一索引]
UI -->|是| IdmTable[幂等表 + 唯一索引]
Q1 -->|更新/状态流转| SM[状态机<br/>UPDATE WHERE status]
Q1 -->|高并发竞争| DL[分布式锁]
Q1 -->|表单提交| Token[Token 防重]
Q1 -->|消息消费| MsgIdm[消息 ID 去重<br/>+ 幂等表]
Q2([场景增强]) --> Pay{涉及钱?}
Pay -->|是| Combine[多重保险<br/>Token + 唯一索引 + 状态机]
Pay -->|否| Single[单一方案够用]
style UniqueIdx fill:#e8f5e8
style SM fill:#fff3e0
style Token fill:#e3f2fd
style Combine fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
最后一句话:幂等是分布式系统的"防重保险丝"——开篇 3700 个用户被扣两次款只是因为支付接口少了一个 requestId 字段。
好的幂等设计 = 业务唯一标识、先判后做、时效清晰、副作用全包。