2024 年我们的支付系统出过一次让人脊背发凉的事故。一个用户投诉说自己明明只下了一单,却被扣了两次钱。我们查流水,确实有两条一模一样的扣款记录,金额、商品、时间几乎完全相同,只差几百毫秒。复盘下来,过程是这样的:用户在网络卡顿时点了一下"支付",前端没及时收到响应,他又点了一下;与此同时,我们的网关因为第一次请求超时,自动做了一次重试。于是同一笔支付,在后端被实实在在地执行了两次。这不是某一行代码写错了,而是我们整条链路在设计时,就没有认真考虑过"同一个请求被执行多次"这件几乎必然会发生的事。投了几天把接口幂等性彻底梳理并改造了一遍,本文复盘这次实战。
问题背景
业务:电商支付系统,下单、支付、扣款链路
事故现象:
- 用户投诉:只下一单,被扣两次款
- 流水库里有两条几乎相同的扣款记录,相差 ~400ms
- 排查发现重复请求来自两个源:
a) 用户网络卡,手动点了两次"支付"
b) 网关首次请求超时,自动重试了一次
现场排查:
# 1. 看扣款接口,它就是"无脑执行"
@PostMapping("/pay")
public Result pay(@RequestBody PayRequest req) {
accountService.deduct(req.getUserId(), req.getAmount());
orderService.markPaid(req.getOrderId());
return Result.ok();
}
# 同样的入参调两次,它就老老实实扣两次钱
# 2. 看扣款 SQL,纯粹的相对更新
UPDATE account SET balance = balance - 100
WHERE user_id = 123;
# 执行两次 -> 余额被扣两次,没有任何防重
# 3. 数据库层面也没有约束
# 流水表 pay_record 没有任何唯一索引
# 同一个 order_id 可以插入任意多条扣款流水
根因:
1. 重复请求是必然的:用户重复点击、网关/RPC 重试、
MQ 重投……这些都无法靠"叫用户别点"来避免
2. 扣款接口没有任何幂等设计,来一次执行一次
3. 数据库没有唯一约束兜底,重复数据能直接落库
4. 团队没有"哪些接口必须幂等"的意识和规范
修复 1:先想清楚什么是幂等、谁需要幂等
=== 幂等的定义 ===
一个接口是"幂等"的,指的是:
用【相同的参数】调用它一次,和调用它多次,
对系统产生的【最终影响是一样的】。
注意:不是"返回值一样",而是"对系统状态的影响一样"。
=== 一个直观的对比 ===
- "把余额设置为 100" -> 幂等(调多少次,余额都是 100)
- "把余额减少 100" -> 不幂等(调 N 次就减 N 个 100)
- 查询、删除某个 id -> 天然幂等
- 新增一条记录、相对扣减 -> 天然不幂等,要额外设计
=== 为什么"重复请求"是必然的,躲不掉 ===
1. 用户侧:网络一卡,用户就会下意识重复点击、刷新
2. 网关 / RPC 框架:请求超时后,框架会【自动重试】
—— 它根本不知道第一次到底成没成功
3. 消息队列:为保证"至少一次投递",MQ 在不确定时
会重投消息,消费者必然会收到重复消息
4. 前端:页面回退、表单重复提交
结论:你无法消灭重复请求。
你能做的,是让接口在收到重复请求时【不出错】。
这就是幂等设计要解决的问题。
=== 哪些接口必须做幂等 ===
判断标准:这个接口会不会"改变系统状态"?
- 查询接口:天然幂等,不用管
- 写接口,尤其涉及钱、库存、积分、状态流转的:
必须做幂等。下单、支付、扣款、退款、发券……
一个都不能漏。
重复执行一次查询无所谓,
重复执行一次扣款就是事故。
修复 2:唯一索引——最朴素也最可靠的幂等
-- === 最底层的一道防线:数据库唯一索引 ===
-- 思路:让"同一笔业务"在表里物理上只能存在一条记录。
-- 重复请求想插入第二条时,数据库直接用唯一约束把它顶回去。
-- 给支付流水表,按"业务唯一键"建唯一索引
ALTER TABLE pay_record
ADD UNIQUE INDEX uk_order (order_id);
-- 一个订单,只允许有一条支付流水。
-- === 扣款逻辑:先插流水,插成功了才扣款 ===
-- 第一次请求:insert 成功 -> 继续扣款
-- 第二次请求:insert 撞唯一索引失败 -> 说明已处理过,直接返回
INSERT INTO pay_record (order_id, user_id, amount, status)
VALUES (20240115001, 123, 100, 'PAYING');
-- 第二次执行这条,会抛 Duplicate entry for key 'uk_order'
-- === 如果唯一键是组合的,就用组合唯一索引 ===
ALTER TABLE coupon_grant
ADD UNIQUE INDEX uk_user_activity (user_id, activity_id);
-- "同一个用户在同一个活动里只能领一次券"
-- —— 这个业务规则,直接用唯一索引钉死。
// === 应用层:捕获唯一键冲突,把它当成"已处理"信号 ===
@Transactional
public Result pay(PayRequest req) {
PayRecord record = new PayRecord(req.getOrderId(),
req.getUserId(), req.getAmount(), "PAYING");
try {
payRecordMapper.insert(record); // 唯一索引在此把关
} catch (DuplicateKeyException e) {
// 插入失败 = 这笔支付之前已经发起过
// 这【不是错误】,而是幂等命中:直接返回之前的结果
PayRecord exist =
payRecordMapper.selectByOrderId(req.getOrderId());
return Result.ok(exist.getStatus());
}
// 只有第一次能走到这里,真正扣款
accountService.deduct(req.getUserId(), req.getAmount());
record.setStatus("PAID");
payRecordMapper.updateStatus(record);
return Result.ok("PAID");
}
// === 为什么说唯一索引最可靠 ===
// 它的防重发生在【数据库】这一层,是最后一道闸。
// 哪怕应用层的并发判断有疏漏、哪怕两个请求
// 真的同时杀到,数据库的唯一约束也保证
// 物理上只有一条能插进去。它不依赖任何"时序运气"。
修复 3:幂等号——业务自己发号,全链路携带
// === 唯一索引的前提:你得有一个"业务唯一键" ===
// 像 order_id 这种天然就唯一的字段最好。
// 但有些场景没有这种天然键(比如"提交一条评论"),
// 这时就要主动引入一个【幂等号 / 请求 ID】。
// === 幂等号的核心:它由【发起方】生成,且全链路透传 ===
// 谁第一个发起这个业务动作,谁就生成一个全局唯一的 id,
// 之后无论这个请求被重试多少次、传给多少个下游,
// 携带的都是【同一个】幂等号。
// 关键:重试时绝不能重新生成 —— 重试用的必须是原来那个号。
// === 调用方:生成一次,重试时复用 ===
public Result doPay(Order order) {
// 幂等号在业务动作"诞生"时生成一次
String idempotentKey = "pay:" + order.getId();
// 或用 UUID,但要保证一次业务动作只生成一次
PayRequest req = new PayRequest(order, idempotentKey);
// 后续这个请求无论重试几次,带的都是同一个 key
return payClient.pay(req);
}
// === 服务方:用去重表 + 幂等号防重 ===
@Transactional
public Result pay(PayRequest req) {
String key = req.getIdempotentKey();
// 先尝试把幂等号"占坑"写进去重表
int inserted = idempotentMapper.insertIfAbsent(key, "PROCESSING");
if (inserted == 0) {
// 占坑失败 = 这个幂等号之前来过
IdempotentRecord r = idempotentMapper.select(key);
if ("DONE".equals(r.getStatus())) {
return r.getResult(); // 已完成:返回上次结果
}
// 还在 PROCESSING:说明上一次还没跑完,
// 让调用方稍后重试,别并发执行
return Result.retryLater();
}
// 占坑成功,我是第一个,真正执行业务
Result result = doRealPay(req);
idempotentMapper.markDone(key, result);
return result;
}
-- === 去重表的结构 ===
CREATE TABLE idempotent_record (
idempotent_key VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- PROCESSING / DONE
result TEXT, -- 缓存的执行结果
created_at DATETIME NOT NULL,
PRIMARY KEY (idempotent_key) -- 主键即唯一,即防重
);
-- insertIfAbsent 的本质:
-- INSERT INTO idempotent_record (...) VALUES (...)
-- ON DUPLICATE KEY UPDATE 不变 -> 用影响行数判断是否抢到坑
-- === 去重表要定期清理 ===
-- 幂等号是有"时效"的,几天前的请求不可能再重试。
-- 否则这张表会无限膨胀。
DELETE FROM idempotent_record
WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY);
修复 4:状态机——用状态流转天然防重
// === 很多业务,本身就带着"状态" ===
// 订单:待支付 -> 已支付 -> 已发货 -> 已完成
// 这种"状态只能单向往前走"的特性,
// 本身就是一种天然的幂等保护。
// === 核心技巧:更新时,把"前置状态"写进 WHERE 条件 ===
// 不要无脑 UPDATE,而是"只有当前状态符合预期,才更新"
public boolean markPaid(Long orderId) {
// 关键:WHERE 里带上 status = '待支付'
int rows = orderMapper.updateStatus(
orderId, "待支付", "已支付");
return rows == 1;
}
// 对应的 SQL:
// UPDATE orders SET status = '已支付'
// WHERE order_id = ? AND status = '待支付'
// === 为什么这样就幂等了 ===
// 第一次请求:订单是"待支付",WHERE 命中,
// 状态改成"已支付",影响行数 = 1
// 第二次重复请求:订单已经是"已支付"了,
// WHERE 里的 status = '待支付' 不再命中,
// 影响行数 = 0 —— 什么都没发生
// 用"影响行数是不是 1"就能判断:
// 这次到底是我真的推进了状态,还是一个重复请求。
// === 这个写法还顺带解决了并发 ===
// 两个请求同时来改同一个订单,
// 数据库的行锁保证它们排队执行 UPDATE,
// 第一个 rows=1 成功,第二个 rows=0 ——
// 它既是幂等,也是一种乐观的并发控制(CAS 思想)。
// === 配合状态推进,严格校验流转合法性 ===
public void ship(Long orderId) {
int rows = orderMapper.updateStatus(
orderId, "已支付", "已发货"); // 只能从"已支付"发货
if (rows == 0) {
// 要么订单不是"已支付"(状态不对,非法流转)
// 要么是重复的发货请求 —— 两种都不该继续执行
throw new BizException("订单状态不允许发货");
}
doShip(orderId);
}
修复 5:Token 机制——防表单重复提交
// === 适用场景:防"用户重复点提交按钮" ===
// 前面几种是后端防重,Token 机制更偏前端交互场景:
// 用户在表单页停留、可能手抖点好几次提交。
// === 流程:先领票,提交时核销,一张票只能用一次 ===
// 第一步:用户进入表单页时,后端发一个一次性 token
@GetMapping("/order/token")
public Result getToken() {
String token = UUID.randomUUID().toString();
// 存进 Redis,设个过期时间(表单不会填几个小时)
redis.set("submit_token:" + token, "1", 10, MINUTES);
return Result.ok(token);
}
// 第二步:用户提交订单时,带上这个 token
@PostMapping("/order/submit")
public Result submit(@RequestBody OrderRequest req,
@RequestHeader("Submit-Token") String token) {
// 用 Redis 的原子操作"核销"这张票:
// DEL 返回 1 表示这次删除真的删掉了 -> 抢到了,放行
// DEL 返回 0 表示 token 不存在(已被用过/过期)-> 拦截
Long deleted = redis.delete("submit_token:" + token);
if (deleted == null || deleted == 0) {
return Result.fail("请勿重复提交");
}
// 核销成功,我是这张票唯一的使用者,执行下单
return orderService.createOrder(req);
}
// === 为什么必须用"原子的核销"动作 ===
// 错误写法:先 GET 看 token 在不在,在 -> 再 DEL -> 再处理。
// "GET 判断" 和 "DEL 核销" 是两步,中间有缝隙:
// 两个并发请求可能都 GET 到 token 存在,都以为自己有效。
// 正确做法:直接用 DEL 的返回值,
// 把"判断"和"核销"合并成一个不可分割的原子操作。
// 谁的 DEL 返回 1,谁才是真正的赢家。
// (Redis 单线程,保证同一个 key 的 DEL 严格串行)
修复 6:分布式锁的配合,以及幂等的边界
// === 分布式锁:解决"并发重复",不替代幂等 ===
// 重复请求有两种时序:
// 1. 串行重复:第一次完全处理完了,第二次才来
// -> 去重表 / 唯一索引 / 状态机,都能挡住
// 2. 并发重复:两次几乎同时杀到,第一次还没处理完
// -> 这时去重表的"查一下在不在"可能两个都查不到
// === 分布式锁负责把"并发"强行掰成"串行" ===
public Result pay(PayRequest req) {
String lockKey = "lock:pay:" + req.getOrderId();
// 同一笔订单的支付,同一时刻只允许一个线程进入
boolean locked = redisLock.tryLock(lockKey, 5, SECONDS);
if (!locked) {
return Result.retryLater(); // 没抢到锁,稍后重试
}
try {
// 锁内,再走一遍幂等判断(去重表 / 状态机)
return doIdempotentPay(req);
} finally {
redisLock.unlock(lockKey);
}
}
// === 关键:锁和幂等是【配合】,不是【二选一】===
// 锁:把并发掰成串行,让幂等判断不被并发干扰
// 幂等(唯一索引/去重表/状态机):真正的"事实判断"
// 锁会过期、会失效,绝不能只靠锁。
// 数据库唯一索引那道闸,必须始终留着兜底。
// === 一条经验:幂等判断要在"事务内、且有终态记录" ===
// 占坑(写去重表)和业务操作,最好在同一个事务里,
// 或保证占坑记录有明确终态。否则:
// 占了坑、业务却执行失败、坑还占着 ->
// 这笔业务以后永远重试不了(被自己的坑卡死)。
// 失败时要么回滚占坑,要么把状态置为"可重试"。
=== 幂等不是万能的,它的边界要想清楚 ===
1. 幂等保证的是"重复执行不出错",
不保证"重复请求不发生"。重复该来还是来。
2. 幂等号必须由【发起方】生成,且重试时复用同一个。
如果每次重试都生成新号,幂等就彻底失效了 ——
这是落地时最容易犯的错。
3. "查到结果直接返回"也要小心:返回的是
第一次执行的结果,要确保它和这次请求语义一致。
4. 幂等窗口是有限的。去重表不可能存一辈子,
清理掉的老幂等号,理论上又能被重复执行 ——
所以清理周期要远大于"任何重试的最大间隔"。
5. 选型小结:
- 有天然唯一键(订单号)-> 唯一索引,最省事最可靠
- 无天然唯一键 -> 幂等号 + 去重表
- 业务自带状态 -> 状态机 + WHERE 带前置状态
- 防用户手抖重复提交 -> Token 机制
- 防并发同时杀到 -> 分布式锁 + 上述任一兜底
实际项目里,这些往往是【组合】使用的。
优化效果
指标 改造前 改造后
=============================================================
扣款接口 无脑执行,来一次扣一次 幂等号+去重表防重
支付流水表 无唯一约束 order_id 唯一索引兜底
订单状态更新 无脑 UPDATE WHERE 带前置状态(CAS)
表单重复提交 直接重复下单 Token 一次性核销
并发重复请求 两条都能落库 分布式锁掰成串行
重复扣款投诉 发生过事故 改造后为 0
幂等意识 无 写接口幂等纳入规范评审
去重表治理 — 定时清理 7 天前记录
治理过程:
- 梳理所有"写接口",标注哪些必须幂等:1 天
- 核心资金接口加唯一索引 + 去重表:1.5 天
- 订单链路改造为状态机 CAS 更新:1 天
- 表单 Token + 分布式锁配合:1 天
- 幂等规范沉淀 + 全量评审:0.5 天
避坑清单
- 幂等指相同参数调用一次和多次对系统的最终影响一致,不是返回值一致
- 重复请求是必然的:用户重复点击、网关/RPC 自动重试、MQ 重投,躲不掉只能接住
- 所有改变系统状态的写接口都要做幂等,涉及钱、库存、状态流转的一个都不能漏
- 数据库唯一索引是最可靠的兜底,防重发生在最底层,不依赖应用层时序运气
- 无天然唯一键时引入幂等号,它必须由发起方生成且重试时复用同一个号
- 业务自带状态时用状态机,UPDATE 的 WHERE 带前置状态,靠影响行数判断是否重复
- WHERE 带前置状态的写法同时是一种 CAS 并发控制,行锁保证并发请求串行
- Token 机制防表单重复提交,核销必须用 DEL 返回值这种原子操作,不能先查后删
- 分布式锁解决并发重复,把并发掰成串行,但它会失效,绝不能替代唯一索引兜底
- 去重表要定时清理,清理周期必须远大于任何重试的最大间隔,否则老请求会被重放
总结
这次重复扣款的事故,真正让我警醒的不是某一段代码写错了,而是我们整个团队在设计接口时,脑子里压根就没有"同一个请求可能被执行多次"这根弦。我们默认了一种太过理想的世界:用户点一次提交,请求就老老实实地发一次、到达一次、被执行一次。可现实根本不是这样。用户的网络一卡,他看不到响应,几乎是本能地就会再点一下;我们自己的网关,在第一次请求超时、它自己也不知道后端到底成没成功的时候,会贴心地、也是无情地自动帮你重试一次;消息队列为了不丢消息,在投递结果不确定时一定会选择重投。这些重复,没有一个是 bug,它们全都是这些组件在尽职尽责地工作。所以这次复盘我想通的第一件、也是最根本的一件事是:重复请求不是一种异常,而是分布式系统里一种必然会发生的常态,你不可能靠"教育用户别乱点"或者"把重试关掉"去消灭它,你唯一能做的,是让你的接口在被重复请求击中的时候,稳稳地接住,不出错。这,就是幂等设计存在的全部意义。想清楚这一点之后,接下来要回答的就是"哪些接口需要被改造"。判断标准其实非常简单粗暴:这个接口会不会改变系统的状态?查询接口天然就是幂等的,你查一百次和查一次,系统状态分毫不变,这类接口完全不用操心。真正要把每一个都揪出来认真对待的,是那些写接口,尤其是任何一个会动到钱、动到库存、动到积分、动到订单状态的接口——下单、支付、扣款、退款、发券,这些接口重复执行一次,就是一次实打实的资损或数据错乱。把这些接口列出来,就是整个改造的清单。具体怎么改,这次我落地了好几种手段,而它们其实是分层次、各有所长的。最底层、也最让我信任的一道防线,是数据库的唯一索引。它的可靠之处在于,它的防重发生在整条链路的最末端、最物理的那一层——哪怕我应用层的并发判断写得有漏洞,哪怕两个重复请求真的在同一毫秒一起杀到了数据库,唯一约束也铁面无私地保证,物理上只有一条记录能落地,另一条必然被顶回来。它不依赖任何"谁先谁后"的时序运气,这种确定性是别的方案给不了的。所以只要这个业务有一个天然的唯一键,比如订单号,那就毫不犹豫地给它建唯一索引,这是最省事也最稳的做法。如果业务没有这种天然唯一键,那就要主动引入一个幂等号,配一张去重表来防重,而这里藏着一个落地时最容易翻车的细节:幂等号必须由请求的发起方生成,并且在所有的重试中,携带的都必须是同一个号——如果哪个环节图省事,在重试的时候重新生成了一个新号,那这个号就和原来的对不上,去重表查不到,幂等机制就被悄无声息地架空了。第三种手段是状态机,它特别适合那些本身就带着状态、而且状态只能单向往前走的业务,比如订单。技巧是更新状态时绝不无脑 UPDATE,而是把"期望的前置状态"写进 WHERE 条件里,只有当订单确实还处在"待支付"时,才允许把它改成"已支付";第一次请求会命中、影响一行,重复请求过来时订单已经不是"待支付"了、WHERE 不再命中、影响零行——靠影响行数是不是一,就能干净利落地分辨出这次到底是真正的状态推进,还是一个该被忽略的重复请求。而且这个写法很巧妙地一箭双雕,它同时还是一种 CAS 式的乐观并发控制,数据库的行锁会让并发改同一订单的请求乖乖排队,天然就把并发问题也一起解决了。第四种是 Token 机制,它更偏前端交互,专门对付用户在表单页上手抖点好几次提交:让用户进页面时先领一张一次性的票,提交时核销,而核销这个动作必须是原子的——直接用 Redis 的 DEL 返回值来判断,谁的 DEL 返回了 1 谁就是唯一的赢家,绝不能写成"先查票在不在、再删票"那种两步式的判断,因为那两步之间的缝隙,足够两个并发请求一起钻进来。最后还要补充一点:分布式锁也是这套体系里的一员,但它的定位常被误解。锁解决的是"并发重复"——两个请求几乎同时到达、第一个还没处理完时,去重表可能两个都还查不到记录;锁的作用就是把这种并发强行掰成串行,让后面的幂等判断能在一个干净、不被并发干扰的环境里做出正确结论。但锁会过期、会因为各种原因失效,所以它永远只是配合,绝不能用它来替代唯一索引那道最终的兜底闸门。回头看,这次幂等改造给我留下的最大改变,是我对"接口"这个东西的理解。在出事之前,我写一个接口,想的是"它被正确地调用一次,应该做什么";出事之后我才明白,一个真正健壮的接口,你还必须想清楚另一个问题——"当它被同样的参数调用第二次、第三次的时候,它应该做什么"。前一个问题决定了功能能不能跑通,后一个问题决定了这个系统在真实世界的网络抖动、用户手抖、框架重试的反复捶打之下,会不会把用户的钱扣错。如今幂等已经写进了我们的编码规范,而规范里那条最硬的要求是:任何一个会改变系统状态的写接口,作者都必须能明确说出它靠哪一种机制保证幂等,说不出来,这个接口就不允许上线。
—— 别看了 · 2026