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("订单提交成功");
}
相关推荐
溪语流沙1 小时前
Django + Vue电商项目第001讲:开篇|注册登录加增删改查,那不是电商
redis·python·mysql·docker·typescript·django·vue
努力努力再努力wz1 小时前
【Redis进阶系列】:从主从复制到 Sentinel,一文建立故障检测、Leader 选举与 Failover 的完整心智模型
数据库·redis·缓存
Wang's Blog2 小时前
Java 项目实战: 外卖平台-后台系统登录功能开发
java·服务器·redis
Wang's Blog3 小时前
Java 项目实战: 外卖平台-后台退出功能与首页iframe架构
java·服务器·redis
海天一色y3 小时前
生产级医疗AI Agent:Multi-Agent RAG架构解析
redis·milvus·multi-agent
Wang's Blog3 小时前
Java 项目实战: 外卖平台-软件开发流程与项目整体介绍
java·服务器·redis
Wang's Blog11 小时前
Java 接入Redis: 服务启动停止与后台运行配置
java·服务器·redis
程序员JerrySUN15 小时前
Jetson Edge AI 实战01:Nano、Xavier、Orin 怎么选?Jetson 硬件选型详解【视频讲解】
java·数据库·redis·安全·mybatis
行百里er16 小时前
Redis 版本演进、新特性与协议那些事儿
redis·后端
Asurplus16 小时前
【SpringBoot4】3、从SpringBoot4升级Sa-Token1.46.0报错
redis·sa-token·json·jackson·springboot4