当前位置: 首页 > 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.dtcms.com/a/288302.html

相关文章:

  • [硬件电路-40]:从物理世界到数字软件,信号处理的共通性
  • java基础(day11)
  • 突破 MySQL 性能瓶颈:死锁分析 + 慢查询诊断 + 海量数据比对实战
  • Redis布隆过滤器的学习(六)
  • 财务数字化——解读财务指标及财务分析的基本步骤与方法【附全文阅读】
  • 基于LSTM的时间序列到时间序列的回归模拟
  • 06-人机共生:Prompt之外的思考
  • Linux Shell 命令 + 项目场景
  • windows11下基于docker单机部署ceph集群
  • 同步队列阻塞器AQS的执行流程,案例图
  • 张量交换维度(转置),其实是交换了元素的排列顺序
  • lvs集群技术(Linux virual server)
  • MinIO深度解析:从核心特性到Spring Boot实战集成
  • 笔试大题20分值(用两个栈实现队列)
  • 基于densenet网络创新的肺癌识别研究
  • lvs 集群技术
  • 渗透高级----第四章:XSS进阶
  • 如何优雅调整Doris key顺序
  • linux--------------------BlockQueue的生产者消费模型
  • 【Docker基础】深入解析Docker-compose核心配置:Services服务配置详解
  • Gitee 提交信息的规范
  • 算法基础知识总结
  • GoC 图片指令
  • BeanFactory 和 FactoryBean 的区别
  • 架构探索笔记【1】
  • 如何快速学习一门新技术
  • 实用的文件和文件夹批量重命名工具
  • 手撕Spring底层系列之:注解驱动的魔力与实现内幕
  • 【Linux】重生之从零开始学习运维之Nginx
  • 【服务器与部署 14】消息队列部署:RabbitMQ、Kafka生产环境搭建指南