2024 年我们的支付系统出过一次让人后背发凉的事故:有用户被同一笔订单扣了两次款。复盘下来,根因是前端在网络抖动时重试了提交请求,而我们的下单接口压根没有做幂等控制 —— 同一个请求打进来两次,就实打实创建了两笔订单、扣了两次钱。投了一周给核心写接口补幂等,本文复盘接口幂等设计的完整实战。
问题背景
业务:支付下单,Spring Boot + MySQL,日均下单 200 万笔
事故现象:
- 用户投诉:一笔订单被扣了两次款
- 对账发现当天有 37 笔订单出现"一单两扣"
- 都集中在网络质量差的移动端用户
现场排查:
# 1. 翻接口访问日志,定位到一笔重复订单
14:22:01.310 POST /api/order/create userId=88123 traceId=A -> 200
14:22:04.880 POST /api/order/create userId=88123 traceId=B -> 200
# 两个请求,body 完全一样,间隔 3.5 秒,各自建了一笔订单
# 2. 前端代码:提交按钮点击后,3 秒无响应会自动重试
# 3. 那次后端 RT 抖到 4 秒 -> 前端以为失败 -> 自动重发
# 但第一次请求其实成功了 -> 两笔订单都落库
根因:
1. 下单这个"写接口"完全没有幂等控制
2. 任何重试(前端重试、网关重试、用户狂点)都会重复下单
3. 没有唯一约束兜底,数据库层面也拦不住
4. 团队对"哪些接口必须幂等"没有统一认知和规范
修复 1:先分清哪些接口需要幂等
幂等(Idempotent):同一个请求执行一次和执行多次,
对系统产生的影响完全相同。
=== 按 HTTP 方法的天然语义 ===
GET 天然幂等 —— 查询,查多少次结果都一样
PUT 天然幂等 —— 全量覆盖,覆盖几次结果一样
DELETE 天然幂等 —— 删除,删第二次还是"不存在"
POST 天然不幂等 —— 每次都"新建",最危险,必须重点防
=== 真正要关心的:写操作 ===
必须做幂等:
- 创建订单、发起支付、扣款、退款
- 账户加减余额、积分变动
- 发券、库存扣减
- 发送短信/通知(防重复轰炸)
可以不做(或天然幂等):
- 纯查询接口
- 按主键的全量更新(UPDATE ... SET ... WHERE id=?)
- 状态机的"幂等转移"(已支付再支付,直接返回成功)
=== 一个认知误区 ===
"重试不就是网络问题吗,概率很低" —— 错。
重试无处不在:前端重试、网关/LB 重试、RPC 框架重试、
MQ 消息重投、用户手抖狂点、定时任务重跑。
只要接口对外,就要假设它一定会被重复调用。
修复 2:方案一 —— 数据库唯一索引(最硬的兜底)
-- === 思路:让数据库帮你拦重复,这是最后一道防线 ===
-- 给"业务上不允许重复"的字段加唯一索引
-- 订单表:同一个幂等号只能有一条订单
ALTER TABLE orders
ADD COLUMN idempotent_key VARCHAR(64) NOT NULL COMMENT '幂等号',
ADD UNIQUE KEY uk_idempotent (idempotent_key);
-- 支付流水表:同一笔订单 + 同一种支付动作只能有一条
ALTER TABLE payment_flow
ADD UNIQUE KEY uk_order_action (order_id, action_type);
-- 账户变动流水:同一个业务单号只能记一次账
ALTER TABLE account_flow
ADD UNIQUE KEY uk_biz_no (biz_no);
-- 唯一索引的好处:无论上层逻辑多么混乱,只要重复数据
-- 想落库,数据库直接抛 DuplicateKeyException,从根上挡住。
// === 配合唯一索引的写法:捕获冲突异常,转成"幂等成功" ===
public OrderResult createOrder(OrderRequest req) {
Order order = buildOrder(req);
order.setIdempotentKey(req.getIdempotentKey());
try {
orderMapper.insert(order); // 唯一索引保护
return OrderResult.success(order);
} catch (DuplicateKeyException e) {
// 冲突 = 这个请求之前已经成功过了
// 不是报错,而是把"已存在的那笔"查出来返回 —— 对调用方表现为幂等成功
Order exist = orderMapper.selectByIdempotentKey(req.getIdempotentKey());
return OrderResult.success(exist);
}
}
// 关键:DuplicateKeyException 不该当成失败抛给用户,
// 它恰恰证明"上一次请求成功了",要转成幂等成功响应。
修复 3:方案二 —— 幂等号 + 前置查重
// === 唯一索引是兜底,但更主动的做法是请求进来先查重 ===
// 核心:每个写请求带一个全局唯一的"幂等号"(idempotentKey)
// 幂等号从哪来?—— 由"发起方"生成,不能由服务端生成
// 1. 下单页加载时,前端就向后端要一个 token(下面修复 4)
// 2. 或者由业务天然唯一键拼成:userId + 商品 + 客户端时间窗
// 3. 上游服务调用时,用上游的业务单号做幂等号
// 原则:同一个"业务意图"必须对应同一个幂等号,重试不变
@Transactional
public OrderResult createOrder(OrderRequest req) {
String key = req.getIdempotentKey();
if (key == null || key.isEmpty()) {
throw new BizException("缺少幂等号");
}
// 1. 先查:这个幂等号处理过没有
OrderIdempotent record = idempotentMapper.selectByKey(key);
if (record != null) {
// 处理过了,直接返回上次的结果(从 record 里取或回查订单)
return OrderResult.success(
orderMapper.selectById(record.getOrderId()));
}
// 2. 没处理过:正常下单
Order order = buildOrder(req);
orderMapper.insert(order);
// 3. 落幂等记录(和订单在同一个事务里,要么都成功要么都回滚)
OrderIdempotent idem = new OrderIdempotent();
idem.setIdempotentKey(key);
idem.setOrderId(order.getId());
idempotentMapper.insert(idem); // 这张表的 key 字段也有唯一索引
return OrderResult.success(order);
}
// 注意:"查重 + 插入"之间有并发窗口 —— 两个请求同时查,都查到 null,
// 都往下走。所以幂等表的唯一索引不能少,它兜住并发漏网的那一个。
// 查重负责"快速命中已处理的",唯一索引负责"挡住并发竞争的"。
修复 4:方案三 —— Token 机制(防表单重复提交)
// === 适合"用户狂点提交按钮"这类场景 ===
// 两步:进页面先领 token,提交时带 token 来核销,核销只能成功一次
// 第一步:进入下单页时,发一个一次性 token
@GetMapping("/order/token")
public String getOrderToken() {
String token = UUID.randomUUID().toString();
// 存 Redis,带过期时间(下单页停留不会太久)
redis.opsForValue().set("order:token:" + token, "1", Duration.ofMinutes(10));
return token;
}
// 第二步:提交下单时校验并"核销"token
@PostMapping("/order/create")
public OrderResult create(@RequestBody OrderRequest req,
@RequestHeader("Order-Token") String token) {
// 用 Lua 保证"判断存在 + 删除"是原子的,只有一个请求能删成功
String lua =
"if redis.call('get', KEYS[1]) then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
Long deleted = redis.execute(
new DefaultRedisScript<>(lua, Long.class),
List.of("order:token:" + token));
if (deleted == null || deleted == 0) {
// 删失败 = token 不存在或已被别的请求核销 = 重复提交
throw new BizException("请勿重复提交");
}
// 核销成功,继续下单
return orderService.createOrder(req);
}
// 为什么要用 Lua:如果先 get 再 del 分两步,
// 两个并发请求可能都 get 到 token、都觉得自己有效 -> 又重复了。
// "存在即删"必须是一个原子操作,这是 token 方案的命门。
修复 5:方案四 —— 状态机幂等(最优雅)
// === 很多业务本身就是状态流转,利用状态机天然防重 ===
// 订单状态:待支付 -> 已支付 -> 已发货 -> 已完成
// "支付"这个动作,本质是 待支付 -> 已支付 的一次状态转移
// 错误:不看当前状态,直接改
public void pay(Long orderId) {
Order o = orderMapper.selectById(orderId);
o.setStatus(PAID);
accountService.deduct(o.getAmount()); // 重复调用 -> 重复扣款!
orderMapper.update(o);
}
// 正确:用带状态条件的 UPDATE,靠"影响行数"判断是否真的转移了
public PayResult pay(Long orderId) {
// UPDATE ... WHERE status=待支付 —— 只有当前是待支付才会更新成功
int rows = orderMapper.updateStatusCAS(
orderId, /*from*/ UNPAID, /*to*/ PAID);
if (rows == 0) {
// 影响 0 行 = 订单不是"待支付"状态了(已被支付过)
// 这是重复支付,直接返回成功,不再扣款
Order o = orderMapper.selectById(orderId);
if (o.getStatus() == PAID) {
return PayResult.success("已支付"); // 幂等成功
}
throw new BizException("订单状态异常");
}
// rows == 1 = 状态真的从待支付翻成了已支付,这是"第一次",才扣款
accountService.deduct(orderId);
return PayResult.success("支付成功");
}
// 对应 SQL:
// UPDATE orders SET status = #{to}, pay_time = NOW()
// WHERE id = #{id} AND status = #{from}
//
// 精髓:把"是否第一次执行"的判断,交给数据库行锁 + WHERE 条件,
// 影响行数 1 就是第一次、0 就是重复,天然并发安全,无需额外加锁。
修复 6:细节与监控
// === 细节 1:幂等不只是"挡住",还要"返回一致的结果" ===
// 重复请求不能简单抛异常,要返回和第一次"一样的成功结果",
// 否则调用方收到错误,可能又触发新一轮重试 -> 死循环。
// === 细节 2:幂等记录要不要存"完整响应" ===
// 简单场景:幂等表存 orderId,重复时回查订单即可
// 复杂场景:把第一次的响应体序列化存下来,重复时原样返回
public OrderResult handle(String key, Supplier action) {
String cached = redis.opsForValue().get("idem:resp:" + key);
if (cached != null) {
return JSON.parseObject(cached, OrderResult.class); // 原样返回
}
OrderResult result = action.get();
redis.opsForValue().set("idem:resp:" + key,
JSON.toJSONString(result), Duration.ofHours(24));
return result;
}
// === 细节 3:幂等记录的过期与清理 ===
// Redis 方案设合理 TTL(覆盖业务重试窗口即可,如 24h)
// DB 幂等表要定期归档,否则无限膨胀
# 幂等相关监控:重复请求是有价值的信号,要可观测
groups:
- name: idempotent
rules:
# 1. 命中幂等拦截的请求量(突增说明上游在疯狂重试)
- alert: IdempotentHitSurge
expr: increase(idempotent_hit_total[5m]) > 1000
for: 5m
annotations:
summary: "{{ $labels.api }} 幂等拦截 5min 超 1000 次,排查上游重试风暴"
# 2. 唯一索引冲突异常(兜底被频繁触发 = 前置查重可能有漏洞)
- alert: DuplicateKeySurge
expr: increase(duplicate_key_exception_total[5m]) > 100
for: 5m
annotations:
summary: "{{ $labels.table }} 唯一键冲突频发,检查幂等前置逻辑"
# 3. 缺失幂等号的写请求(说明有调用方没按规范传)
- alert: MissingIdempotentKey
expr: increase(idempotent_key_missing_total[10m]) > 0
annotations:
summary: "{{ $labels.api }} 出现无幂等号的写请求,排查调用方"
优化效果
指标 治理前 治理后
=============================================================
一单两扣事故 当天 37 笔 0
写接口幂等覆盖 0% 核心写接口 100%
前端重试导致的重单 直接重复落库 幂等拦截,返回原结果
并发重复请求 靠运气 唯一索引 100% 兜住
幂等号规范 无 写接口强制带,缺失告警
重复请求的响应 报错(诱发再重试) 返回一致成功结果
幂等记录可观测性 完全不可见 命中/冲突全量监控
压测(同一请求并发重放 200 次):
- 治理前:并发重放 -> 创建 200 笔订单
- 治理后:并发重放 -> 仅 1 笔订单落库,其余 199 次返回同一结果
排查与改造:
- 定位重复扣款根因:0.5 天
- 梳理需要幂等的写接口清单(23 个):1 天
- 补唯一索引 + 幂等表:1 天
- token 机制 + 状态机改造:2 天
- 并发重放压测验证:0.5 天
避坑清单
- POST 这类"新建"接口天然不幂等,所有核心写接口都要假设会被重复调用
- 重试无处不在:前端、网关、RPC 框架、MQ 重投、用户狂点、定时任务重跑
- 数据库唯一索引是最硬的兜底,无论上层逻辑多乱都能挡住重复落库
- DuplicateKeyException 不是失败,它证明上次成功了,要转成幂等成功
- 幂等号必须由发起方生成、重试时保持不变,服务端生成的没有意义
- "前置查重 + 唯一索引"配合用:查重快速命中,唯一索引兜并发漏网
- token 机制的"判断存在 + 删除"必须用 Lua 原子执行,否则并发还会漏
- 状态机幂等最优雅:带状态条件的 UPDATE,靠影响行数判断是否首次
- 重复请求要返回与第一次一致的成功结果,报错会诱发新一轮重试
- 幂等命中量、唯一键冲突、缺失幂等号都要上监控,重复请求是有价值的信号
总结
这次重复扣款的事故,表面看是前端重试引发的,但本质暴露的是我们对"接口幂等"这件事根本没有体系化的认知。最深刻的一个教训是:不要把重复请求当成小概率的意外,它其实是系统里无处不在的常态 —— 前端会在超时后自动重试,网关和负载均衡会重试,RPC 框架默认就带重试,消息队列保证的是"至少一次"投递所以必然重投,用户在按钮没反应时会反复点,定时任务可能因为部署而重跑。只要一个写接口对外暴露,就必须假设它一定会被重复调用,这不是悲观,而是事实。想清楚这一点后,幂等设计就有了几套清晰的武器:最底层、最硬的一道防线永远是数据库的唯一索引,因为不管上层代码写得多混乱、并发控制有多少漏洞,只要重复数据想落库,数据库就会直接拒绝,这是不依赖任何应用层逻辑的终极兜底;在它之上,是"幂等号 + 前置查重",请求带着一个由发起方生成、重试时保持不变的唯一幂等号进来,服务端先查这个号处理过没有,处理过就直接返回上次的结果;对于用户狂点提交这种场景,token 机制很合适,但要记住它的命门是"判断 token 存在并删除"必须用 Lua 脚本原子完成,否则并发下照样漏;而我个人觉得最优雅的是状态机幂等,很多业务动作本质就是一次状态转移,用一条带 WHERE 状态条件的 UPDATE,靠数据库返回的影响行数来判断这到底是"第一次执行"还是"重复执行",把并发安全直接交给数据库的行锁,既干净又不需要额外的锁。还有一个容易被忽略的细节:幂等的目标不只是"挡住重复执行",更要"对重复请求返回和第一次一致的成功结果",如果你对重复请求返回错误,调用方收到失败可能又会触发新一轮重试,反而陷入死循环。幂等不是某个接口的小技巧,而是一道必须在设计阶段就想清楚、并形成团队规范的工程红线。
—— 别看了 · 2026