在分布式系统和高并发场景下,重复请求是一个常见且令人头痛的问题。无论是用户频繁点击提交按钮,网络超时导致的重试,还是消息队列的重复投递,都可能引发数据不一致或资源重复消耗的严重后果。
技术栈:Spring Boot 3.0 + JDK 17 + Spring AOP + Redis + Lua + SpEL
核心目标:构建一个开箱即用、生产就绪、注解驱动的通用幂等与防重中间件。
一、需求背景与痛点分析
1.1 常见痛点场景
- 用户多次点击提交:例如用户快速点击“提交订单”按钮,导致后端生成多笔内容相同的订单。
- 网络超时重试:客户端因超时重复发起请求,或第三方回调(如支付回调)因网络问题被重复触发。
- 消息重复消费:消息队列(如Kafka, RabbitMQ)因At-least-once投递语义导致的消息重复,引发账户余额被多次扣减。
- 业务数据重复:如考试报名场景,同一考生重复提交导致数据库出现多条相同身份证号的记录。
以上场景都违背了 幂等性(Idempotency) 的核心原则:对于同一操作(或使用相同参数的请求),无论执行一次还是多次,其结果应该是一致的。
1.2 现有解决方案的局限性
在动手造轮子之前,我们不妨先看看常见的解决方案及其缺陷。

- 数据库唯一索引:简单,但仅适用于防数据重复写入的场景,对纯查询或更新操作无效,且无法应对高并发下的请求“穿透”。
- 前端按钮防抖/禁用:体验友好,但极度不可靠,请求可被绕过(如直接调用API),无法作为核心防线。
- Token机制:需要前后端协同配合,流程相对复杂,增加了系统设计的耦合度。
- 手动操作Redis:虽然有效,但需要在业务代码中大量编写重复的
setnx或set ... NX EX逻辑,代码侵入性强,维护成本高。
正因如此,一个基于 Spring AOP 和 Redis,以注解形式提供、对业务代码无侵入的通用防重幂等中间件显得尤为必要。这不仅是一种最佳实践,更是提升后端服务鲁棒性的重要手段。
二、整体架构与设计原理
2.1 核心处理流程
整个中间件的核心逻辑围绕一个自定义注解@Idempotent展开。当请求到达时,其处理流程清晰明了:

- 请求拦截:AOP切面拦截所有被
@Idempotent注解标记的方法。
- 唯一键生成:解析注解中的SpEL(Spring Expression Language)表达式,结合方法参数动态生成一个全局唯一的业务键(Key)。
- 原子性校验:向Redis发起
SET key value EX expire NX命令。这是一个原子操作,仅在Key不存在时设置成功并附加过期时间。
- 逻辑执行:
- 若设置成功:表明是首次请求,放行执行业务逻辑。
- 若设置失败:表明是重复请求,根据配置的策略(如抛出异常)直接拒绝。
整个过程在毫秒级内完成,将防重压力从数据库转移到了高性能的Redis,实现了无数据库压力的并发控制。
2.2 模块化组件设计
为了实现高内聚、低耦合和良好的可扩展性,我们将系统拆分为以下核心组件,各司其职。

