欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 教育 > 锐评 > 分布式环境下如何防止重复提交?AOP+Redis设计高可用的防重提交组件

分布式环境下如何防止重复提交?AOP+Redis设计高可用的防重提交组件

2025/5/11 5:12:31 来源:https://blog.csdn.net/Zyw907155124/article/details/146006693  浏览:    关键词:分布式环境下如何防止重复提交?AOP+Redis设计高可用的防重提交组件

文章目录

    • 一、问题场景还原
    • 二、解决方案设计
      • 2.1 技术选型对比
      • 2.2 核心实现逻辑
      • 2.3 SpEL表达式
    • 三、代码实现(SpringBoot 3.X + MyBatisPlus + AOP + Redis)
      • 3.1 添加核心依赖
      • 3.2 定义防重注解
      • 3.3 实现AOP切面
      • 3.4 业务层使用示例
        • 场景1:用户注册(仅依赖 手机号)
        • 场景2:用户提交当日运动计划(仅依赖 `userId` )
        • 场景3:用户提交订单(组合 `userId` 和参数)
    • 四、方案优势
    • 五、注意事项

引言:本文针对SpringBoot+MyBatisPlus项目中重复提交问题,提出基于动态Key+分布式锁的通用解决方案。通过AOP切面实现防重逻辑与业务解耦,支持灵活配置唯一键规则,日均节省无效请求30%+,适用于注册、下单、评论等高频场景。

一、问题场景还原

典型问题场景

  1. 用户注册接口连续点击
  2. 运动计划重复提交
  3. 订单创建高频请求
  4. 网络延迟导致连续触发多次请求
  5. 服务端处理耗时过长,前序请求未完成时新请求到达
  6. 恶意用户通过脚本高频调用接口

致命后果:数据库产生重复用户记录、库存超卖、积分重复发放等生产事故。

传统方案缺陷

  • 数据库唯一索引:无法应对动态组合键
  • 前端防抖:无法防御绕过浏览器的请求
  • synchronized锁:分布式环境失效

二、解决方案设计

2.1 技术选型对比

方案适用场景缺点
前端按钮防抖简单场景无法防御脚本攻击
数据库唯一索引写操作场景增加数据库压力
Token机制表单提交需要前后端配合
synchronized锁所有写接口分布式环境失效
Redis+AOP所有写接口需处理Redis故障

最终方案:采用Redis作分布式锁,AOP实现业务零侵入,支持动态Key生成

2.2 核心实现逻辑

技术栈组合

  • Spring AOP:实现业务无侵入
  • Redis分布式锁:保证集群环境一致性
  • SpEL表达式:支持动态Key生成

核心流程图

用户 AOP切面 Redis 业务代码 发起请求 生成唯一Key(用户ID+参数MD5) 返回是否存在 返回"请勿重复提交" 设置Key(5秒过期) 执行核心逻辑 返回结果 删除Key(仅当成功时) alt [Key已存在] [Key不存在] 用户 AOP切面 Redis 业务代码

2.3 SpEL表达式

SpEL(Spring Expression Language)是Spring框架的核心技术之一,是一种功能强大的表达式语言,支持在运行时动态查询和操作对象图。其语法简洁灵活,与Spring生态系统深度集成,广泛应用于配置、数据绑定、方法调用等场景。

SpEL通过灵活的语法和强大的运行时能力,显著提升了Spring应用的动态性和可配置性。其核心优势包括:

  • 简化复杂操作:通过表达式替代硬编码,减少冗余代码。
  • 动态适配:在配置、权限、数据绑定等场景中实现运行时决策。
  • 安全性平衡:通过上下文控制兼顾功能与安全。

三、代码实现(SpringBoot 3.X + MyBatisPlus + AOP + Redis)

3.1 添加核心依赖

<!-- 必须组件 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

3.2 定义防重注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicate {/*** 唯一Key的生成策略参数(支持SpEL表达式)* 示例:从参数中取手机号 -> #request.mobile* 从用户ID生成 -> #userId*/String key() default "";/*** 锁过期时间(默认3秒)*/int expire() default 3;/*** 错误提示信息*/String message() default "请勿重复提交";
}

3.3 实现AOP切面

