网上写幂等方案的文章多得数不清,Token 机制、数据库唯一索引、Redis 原子操作、乐观锁、状态机……每篇说的都不一样,越看越乱。其实完全不用去纠结那些,先把核心逻辑搞清楚,你会发现那些方案不过是同一套思路在不同场景下的落地。

先说幂等这个词,很多人听着高端,其实你每天都在跟它打交道。

用户在付款页面因为网络慢,急了手点了两下——你的系统要不要扣两次钱?MQ 消费者宕机重启,同一条消息被消费了两遍——数据库里要不要有两条记录?这些都是幂等问题。

书面的定义是:同一笔操作,无论执行多少次,对系统产生的副作用与仅执行一次完全一致。 听起来有点绕,说白了就是——重复来多少次,结果都一样,不会因为多来几次就出问题。

那怎么实现?我总结了一句口诀:一锁、二判、三更新。

听起来很简单,但里面藏着不少细节,我们一个一个展开说。


口诀拆解

一锁,是说在处理之前先加锁,目的是防止并发。但这里有个容易被忽视的点:锁不是每次都必须加的。如果你的并发量很小,或者"二判"这一步本身就带有加锁语义(比如 Redis 的 setIfAbsent、数据库的乐观锁),那就没必要再单独搞一把锁,反而增加复杂度。

二判,是整个幂等逻辑的核心——怎么判断这笔操作有没有执行过?这里你需要先想清楚一件事:用什么来标识"同一笔操作"?

在 Web 请求里,通常可以组合 requestUri + 请求参数 + 用户标识 来生成一个唯一 key。在 MQ 或 RPC 场景里,这个 key 就更明确了,就是业务单号,比如订单号、支付流水号。

确定好 key 之后,怎么判断它有没有执行过?常见的有两种方式:一是写流水表,每次执行时往表里插一条记录,下次来了先查,有记录就幂等返回;二是基于状态机,比如订单状态已经是"已支付"了,那"更新为已支付"这个操作就直接幂等返回,根本不用再执行。

三更新,判断没问题之后,正常执行业务逻辑就好了。


举个真实的例子:Web 请求防重

最常见的场景就是用户手抖,或者网络抖动,前端重复提交了同一个请求。

这种情况有个很简洁的解法:在 Web 拦截器层用 Redis 的 setIfAbsent 做防重。

String key = buildIdempotentKey(request); // 基于 uri + 参数 + 用户 ID 生成
Boolean isFirstRequest = redisTemplate.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(isFirstRequest)) {
    // 重复请求,直接返回幂等响应
    return IdempotentResponse.duplicate();
}
// 正常继续处理

setIfAbsent 的意思就是:如果这个 key 不存在,就写进去并返回 true;如果已经存在,就返回 false。返回 false 说明前面已经有一笔请求进来了,当前这笔直接幂等返回。

TTL 设置 3~5 秒就够了,因为 Web 层的重复请求基本都是短时间内的网络抖动或用户重复点击导致的,窗口不用太大。

这里还有个细节值得说一下:Redis 的 setIfAbsent 本身是原子操作,天然防并发,所以这个场景连"一锁"这一步都省了。 口诀里的"一锁"在这里已经被 Redis 替代了。


再举一个:MQ 和 RPC 的幂等处理

MQ 和 RPC 的场景比 Web 要复杂一些,因为它们可能真的存在并发——两条重复消息同时打过来,或者 RPC 调用方超时重试导致两笔请求同时到达。

我们以"更新订单状态为已支付"举例,整个流程大概是这样的:

第一步,先加分布式锁。 以订单号为 key 加锁,防止并发场景下两笔请求同时进来、都通过了判断、然后都去更新数据库。

第二步,查一下订单状态。 如果发现订单已经是"已支付"了,说明之前已经处理过了,直接返回成功。

第三步,用乐观锁更新。 就算前两步有漏网的,数据库这里也是最后一道保险:

UPDATE `order` SET status = 'PAID' WHERE id = #{orderId} AND status = 'PAYING';

这条 SQL 只有在订单状态是"支付中"时才会真正执行更新。如果状态已经是"已支付",这条 SQL 影响行数为 0,什么都不会发生。你可以把它理解成一种状态机:只有处于合法前置状态的操作,才允许被执行。

这三步组合下来,覆盖了绝大多数场景:分布式锁解决并发,状态判断提前拦截重复,乐观锁兜底保证数据库层的一致性。


最后说一句

幂等没有银弹,核心逻辑永远是那三步。真正需要认真想的,是两个问题:

  1. 什么是"同一笔操作"?你的幂等 key 怎么设计?
  2. 用什么来记录"这笔操作执行过了"?流水表、Redis、状态机,还是乐观锁?

想清楚这两个,口诀套进去,幂等问题基本就解决了。