这种设计使得每个组件职责单一,例如存储层可以轻松地从Redis替换为其他分布式协调服务(如ZooKeeper),而处理策略也可以根据业务需求自定义。
三、核心代码实现解析
3.1 定义幂等注解 (@Idempotent)
注解是使用的入口,定义了幂等规则。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等唯一键,支持SpEL表达式,例如: 'order:' + #userId
*/
String key();
/**
* 键的过期时间(秒),默认5分钟
*/
int expire() default 300;
/**
* 存储的值,默认”1“
*/
String value() default "1";
/**
* 重复请求处理模式
*/
Mode mode() default Mode.REJECT;
enum Mode {
// 拒绝并抛出异常
REJECT,
// 返回缓存的结果 (需配合结果缓存使用)
RETURN_CACHE
}
}
key属性是整个设计的精髓,它利用 Spring SpEL 的动态能力,允许开发者灵活地组合方法参数、常量来构造唯一标识。
3.2 AOP切面逻辑 (IdempotentAspect)
切面是驱动整个流程执行的引擎。
@Aspect
public class IdempotentAspect {
private final IdempotentService idempotentService;
private final ExpressionParser parser = new SpelExpressionParser();
private final StandardReflectionParameterNameDiscoverer discoverer =
new StandardReflectionParameterNameDiscoverer();
public IdempotentAspect(IdempotentService idempotentService) {
this.idempotentService = idempotentService;
}
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = discoverer.getParameterNames(signature.getMethod());
Object[] args = joinPoint.getArgs();
// 1. 解析 SpEL 表达式,生成唯一Key
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < args.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
String key = parser.parseExpression(idempotent.key()).getValue(context, String.class);
// 2. 尝试获取幂等锁(基于Redis SET NX EX)
if (!idempotentService.tryLock(key, idempotent.expire())) {
// 3. 根据模式处理重复请求
if (idempotent.mode() == Idempotent.Mode.REJECT) {
throw new IllegalStateException("重复请求,请勿重复提交");
}
// TODO: Mode.RETURN_CACHE 模式(需实现结果缓存)
}
// 4. 执行业务方法
return joinPoint.proceed();
}
}
3.3 可扩展的失败处理器
为了应对不同的业务需求,我们设计了处理器接口,默认实现为空(由AOP统一抛异常),你可以自定义,例如记录日志、发送告警或返回特定格式的响应。
public interface IdempotentFailureHandler {
void handle(String key, Method method);
}
@Component
public class DefaultIdempotentFailureHandler implements IdempotentFailureHandler {
@Override
public void handle(String key, Method method) {
// 默认实现:什么也不做,交由AOP抛出异常。
// 可在此扩展:记录监控日志、发送钉钉/企业微信告警等。
}
}
四、实战应用案例
案例1:下单接口防重复提交
@PostMapping("/order")
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
public Result<String> createOrder(@RequestParam String userId, @RequestParam String goodsId) {
// 业务下单逻辑
orderService.create(userId, goodsId);
return Result.success("下单成功");
}
效果:同一用户(userId)对同一商品(goodsId)在5分钟(expire=300)内,只有第一次请求会成功创建订单,后续重复请求会被立即拒绝。
案例2:考生报名防身份证号重复
@PostMapping("/enroll")
@Idempotent(key = "'enroll:' + #candidate.idCard", expire = 300)
public Result<Void> enroll(@RequestBody Candidate candidate) {
// 防止同一身份证重复报名
enrollmentService.save(candidate);
return Result.OK();
}
// 考生DTO
public class Candidate {
private String name;
private String idCard; // 身份证号作为幂等键的一部分
private String phone;
}
效果:生成的Key形如enroll:11010119900307XXXX。系统确保了同一身份证号在5分钟内仅能成功报名一次。
案例3:秒杀场景用户维度限流
@PostMapping("/seckill")
@Idempotent(key = "'seckill:' + #userId + ':' + #goodsId", expire = 60)
public Result<String> seckill(@RequestParam String userId, @RequestParam Long goodsId) {
return seckillService.execute(userId, goodsId);
}
效果:在秒杀活动的高峰期,即使用户疯狂点击或前端因延迟重复发送请求,基于 Redis 的原子操作也能保证同一用户对同一秒杀商品在1分钟内仅能成功参与一次,有效防止库存超卖和请求洪峰。
五、生产环境考量:性能与可靠性
- 高性能:核心校验基于Redis的
SET NX EX原子命令,单节点QPS可达5万以上,完全满足高并发场景。
- 强一致性:利用Redis的分布式锁语义,在集群环境下也能保证同一Key幂等性判断的一致性。
- 安全可靠:Key通过受控的SpEL上下文生成,避免了SQL注入类的风险。同时,所有Key均设置自动过期时间,无需手动清理,无内存泄漏之忧。
- 资源隔离:将防重压力与业务数据库解耦,利用独立的 Redis 集群承载,保障核心业务数据库的稳定。
六、如何快速集成使用?
该中间件已封装为Spring Boot Starter,只需简单两步即可引入你的项目。
- 引入Maven依赖
<!-- https://mvnrepository.com/artifact/io.github.songrongzhen/once-kit-spring-boot-starter -->
<dependency>
<groupId>io.github.songrongzhen</groupId>
<artifactId>once-kit-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
- 在需要防重的方法上添加注解
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
配置好项目中的Redis连接后,你的接口就自动拥有了防重与幂等能力。
总结与展望
本文从实际痛点出发,深入剖析了如何利用Spring Boot 3、AOP和Redis构建一个生产级的通用幂等防重中间件。通过注解驱动和SpEL动态表达式,它实现了业务逻辑与防重逻辑的彻底解耦,兼具灵活性、高性能和易用性。
这个项目本身也是一个很好的 开源实战 案例,展示了如何设计一个可扩展的Spring Boot Starter。你可以在此基础上,继续探索和实现更多高级特性,如结合本地缓存提升性能、增加更丰富的数据统计和监控等,使其更加强大。
本文涉及的工具完整代码已开源。欢迎在 云栈社区 交流更多关于系统架构、高并发设计以及中间件开发的最佳实践。
