Redis+ThreadLocal实现防重复提交,参考美团GTIS防重系统

防止重复提交应该是我们系统中绕不过的一个功能,有效的防止重复提交可以确保系统的稳定性和数据一致性。

如果不做防重,在并发、网络延迟或者是用户误操作的情况下,可能会出现重复提交、重复下单、重复扣款等,甚至一些可爱的用户就喜欢一直点按钮,防重也可以有效的缓解系统后续的资源浪费。

那该如何高效的实现这个功能呢,那当然是通过注解啦,哪个方法需要在哪里加注解就行了。

当然,重点还是在防重的实现上,下面通过 核心步骤拆解+流程图+源码 的方式讲解。

1.核心步骤

  1. 设置全局请求唯一id:通过token+请求url+请求参数生产唯一id并通过MD5加密。
  2. 将唯一id作为key存储至redis中和ThreadLocal中,并设置redis有效过期时间。
  3. 业务处理成功------保持redis中的key自动过期。
  4. 业务处理失败------通过ThreadLocal获取key并删除redis缓存。
  5. 获取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("订单提交成功");
}
相关推荐
我叫Double5 小时前
简易版的EINO基于redis库的向量搜索项目v2
数据库·redis·bootstrap
techdashen6 小时前
dial9:给 Tokio 装上“飞行记录仪“
java·数据库·redis
环流_9 小时前
Redis过期策略
数据库·redis·缓存
曲幽10 小时前
让 FastAPI Agent 思考不阻塞:手把手教你实现异步任务与后台处理方案
redis·python·agent·fastapi·web·async·celery·ai agent·backgroundtask
van久11 小时前
Day30:Redis 缓存策略 + 菜单实战缓存 + 三大缓存问题(穿透 / 击穿 / 雪崩)
数据库·redis·缓存
与数据交流的路上11 小时前
Redis-jedis连接池配置错误导致Redis CPU飙高
数据库·redis·缓存
杂家11 小时前
Windows部署Redis
数据库·windows·redis
液态不合群12 小时前
Redis--哨兵机制与CAP定理
java·redis·bootstrap
tellmewhoisi12 小时前
多版本共用redis导致数据没及时更新报错
数据库·redis·缓存