幂等性设计方案
# 14.幂等性设计方案
本篇定位:幂等性是分布式系统的"防重保险丝"——网络抖动、客户端重试、消息重投、定时任务重跑都可能让同一个操作跑多次。做对了用户扣款只扣一次、消息处理只处理一次;做差了就是重复扣款、订单跳号、积分翻倍、库存扣穿。本文从一次 "3700 人被扣两次款、赔 73 万" 的支付事故讲起,回答七个刺入骨髓的问题——为什么分布式系统必然产生重复请求?"恰好一次"到底存不存在?六大幂等方案怎么选?幂等键怎么设计不撞车?副作用(短信/积分/消息)如何一起纳入幂等?
# 一、案例引入:3700 人扣两次款、赔 73 万的那一晚
# 1.1 事故现场:11 秒的银行响应差点毁掉一款 App
某电商大促晚 22:00,用户 A 在 App 上点击"支付 ¥199"——11 秒后页面依然转圈。用户 A 心里嘀咕"是不是没点上?"再点了一次。
服务端视角:
- 22:00:03 收到用户 A 的支付请求 #1,调用银行接口——银行响应正常但耗时 11 秒(后端偶发慢查询)。
- 22:00:14 银行返回"扣款成功"——支付服务写入订单成功,返回 App。
- 22:00:11 用户 A 已经点了第二次——请求 #2 到达支付服务。
- 22:00:11 支付服务又发起一次银行扣款——因为没有幂等保护,接口层完全把两次当独立请求处理。
- 22:00:22 第二次扣款成功——用户 A 的账户少了 ¥398,订单表里有两条 ¥199 的订单。
同一晚 3700 个用户被同样坑。事故第二天:
- 客服中心接到 3700+ 投诉,3 天连轴转。
- 财务紧急退款 + 补偿总计 ¥73 万。
- App 评分从 4.5 星掉到 2.1 星(半个月才修复回来)。
- 金融监管部门约谈——"重复扣款"属于金融事故。
- 内部通报:全公司 P0 事故,责任团队被绩效扣分。
# 1.2 顺藤摸根因:不是"用户手快",是"接口没兜底"
复盘会锁定 7 处设计缺陷(每一处都是分布式系统的经典雷区):
| # | 缺陷 | 直接后果 |
|---|---|---|
| ① | 支付接口没有 requestId / 幂等键 | 服务端根本分不清"两次请求"还是"两个请求" |
| ② | 客户端没有按钮防抖 / Loading 遮罩 | 用户可以疯狂点 |
| ③ | 银行接口本身正常但慢,没有客户端超时 | 用户以为失败 |
| ④ | 支付状态机不严 —— 已支付订单再次支付 UPDATE 不加 status='WAIT' 条件 | 重复支付通过 |
| ⑤ | 副作用(发短信通知、扣积分、发红包)没纳入幂等 | 用户收到 2 条通知,积分扣 2 次 |
| ⑥ | 幂等表 / 唯一索引一个都没做 | DB 层根本挡不住 |
| ⑦ | 事后没有对账机制发现异常 | 3700 人重复扣款直到用户投诉才知道 |
这 7 处联合作用——事故的规模指数放大。
# 1.3 七个"为什么":疑惑清单
复盘会上灵魂拷问:
- 分布式系统里为什么必然会有重复请求? 我不能设计成"客户端只发一次"吗?
- HTTP 语义里的
GET/POST/PUT/DELETE幂等性是怎么定义的? 遵循了就没事吗? - 业界都在说的"At-least-once"、"Exactly-once"、"At-most-once"到底啥区别? 恰好一次真的能实现吗?
- 六大幂等方案(唯一索引/Token/状态机/乐观锁/分布式锁/幂等表)在什么场景各自最合适?
- 幂等键(requestId / idempotent-key)该由谁生成? 用时间戳可以吗?
- 副作用(短信、积分、MQ 消息)怎么和主业务一起做幂等?
- 重复请求来了,是"直接拒绝"还是"返回首次结果"? 差别在哪?
这 7 问就是本文的骨架。走完第 10 章会全部回答。
# 二、架构决策三角:正确性 × 性能 × 简洁
幂等设计本质是在三个维度做联合最优化:
正确性(绝不重复执行、零丢单)
▲
/│\
/ │ \
/ │ \
/ 幂 │ 等\
/ 系 │ 统 \
/ 决 │ 策 \
────────┼──────
性能(每请求增开销 <5ms) 简洁(业务代码不侵入)
- 只求正确性 → 每个请求走分布式锁 + 双写幂等表——QPS 掉一半。
- 只求性能 → 全用 Redis 缓存——极端场景(Redis 挂)依然会漏。
- 只求简洁 → 全交给 DB 唯一索引——遇到"重复请求返回原结果"需求就不够。
架构选择的本质:"接受哪个代价"。金融接受性能损失换绝对正确、社交接受偶发重复换极致性能、B 端接受业务侵入换简洁明确。
# 三、幂等的"存在本质":分布式系统里没有"恰好一次"
要理解为什么必须做幂等,先要理解为什么分布式系统一定会有重复请求。
# 3.1 网络通信的三种消息语义
分布式系统里的每一次网络通信,都只能是这三种语义之一:
| 语义 | 定义 | 代价 |
|---|---|---|
| At-most-once | 至多一次——可能丢,不会重 | 发一次不管成没成 |
| At-least-once | 至少一次——可能重,不会丢 | 失败就重试 |
| Exactly-once | 恰好一次——不丢不重 | 理论上通信层做不到 |
为什么"恰好一次"是理论谎言:客户端发送请求后收不到响应,它无法区分:
- 情况 A:请求根本没到服务端(该重试)。
- 情况 B:请求到了但响应丢了(不该重试,会重复)。
任何单纯的通信层协议都不能区分这两种情况——所以"Exactly-once"在通信层做不到。
# 3.2 唯一的正解:At-least-once + 服务端幂等 = Effectively-once
客户端负责:at-least-once(不丢,可以重试)
服务端负责:幂等(重复请求不产生副作用)
──────────
组合效果:Effectively-once(业务上等价于恰好一次)
这就是分布式系统里"恰好一次"的唯一实现方式。Kafka Exactly-once、TCC 事务、支付重试——底层全是这个套路。
# 3.3 重复请求的六大来源(不是"用户手快"这么简单)
① 用户层
・多次点击按钮
・浏览器后退再前进
・刷新页面表单重发
② 客户端层
・HTTP 超时后重试
・断网重连补发
・App 后台进程唤起补发
③ 网络层
・LB 超时重试(后端可能已经处理成功)
・代理层重传
④ 服务层
・RPC Client 失败重试(Ribbon / Feign 默认 2 次)
・幂等 Ribbon 重试 GET 也会重试 POST(配错的话)
⑤ 消息队列层
・MQ 至少一次投递
・消费失败 NACK 重试
・Rebalance 时同一消息投多个消费者
⑥ 运维层
・定时任务重跑
・故障恢复后补偿
・人工重发脚本
每一层都可能产生重复——你堵得住用户,堵得住 LB 吗?堵得住 MQ 吗?结论:只能在服务端做幂等。
# 四、HTTP 语义的幂等性:REST 规范和它的陷阱
RESTful 规范里定义了 HTTP 方法的幂等性——但仅仅遵循 HTTP 语义远远不够。
# 4.1 HTTP 方法幂等性表
| 方法 | 幂等? | 安全? | 说明 |
|---|---|---|---|
| GET | ✅ | ✅ | 只读,天然幂等 |
| HEAD | ✅ | ✅ | 只读元信息 |
| PUT | ✅ | ❌ | 整体替换 —— 多次替换结果相同 |
| DELETE | ✅ | ❌ | 多次删同一资源,效果相同 |
| POST | ❌ | ❌ | 每次新建资源——天然不幂等 |
| PATCH | ⚠️ | ❌ | 视操作而定(age+1 就不幂等) |
# 4.2 陷阱一:语义幂等 ≠ 实现幂等
HTTP 语义说 PUT /user/123 {name:"A"} 幂等——是设计上如此,不是实现上如此。你的实现可能是:
@PutMapping("/user/{id}")
void updateUser(@PathVariable Long id, @RequestBody User u) {
userDao.update(id, u); // 幂等 ✓
auditLog.record("update user"); // ❌ 每次都写审计日志
mqTemplate.send("user_changed"); // ❌ 每次都发消息
}
主操作幂等,副作用不幂等——重复请求的用户会看到审计日志双份、下游收到两条消息。这就是本文事故第 ⑤ 条。
# 4.3 陷阱二:POST 天然不幂等,需要"业务幂等"
创建订单、支付、注册——这些天然是 POST,但业务上必须幂等。HTTP 帮不了你,只能应用层设计。
# 4.4 陷阱三:GET 也不总是幂等
GET /user/123/click_ad?adId=456
这个 GET 其实记录了一次广告点击——读接口有副作用,重复请求就是重复计数。GET 的幂等语义是"约定",如果你违反了约定,重复就来了。
结论:RESTful 语义只是起点,不是终点。想真正幂等,看下面六大方案。
# 五、六大幂等方案的谱系:怎么选不踩坑
# 5.1 六大方案对比矩阵
| 方案 | 原理 | 实现难度 | 性能 | 支持"返回首次结果" | 适合 |
|---|---|---|---|---|---|
| 唯一索引 | DB 唯一约束 | ⭐ | ⭐⭐⭐⭐ | ❌ | 创建类(下单/注册) |
| 状态机 | UPDATE WHERE 条件 | ⭐⭐ | ⭐⭐⭐⭐ | ⚠️ 查库返回状态 | 状态流转(支付/发货) |
| 乐观锁 (version) | UPDATE ... WHERE version=? | ⭐⭐ | ⭐⭐⭐⭐ | ❌ | 库存扣减、计数 |
| Token 防重 | 提交前领 Token,消费一次即失效 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ❌ | 表单提交、支付按钮 |
| 分布式锁 | 加锁串行化 | ⭐⭐⭐ | ⭐⭐ | ❌ | 高竞争(抢购/秒杀) |
| 幂等表 | 请求键+结果一起存表 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | 通用接口幂等 |
# 5.2 决策链:从业务类型出发
是查询接口? ── 是 ─→ 天然幂等,不用做
│
否
↓
是创建资源? ─── 是 ─→ 唯一索引(业务唯一键 + DB unique)
│ 需要返回首次结果? 加"幂等表"
否
↓
是状态流转? ─── 是 ─→ 状态机(UPDATE ... WHERE status=?)
│
否
↓
是计数/扣减? ─── 是 ─→ 乐观锁(version 字段 或 UPDATE 加条件)
│
否
↓
是表单提交? ─── 是 ─→ Token 防重(GETDEL 原子消费)
│
否
↓
高竞争场景? ── 是 ─→ 分布式锁(详见第 15 篇)
│
否
↓
通用接口需返回原结果? ─→ 幂等表(请求键+响应体)
# 5.3 方案一:唯一索引——DB 的最后一道防线
核心思想:业务唯一键 = DB 唯一索引——重复插入直接抛 DuplicateKey 异常。
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),
created_at DATETIME,
UNIQUE KEY uk_request_id (request_id) -- 关键
);
代码:
public Order createOrder(OrderRequest req) {
try {
return orderRepo.insert(req.toEntity());
} catch (DuplicateKeyException e) {
// 说明重复请求 —— 查出首次记录返回
return orderRepo.findByRequestId(req.getRequestId())
.orElseThrow(() -> new IllegalStateException("data inconsistent"));
}
}
为什么 DB 唯一索引最可靠:
- DB 是数据的最后一道防线——缓存可以丢、应用可以重启,但 DB 唯一约束永远生效。
- 零并发问题——DB 引擎的 unique 约束是原子的,不管多少个请求同时来。
局限:无法返回原结果(DuplicateKey 抛出时结果已经"发生"了,但拿不回原来的响应体)。想要"重复请求返回首次结果"必须叠加幂等表。
# 5.4 方案二:状态机——最优雅的业务幂等
核心思想:业务状态只能按规定路径流转——重复的状态转换会被 WHERE 条件挡掉。
订单状态机:
┌─→ WAIT_PAY ─→ PAID ─→ SHIPPED ─→ DELIVERED
│ │
└── CANCELLED └─→ REFUNDING ─→ REFUNDED
代码:
@Transactional
public PayResult payOrder(String orderId) {
// UPDATE 加 fromStatus 条件
int rows = orderRepo.updateStatus(orderId, "WAIT_PAY", "PAID");
if (rows == 1) {
// 首次成功,触发副作用
eventPublisher.publish(new OrderPaidEvent(orderId));
return PayResult.SUCCESS;
} else {
// rows=0 —— 状态已经不是 WAIT_PAY,可能已支付
Order current = orderRepo.findById(orderId);
return switch (current.getStatus()) {
case "PAID" -> PayResult.ALREADY_PAID; // 幂等返回
case "CANCELLED" -> PayResult.CANCELLED;
default -> PayResult.STATUS_ERROR;
};
}
}
关键 SQL:
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_id = ?
AND status = 'WAIT_PAY'; -- 状态机的核心:加原状态条件
影响行数是判断的关键:
rows == 1:首次执行成功。rows == 0:已经执行过(幂等命中)或状态不允许。
优雅之处:零额外表 / 零额外锁——利用业务本身的状态约束就搞定了幂等。
# 5.5 方案三:Token 防重——防"用户手快"的专用武器
核心思想:用户操作前先领一个一次性 Token,提交时消费——同一 Token 只能消费一次。
流程:
① 用户打开下单页
Client → Server: GET /pay/token
Server: 生成 UUID → Redis SET token:xxx=UNUSED, TTL=30min
Server → Client: token=xxx
② 用户点击支付
Client → Server: POST /pay {..., token: xxx}
Server: Redis GETDEL token:xxx ← 关键:原子取出并删除
├── 返回 UNUSED → 首次提交,处理业务
└── 返回 nil → 重复提交,拒绝
代码:
public PayResult pay(PayRequest req) {
// GETDEL 是原子操作,一个 Token 只能成功消费一次
String tokenStatus = redis.execute("GETDEL", "pay_token:" + req.getToken());
if (tokenStatus == null) {
throw new DuplicateSubmitException("please don't submit twice");
}
return doPayment(req);
}
Redis 版本要求 ≥ 6.2.0(GETDEL 命令自 6.2.0 引入)。旧版本用 Lua 脚本:
local val = redis.call('GET', KEYS[1])
if val then redis.call('DEL', KEYS[1]) end
return val
优缺点:
- ✅ 用户端友好——一按到底直接拒绝,不涉及业务处理。
- ✅ 性能极佳——只是一次 Redis 操作。
- ❌ 只能防"提交前领了 Token 的场景"——API 直接调用(脚本、爬虫)绕过这个。
结论:Token 只是"用户体验层"防重,服务端还得叠加其他方案(唯一索引 / 状态机)兜底。
# 5.6 方案四:乐观锁——扣库存 / 计数场景
UPDATE stock
SET count = count - 1, version = version + 1
WHERE sku_id = ?
AND version = ? -- 乐观锁:拿到时的版本号
AND count > 0; -- 业务约束
影响行数 = 0 说明要么版本变了(有并发)、要么库存扣完了——业务层判断处理。
推广版:不用 version 字段,用业务字段自身做条件:
-- 转账扣款:只有余额 >= 金额才能扣
UPDATE account SET balance = balance - 100 WHERE user_id = ? AND balance >= 100;
# 5.7 方案五:分布式锁——高竞争的最后手段
思想:加分布式锁把并发操作串行化——详见第 15 篇分布式锁专题。
注意:分布式锁不是"幂等"的银弹——它防的是"并发冲突"而非"同一请求重复"。如果同一个 requestId 前后跨越了锁的过期时间,仍然会重复执行。分布式锁 + 幂等键才是完整方案。
# 5.8 方案六:幂等表——最通用的方案
核心思想:请求进来时,先把 (bizType, idempotentKey) 插到幂等表,作为"我要开始处理这个请求"的标记;处理完成后回写响应。
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(50) NOT NULL, -- 业务类型
idempotent_key VARCHAR(128) NOT NULL, -- 幂等键
status VARCHAR(20) NOT NULL, -- PROCESSING / SUCCESS / FAIL
request_body TEXT, -- 原始请求(可选,用于对账)
response_body TEXT, -- 首次响应
created_at DATETIME,
completed_at DATETIME,
expired_at DATETIME,
UNIQUE KEY uk_biz_key (biz_type, idempotent_key)
);
流程:
public Response handle(Request req) {
String key = req.getIdempotentKey();
String bizType = "pay";
// Step 1: 插入 PROCESSING 记录(DB 唯一索引保证原子)
try {
idempotentRepo.insert(bizType, key, "PROCESSING", req.toJson());
} catch (DuplicateKeyException e) {
// 说明是重复请求 —— 查已有记录
IdempotentRecord existing = idempotentRepo.findByBizAndKey(bizType, key);
return switch (existing.getStatus()) {
case "SUCCESS" -> Response.fromJson(existing.getResponseBody());
case "PROCESSING" -> Response.processing("in progress"); // 首次还在处理
case "FAIL" -> Response.fail("previous failed");
};
}
// Step 2: 处理业务
Response resp;
try {
resp = doBusiness(req);
idempotentRepo.updateResponse(bizType, key, "SUCCESS", resp.toJson());
} catch (Exception e) {
idempotentRepo.updateResponse(bizType, key, "FAIL", null);
throw e;
}
return resp;
}
优点:
- 重复请求返回首次结果——用户体验一致。
- 通用——任何接口都能套用。
代价:
- 每次请求多 1~2 次 DB IO。
- 幂等表可能很快膨胀,需要定期清理过期记录(比如 7 天前的 PROCESSING/SUCCESS 归档)。
# 六、幂等键设计:微不足道却决定生死
幂等键(idempotent-key、request-id、trace-id) 是幂等的"锚点"——设计不好,方案再对也白搭。
# 6.1 好的幂等键的三大特征
特征一:客户端生成,客户端持久化。
必须客户端生成——服务端生成的话,客户端每次重试都会拿到新的 ID,幂等就废了。
必须持久化——App 重启、页面刷新后,同一个业务的重试要保持同一个键。
特征二:业务唯一性。
不同业务操作要不同的键(否则会互相干扰)。推荐组合:
requestId = ${bizType}:${businessKey}:${clientNonce}
例:
"pay:U123:O456:a3f8b2" 支付
"refund:U123:R789:c9d1e5" 退款
"send_msg:conv999:m444:xyz" 发消息
特征三:足够随机 + 长度控制。
UUID.v4() 是首选(128 位随机,撞概率 = 每秒生成 100 亿个,261 亿年才有 50% 撞率)。
避免以下作法:
- ❌ 用时间戳:同一毫秒并发会撞。
- ❌ 用自增 ID:客户端间独立,无法全局唯一。
- ❌ 用随机数(
Random.nextInt):Java 默认 Random 只有 48 位种子,撞概率不能忽视。 - ❌ 用用户手机 MD5:不同请求撞键。
# 6.2 幂等键场景速查
| 业务场景 | 幂等键构造 |
|---|---|
| 下单 | ${userId}:${clientRequestId} |
| 支付 | ${orderId}:${paymentChannel} |
| 退款 | ${refundId} |
| 转账 | ${srcAccount}:${dstAccount}:${clientSerialNo} |
| 消息消费 | ${consumerGroup}:${topic}:${partition}:${offset} |
| 定时任务 | ${jobName}:${executionDate}:${bucketNo} |
# 6.3 幂等键的传递
关键要求:跨服务调用时,幂等键要沿链路透传:
Client (生成 reqId=abc) ─→ Gateway ─→ Service A ─→ Service B ─→ Service C
│ │ │ │
全部使用同一个 reqId=abc 做自己的幂等
做法:HTTP Header 里放 X-Request-Id(约定俗成):
- 网关生成或校验(如果客户端没带就自动生成)。
- 后端每层调用透传给下一层。
- traceId 与 requestId 可以是同一个(就是"链路追踪 + 幂等"合一)。
# 6.4 幂等键的时效
幂等键不能无限保留(DB 会爆),也不能太短(重试还没到期就失效)。经验值:
| 业务 | 保留时长 | 理由 |
|---|---|---|
| 表单 Token | 30 分钟 | 用户填写 + 提交时间 |
| 支付幂等 | 24 小时 | 常见重试周期 |
| 通用 API | 1 小时 | 大多数客户端重试都会在此内 |
| 消息消费 | 7 天 | MQ 最长重投窗口 |
| 定时任务 | 30 天 | 跨月对账场景 |
# 七、副作用一起做幂等:本文事故的核心教训
事故里的第 ⑤ 条——"副作用没纳入幂等"是新手最容易漏的坑。
# 7.1 副作用是什么
主业务:扣款、生成订单。 副作用:发短信、扣积分、增经验值、发 MQ 消息、写审计日志、发 Push、更新推荐画像……
副作用往往分散在多个服务、多个存储——如果只对主业务做了幂等,用户会看到:
- 只扣一次钱(主业务对了)。
- 但收到 2 条短信(副作用没保护)。
- 积分扣 2 次(另一个服务的另一个副作用)。
- 推荐画像更新 2 次(下游 MQ 消费两次)。
用户投诉根本讲不清"到底出了什么问题" —— 表面正常,细节全乱。
# 7.2 三种处理方式
方式一:全部一起放事务。
@Transactional
public void pay(PayRequest req) {
// 主业务
orderRepo.updateStatus(req.getOrderId(), "PAID");
// 副作用(同库同事务)
accountRepo.deduct(req.getUserId(), req.getAmount());
pointRepo.deduct(req.getUserId(), req.getPointsUsed());
auditRepo.insert(new AuditLog("PAY", req));
// 消息通过 事务消息 或 本地消息表(详见第 8 篇)
localMsgRepo.insert(new PendingMessage("SMS", req.getUserId(), "支付成功"));
}
同一事务内的操作要么都成、要么都失败——天然幂等(因为主业务的状态机会挡)。
方式二:主业务幂等键作为副作用的幂等键。
如果副作用跨库/跨服务,无法放同一事务:
public void pay(PayRequest req) {
// 主业务(DB 幂等)
if (orderRepo.updateStatus(...) == 0) return; // 已支付
// 副作用 - 每个都用主业务的幂等键
smsService.send(req.getUserId(), "支付成功", req.getRequestId()); // 短信服务用同一 key 幂等
pointService.deduct(req.getUserId(), 100, req.getRequestId()); // 积分服务同上
mqService.publish("pay_success", req.toJson(), req.getRequestId()); // MQ 用同一 key 去重
}
关键:每个下游服务的接口都要接收幂等键并自己做幂等——这是"合约",业务代码里必须写。
方式三:事件驱动 + 消息最终一致。
主业务写好 → 发消息 → 下游订阅消费——每个消费者自己保证幂等(消息 ID 或业务键)。这是微服务时代的标准做法(详见第 8 篇 MQ 专题)。
# 7.3 副作用漏掉一个都不行的心法
心法一:副作用清单化。
每个接口开发前列一张表——这个操作会产生哪些副作用:
支付接口的副作用清单:
□ 扣余额(DB)
□ 扣积分(RPC)
□ 加经验(RPC)
□ 发支付短信(RPC)
□ 发 App Push(RPC)
□ 发 MQ pay_success(MQ)
□ 更新推荐画像(MQ)
□ 写审计日志(DB)
每一项都要标注"如何幂等"——填不出来的就是隐患点。
心法二:先做后判 → 变成 → 先判后做。
对每个副作用都问一句:"如果它发生了两次,用户能不能忍" ——不能忍的必须先判后做。
# 八、反例合集:三种典型翻车
# 8.1 反例一:先做后判(本文事故的第一个坑)
// ❌ 错误:先扣款再判重
public PayResult pay(PayRequest req) {
// 立即调银行扣款
BankResult r = bank.deduct(req.getUserId(), req.getAmount());
// 事后判重
if (idempotentRepo.exists(req.getRequestId())) {
bank.refund(req.getUserId(), req.getAmount()); // 回滚
return PayResult.DUPLICATE;
}
return PayResult.SUCCESS;
}
问题:
- 重复请求瞬间资金已经动了——回滚可能失败(银行接口挂了)。
- 多次调用银行接口浪费额度、增加成本。
- 出问题时监管审计——"你自己承认知道重复了,为啥还扣了钱"。
修正:永远先判后做。判重通过再动业务。
# 8.2 反例二:时间戳当幂等键
// ❌ 错误:用时间戳
String key = req.getUserId() + ":" + System.currentTimeMillis();
问题:
- 同毫秒并发必然撞键(客户端点两次 <1ms 完全正常)。
- 跨机器时钟不同步——两台机器 T=100ms 差异,同一 key 出现 100ms 差。
- 重试时时间戳变了——服务端根本识别不出是同一请求。
修正:客户端 UUID + 客户端持久化——重试用同一个。
# 8.3 反例三:只做主业务、副作用漏掉(本文事故第 ⑤ 条)
public void handle(Request req) {
if (orderRepo.updateStatus(...) > 0) {
smsService.send(...); // ❌ 没传幂等键
pointService.deduct(...); // ❌ 没传幂等键
mqTemplate.send(...); // ❌ 消费者没做幂等
}
}
表面看没问题——主业务的状态机确实挡住了重复。但如果客户端超时重试:
- 请求 #1 主业务失败在 sms 前——重试
- 请求 #2 主业务成功——发 SMS + 扣积分 + 发 MQ
- 请求 #1 那次可能又被处理——再发一次 SMS + 扣积分
修正:把 requestId 传给每个下游,下游自己幂等。
# 8.4 反例演进:V0 → V3
V0(事故版,本文案例):
✗ 接口无 requestId
✗ 客户端无防抖
✗ 状态机 UPDATE 不带条件
✗ 副作用未纳入幂等
✗ 无对账
V1(补救版):
✓ 接口加 requestId(客户端 UUID)
✓ 状态机严格 UPDATE ... WHERE status='WAIT_PAY'
✓ 前端按钮防抖 + Loading 遮罩
✓ 银行接口客户端超时 8s
V2(工程化):
✓ AOP 注解幂等中间件 @Idempotent(key="#req.requestId")
✓ 幂等表存请求响应
✓ 副作用统一透传 requestId
✓ 每日对账任务
V3(全链路):
✓ 网关注入 X-Request-Id
✓ 链路追踪整合
✓ 全链路重复率监控
✓ 自动补偿 + 差错处理
# 九、幂等 AOP 中间件:一个注解搞定 80% 场景
生产实践里,把上面的方案统一封装成注解,业务代码零侵入。
# 9.1 目标使用方式
@Idempotent(key = "#req.requestId", bizType = "pay",
expireHours = 24, mode = IdempotentMode.RETURN_FIRST_RESULT)
public PayResult pay(PayRequest req) {
return doPayment(req);
}
一行注解等价于本文所有方案组合——Token 校验 + 幂等表 + 状态机 + 返回首次结果。
# 9.2 核心切面实现
@Aspect
@Component
public class IdempotentAspect {
@Around("@annotation(idem)")
public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
String key = parseSpEL(idem.key(), pjp); // 解析注解里的 SpEL
String bizType = idem.bizType();
// Step 1: 尝试插入 PROCESSING
boolean firstTime = idempotentRepo.tryInsertProcessing(
bizType, key, idem.expireHours()
);
if (!firstTime) {
// 重复请求
IdempotentRecord r = idempotentRepo.find(bizType, key);
return switch (r.getStatus()) {
case "SUCCESS" -> deserialize(r.getResponseBody(), pjp.getSignature());
case "PROCESSING" -> throw new DuplicateProcessingException();
case "FAIL" -> throw new PreviousFailedException();
};
}
// Step 2: 执行业务
try {
Object result = pjp.proceed();
idempotentRepo.updateSuccess(bizType, key, serialize(result));
return result;
} catch (Throwable ex) {
idempotentRepo.updateFail(bizType, key);
throw ex;
}
}
}
# 9.3 生产环境的坑
坑一:response 序列化 / 反序列化。 返回值可能包含 LocalDateTime、BigDecimal——Jackson 需要配置好类型信息。
坑二:PROCESSING 卡死。 服务突然 kill,幂等表里留了 PROCESSING 记录——必须有兜底扫描任务把超过 N 分钟的 PROCESSING 认定为 FAIL。
坑三:热点 key。 同一 key 短时间大量重试——DB 会热点。加 Redis 一层预判减轻 DB 压力。
# 十、综合案例串讲:把 7 问全部回扣
# 10.1 逐条回答一开始的 7 问
| # | 疑问 | 答案(章节) |
|---|---|---|
| ① | 分布式系统为什么必有重复请求? | §3:六大来源 + 通信层永远做不到 Exactly-once |
| ② | HTTP 幂等语义够吗? | §4:只是约定 + 主业务,副作用要单独做 |
| ③ | 三种消息语义? | §3:At-least-once + 幂等 = Effectively-once |
| ④ | 六大方案怎么选? | §5:决策链(创建/流转/表单/竞争/通用) |
| ⑤ | 幂等键怎么设计? | §6:客户端 UUID + 业务前缀 + 链路透传 |
| ⑥ | 副作用怎么处理? | §7:清单化 + 透传 requestId + 每个下游自己做 |
| ⑦ | 拒绝还是返回原结果? | §5.8:幂等表模式 = 返回原结果 |
# 10.2 事故重演:如果当时用了本文方案
回到开头 3700 人重复扣款:
若当时用了完整方案:
- 客户端生成 UUID requestId、按钮防抖 → 用户点两次也是同一 requestId。
- 接口层
@Idempotent注解 → 请求 #2 进入时幂等表检测到 PROCESSING → 直接返回"支付进行中"。 - 状态机 UPDATE 加 status 条件 → 就算 #2 到了 Bank 调用前,UPDATE 也会返回 0 行 → 走幂等分支。
- 副作用透传 requestId → SMS/积分/MQ 都不会发两遍。
- 每日对账 → 就算前几层全漏掉,对账任务 T+1 会发现"用户 A 有两条 Order 单号不同但一模一样",触发告警。
结果:3700 人变 0 人,¥73 万损失变 0,公司无需被监管约谈。
# 10.3 一次支付的"幂等一生"完整时序
── T0: 用户点"支付 ¥199" ─────────────────
Client:
1. 生成 requestId = UUID.v4() = "a3f8b2c4-..."
2. 持久化到本地 sqlite (防 App kill 后重发)
3. 按钮进入 loading 状态
── T0+50ms: 请求到达服务端 ─────────────
Gateway:
- X-Request-Id 校验/注入
- 转发到 pay-service
── T0+55ms: 进入 @Idempotent 切面 ─────
1. 从入参提取 requestId
2. Redis SETNX pay:req:a3f8b2c4 = PROCESSING (TTL=24h)
├── 首次 = true → 继续
└── 首次 = false → 查 Redis 已有结果,返回
── T0+60ms: 业务处理 ────────────────
UPDATE orders
SET status='PAID'
WHERE order_id=? AND status='WAIT_PAY';
Bank RPC (with X-Request-Id header)
- Bank 内部也做幂等(银行侧的责任)
── T0+2s: 用户不耐烦,又点了一下 ─────
Client:
1. 检测 sqlite 里有 pending requestId a3f8b2c4 → 复用!
2. 重新发起 HTTP,request body 完全一样
── T0+2.05s: 服务端第二次收到 ─────
1. @Idempotent 切面:Redis GET pay:req:a3f8b2c4 = PROCESSING
2. 返回 "支付进行中,请稍候"
3. 客户端收到 → 显示"正在处理"
4. Bank 没被调用第二次,副作用零重复
── T0+8s: 首次 Bank 处理完,主流程回来 ─────
1. UPDATE 成功
2. 副作用:
- SMS 服务(透传 requestId)
- 积分服务(透传 requestId)
- MQ 发消息 pay_success(transactionId=requestId)
3. Redis SET pay:req:a3f8b2c4 = 结果 JSON, TTL=24h
── T0+8.1s: 客户端收到成功响应 ─────
显示"支付成功"
── T+8s ~ 24h: 期间任何重试 ─────
只要 requestId 一致,一律返回相同的成功响应
副作用零重复触发
── T+24h: 幂等键过期 ────────────
Redis TTL 到期 → 数据消失
(此时同 requestId 再来,视为新请求,但业务上已完成)
── T+1 day 03:00: 对账任务 ─────
扫描 T-1 天所有支付
- 双重支付检测(同 userId 同金额同 skuId 5min 内多单)
- 异常告警(人工介入)
# 10.4 四条幂等设计哲学
哲学一:"恰好一次"是双方的合约。 客户端 at-least-once + 服务端幂等 = effectively-once。任何一方偷懒,合约就废。别指望"网络稳定就不用做幂等"。
哲学二:先判后做,别搞逆天回滚。 主业务发生了 = 副作用可能已经发出去了。事后回滚永远不如事前拦截。特别是涉及第三方接口(银行、支付、短信),回滚比不上钱难要回。
哲学三:幂等键是"业务的 DNA",不能随便造。 客户端生成 + 客户端持久化 + 业务前缀 + 链路透传——这四条缺一不可。用时间戳、随机数、自增 ID 的都是给未来事故埋雷。
哲学四:主业务幂等只是入门,副作用幂等才是修行。 SMS、积分、Push、MQ、审计日志、推荐画像——每一个都要有幂等键。事故不是因为主业务错了,而是因为副作用没管。
# 10.5 幂等设计速查表
| 决策点 | 首选 | 备选 | 禁忌 |
|---|---|---|---|
| 幂等键生成 | 客户端 UUID + 持久化 | 客户端 UUID | 服务端生成 / 时间戳 |
| 幂等键长度 | UUID (36 字符) | Snowflake ID | Random 短字符串 |
| 创建类操作 | 唯一索引 + 幂等表 | 唯一索引 | 无约束 |
| 状态流转 | 状态机 UPDATE WHERE | 分布式锁 | 直接 UPDATE 无条件 |
| 表单提交 | Token GETDEL + 状态机双重 | Token 只用一层 | 无 Token 无状态机 |
| 通用接口 | @Idempotent 注解 + 幂等表 | Redis 存结果 | 每次业务代码里手写 |
| 副作用处理 | 透传 requestId + 每层幂等 | 事件驱动 + 消费者幂等 | 主业务外无保护 |
| 幂等键时效 | Redis 24h + DB 7 天 | 与 MQ 重试周期对齐 | 无过期 |
| 兜底 | 每日对账 + 差错告警 | 人工审计 | 完全依赖前端 |
# 10.6 上线检查清单(20 项)
幂等键
- [ ] 每个写接口都定义了幂等键字段
- [ ] 客户端生成 UUID + 本地持久化
- [ ] 链路透传(Gateway → Service → Service)
- [ ] 明确的时效(24h / 7d)
主业务
- [ ] 创建类:唯一索引
- [ ] 状态类:UPDATE 加 WHERE status
- [ ] 计数类:乐观锁或 UPDATE 加 balance>0
副作用
- [ ] 副作用清单化列出
- [ ] 每个副作用透传 requestId
- [ ] SMS/Push/积分/MQ 消费者自己做幂等
- [ ] 关键副作用与主业务同事务或走本地消息表
用户端
- [ ] 按钮防抖 + Loading 遮罩
- [ ] 显式重试提示(不是无限自动重试)
- [ ] 客户端超时设置合理
服务端
- [ ] @Idempotent 注解中间件覆盖率
- [ ] 幂等表定期清理
- [ ] PROCESSING 超时兜底扫描
- [ ] Redis 热点 key 预判
运维
- [ ] 每日对账 + 差错告警
- [ ] 重复率、命中率监控
- [ ] 演练"客户端疯狂重试"场景
# 十一、写在最后
幂等是"看不见的功课"——做对了没人看得见,做错了 3700 人被扣款、赔 73 万、监管约谈。分布式系统里,"恰好一次"是所有工程师追求的圣杯,实现路径只有一条——at-least-once + 服务端幂等。
幂等设计的三条底线:
- 重复请求是分布式系统的常态——不要假设"客户端不会重发"、"MQ 不会重投"。
- 主业务幂等只是起点,副作用幂等才是终点——用户看到的"重复"往往是副作用泄露。
- 兜底是最后一道防线,对账是幂等的救命稻草——即使前面全漏,T+1 对账能救回来。
下次面对"这个接口做下幂等"的需求时,希望你脑子里冒出的不是"加个 Redis 判重",而是——"幂等键怎么设计、副作用怎么覆盖、返回策略怎么定、对账怎么兜底"。这,就是幂等设计的真正功力。