2024 年我们的支付系统出过一次对账对不平的事故:财务月底核账,发现某天有几十笔订单,用户明明只付了一次钱,系统里却记了两笔甚至三笔扣款。排查下来原因五花八门——有用户手抖连点了两下提交按钮的,有前端请求超时后自动重试的,有 MQ 消息重复投递的,还有网关层做了失败重试的。这些场景的共同点是:同一个"操作"被发起了多次,而我们的接口对此毫无防备,来一次就执行一次。说到底,是我们整个系统几乎没有"幂等"这个概念。投了几天给所有写接口补上幂等设计,本文复盘这次实战。
问题背景
业务:支付/订单系统,对外提供下单、支付、退款等写接口
事故现象:
- 用户付款一次,系统记录 2-3 笔扣款
- 对账对不平,财务月底才发现,已造成实际资损
- MQ 消费端偶发重复处理同一条消息
现场排查:
# 1. 统计重复的来源
SELECT biz_no, COUNT(*) c FROM payment_record
GROUP BY biz_no HAVING c > 1;
-- 几十个 biz_no 有 2~3 条记录
# 2. 重复请求的几种来源:
# - 用户连点提交按钮(前端没防重)
# - 前端请求超时,自动重发,但其实第一次已成功
# - 网关 / RPC 框架配了失败重试
# - MQ "至少一次"投递,消息天然可能重复
# - 用户对着失败页面手动刷新重试
# 3. 看接口代码:来一个请求就插一条、扣一次款
public void pay(PayRequest req) {
deductBalance(req.getUserId(), req.getAmount());
insertPaymentRecord(req); // 没有任何重复判断
}
根因:
1. 写接口默认"调一次执行一次",完全没有幂等保护
2. 网络重试、MQ 重投、用户重复操作普遍存在,重复请求是常态
3. 把"防重复"寄希望于前端,而前端防重根本不可靠
4. 没有统一的幂等方案,各接口各写各的
修复 1:先想清楚什么是幂等
=== 幂等的定义 ===
一个操作,执行一次和执行多次,对系统产生的【最终影响相同】。
注意:不是"多次执行都成功",而是"多次执行的结果和一次一样"。
=== 哪些接口天然幂等,哪些不 ===
天然幂等(不用特殊处理):
- 查询(GET):查多少次数据都不变
- 删除指定 id(DELETE /order/123):删一次和删多次,
结果都是"这条没了"
- 把字段设为绝对值(UPDATE SET status='PAID'):
设多少次都是 PAID
天然【不】幂等(必须做幂等设计):
- 新增(INSERT 一条订单):调两次就两条
- 基于当前值做增减(UPDATE SET balance = balance - 100):
执行两次就扣两次
- 发消息、发短信、调第三方支付:每次都真的发/扣
=== 关键认知 ===
重复请求不是异常,是【系统常态】 ——
网络会重试、MQ 会重投、用户会重复点击。
所以:不要假设"请求只会来一次",
所有【非幂等的写接口】,都必须主动做幂等设计。
"防重"不能依赖前端,前端能拦住连点,
拦不住网络层重试和 MQ 重投,服务端必须自己兜住。
修复 2:幂等的基石 —— 全局唯一的业务号
// === 幂等的前提:能识别出"这两个请求其实是同一个操作" ===
// 怎么识别?靠一个由【请求方生成】、全局唯一、
// 且同一次操作多次重试都【保持不变】的 id。
// 常见叫法:幂等号 / 业务流水号 / requestId。
// === 错误做法:服务端自己生成 id ===
// public void pay(PayRequest req) {
// String id = UUID.randomUUID().toString(); // 错!
// // 每次请求服务端都新生成一个 id,
// // 重试的两个请求 id 不同 -> 根本识别不出是重复
// }
// === 正确做法:id 由发起方生成,重试时复用同一个 id ===
// 1. 下单场景:前端进入收银台时,先调一个接口拿一个
// "支付流水号",这个号在这次支付的整个生命周期里不变,
// 用户连点、超时重试,带的都是同一个号。
// 2. 系统间调用:上游生成 requestId 放进请求,
// 重试时带同一个 requestId。
// 3. MQ 场景:用消息里业务自带的唯一键(如订单号)。
public class PayRequest {
private String idempotentNo; // 幂等号,由发起方生成并保证重试不变
private Long userId;
private BigDecimal amount;
}
// === 这个号要满足三个条件 ===
// 1. 全局唯一:不同业务操作的号一定不同
// 2. 重试不变:同一操作的多次重试,带同一个号
// 3. 发起方生成:服务端不能自己造,否则识别不了重复
修复 3:数据库唯一索引 —— 最可靠的兜底
// === 最朴素也最可靠的幂等:给业务表加唯一索引 ===
// 让数据库来保证"同一个幂等号只能成功插入一条"。
// 1. 给业务表的幂等号字段建唯一索引
// ALTER TABLE payment_record
// ADD UNIQUE KEY uk_idempotent (idempotent_no);
// 2. 插入时,靠唯一索引拦截重复
public void pay(PayRequest req) {
PaymentRecord record = buildRecord(req);
try {
paymentMapper.insert(record); // 重复的幂等号会插入失败
} catch (DuplicateKeyException e) {
// 唯一索引冲突 = 这个操作之前已经处理过了
// 直接当成功返回,不要再执行扣款
log.info("重复请求,幂等号已存在: {}", req.getIdempotentNo());
return;
}
deductBalance(req.getUserId(), req.getAmount());
}
// === 为什么唯一索引是最可靠的 ===
// 唯一约束由数据库引擎在底层保证,
// 即使两个重复请求【完全并发】地走到 insert,
// 数据库也只会让其中一条成功,另一条必定报冲突。
// 不依赖应用层的判断时序,没有并发窗口。
// === 注意:扣款和插记录要放在同一个事务里 ===
// 否则可能"记录插了、扣款失败"或反之。
@Transactional(rollbackFor = Exception.class)
public void payTx(PayRequest req) {
try {
paymentMapper.insert(buildRecord(req));
} catch (DuplicateKeyException e) {
return; // 已处理过,幂等返回
}
deductBalance(req.getUserId(), req.getAmount());
}
修复 4:防重表 / Token 方案
// === 场景:业务表本身不方便加唯一索引,用独立的"防重表" ===
// 单独建一张幂等记录表,专门记"哪些幂等号已经处理过"。
// CREATE TABLE idempotent_record (
// idempotent_no VARCHAR(64) PRIMARY KEY, // 幂等号做主键
// status VARCHAR(16), // PROCESSING / DONE
// result TEXT, // 缓存第一次的处理结果
// create_time DATETIME
// );
public Result pay(PayRequest req) {
String no = req.getIdempotentNo();
try {
// 先抢占:插入一条 PROCESSING 记录
idempotentMapper.insert(no, "PROCESSING");
} catch (DuplicateKeyException e) {
// 插不进去 = 这个号已经有人处理过或正在处理
IdempotentRecord r = idempotentMapper.get(no);
if ("DONE".equals(r.getStatus())) {
return parse(r.getResult()); // 已完成,直接返回上次结果
}
// 还在 PROCESSING:说明前一个请求还没跑完
throw new BizException("请求处理中,请勿重复提交");
}
// 抢占成功,执行真正的业务
Result result = doPay(req);
idempotentMapper.updateDone(no, toJson(result));
return result;
}
// === Token 方案(防"用户连续点击提交")===
// 1. 用户进入表单页时,服务端发一个一次性 token
// 2. 提交时带上 token,服务端校验并【原子地删除】token
// 3. 删除成功才放行;token 不存在 = 重复提交,拦掉
// 校验+删除用 Redis 的 Lua / DEL 返回值保证原子,
// 否则并发下两个请求会同时通过校验。
// === 防重记录要设过期/定期清理 ===
// 幂等表会一直涨,给老记录设 TTL 或定时归档,
// 过期时间要长于"业务可能重试的最大时间窗口"。
修复 5:Redis 做幂等与 MQ 消费幂等
// === 用 Redis 做轻量级幂等(高并发、能容忍极小概率失败)===
// 利用 SET NX:幂等号不存在才设成功,存在即重复。
public Result payWithRedis(PayRequest req) {
String key = "idem:pay:" + req.getIdempotentNo();
// SET key 1 NX EX 3600:抢占,成功才往下走
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofHours(1));
if (!Boolean.TRUE.equals(first)) {
throw new BizException("重复请求"); // 没抢到 = 重复
}
try {
return doPay(req);
} catch (Exception e) {
// 业务失败要把 key 删掉,允许用户重试,
// 否则用户这次失败了却再也提交不了
redisTemplate.delete(key);
throw e;
}
}
// 注意:Redis 幂等是"性能优先"的方案,
// 极端情况下(Redis 宕机/key 过期)仍可能漏。
// 涉及资金的核心链路,要叠加修复 3 的数据库唯一索引兜底。
// === MQ 消费端幂等:消息一定会重复,消费者必须幂等 ===
// Kafka/RocketMQ 都是"至少一次"投递,重复消息是必然的。
@KafkaListener(topics = "order-paid")
public void onMessage(OrderPaidEvent event) {
String msgKey = "mq:consumed:" + event.getOrderNo();
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(msgKey, "1", Duration.ofDays(3));
if (!Boolean.TRUE.equals(first)) {
log.info("消息已消费过,跳过: {}", event.getOrderNo());
return; // 重复消息,直接 ack 跳过
}
handleOrderPaid(event);
}
// 更稳的做法:消费端的业务操作本身就落一张带唯一索引的表,
// 让数据库兜底,Redis 只做快速过滤。
修复 6:幂等设计的统一规范与监控
// === 用注解 + AOP 把幂等做成统一能力,避免每个接口各写各的 ===
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
long expireSeconds() default 3600; // 幂等记录保留时长
}
@Aspect
@Component
public class IdempotentAspect {
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent)
throws Throwable {
String no = resolveIdempotentNo(pjp); // 从入参取幂等号
if (no == null || no.isEmpty()) {
throw new BizException("缺少幂等号");
}
String key = "idem:" + no;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, "1",
Duration.ofSeconds(idempotent.expireSeconds()));
if (!Boolean.TRUE.equals(first)) {
throw new BizException("重复请求,请勿重复提交");
}
try {
return pjp.proceed();
} catch (Throwable t) {
redisTemplate.delete(key); // 失败放行重试
throw t;
}
}
}
// 业务侧只需在写接口上加一个注解:
// @Idempotent
// public Result pay(PayRequest req) { ... }
# 幂等相关监控:重复请求是常态,但量级异常要告警
groups:
- name: idempotent
rules:
# 1. 重复请求拦截量突增(可能有异常重试风暴)
- alert: DuplicateRequestSpike
expr: rate(idempotent_duplicate_total[5m]) > 50
for: 5m
annotations:
summary: "幂等拦截量突增,排查上游是否有异常重试风暴"
# 2. 缺少幂等号的请求(上游没按规范传)
- alert: MissingIdempotentNo
expr: increase(idempotent_missing_no_total[10m]) > 0
annotations:
summary: "出现缺少幂等号的写请求,排查上游调用方"
# 3. 同一业务号出现多条记录(幂等被击穿,严重)
- alert: IdempotentBreached
expr: increase(idempotent_breach_total[5m]) > 0
annotations:
summary: "检测到同一业务号多条记录,幂等被击穿,立即排查"
优化效果
指标 治理前 治理后
=============================================================
写接口幂等 几乎没有 写接口全部接入幂等
幂等号 无,服务端各自生成 发起方生成,重试不变
重复扣款 月底对账才发现 0(实时拦截)
连点提交 每点一次执行一次 Token/幂等号拦截
网络重试 重复执行 识别为同一操作,幂等返回
MQ 重复消息 重复处理 消费端幂等跳过
核心链路兜底 无 数据库唯一索引
幂等实现方式 各接口各写各的 注解 + AOP 统一
幂等可观测 无 重复拦截/击穿监控
治理过程:
- 统计重复来源 + 梳理所有写接口:1 天
- 核心资金链路加数据库唯一索引:1 天
- 防重表 / Redis 幂等方案落地:1.5 天
- 注解 + AOP 统一幂等能力:1 天
- MQ 消费端幂等 + 监控接入:1.5 天
避坑清单
- 幂等是"执行一次和多次最终影响相同",不是"多次都成功"
- 重复请求是系统常态:网络会重试、MQ 会重投、用户会连点,不是异常
- 查询/删除指定 id/设绝对值天然幂等,新增/增减/发消息必须做幂等设计
- 防重不能靠前端,前端拦不住网络层重试和 MQ 重投,服务端必须自己兜
- 幂等号必须由发起方生成、全局唯一、重试时保持不变,服务端不能自己造
- 数据库唯一索引是最可靠的幂等兜底,靠引擎保证无并发窗口
- 插记录和扣款必须在同一事务,否则会出现记录与扣款不一致
- Redis SET NX 幂等性能好但极端情况会漏,核心资金链路要叠加唯一索引
- Redis 幂等遇业务失败要删 key 放行重试,否则用户失败后再也提交不了
- MQ 是至少一次投递,消费端必须幂等,最好用带唯一索引的表兜底
总结
这次幂等专项治理,最让我警醒的不是某一个技术细节,而是一个被我们长期忽视的事实:重复请求根本不是异常,它是分布式系统里再正常不过的常态。我们过去写接口时,内心深处一直有一个未经审视的假设——"用户发起一个操作,这个请求就只会到达我这里一次"。可现实把这个假设撕得粉碎:用户会因为页面卡顿而连点两下提交按钮;前端会在请求超时后自动重发,而它根本不知道服务端其实第一次已经成功了;网关和 RPC 框架默认就配着失败重试;消息队列为了不丢消息,采用的是"至少一次"投递,这意味着同一条消息天然就可能来好几遍。这些重试机制每一个单独看都是合理的、甚至是必要的,但它们叠加在一起,就让"同一个操作被发起多次"成了每天都在发生的事。而我们的接口对此毫无防备,来一个请求就老老实实扣一次款、插一条记录,于是用户付一次钱、系统记三笔账这种事就成了必然,只是它不会立刻爆发,而是悄悄累积,直到月底财务对账才暴露出来,那时已经是实打实的资损了。想通了这一层,治理的方向就清晰了:所有非幂等的写接口,都必须主动做幂等设计,这不是锦上添花,是底线。而幂等设计的整个大厦,都建立在一块基石之上——一个能够标识"这两个请求其实是同一个操作"的全局唯一业务号。这个号有一个极其关键、又极其容易做错的要求:它必须由请求的发起方生成,并且在这次操作的多次重试中始终保持不变。如果服务端自己生成这个号,那重试过来的两个请求会拿到两个不同的号,服务端就永远无法判断它们是不是重复——这是很多人第一次做幂等时会踩的坑。有了这个号之后,具体的实现手段有很多:可以用数据库唯一索引,这是最朴素也最可靠的一种,因为唯一约束由数据库引擎在最底层保证,哪怕两个重复请求完全并发地冲进来,数据库也只会放一个进去;可以用一张独立的防重表来记录"哪些号处理过",还能顺便把第一次的处理结果缓存下来,让重复请求直接拿到一致的返回;可以用 Redis 的 SET NX 做高性能的快速过滤;还可以用一次性 Token 来专门对付用户的连续点击。这些手段各有各的适用场景,但有一条原则我想特别强调:对于涉及资金这种绝对不容出错的核心链路,不要把宝全押在 Redis 这种性能优先、但极端情况下会漏的方案上,一定要在它背后再压一道数据库唯一索引作为最终兜底。Redis 负责把绝大多数重复请求高效地、轻量地挡掉,而数据库唯一索引负责那万分之一的漏网之鱼——这又是一次"快速过滤层 + 可靠兜底层"的双层防护思路。最后,我们没有让每个接口各自去实现幂等,而是用一个自定义注解加上 AOP 切面,把幂等抽象成了一个统一的、声明式的能力,业务开发只需要在写接口上加一个注解,剩下的抢占、拦截、失败放行全部由切面统一处理。这次治理之后我形成了一个习惯:每写一个写接口,我都会先停下来问自己一句——如果这个请求被发两遍、三遍,会发生什么?如果答案是"会出问题",那它就必须有幂等保护,没有例外。
—— 别看了 · 2026