接口幂等这个词,面试时人人会答「多次调用结果一致」。真到线上,重复扣款、重复发货、重复发券还是照样发生。

问题不在于不知道要做幂等,在于做在了错的地方,或者漏了某几条路径

重复请求是从哪来的

先把来源列清楚,因为不同来源的解法不一样:

  • 用户手抖连点两下提交按钮
  • 网络超时后客户端自动重试
  • 消息队列的 at-least-once 语义导致同一条消息被消费两次
  • 上游服务失败重试
  • 运维手动重放请求排查问题

前端防抖只能挡住第一条。 后面四条全都绕过前端,所以「按钮点击后置灰」这种做法解决不了幂等问题,它只是改善体验。

幂等键该由谁生成

这是最常见的设计错误。

很多实现是在服务端进来时生成一个 key,然后去重。这是无效的——同一个业务请求进来两次,服务端会生成两个不同的 key,去重逻辑根本不会命中。

幂等键必须由发起方生成,并且在重试时保持不变。

常见的几种来源:

来源 适合 注意
客户端生成的 UUID 用户主动触发的操作 重试时必须复用同一个,不能重新生成
业务唯一键(订单号+操作类型) 内部服务调用 语义最清晰,推荐
消息 ID MQ 消费 注意区分「同一条消息重投」和「不同消息内容相同」

业务唯一键是最稳的。比如「给订单 A 发货」这个操作,天然可以用 订单号 + 发货 做键,不需要额外传参,也不怕客户端实现不规范。

数据库唯一索引是最可靠的兜底

在应用层判断「这个 key 处理过没有」,无论怎么写都存在竞态:两个请求同时查,同时发现没有,同时插入。

-- 幂等记录表
CREATE TABLE idempotent_record (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  idem_key    VARCHAR(128) NOT NULL,
  status      TINYINT NOT NULL,      -- 0 处理中 1 成功 2 失败
  result      TEXT,
  created_at  DATETIME NOT NULL,
  UNIQUE KEY uk_idem_key (idem_key)
)

流程是:

  1. 先尝试插入一条 status = 处理中 的记录
  2. 插入成功 → 说明是第一次,执行业务逻辑,完成后更新状态和结果
  3. 插入失败(唯一键冲突)→ 说明重复了,读出已有记录

关键在第三步:唯一索引冲突这件事本身就是判断依据,不需要先查再插。数据库的唯一约束是原子的,这是应用层做不到的。

处理中的请求怎么办

第三步读出来的记录如果是「处理中」,说明前一个请求还没跑完。这时候有三个选择:

直接返回「处理中」。 最简单,但调用方要能处理这个状态。

短暂等待后重查。 适合业务执行很快的场景,但要设上限,不能无限等。

直接报错让对方重试。 最省事,但如果对方的重试间隔很短,会一直撞上。

多数场景推荐第一种,把状态透出去,让调用方决定。

几个容易漏的点

处理中的记录必须能超时释放。 服务在执行到一半时崩溃,那条「处理中」的记录会永远卡在那里,导致这个业务后续永远无法执行。需要给它一个超时时间,超过之后允许被重新抢占。

失败的请求算不算幂等。 第一次调用业务失败了,第二次同 key 进来,是返回上次的失败结果,还是允许重试?这两种语义完全不同,必须明确定义。 一般来说,业务校验失败(参数不对、余额不足)应该直接返回上次结果;系统异常(网络抖动、数据库超时)应该允许重试。这个区分做不好,要么用户一次失败永远不能再试,要么系统异常时反复执行。

幂等记录要和业务操作在同一个事务里。 如果幂等记录先提交、业务操作后失败回滚,那这个 key 就被占用了,重试永远进不来。

Redis 做幂等要考虑丢失。SETNX 做去重很方便,但 Redis 不是持久化可靠的——主从切换、内存淘汰都可能让 key 消失,然后重复请求就穿透了。对钱相关的操作,最终还是要落到数据库的唯一约束上。 Redis 只适合做前置的快速过滤。

查询接口不需要幂等。 只有会改变状态的操作才需要。给 GET 接口加幂等逻辑是纯粹的浪费。

记录表会一直涨

幂等记录表是只增不减的,量大的系统一天能涨几百万行。

要有清理策略:按业务特点定一个保留期(比如 7 天或 30 天),定时清理过期数据。注意保留期必须大于任何可能的重试窗口——如果你的 MQ 死信重投是 3 天后,那保留期不能设成 1 天。

最后

幂等的本质不是「拦住重复请求」,是让重复请求得到和第一次一样的结果。这两者的区别在于:拦住是不执行,幂等是返回上次的结果。

调用方期望的是后者——它重试是因为没收到响应,不是因为想再执行一次。直接返回一个「重复请求」的错误,对调用方来说和失败没区别,它还会继续重试。