import com.example.demo.annotation.PreventDuplicate;
import com.example.demo.config.result.ResultCode;
import com.example.demo.exception.base.BaseException;
import com.example.demo.uitls.UserHelper;
import jakarta.annotation.Resource;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.core.ParameterNameDiscoverer;import java.lang.reflect.Method;
import java.util.Objects;
import java.util.concurrent.TimeUnit;/*** PreventDuplicateAspect : 防止重复提交切面** @author zyw* @create 2025-03-03  15:50*/@Aspect
@Component
public class PreventDuplicateAspect {@Resourceprivate StringRedisTemplate stringRedisTemplate;/***  获取方法参数名*/private static final ParameterNameDiscoverer PARAM_NAME_DISCOVERER = new DefaultParameterNameDiscoverer();/*** 获取方法参数名* @param method* @return*/private String[] getParameterNames(Method method) {return PARAM_NAME_DISCOVERER.getParameterNames(method);}@Around("@annotation(prevent)")public Object checkDuplicate(ProceedingJoinPoint joinPoint, PreventDuplicate prevent) throws Throwable {// 1. 解析SpEL表达式生成唯一KeyString uniqueKey = generateUniqueKey(joinPoint, prevent.key());String lockKey = "prevent:submit:" + uniqueKey;// 2. 尝试获取分布式锁Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", prevent.expire(), TimeUnit.SECONDS);if (Boolean.FALSE.equals(success)) {throw new BaseException(ResultCode.REPEAT_SUBMIT, prevent.message());}try {// 3. 执行业务逻辑return joinPoint.proceed();} finally {// 4. 业务完成后删除Key(根据业务需求决定是否立即释放)stringRedisTemplate.delete(lockKey);}}/*** 解析SpEL表达式生成动态Key* @param joinPoint* @param keyExpression* @return*/private String generateUniqueKey(ProceedingJoinPoint joinPoint, String keyExpression) {// 1. 如果表达式为空,默认生成类+方法+参数哈希的Key(确保基本唯一性)if (keyExpression == null || keyExpression.isEmpty()) {return defaultKey(joinPoint);}// 2. 获取方法签名和参数值MethodSignature signature = (MethodSignature) joinPoint.getSignature();Object[] args = joinPoint.getArgs();String[] parameterNames = getParameterNames(signature.getMethod());// 3. 创建SpEL解析上下文,绑定参数名和值EvaluationContext context = new StandardEvaluationContext();for (int i = 0; i < args.length; i++) {context.setVariable(parameterNames[i], args[i]);}// 注入缓存中的用户IdLong userId = UserHelper.getLoginUserId();// 绑定到上下文变量context.setVariable("userId", userId);// 4. 解析表达式SpelExpressionParser parser = new SpelExpressionParser();Expression expression = parser.parseExpression(keyExpression);Object value = expression.getValue(context);// 5. 确保解析结果非空if (value == null) {throw new IllegalArgumentException("SpEL表达式解析结果为空: " + keyExpression);}// 6. 组合类名+方法名+表达式值生成唯一Key(避免不同接口冲突)String className = joinPoint.getTarget().getClass().getName();String methodName = signature.getMethod().getName();return String.format("lock:%s:%s:%s", className, methodName, value);}/***  默认Key生成策略:类名+方法名+参数哈希* @param joinPoint* @return*/private String defaultKey(ProceedingJoinPoint joinPoint) {MethodSignature signature = (MethodSignature) joinPoint.getSignature();String className = joinPoint.getTarget().getClass().getName();String methodName = signature.getMethod().getName();int paramsHash = Objects.hash(joinPoint.getArgs());return String.format("lock:%s:%s:%d", className, methodName, paramsHash);}
}

3.4 业务层使用示例

场景1:用户注册(仅依赖 手机号)
    @PreventDuplicate(key = "#dto.phone", expire = 10, message = "该手机号正在注册中,请勿重复提交")public Boolean register(RegistrationDto dto) {Long loginUserId = UserHelper.getLoginUserId();log.info("当前账号id:{},开始注册", loginUserId);// 模拟业务执行try {Thread.sleep(3000);}catch (Exception e){e.getStackTrace();}log.info("当前账号id:{},注册成功", loginUserId);return true;}
场景2:用户提交当日运动计划(仅依赖 userId
    @PreventDuplicate(key = "#userId", expire = 10, message = "该账号正在评论中,请勿重复评论")public Boolean submitComment(CommentDto dto) {Long loginUserId = UserHelper.getLoginUserId();log.info("当前账号id:{},开始评论", loginUserId);// 模拟业务执行try {Thread.sleep(3000);}catch (Exception e){e.getStackTrace();}log.info("当前账号id:{},评论成功", loginUserId);return true;}
场景3:用户提交订单(组合 userId 和参数)
    @PreventDuplicate(key = "#userId + '-' + #dto.productId", expire = 10, message = "该商品订单正在生成中,请勿重复提交")public Boolean submitOrder(OrderDto dto) {Long loginUserId = UserHelper.getLoginUserId();log.info("当前账号id:{},开始提交订单", loginUserId);// 模拟业务执行try {Thread.sleep(3000);}catch (Exception e){e.getStackTrace();}log.info("当前账号id:{},订单提交成功", loginUserId);return true;}

在这里插入图片描述

四、方案优势

  1. 动态Key生成:支持用户ID、手机号、设备ID等多种组合方式
  2. 分布式生效:Redis集群保证多实例环境下的防重一致性
  3. 性能优异:Redis操作耗时<3ms,远低于数据库唯一约束方案
  4. 灵活配置:通过interval参数控制防重时间窗口(秒级精度)
  5. 故障容错:Redis宕机时可通过@ConditionalOnBean降级处理
维度本方案数据库唯一索引本地锁
分布式支持
动态Key
性能影响<1ms依赖索引性能纳秒级
代码侵入性
异常处理自动释放锁依赖事务回滚易死锁

五、注意事项

  1. Key设计原则:建议包含「业务类型+唯一标识」,如REGISTER:13800138000
  2. 过期时间:根据业务耗时设置,建议「平均处理时间*3」
  3. 异常处理:在finally块中根据业务结果决定是否立即删除Key
  4. 压力测试:建议用JMeter模拟1000+并发验证防重效果

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词