防止重复提交应该是我们系统中绕不过的一个功能,有效的防止重复提交可以确保系统的稳定性和数据一致性。
如果不做防重,在并发、网络延迟或者是用户误操作的情况下,可能会出现重复提交、重复下单、重复扣款等,甚至一些可爱的用户就喜欢一直点按钮,防重也可以有效的缓解系统后续的资源浪费。
那该如何高效的实现这个功能呢,那当然是通过注解啦,哪个方法需要在哪里加注解就行了。
当然,重点还是在防重的实现上,下面通过 核心步骤拆解+流程图+源码 的方式讲解。
1.核心步骤
- 设置全局请求唯一id:通过token+请求url+请求参数生产唯一id并通过MD5加密。
- 将唯一id作为key存储至redis中和ThreadLocal中,并设置redis有效过期时间。
- 业务处理成功------保持redis中的key自动过期。
- 业务处理失败------通过ThreadLocal获取key并删除redis缓存。
- 获取redis数据判断用户是否重复提交。
有同学可能会问:直接使用redis存储key不就行了吗,为啥还要使用ThreadLocal呢,这不纯增加代码难度和可维护性吗!
那我就有的说了:如果在并发的情况下业务处理失败或出现异常的时候该怎么处理呢?如何找到redis的key并将其删除呢?如果不删除这个防重key那业务处理失败后用户将无法再次提交。
答案就是将redis的key存储在ThreadLocal中,这样做的好处就是既能在线程周期内获取到key,又能保证每个线程的数据独立存储,避免并发问题。
2.流程图

3.核心源码
自定义防重注解
java
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 默认间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
/**
* 默认时间单位
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 默认提示消息
*/
String message() default "您刚刚已经提交了表单,请勿重复提交";
}
Aop拦截处理
java
@Aspect
@Component
public class RepeatSubmitAspect {
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();
/**
* 前置拦截
* @param point
* @param repeatSubmit
* @throws Throwable
*/
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
// 如果注解不为0 则使用注解数值
long interval = 0;
if (repeatSubmit.interval() > 0) {
interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
}
if (interval < 1000) {
throw new BusinessException("配置的重复提交间隔时间不能小于'1'秒, 请重新配置!");
}
HttpServletRequest request = ServletUtils.getRequest();
// 请求参数
String nowParams = argsArrayToString(point.getArgs());
// 请求地址
String url = request.getRequestURI();
String submitKey = SecureUtil.md5(LoginSysUserUtils.getUsername() + ":" + nowParams);
// 唯一标识(指定key + url + 参数加密)
String cacheRepeatKey = GlobalConstants.REPEAT_SUBMIT_KEY + url + submitKey;
String key = RedissonUtils.getCacheObject(cacheRepeatKey);
if (key == null) {
RedissonUtils.setCacheObject(cacheRepeatKey, "", Duration.ofMillis(interval));
KEY_CACHE.set(cacheRepeatKey);
} else {
throw new BusinessException(repeatSubmit.message());
}
}
/**
* 处理完请求后执行
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {
if (jsonResult instanceof ApiResponse r) {
try {
// 成功则不删除redis数据 保证在有效时间内无法重复提交
if (r.getCode() == ApiResponse.SUCCESS) {
return;
}
RedissonUtils.deleteObject(KEY_CACHE.get());
} finally {
KEY_CACHE.remove();
}
}
}
/**
* 拦截异常操作
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
RedissonUtils.deleteObject(KEY_CACHE.get());
KEY_CACHE.remove();
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray) {
StringBuilder params = new StringBuilder();
if (paramsArray != null && paramsArray.length > 0) {
for (Object o : paramsArray) {
if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
try {
params.append(JSONObject.toJSONString(o)).append(" ");
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
return params.toString().trim();
}
/**
* 判断是否需要过滤的对象。
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.entrySet()) {
Map.Entry entry = (Map.Entry) value;
return entry.getValue() instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
}
Controller方法示例
java
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS, message = "系统正在处理,请勿重复提交")
@PostMapping("/submitOrder")
public R<String> submitOrder(@RequestBody OrderDTO order) {
// ... 业务处理逻辑
return R.ok("订单提交成功");
}