当前位置: 首页 > news >正文

《一行注解解决重复提交:Spring Boot 接口幂等实战》

《一行注解解决重复提交: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 拒绝再次进入业务逻辑。

三、实现步骤
  1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {/** 幂等键 SpEL;空串时使用默认规则 */String keySpEL() default "";/** 锁存活时间(秒) */int ttl() default 5;/** key 前缀 */String prefix() default "repeat:";
}
  1. 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);}
}
  1. 依赖坐标(最新正式版)
<!-- 分布式锁 -->
<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 一次请求的完整链路
  1. 浏览器/前端调用接口
  2. Spring 拦截器 → AOP 切面 NoRepeatSubmitAspect
  3. 切面 计算幂等键 key
    • 默认:repeat:{userId}:{方法签名}:{参数MD5}
    • 自定义:repeat:{自定义SpEL值}
  4. 尝试拿锁
    • 分布式:Redisson RLock.tryLock(0, ttl, SECONDS)
    • 本地:Caffeine cache.getIfPresent(key)
  5. 拿锁成功 → pjp.proceed() → 执行业务 → 返回正常结果
  6. 拿锁失败 → 抛 BizException("请勿重复提交")业务方法根本不会被执行
2 锁的粒度与隔离级别
维度说明
锁名称repeat:{业务唯一key},不同业务/参数/用户天然隔离
锁类型RLock(Redisson 分布式)或本地 Cache(单机)
锁超时注解 @NoRepeatSubmit(ttl = 5) 指定,5 秒后自动过期
锁竞争无阻塞,tryLock(0, …) 立即返回失败,避免排队
3 为什么能防重复请求?
  1. 幂等键唯一
    同一用户、同一接口、同一参数 → 同一 key → 同一锁。

  2. 锁生命周期短
    只保护 本次请求窗口,防止“连点”或网络重发,不会长期占用。

  3. 无侵入
    业务方法看不到任何锁代码,异常在切面层就返回,不会走到 Service/DAO

  4. 可横向扩展
    Redisson 锁基于 Redis,集群部署时多台应用共享同一把分布式锁,水平扩容也能防重

4 时序图(文字版)
前端          切面(锁)              业务方法
───► 请求1 ──► 拿锁成功 ──► 执行Service├─► 请求2 ──► 拿锁失败 ──► 直接返回错误└─► 5s后锁自动过期
5 什么时候会失效?

幂等键计算错误:SpEL 写成了常量,导致不同请求同一 key;
ttl 设置过长:业务正常需要 8 s,锁 5 s 提前释放,可能产生并发;
Redis 故障:分布式锁降级为本地锁,多实例场景下可能出现“漏网之鱼”。

六、结语

一行 @NoRepeatSubmit,让 Spring Boot 接口自带“防抖”能力。
“幂等键即锁名,切面即守门员,锁成功进门办事,锁失败直接拒客。”

把复杂留给自己,把简单留给业务方 —— 这才是优雅编码。

http://www.lryc.cn/news/593202.html

相关文章:

  • 深入理解设计模式:策略模式的艺术与实践
  • 在非Spring Boot的Spring项目中使用Lock4j
  • 用graphviz画一个关系图
  • 云服务器磁盘IO性能优化的测试与配置方法
  • 2025年7月19日,二维矩阵
  • 智能制造——解读39页汽车行业数字化工厂解决方案【附全文阅读】
  • 异世界历险之数据结构世界(二叉树-leetcode)
  • 国产电科金仓数据库:融合进化,智领未来
  • 【Unity3D实例-功能-移动】角色移动-通过WSAD(Rigidbody方式)
  • 架构探索笔记【1】
  • JavaScript空值安全深度指南
  • windows内核研究(驱动开发之内核编程)
  • Java无服务架构新范式:Spring Native与AWS Lambda冷启动深度优化
  • 【小沐学GIS】基于Rust绘制三维数字地球Earth(Rust、OpenGL、GIS)
  • C++STL系列之概述
  • OpenCV 官翻5 - 机器学习
  • 【web安全】万能密码
  • 物联网系统中的可视化大屏定义
  • UGUI 性能优化系列:第三篇——渲染与像素填充率优化
  • 小明记账簿焕新记:从单色到多彩的主题进化之路
  • 【Android】ListView与RecyclerView的基础使用
  • 安全隔离新选择:SiLM5768L系列 - 集成互锁功能的高速六通道数字隔离器
  • 从随机数值到特征检测器的学习与更新
  • 【Linux驱动-快速回顾】简单了解一下PinCtrl子系统:设备树如何被接解析与匹配
  • 大模型 Function Call 的实现步骤及示例详解
  • SpringBoot 3.0 挥别 spring.factories,拥抱云原生新纪元
  • Java机考题:815. 公交路线 图论BFS
  • 猎板:在 5G 与 AI 时代,印制线路板如何满足高性能需求
  • SQL Server和PostgreSQL填充因子
  • 数据结构与算法之美:拓扑排序