先讲个真实场景:某天早上,客服群炸了。用户小张点了三次"确认支付",前端按钮明明做了防抖,后端接口也返回了"处理中,请勿重复提交"。但后台流水表里赫然躺着三条扣款记录,而小张的账户只被扣了一次——另外两条变成了躺在余额里吃灰的"冻结中"。
这不是段子。这是几乎所有业务系统都躲不掉的幂等性问题,也是我这几年在后端实战里踩过最多的坑。所以今天,我想把"幂等"这件事彻底讲明白:它不是什么高大上的架构名词,它是一种必须从接口设计第一天就写进 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 锁就宣布"幂等搞定",然后测试环境跑得好好的,生产一上量,锁超时击穿直接重复扣款。
还有一个常被忽略的:幂等不等于顺序一致
如果同一订单先后发起"取消"和"支付"两个请求,谁先谁后?你的状态机必须定义好非法流转的处理方式——比如"订单已取消时收到支付请求,直接拒绝并退款",而不是让两条请求互相覆盖。这在业务上叫"状态冲突",在实现上通常需要版本号或者状态条件更新来兜住。
四、落地清单
如果你要给一个新接口加幂等,我会按这个顺序做:
- 先问业务问题:这个操作重复执行,后果是什么?是多扣钱(必须幂等),还是只是多点一次(无所谓),还是日志重复(可以放过)。
- 选实现层级:能用状态机就用状态机;不能就用唯一键;再不行上 Redis。
- 设计幂等键:客户端生成还是服务端生成?推荐客户端在发起请求时生成 UUID 传上来,服务端原样落库。注意:不要用请求体里的业务字段拼 hash,除非你确定那组字段在业务上真的唯一。
- 定义中间态:PROCESSING 期间重复请求返回什么?
- 确定 TTL:幂等键多久过期?结果缓存多久过期?
- 压测并发重复请求:用 100 个线程同时打同一个 request_id,看结果是不是只落了一条业务记录。这条测试不能省。
五、写在最后
幂等性听起来平淡无奇,它甚至不像"高并发"、"分库分表"那样让人兴奋。但正是这种朴素的东西,决定了你的系统在上线第一天是稳的,还是在第一次促销、第一次网络抖动、第一次 MQ 重投时就原地爆炸。
分布式系统里有一句老话:不要假设调用只会发生一次。 这句话不是理论,是血淋淋的账单。
下次再有人跟你说"这个接口不会被重复调用",你可以笑着回他一句:会的,一定会。你只是还不知道在哪儿等着你。
祝各位的扣款记录里,永远只有那一条真的成功。