后端接口幂等性校验(数据库唯一索引 + 自定义注解和拦截器的token校验)
最近工作中需要实现一个防止重复提交的任务,其本质就是需要保证接口的幂等性。接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
采用的方案包括了两部分,一部分核心的业务逻辑使用token校验,另一部分使用数据库的唯一索引来防止重复更改数据库。
先来说说使用数据库唯一索引的方案,使用数据库唯一索引的方案可以防止数据库表中在唯一索引字段相同的情况下去更改数据库,而更改数据库的操作,其中只有新增操作(INSERT语句)和计算式的更新操作(类似 UPDATE table SET a = a + 1 WHERE a = 0)会产生幂等性的问题,其余的情况都是具有天然幂等性的,我们不需要考虑。当我们在数据库表中加入了唯一索引之后,使用INSERT IGNORE INTO 语句就可以实现如果唯一索引冲突,则该条记录不会被插入到数据库中。
既然是通过数据库去做防重校验,那么在高并发的场景下必然会产生数据库压力较大的问题,这种情况下就需要对请求进行限流。做法是在网关(我这里使用的网关是Spring Cloud Gateway)中实现GlobalFilter接口,匹配满足特殊关键字的Url或是在配置文件中的Url,将IP与Url拼接成key存入到Redis中,并设置一个较短的过期时间,在过期时间内重复请求的话就提示,进行限流操作,代码如下:
@Component public class RateLimiterFilter implements GlobalFilter, Ordered { private Logger logger = LoggerFactory.getLogger(RateLimiterFilter.class); private static final Integer RETRY_CODE = 604; private static final Integer FAILED_CODE = 608; @Autowired RedisUtils redisUtils; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { //获取请求机器IP地址 String address = exchange.getRequest().getRemoteAddress().getAddress().toString(); logger.info("请求机器IP地址:{}", address); //获取请求路径 String path = exchange.getRequest().getPath().toString(); logger.info("请求路径:{}" , path); //请求路径中是否模糊匹配save、update、insert(不区分大小写) String pattern = ".*save.*|.*update.*|.*insert.*"; Pattern r = Pattern.compile(pattern,Pattern.CASE_INSENSITIVE); Matcher m = r.matcher(path); boolean isExist = false; //判断请求路径在配置限流的文件中是否存在 for (String url : readFileAsList()) { if (url.equals(path)) { isExist = true; break; } } //若请求路径匹配到了正则表达式(包含save、update、insert关键字)或在配置限流的文件中存在 if(m.matches() || isExist){ ServerHttpResponse response = exchange.getResponse(); StringBuffer sb = new StringBuffer(); String redisKey = sb.append(RedisUtils.KEY_PREFIX).append(address).append(_).append(path).toString(); boolean exists = redisUtils.exists(redisKey); //若redis中存在key则提示请勿频繁请求,否则将key添加到redis中 if(exists){ return getFailedResponse(response, "请勿频繁请求,稍后再试!", RETRY_CODE); }else { //存入redis并设置过期时间 boolean isSetSuccess = redisUtils.set(redisKey, redisKey, RedisUtils.URL_EXPIRE_TIME); if(isSetSuccess) { logger.info("redis中已存入数据:{}" , redisKey); }else { return getFailedResponse(response, "redis新增失败!", FAILED_CODE); } } } return chain.filter(exchange); } @Override public int getOrder() { return 10000; } /** * 返回错误的response * @param response * @param message * @param failedCode * @return */ private Mono<Void> getFailedResponse(ServerHttpResponse response, String message, Integer failedCode) { response.setStatusCode(HttpStatus.UNAUTHORIZED); JSONObject result = new JSONObject(); result.put("MESSAGE", message); result.put("CODE", failedCode); byte[] bytes = result.toJSONString().getBytes(StandardCharsets.UTF_8); DataBuffer buffer = response.bufferFactory().wrap(bytes); //指定返回数据为Json格式 response.getHeaders().add("Content-Type", "application/json;charset=utf-8"); return response.writeWith(Mono.just(buffer)); } /** * 读取配置文件中需要限流的请求路径 * @return */ private List<String> readFileAsList(){ List result = new ArrayList(); try { //读取classpath下的xxx.txt文件 ClassPathResource classPathResource = new ClassPathResource("xxx.txt"); BufferedReader in = new BufferedReader(new InputStreamReader(classPathResource.getInputStream())); String str; while ((str = in.readLine()) != null) { result.add(str); } result.forEach(i -> logger.info("读取限流URL文件条目:{}" , i)); } catch (IOException e) { logger.error("读取限流URL文件失败:{}" , e.getMessage()); } return result; } }
讲完了数据库唯一索引之后再来说说token校验的方案,为了保证幂等性,需要为请求单独创建一个token值,然后将这个token值存入到redis中,当前端调用提交方法时将token存入到请求的header或者请求参数中传到后端,后端获取到这个token值,去redis查询一下,若在redis中找到了这个token值的key,那么这个请求是第一次请求,正常提交,然后将token值从redis中删去,如果redis中找不到token值的key,则提示前端请勿重复提交。
因为是针对某一些接口去做token校验,具体做法是通过自定义注解的方式,在需要token校验的接口上添加注解,然后通过拦截器的方式去拦截重复提交的请求。我使用的Spring Boot版本是2.1.4.RELEASE,具体代码如下:
1、application.yml文件中的redis配置
spring: redis: host: localhost port: 6379 password: lettuce: pool: maxActive: 8 #连接池最大连接数(使用负值表示没有限制) maxWait: -1ms #连接池最大阻塞等待时间(使用负值表示没有限制) maxIdle: 8 #连接池中的最大空闲连接 minIdle: 0 #连接池中的最小空闲连接 database: 1 #Redis默认使用数据库
2、自定义注解
/** * 在需要校验幂等性的Controller的方法上使用此注解 */ @Target({ ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface CheckIdempotence { }
3、service层(我这里只放service的实现类)
@Service public class TokenService implements ITokenService { private static final String TOKEN_NAME = "token"; @Autowired RedisUtils redisUtils; /** * 生成token并存入redis中 * @return */ @Override public ResponseResult generateToken() { StringBuffer sb = new StringBuffer(); String userId = ShiroUtils.getSysUser().getId(); //生成UUID String tokenValue = String.valueOf(UUID.randomUUID().toString()); //前缀 + 用户id + UUID组成 token String key = sb.append(RedisUtils.KEY_PREFIX).append(userId).append(_).append(tokenValue).toString(); //将token写入redis并设置过期时间 boolean isSetSuccess = redisUtils.set(key, key, Long.parseLong("86400")); //redis写入成功则设置token成功 if(isSetSuccess) { ResponseResult result = ResponseResultFactory.getSuccessResult("获取Token成功"); result.setResult(key); return result; }else { return ResponseResultFactory.getErrorResult(ErrorCodeEnum.REDIS_SET_FAILED.getCode(), ErrorCodeEnum.REDIS_SET_FAILED.getErrorMsg()); } } /** * 校验token值 * @param request */ @Override public void checkToken(HttpServletRequest request) { //从header中获取token值 String token = request.getHeader(TOKEN_NAME); if (StringUtils.isBlank(token)){ throw new TokenValidateException(ErrorCodeEnum.NO_TOKEN); } //判断token在redis中是否存在 boolean exists = redisUtils.exists(token); if(!exists){ throw new TokenValidateException(ErrorCodeEnum.SUBMIT_DUPLICATE_TOKEN); }else { //token存在则删除,且删除成功才算完成,否则会出现并发安全性问题 boolean del = redisUtils.del(token); if(!del){ throw new TokenValidateException(ErrorCodeEnum.REDIS_DEL_FAILED); } } } }
4、拦截器
@Component public class CheckIdempotenceInterceptor extends HandlerInterceptorAdapter { @Autowired TokenService tokenService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); //是否在方法上添加了注解 CheckIdempotence methodAnnotation = method.getAnnotation(CheckIdempotence.class); if (methodAnnotation != null) { checkIdempotence(request); } return true; } private void checkIdempotence(HttpServletRequest request) { //校验token tokenService.checkToken(request); } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }
5、添加拦截器到配置类中,否则拦截器不生效
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Resource CheckIdempotenceInterceptor interceptor; @Override public void addInterceptors(InterceptorRegistry registry) { //这样写拦截器中无法注入bean //registry.addInterceptor(new CheckIdempotenceInterceptor()); registry.addInterceptor(interceptor); } }
至此就完成了后端的防重校验的逻辑,主要的思路是结合数据库唯一索引和token校验来做接口幂等性的校验,保证提交只会在后端走一遍。其实幂等性校验有很多的方案,我这里只是给出了其中的一种大致思路。
如果文中有错误的话,欢迎各位小伙伴指正。