一个用户被扣了三次款:我在架构设计里漏掉"幂等"这两个字,酿成的那场重复支付事故,以及如何为写接口铸上一道防重的锁
这是一通让我至今心有余悸的客诉电话转过来的。一个用户投诉:他买一件商品,明明只下了一单,银行卡却被连续扣了三次款。财务一查流水,触目惊心——确实有三笔金额一模一样、时间只差几秒的扣款记录,对应着同一个用户、同一件商品。我后背一凉:这不是小 bug,这是真金白银的资损,是会严重动摇用户信任的那种事故。
我紧急排查,很快还原了现场:那个用户当时网络卡顿,支付按钮点下去半天没反应,他不耐烦地连点了三下。于是,三个几乎同时发出的支付请求,涌向了我的服务端;而我那个支付接口,对这三个"看起来一样、但服务端并不知道它们是重复的"请求,老老实实地、独立地各处理了一遍——创建了三笔支付、扣了三次款。揪出根因的那一刻,我又懊悔又警醒,因为我发现,这场资损事故的背后,是我在架构设计时,漏掉了一个在分布式系统里至关重要、却被我忽视了的概念:幂等性(Idempotency)。我的支付接口,是一个"非幂等"的写接口——它经不起重复调用,而在真实的、充满了重试与重复的网络世界里,"重复调用"恰恰是必然会发生的。
故障现场:一个经不起重复调用的支付接口
我把出问题的支付接口逻辑,简化到最小。它的问题,不在于代码写错了,而在于它对"同一个请求被发来多次"这件事,毫无防备:
// 支付接口(有致命缺陷的版本): 没有任何防重机制
@PostMapping("/pay")
public Result pay(@RequestBody PayRequest req) {
// 1. 校验订单、余额 ...
// 2. 直接扣款、创建支付记录
accountService.deduct(req.getUserId(), req.getAmount()); // 扣款!
paymentService.createPayment(req.getOrderId(), req.getAmount()); // 创建支付记录
return Result.success();
}
// 问题: 用户连点3下, 3个请求几乎同时到达, 这个接口会:
// 请求1: 扣款 + 创建支付 ✓
// 请求2: 又扣款 + 又创建支付 ✓ (它不知道请求1刚扣过!)
// 请求3: 再扣款 + 再创建支付 ✓ (它也不知道前两个刚扣过!)
// → 同一笔订单, 被扣了 3 次款! 资损!
看着这段代码,我意识到它的"罪"不在于逻辑错误——它每一步都做得"对":校验、扣款、记录,挑不出毛病。它的致命缺陷,在于它隐含了一个在分布式世界里站不住脚的假设:"每一个打到我这里的请求,都是一个全新的、独立的、应该被处理的请求。"可现实是,由于用户的重复点击,这三个请求,本质上是同一个支付意图的三次重复表达。而我的接口,没有任何能力去识别"它们其实是同一个",于是把它们当成三个独立的支付,各扣了一次款。"幂等",这个我设计时压根没考虑的特性,它要求的恰恰是:无论这个支付请求被调用一次、还是被重复调用一百次,其最终产生的效果(扣款),都应该和"只调用一次"完全相同。我的接口,彻底违背了它。
而更让我警醒的是,我意识到"重复请求"绝不只来自用户连点:
"重复请求", 在分布式系统里是【必然】会发生的, 来源远不止用户连点:
1. 用户重复点击: 按钮没反应, 用户手动点了好几次 (我这次的直接原因)
2. 网络重试: 客户端/网关发出请求后超时, 自动重试 (但其实第一次已成功)
3. 消息队列重投: MQ 为保证"至少投递一次", 可能把同一条消息投递多次
4. 前端框架重发: 某些框架在特定情况下会重发请求
5. 微服务间的重试: 服务A调服务B超时重试, 但B其实已处理成功
→ 在网络的世界里, "一个请求只会被发起、且只会成功处理一次"
是一个【天真而危险】的幻想; "重复"才是常态!
→ 所以, 凡是会"改变数据/产生副作用"的写接口, 都必须考虑幂等!
第一件事:搞懂什么是幂等,以及为什么写接口必须幂等
定位到根因,我把"幂等性"这个我一直一知半解的概念,彻底搞懂了:幂等(Idempotent),指的是一个操作,无论执行一次,还是重复执行多次,所产生的影响(对系统状态的改变)都是相同的。一个幂等的操作,你可以放心地重复调用,而不用担心它会产生额外的、累加的副作用。
幂等的本质: 操作一次 和 操作多次, 对系统状态的最终影响, 是相同的。
天然幂等的操作(可以放心重复):
- 查询: SELECT ... (查一次和查十次, 数据没变)
- 赋值式更新: UPDATE user SET status = 'PAID' WHERE id = 1
(把状态设为 PAID, 设一次和设十次, 结果都是 PAID)
- 删除: DELETE WHERE id = 1 (删一次和删十次, 结果都是"它没了")
天然【非】幂等的操作(重复调用会出事):
- 累加式更新: UPDATE account SET balance = balance - 100 WHERE id = 1
(扣 100, 调一次扣 100, 调三次扣 300! ← 我踩的坑就是这类!)
- 插入: INSERT INTO payment ... (插一次一条, 插三次三条)
- "创建"类操作: 创建订单、创建支付 ... (天然非幂等)
关键: HTTP 方法的语义里, GET/PUT/DELETE 设计上应是幂等的, POST 通常非幂等。
而我们绝大多数"写操作"(尤其涉及金钱、创建), 都是【非幂等】的,
这正是它们在重复调用面前如此脆弱的原因 —— 必须主动给它们加上幂等保障!
原理终于清晰了。幂等的核心,是"操作一次"和"操作多次"对系统状态的最终影响相同——一个幂等的操作,天生就能扛住重复调用。有些操作是天然幂等的:查询(查多少次数据都不变)、赋值式更新(SET status = 'PAID',设多少次都是 PAID)、按主键删除(删多少次结果都是"它没了")。而有些操作,则天然非幂等:累加式更新(balance = balance - 100,调三次就扣 300——这正是我踩的坑!)、插入(插三次就三条记录)、各种"创建"类操作。而问题的关键在于:我们日常写的绝大多数有价值的"写操作"——扣款、下单、创建支付——恰恰都属于"非幂等"的那一类。它们在重复调用面前,天生就是脆弱的。所以,在一个'重复请求是常态'的分布式世界里,为这些非幂等的写接口,主动地设计并加上'幂等保障',就不是一个'可选的优化',而是一个'必须的、关乎数据正确性与资金安全的'架构要求。我这次的资损事故,正是因为我把这个"必须",当成了"可选",直接忽略了。
第二件事:正解——用"唯一标识 + 防重判断"给写接口上锁
搞懂了根因,正解的思路就清晰了:既然问题是"服务端无法识别两个请求其实是同一个",那解法就是——给每一次"业务操作"赋予一个全局唯一的标识(幂等键),服务端在真正执行前,先用这个标识去判断"这个操作我是不是已经处理过了";处理过了,就直接返回上次的结果,绝不重复执行。我落地了几种常用方案:
// 正解1: 数据库唯一约束 —— 最简单可靠的"兜底防线"
// 给"业务唯一键"加唯一索引, 重复插入会被数据库直接拒绝。
// 比如: 支付表的 order_id 加唯一约束, 同一订单只能有一条支付记录
// ALTER TABLE payment ADD UNIQUE KEY uk_order_id (order_id);
try {
paymentService.createPayment(req.getOrderId(), req.getAmount()); // 重复会抛唯一键冲突
} catch (DuplicateKeyException e) {
return Result.success("支付已处理, 请勿重复提交"); // 优雅地告诉前端"这单已付过了"
}
// 正解2: 幂等令牌(Token)机制 —— 防"用户重复点击"的经典方案
// ① 用户进入支付页时, 服务端先发一个一次性的 token 给前端
// ② 用户点支付, 带上这个 token
// ③ 服务端用 Redis 原子操作校验+删除这个 token:
String token = req.getToken();
// 用 Redis 的原子删除: 删成功(返回1)说明是第一次, 删失败(0)说明 token 已用过
Long deleted = redis.delete("pay_token:" + token); // 实际用 lua 保证"校验+删除"原子
if (deleted == 0) {
return Result.fail("请勿重复提交"); // token 已被用过, 拦截!
}
// 走到这, 说明是第一次, 放心处理支付 ...
// 正解3: 业务状态机 —— 用"状态流转"天然防重
// 订单状态: 待支付 → 已支付。 扣款前先用"乐观锁"判断+流转状态:
// UPDATE orders SET status='PAID' WHERE order_id=? AND status='待支付'
// 只有第一个请求能把"待支付"改成"已支付"(影响1行), 后续请求影响0行 → 拦截
这几种方案,从不同层面织就了幂等的防护网。正解1(数据库唯一约束)是最简单、最可靠的"最后兜底防线"——给"业务唯一键"(如订单号)加唯一索引,那么哪怕重复请求穿透了上层所有防御,数据库这一关也会用"唯一键冲突"把第二条重复记录硬生生挡下,确保数据层面绝不重复。正解2(幂等令牌)是专门针对"用户重复点击"的经典方案:页面加载时发一个一次性 token,提交时带上,服务端用 Redis 原子地"校验并删除"这个 token——第一个请求删除成功、放行,后续重复请求因 token 已不存在而被拦截。正解3(业务状态机)则最优雅:利用"订单状态只能从'待支付'流转到'已支付'一次"这个业务规则,用一条带状态条件的 UPDATE(乐观锁),让只有第一个请求能成功流转状态、后续请求因状态已变而落空。这三者可以、也常常被组合使用:用 token 在入口拦住大部分重复点击,用状态机控制业务流转,再用数据库唯一约束做最后的、绝不失守的兜底——层层设防,确保同一笔支付,无论被请求多少次,都只会真正执行一次。
下面这张图,展示了一个带幂等保障的写接口,是如何处理重复请求的:
这张图的核心,是那个"这个操作之前处理过吗?"的判断节点——它就是幂等的灵魂。有了这个判断,接口就从"来一个请求处理一个"的'无脑执行',升级成了"先识别、再决定"的'智能防重':第一次就老老实实执行并留下记录;重复的,就识别出来、跳过执行、直接返回上次结果。而底层那条"数据库唯一约束"的兜底防线(黄色虚线),则是在所有上层判断万一都失效时,确保数据绝不重复的最后一道保险。
第三件事:幂等设计的几个"魔鬼细节"
动手实现幂等时,我又踩了几个"细节坑",才发现幂等这事儿,知道思路容易,做对却有不少讲究。我把这几个魔鬼细节也总结了出来:
// 魔鬼细节1: "判断"和"执行"必须保证【原子性】, 否则有并发漏洞!
// ✗ 错误: 先 if(查到没处理过), 再执行 —— 两步之间, 并发请求会双双通过判断!
if (redis.get("idempotent:" + key) == null) { // 请求1、2 同时查, 都发现是 null
process(); // 请求1、2 都执行了! 防重失效!
redis.set("idempotent:" + key, "done");
}
// ✓ 正确: 用原子操作"占坑"—— setnx(set if not exist), 只有一个能成功
Boolean ok = redis.setIfAbsent("idempotent:" + key, "processing", 10, MINUTES);
if (!ok) { return Result.fail("处理中或已处理"); } // 没抢到坑 → 是重复请求
// 抢到坑的, 才执行业务 ...
// 魔鬼细节2: 幂等键怎么选? 必须能唯一标识一次"业务操作"
// ✗ 用 userId: 同一用户的不同支付会被误判为重复
// ✓ 用 orderId / 业务流水号 / 客户端生成的 requestId: 精确标识一次操作
// 魔鬼细节3: 处理失败了, 占的"坑"要释放, 否则这个操作永远没法重试了
try { process(); }
catch (Exception e) {
redis.delete("idempotent:" + key); // 失败要清理, 允许重试
throw e;
}
// 魔鬼细节4: 幂等记录的"过期时间", 要权衡 —— 太短防不住延迟的重复, 太长占空间
这几个魔鬼细节,每一个都可能让你"自以为做了幂等、其实没做对"。细节1(原子性)是最致命的:如果"判断有没有处理过"和"执行"是分开的两步,那么在高并发下,多个重复请求会同时通过"还没处理过"的判断,然后都执行了一遍——防重在并发面前彻底失效!正确的做法,是用 Redis 的 setnx(set if not exist)这类原子操作来"占坑",保证只有一个请求能占坑成功、得以执行。细节2(幂等键的选择):键必须能精确唯一地标识"一次业务操作"——用 orderId 或客户端生成的 requestId 是对的,用 userId 则会把同一用户的不同支付误判成重复。细节3(失败要释放坑):如果占了坑、但业务执行失败了,得把坑释放掉,否则这个操作就被"永久幂等"住、再也无法重试了。细节4(过期时间的权衡):幂等记录留多久,是个需要权衡的工程问题。这些细节告诉我:幂等不是'加一个判断'那么简单,它是一套需要严谨考虑'原子性、键的选择、失败处理、过期'的完整机制——任何一个细节没做对,这道防重的锁,就可能在某个并发的、异常的边界上,悄悄地失守。
第四件事:幂等只是冰山一角——分布式系统的"防御性设计"
这次幂等事故,把我的视野拉到了一个更大的命题上:幂等,本质上是分布式系统里一类"防御性设计"的代表。在分布式的世界里,网络会抖动、请求会重复、节点会宕机、消息会乱序——种种"异常",不是偶发的意外,而是必然的常态。一个健壮的系统,必须对这些常态化的异常,做好防御。我顺着这个思路,把分布式系统里几个和幂等同源的"防御性设计"也梳理了一遍:
分布式系统的"防御性设计"家族(都在防御"必然发生的异常"):
1. 幂等(本文): 防"重复请求"
—— 重复请求是常态, 写接口必须能扛住重复调用
2. 重试 + 退避: 应对"瞬时失败"
—— 网络抖动、对方瞬时过载是常态, 失败要带退避地重试
—— 但注意: 重试本身, 又是"重复请求"的来源 → 所以重试的对象必须幂等!
3. 熔断 / 降级: 防"故障扩散"
—— 依赖的服务会挂, 要能熔断(快速失败)、降级(给兜底结果), 防止雪崩
4. 限流: 防"流量打垮系统"
—— 流量会突增, 要限流, 保护系统在容量内运行
5. 超时控制: 防"无限期等待"
—— 对方可能不响应, 每个远程调用都要设超时, 别让线程被永久占用
6. 最终一致性 / 对账: 防"数据不一致"
—— 分布式事务难做到强一致, 用最终一致 + 定时对账, 兜住数据正确性
核心心智: 在分布式系统里, 不要假设"一切正常",
而要假设"任何能出错的地方, 都会出错", 并为每一种"出错", 设计好防御。
这个梳理,让我对"做分布式系统架构"这件事,有了脱胎换骨的认识。它的核心,是一种"防御性"的心智模式——你不能像写单机程序那样,默认"网络可靠、请求不重复、对方一定响应、节点不会挂";恰恰相反,你必须假设"凡是能出错的地方,都一定会出错",然后为每一种可能的"出错",都预先设计好相应的防御。幂等,防的是"重复请求";重试 + 退避,应对的是"瞬时失败"(而它又恰恰要求被重试的对象是幂等的——你看,这些设计是环环相扣的);熔断降级,防的是"故障扩散导致雪崩";限流,防的是"流量打垮系统";超时控制,防的是"无限期的等待拖垮线程";最终一致 + 对账,兜的是"数据的最终正确"。我这次的幂等事故,本质上是我还停留在"单机思维"——默认请求不会重复——却去做了一个分布式的系统。而真正的分布式架构能力,正是建立在这种'假设一切都会出错、并为之设防'的防御性思维之上的。把这个家族的防御设计整理成一张表:
| 防御设计 | 防御的"必然异常" | 典型手段 |
|---|---|---|
| 幂等 | 重复请求 | 唯一约束/token/状态机 |
| 重试+退避 | 瞬时失败 | 指数退避重试(对象须幂等) |
| 熔断降级 | 故障扩散/雪崩 | 熔断器/兜底返回 |
| 限流 | 流量突增打垮 | 令牌桶/漏桶/滑动窗口 |
| 超时控制 | 无限期等待 | 每个远程调用设超时 |
| 最终一致+对账 | 数据不一致 | 异步补偿/定时对账 |
第五件事:把"幂等方案选型"沉淀成一张对照表
回到幂等本身,我把几种主流的幂等实现方案,按它们的适用场景、优缺点,整理成了一张选型对照表,方便以后根据具体场景快速决策:
幂等方案选型, 没有"银弹", 要按场景选(常常组合使用):
- 数据库唯一约束: 实现最简单, 最可靠的兜底。
适合: 有天然唯一键(订单号)的"创建"类操作。
缺点: 依赖数据库, 高并发下唯一键冲突有一定开销。
- 悲观锁 (select for update): 强一致, 但性能差、易死锁。
适合: 并发不高、一致性要求极强的场景。慎用。
- 乐观锁 (版本号/状态条件 update): 性能好, 适合"状态流转"。
适合: 有明确状态机的业务(待支付→已支付)。
- 唯一 token + Redis: 灵活, 适合防"用户重复提交表单/点击"。
适合: 面向用户交互入口的防重。
- 分布式锁 (Redis/zk): 通用但重, 要处理锁超时、续期等复杂性。
适合: 需要"全局串行化"某操作的场景。
- 去重表 + Redis 缓存: 通用的"操作流水号"防重方案。
适合: 各类需要按 requestId 去重的接口。
这张选型表的核心思想是:幂等没有"一招鲜吃遍天"的银弹,不同的方案,在实现复杂度、性能、一致性强度、适用场景上,各有取舍;真正的功夫,在于根据你具体的业务场景(是创建类还是状态流转类?并发多高?一致性要求多强?重复主要来自哪?),去选择、甚至组合最合适的方案。比如我最终的方案,就是组合拳:面向用户的支付入口,用 token 拦住绝大多数的重复点击;业务流转上,用状态机的乐观锁控制"待支付→已支付"只发生一次;最底层,再用支付表 order_id 的数据库唯一约束做绝不失守的兜底。这种"多层防御、组合使用"的思路,恰恰呼应了前面讲的防御性设计——用多道彼此独立的防线,去应对一个'绝不能出错'(资金安全)的场景,哪怕某一层万一失守,后面还有别的层兜着。把这几种方案的选型要点汇总成一张对照表:
| 方案 | 适合场景 | 优点 | 注意 |
|---|---|---|---|
| 数据库唯一约束 | 有唯一键的创建操作 | 简单可靠, 兜底强 | 高并发有冲突开销 |
| 乐观锁(状态条件) | 有状态机的业务 | 性能好, 优雅 | 需有明确状态流转 |
| 唯一 token+Redis | 用户重复提交/点击 | 灵活, 拦在入口 | 需校验删除原子 |
| 分布式锁 | 需全局串行化 | 通用 | 重, 要管超时续期 |
| 去重表+缓存 | 按 requestId 去重 | 通用 | 需维护去重存储 |
一张"这个接口要不要、怎么做幂等"的决策图
把这次踩坑沉淀成一张图。每设计一个接口时,照着它判断幂等怎么做:
这张图的判断主线:凡是"会改变数据/产生副作用"的写接口,只要它非幂等(扣款、创建、累加),就必须设计幂等。再根据"重复主要来自哪",选 token、状态机、唯一约束,并组合成多层防御——最关键的是,无论哪种方案,都要保证"判断有没有处理过"和"执行"这两步的原子性。把这套判断变成设计每个接口时的本能,资损级的重复事故就能被挡在门外。
我立下的几条幂等与分布式设计规矩
这次"一个用户被扣三次款"的资损事故后,我给自己立了几条规矩:
- 写接口默认考虑幂等:凡是会改数据/产生副作用的接口,设计时第一件事就是问"它幂等吗?重复调用会怎样?"。
- 涉钱/创建必须幂等:扣款、下单、创建支付等涉及金钱或"创建"的操作,幂等是硬要求,绝不能省。
- 判断与执行保证原子:用 setnx 等原子操作"占坑",绝不写"先 if 查、再执行"的非原子防重(并发下必失效)。
- 幂等键精确标识操作:用 orderId/requestId 等能唯一标识"一次业务操作"的键,别用 userId 这种会误判的。
- 多层防御兜底:token 拦入口 + 状态机控流转 + 数据库唯一约束兜底,关键场景层层设防。
- 重试的对象必须幂等:任何带重试的调用,其目标接口必须幂等,否则重试本身就是重复请求的来源。
- 用分布式思维做架构:假设网络会抖、请求会重、节点会挂,为每种必然的异常(重复、失败、雪崩、超时)设计防御。
这几条里,第一条"写接口默认考虑幂等"是最该刻进设计本能的。而贯穿所有规矩的那条主线,是从"单机思维"到"分布式思维"的转变。我这次栽这么大一个跟头,根子上是我用着"单机"的脑子,去设计一个"分布式"的系统——我下意识地默认"一个请求只会被发起一次、且只会成功一次",这在单机、在理想世界里似乎天经地义,可在真实的、有网络、有重试、有并发的分布式环境里,是一个彻头彻尾的、危险的幻想。分布式系统最核心的思维转变,就是要放弃这种'一切都很美好'的默认假设,转而拥抱'墨菲定律'——凡是可能出错的,就一定会出错;然后,为每一种'必然会出错'的情况,都老老实实地设计好防御。幂等,只是这种防御性思维,在"防重复请求"这一个点上的具体体现。
写在最后:真正的健壮,是假设一切都会出错
这次被"重复支付"狠狠教育的经历,让我对"什么是健壮的系统"这件事,有了一次认知的升级。我过去理解的"健壮",更多是"功能正确、逻辑没 bug"——把该实现的功能,正确地实现出来。可这次让我明白,在分布式、在真实世界里,真正的健壮,有一层更深的含义:它不是'假设一切正常,然后把正常流程做对',而是'假设一切都会出错,然后让系统在各种出错的情况下,依然能保持正确'。我那个支付接口,在"一切正常"(请求不重复)的假设下,是完全正确的;可它脆弱,正因为它只在"正常"时正确,一旦遭遇"重复请求"这种异常,就轰然崩塌。而一个真正健壮的接口,会预先就假设"请求一定会重复",并为此做好防御——它的正确性,不依赖于"运气好、没遇到异常",而是在异常必然发生的前提下,依然稳如磐石。
想通这一点,我对"防御性"这三个字,生出了由衷的敬意。编程里,尤其是构建面向真实世界、面向规模、面向分布式的系统时,'乐观'是一种危险的奢侈,而'防御性的悲观'——时刻假设最坏的情况会发生、并为之做好准备——才是通往健壮与可靠的、那条唯一靠谱的路。这种"防御性的悲观",体现在方方面面:对每一个外部输入,都假设它可能是恶意的、非法的;对每一次远程调用,都假设它可能失败、可能超时、可能重复;对每一个并发,都假设最坏的时序会发生;对每一份数据,都假设它可能不一致。这听起来很"累"、很"不信任",但正是这种近乎偏执的、对"出错"的预设与防御,才铸就了那些能在真实世界的惊涛骇浪里、依然稳定运行的可靠系统。
所以,如果你也想构建出真正健壮、能扛住真实世界考验的系统,我想把这次踩坑最想说的话送给你:请养成一种'防御性的悲观'——在设计每一段逻辑、每一个接口时,都多花一点力气,去想一想'它会怎么出错',而不只是'它正常时怎么工作'。这个请求会不会重复?这个调用会不会失败?这个并发会不会有竞态?这个依赖会不会挂掉?这份数据会不会不一致?因为真正区分'能用的系统'和'可靠的系统'的,往往不是前者实现了多少功能,而是后者,为多少种'可能的出错',默默地铸好了防线;健壮,从来不是'运气好没出事',而是'早就假设会出事、并为之做足了准备'。那三笔让用户多扣的款,最终教给我的,正是这份对"出错"的敬畏与防御——它让我懂得,写出"正常时能跑"的代码只是入门,而写出"假设一切都会出错、却依然坚挺"的代码,才是一个架构师真正的修养所在。
—— 别看了 · 2026