为什么接口必须幂等
幂等的意思是:同一个接口,被调用一次和被调用一百次,对系统的影响是完全一样的。查询、取消、设置这类操作天然幂等,而“创建订单”“扣款”“扣库存”这类写操作,如果不做幂等,用户多点一次、网络超时重试一次,就可能重复扣款、超卖、重复下单。
分布式环境下,你根本没法假设“客户端只会调一次”。网关会重试、消息会重投、前端会防抖失败、支付回调会重复到达。所以后端的写接口从设计之初就要回答一个问题:同一个业务请求来了第二次,我怎么认出它是重复的,然后直接返回上次的结果,而不是再执行一遍。
- 幂等同一请求执行多次,副作用只发生一次。
- 分布式 ID分库分表后自增主键失效,要全局唯一有序的 ID。
- 防重表用唯一约束挡住重复写入。
- 幂等令牌执行前先领令牌,用完即弃,重复请求拿不到新令牌。
重复请求是怎么发生的
重复请求不是小概率事件,它有几个稳定来源:用户手抖连点两次提交、客户端超时后自动重试、消息队列投递失败重投、支付平台异步回调多次通知、网关熔断恢复后重放。这些场景里,请求体长得一模一样,甚至业务主键也可能一样,必须靠服务端自己识别。
关键点:不能指望前端防抖来解决后端幂等。前端防抖只是减少概率,真正的兜底在服务端。前端可以做的是提交后置灰、禁用按钮,但后端必须有自己的防重机制。
分布式 ID 怎么生成
一旦分库分表或多实例部署,数据库自增主键就不能用了——每个实例、每张分片各自自增会撞号。需要的是全局唯一、趋势递增、本地可生成的 ID。最常用的是雪花算法:
# 64 位结构:1 位符号 + 41 位时间戳 + 10 位机器号 + 12 位序列号
# 同一毫秒内按序列号自增,不同毫秒靠时间戳拉开
id = (timestamp << 22) | (workerId << 12) | sequence
好处是不依赖中心、本地生成极快、整体趋势递增,存进数据库做主键时索引也友好。要注意的是机器号的分配:用配置中心或 Kubernetes 副本号固定,别让两台机器拿到同一个 workerId,否则会撞 ID。时钟回拨是它的老问题,遇到时钟倒退要拒绝发号或等待,别硬发。
唯一约束与防重表
最朴素也最可靠的幂等手段,就是让数据库本身帮你挡。在业务表上对“业务唯一键”建唯一索引,比如订单表对 user_id + 业务流水号 建唯一约束:第一次插入成功,第二次再插就报唯一键冲突,服务端捕获这个异常,转成“该请求已处理”直接返回上次结果。
-- 防重表:同一个 biz_no 只能成功落一次
CREATE TABLE idempotent_record (
biz_no VARCHAR(64) PRIMARY KEY,
status VARCHAR(16),
result TEXT,
create_time DATETIME
);
更通用的做法是单独一张防重表:先往防重表插一条 biz_no,插成功才执行业务,业务执行完回写结果。重复请求来时防重表里已有这条记录,直接读回上次的结果返回。它把“防重”和“业务”解耦,适合支付、回调这类任意写接口。
状态机与乐观锁
很多写操作本质是状态流转:订单待支付、已支付、已发货、已完成。用状态机做幂等最自然——每一步只允许从合法的前置状态流转。比如“已支付”的订单再来一次支付回调,一看状态已经是已支付,直接返回成功,不再重复扣款。
UPDATE order SET status = 'PAID'
WHERE id = #{id} AND status = 'UNPAID'
-- 影响行数为 0,说明已被处理过,返回上次结果
-- 影响行数为 1,说明这次才真正扣
这种带条件的更新,行锁天然保证了并发下只有一次能成功。配合乐观锁版本号,还能挡住两个人同时改一单的并发问题。状态机 + 条件更新,是金融类接口最稳的幂等写法。
幂等令牌:先取后用
对于“创建类”接口(提交订单、发表评论),前端在打开表单时先向后端申请一个一次性幂等令牌,后端把这个令牌存进 Redis 并设短过期时间。真正提交时,必须带着这个令牌一起上,后端用 Lua 脚本原子地“检查并删除”这个令牌:
// 令牌存在:删除成功,放行执行,令牌只能用一次
// 令牌不存在或已过期:拒绝,视为重复提交
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else return 0 end
关键是“检查 + 删除”必须原子,否则两个并发请求都读到令牌存在就会放行。令牌用一次就作废,所以连点两次时第二次拿不到令牌,直接被挡。这是表单防重提交的标准打法。
防超发与库存扣减
秒杀、抢购最怕超发:库存只剩 1 件,并发请求同时来,都读到库存为 1,然后都扣减成功,卖了 2 件。解决办法是把“扣库存”做成一条带条件的原子 SQL:UPDATE stock SET count = count - 1 WHERE sku_id = ? AND count > 0,数据库行锁保证只有一个请求的影响行数为 1,其余为 0 直接返回已售罄。
高并发下还可以把库存提前预热到 Redis,用 Lua 脚本在内存里原子判断并扣减,扣成功再异步落库,扛住流量洪峰。但无论用哪种,最终的防超发一定要落在数据库那一条带条件的更新上,Redis 只是削峰,不是最终一致性的保证。
实战清单
落地清单:
- 写接口默认按幂等设计,别假设客户端只会调一次。
- 分布式 ID 用雪花算法,固定机器号,处理时钟回拨。
- 关键写操作用唯一约束或防重表兜底,重复请求读回上次结果。
- 状态流转类用状态机加条件更新,挡住重复回调。
- 创建类表单用一次性幂等令牌,先取后用、原子删除。
- 扣库存用带条件的原子更新,防超发最终落库。
- 前端防抖只做辅助,幂等兜底必须在服务端。
幂等的本质,是给每个业务请求一个可识别的身份,然后让系统对“同一个身份的第二次出现”保持宽容。把分布式 ID、唯一约束、状态机、幂等令牌这几件事按场景用好,重复扣款、超卖、重复下单这类线上事故,基本就被从根上堵住了。