我们给支付接口加了幂等键,结果它照样扣了三次钱

2026-11-13 12 0

先说个真事。

某个周六凌晨两点,值班手机把我叫醒:支付网关告警,某笔订单被扣款三次。

不是重试导致的重复请求——重试是我们自己加的,而且它工作得很好。真正的问题是:幂等键写了,扣款也执行了,但两者没锁在同一笔事务里。

复盘结论是那句烂大街的话:"幂等键没做好。"我花了两天翻代码才明白,不是幂等键写得不对,是我们把"幂等"当成中间件功能,而不是一段业务状态机。这个认知偏差,比任何一条具体 bug 都值钱。

这篇不讲"什么是幂等键",讲更硬的三件事:幂等键的生命周期、崩溃窗口,以及三道我亲手踩过的送命题。


一、幂等不是"标记位",是状态机

99% 的教程教你这么写:

if (redis.setnx(key, 1) == 0) {
    return "duplicate request, ignored";
}
doBusinessLogic();

两个假设都不成立:setnx 成功 ≠ 业务一定完成;执行过程中进程不会死。

真实世界里,进程会在任何一行指令之间死掉。你在 setnx 之后、业务逻辑之内遭遇一次 OOM kill、一次容器驱逐、一次网关超时掐断连接——然后重试请求带着同一个幂等键回来了。

此时键已存在 → 直接返回"重复请求" → 但业务从没做完。用户的订单永远停在"待支付"。

这比重复扣款还恶心。重复扣款用户能投诉,"钱扣了但订单没生成"直接变成客诉工单和坏账。

正确的抽象是:幂等记录必须是一个带状态的生命周期实体,而不是布尔标记。至少三种状态:

  • PROCESSING:已受理,正在执行
  • SUCCEEDED:业务完成,直接返回上次的结果
  • FAILED:明确失败,允许重试

只有 PROCESSING 需要小心处理(等待或返回 409),SUCCEEDED 才是"幂等命中"。


二、送命题一:先写标记还是先执行业务

两种顺序,各有各的死法。

顺序 A:先标记,后业务。崩溃窗口内业务未完成,标记却已存在,重试被判重复 → 业务丢失。

顺序 B:先业务,后标记。业务完成但标记没写,重试重新执行业务 → 这就是开头那次三倍扣款。

两个失败模式互为镜像,危害却不对称:A 是假阴性(本该成功被判失败),B 是假阳性(本该幂等被判重复)。

唯一同时躲开两个窗口的做法:把幂等记录和业务写入放进同一个数据库事务。

CREATE TABLE idempotency_keys (
    key           text        NOT NULL,
    scope         text        NOT NULL,
    status        text        NOT NULL DEFAULT 'PROCESSING',
    request_hash  text        NOT NULL,
    response_body jsonb,
    created_at    timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (scope, key)
);

request_hash 是护栏:同一个 key 配不同的请求体,说明客户端在乱用,直接返回 422,而不是当成重复静默丢弃——那种沉默会让人排查到怀疑人生。

事务也有边界

业务要调第三方(发短信、扣款网关)时,HTTP 调用塞不进数据库事务,只能退一步:本地事务只落"意图",事务外调第三方,成功后回写结果。

BEGIN;
  INSERT INTO idempotency_keys(...) VALUES ('pay',$1,'PROCESSING',$2);
  INSERT INTO payment_intents(order_id,amount,status) VALUES ($3,$4,'PENDING');
COMMIT;
-- 事务外调第三方,成功后回写 SUCCEEDED + response_body

残留的 PROCESSING 记录必须有超时兜底:超过 N 分钟仍是 PROCESSING 的,要么判失败允许重试,要么主动去第三方查单确认。这不是可选项。否则第三方调用成功但回写前进程死了,你永远卡在假"处理中"上。


三、送命题二:并发同键的竞态

另一常被忽略的场景:客户端并发重试两个完全相同的请求(手抖点两下,或网关同时发两份),两个请求带着同一个幂等键同时到达。

如果实现是"先 SELECT 判断,再 INSERT",这是教科书级 TOCTOU 竞态:两个请求同时 SELECT 都发现没有,然后都 INSERT,都去执行业务。

解法不神秘:用数据库唯一索引做仲裁,让"检查"和"占位"成为同一个原子操作。插入冲突说明有人在处理,走等待或拒绝分支。靠的是唯一索引,不是你的应用逻辑——这才是可靠的。

