上周帮一个客户查库存对不上的问题,最后定位到支付回调:微信支付因网络抖动重发了两次通知,接口老老实实处理了两遍,库存扣了两次,订单状态被第二次覆盖。这类问题在订单、支付、库存、发券场景里几乎必然发生——不是代码写得不好,而是设计之初就没考虑"同一件事被做两次"该怎么办。
幂等(idempotent)指同一个操作执行一次和多次,系统最终状态一样。注意是"最终状态一样",而不是"第二次不执行",这个区别直接决定方案怎么选。
一个判断标准:凡是可能被重复调用的接口(用户连点、网络重试、消息重投、定时任务重跑),都要问一句「第二次进来会发生什么」。如果答案是"再写一条数据""再扣一次库存""再发一张券",那它就缺幂等。
一、先分清三层防护,别指望一层解决所有问题
一说防重复,多数人只想到"按钮置灰",也有人上来就上分布式锁。其实重复请求有三个来源,对应三层不同的手段:
| 层次 | 解决什么问题 | 典型手段 | 能力边界 |
|---|---|---|---|
| 前端层 | 用户手抖连点 | 按钮置灰、loading、请求防抖 | 刷新页面、脚本刷接口、App 自动重试全部能绕过 |
| 接口层 | 同一请求被投递多次 | 唯一索引、状态机条件更新、幂等令牌 | 依赖业务上有一个能唯一标识"这一次"的字段 |
| 业务层 | 已经产生的错账 | 流水对账、补偿任务、人工核销 | 只能事后发现,不能事前阻止 |
三层都得有,但重心在接口层。前端那层只是体验优化,会用开发者工具的人一分钟就能绕过。
二、四种落地方式,按成本从低到高
第一种:唯一索引,最便宜也最可靠
能靠数据库约束解决的问题,就别交给代码。给业务表加一个真正代表"这一次操作"的唯一索引,重复插入会被数据库直接挡掉:
ALTER TABLE orders
ADD UNIQUE KEY uk_user_biz (user_id, biz_no);
-- 插入时捕获重复键,当成"已处理"正常返回
INSERT INTO orders (user_id, biz_no, amount, status) VALUES (?, ?, ?, 0);
这里的 biz_no 不能是自增 id,得是业务侧生成的一次性编号:服务端请求号、上游订单号,或"用户 id + 日期 + 序号"。捕获到 duplicate key 时不要报错,而是查出已存在的记录原样返回——两次结果一致,才叫幂等。
第二种:状态机 + 条件更新,天然幂等
支付回调、发货通知这类"状态流转"场景,最好用条件更新来表达,别先查再改:
UPDATE orders
SET status = 2, paid_at = NOW()
WHERE id = ? AND status = 1; -- 只有"待支付"才允许改成"已支付"
-- 判断影响行数:0 表示已经被处理过,直接返回成功
if ($affectedRows === 0) { return success(); }
一条 SQL 同时完成判断和写入,天然避开并发窗口。注意"先 SELECT 再 UPDATE"是错的——两个请求可能同时查到 status=1 然后都去更新。判断条件必须写进 WHERE,让数据库的原子性兜底。
第三种:幂等令牌,解决"提交前无法预判重复"
有些场景拿不到天然的唯一键,比如提交表单、发起提现。这时用令牌:页面打开时服务端下发一次性 token,提交时随请求带上,服务端用原子操作消费它。
// 令牌写入:SETNX + 过期时间,一步完成
$ok = $redis->set("idem:{$token}", 1, ['nx', 'ex' => 600]);
if (!$ok) { throw new Exception('请求已处理,请勿重复提交'); }
// 令牌必须和业务数据同一个事务/同一次提交,业务失败要能释放
关键细节是消费时机:必须先占位再执行业务,不能等业务成功后才写令牌,否则并发请求仍会同时通过。同时要设过期时间,避免业务失败后令牌永久占用。
第四种:分布式锁,最后手段而不是默认方案
锁能解决的问题,唯一索引和条件更新基本都能解决,还更可靠。锁的麻烦在于:锁服务可能挂、超时可能提前释放、跨服务调用链上该锁哪一段很难界定。真正需要锁的通常是跨多表、跨服务的复合操作,这时也只锁最关键的临界区——粒度太粗,等于给系统加了个串行瓶颈。
四种方式的取舍其实很朴素:能交给数据库约束的,别交给代码;能写成一条条件 UPDATE 的,别写成先查后改;能用一次性令牌的,别上锁。越靠近数据的方案,越难被绕过。
三、四个最容易踩的坑
- 幂等记录和业务数据不在同一个事务里。最要命的一个。先写"已处理"标记再执行业务,业务失败了但标记留下了,重试进来直接被判为重复,数据永久丢失。正确做法是两者同事务提交,或用"处理中/成功/失败"三态记录,失败态允许重试。
- 唯一索引建不上,就退化成代码判断。历史数据里往往已有重复值,索引加不上去。别直接改成"先查后插",正确顺序是:先清洗历史重复,再加索引;实在清不掉,就加一个带哈希值的辅助列来建索引。
- 幂等键选成了"不唯一"的字段。用手机号、金额、下单时间当幂等键,正常业务都会被误判成重复。幂等键的标准是:同一次业务必定相同,不同业务必定不同。
- 回调返回"成功"但业务其实失败了。上游收到成功就不再重推,单子卡在中间态。回调必须严格区分"已收到但处理失败"和"处理成功",前者返回失败让上游重推,后者才返回成功。
四、落地顺序:先便宜,后复杂
给已有系统补幂等,建议按这个顺序:先盘点所有"会写数据 + 可能被重复调用"的接口列成清单;再优先给订单、支付、库存、发券加唯一索引和条件更新,这四类占了线上重复数据的九成;最后才针对无法用约束表达的场景引入令牌或锁。
自查清单:① 幂等键是否唯一标识一次业务?② 判断条件是否写进 WHERE 而非先查后改?③ 幂等记录与业务是否同事务?④ 失败态是否允许重试?⑤ 回调返回值是否准确?五条全打勾才算真正幂等。
五、写在最后
幂等不是高深技术,更像一种设计习惯:写任何会改数据的接口之前,先想一遍"它被调用两次会怎样"。想清楚了,一个唯一索引或一句带条件的 UPDATE 就够;想不清楚,就要等到某天凌晨被电话叫起来对账。如果你家的订单、库存、支付也对不上,可以拨 18833330416 或在网站留言,我们先帮你梳理幂等缺口再决定改哪里。
