既然要实现美团 GTIS,所以要先来探究一下 美团的 GTIS 是个什么。 它是一个轻量的重复操作关卡系统,它能够确保在分布式环境中操作的唯一性。我们可以用它来间接保证每个操作的幂等性。它具有如下特点:
高效:低延时,单个方法平均响应时间在 2ms 内,几乎不会对业务造成影响;
可靠:提供降级策略,以应对外部存储引擎故障所造成的影响;提供应用鉴权,提供集群配置自定义,降低不同业务之间的干扰;
简单:接入简捷方便,学习成本低。只需简单的配置,在代码中进行两个方法的调用即可完成所有的接入工作;
灵活:提供多种接口参数、使用策略,以满足不同的业务需求。 以上是美团对于 GTIS 的解释。 美团对于生成全局唯一 ID 的存储采用的是阿里的 Tair实现的,这里不做考究。改为使用 Redis 作为全局唯一 ID 的存储。以下代码参考了 RuoYi-Vue-Plus 防重提代码,在原基础上简化而来。改 SpringBoot 版本为 2.6.x ,使用阿里的云原生应用脚手架搭建。
需要的 POM
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.6.13</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
准备一个注解 RepeatSubmit.java
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeUnit;
/**
* 自定义注解防止表单重复提交
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
准备一个 ServletUtil
import javax.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public class ServletUtils {
public static ServletRequestAttributes getRequestAttributes() {
try {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
return (ServletRequestAttributes) attributes;
} catch (Exception e) {
return null;
}
}
public static HttpServletRequest getRequest() {
try {
return getRequestAttributes().getRequest();
} catch (Exception e) {
return null;
}
}
}
准备 AOP 代码
import java.time.Duration;
import java.util.StringJoiner;
import javax.servlet.http.HttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import com.runbrick.demo.annotation.RepeatSubmit;
import com.runbrick.demo.utils.ServletUtils;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Exception {
// 间隔时间
long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
if (interval < 1000) {
throw new Exception("重复提交间隔时间不能小于'1'秒");
}
HttpServletRequest request = ServletUtils.getRequest();
// 获取请求参数
String nowParams = argsArrayToString(joinPoint.getArgs());
// request.getRequestURL() 返回的是全路径。
// request.getRequestURI() 返回的是除去host(域名或者ip)部分的路径
String url = request.getRequestURI();
// TODO:这个 key 在业务中要根据每个人去判断,我这里是为了测试所以简化了代码
String submitKey = SecureUtil.md5("RepeatKey" + ":" + nowParams);
// 唯一标识(指定key + url + 消息头)
String cacheRepeatKey = "repeat_submit:" + url + submitKey;
if (Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval)))) {
KEY_CACHE.set(cacheRepeatKey);
} else {
throw new RuntimeException("请勿重复点击");
}
}
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "result")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object result) {
// TODO:需要自行根据返回内容判断是否需要删除这个 key 值
// redisTemplate.delete(KEY_CACHE.get());
// KEY_CACHE.remove();
log.info("最后返回的结果{}", result);
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
// TODO:如果出现了异常是需要清理的,这里为了演示并没有删除
// redisTemplate.delete(KEY_CACHE.get());
// KEY_CACHE.remove();
}
private String argsArrayToString(Object[] paramsArray) {
StringJoiner params = new StringJoiner(" ");
for (Object o : paramsArray) {
// 去除文件判断
params.add(JSONUtil.toJsonStr(o));
}
return params.toString();
}
}
准备一个控制器
这个控制器在创建的时候就带着,加上 @RepeatSubmit 注解即可。
@Controller
public class BasicController {
@RequestMapping("/hello")
@ResponseBody
@RepeatSubmit(interval = 30000)
public String hello(@RequestParam(name = "name", defaultValue = "unknown user") String name) {
return name;
}
}
@RepeatSubmit 中 interval 的时间是 30000 ms ,是为了测试的时候更好观察 redis 客户端中的内容。如果不看可以自己调整一下时间。
启动服务测试下
访问 http://127.0.0.1:9802/hello?name=张三 之后第一次是成功的,如果连续点击就会出现下面的错误。过了设定的时间就又能访问了。
java.lang.RuntimeException: 请勿重复点击
at com.runbrick.demo.aspect.RepeatSubmitAspect.doBefore(RepeatSubmitAspect.java:63) ~[classes/:na]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_333]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_333]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_333]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_333]
...
以上就是基于 GTIS 实现的简单防重复提交的代码,如果需要生产去配置可以自行下载 RuoYi-Vue-Plus 去看下里面的代码逻辑。
注:
[1] 美团技术团队关于 GTIS 的介绍(这里面不止介绍了 GTIS,需要往下滑滑): https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html
[2] RuoYi-Vue-Plus 地址: https://github.com/dromara/RuoYi-Vue-Plus
评论区