- 用户点了提交,页面在转圈,心里着急,又点了一下。这个再普通不过的动作,后端往往要面对两笔一模一样的订单、两条重复的扣款记录。如果只是用户连点还好,更麻烦的是支付平台回调重推、MQ消息重复消费、RPC重试机制,这些“系统级重复”根本不受前端控制,随时可能把数据搞乱。
- 大多数团队处理这种问题,第一反应是在业务代码里写一段
Redis setnx,第二反应是下一个项目里复制一份改一改。直到某天线上冒出一个漏网的重复请求,大家才意识到,散落在各个服务里的“防重代码”本身才是最大的风险源——每个接口的实现方式都不一样,有的把标记存在数据库,有的用本地锁,有的干脆只在前端做了个按钮置灰。 - 我最近把一个通用幂等和防重组件沉淀成了独立的
spring-boot-starter,基于Redis实现,通过一个注解就能让任意接口具备防重和幂等能力。从最初的“能跑”到真正敢放在生产环境,中间踩了不少坑,也推翻过好几版设计。这篇文章把完整的设计思路、核心实现和那些常规文档里不会写的问题都梳理一遍,给需要的团队做个参考。
1. 先搞清楚:防重、幂等、分布式互斥到底在解决什么
很多人把这三个词混着用,但设计阶段不把它们分开,做出来的组件一定别扭。我的理解是这样的:防重关注的是“这次请求不允许重复进来”,幂等关注的是“重复请求执行多次和执行一次的效果相同”,而分布式互斥关注的是“同一时刻只能有一个实例执行这段代码”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.1 三者的典型场景差异
- 防重最常见的场景是用户下单时连点提交,或者表单提交时没等结果就再次点击。前端按钮置灰能挡住大多数情况,但总有绕过前端的人,服务端必须兜底。
- 幂等更典型的场景是支付回调。支付平台为了保证通知成功,会按间隔重试多次,甚至同一个回调在极端情况下会重复推送到两个不同的实例。消费端如果只是简单判断“重复就不处理”,第一次消费成功、第二次重复消费时仍然可能把数据改坏。
- 分布式互斥更像是一种底层能力,本质是让逻辑在并发环境下只执行一次,很多需要抢占式处理的定时任务、缓存重建场景会用到。
1.2 同一个中间件必须同时覆盖这三种诉求
这也是我把组件设计成“状态机”而不是简单“加个key”的原因。如果只用一个SETNX做防重,只能挡住“同一瞬间”的并发重复,但挡不住“第一次处理完成后,第二次请求又来了”这种后置重复;如果只做幂等,控制不了请求并发期间的互斥。所以中间件内部的Redis标记不能是一个“有/无”的二元状态,而应该是一个流转的状态机:请求进来时标记为“执行中”,业务完成后标记为“已成功”,业务失败时移除标记。这样“执行中”负责互斥,“已成功”负责幂等,“移除”负责允许重试。
简单做个对照:
| 能力 | 核心问题 | 标记状态 | 失败处理 |
|---|---|---|---|
| 防重 | 重复请求不允许进入 | 执行中 | 移除标记,允许重试 |
| 幂等 | 重复请求必须结果一致 | 已成功 | 不改变结果 |
| 互斥 | 同一时刻只能一个请求执行 | 执行中 | 等待或拒绝 |
2. 选型与设计决策:为什么是注解 + AOP + Redis 这套组合
通用中间件的第一步不是写代码,而是选型。我见过不少团队在这个环节纠结很久,有的用数据库唯一索引,有的用本地缓存,有的用Zookeeper分布式锁。客观说,这些方案都有自己的适用场景,但对于“通用幂等与防重”这个目标,Redis确实是最优解。
2.1 Redis 对比数据库唯一索引和本地锁
数据库唯一索引防重是最早的常见方案,在订单表上建一个业务单号的唯一索引,重复插入时数据库会抛异常。这个方案的优点是数据绝对准确,缺点是侵入业务表结构、依赖数据库性能,而且在高并发下数据库连接容易被打满。更重要的是,它只能防“写入型”重复,对“读取后计算再写入”这种典型非幂等操作无能为力。
本地锁的问题更明显:一个服务部署三个实例,每个实例都有自己的锁,同一个请求分发到不同实例上,本地锁根本感知不到彼此。用Redisson分布式锁能解决实例间互斥,但又引入一个新的问题——锁释放的时机很难把控,业务超时导致锁提前释放的场景几乎每个用Redisson的团队都踩过。
Redis方案的好处在于:SET NX EX一条命令就能实现原子性的“占位+过期”,GET + 比较 + DEL配合Lua脚本可以实现安全的锁释放,Redis本身又是绝大多数后端团队已有的基础设施,不用额外引入新组件。
2.2 注解驱动的侵入性取舍
接口防重最怕的就是业务代码里到处是Redis操作。我见过有的项目在Service层里写:
java复制Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
if (!success) {
throw new RuntimeException("重复请求");
}
这类代码最大的问题是:每个接口的key生成规则不同、过期时间不同、清理方式不同,散落一地,后来维护的人根本不敢动。而且如果调用方换了实现方式,比如从Redis换成了数据库,所有接口都要跟着改。
所以我坚持用注解 + AOP来封装。使用方只需要在接口方法上加一个@Idempotent注解,指定幂等键的取法,剩下的Redis交互、状态流转、锁释放全部由切面统一处理。业务代码零侵入,底层实现想要从Redis换成别的存储,只需要替换一个IdempotentRepository实现类。
2.3 令牌模式和参数模式的兼容设计
市面上常见的防重组件,要么只支持“请求头传token”的令牌模式,要么只支持“从参数提取key”的参数模式。我一开始也只想做一种,但实际使用中发现两种模式各有不可替代的场景:
- 令牌模式:前端先调用
POST /idempotent/token获取一个一次性token,提交业务请求时把这个token放在Header里。服务端消费token,消费成功才执行业务。这种模式防重效果最强,同一个token无论如何都不可能提交两次,适合下单、支付这类“提交一次就够了”的重操作。 - 参数模式:服务端从请求参数中提取业务唯一键,比如订单号、流水号、消息ID,拼接后作为Redis key。不需要前端额外配合,适合处理第三方回调、MQ消息这类“源头自带唯一键”的场景。
一个通用中间件如果只支持其中一种,使用面会窄很多。所以注解里我会加一个type字段,PARAM和TOKEN两种模式都支持,切面里根据类型走不同的处理分支。
3. 核心原理:Redis状态机、Lua脚本与看门狗续期
设计一个生产级中间件和写Demo最大的区别在于,要仔细推演各种并发时序下的行为。这一节我把组件的地基拆开讲清楚。
3.1 单一标记为什么不够:三个状态的设计由来
最直觉的防重实现是这样的:请求进来时SETNX一个key,成功就执行业务,失败就拒绝。只用一个key的“存在”来表达“不允许重复”,有一个很大的漏洞——第一次请求已经处理成功了,key还在,第二次请求仍然会被拒绝。
这个逻辑放在“防重”场景下是正确的,因为防重确实不希望同一个操作在短时间内被重复执行。但在“幂等”场景下就出问题了:幂等的要求不是“拒绝重复请求”,而是“重复请求要获得和第一次一样的处理结果”。同一个支付回调第一次处理成功了,第二次再推过来时应当被直接判断为“已处理”,而不是抛一个“处理中”的异常。
所以我把Redis value设计成三种状态:
| 状态 | value取值 | 含义 | 请求处理逻辑 |
|---|---|---|---|
| 执行中 | 请求唯一ID | 第一次请求正在执行 | 重复请求拒绝或等待 |
| 已成功 | SUCCESS | 第一次请求已处理完成 | 重复请求直接返回成功 |
| 已移除 | key不存在 | 业务失败或已过期 | 允许重新执行 |
这个设计让同一个组件既能做防重(遇到“已成功”也拒绝),又能做幂等(遇到“已成功”直接返回成功结果),还能做互斥(遇到“执行中”互相等待)。
3.2 原子性问题的经典陷阱与Lua脚本解法
上一节提到的“执行中”状态,常见实现是两步:
java复制Boolean success = redisTemplate.opsForValue().setIfAbsent(key, requestId);
if (success) {
redisTemplate.expire(key, 10, TimeUnit.SECONDS);
}
这段代码有个很经典的问题:setIfAbsent和expire是两次独立的Redis操作,如果第二次设置过期时间前进程崩溃,这个key就成了“永不释放的锁”,后面所有相同key的请求都会被永久拒绝。很多人写的时候没注意,线上出了故障才排查出来。
正确做法是用Redis的SET NX EX一条命令完成“占位+过期”,或者直接用Lua脚本把两步合并成一步。我在组件里用的是Lua脚本,因为后续还有“检查value再删除”“检查value再更新状态”这些复合操作,脚本统一管理更清晰:
lua复制-- 尝试获取执行权
if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then
return 1
else
return 0
end
KEYS[1]是幂等key,ARGV[1]是本次请求的唯一ID,ARGV[2]是过期时间秒数。这条脚本的原子性由Redis单线程模型保证,不需要额外加锁。
3.3 看门狗续期机制:避免长业务被打断
过期的意义是“兜底”,防止进程崩溃后key永远不释放。但有了过期时间,就引入了另一个问题:业务执行时间超过过期时间,Key提前失效,后一个重复请求就能再次获得执行权,导致同一业务被并发执行两次。
举个例子,一个订单处理接口正常耗时1秒,你给了30秒过期时间,看起来绰绰有余。但某天接口因为外部依赖变慢,一个请求跑了40秒,30秒时key到期被清掉,此时另一个相同请求进来,发现key不存在,于是也进来执行,两个请求同时处理同一笔订单,数据就乱了。
解决这个问题不能靠“把过期时间设大”,因为过期时间设得越大,进程崩溃后key残留的时间就越长,对后续请求的影响也越大。正确方案是做“看门狗续期”,类似Redisson的watch dog机制:业务执行过程中,后台线程每隔一段时间自动延长key的过期时间,业务执行完就取消续期。这样无论业务跑多久,key都不会在业务执行期间提前失效;一旦业务崩溃,续期线程也随之中断,key会在原过期时间后自动清理。
我在切面里用一个ScheduledExecutorService启动续期任务:
java复制private ScheduledFuture<?> startRenewTask(String key, String requestId, long expire, TimeUnit timeUnit) {
long period = Math.max(1, timeUnit.toMillis(expire) / 3);
return renewExecutor.scheduleAtFixedRate(() -> {
repository.renew(key, requestId, expire, timeUnit);
}, period, period, TimeUnit.MILLISECONDS);
}
续期频率设置为过期时间的1/3,避免续期线程本身成为性能瓶颈。renew方法同样用Lua脚本实现,先校验value是否是当前请求的ID,避免续期到其他请求的key上。
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
3.4 执行者标识:为什么DEL必须校验value
这是我在生产上踩过的一个很深的坑。早期版本里,业务执行失败后直接redisTemplate.delete(key),看起来没问题,但在并发场景下会误删其他请求的锁。
具体时序是这样的:请求A获取执行权,key=A_ID,业务执行很慢;业务执行时间超过过期时间,key自动过期;请求B进来,key=B_ID,获取执行权成功,开始执行业务;此时请求A执行完毕(业务逻辑成功或失败),进入清理阶段,直接执行delete(key),把请求B的key删掉了。结果是请求B的防重失效,另一个相同请求C又进来了。
正确的做法是“先比较再删除”,只有value和当前请求ID一致时才允许删除,这样A只能删除自己的key,B的key留在Redis里不受影响。这个比较+删除操作合并成一个Lua脚本,保证原子性:
lua复制if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
同样的道理也适用于“标记成功”操作。成功处理完业务后,要把执行中状态改成已成功状态,这个改状态操作也必须校验value,否则可能把别的请求的状态覆盖掉。
4. 生产级落地:从零搭一个 spring-boot-starter
理论部分梳理清楚了,接下来是完整的代码实现。我尽量把关键代码贴出来,配上设计说明,这样你可以直接照着搭,也可以根据自己的场景做裁剪。
4.1 项目结构与依赖
我建议单独建一个Maven模块,命名类似idempotent-spring-boot-starter,这样任何需要防重能力的服务只要引入依赖就行。目录结构如下:
code复制idempotent-spring-boot-starter/
├── pom.xml
└── src/main/
├── java/com/example/idempotent/
│ ├── annotation/
│ │ ├── Idempotent.java
│ │ └── EnableIdempotent.java
│ ├── aspect/
│ │ └── IdempotentAspect.java
│ ├── repository/
│ │ ├── IdempotentRepository.java
│ │ ├── RedisIdempotentRepository.java
│ │ └── LocalIdempotentRepository.java
│ ├── config/
│ │ ├── IdempotentProperties.java
│ │ └── IdempotentAutoConfiguration.java
│ └── exception/
│ └── IdempotentException.java
└── resources/
└── META-INF/
└── spring.factories
pom.xml依赖只需要Redis和AOP,其他都是Spring Boot的常规依赖:
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
optional=true很关键,表示这个依赖不会传递到使用方,使用方自己决定是否引入Redis和AOP。如果你的使用方已经有Redis,就不需要重复引入。
4.2 注解定义:key、过期时间、策略、等待能力
注解是使用方唯一要接触的东西,字段设计要兼顾灵活性和简洁性。我最终保留了以下几个字段:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等键,支持SpEL表达式
* 例如:#request.bizId、#orderId、#req.userId + '-' + #req.bizNo
*/
String key() default "";
/**
* Redis key前缀
*/
String prefix() default "";
/**
* 防重模式:PARAM / TOKEN
*/
IdempotentType type() default IdempotentType.PARAM;
/**
* key过期时间,默认30秒
*/
long expire() default 30;
/**
* 过期时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 业务处理成功后,key继续保留的时间
* 在这段时间内的重复请求会被直接判定为“已处理”
*/
long successExpire() default 5;
/**
* 是否开启看门狗自动续期
*/
boolean renew() default true;
/**
* 遇到重复请求时是否等待第一次执行完成
*/
boolean waitForResult() default false;
/**
* 最大等待时间(秒)
*/
int waitTimeout() default 3;
/**
* 是否缓存业务结果,开启后重复请求可返回第一次的结果
*/
boolean enableResultCache() default false;
}
这里有几个字段需要特别解释一下。successExpire是指业务已经做完了、但Redis里的幂等标记还要再保留一段时间,这是为了“幂等返回”。如果业务做完立刻删key,同样的请求马上再来一遍,就不能识别为“已处理”了。但也不能保留太长时间,否则用户想重新发起相同的操作会被一直拒绝。successExpire默认5秒是比较稳妥的值,实际操作场景里一般设置为接口允许的最大重试间隔。
waitForResult是个体验优化字段。用户连点时,第一次请求可能正在执行,第二次请求如果直接拒绝,用户会看到“处理中”的报错,体验不好。如果开启这个字段,第二次请求不会立刻拒绝,而是在Redis里轮询等待第一次请求结束,拿到结果后直接返回。这个功能我会在后面的代码里详细说明,使用时要小心,因为长时间自旋会占住业务线程。
4.3 存储层:接口隔离 + Redis实现 + 本地兜底
先定义存储层接口,把对Redis的依赖隔离在接口后面,方便替换和测试:
java复制public interface IdempotentRepository {
/** 尝试获取执行权,成功返回true */
boolean tryAcquire(String key, String requestId, long expire, TimeUnit timeUnit);
/** 获取当前状态 */
String get(String key);
/** 标记业务执行成功 */
boolean markSuccess(String key, String requestId, long successExpire, TimeUnit timeUnit);
/** 释放执行权(业务失败) */
boolean remove(String key, String requestId);
/** 续期 */
boolean renew(String key, String requestId, long expire, TimeUnit timeUnit);
}
Redis实现如下。所有多步骤操作都通过Lua脚本保证原子性:
java复制public class RedisIdempotentRepository implements IdempotentRepository {
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String TRY_ACQUIRE_LUA =
"if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then " +
"return 1 else return 0 end";
private static final String MARK_SUCCESS_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"redis.call('set', KEYS[1], 'SUCCESS') " +
"redis.call('expire', KEYS[1], ARGV[2]) " +
"return 1 else return 0 end";
private static final String REMOVE_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
private static final String RENEW_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire', KEYS[1], ARGV[2]) " +
"else return 0 end";
private final StringRedisTemplate redisTemplate;
public RedisIdempotentRepository(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.redisTemplate.setKeySerializer(new StringRedisSerializer());
}
@Override
public boolean tryAcquire(String key, String requestId, long expire, TimeUnit timeUnit) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(TRY_ACQUIRE_LUA, Long.class),
List.of(key),
requestId,
String.valueOf(timeUnit.toSeconds(expire))
);
return Long.valueOf(1).equals(result);
}
@Override
public String get(String key) {
return redisTemplate.opsForValue().get(key);
}
@Override
public boolean markSuccess(String key, String requestId, long successExpire, TimeUnit timeUnit) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(MARK_SUCCESS_LUA, Long.class),
List.of(key),
requestId,
String.valueOf(timeUnit.toSeconds(successExpire))
);
return Long.valueOf(1).equals(result);
}
@Override
public boolean remove(String key, String requestId) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(REMOVE_LUA, Long.class),
List.of(key),
requestId
);
return Long.valueOf(1).equals(result);
}
@Override
public boolean renew(String key, String requestId, long expire, TimeUnit timeUnit) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(RENEW_LUA, Long.class),
List.of(key),
requestId,
String.valueOf(timeUnit.toSeconds(expire))
);
return Long.valueOf(1).equals(result);
}
}
这里有一个细节值得注意:StringRedisTemplate的序列化器默认就是字符串,如果直接用RedisTemplate注入,默认的JdkSerializationRedisSerializer会把key序列化成二进制乱码,排查问题的时候特别痛苦。所以我用StringRedisTemplate保证key和value都是可读字符串。
4.4 切面核心逻辑:获取、执行、标记、释放
切面是整个组件的核心,逻辑流程比较长,我分成几段说明。首先看主流程:
java复制@Aspect
public class IdempotentAspect {
private final IdempotentRepository repository;
private final IdempotentProperties properties;
private final ScheduledExecutorService renewExecutor;
public IdempotentAspect(IdempotentRepository repository, IdempotentProperties properties) {
this.repository = repository;
this.properties = properties;
this.renewExecutor = Executors.newScheduledThreadPool(
properties.getRenewPoolSize(),
r -> {
Thread t = new Thread(r, "idempotent-renew");
t.setDaemon(true);
return t;
});
}
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
String key = resolveKey(joinPoint, idempotent);
String fullKey = buildKey(joinPoint, idempotent, key);
String requestId = UUID.randomUUID().toString();
if (idempotent.type() == IdempotentType.TOKEN) {
return handleTokenMode(joinPoint, idempotent, fullKey);
}
return handleParamMode(joinPoint, idempotent, fullKey, requestId);
}
}
resolveKey负责从SpEL表达式解析出幂等键,后面单独说明。handleParamMode是参数模式的处理逻辑:
java复制private Object handleParamMode(ProceedingJoinPoint joinPoint, Idempotent idempotent,
String fullKey, String requestId) throws Throwable {
boolean acquired = repository.tryAcquire(fullKey, requestId, idempotent.expire(), idempotent.timeUnit());
if (!acquired) {
return handleRepeatedRequest(joinPoint, idempotent, fullKey);
}
// 开启看门狗续期
ScheduledFuture<?> renewTask = null;
if (idempotent.renew()) {
long renewPeriod = Math.max(1, idempotent.timeUnit().toMillis(idempotent.expire()) / 3);
renewTask = renewExecutor.scheduleAtFixedRate(
() -> repository.renew(fullKey, requestId, idempotent.expire(), idempotent.timeUnit()),
renewPeriod, renewPeriod, TimeUnit.MILLISECONDS);
}
try {
Object result = joinPoint.proceed();
// 业务成功,标记为已成功状态,并保留一段时间
repository.markSuccess(fullKey, requestId, idempotent.successExpire(), idempotent.timeUnit());
// 可选:缓存业务结果
if (idempotent.enableResultCache()) {
cacheResult(fullKey, result);
}
return result;
} catch (Throwable ex) {
// 业务失败,释放执行权,让后续请求有机会重试
repository.remove(fullKey, requestId);
throw ex;
} finally {
if (renewTask != null) {
renewTask.cancel(false);
}
}
}
handleRepeatedRequest是关键分支,它根据当前key的状态决定是拒绝、等待还是返回成功结果:
java复制private Object handleRepeatedRequest(ProceedingJoinPoint joinPoint, Idempotent idempotent,
String fullKey) throws Throwable {
String status = repository.get(fullKey);
// 第一次请求已处理成功
if (STATUS_SUCCESS.equals(status)) {
if (idempotent.enableResultCache()) {
Object cached = getCachedResult(fullKey);
if (cached != null) {
return cached;
}
}
if (idempotent.type() == IdempotentType.PARAM) {
throw new IdempotentException("重复请求,该操作已处理成功");
}
}
// 第一次请求还在执行中,等待结果
if (idempotent.waitForResult()) {
Object result = spinWait(fullKey, idempotent.waitTimeout());
if (result != null) {
return result;
}
}
throw new IdempotentException("请求正在处理中,请勿重复提交");
}
spinWait的轮询实现:
java复制private Object spinWait(String fullKey, int timeoutSeconds) {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
String status = repository.get(fullKey);
if (STATUS_SUCCESS.equals(status)) {
return getCachedResult(fullKey);
}
if (status == null) {
// key不存在,说明第一次请求执行失败,标记已释放
return null;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
return null;
}
handleTokenMode的实现相对简单,核心是“消费token”:
java复制private Object handleTokenMode(ProceedingJoinPoint joinPoint, Idempotent idempotent,
String fullKey) throws Throwable {
// token模式下,fullKey就是token本身,切面需要做“检查并删除”
boolean consumed = repository.consumeToken(fullKey);
if (!consumed) {
throw new IdempotentException("token无效或已被使用,请刷新后重试");
}
return joinPoint.proceed();
}
consumeToken在IdempotentRepository接口中增加一个方法,Redis实现用Lua:
lua复制local token = redis.call('get', KEYS[1])
if token and token ~= '' then
redis.call('del', KEYS[1])
return 1
else
return 0
end
4.5 自动装配与使用方的一行接入
自动配置类的核心就是注册两个Bean:存储层和切面。注意用@ConditionalOnMissingBean,给使用方留出覆盖的空间:
java复制@Configuration
@EnableConfigurationProperties(IdempotentProperties.class)
@ConditionalOnClass({StringRedisTemplate.class, JoinPoint.class})
public class IdempotentAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public IdempotentRepository idempotentRepository(StringRedisTemplate redisTemplate,
IdempotentProperties properties) {
IdempotentRepository redisRepo = new RedisIdempotentRepository(redisTemplate);
if (properties.isFallbackEnabled()) {
return new DegradableIdempotentRepository(redisRepo, new LocalIdempotentRepository());
}
return redisRepo;
}
@Bean
@ConditionalOnMissingBean
public IdempotentAspect idempotentAspect(IdempotentRepository repository,
IdempotentProperties properties) {
return new IdempotentAspect(repository, properties);
}
}
Spring Boot 2.7之前的项目用META-INF/spring.factories注册自动配置类:
code复制org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.idempotent.IdempotentAutoConfiguration
Spring Boot 3.x要使用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,内容只有一行类全名。两种方式对应不同Spring Boot版本,这个细节很容易踩坑。
使用方的最小接入只需要三步:
- 在
pom.xml引入依赖。 - 在启动类上加
@EnableIdempotent注解,这个注解实际做的就是@Import(IdempotentAutoConfiguration.class)。 - 在需要防重的接口方法上加上
@Idempotent注解。
4.6 本地兜底与故障降级:Redis挂了业务不能挂
设计过程中考虑过一个问题:如果Redis本身挂了,组件的tryAcquire调用会抛出连接异常,导致所有被注解保护的接口全部不可用。对于下单、支付这种核心接口来说,这是不可接受的——防重组件不应该成为业务可用性的瓶颈。
我给组件增加了一个降级开关,默认关闭,开启后当Redis异常时会自动切换到本地内存实现。本地实现基于ConcurrentHashMap加定时清理,只能做到单机级别互斥,但至少保证Redis故障时业务不中断,同时输出告警日志让运维感知。
java复制public class DegradableIdempotentRepository implements IdempotentRepository {
private final IdempotentRepository primary;
private final IdempotentRepository fallback;
private final AtomicBoolean degraded = new AtomicBoolean(false);
public DegradableIdempotentRepository(IdempotentRepository primary, IdempotentRepository fallback) {
this.primary = primary;
this.fallback = fallback;
}
@Override
public boolean tryAcquire(String key, String requestId, long expire, TimeUnit timeUnit) {
IdempotentRepository target = degraded.get() ? fallback : primary;
try {
boolean result = target.tryAcquire(key, requestId, expire, timeUnit);
if (degraded.get()) {
degraded.set(false);
}
return result;
} catch (Exception e) {
if (!degraded.compareAndSet(false, true)) {
// 已处于降级状态,避免重复日志
} else {
// 此处应该走日志告警
System.err.println("[idempotent] Redis异常,切换到本地降级模式: " + e.getMessage());
}
return fallback.tryAcquire(key, requestId, expire, timeUnit);
}
}
// 其他方法类似,不再一一列出
}
降级逻辑用AtomicBoolean做状态标记,避免Redis抖动时反复切换。需要注意的是,降级模式只适合“容忍单机防重”的场景,如果业务要求严格全局幂等,降级带来的风险比直接拒绝更大,这个开关要由使用方根据业务权衡决定。
还有一点我觉得值得说一下:Redis高可用很重要。即使组件做了降级,也不建议长期在降级模式下运行。生产环境用户量大的时候,必须确保Redis是主从+哨兵或者Cluster模式,而不是单机。组件本身的健壮性建立在Redis的可用性之上。
5. SpEL表达式解析与参数名获取
幂等键的SpEL解析是切面里最容易被忽略的部分,坑也是最隐蔽的。resolveKey的实现如下:
java复制private String resolveKey(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
String spEL = idempotent.key();
if (StringUtils.hasText(spEL)) {
// 方法参数的SpEL解析
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
String[] paramNames = new DefaultParameterNameDiscoverer().getParameterNames(method);
if (paramNames == null) {
throw new IdempotentException("无法获取参数名,请在编译时开启 -parameters 参数");
}
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
// 同时支持 #p0、#p1 风格
context.setVariable("p" + i, args[i]);
}
Expression expression = new SpelExpressionParser().parseExpression(spEL);
Object value = expression.getValue(context);
if (value == null) {
throw new IdempotentException
