本文最后更新于 185 天前,如有失效请评论区留言。
前言
系统框架的防重功能参考了美团 GTIS 解决方案,参考文章 分布式系统互斥性与幂等性问题的分析与解决
GTIS 的实现思路是将每一个不同的业务操作赋予其唯一性。这个唯一性是通过对不同操作所对应的唯一的内容特性生成一个唯一的全局ID来实现的。基本原则为:相同的操作生成相同的全局ID;不同的操作生成不同的全局ID。
生成的全局ID需要存储在外部存储引擎中,数据库、Redis 亦或是 Tair 等等均可实现。
总的来说,防重幂等基本原理就是,依赖于存储引擎,通过对不同操作所对应的唯一的内容特性生成一个唯一的全局ID来防止操作重复。
实现
配置类 IdempotentConfig
/**
* 幂等功能配置
*
* @author Lion Li
*/
@AutoConfiguration(after = RedisConfiguration.class)
public class IdempotentConfig {
@Bean
public RepeatSubmitAspect repeatSubmitAspect() {
return new RepeatSubmitAspect();
}
}
注解 RepeatSubmit
这里定义三个属性,interval 表示重复提交判定时间,默认 5s,TimeUnit 时间单位,message 提示信息
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 50000;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 提示消息 支持国际化 格式为 {code}
*/
String message() default "{repeat.submit.message}";
}
切面实现 RepeatSubmitAspect
这里使用 redis 的来判断,使用 redission 的分布式锁,调用 setIfAbsent 方法来实现的。 通过设置 key 的过期时间,如果设置成功则认为首次提交,如果失败,则表示当前操作为重复操作。
key 的组成部分
1、当前请求的路由 url
2、当前登录绘画的 token
3、当前请求的参数 json 字符串
这个 key 表明当前是谁在操作什么
/**
* 防止重复提交(参考美团GTIS防重系统)
* * @author Lion Li
*/@Aspect
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 = GlobalConstants.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<?> r) {
try {
// 成功则不删除redis数据 保证在有效时间内无法重复提交
if (r.getCode() == R.SUCCESS) {
return;
}
RedisUtils.deleteObject(KEY_CACHE.get());
} finally {
KEY_CACHE.remove();
}
}
}
}
使用
使用时,业务方只需要在操作的前后调用GTIS的前置方法和后置方法,如果前置方法返回可进行操作,则说明此时无重复操作,可以进行,否则则直接结束操作。
/**
* 修改对象存储配置
*/
@SaCheckPermission("system:ossConfig:edit")
@Log(title = "对象存储配置", businessType = BusinessType.UPDATE)
@RepeatSubmit(message="不要重复提交")
@PutMapping()
public R<Void> edit(@Validated(EditGroup.class) @RequestBody SysOssConfigBo bo) {
return toAjax(ossConfigService.updateByBo(bo));
}