你的支付接口被扣了两次钱:一次幂等性设计翻车实录

2026-10-23 8 0

先讲个真实场景:某天早上,客服群炸了。用户小张点了三次"确认支付",前端按钮明明做了防抖,后端接口也返回了"处理中,请勿重复提交"。但后台流水表里赫然躺着三条扣款记录,而小张的账户只被扣了一次——另外两条变成了躺在余额里吃灰的"冻结中"。

这不是段子。这是几乎所有业务系统都躲不掉的幂等性问题,也是我这几年在后端实战里踩过最多的坑。所以今天,我想把"幂等"这件事彻底讲明白:它不是什么高大上的架构名词,它是一种必须从接口设计第一天就写进 DNA 的纪律。

一、先搞清楚:为什么重复请求一定会发生

很多人以为幂等问题是"前端没做好防抖"。太天真了。防抖只是最外层的一层纸糊的防线,重复请求可以来自任何地方:

  • 网络超时重试:客户端等 3 秒没响应,就重发一次。但服务端其实已经处理完了,只是响应包在路上丢了。你以为在重试,其实是在重复扣钱。
  • 网关与负载均衡:网关超时了,但它不知道后端是"没处理"还是"处理了没回包",于是换一台机器再打一遍。
  • MQ 消费:消息队列至少一次(at-least-once)投递是常态,消费端 ack 失败就得重投。你写的 `if (check) { do(); }` 逻辑,在并发消费下就是个薛定谔的 bug。
  • 用户手抖:移动网络抖动时,用户看见那个转圈会下意识再点一次。这是人性,不是 bug。

核心认知:客户端的每一次重试都是合理的。系统的不幂等,是服务端的责任。

二、幂等的三种实现层级

按"成本从低到高"和"可靠性从低到高"排列,业界大致有这几种做法。

第 1 层:业务唯一键(最朴素,也最常用)

给每一笔业务操作一个天然唯一的标识——订单号、请求流水号。最经典的做法是建一张去重表:

