为什么接口必须幂等

幂等的意思是:同一个接口,被调用一次和被调用一百次,对系统的影响是完全一样的。查询、取消、设置这类操作天然幂等,而“创建订单”“扣款”“扣库存”这类写操作,如果不做幂等,用户多点一次、网络超时重试一次,就可能重复扣款、超卖、重复下单。

分布式环境下,你根本没法假设“客户端只会调一次”。网关会重试、消息会重投、前端会防抖失败、支付回调会重复到达。所以后端的写接口从设计之初就要回答一个问题:同一个业务请求来了第二次,我怎么认出它是重复的,然后直接返回上次的结果,而不是再执行一遍。

本文核心知识点
  • 幂等同一请求执行多次,副作用只发生一次。
  • 分布式 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

关键是“检查 + 删除”必须原子,否则两个并发请求都读到令牌存在就会放行。令牌用一次就作废,所以连点两次时第二次拿不到令牌,直接被挡。这是表单防重提交的标准打法。