1. 为什么重写幂等组件:一次生产事故后的彻底复盘
做后端开发的兄弟应该都有过这种经历:半夜被报警电话叫醒,醒来一看,订单表里多了两条一模一样的记录,用户付了两次款,库存扣了两次。这种问题在单体架构时代就够头疼的了,到了微服务、分布式环境下更是防不胜防。我在维护开源项目 ForgeAdmin 的时候,就实打实踩过这个坑,也正是这个坑,逼着我把项目里的幂等组件从 v1.x 彻底重写成了 v2.0。
先说下背景。ForgeAdmin 是一套基于 Spring Boot 3 + Spring Cloud 的微服务快速开发脚手架,我们用它做了好几个中大型项目,涉及订单、支付、库存、积分这些核心链路。老版本的幂等组件用法很简单,就一个注解 @Idempotent 往接口上一加,底层基于 Redis 的 SETNX 实现,请求进来先尝试占锁,拿到了就放行,拿不到就报重复请求。这套逻辑在低并发、单实例部署的时候跑得挺欢,一旦流量上来、服务拆分成多个实例之后,各种幺蛾子就全冒出来了。
最典型的一次事故是这样的:压测环境里模拟用户疯狂点击提交订单按钮,前端虽然做了按钮置灰,但依然有大量请求在短时间内打到后端。结果订单服务三个实例同时收到同一笔订单的请求,三个实例的 Redis 连接各自执行 SETNX,竟然全部成功了。为什么?因为我们的 key 是通过 userId + timestamp 拼出来的,时间戳精确到秒,三个请求落在同一秒内,理论上 key 应该相同才对,但实际编码时用了 System.currentTimeMillis() 生成 traceId,导致每个请求的 key 都不一样。更离谱的是,老版本组件压根没有做请求参数的校验,参数不同但业务含义相同的请求,在幂等层面完全无法识别。
这次事故给我敲了个警钟:幂等组件不是一个注解加一个 Redis 锁那么简单,它涉及请求标识的生成策略、锁的粒度控制、异常场景的补偿机制、防重表的设计、幂等状态的存储结构,以及跟分布式事务的配合。所以 v2.0 的升级不是小修小补,而是从底层设计上重构了整个组件的核心机制。
今天这篇文章,我就把整个升级过程中的设计思路、核心代码实现、踩过的坑、排查问题的思路完整梳理一遍。如果你也在做微服务架构,或者你的项目里也有类似的幂等需求,这篇文章应该能帮你少走不少弯路。内容会比较长,但每一段都是我实际趟过水之后写出来的,耐心看完,你收获的绝对不止一个组件的用法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与方案选型:为什么选择“注解 + 防重表 + 分布式锁”三件套
2.1 老版本核心痛点:为什么 SETNX 方案撑不住分布式场景
先别急着看新方案,我把老版本的设计缺陷掰开揉碎说清楚。因为只有真正理解了旧方案为什么不行,你才能明白 v2.0 里那些看似复杂的设计到底在解决什么问题。
老版本的核心实现是这样一段伪代码:
java复制public boolean tryLock(String key, String requestId, long expireTime) {
// 利用 Redis SETNX 原子性,key 不存在则设置成功返回 true
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, requestId, expireTime, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
这段代码单独看没有任何问题,SETNX 是 Redis 原生的原子操作,分布式环境下多个实例同时执行也不会出现并发写入覆盖的问题。但问题是,它只是“占锁”成功,不等于“幂等”成功。
我总结了老版本的四个致命缺陷:
第一,key 的生成策略太随意。当时为了图方便,直接用 userId + timestamp + random 拼 key,看似每次请求都不同,实际上破坏了幂等的基本前提——同一个业务请求必须映射到同一个 key 上。正确的做法应该是根据业务特征生成唯一业务流水号,比如订单号、支付流水号、用户操作序列号。
第二,锁的粒度过大。整个组件只有一个全局锁维度,没有区分接口级、参数级、用户级。结果就是:用户 A 的请求把锁占住了,用户 B 的相同接口请求也被阻塞,严重的锁竞争直接拖垮了接口响应时间。我在生产环境实测过,高峰期订单提交接口的 P99 延迟从 80ms 涨到了 1.2 秒,罪魁祸首就是这种粗粒度的锁。
第三,没有防重表兜底。SETNX 锁是有过期时间的,如果业务处理时间超过了锁的过期时间(比如外部接口调用超时),锁会自动释放,这时候相同的请求再次进来,照样能穿透到业务层。Redis 锁帮你挡住了第一波重复请求,但挡不住第二波、第三波。防重表(或者叫幂等记录表)能够在锁过期之后,依然通过数据库的唯一索引把重复请求拦下来。
第四,缺少异常状态管理。老版本只记录了“请求正在处理中”和“请求处理完成”两种状态,对于处理失败、超时、未知异常这些场景完全没有覆盖。结果就是:业务处理失败后,前端重试时幂等组件直接判定“重复请求”给拦下,用户死活提交不了订单,连失败重试的机会都没有。
这四个问题其实代表了分布式幂等设计的四个核心维度:标识维度、锁维度、存储维度、状态维度。v2.0 的整个设计就是围绕这四个维度重新展开的。
2.2 v2.0 的整体架构:三层拦截机制
v2.0 的设计目标很明确——既要保证幂等的强一致性,又要兼顾高并发场景下的性能损耗。最终我采用了“注解 + 防重表 + 分布式锁”三件套的组合方案,三层拦截各司其职:
text复制请求进入 -> 第一层:幂等注解解析(生成幂等标识)
-> 第二层:Redis 分布式锁(快速拦截并发重复请求)
-> 第三层:防重表唯一索引(兜底持久化拦截)
-> 业务逻辑执行 -> 记录幂等结果 -> 释放锁
第一层是消息入口的统一拦截,通过自定义幂等标识生成器,从请求参数、请求头、用户信息等维度生成全局唯一的 traceId,确保同一业务请求在任何时刻、任何实例上计算出的 traceId 完全一致。
第二层是 Redis 分布式锁,利用 SETNX + Lua 脚本的原子性,实现毫秒级的并发请求拦截。锁的 key 设计为 idempotent:{traceId},value 存储请求完整 JSON,过期时间根据业务耗时动态设置,默认 10 秒,最长不超过 60 秒。这一层解决的是“极短时间内大量重复请求打进来”的问题,拦截效率最高。
第三层是防重表,这是 v2.0 新增的核心模块。防重表的核心是一张数据库表,表中为 traceId 字段建立唯一索引。请求通过 Redis 锁校验后,在执行真正业务逻辑之前,先向防重表插入一条状态为“处理中”的记录。如果插入失败(唯一索引冲突),说明这个请求之前已经处理过,直接返回重复请求的响应。这一层解决的是“Redis 锁过期后重复请求再次涌入”的问题,利用数据库的唯一索引天然保证强一致性。
为什么非要用“Redis 锁 + 防重表”双保险?这得从两个中间件的特性说起。Redis 的优势是快,毫秒级响应,扛得住高并发;但 Redis 有丢数据的可能,如果 Redis 发生了主从切换、持久化失败,锁的状态可能丢失。数据库的优势是稳,只要磁盘不坏,数据就在那里,唯一索引的约束是强一致性的最后一道屏障。两个结合起来,快的那层负责挡流量,稳的那层负责兜底,各取所长。
2.3 为什么放弃“分布式事务方案”而选择独立幂等组件
在方案评审的时候,团队里有人提了个问题:Seata 这类分布式事务中间件不是也能解决一致性问题吗,为什么还要单独做一个幂等组件?这个问题确实值得讨论,我当时的回答是:分布式事务和幂等是两码事,它们解决的是不同层面的问题。
分布式事务解决的是“多个服务之间数据最终一致性”的问题,比如订单服务扣库存、支付服务扣余额、积分服务加积分,这三个操作要么全部成功,要么全部回滚。但分布式事务框架本身并不保证接口的幂等性——你还是得自己处理重复请求的问题。恰恰相反,分布式事务的很多场景(比如 TCC 模式的 confirm 和 cancel 操作)要求参与者必须实现幂等,否则事务框架本身就乱套了。
幂等组件解决的则是“同一请求重复执行不会产生副作用”的问题。它不关心你的业务逻辑是不是跨服务、跨数据库,只关心“这个请求我之前处理过了没有,处理到哪一步了”。
从实施成本来看,引入 Seata 需要部署 TC(事务协调器)服务、配置事务分组、改造业务代码,学习成本和运维成本都不低。而幂等组件只需要加一个注解、配一张表,成本低得多。
所以我在 v2.0 里做了一个清晰的边界划分:如果业务场景涉及多服务间数据一致性,用 Seata 管分布式事务,同时组件会提供幂等钩子配合事务的各个环节;如果业务场景只是单个服务内的接口防重,直接上幂等组件就行,不需要为了一个幂等需求引入一套分布式事务框架。 这种设计既保证了能力上限,又避免了过度设计。
3. 核心细节解析与实操要点:v2.0 的七个关键机制
3.1 幂等标识生成器:如何保证 traceId 的全局唯一且业务可识别
整个组件的基石是幂等标识。如果 traceId 生成得不对,后面所有的拦截机制都是白搭。v2.0 把 traceId 的生成策略设计成了一个可扩展的接口 IdempotentKeyGenerator,内置四种实现,按优先级自动装配:
| 策略 | 适用场景 | 解析方式 |
|---|---|---|
| 参数组合策略 | 表单提交、JSON 接口 | @IdempotentKey 注解标记的参数,按顺序拼接 |
| Token 令牌策略 | 页面防重复提交 | 前端调用令牌接口获取一次性 token,放入 header 或 body |
| 自定义 SpEL 策略 | 复杂场景 | 注解中直接写 SpEL 表达式,如 #request.orderId |
| 全局唯一 ID 策略 | 网关层统一生成 | 基于雪花算法生成分布式 ID,由调用方透传 |
实际项目中最常用的是参数组合策略和 Token 令牌策略的组合。我举个例子:在订单提交接口上,参数里有 userId 和 orderId,如果只拼 orderId,那用户对不同订单号的请求会被识别为不同请求,这是对的。但如果用户重复提交相同订单,两次请求的 orderId 相同,userId 也相同,拼出来的 traceId 完全一致,幂等组件就能正确拦截。
这里有个容易踩坑的地方:如果拼接的参数里混入了时间戳或者随机数,幂等就会失效。 有一次我接手一个旧项目,发现前端每次都会往请求体里塞一个 clientRequestTime 字段,后端幂等 key 一不小心把这个字段拼进去了,结果同一请求的两次重试 traceId 完全不同,组件形同虚设。最后只好在生成器里加了过滤逻辑,把变化频繁的噪声参数排除掉。
3.2 分布式锁的优化:从字符串锁到 Lua 脚本锁
老版本的锁实现就是简单的 SETNX 加 DEL,v2.0 改用 Lua 脚本把加锁、校验、解锁三个操作合并成一次原子调用,彻底解决了锁误删和死锁的问题。
先看加锁的 Lua 脚本:
lua复制-- KEYS[1]: 锁的 key
-- ARGV[1]: 请求唯一标识 requestId
-- ARGV[2]: 锁的过期时间(毫秒)
if redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
return 1
else
-- 如果 key 已存在,说明有请求正在处理
-- 校验 value 是否等于当前 requestId,如果相等说明是同一请求的重试,允许进入
if redis.call('get', KEYS[1]) == ARGV[1] then
return 1
end
return 0
end
这段脚本解决了一个非常微妙的场景:假设请求 A 拿到锁后,业务执行超过了锁的过期时间,锁自动释放。此时请求 B 进来成功加锁,开始执行业务。请求 A 终于执行完了,在释放锁的时候如果直接用 DEL,就会把请求 B 的锁给误删掉。加了 requestId 的校验之后,释放锁的脚本会先判断 value 是否等于当前请求的 requestId,相等才删除。这就是经典的“锁误删”问题的解决方案。
解锁的 Lua 脚本是这样的:
lua复制-- KEYS[1]: 锁的 key
-- ARGV[1]: 请求唯一标识 requestId
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
眼尖的兄弟可能发现了,这里其实还有一个细节问题:同一个请求重试时锁被自己续期了,但重试请求的 requestId 和原请求的 requestId 是否一致? 如果每次重试都生成新的 requestId,上面的校验就会失效。所以 v2.0 做了一个关键设计:请求第一次进入时生成并缓存 requestId,后续重试会通过请求头中的 X-Request-Id 透传,确保同一业务请求链路上的 requestId 始终不变。如果前端没有透传,组件会从 traceId 中派生一个 requestId,保证重放相同请求时用的是同一个身份。
3.3 防重表设计:唯一索引兜底的一天不失效
防重表是 v2.0 和 v1.x 最大的区别。我刚开始设计的时候,参考了电商系统里最常见的“防重设计”模式,但后来发现如果直接把防重表做成一张通用大表,在高并发下会变成性能瓶颈。所以在最终的设计里,我采用了按业务线分表的策略:每个接入幂等组件的服务,可以指定独立的防重表,表名支持配置,默认是 idempotent_record。
建表 SQL 大致如下:
sql复制CREATE TABLE `idempotent_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`trace_id` VARCHAR(128) NOT NULL COMMENT '幂等标识',
`biz_type` VARCHAR(64) NOT NULL COMMENT '业务类型',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '请求状态:0-处理中,1-成功,2-失败',
`request_body` TEXT COMMENT '请求参数快照',
`response_body` TEXT COMMENT '响应结果快照',
`expire_time` DATETIME NOT NULL COMMENT '过期时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trace_id` (`trace_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='接口幂等记录表';
关键就在这个 uk_trace_id 唯一索引上。当请求通过了 Redis 锁校验之后,业务执行之前,组件先执行一步 INSERT INSERT:
java复制public boolean tryRecord(String traceId, String bizType, String requestBody) {
try {
IdempotentRecord record = new IdempotentRecord();
record.setTraceId(traceId);
record.setBizType(bizType);
record.setStatus(RecordStatus.PROCESSING.getCode());
record.setRequestBody(requestBody);
idempotentRecordMapper.insert(record);
return true;
} catch (DuplicateKeyException e) {
// 唯一索引冲突,说明已存在相同 traceId 的记录
return false;
}
}
这里有几个细节值得注意:
第一个细节,INSERT 必须在业务事务之外单独执行。如果放在事务里,业务执行过程中抛异常回滚,防重表的记录也会跟着回滚,那防重就失效了。所以组件内部用 REQUIRES_NEW 传播级别把插入防重记录的操作单独提交,不管业务最终成不成功,这条记录不会丢。
第二个细节,防重记录需要设置过期时间。因为防重表不可能一直膨胀下去,否则存储会成为瓶颈。我设置的默认过期时间是 24 小时,也就是说,同一 traceId 的请求在 24 小时内会被拦截,超过 24 小时后防重记录会被清理任务删除,之后相同 traceId 的请求可以重新进入。这个时间可以根据业务场景调整,比如支付回调的幂等窗口期可能只需要几分钟,而订单创建的幂等窗口期可能需要好几天。
第三个细节,status 字段不能省略。如果只靠唯一索引拦截,那么处理失败的请求也会被永久拦截,用户连重试的机会都没有。所以在更新业务结果的时候,组件会同步更新 status:成功之后标记为 SUCCESS,失败之后标记为 FAIL。组件的拦截逻辑是:只有 status = SUCCESS 的记录才会直接返回“重复请求”,status = FAIL 的记录允许“失败重试”,status = PROCESSING 且锁已释放的记录允许“超时重试”。这样一来,幂等组件从“一刀切拦截”变成了“智能拦截”,既防重又不耽误业务恢复。
3.4 状态流转与超时清理机制:“处理中”状态的请求挂了怎么办
说到 PROCESSING 状态,就不得不提一个经典的分布式问题:如果请求在“处理中”状态时,处理请求的服务实例突然宕机了,那么这条记录会一直卡在“处理中”,直到它过期被清理。 在这个窗口期内,相同的请求会被防重表挡住,前端重试也进不去,用户体验就很差。
针对这个问题,v2.0 引入了两种恢复机制。
第一种是锁过期自动恢复。请求进入业务逻辑时,Redis 锁会有一个过期时间(默认 10 秒),如果业务执行业务的线程挂了,锁会在 10 秒后自动释放。此时防重表里的记录还是 PROCESSING,组件在拦截时发现锁已不存在,但 PROCESSING 记录还在,会触发“状态恢复”逻辑:把 PROCESSING 强制改为 FAIL,同时修改一个版本号字段,让后续请求可以重新尝试执行。
第二种是定时任务兜底。组件内置一个轻量级的定时任务(基于 Spring Schedule),每隔一分钟扫描一次,把所有 status = PROCESSING 且 update_time 超过阈值(比如 5 分钟)的记录统一改为 FAIL,并释放对应的 Redis 锁。这个定时任务可以配置为只扫描当前实例写入的记录,避免多实例同时扫描造成不必要的资源浪费。
这两个机制配合起来,基本能覆盖绝大多数异常场景。不过这也不是完美方案,如果业务逻辑真的执行了很重的操作,比如外部接口调用超过锁的过期时间,那么锁在业务还没结束时就释放了,此时新请求进来可能会拿到锁,和还在执行的旧请求并发进入业务层。这个问题靠幂等组件本身解决不了,需要业务层再做兜底校验(比如数据库层面的唯一约束)。我在设计文档里把这个边界写得很清楚:幂等组件解决的是“重复请求”的拦截问题,不能替代“并发控制”的完整方案。
3.5 与分布式事务的配合:幂等钩子与事务控制器的衔接
我在前面提到过,幂等组件和分布式事务是不同层面的东西,但在实际项目中,它们经常需要配合使用。v2.0 里做了一个很有意思的设计:把幂等记录的状态更新和事务控制器的各个阶段做了事件联动。
具体来说,组件定义了几个扩展点:
IdempotentBeforeTransaction:在分布式事务开启之前触发,此时会先插入防重记录(状态为 PROCESSING),然后开启事务。IdempotentCommit:事务提交成功后触发,将防重记录状态改为 SUCCESS。IdempotentRollback:事务回滚后触发,将防重记录状态改为 FAIL。
在 Seata 的 AT 模式下,这个流程大致是这样实现的:
java复制@GlobalTransactional
@Idempotent
public void createOrder(OrderRequest request) {
// 组件在方法执行前:插入防重记录(PROCESSING)
// 组件在方法执行后:根据事务提交/回滚,更新防重记录状态
orderService.create(request);
}
这个设计的妙处在于:防重记录的最终状态和分布式事务的结果是绑定的。事务成功了,幂等记录就是成功;事务回滚了,幂等记录就是失败(允许重试)。这样就避免了“业务事务回滚了,但防重记录还停留在成功状态,导致重试请求无法进入”的尴尬局面。
有兄弟可能会问:为什么不直接在防重表里也开启本地事务,跟业务事务一起提交?原因是业务事务可能跨多个数据源(订单库、库存库、积分库),防重表可能跟这些库不在同一个实例上,本地事务根本管不住全局一致性。所以组件只能通过“事件驱动”的方式,在事务的各个阶段去同步防重记录的状态,保证最终一致。
3.6 性能优化:从“每次全量校验”到“hash 快速通道”
做任何组件都得考虑性能问题。v2.0 加了三层拦截之后,如果每层都全量走一遍,每个请求至少多出 3 次 Redis 访问和 1 次数据库访问,在高并发下这个开销不可忽视。
为了解决这个问题,我在缓存层做了优化:防重记录查询结果会缓存到 Redis 中,键为 idempotent:record:{traceId},缓存时间为 10 分钟。当请求进来时:
- 先查缓存,如果缓存存在且状态为 SUCCESS,直接返回重复请求的响应,不查库;
- 缓存不存在,再查防重表;
- 防重表也没有,说明是新请求,走完整流程。
同时,为了应对极端情况下的缓存穿透(比如恶意请求随机生成 traceId 打过来),组件还加了一个简单的 BloomFilter 前置过滤。不过说实话,这个 BloomFilter 在大多数业务场景下都不太必要,如果你的接口是暴露在公网上的,加一个确实能扛过一部分扫描型流量;如果只是内部服务间调用,可以省略。
还有一个性能优化点:组件支持“标记模式”和“执行模式”两种运行模式。标记模式下,组件只做幂等标识的生成和校验,不更新防重表的业务结果,适合接口逻辑较轻的场景;执行模式下,组件会完整走“插入防重记录 -> 执行业务 -> 更新业务状态”的流程,适合订单、支付这类需要强幂等保护的接口。这个设计其实是从实际需求出发的——不是所有接口都需要那么重的幂等保障,提供轻量模式可以降低接入成本。
3.7 SPI 扩展机制:不修改核心代码,按业务定制
用过开源组件的兄弟都知道,一个组件能不能在真实项目里落地,很大程度取决于它的扩展能力。v2.0 在设计之初就把 SPI(Service Provider Interface)作为一等公民,核心模块和扩展模块完全解耦。
目前内置的扩展点包括:
IdempotentKeyGenerator:幂等标识生成器,前面提到过。IdempotentStore:防重记录的存储实现,默认是 MySQL 实现,理论上可以扩展为 MongoDB、Elasticsearch 等。IdempotentLock:分布式锁的实现,默认是 Redis 实现,可以扩展为 ZooKeeper、etcd 实现。IdempotentResponseProducer:重复请求的响应构造器,默认统一返回RepeatSubmitException,可以通过扩展指定每个接口的重复响应格式。
比如有一个场景:部分老接口的调用方传的幂等标识不是 traceId,而是调用方自己的 bizSeqNo。此时不需要修改任何核心代码,只需要实现一个 IdempotentKeyGenerator,在配置里指定针对某个接口使用这个自定义生成器即可。
这种插件化的设计让组件不只是一个“开箱即用”的工具,更是一个可以长期演进的基础设施。
4. 实操过程与核心环节实现:从依赖接入到完整配置
4.1 Maven 依赖与基础配置
先看一下如何在 ForgeAdmin 项目中引入 v2.0 组件。由于组件已经发布到了 Maven 中央仓库,接入方式非常简单:
xml复制<dependency>
<groupId>io.github.forgeadmin</groupId>
<artifactId>forgeadmin-idempotent-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>
如果你用的不是 Spring Boot 而是 Spring Cloud,额外加一个配置:
xml复制<dependency>
<groupId>io.github.forgeadmin</groupId>
<artifactId>forgeadmin-idempotent-spring-cloud-starter</artifactId>
<version>2.0.0</version>
</dependency>
启动类不需要加任何注解,组件会通过 Spring Boot 的自动配置机制自动加载。但需要确保配置文件中写清楚 Redis 和数据源信息,否则组件会启动失败并提示缺依赖。
在 application.yml 中的配置项如下:
yaml复制forgeadmin:
idempotent:
enabled: true
# 防重表所在数据源(默认使用主数据源)
datasource-name: primary
# 全局锁过期时间(毫秒)
lock-expire-millis: 10000
# 防重记录过期时间(小时)
record-expire-hours: 24
# 定时清理任务开关
scheduler-enabled: true
# 定时清理间隔(毫秒)
scheduler-interval-millis: 60000
# 状态为 PROCESSING 的超时阈值(毫秒)
processing-timeout-millis: 300000
# 重复请求处理策略:throw / return
duplicate-handler: return
这里重点说一下 duplicate-handler 配置。如果配成 throw,组件检测到重复请求会抛出 RepeatSubmitException,由全局异常处理器统一处理,通常返回“操作过于频繁,请稍后再试”。如果配成 return,组件会根据方法的返回类型,直接返回一个默认值(比如返回 R.failed("重复请求")),适合不希望抛出异常影响调用方正常流程的场景。我在大部分项目里用的都是 throw,因为它能配合全局异常处理器输出更友好的错误提示。
4.2 注解使用方式:从入门到进阶
组件核心的注解是 @Idempotent,用法非常灵活。最基本的用法,加在 Controller 或 Service 方法上:
java复制@PostMapping("/order/create")
@Idempotent(bizType = "createOrder")
public R<OrderVO> createOrder(@RequestBody @Validated OrderCreateRequest request) {
// 业务逻辑...
}
bizType 代表业务类型,会写入防重表记录中,方便按业务维度做数据分析。如果不指定,默认取 接口全类名 + 方法名。
如果接口参数里有业务主键,也可以通过 @IdempotentKey 指定参与幂等标识拼接的字段:
java复制@PostMapping("/order/pay")
@Idempotent(bizType = "payOrder")
public R<PayResult> payOrder(@RequestBody @Validated PayRequest request,
@IdempotentKey("orderId") Long orderId) {
// 业务逻辑...
}
这里 orderId 是从方法参数上取的,但实际开发中更多是从嵌套对象中取字段,比如 request.getOrderId()。这种情况可以用 SpEL 表达式:
java复制@PostMapping("/order/pay")
@Idempotent(bizType = "payOrder", keyExpression = "#request.orderId + ':' + #request.userId")
public R<PayResult> payOrder(@RequestBody @Validated PayRequest request) {
// 业务逻辑...
}
keyExpression 支持多种写法,只要符合 Spring SpEL 语法都可以。如果接口里没有合适的参数可以作为幂等标识,也可以用“预生成令牌”的方式:先调用令牌接口拿到一个 token,提交时把 token 放进 header 或 body 中。组件内置了 IdempotentTokenService,创建一个 token:
java复制@GetMapping("/order/token")
public R<String> getToken() {
return R.ok(idempotentTokenService.createToken());
}
提交订单时,前端把 token 放进 header(比如 X-Idempotent-Token),后端接口的幂等标识就自动从这个 header 取值:
java复制@PostMapping("/order/create")
@Idempotent(bizType = "createOrder", keyFromHeader = "X-Idempotent-Token")
public R<OrderVO> createOrder(@RequestBody @Validated OrderCreateRequest request) {
// 业务逻辑...
}
这种模式和表单防重提交流程是绝配,前端提交成功一次之后,token 失效,用户再怎么点按钮都不会重复提交。
4.3 核心的 AOP 拦截流程拆解
v2.0 基于 Spring AOP(切面编程)实现对注解的拦截。整个拦截流程分为五个步骤,我直接展示核心代码结构:
java复制@Aspect
@Component
public class IdempotentInterceptor {
@Around("@annotation(idempotent)")
public Object intercept(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 解析幂等标识
String traceId = idempotentKeyGenerator.generate(joinPoint, idempotent);
if (StringUtils.isBlank(traceId)) {
throw new IdempotentException("幂等标识解析失败,无法执行幂等控制");
}
// 2. 加 Redis 分布式锁
String requestId = IdempotentContext.getRequestId();
boolean locked = idempotentLock.tryLock(traceId, requestId, idempotent.lockExpireMillis());
if (!locked) {
// 说明已有相同请求在执行,检查防重表状态
try {
IdempotentRecord record = idempotentStore.selectByTraceId(traceId);
if (record != null && record.getStatus() == RecordStatus.SUCCESS.getCode()) {
// 已成功执行过,直接返回幂等响应
return idempotentResponseProducer.produce(joinPoint, idempotent, record);
}
// 状态未知,抛异常
throw new IdempotentException("请求正在处理中,请勿重复提交");
} finally {
// 锁没拿到,不需要释放锁
}
}
// 3. 插入防重记录(PROCESSING)
boolean recorded = false;
try {
recorded = idempotentStore.tryRecord(traceId, idempotent.bizType(),
IdempotentContext.getRequestBody());
if (!recorded) {
// 唯一索引冲突,说明之前已经有记录
IdempotentRecord record = idempotentStore.selectByTraceId(traceId);
if (record.getStatus() == RecordStatus.SUCCESS.getCode()) {
return idempotentResponseProducer.produce(joinPoint, idempotent, record);
} else if (record.getStatus() == RecordStatus.FAIL.getCode()) {
// 上次失败,允许重试
idempotentStore.updateStatus(traceId, RecordStatus.PROCESSING.getCode());
} else {
throw new IdempotentException("请求正在处理中,请勿重复提交");
}
}
// 4. 执行目标方法
Object result = joinPoint.proceed();
// 5. 更新防重记录状态为 SUCCESS
idempotentStore.updateStatus(traceId, RecordStatus.SUCCESS.getCode());
return result;
} catch (Throwable throwable) {
// 业务异常,更新防重记录状态为 FAIL
if (recorded || idempotentStore.selectByTraceId(traceId) != null) {
idempotentStore.updateStatus(traceId, RecordStatus.FAIL.getCode());
}
throw throwable;
} finally {
// 释放分布式锁
idempotentLock.unlock(traceId, requestId);
}
}
}
这段代码的逻辑其实不复杂,但有几处边界情况需要细品:
第一个边界,如果 Redis 锁没拿到(说明并发的相同请求正在执行),组件不会直接返回“重复请求”,而是先查防重表。为什么?因为锁持有者可能正在执行,也可能执行完了还没来得及释放锁。查表可以拿到最终状态,避免因为锁的时序问题误杀请求。
第二个边界,插入防重记录失败时,要区分“唯一索引冲突”和“其他数据库异常”。唯一索引冲突是业务层面预期内的,其他异常是系统层面的,需要重新抛出。上面代码中 tryRecord 方法会吞掉 DuplicateKeyException 并返回 false,其他异常会让异常继续往上抛。
第三个边界,执行目标方法抛出业务异常时,防重记录状态要更新为 FAIL,同时把锁释放。这里有个隐藏的问题:如果目标方法抛出的异常是 RepeatSubmitException 本身呢?那说明业务代码里主动触发了重复提交的判断,此时也应该更新为 FAIL 还是保持 PROCESSING?我的选择是更新为 FAIL,因为 RepeatSubmitException 代表业务上不认可这次操作,应当允许用户稍后重试。
4.4 定时清理任务和防重表数据归档
防重表的数据不能无限增长,所以组件内置了数据清理机制。我用 Spring 的 @Scheduled 注解实现了两个任务:
java复制@Component
public class IdempotentRecordCleaner {
@Scheduled(cron = "${forgeadmin.idempotent.scheduler-cron:0 0 2 * * ?}")
public void cleanExpiredRecords() {
// 删除过期时间早于当前时间的成功记录
int deleted = idempotentRecordMapper.deleteExpiredRecords();
log.info("幂等记录清理完成,共删除 {} 条过期记录", deleted);
}
@Scheduled(fixedDelay = 60000)
public void recoverProcessingRecords() {
// 将超过 processing-timeout 的 PROCESSING 记录置为 FAIL
int recovered = idempotentRecordMapper.recoverTimeoutRecords();
if (recovered > 0) {
log.warn("检测到 {} 条超时的 PROCESSING 记录,已自动恢复为 FAIL", recovered);
}
}
}
删除过期记录的任务最好在凌晨执行,避免影响业务高峰期。恢复超时记录的任务建议间隔短一些,因为“处理中”状态如果长时间不恢复,会阻塞用户的正常请求。
这里有一个数据归档的思路可以分享:对于大型系统,防重表的数据量会非常庞大,简单 DELETE 不一定能按时完成任务。我的建议是采用“双表轮换”策略——维护一张热表(保留最近 7 天数据)和一张归档表(保存历史数据),每天凌晨通过表切换的方式把冷数据迁走。这种方案比直接 DELETE 大表要优雅得多,而且不会产生长时间的行锁。
5. 常见问题与排查技巧实录:一线 Debug 经验
5.1 高并发下“重复请求被拦截但业务还在执行”的坑
这类问题发生的场景比较隐蔽,我描述一下现象:压测时发现有部分请求被幂等组件拦截并返回“重复请求”,但实际业务却是成功的,而且防重表里已经有对应的成功记录。
排查思路要沿着时序图走。假设请求 A 和请求 B 是同一个业务请求的两次重试:
- 请求 A 先到达,拿到 Redis 锁,插入防重记录(PROCESSING),开始执行业务。
- 请求 A 执行了 3 秒业务逻辑,但 Redis 锁的过期时间只有 2 秒,锁在持续时间到达后自动释放。
- 请求 B 此时到达,发现锁不存在,成功拿到锁,查询防重表发现记录状态还是 PROCESSING,于是走了“失败重试”逻辑,再次执行业务。
- 请求 A 和请求 B 同时执行业务,数据库层面的唯一约束可能挡住其中一个,但防重表里最终会出现两条成功的记录(如果业务表没有唯一约束)。
最终的现象就是:防重表里 traceId 相同,但业务表里可能出现两条相同的业务数据。这不是幂等组件没用,而是锁的过期时间设置不合理,导致锁提前释放,让重复请求钻了空子。
我推荐的解决方法是:锁的过期时间必须大于业务的最长执行时间,而不是平均执行时间。 计算方式可以这样:统计接口的 P99 耗时,乘以一个安全系数(比如 3 倍),再加上网络抖动余量。如果接口 P99 是 800ms,那锁的过期时间至少设置 3000ms。如果业务逻辑里有外部接口调用,最好把所有下游接口的 P99 加总后乘以 2。
为了进一步降低锁提前失效的风险,v2.0 还提供了“锁续期”能力:用一个后台线程监控业务执行状态,如果业务还没结束但锁即将过期,自动延长锁的过期时间。这个功能默认是关闭的,需要显式开启:
yaml复制forgeadmin:
idempotent:
lock-renew:
enabled: true
renew-interval-millis: 3000
max-expire-millis: 60000
开启之后,业务方法执行每超过 3 秒,后台线程就会检查一次锁的剩余时间,如果剩余时间不足 1 秒就会自动续期,最长时间不超过 60 秒。这个机制能覆盖绝大多数超时场景,但不能完全替代合理的锁过期时间设置。
5.2 同一接口既能“防重”又能“重试”:状态机逻辑的正确姿势
更多兄弟在实际使用中会遇到的问题是:幂等组件太“灵敏”了,稍微有点异常就把请求标记为 FAIL,导致用户多次重试都能成功执行,反而失去了防重的意义。
这个问题的根子在于状态机的判定逻辑太粗糙。v2.0 在更新防重记录状态时,把“业务失败”和“系统异常”做了区分:业务失败(比如参数校验不通过、余额不足)不算在幂等拦截范围内,更新为 FAIL 后允许重试;系统异常(比如数据库连接失败、外部接口超时)则保持 PROCESSING 或标记为特殊状态,重试时做特殊处理。
具体实现时,组件提供了一个扩展接口:
java复制public interface IdempotentExceptionClassifier {
/**
* 判断异常是否属于可重试异常
*/
boolean isRetryable(Throwable throwable);
}
默认实现里,BizException 和 IllegalArgumentException 归为不可重试异常,RemoteAccessException 和 TimeoutException 归为可重试异常。你可以在配置里指定:
yaml复制forgeadmin:
idempotent:
retryable-exceptions:
- org.springframework.dao.DataAccessException
- java.util.concurrent.TimeoutException
这样一来,“防重”和“重试”就真正统一到了一个状态机里:不可重试的失败不允许重入,可重试的失败放行重试。这个细节做得好不好,直接决定了组件在实际项目中的可用性。
5.3 排查实例:Redis 连接异常导致幂等组件全部失效
有一次生产线上突然出现一个奇怪的问题:订单提交接口大面积超时,但排查业务日志发现业务代码根本没执行到。后来定位到是幂等组件的 Redis 连接池满了,所有请求都在等待获取 Redis 连接,导致接口被幂等组件卡死了。
这个问题的根因是:Redis 连接池的默认大小(比如 8 个连接)在请求量突增时会成为瓶颈。如果 Redis 服务本身还出现了慢查询,连接被长时间占用,新请求的获取连接操作就会进入阻塞态。
排查思路其实很直接:从 Redis 监控面板看连接数是否打满,再从幂等组件的日志看是否有很多“获取分布式锁超时”的警告。
解决办法有几个方向:
方向一,调整连接池参数。把 maxTotal 从默认值调大,maxWaitMillis 调短,避免请求无限制等待:
yaml复制spring:
data:
redis:
jedis:
pool:
max-active: 32
max-idle: 16
min-idle: 4
max-wait: 800ms
方向二,给幂等组件加本地降级开关。如果 Redis 挂了或者连接池满了,组件可以自动降级为“放行”模式,让请求继续走业务逻辑,而不是被幂等组件卡死。这个方案看似反直觉(幂等组件不拦请求了还怎么保证幂等),但你要考虑到:在 Redis 不可用的情况下,连业务系统的核心功能都已经受影响了,此时保证“系统可用”比保证“系统幂等”更重要。所以 v2.0 提供了一个 fail-open 配置,默认是关闭的,生产环境建议开启:
yaml复制forgeadmin:
idempotent:
fail-open: true
开启后,组件内部所有 Redis 操作(加锁、解锁、查询缓存)如果抛异常,都会被捕获并记录日志,然后放行请求。防重表的数据库操作不受影响,依然能够提供一定程度的幂等保护。
方向三,接入 Redis 高性能模式。开启 Redis 的 IO 多线程(Redis 6.0+),提高单实例的吞吐量,减轻连接池压力。这个属于基础设施优化,具体配置可以在 redis.conf 中设置 io-threads 4。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 相同参数请求未被拦截 | traceId 生成了随机值 | 查看组件日志中打印的 traceId 是否一致 | 检查 keyExpression 是否误用了时间戳/随机数 |
| 请求被误拦截且状态为 PROCESSING | 业务执行超过锁过期时间 | 查看防重表状态和 Redis 锁剩余时间 | 调大 lockExpireMillis 或开启锁续期 |
| 业务失败后无法重试 | 状态机判定为不可重试 | 查看异常分类器日志 | 配置 retryable-exceptions 或自定义分类器 |
| Redis 故障导致接口超时 | 连接池被打满 | 查看 Redis 监控面板 | 调整连接池参数或开启 fail-open |
| 防重表数据量过大 | 过期清理任务未生效 | 查看定时任务执行日志 | 确认 scheduler-enabled 开启,检查 cron 表达式 |
| 多实例部署时幂等失效 | 不同实例生成的 traceId 不一致 | 对比不同实例的日志输出 | 确认 keyExpression 使用同一套规则,检查 requestId 透传逻辑 |
| 防重记录插入报唯一索引冲突 | traceId 已存在但状态异常 | 查看完整异常堆栈 | 按 3.3 节的 tryRecord 逻辑处理 DuplicateKeyException |
这七类问题是社区用户反馈中最高频的,我在每次版本迭代时都会重点检查这些场景是否回归。
6. 从 v1.x 平滑升级到 v2.0 的十个注意事项
最后聊一下升级过程中大家最关心的问题:老项目怎么低成本切到 v2.0。我自己的项目从 v1.x 升级到 v2.0 用了大概三天时间,大部分时间花在了防重表建表和接口 traceId 规则梳理上。下面十个注意事项是我和团队踩过坑之后总结出来的,绝对是干货。
注意一:先确认组件版本兼容性。 v2.0 底层依赖 Spring Boot 3.x 和 JDK 17+,如果项目还在 Spring Boot 2.x 或 JDK 8,需要先升基础框架。这一条看起来是废话,但它会在 maven 依赖冲突时让你崩溃。我建议先在测试环境把所有依赖树跑一遍,确认没有版本冲突再动生产环境。
注意二:防重表必须先建好再升级。 老版本只有 Redis 锁,没有防重表。升级后如果防重表缺失,所有接口会直接报错。建表 SQL 在开源仓库的 docs/sql/ 目录下,直接执行即可。如果有多环境,记得每个环境的数据库都要执行。
注意三:存量接口的幂等标识要重新核对。 老版本可能用了时间戳拼 traceId,升级后如果 keyExpression 配置不当,可能导致同一请求的 traceId 变化无常,防重效果归零。建议在升级前用脚本扫描所有使用 @Idempotent 注解的接口,逐一确认 key 表达式是否合理。
注意四:旧的 Redis 锁 key 需要平滑迁移。 老版本的锁 key 格式是 idempotent:{traceId},v2.0 改成了 idempotent:lock:{traceId}。如果升级时 Redis 里还残留旧的 key,可能会导致新版本加锁时发现 key 已存在但 value 不匹配,误判为“正在处理中”。最简单的处理方式:升级发布前,在 Redis 中批量删除旧的 idempotent:* 前缀的 key,或者在代码里加上旧 key 的兼容清理逻辑。
注意五:防重表记录和业务数据的清理策略要匹配。 如果防重记录过期时间设置得太短,清理任务删除记录后,相同 traceId 的请求可以再次进入,如果业务库已经存在相同数据,就会产生重复数据。反之,如果过期时间太长,防重表会越来越臃肿。建议按业务最长幂等窗口期 + 24~48 小时冗余来设置。
注意六:接口返回的重复请求提示语建议统一。 不同部门的兄弟接入组件后,各自定义了不同的重复响应格式,有的返回“操作频繁”,有的返回“请勿重复提交”,还有的返回一堆 JSON 错误码。后续联调的时候,前端处理工作量成倍增加。我建议在组件配置里统一 duplicate-handler 为 throw,并配合一个全局异常处理器统一返回“操作过于频繁,请稍后重试”。
注意七:别忘了给防重表加监控。 我自建的监控面板上,防重表有几个指标必须盯着:insert 成功次数、唯一索引冲突次数、PROCESSING 超时恢复次数。这些指标能直观反映幂等组件的拦截效果和异常频率。如果冲突次数异常升高,说明有接口的 traceId 生成规则出了问题;如果恢复次数过高,说明锁过期时间设置不合理。
注意八:多环境部署时,注意 Redis key 的隔离。 测试环境和生产环境共用一个 Redis 实例时(虽然不推荐,但确实有人这么干),幂等锁的 key 可能会污染。v2.0 支持配置 key 前缀,比如:
yaml复制forgeadmin:
idempotent:
key-prefix: prod: # 生产环境加 prod 前缀
注意九:升级后做一次全量回归。 重点回归的接口包括:订单创建、支付回调、退款、库存扣减、积分发放。这些接口都是幂等的高危场景。回归用例要覆盖:正常请求、重复点击、失败重试、超时重试、并发重复请求、重启服务后重试。
注意十:回滚预案要提前准备好。 v2.0 用的注解和 v1.x 是同一个 @Idempotent,只是在底层实现上做了重整,理论上回滚只需要换回旧版本依赖即可。但防重表的数据会残留,回滚前建议清空防重表的 PROCESSING 状态记录,否则旧版本组件不会读防重表,影响不大。真正的风险在于,如果升级期间已经产生了新的业务数据,回滚后防重失效可能导致重复提交,所以升级前最好备份业务库。
7. 延伸思考:幂等组件还能怎么玩
v2.0 的升级已经完成,但在写这篇文章的过程中,我又梳理了几个值得继续深挖的方向,写出来给兄弟们参考。
第一个方向是消息队列场景的幂等集成。目前组件的注解主要作用于 HTTP 接口,但很多分布式系统里,消息消费者也需要幂等保护。比如用户下单后发送一条 MQ 消息给积分服务,如果消息重复投递,积分就可能加两次。v2.0 现在的处理方式是在 MQ 消费者方法上也加 @Idempotent 注解,traceId 从消息体里的业务 ID 生成,原理和 HTTP 接口完全一致。后续可以考虑做一个更顺滑的 MQ 适配器,自动识别 RabbitMQ / RocketMQ / Kafka 的消息上下文,减少配置成本。
第二个方向是防重表的分库分表扩展。当单表数据量超过千万级时,唯一索引的性能会明显下降。v2.0 是通过配置 datasource-name 指定独立的数据源来缓解这个压力,但跨分片的唯一性还是得靠 traceId 的全局唯一性来保证。如果未来接入的业务线足够多,可能需要按照 biz_type 做水平分表,或者引入分布式 ID 方案(如美团 Leaf、百度 UidGenerator)从源头保证 traceId 的唯一性。
第三个方向是Coap / gRPC 等非 HTTP 协议的幂等支持。微服务架构里还有大量内部服务使用 gRPC 通信,这些接口同样存在幂等需求。目前我采用的是用 gRPC 拦截器(ServerInterceptor)适配组件的核心接口,但代码还没完全开源,等整理好之后再发一篇文章详细说。
第四个方向是幂等分析和可视化。防重表里其实积累了大量有价值的业务数据——哪些接口重复提交率高、哪些用户频繁触发幂等、哪些 traceId 重复次数异常,这些信息可以做成一个简易的幂等监控大盘,帮助运维和业务团队快速定位问题。目前组件只提供了一些基础指标(通过 Micrometer 暴露),还没有完整的 UI 界面。
最后再分享一点我个人的实操体会。做分布式系统的兄弟应该都有一个共识:很多问题不是靠某个中间件单打独斗能解决的,而是要通过多层防护、冗余设计、状态管理共同逼近“万无一失”。 幂等组件也是一样,我从来没有指望它能拦截所有重复请求,而是把它作为整个防护体系中的一环。Redis 锁扛第一波流量,防重表兜住极端场景,业务层的唯一约束做最后的防线,三层叠加之后,重复请求的存活概率已经被压缩到极低。
v2.0 上线之后,我在生产环境观察了两个多月,订单、支付、库存、积分四个核心链路的重复提交问题基本清零。唯一一次报警是某个接口的 traceId 生成规则被团队新同学误改,导致幂等失效,好在监控指标(唯一索引冲突次数大幅下降)及时暴露了问题,十分钟内定位并修复。这件事也印证了我一直坚持的一个观点:组件再强大,也抵不过配置管理的疏忽。任何时候都要保留监控和告警能力,这是分布式系统里保命的底线。
这篇文章写到这里,该讲的细节基本都覆盖了。如果你也在做幂等相关的设计,或者正在用 ForgeAdmin,欢迎把你的踩坑经历和心得体会发在评论区,咱们一起把分布式系统这个硬骨头啃得更透彻。
