接口幂等这个词,面试时人人会答「多次调用结果一致」。真到线上,重复扣款、重复发货、重复发券还是照样发生。
问题不在于不知道要做幂等,在于做在了错的地方,或者漏了某几条路径。
重复请求是从哪来的
先把来源列清楚,因为不同来源的解法不一样:
- 用户手抖连点两下提交按钮
- 网络超时后客户端自动重试
- 消息队列的 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)
)
流程是:
- 先尝试插入一条
status = 处理中的记录 - 插入成功 → 说明是第一次,执行业务逻辑,完成后更新状态和结果
- 插入失败(唯一键冲突)→ 说明重复了,读出已有记录
关键在第三步:唯一索引冲突这件事本身就是判断依据,不需要先查再插。数据库的唯一约束是原子的,这是应用层做不到的。
处理中的请求怎么办
第三步读出来的记录如果是「处理中」,说明前一个请求还没跑完。这时候有三个选择:
直接返回「处理中」。 最简单,但调用方要能处理这个状态。
短暂等待后重查。 适合业务执行很快的场景,但要设上限,不能无限等。
直接报错让对方重试。 最省事,但如果对方的重试间隔很短,会一直撞上。
多数场景推荐第一种,把状态透出去,让调用方决定。
几个容易漏的点
处理中的记录必须能超时释放。 服务在执行到一半时崩溃,那条「处理中」的记录会永远卡在那里,导致这个业务后续永远无法执行。需要给它一个超时时间,超过之后允许被重新抢占。
失败的请求算不算幂等。 第一次调用业务失败了,第二次同 key 进来,是返回上次的失败结果,还是允许重试?这两种语义完全不同,必须明确定义。 一般来说,业务校验失败(参数不对、余额不足)应该直接返回上次结果;系统异常(网络抖动、数据库超时)应该允许重试。这个区分做不好,要么用户一次失败永远不能再试,要么系统异常时反复执行。
幂等记录要和业务操作在同一个事务里。 如果幂等记录先提交、业务操作后失败回滚,那这个 key 就被占用了,重试永远进不来。
Redis 做幂等要考虑丢失。 用 SETNX 做去重很方便,但 Redis 不是持久化可靠的——主从切换、内存淘汰都可能让 key 消失,然后重复请求就穿透了。对钱相关的操作,最终还是要落到数据库的唯一约束上。 Redis 只适合做前置的快速过滤。
查询接口不需要幂等。 只有会改变状态的操作才需要。给 GET 接口加幂等逻辑是纯粹的浪费。
记录表会一直涨
幂等记录表是只增不减的,量大的系统一天能涨几百万行。
要有清理策略:按业务特点定一个保留期(比如 7 天或 30 天),定时清理过期数据。注意保留期必须大于任何可能的重试窗口——如果你的 MQ 死信重投是 3 天后,那保留期不能设成 1 天。
最后
幂等的本质不是「拦住重复请求」,是让重复请求得到和第一次一样的结果。这两者的区别在于:拦住是不执行,幂等是返回上次的结果。
调用方期望的是后者——它重试是因为没收到响应,不是因为想再执行一次。直接返回一个「重复请求」的错误,对调用方来说和失败没区别,它还会继续重试。