重复提交可能的原因
- 前端重复点击
- 网关超时重试
- 服务有多个节点,定时任务不支持分布式(例:JDK Timer、SpringTask、delayQueue等)
常见解决方案
- 前端提交后按钮置灰(前端可能会被绕过,所以后端也要同步校验)
- 后端提供一个接口生成唯一的uuid,前段提交时带上uuid信息,后端校验uuid是否重复,重复即为相同请求(uuid可存在redis里)
- 后端数据库设置唯一索引
以上方式都可以解决一部分重复提交的问题。但是往往在我们实际项目基本所有提交都要做防重处理,很自然就能想到可以把这一操作抽出来做成一个spring的AOP切面。而为了更简单的实现,我们还你能进一步的结合redis去做。
前置知识
1、熟练
StringRedisTemplate基本操作
String操作的setIfAbsent命令,如果key不存在则写入这个key并设置过期时间,等价于setNX和setPX命令的组合。
Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);
2、理解SpringAOP中AspecJ基本原理及基本使用
AspectJ定义的通知类型
- Before(前置通知):目标对象的方法调用之前触发
- After (后置通知):目标对象的方法调用之后触发
- AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发
- AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。
- Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法
执行顺序
此执行顺序是基于spring-aop-5.2.15.RELEASE版本,在Spring5.2.7之前around after在@Afer之前执行,之后顺序是调转过来的详细看
这里:Spring AOP 的执行顺序详解 – 源码巴士
- 正常return:
@around的joinPoint.proceed()前的代码->@Before->被代理的方法体->@AfterReturning->@After->@around的joinPoint.proceed()后的代码
- 被代理的方法发生异常:
@around的joinPoint.proceed()前的代码->@Before->被代理的方法体->@AfterThrowing->@After此时joinPoint.proceed()后的代码并不会执行
功能实现
自定义注解
/**
* @author CVToolMan
* @create 2024/1/21 10:35
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckRepeat {
/**
* 加锁过期时间,默认是5秒
* @return
*/
long lockTime() default 5;
String msg() default "请忽重复提交!";
}
定义切面
通过切面拦截标注了@CheckRepeat注解的方法,并通过类的全路径+方法名+userId作为key写入redis,在一段时间内限制用户重复请求,默认为5S。注意代码里还有一段如果发生异常的时候要删除key的逻辑,为什么这样处理呢,试想一下如果请求过程中由于某种原因发了异常,这个时候按常理来说保存是没有成功,就不应该限制用户第二次发起的请求。所以这时我们应该把key删除。
/**
* @author CVToolMan
* @create 2024/1/21 10:34
*/
@Aspect
@Component
@Slf4j
public class CheckRepeatAspect {
private StringRedisTemplate stringRedisTemplate;
private static final ThreadLocal<String> KEY = new ThreadLocal<>();
public CheckRepeatAspect(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Pointcut("@annotation(checkRepeat)")
public void pointCutNoRepeatSubmit(CheckRepeat checkRepeat) {
}
/**
* 类名+方法名+userId作为redis的key
*
* @param joinPoint
* @param userId
* @return
*/
private String formatKey(ProceedingJoinPoint joinPoint, Long userId) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String path = method.getDeclaringClass().getName();
String methodName = method.getName();
String key = "check:repeat:" + String.format("%s-%s-%s", path, methodName, userId);
KEY.set(key);
return key;
}
@Around("pointCutNoRepeatSubmit(checkRepeat)")
public Object around(ProceedingJoinPoint joinPoint, CheckRepeat checkRepeat) throws Throwable {
log.info("[CheckRepeatAspect around]方法执行前>>>>>>>");
// Long userId = UserHolder.getUser().getId();
//实际项目中用户登录的信息可能存在redis里并放到ThreadLocal线程上下文中获取,这里直接写死一个
Long userId =2343243L;
if (null != userId) {
long lockTime = checkRepeat.lockTime();
String msg = checkRepeat.msg();
String key = formatKey(joinPoint, userId);
//加锁
boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
if (!isLock) {
//抛异常
throw new RuntimeException(msg);
}
}
Object obj = joinPoint.proceed();
log.info("[CheckRepeatAspect around]方法执行后>>>>>>>");
return obj;
}
@Before("pointCutNoRepeatSubmit(checkRepeat)")
public void before(CheckRepeat checkRepeat) {
log.info("[CheckRepeatAspect before],执行了>>>>>>>");
}
@AfterReturning("pointCutNoRepeatSubmit(checkRepeat)")
public void afterReturning(CheckRepeat checkRepeat) {
log.info("[CheckRepeatAspect afterReturning],执行了>>>>>>>");
}
@After("pointCutNoRepeatSubmit(checkRepeat)")
public void after(CheckRepeat checkRepeat) {
log.info("[CheckRepeatAspect after],执行了>>>>>>>");
KEY.remove();
}
/**
* 发生异常删除key
*
* @param checkRepeat
* @param e
*/
@AfterThrowing(value = "pointCutNoRepeatSubmit(checkRepeat)", throwing = "e")
public void doAfterThrowing(CheckRepeat checkRepeat, Exception e) {
log.info("[CheckRepeatAspect doAfterThrowing],方法执行了>>>>>>" );
if (StringUtils.isNotBlank(KEY.get())) {
stringRedisTemplate.delete(KEY.get());
}
}
}
使用
在Controller加注解,注解支持自定义指定key的失效时间和拦截下来的描述信息。
@PostMapping
@CheckRepeat(lockTime = 10,msg = "请求已提交,请刷新页面!")
public Result saveShop(@RequestBody Shop shop) throws Exception{
log.info("进入saveShop方法>>>>>>>>");
// if (true){
// throw new Exception("发生错误");
// }
// 写入数据库
shopService.save(shop);
// 返回店铺id
return Result.ok(shop.getId());
}
效果展示
用postMan10s内连续发起两次请求