CREATE TABLE idempotency_log (
    request_id   VARCHAR(64)  PRIMARY KEY,
    biz_type     VARCHAR(32)  NOT NULL,
    status       VARCHAR(16)  NOT NULL,  -- PROCESSING / SUCCESS / FAILED
    response_body TEXT,
    created_at   TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

处理逻辑变成:

-- 插入去重表,唯一键冲突就说明重复请求
INSERT INTO idempotency_log (request_id, biz_type, status)
VALUES (?, ?, 'PROCESSING');

-- 如果这里报 DuplicateKeyException,说明是重复请求
-- 读取之前的结果直接返回,不要再执行业务逻辑

这招的好处是:唯一约束由数据库保证,天然抗并发,不需要分布式锁。坏处也很明显——多了一次写操作,而且你必须想清楚"处理中"这个中间状态怎么办。

第 2 层:Redis 去重 + 状态机

如果你的写操作对延迟敏感,可以先用 Redis 挡一道:

SETNX idem:{request_id} 1 EX 300 NX
-- 返回 1:抢到锁,第一个请求,继续执行业务
-- 返回 nil:重复请求

但这里有个致命细节:SETNX 成功不等于业务成功。你业务处理到一半崩了,Redis 里那个 key 还挂着 5 分钟,用户重试就被"误判"成重复请求,直接吃了个闭门羹。

所以必须配合状态机,把"处理中"和"已完成"区分开:

-- 第一次:写入处理中
SET idem:{request_id} "PROCESSING" EX 300 NX
-- 完成后:更新为成功并缓存结果(注意保留 TTL 或另设结果过期时间)
SET idem:{request_id} "SUCCESS:{result_json}" EX 86400
-- 失败:删除 key,允许用户重试
DEL idem:{request_id}

处理中遇到重复请求,有两种策略:一是直接返回"处理中,请稍候";二是等一会儿再查结果。我个人倾向第一种,除非你的业务天然要求"必须拿到最终结果"。

第 3 层:业务状态机(终极解法)

这是我最推崇的方式,因为它把幂等性内化到了业务模型里,根本不依赖任何中间件:

-- 订单状态流转
-- CREATED -> PAYING -> PAID -> SHIPPED
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = ? AND status = 'PAYING';

看这个 WHERE 条件:只有状态是 PAYING 时才允许改成 PAID。第二次请求进来时,受影响行数为 0,说明状态已经流转过了,直接返回已有结果即可。

这个模式和乐观锁异曲同工,但语义更清晰——状态机是业务规则,乐观锁是技术手段。很多业务其实天然就是状态机:工单、审批、订单、物流,全都用得上。

三、几个容易翻车的边界情况

坑 1:把"删除"当幂等。 DELETE 接口重复调用,返回"删除成功"还是"不存在"?建议统一返回成功。删除操作天然幂等,但如果你在删除前还做了"扣库存""退款"这些动作,那就不是了。副作用必须一起做幂等处理。

坑 2:幂等键的生命周期太短。 TTL 设成 60 秒,结果用户的支付网关 3 分钟后才回调,重复了。所以幂等键的过期时间必须大于业务可能的最长重复窗口,通常至少覆盖一天甚至更长。

坑 3:幂等键的粒度不对。 用 user_id 做幂等键?错了,这会导致同一用户不同订单被误判为重复。幂等键必须标识"这一次具体操作",而不是"这个人"。

坑 4:以为幂等 = 线程安全。 幂等解决的是"多次执行同一操作",线程安全解决的是"并发访问共享状态"。两个正交的问题,别混着谈。我见过团队把接口加了 Redis 锁就宣布"幂等搞定",然后测试环境跑得好好的,生产一上量,锁超时击穿直接重复扣款。

还有一个常被忽略的:幂等不等于顺序一致

如果同一订单先后发起"取消"和"支付"两个请求,谁先谁后?你的状态机必须定义好非法流转的处理方式——比如"订单已取消时收到支付请求,直接拒绝并退款",而不是让两条请求互相覆盖。这在业务上叫"状态冲突",在实现上通常需要版本号或者状态条件更新来兜住。

四、落地清单

如果你要给一个新接口加幂等,我会按这个顺序做:

  1. 先问业务问题:这个操作重复执行,后果是什么?是多扣钱(必须幂等),还是只是多点一次(无所谓),还是日志重复(可以放过)。
  2. 选实现层级:能用状态机就用状态机;不能就用唯一键;再不行上 Redis。
  3. 设计幂等键:客户端生成还是服务端生成?推荐客户端在发起请求时生成 UUID 传上来,服务端原样落库。注意:不要用请求体里的业务字段拼 hash,除非你确定那组字段在业务上真的唯一。
  4. 定义中间态:PROCESSING 期间重复请求返回什么?
  5. 确定 TTL:幂等键多久过期?结果缓存多久过期?
  6. 压测并发重复请求:用 100 个线程同时打同一个 request_id,看结果是不是只落了一条业务记录。这条测试不能省。

五、写在最后

幂等性听起来平淡无奇,它甚至不像"高并发"、"分库分表"那样让人兴奋。但正是这种朴素的东西,决定了你的系统在上线第一天是稳的,还是在第一次促销、第一次网络抖动、第一次 MQ 重投时就原地爆炸。

分布式系统里有一句老话:不要假设调用只会发生一次。 这句话不是理论,是血淋淋的账单。

下次再有人跟你说"这个接口不会被重复调用",你可以笑着回他一句:会的,一定会。你只是还不知道在哪儿等着你。

祝各位的扣款记录里,永远只有那一条真的成功。

相关文章

数据库没慢,你的服务却卡死了:连接池里那把椅子,藏着 P99 崩塌的真凶
你的服务平均值 8ms,P99 却要 3 秒:排队论视角下的尾延迟凶案现场
你的分页接口正在偷偷拖垮数据库:OFFSET 的谎言,和游标分页的真相
B+树统治了数据库界三十年,然后被一台”缝纫机”干翻了:LSM-Tree 的暴力美学
服务器上的时间是假的:分布式系统里那三个互相打架的时钟
为什么你的API总被吐槽?聊聊那些让人又爱又恨的接口设计

发布评论