Loading... ## 一、HandlerInterceptor拦截器 ```java public interface HandlerInterceptor { /** 预处理回调方法,实现处理器的预处理(如登录检查),第三个参数为响应的处理器(如我们上一章的Controller 实现); 返回值: true 表示继续流程(如调用下一个拦截器或处理器); false表示流程中断(如登录检查失败),不会继续调用其他的拦截器或处理器,此时我们需要通过 response来产生响应; */ default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true; } /** 后处理回调方法,实现处理器的后处理(但在渲染视图之前),此时我们可以通过 modelAndView(模型和视图对象)对模型数据进行处理或对视图进行处理,modelAndView 也可能为 null。 */ default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception { } /** 整个请求处理完毕回调方法,即在视图渲染完毕时回调,如性能监控中我们可以在此记录结束时间并输出消耗时间, 还可以进行一些资源清理,类似于 try-catch-finally 中的 finally,但仅调用处理器执行链中 preHandle 返回 true 的拦截器的 afterCompletion。 */ default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception { } } ``` ### (1)应用场景(本质是AOP) 1. **日志记录**:记录请求信息(信息监控、计算PV) 2. **权限检查**(**建议用Filter**):登陆检查、权限校验 3. **性能监控**:记录慢sql处理时间 4. **通用行为**:获取cookie、Locale、Theme等信息、调用处理器前后自动打开、关闭Session 5. **结合Redis做限流限频** ### (2)执行流程   中断流程中,比如是 HandlerInterceptor4 中断的流程(preHandle 返回 false),此处仅调用它之前拦截器HandlerInterceptor3的 preHandle 返回 true 的 afterCompletion 方法。 ### (3)应用 **`HandlerInterceptorAdapter`已经过期,建议直接实现该接口。目前无需将三个方法都实现,只需要实现对应需要的方法即可。** #### 性能监控,计算处理时间 拦截器是单例,因此不管用户请求多少次都只有一个拦截器实现,即线程不安全。 应该使用 ThreadLocal,它是线程绑定的变量,提供线程局部变量(一个线程一个 ThreadLocal,A 线程的 ThreadLocal 只能看到 A 线程的 ThreadLocal,不能看到 B 线程的 ThreadLocal)。 不过到现在,一些方向代理的服务器(Apache)、数据连接池(Druid)都可以收集到了 ```java /** * 描述:慢sql统计(该拦截器需要放第一位保证时间准确) */ public class StopWatchHandlerInterceptor implements HandlerInterceptor { /** * Spring 提供一个命名的 ThreadLocal 实现 */ private NamedThreadLocal<Long> startTimeThreadLocal = new NamedThreadLocal<>("StopWatch-StartTime"); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1、开始时间 long beginTime = System.currentTimeMillis(); // 线程绑定变量(该数据只有当前请求的线程可见) startTimeThreadLocal.set(beginTime); // 继续流程 return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 2、结束时间 long endTime = System.currentTimeMillis(); // ... 当执行时间超过n秒推送到慢sql列表中(目前是通过阿里的数据连接池Druid进行收集) } } ``` ## 二、 HandlerInterceptor + Redis 实现限流限频 ```java /** * 对请求接口进行频率限制,暂时不支持滑动,后续可以改成令牌桶或者漏斗的方式 **/ @Slf4j public class RequestRateLimitInterceptor implements HandlerInterceptor { private final static String RATE_LIMIT_PREFIX = "xy:api:req:rate:limit:"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 获取lockStringRedisTemplate实例(多个地方装配,但共用该实例) StringRedisTemplate lockStringRedisTemplate = SpringContextHolder.getBean("lockStringRedisTemplate"); if (handler instanceof HandlerMethod) { // 获取带@RateLimit注解的方法或类,拿到限制时间和限制次数 HandlerMethod han = (HandlerMethod) handler; RateLimit methodAnnotation = han.getMethodAnnotation(RateLimit.class); Method method = han.getMethod(); Class<?> declaringClass = method.getDeclaringClass(); RateLimit annotation = declaringClass.getAnnotation(RateLimit.class); int timeout = 0; int limitNum = 0; if (methodAnnotation != null) { timeout = methodAnnotation.limitTime(); limitNum = methodAnnotation.limitNum(); } else if (annotation != null) { timeout = annotation.limitTime(); limitNum = annotation.limitNum(); } else { return true; } // 标识:uri:token作为key,采用最基础的String数据结构(每次自增+1) String token = SoaBaseParams.fromThread().getToken(); String key = RATE_LIMIT_PREFIX + request.getRequestURI() + ":" + token; Long rCount = lockStringRedisTemplate.opsForValue().increment(key); // 如果本次窗口第一次调用,则设置过期时间 if (rCount == null || rCount == 1L) { lockStringRedisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS); } else { // 窗口期内大于限制次数,preHandle()返回false if (rCount.compareTo((long) limitNum) >= 1) { log.warn("[API限流]:overcount, count = " + rCount); WebUtil.sendJson(response, JsonResult.failedCode(EnSysCodeStatus.TOO_OFTEN)); return false; } } return true; } return true; } } ``` 上述设计没有采用**滑动窗口限流**,这样会导致设计不合理。 假设:用户 `1分钟内`只允许访问 `5`次,从 `00:00`开始算的话,用户 `00:59-01:00`的时候访问了 `50`次,`01:00-01:01`的时候又访问了 `50`次。这样实际上他们在这 `00:59-01:01`这 `2秒`内访问了 `100`次,显然是不合理的。 ### 优化方案一:滑动窗口限流 ```java @Slf4j public class RequestRateLimitInterceptor implements HandlerInterceptor { private final static String RATE_LIMIT_PREFIX = "xy:api:req:rate:limit:"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { StringRedisTemplate lockStringRedisTemplate = SpringContextHolder.getBean("lockStringRedisTemplate"); if (handler instanceof HandlerMethod) { HandlerMethod han = (HandlerMethod) handler; RateLimit methodAnnotation = han.getMethodAnnotation(RateLimit.class); Method method = han.getMethod(); Class<?> declaringClass = method.getDeclaringClass(); RateLimit annotation = declaringClass.getAnnotation(RateLimit.class); int timeout = 0; int limitNum = 0; if (methodAnnotation != null) { timeout = methodAnnotation.limitTime(); limitNum = methodAnnotation.limitNum(); } else if (annotation != null) { timeout = annotation.limitTime(); limitNum = annotation.limitNum(); } else { return true; } // 优化为窗口滑动 String token = SoaBaseParams.fromThread().getToken(); String key = RATE_LIMIT_PREFIX + request.getRequestURI() + ":" + token; long ts = System.currentTimeMillis(); // key-value-score 记录行为,后两者都为毫秒时间戳,节省内存 lockStringRedisTemplate.opsForZSet().add(key, String.valueOf(ts), ts); // 移除时间窗口以外的数据(ts-timeout时间点之前的所有数据),剩下的都是 lockStringRedisTemplate.opsForZSet().removeRangeByScore(key, 0, ts - timeout); // 获取窗口内的行为数量 Long count = lockStringRedisTemplate.opsForZSet().zCard(key); // 设置zset的过期时间,避免冷用户持续占用内存(过期时间为时间窗口时长 + 宽限1s) lockStringRedisTemplate.expire(key, timeout + 1000, TimeUnit.MILLISECONDS); // 判断是否超标 if (count > limitNum) { log.warn("[API限流]:overcount, count = " + count); WebUtil.sendJson(response, JsonResult.failedCode(EnSysCodeStatus.TOO_OFTEN)); return false; } return true; } return true; } } ``` 还可以直接用 `RedisTemplate`少一层数据转换。 ### 优化方案二:漏斗限流 ```java public class FunnelRateLimiter { static class Funnel { // 漏斗容量 int capacity; // 漏嘴流水速率(每秒流水量 = 容量 / 漏完耗时) float leakingRate; // 漏斗剩余空间 int leftQuota; // 上次漏水时间 long leakingTs; // 漏斗初始化 public Funnel(int capacity, float leakingRate) { this.capacity = capacity; this.leakingRate = leakingRate; this.leftQuota = capacity; this.leakingTs = System.currentTimeMillis(); } // 更新漏斗空间 void makeSpace() { long nowTs = System.currentTimeMillis(); long deltaTs = nowTs - leakingTs; // 计算腾出空间 int deltaQuota = (int) (deltaTs * leakingRate); // 间隔时间太长,整数数字过大溢出 if (deltaQuota < 0) { this.leftQuota = capacity; this.leakingTs = nowTs; return; } // 腾出空间太小,最小单位是 1 if (deltaQuota < 1) { return; } // 增加剩余空间 this.leftQuota += deltaQuota; // 更新记录时间 this.leakingTs = nowTs; // 剩余空间不能大于容量 if (this.leftQuota > this.capacity) { this.leftQuota = this.capacity; } } // 判断剩余空间是否足够 boolean watering(int quota) { makeSpace(); if (this.leftQuota >= quota) { this.leftQuota -= quota; return true; } return false; } } private Map<String, Funnel> funnels = new HashMap<>(); public boolean isActionAllowed(String userId, String actionKey, int capacity, float leakingRate) { String key = String.format("%s:%s", userId, actionKey); Funnel funnel = funnels.get(key); if (funnel == null) { funnel = new Funnel(capacity, leakingRate); funnels.put(key, funnel); } // 需要 1 个 quota return funnel.watering(1); } } ``` 漏斗算法如上,Redis 4.0 提供了一个限流 Redis 模块,它叫 `redis-cell`。该模块也使用了漏斗算法,并 提供了**原子的限流指令**。 该模块只有 1 条指令 `cl.throttle`,参数和返回值如下:   在执行限流指令时,如果被拒绝了,就需要丢弃或重试。`cl.throttle` 指令考虑的非常周到,连重试时间都帮你算好了,直接取返回结果数组的第四个值进行 `sleep` 即可,如果不想阻塞线程,也可以异步定时任务来重试。 ### 优化方案三:令牌桶限流 上面提到的漏斗算法,它的流水速率仅决定于其漏嘴的大小,因此它的流水速率是不变的。**这也就意味着漏斗限流无法应对流量突发的情况。** 令牌桶算法则与之相反,它会以指定的速率往桶里放令牌(Token),如果桶满了就停止增加。当有请求来的时候,就从桶拿走一个令牌,如果没得拿了就阻塞或拒绝。但它的优势在于其速率是可以调整的,有些变种算法就会实时调整速率。 其实令牌桶和漏斗算法在代码层面基本是没有太大差异的,只是抽象概念不同而已。 ```java public class TokenRateLimiter { static class Token { // 桶容量 int capacity; // 令牌生成速率(每一token生成时间 = 放满耗时 / 桶容量) float creatingRate; // 当前令牌数(有初始值,建议:初始令牌数 = 最大的突发流量的持续时间 / 令牌生成速率) int currentQuota; // 最后一次令牌发放时间 long creatingTs; // 桶初始化 public Token(int capacity, float creatingRate, int currentQuota) { this.capacity = capacity; this.creatingRate = creatingRate; this.currentQuota = currentQuota; this.creatingTs = System.currentTimeMillis(); } // 生成令牌 void generateToken() { long nowTs = System.currentTimeMillis(); long deltaTs = nowTs - creatingTs; // 计算令牌生成数(向下取整) int deltaQuota = (int) (deltaTs / creatingRate); // 间隔时间太长,整数数字过大溢出 if (deltaQuota < 0) { this.currentQuota = capacity; this.creatingRate = nowTs; return; } // 间隔太短,还没生成,最小单位是 1 if (deltaQuota < 1) { return; } // 增加令牌数 this.currentQuota += deltaQuota; // 更新记录时间 this.creatingTs = nowTs; // 令牌数不能大于容量 if (this.currentQuota > this.capacity) { this.currentQuota = this.capacity; } } // 判断剩余空间是否足够 boolean taking(int quota) { generateToken(); if (this.currentQuota >= quota) { this.currentQuota -= quota; return true; } return false; } void changeCreatingRate(float creatingRate) { this.creatingRate = creatingRate; } } private Map<String, Token> tokens = new HashMap<>(); public boolean isActionAllowed(String userId, String actionKey, int capacity, float creatingRate, int currentQuota) { String key = String.format("%s:%s", userId, actionKey); Token token = tokens.get(key); if (token == null) { token = new Token(capacity, creatingRate, currentQuota); tokens.put(key, token); } // 需要 1 个 quota return token.taking(1); } public void changeCreatingRate(String userId, String actionKey, float creatingRate) { String key = String.format("%s:%s", userId, actionKey); Token token = tokens.get(key); if (token == null) { return; } token.changeCreatingRate(creatingRate); } } ``` 两种方案在Redis层面同样都可以加个 `漏斗流完/桶加满耗时 * 1.5`的 `expire`时间。令牌桶算法的重难点应该是如何实现弹性限流/平滑限流? 同样值得思考的还有:如何准确的计算API网关的QPS?限流的大小又需要根据什么来评估? Last modification:August 22, 2022 © Allow specification reprint Like 0 喵ฅฅ