文章目录
- 一、问题场景还原
- 二、解决方案设计
- 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%+,适用于注册、下单、评论等高频场景。
一、问题场景还原
典型问题场景:
- 用户注册接口连续点击
- 运动计划重复提交
- 订单创建高频请求
- 网络延迟导致连续触发多次请求
- 服务端处理耗时过长,前序请求未完成时新请求到达
- 恶意用户通过脚本高频调用接口
致命后果:数据库产生重复用户记录、库存超卖、积分重复发放等生产事故。
传统方案缺陷:
- 数据库唯一索引:无法应对动态组合键
- 前端防抖:无法防御绕过浏览器的请求
- synchronized锁:分布式环境失效
二、解决方案设计
2.1 技术选型对比
方案 | 适用场景 | 缺点 |
---|---|---|
前端按钮防抖 | 简单场景 | 无法防御脚本攻击 |
数据库唯一索引 | 写操作场景 | 增加数据库压力 |
Token机制 | 表单提交 | 需要前后端配合 |
synchronized锁 | 所有写接口 | 分布式环境失效 |
Redis+AOP | 所有写接口 | 需处理Redis故障 |
最终方案:采用Redis作分布式锁,AOP实现业务零侵入,支持动态Key生成
2.2 核心实现逻辑
技术栈组合:
- Spring AOP:实现业务无侵入
- Redis分布式锁:保证集群环境一致性
- SpEL表达式:支持动态Key生成
核心流程图:
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;}
四、方案优势
- 动态Key生成:支持用户ID、手机号、设备ID等多种组合方式
- 分布式生效:Redis集群保证多实例环境下的防重一致性
- 性能优异:Redis操作耗时<3ms,远低于数据库唯一约束方案
- 灵活配置:通过interval参数控制防重时间窗口(秒级精度)
- 故障容错:Redis宕机时可通过@ConditionalOnBean降级处理
维度 | 本方案 | 数据库唯一索引 | 本地锁 |
---|---|---|---|
分布式支持 | ✅ | ✅ | ❌ |
动态Key | ✅ | ❌ | ✅ |
性能影响 | <1ms | 依赖索引性能 | 纳秒级 |
代码侵入性 | 无 | 高 | 中 |
异常处理 | 自动释放锁 | 依赖事务回滚 | 易死锁 |
五、注意事项
- Key设计原则:建议包含「业务类型+唯一标识」,如
REGISTER:13800138000
- 过期时间:根据业务耗时设置,建议「平均处理时间*3」
- 异常处理:在finally块中根据业务结果决定是否立即删除Key
- 压力测试:建议用JMeter模拟1000+并发验证防重效果