顺带说,很多人用 Redis 做幂等键,理由是"快"。但 SETNX 和业务写入根本不在一个事务边界里,它只解决并发抢锁,没解决崩溃窗口。只有 Redis 可用时可以接受,但要清楚:它是准入闸门,不是幂等实现。


四、送命题三:幂等键的生命周期,比业务数据还短

这条最容易被忽略,后果最阴。

幂等键要活多久?至少要比"任何一次重试可能发生的时间窗 + 客户端可能重放的时长"更长。而现实是,幂等表常被定期清理任务"顺手清一下",理由是"这些记录没用了"。

然后某天,一个离线补单脚本、或 MQ 消费者重投递、或用户翻出半年前的旧页面又点了一次提交,带着两个月前的幂等键回来——记录已被清掉,INSERT 成功,业务重跑一遍。

清理策略不能拍脑袋:

  • 按 TTL 清理:TTL 必须 > 最大可接受重放窗口,且要与客户端重试策略对齐
  • 按业务维度清理:跟订单生命周期绑定,保留 N 天
  • 永不清理 + 分区滚动:按时间分区,老数据直接 DROP PARTITION,不锁表不占空间

我倾向第三种:用"最大分区保留期 ≥ 客户端最长重试窗口"这条硬约束把它锁死。

另一个小而致命的细节:幂等键要全局唯一。客户端生成的 UUID 没问题;服务端生成的(比如 用户+时间戳)可能撞,分库分表后各分片各生成各的,撞的概率成倍上升。


五、幂等 ≠ 去重 ≠ 顺序性

这是三件事:幂等是同一请求执行多次效果等于一次,去重是重复请求不产生新数据(靠唯一索引),顺序性是同一实体的多次变更按序生效。

有幂等键不代表有顺序性。支付回调先到"支付成功",再倒序来一个"支付关闭"(MQ 重投递下几乎必然发生),幂等键拦不住第二个消息——它是个全新的键。

解法不是继续加幂等键,而是状态机 + 版本号:

UPDATE orders
   SET status = $1, version = version + 1
 WHERE order_id = $2
   AND version < $3            -- 旧版本消息直接不生效
   AND status IN ('PENDING');  -- 状态机约束

幂等解决"同一次操作重复到达",状态机解决"不同操作乱序到达"。两个问题两个解,别指望一把钥匙开两把锁。


六、怎么验证你的幂等真的对

大多数团队的幂等测试是:连调两次,断言第二次返回"重复"。这测不出上面任何一条——既没模拟崩溃,也没模拟并发。

有用的对抗性测试有四组:

  1. 崩溃注入:业务执行到一半 kill 进程,再重试,断言业务恰好完成一次。需要测试桩能精确控制中断点,最值得投入
  2. 并发同键:N 个线程同时带同一键发起请求,断言副作用只发生一次。用 N=20 这种夸张值,能把竞态窗口放大到必现
  3. 键重用 + 不同载荷:同一键配不同请求体,断言明确报错而非静默丢弃
  4. 键过期后重放:手工删除幂等记录后重放老请求,断言不产生重复副作用——专治拍脑袋定的 TTL

做完第四组测试后,我司幂等表保留期从 24 小时改成了 30 天。那次三倍扣款之后,我们把网关层也加了一层基于业务单号的去重。TTL 这种东西,业务方比你更清楚"重试可能跨越多久"。


七、最后

现在我会跟团队这样讲幂等:

幂等键不是防线,事务才是防线。
幂等键不是标记,是带状态的记录。
幂等解决不了顺序问题,状态机才行。

如果你在做支付、库存、发放、扣减这类"副作用不可回滚"的接口,去查三件事:幂等记录和业务写入是否在同一事务里;并发同键是否靠数据库唯一索引而非应用层 if 判断仲裁;幂等记录保留期是否大于客户端最长可能重放窗口。

这三条不过关,你的幂等键就只是一个好看的字符串。

如果你也在深夜被支付告警叫醒过,欢迎交流。我那台服务器还在跑,账单还在涨,教训还在。

(本文说的"我们",是那个被坑过的小团队。坑归我们,代码归你。)

相关文章

分布式系统里没有「现在」:时钟、超时与租约,一次讲透
Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解
你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天

发布评论