likes
comments
collection
share

系统设计:如何使用AOP设计一个防重提交功能

作者站长头像
站长
· 阅读数 9

防重幂等的概念

防重幂等指的是我们的业务需要防止两条相同的数据重复提交导致脏数据或业务错乱。需要注意的是,重复提交属于小概率事件,这和并发压测不是同一个概念。

我们的目标是通过防重幂等的设计,让系统支持业务失败或异常快速释放限制。业务处理成功后,会在指定时间限定内限制同一条数据的提交。本文将介绍如何在SpringBoot开发中,使用AOP+Redis实现一个防重幂等功能。

防重幂等设计思路

目标:防止同一个用户在同一个业务下提交同一个数据。

策略:将用户路径+请求参数+Token生成唯一ID,存入Redis。具体流程如下:

  1. 用户从前端发送请求,我们通过切面拦截,拿到请求地址、请求参数和token,生成一个唯一ID。
  2. 判断Redis中是否已存在数据以及数据是否有效
  3. 如果不存在:正常执行业务。如果存在:抛出异常,提示重复提交。
  4. 通过AOP拦截方法执行结果,如果结果正常,就放行;否则就删掉存入Redis的Key,说明本次业务异常,下次提交可以放行。

自定义注解@RepeatSubmit

首先我们定义一个注解@RepeatSubmit,作用于方法上,设置如下参数,用于设置AOP切点。

  • interval:间隔时间
  • timeUnit:时间单位,ms
  • message:支持国际化的提示消息
@Inherited  
@Target(ElementType.METHOD)  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
public @interface RepeatSubmit {  
  
    /**  
     * 间隔时间(ms),小于此时间视为重复提交  
     */  
    int interval() default 5000;  
  
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;  
  
    /**  
     * 提示消息 支持国际化 格式为 {code}  
     */  
     String message() default "{repeat.submit.message}";  
  
}

自定义切面@RepeatSubmitAspect

定义一个切面@RepeatSubmitAspect,作为防重幂等的模块化,用于横切标记上@RepeatSubmit注解的方法。

我们需要定义三个通知:前置通知、后置通知、抛出异常时的通知,他们执行的业务如下,基本上是按照上述防重幂等设计的策略来写的。

  • doBefore:使用@Before("@annotation(repeatSubmit)")定义前置通知,切点是加了注解的方法。
    • 从注解拿到间隔时间
    • 从切点拿到请求参数,从ServletRequest拿到请求地址和请求头的用户token。
    • 拼接SubmitKey:对token:请求参数做MD5加密。
    • 拼接CacheKey(存到ThreadLocal里面):将缓存常量SUBMIT_KEY,拼接URL,SubmitKey三者拼接作为Cachekey。(这意味着,如果请求的地址相同、参数相同、token相同,就认为是相同提交。)
    • 判断Redis中是否已存在Key,如果存在,抛出异常。否则将CacheKey设置到Redis。
  • doAfterReturning:使用@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")定义后置通知并拿到jsonResult。
    • 拿到jsonResult,转为R类型
    • 判断code是否为成功,如果是就返回,如果不是就代表业务失败,于是我们就删掉CacheKey,因为这次业务并未处理成功,下一次请求是可以接纳的。
    • 删除ThreadLocal本地变量
  • doAfterThrowing
    • 删除key,移除ThreadLocal本地变量

PS:这里用到了ThreadLocal,ThreadLocal是一个 Java 类,可以用来定义只由创建它们的线程访问的变量,常用于我们需要存储不在线程之间共享的数据。

@Aspect  
@Component  
public class RepeatSubmitAspect {  
  
    private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();  
  
    @Before("@annotation(repeatSubmit)")  
    public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {  
        // 如果注解不为0 则使用注解数值  
        long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());  
  
        if (interval < 1000) {  
            throw new ServiceException("重复提交间隔时间不能小于'1'秒");  
        }  
        HttpServletRequest request = ServletUtils.getRequest();  
        String nowParams = argsArrayToString(point.getArgs());  
  
        // 请求地址(作为存放cache的key值)  
        String url = request.getRequestURI();  
  
        // 唯一值(没有消息头则使用请求地址)  
        String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName()));  
  
        submitKey = SecureUtil.md5(submitKey + ":" + nowParams);  
        // 唯一标识(指定key + url + 消息头)  
        String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;  
        if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {  
            KEY_CACHE.set(cacheRepeatKey);  
        } else {  
            String message = repeatSubmit.message();  
            if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {  
                message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));  
            }  
            throw new ServiceException(message);  
        }  
    }  
  
    /**  
     * 处理完请求后执行  
     *  
     * @param joinPoint 切点  
     */  
    @AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")  
    public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {  
        if (jsonResult instanceof R) {  
            try {  
                R<?> r = (R<?>) jsonResult;  
                // 成功则不删除redis数据 保证在有效时间内无法重复提交  
                if (r.getCode() == R.SUCCESS) {  
                    return;  
                }  
                RedisUtils.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) {  
        RedisUtils.deleteObject(KEY_CACHE.get());  
        KEY_CACHE.remove();  
    }  
  
    /**  
     * 参数拼装  
     */  
    private String argsArrayToString(Object[] paramsArray) {  
        StringJoiner params = new StringJoiner(" ");  
        if (ArrayUtil.isEmpty(paramsArray)) {  
            return params.toString();  
        }  
        for (Object o : paramsArray) {  
            if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {  
                params.add(JsonUtils.toJsonString(o));  
            }  
        }  
        return params.toString();  
    }  
  
    /**  
     * 判断是否需要过滤的对象。  
     *  
     * @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.values()) {  
                return value instanceof MultipartFile;  
            }  
        }  
        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse  
            || o instanceof BindingResult;  
    }  
  
}

简单测试

我们创建一个接口用于测试防重幂等,我的系统中使用Sa-Token权限框架,为了方便,我们通过@SaIngore放行接口。

/**  
 * @author AjaxZhan  
 */@RestController  
@RequestMapping("/repeat")  
@Slf4j  
@SaIgnore  
public class RepeatController {  
  
    @PostMapping  
    @RepeatSubmit(interval = 2000)  
    public R<Void> repeat1(String info){  
        log.info("请求成功,信息" + info);  
        return R.ok("请求成功");  
    }  
}

使用Apifox测试结果如下:

系统设计:如何使用AOP设计一个防重提交功能

当我们在2s内连续提交就会触发异常:

系统设计:如何使用AOP设计一个防重提交功能

至此,我们就成功地使用AOP+Redis的方式设计了一个防重幂等功能。

参考文章

  1. 防重幂等的设计 (dromara.org)