超时重试、用户双击、MQ 重投、网络抖动,都会让同一个业务动作到达服务端不止一次。幂等设计的目标是让”执行一次”和”执行多次”产生完全相同的业务效果。本文梳理重复请求的来源、天然幂等与需要设计的幂等,以及唯一约束、防重 Token、状态机、分布式锁等方案的适用边界与代码思路。

它解决什么问题

幂等(Idempotency)原本是数学概念:f(f(x)) = f(x),操作执行多次与执行一次效果相同。放到接口语义上,指同一个请求无论到达一次还是一百次,服务端业务状态的变化都一样——不要求返回值逐字节相同,但副作用必须只发生一次。

重复请求从哪里来?主要有四个源头:

  1. 超时重试:客户端、网关、RPC 框架在超时后自动重发,而超时恰恰可能是”服务端已处理成功但响应丢了”。
  2. 用户操作:双击提交按钮、刷新页面重复提交表单、App 断网恢复后重发请求。
  3. MQ 重复投递:主流消息队列只承诺至少一次(at-least-once)投递,消费者处理完但 ack 丢失时,消息会再次送达。
  4. 网络与中间件抖动:响应包丢失、负载均衡重发、第三方平台按策略多次回调(支付回调最典型)。

不处理幂等的代价,在资金、库存、订单场景是资损级事故:重复扣款、重复发货、库存超卖。更麻烦的是这类问题平时不暴露,压测和故障时才集中爆发。

核心原理

天然幂等与需要设计的幂等

先看操作本身的性质:

  • 查询、按主键删除、UPDATE SET status = 'PAID' 这类赋值更新是天然幂等的,重复执行结果不变。
  • UPDATE SET balance = balance - 100 这类增量更新、无条件 INSERT、调用外部接口都不是幂等的,重复一次副作用就叠加一次。

判断一个接口要不要做幂等,就看它是否包含非幂等副作用。包含,就必须引入”请求身份”的概念:给每次业务动作分配全局唯一标识(幂等键,可以是业务单号、交易流水号,或客户端生成的 Idempotency-Key),服务端凭它识别”这是不是同一件事”。所有幂等方案的内核都是同一句话:用唯一身份识别重复,用原子机制保证只执行一次。

“先查后做”的并发陷阱

最直觉也最错误的做法是:先 SELECT 查这笔单处理过没有,没处理过就执行。两个并发请求可能同时查到”未处理”,于是都执行——检查与执行之间的时间窗口就是重复的入口。所以幂等判断必须落到原子操作上:数据库唯一索引冲突、Redis 原子命令,或锁保护下的临界区。纯应用层的 if-else 判断在并发下必然失效。

深入机制

数据库唯一约束与去重表

给业务单号建唯一索引,插入冲突即重复:

CREATE TABLE pay_order (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL COMMENT '业务单号',
amount DECIMAL(10,2) NOT NULL,
UNIQUE KEY uk_order_no (order_no)
);
try {
orderMapper.insert(order); // 依赖 uk_order_no 兜底
return OrderResult.success(order);
} catch (DuplicateKeyException e) {
// 重复请求返回与首次一致的结果,而不是报错
return OrderResult.success(orderMapper.selectByOrderNo(order.getOrderNo()));
}

去重表是同一思路的独立化:单建一张只含唯一键的处理记录表,先插去重表再办业务。关键点是把去重表插入和业务写入放进同一个事务——事务回滚则去重记录一起消失,后续请求还能正常重入;否则会出现”记录说办了、业务没办成”的死局。

防重 Token

表单类接口的经典方案。页面加载时服务端签发一次性 Token,存 Redis 并设短过期;提交时服务端校验并删除,验证通过才执行:

// Redis 6.2+ 原子地"取出并删除",同一 Token 只能被消费一次
Object token = redisTemplate.opsForValue().getAndDelete("token:" + tokenId);
if (token == null) {
throw new BizException("请勿重复提交");
}
// 校验通过后执行业务,业务侧仍要有唯一索引兜底

先消费 Token 还是先办业务存在取舍:先办业务,Token 未删期间的重放会穿透;先消费 Token,业务失败后用户要重新获取。工程上推荐先原子消费、失败时明确提示重新发起,且资金类场景不能只靠 Token,必须有数据库唯一约束兜底。

状态机防重

订单、支付这类有生命周期的业务,用状态流转条件把”只允许执行一次”写进更新语句:

UPDATE pay_order
SET status = 'PAID', paid_at = NOW(), version = version + 1
WHERE order_no = #{orderNo} AND status = 'UNPAID';

状态机天然幂等:重复请求撞上 status = 'UNPAID' 不成立,影响行数为 0,无副作用。它还顺带解决了乱序问题——已关闭的订单不会被迟到的回调重新置为已支付。凡是有状态流转的业务,这是成本最低、最该先做的幂等层。

