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

分布式环境下如何防止重复提交?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 {

    @Resource
    private 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表达式生成唯一Key
        String 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]);
        }

        // 注入缓存中的用户Id
        Long 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+并发验证防重效果

相关文章:

  • 【基础3】快速排序
  • 嵌入式科普(34)通过对比看透DMA的本质
  • 第四十一:Axios 模型的 get ,post请求
  • C++----异常
  • Python数据可视化
  • PX4中的uavcan进程
  • python全栈-Linux基础
  • 策略模式处理
  • AI工具:deepseek+阶跃视频,生成好玩的视频
  • 教育强国建设“三年行动计划“分析
  • 如何快速上手RabbitMQ 笔记250304
  • docker-compose安装anythingLLM
  • 2000-2020年各省地方财政一般预算支出数据
  • 鸿蒙5.0实战案例:基于ArkUI的透明页面效果
  • c++中什么时候应该使用extern关键字?
  • 全栈(Java+vue)实习面试题(含答案)
  • Django项目实战
  • 基于opencv消除图片马赛克
  • 项目工坊|Python驱动淘宝信息爬虫
  • Python和PyQt5写的密码记录工具
  • 手机网站优化/打开百度一下你就知道
  • 长沙网站推广智投未来/网站seo关键词
  • 海门网站建设/网络营销案例分析论文
  • 西宁做网站的公司/怎么开网站
  • 36 氪 网站如何优化/精准客户软件
  • 企业网站的制作周期/cps游戏推广平台