《一行注解解决重复提交:Spring Boot 接口幂等实战》
一、问题背景
高并发或前端重复点击时,「支付、下单、抢券」类接口极易产生重复数据或资损。传统做法在业务代码里加锁、校验、状态机,既繁琐又容易遗漏。
本文给出“一个注解 + 30 行 AOP”的通用方案,支持:
- 任意维度幂等键(用户+订单号、手机号+活动 ID …)
- 本地 / 分布式锁一键切换
- 业务零侵入,RT < 1 ms
二、最终效果
@PostMapping("/pay")
@NoRepeatSubmit(keySpEL = "#userId + ':' + #order.id", ttl = 10)
public ApiResp<Void> pay(@RequestBody Order order) {
return ApiResp.success(payService.pay(order));
}
第二次点击直接返回 "请勿重复提交"
,10 秒内同一 key 拒绝再次进入业务逻辑。
三、实现步骤
- 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
/** 幂等键 SpEL;空串时使用默认规则 */
String keySpEL() default "";
/** 锁存活时间(秒) */
int ttl() default 5;
/** key 前缀 */
String prefix() default "repeat:";
}
- AOP 切面(核心 30 行)
@Aspect
@Component
@RequiredArgsConstructor
public class NoRepeatSubmitAspect {
private final RedissonClient redisson; // 可选:分布式锁
private final Cache localCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10))
.build();
private final SpelExpressionParser parser = new SpelExpressionParser();
private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
@Around("@annotation(submit)")
public Object around(ProceedingJoinPoint jp, NoRepeatSubmit submit) throws Throwable {
String key = buildKey(jp, submit);
RLock lock = redisson.getLock(key);
boolean locked = lock.tryLock(0, submit.ttl(), TimeUnit.SECONDS);
if (!locked) throw new BizException("请勿重复提交");
try {
return jp.proceed();
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
}
private String buildKey(ProceedingJoinPoint jp, NoRepeatSubmit submit) {
Method method = ((MethodSignature) jp.getSignature()).getMethod();
EvaluationContext ctx = new StandardEvaluationContext();
// 注入常用变量
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Object[] args = jp.getArgs();
String[] names = nameDiscoverer.getParameterNames(method);
for (int i = 0; i < names.length; i++) ctx.setVariable(names[i], args[i]);
ctx.setVariable("request", request);
ctx.setVariable("userId", StpUtil.getLoginIdAsString());
ctx.setVariable("methodName", method.toGenericString());
ctx.setVariable("argsMD5", DigestUtils.md5DigestAsHex(JSON.toJSONBytes(args)));
String spEL = StringUtils.hasText(submit.keySpEL()) ? submit.keySpEL()
: "#userId + ':' + #methodName + ':' + #argsMD5";
return submit.prefix() + parser.parseExpression(spEL).getValue(ctx, String.class);
}
}
- 依赖坐标(最新正式版)
<!-- 分布式锁 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.32.0</version>
</dependency>
<!-- 本地缓存(可选) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
四、常见 SpEL 示例
场景 | keySpEL 写法 |
---|---|
用户+订单号 | "#userId + ':' + #order.id" |
手机号+活动 | "#request.getParameter('mobile') + ':' + #actId" |
默认规则 | 留空即可(用户+方法+参数 MD5) |
五、 这个注解具体是如何防止重复请求的?
1 一次请求的完整链路
- 浏览器/前端调用接口
- Spring 拦截器 → AOP 切面
NoRepeatSubmitAspect
- 切面 计算幂等键 key
• 默认:repeat:{userId}:{方法签名}:{参数MD5}
• 自定义:repeat:{自定义SpEL值}
- 尝试拿锁
• 分布式:RedissonRLock.tryLock(0, ttl, SECONDS)
• 本地:Caffeinecache.getIfPresent(key)
- 拿锁成功 →
pjp.proceed()
→ 执行业务 → 返回正常结果 - 拿锁失败 → 抛
BizException("请勿重复提交")
,业务方法根本不会被执行
2 锁的粒度与隔离级别
维度 | 说明 |
---|---|
锁名称 | repeat:{业务唯一key} ,不同业务/参数/用户天然隔离 |
锁类型 | RLock (Redisson 分布式)或本地 Cache (单机) |
锁超时 | 注解 @NoRepeatSubmit(ttl = 5) 指定,5 秒后自动过期 |
锁竞争 | 无阻塞,tryLock(0, …) 立即返回失败,避免排队 |
3 为什么能防重复请求?
幂等键唯一
同一用户、同一接口、同一参数 → 同一 key → 同一锁。锁生命周期短
只保护 本次请求窗口,防止“连点”或网络重发,不会长期占用。无侵入
业务方法看不到任何锁代码,异常在切面层就返回,不会走到 Service/DAO。可横向扩展
Redisson 锁基于 Redis,集群部署时多台应用共享同一把分布式锁,水平扩容也能防重。
4 时序图(文字版)
前端 切面(锁) 业务方法
───► 请求1 ──► 拿锁成功 ──► 执行Service
├─► 请求2 ──► 拿锁失败 ──► 直接返回错误
└─► 5s后锁自动过期
5 什么时候会失效?
• 幂等键计算错误:SpEL 写成了常量,导致不同请求同一 key;
• ttl 设置过长:业务正常需要 8 s,锁 5 s 提前释放,可能产生并发;
• Redis 故障:分布式锁降级为本地锁,多实例场景下可能出现“漏网之鱼”。
六、结语
一行 @NoRepeatSubmit
,让 Spring Boot 接口自带“防抖”能力。
“幂等键即锁名,切面即守门员,锁成功进门办事,锁失败直接拒客。”
把复杂留给自己,把简单留给业务方 —— 这才是优雅编码。