分布式锁串行化

对同一业务资源(如订单号)加分布式锁,把并发重试串成串行:

RLock lock = redissonClient.getLock("idempotent:" + orderNo);
if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) {
return Result.processing(); // 拿不到锁说明正在处理中
}
try {
Order order = orderMapper.selectByOrderNo(orderNo);
if ("PAID".equals(order.getStatus())) {
return Result.success(order); // 已处理过,返回历史结果
}
// 执行业务并更新状态,同一事务提交
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

锁只负责”同一时刻只有一个请求在办”,不代表历史上没办过——进入临界区后仍要查业务状态或幂等记录。锁粒度按业务标识走,锁的租约时间按业务最长耗时的两到三倍设置。

MQ 消费幂等

MQ 只保证至少一次投递,消费端必须自己消化重复。标准做法是消费前用消息 ID 或业务流水号查/插消费记录表(或 Redis 的 SETNX),已消费直接 ack 跳过;未消费则在同一事务里完成”业务写入 + 记录消费位”。消费失败要把异常抛出让 MQ 重投,不能因为怕重复就吞掉异常——那是丢消息,比重复更糟。

工程实践

  • 分层兜底:前端按钮置灰、防连点只是体验优化;服务端的唯一索引加状态机是底线;Token、分布式锁按场景叠加。任何单层都不能宣称”已解决幂等”。
  • 幂等键设计:优先用业务天然唯一字段(订单号、交易流水号);没有时让客户端在发起流程前生成 UUID 作为 Idempotency-Key,服务端按它去重。
  • 重复请求的响应要与首次一致:返回首次成功的结果而不是报错,否则上游会认为失败而继续重试。
  • 验证靠测试:同一幂等键并发打 50 个请求只应产生一条流水;模拟超时重试、回调重发、MQ 重投后,订单状态、资金流水、库存数量交叉对账必须严丝合缝。

对比与权衡

方案 适用场景 优点 代价 / 风险
唯一索引 / 去重表 插入类(建单、流水) 数据库层硬保证,最可靠 只适用插入;去重表需与业务同事务
防重 Token 表单提交、页面操作 通用,对业务侵入小 依赖 Redis 可用性;两步交互;需配合兜底
状态机 有状态流转的业务(订单、支付) 零额外存储,顺带防乱序 不适用无状态的增量操作
分布式锁 同一资源的并发更新 通用,可保护复杂临界区 性能开销、锁超时治理;锁内仍要查状态
乐观锁(版本号) 并发更新同一条记录 无锁开销 冲突频繁时重试放大;需定义失败策略

选择优先级:能用业务唯一键加唯一索引的不要发明轮子;有状态流转先上状态机;需要串行化同一资源再加分布式锁;资金类场景多层叠加,不押宝单一机制。

面试视角

幂等和防重有什么区别? 防重是阻止重复请求到达或执行,偏拦截;幂等是允许重复请求到达,但保证副作用只发生一次,偏结果。工程上追求的是幂等,防重只是减轻幂等逻辑压力的辅助手段。

为什么”先查询再操作”不能保证幂等? 查询与执行之间存在时间窗口,两个并发请求可能同时读到”未处理”而都执行。判重必须落到原子机制上:唯一索引冲突、Redis 原子命令或锁内检查,应用层的先查后做在并发下必然失效。

MQ 消费怎么做幂等? 消费端用消息 ID 或业务流水号去重:先查消费记录,已消费直接 ack;未消费则业务写入与消费记录在同一事务提交。消费失败抛异常触发重投,靠消费端幂等消化重复,而不是吞异常造成丢消息。

Token 机制先删 Token 还是先执行业务? 推荐先原子消费(GETDEL 或 Lua)再执行业务:先执行业务则 Token 存续期间的重放会穿透。代价是业务失败后用户需重新获取 Token,且必须保留数据库唯一约束做最后兜底。

支付回调如何设计幂等? 以第三方交易流水号为幂等键建唯一索引;更新订单用状态机条件 UNPAID -> PAID;订单更新、资金流水、回调记录放进同一事务;重复回调直接返回成功,终止第三方继续重试。

小结

  • 重复请求来自重试、双击、MQ 重投和网络抖动,不可能在上游杜绝,只能在服务端消化。
  • 先区分操作是否天然幂等;非幂等操作必须引入幂等键,并把判重落到原子机制上,拒绝先查后做。
  • 方案栈从底到上:唯一索引兜底、状态机防流转重复、Token 防表单重提、分布式锁串行化临界区、MQ 消费记录防重投。
  • 重复请求要返回与首次一致的结果;上线前用并发压测、重试注入和数据对账验证,而不是只跑通正常路径。