用户只点了一次提交,因为网络超时客户端自动重试,结果同一笔订单被下了两次、款也扣了两次,我对着接口没做幂等设计这个坑排查了大半天的复盘
这是一个让我对"分布式与网络环境下的不确定性"彻底敬畏的架构坑。它最让人后怕的地方在于:出问题的不是某段写错的逻辑,而是一个我压根没考虑过的场景——"同一个请求,被发送/处理了不止一次"。而在真实的网络世界里,这恰恰是必然会发生的常态。
事情起于用户投诉:有人反映自己明明只下了一单,却被扣了两次款、收到了两份货。我查日志,发现确实有两条几乎同时的、内容完全一样的下单记录。我把下单接口的代码拎出来,逻辑朴实无华:
// 下单接口(有问题的版本)
@PostMapping("/order")
public Result createOrder(@RequestBody OrderRequest req) {
// 1. 扣减库存
inventoryService.deduct(req.getProductId(), req.getQuantity());
// 2. 扣款
paymentService.charge(req.getUserId(), req.getAmount());
// 3. 创建订单
Order order = orderService.create(req);
// 4. 发起发货
shipmentService.ship(order.getId());
return Result.ok(order);
}
这段代码,在"每个请求只来一次"的理想世界里,完全正确。可现实是:用户点了提交,请求发到服务器,服务器其实已经成功处理了,但返回响应的网络包在回来的路上超时丢失了;客户端没收到响应,以为失败,于是自动重试,又发了一次一模一样的请求——服务器收到后,又老老实实地从头执行了一遍:再次扣库存、再次扣款、再次创建订单、再次发货。于是,一次用户操作,变成了两次实际执行。我盯着那两条重复记录,意识到问题的本质:我的接口,默认了"每个请求只会来一次"这个根本不成立的假设。
第一件事:看清真相——网络环境下"重复请求"是必然,接口必须幂等
我去深入理解了"幂等性(Idempotency)"这个概念,以及它在分布式系统里为何如此重要,才彻底明白这个坑的根源——在网络和分布式环境里,"一个请求被重复发送/处理"不是偶然的意外,而是必然会发生的常态;任何"会产生副作用(扣款、下单)"的接口,都必须被设计成"幂等"的。
幂等性与重复请求的真相
# 1. 什么是"幂等(Idempotent)":
# 一个操作, 不管执行【一次】还是【多次】, 产生的【效果(对系统状态的影响)都相同】。
# - 幂等的例子: "把订单状态设为已支付"(设几次都是已支付)、"删除id=5的记录"
# - 不幂等的例子: "扣款100元"(扣一次少100, 扣两次少200!)、"库存-1"、"插入一条订单"
# 2. 为什么网络环境下"重复请求"是【必然】:
# a) 客户端重试: 请求超时/没收到响应, 客户端不知道"到底成没成", 通常会重试
# —— 而"超时"不等于"失败"! 可能服务端已成功, 只是响应在回来路上丢了。
# b) 用户重复操作: 用户点了没反应, 手一抖又点一次; 刷新页面重复提交。
# c) 消息队列: MQ 为保证"不丢消息", 通常是"at-least-once(至少投递一次)",
# 即【宁可重复投递, 也不丢】—— 所以消费者必然会收到重复消息。
# d) 网关/代理重试、负载均衡重试等。
# 3. 我的接口的问题:
# 它做的是"扣款、库存-1、插入订单"这些【不幂等】的操作;
# 而它又默认"每个请求只来一次"——这个假设在网络环境下【根本不成立】;
# 一旦重复请求到来, 就重复扣款、重复下单。
# 4. 核心结论:
# 既然"重复请求"在分布式/网络环境下【无法避免】,
# 那么解决之道就不是"祈祷它不发生", 而是【让接口能正确处理重复请求】——
# 即: 把接口设计成【幂等】的: 同一个请求来多少次, 效果都和来一次一样。
# 核心: 网络/分布式环境下重复请求(客户端重试/用户重复点/MQ至少投递一次)是必然;
# 有副作用的接口必须设计成幂等——同一请求执行多次, 对系统的最终效果和执行一次相同。
真相大白,我醍醐灌顶。原来"幂等"的意思是:一个操作,不管执行一次还是多次,对系统状态产生的效果都相同("把订单设为已支付"设几次都一样,是幂等的;"扣款 100""库存-1""插入一条订单"执行几次效果就翻几倍,是不幂等的)。而在网络环境下,"重复请求"是必然:客户端超时会重试(而超时不等于失败,可能服务端已成功、只是响应丢了)、用户会手抖重复点、消息队列为了不丢消息普遍是"at-least-once(至少投递一次)"即宁可重复也不丢、网关也会重试。而我的接口做的恰恰是"扣款、库存-1、插入订单"这些不幂等的操作,却默认了"每个请求只来一次"这个在网络环境下根本不成立的假设——重复请求一来,就重复扣款、重复下单。核心结论是:既然重复请求无法避免,解决之道就不是"祈祷它不发生",而是让接口能正确处理重复请求——把接口设计成幂等的:同一个请求来多少次,效果都和来一次一样。
第二件事:正解——用幂等键 + 唯一约束/状态机,让重复请求只生效一次
搞懂了原理,正解就清晰了:给每个请求一个唯一的"幂等键",服务端用它去重(已处理过就直接返回上次结果),并辅以数据库唯一约束、状态机等多道防线。
// ====== 正解一(核心): 幂等键 + 去重(Redis SETNX 或 唯一索引) ======
@PostMapping("/order")
public Result createOrder(@RequestBody OrderRequest req,
@RequestHeader("Idempotency-Key") String idemKey) {
// ★ 客户端为每次"业务操作"生成一个唯一的幂等键(如UUID), 重试时用【同一个】键
// 服务端用这个键去重:
Boolean isFirst = redis.setIfAbsent("idem:" + idemKey, "1", Duration.ofHours(24));
if (!Boolean.TRUE.equals(isFirst)) {
// 这个键已存在 → 是重复请求 → 直接返回(或返回上次的结果), 不再执行业务!
return Result.ok(getCachedResult(idemKey));
}
// 第一次, 正常执行业务...
Order order = doCreateOrder(req);
cacheResult(idemKey, order); // 缓存结果供重复请求返回
return Result.ok(order);
}
// ====== 正解二: 数据库唯一约束(最可靠的兜底) ======
// 给订单表的"业务唯一标识"(如 幂等键/业务订单号)加【唯一索引】:
// ALTER TABLE orders ADD UNIQUE KEY uk_idem (idempotency_key);
// 重复请求插入时, 数据库会因唯一约束冲突而失败 → 捕获冲突, 当作"已处理"返回。
// → 这是最后一道防线: 即使前面的去重失效, 数据库也不会出现两条重复订单。
// ====== 正解三: 状态机流转(用状态变化保证幂等) ======
// 把操作变成"状态流转", 并用条件更新保证只流转一次:
// UPDATE orders SET status='PAID' WHERE id=? AND status='UNPAID';
// ↑ 只有当前是UNPAID才会更新成功(影响行数=1); 重复执行时status已是PAID,
// 影响行数=0, 自然不会重复扣款。检查影响行数即可判断"是不是我这次改的"。
// ====== 正解四: 乐观锁(版本号) ======
// UPDATE account SET balance=balance-100, version=version+1
// WHERE id=? AND version=? // version不匹配则更新失败, 防止基于旧状态的重复操作
// ====== 把"不幂等操作"转成"幂等操作"的思路 ======
// - 不幂等: balance = balance - 100 (相对操作, 执行几次减几次)
// - 幂等: balance = 900 (绝对赋值, 设几次都是900) —— 若业务允许
// - 或: 用唯一的"流水记录"+"一笔流水只记一次"来保证扣款只发生一次
// 核心: 客户端带幂等键、服务端据此去重(已处理直接返回); 配合数据库唯一约束兜底、
// 状态机条件更新、乐观锁; 多道防线让"同一业务操作"无论请求几次, 都只真正生效一次。
修复的核心,是"用幂等键去重 + 唯一约束兜底 + 状态机,让重复请求只生效一次"。正解一(核心):幂等键 + 去重——客户端为每次业务操作生成一个唯一幂等键(重试时用同一个),服务端用 setIfAbsent(Redis SETNX)去重:键已存在就是重复请求、直接返回上次结果不再执行业务。正解二:数据库唯一约束——给业务唯一标识加唯一索引,重复插入会因冲突失败,这是最可靠的最后一道防线,即使前面去重失效也不会出现两条重复订单。正解三:状态机流转——UPDATE ... SET status='PAID' WHERE id=? AND status='UNPAID',只有当前是 UNPAID 才更新成功,重复执行影响行数为 0、不会重复扣款。正解四:乐观锁(版本号)。还有一个思路是把不幂等操作转成幂等操作(balance=balance-100 相对操作改成绝对赋值或唯一流水记录)。归根结底:客户端带幂等键、服务端据此去重,配合数据库唯一约束兜底、状态机条件更新、乐观锁;多道防线让同一业务操作无论请求几次都只真正生效一次。
第三件事:分布式系统里其他必须考虑的"不确定性"
排查后我把分布式系统里其他必须考虑的"不确定性"也系统梳理了一遍,它们和"重复请求"一样,都是网络环境的常态。
分布式系统必须考虑的不确定性
# 1. 重复(本文): 请求/消息会重复。→ 幂等设计。
# 2. 丢失: 请求/响应/消息可能丢。→ 重试 + 幂等(重试又带来重复, 所以二者要配套)。
# 3. 乱序: 消息到达顺序可能和发送顺序不同。→ 业务上不依赖顺序, 或用序号/版本排序。
# 4. 延迟: 网络有不可预测的延迟。→ 设超时, 别假设"立即完成"。
# 5. 超时≠失败: 这是最坑的! 超时只代表"我没收到响应", 不代表"操作没成功"。
# → 所以不能简单"超时就重试一个不幂等操作"; 必须配幂等。
# 6. 部分失败: 多步操作中, 前几步成功、后面失败(扣了款没发货)。
# → 用事务/Saga/最终一致性/对账补偿来处理。
# 7. 并发: 多个请求同时操作同一资源(超卖)。→ 锁/乐观锁/原子操作。
# 8. 时钟不同步: 各机器时间有偏差。→ 别强依赖跨机器的绝对时间顺序。
# 共同根源(分布式的本质难点): 网络是【不可靠】的(会延迟、丢包、乱序、重复),
# 各节点是【独立且可能部分失败】的; 单机时代"调用必成功、顺序确定"的直觉, 在这里全部失效。
# 核心: 分布式/网络环境充满不确定性(重复/丢失/乱序/延迟/超时≠失败/部分失败/并发);
# 设计时必须假设"任何环节都可能出问题", 用幂等/重试/超时/补偿/锁等手段主动应对, 而非假设理想。
排查让我把分布式的其他不确定性也梳理清了。一、重复(本文)。二、丢失(请求/消息可能丢,要重试+幂等配套)。三、乱序(消息到达顺序可能变)。四、延迟(要设超时)。五、超时≠失败(最坑!超时只代表没收到响应、不代表没成功,所以不能简单重试不幂等操作)。六、部分失败(扣了款没发货,要用事务/Saga/对账补偿)。七、并发(超卖,要锁/乐观锁)。八、时钟不同步。它们的共同根源是:网络是不可靠的(会延迟、丢包、乱序、重复),各节点独立且可能部分失败;单机时代"调用必成功、顺序确定"的直觉在这里全部失效。核心是:设计时必须假设"任何环节都可能出问题",用幂等/重试/超时/补偿/锁主动应对,而非假设理想。下面这张图,是这次重复下单的成因与解法:
第四件事:常见操作的幂等性与实现方式速查表
这次踩坑后,我把常见操作的幂等性、以及怎么让它幂等整理成一张表,设计接口时对照。
| 操作 | 天然幂等? | 让它幂等的方式 |
|---|---|---|
| 查询 GET | ✓ 天然幂等 | 无副作用, 不用处理 |
| 删除 DELETE id=5 | ✓ 天然幂等 | 删几次结果都是"没了" |
| 全量更新 PUT(绝对赋值) | ✓ 天然幂等 | 设几次都是同一个值 |
| 新增 POST(插入订单) | ✗ 不幂等 | 幂等键+唯一索引去重 |
| 扣款/加减(相对操作) | ✗ 不幂等 | 幂等键+流水表/状态机 |
| 库存-1 | ✗ 不幂等 | 状态机/乐观锁/唯一约束 |
| 发MQ消息触发动作 | ✗ 不幂等 | 消费端用消息ID去重 |
这张表把"什么操作要特别处理幂等"钉死了。核心规律是:"查询、删除、全量赋值"这类操作天然幂等(做几次结果一样);而"新增、相对的加减(扣款/库存-1)"这类操作天生不幂等(做几次累加几次),必须用幂等键、唯一约束、状态机等手段额外赋予它幂等性。它给我的最大启发是:设计接口时,要先有意识地判断"这个操作天然幂等吗?"——对那些天生不幂等、又会产生重要副作用(尤其涉及钱、库存、订单)的操作,幂等设计不是"锦上添花的优化",而是"必须做的、关乎数据正确性的基本保障"。这其实呼应了 RESTful 设计里对 HTTP 方法的语义约定:GET、PUT、DELETE 被设计为幂等的,而 POST 不是——这不是随意规定,而是反映了这些操作"本质上幂不幂等"的语义;理解这层语义,能帮你在设计 API 时,就自觉地为不幂等的操作(POST 类)考虑幂等保障。养成"设计每个接口时都先问一句它幂不幂等、会不会因重复调用而出错"的习惯——是写出健壮的、经得起网络环境考验的接口的前提。
第五件事:重试与幂等,是一对必须配套的"孪生设计"
这次也让我深刻理解了"重试"和"幂等"之间密不可分的关系。
| 组合 | 结果 | 评价 |
|---|---|---|
| 不重试 + 不幂等 | 请求丢了就真丢了, 但不会重复 | 可靠性差 |
| 重试 + 不幂等(本文) | 重试导致重复执行, 重复扣款 | ✗ 灾难! 最危险的组合 |
| 不重试 + 幂等 | 没充分利用幂等带来的安全重试能力 | 浪费 |
| 重试 + 幂等 | 丢了能重试补救, 重复也不会出错 | ✓ 既可靠又安全 |
这张表道出了"重试"与"幂等"的辩证关系。核心是:"重试"是为了对抗"请求可能丢失"(提高可靠性),但重试必然带来"请求可能重复";而"幂等"正是为了让"重复"变得安全无害——所以,重试和幂等,是一对必须成对出现的孪生设计:有重试就必须有幂等(否则重试会导致重复执行的灾难),有幂等才敢放心地重试(否则不敢重试、可靠性上不去)。我犯的最危险的错,正是落在了"重试 + 不幂等"这个最糟糕的组合里。它给我的深刻启发是:在系统设计中,很多机制不是孤立存在的,而是互相依赖、必须配套的;一个机制(重试)在解决一个问题(丢失)的同时,往往会引入一个新问题(重复),而这个新问题需要另一个配套机制(幂等)来解决;只引入前者而忘了后者,常常会制造出比原问题更严重的新麻烦。这让我形成了一个重要的设计意识:当我引入一个机制来解决某个问题时,要习惯性地追问一句"这个机制本身,会引入什么新的问题?它需要什么配套设计来兜住?";系统设计的成熟,很大程度上体现在能否看到这些"机制之间的连锁反应",并成体系地、配套地去设计,而非头痛医头、引入一个补丁又埋下一个新坑。把"重试与幂等"当成不可分割的一对来设计、并培养"追问每个机制的副作用与配套"的意识——是这个重复下单的坑,教给我的关于"系统化设计"的宝贵一课。
第六件事:设计有副作用的接口时,我现在的判断习惯
现在每当我设计一个会产生副作用的接口,我都会按这张图先想清楚:
这张图的精髓,是"有副作用且天生不幂等的接口,必须做幂等设计"。纯查询天然幂等不用管;有副作用就先判断它天然幂等吗,删除/全量赋值天然幂等,新增/加减不幂等就必须做幂等:客户端带唯一幂等键、服务端去重、数据库唯一约束兜底。涉及状态流转/并发再加状态机条件更新/乐观锁。这套习惯,让我设计接口时,从"假设请求只来一次"变成了"假设请求会重复、主动保证幂等"——核心始终是:网络环境重复请求必然,有副作用的接口必须幂等;幂等键去重+唯一约束兜底,重复请求只生效一次。
我立下的几条规矩
这场"重复下单重复扣款"的事故,换来了我做架构设计时,刻进骨子里的几条铁律:
- 网络环境下重复请求是必然。客户端重试、用户重复点、MQ 至少投递一次。
- 超时不等于失败。可能已成功只是响应丢了,所以重试要配幂等。
- 有副作用的接口必须幂等。尤其涉及钱、库存、订单。
- 幂等键去重是核心。客户端带唯一键,服务端 SETNX/唯一索引去重。
- 数据库唯一约束是最后防线。即使前面去重失效,也不会出现两条。
- 重试与幂等必须配套。有重试就有重复,有幂等才敢重试。
- 引入机制先问它的副作用。每个机制都可能引入需配套解决的新问题。
附:一个可复用的幂等处理切面/装饰器
这次踩坑后,我把"幂等处理"抽象成了一个可复用的组件(注解/切面),让任何需要幂等的接口,加一个注解就能拥有幂等能力,不必每个接口都手写一遍去重逻辑:
// ====== 定义一个幂等注解 ======
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
long expireSeconds() default 86400; // 幂等键的有效期
}
// ====== 用 AOP 切面统一处理所有 @Idempotent 标注的方法 ======
@Aspect
@Component
public class IdempotentAspect {
@Autowired private StringRedisTemplate redis;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
// 1. 从请求头取幂等键
HttpServletRequest req = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
String key = req.getHeader("Idempotency-Key");
if (key == null || key.isEmpty()) {
throw new IllegalArgumentException("缺少 Idempotency-Key 请求头");
}
String redisKey = "idem:" + key;
// 2. SETNX 去重: 第一次能设置成功, 重复请求设置失败
Boolean isFirst = redis.opsForValue()
.setIfAbsent(redisKey, "PROCESSING", Duration.ofSeconds(idempotent.expireSeconds()));
if (!Boolean.TRUE.equals(isFirst)) {
// 重复请求: 直接拒绝或返回提示(更完善的做法是返回上次缓存的结果)
throw new DuplicateRequestException("重复请求, 已忽略");
}
try {
Object result = pjp.proceed(); // 第一次, 执行真正的业务
redis.opsForValue().set(redisKey, "DONE", Duration.ofSeconds(idempotent.expireSeconds()));
return result;
} catch (Throwable e) {
redis.delete(redisKey); // ★ 业务失败要删除键, 允许客户端重试!
throw e;
}
}
}
// ====== 用起来极简: 任何接口加个注解即可 ======
@Idempotent
@PostMapping("/order")
public Result createOrder(@RequestBody OrderRequest req) {
return Result.ok(doCreateOrder(req)); // 业务代码干干净净, 幂等由切面统一保障
}
// 核心: 把幂等逻辑(取键→SETNX去重→成功标记/失败删除)抽成注解+切面, 一次实现处处复用;
// 注意业务失败时要删除幂等键, 否则客户端永远无法重试这个失败的操作。
这个幂等切面,是我这次踩坑后最有价值的工程沉淀之一。它把"幂等处理"这件每个写接口都可能要面对的事,从"每个接口里手写一遍去重代码(容易写错、容易漏)",升级成了"加一个 @Idempotent 注解就自动拥有"的能力——业务代码因此变得干干净净,只专注于业务本身,而幂等这个"横切关注点"被统一、可靠地交给了切面。它还藏着一个我特意处理的关键细节:业务执行失败时,必须删除幂等键——否则一个失败的操作会被"幂等键"永久挡住,客户端再也无法重试它,把"防重复"变成了"防重试",反而坑了用户。这正是我想用这个组件,留给自己也分享给你的核心思想:对于"幂等、限流、鉴权、日志、事务"这类横切关注点(cross-cutting concern)"——它们散布在很多接口里、和具体业务无关、却又至关重要——最好的做法,是用 AOP(切面)、中间件、装饰器等机制,把它们从分散的业务代码里抽离出来、统一实现、声明式地应用。这样做的价值是双重的:一方面,业务代码得以保持纯粹、专注,不被这些通用逻辑污染;另一方面,这些通用逻辑被集中、一致、正确地实现一次,避免了"每个开发者各写一遍、各有各的 bug、有人还忘了写"的混乱(我最初的坑,正是因为幂等是"需要每个接口自觉去写"的)。把横切关注点用统一的机制抽离、让"正确且一致"成为默认——这,是我用一次幂等缺失的事故,换来的、关于"如何优雅地管理系统中那些通用而关键的能力"的实用架构智慧。
写在最后
回头看,这场由"接口没做幂等"引发的、重复下单扣款的事故,真正教给我的,远不止"加个幂等键"这一个技巧。它让我完成了一次从"单机思维"到"分布式思维"的、根本性的认知升级。我栽跟头,根源是我把在"单机、本地调用"环境里养成的直觉,想当然地搬到了"网络、分布式"环境里。在单机世界里,"调用一个方法"是确定的:它要么成功、要么失败,而且只会执行一次;我从没需要担心"这个方法会不会被莫名其妙地执行两遍"。可一旦跨越了网络,这一切确定性都崩塌了:网络会延迟、会丢包、会让响应消失;一个请求,可能成功了你却不知道、可能被自动重试、可能被重复投递。"恰好执行一次(exactly-once)"在分布式世界里是极难实现的奢望,我们能依赖的,往往只是"至少一次"——而"至少一次"就意味着"可能多次"。这让我领悟到一个深刻的认知:从单机编程走向分布式系统,最大的、也最根本的思维转变,是要抛弃对"确定性"的依赖,转而拥抱并主动应对"不确定性"——你必须假设"任何远程调用都可能失败、超时、重复、乱序",并把你的系统设计成"即使在这些坏情况下,依然能保证最终正确"的。这其实是分布式系统设计的核心哲学:"为失败而设计(Design for failure)"——不是去消除不确定性(那不可能),而是承认它、拥抱它,并构建出能在不确定性中依然保持正确的、有韧性的系统;幂等,正是这种哲学最基础、最典型的体现之一——它不去阻止"重复"发生(阻止不了),而是让"重复"变得无害。从"假设一切如我所愿"到"假设一切都可能出错并为之设计"——这,是我用一次重复扣款的事故,换来的、关于架构、关于分布式系统、也关于工程成熟度的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次设计一个写接口时,先问自己一句"它要是被调了两次,会出事吗?",那我对着那两条重复订单复盘的这大半天,就值了。
—— 别看了 · 2026