# Limitation **Repository Path**: bruce6213/limitation ## Basic Information - **Project Name**: Limitation - **Description**: 接口请求限流以及限制客户端IP - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-12-30 - **Last Updated**: 2024-12-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 限制请求IP ## 直连服务器 ``` request.getRemoteAddr(); ``` ## 存在中间代理服务器 ``` request.getHeader("X-Forwarded-For"); ``` 但请求头中的X-Forwarded-For字段非常容易被伪造(Postman),故还不能直接使用。 ### 原理 一般的客户端(例如浏览器)发送HTTP请求是没有`X-Forwarded-For`头的,当请求到达第一个代理服务器时,代理服务器会加上`X-Forwarded-For`请求头,并将值设为客户端的IP地址(也就是最左边第一个值),后面如果还有多个代理,会依次将IP追加到`X-Forwarded-For`头最右边,最终请求到达Web应用服务器,应用通过获取`X-Forwarded-For`头取左边第一个IP即为客户端真实IP。但是如果客户端在发起请求时,请求头上带上一个伪造的`X-Forwarded-For`,由于后续每层代理只会追加而不会覆盖,那么最终到达应用服务器时,获取的左边第一个IP地址将会是客户端伪造的IP。 ### 核心步骤 - 找到最外层代理服务器,添加如下配置: ``` // $remote_addr是获取的是直接TCP连接的客户端IP(类似于Java中的request.getRemoteAddr()),这个是无法伪造的 // 即使客户端伪造也会被覆盖掉,而不是追加 proxy_set_header X-Forwarded-For $remote_addr; ``` - 在其他代理服务器上添加如下配置: ``` // 追加字段 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` - 后端代码相关逻辑判断 ``` public String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) { // 处理多个代理服务器的情况 return ip.split(",")[0].trim(); } return request.getRemoteAddr(); } ``` ## 参考链接 [博客园](https://www.cnblogs.com/irvin-chen/p/14210508.html) # 接口限流 ## 固定窗口 **缺点:**存在边界问题;例如时间窗口为5秒,限流200个请求,如果前四秒没有请求,在第四秒到第六秒之间就有可能来了400个请求,导致违反了5秒内只允许200个请求的限制。 ![image-20241226094602826](https://gitee.com/bruce6213/image/raw/master/image-20241226094602826.png) ## 滑动窗口 只是表面解决了 ![image-20241226095652323](https://gitee.com/bruce6213/image/raw/master/image-20241226095652323.png) ## 漏桶算法 **本质:**将外部请求比作注入漏桶的水,漏桶会存储一定水量并以固定速率出水,即匀速通过请求,如果请求量超过漏桶容量则会被丢弃,消息中间件就是采用漏桶算法的思想。 **缺点:**虽然可以用漏桶出口的固定速率平滑突增流量,但也正是由于固定速率,使得在流量较小的时候也无法更快的处理请求。 ![image-20241226100417380](https://gitee.com/bruce6213/image/raw/master/image-20241226100417380.png) ## 令牌桶算法 Guava的RateLimiter ``` com.google.guava guava 31.0.1-jre ``` ![image-20241230135902869](https://gitee.com/bruce6213/image/raw/master/image-20241230135902869.png) ## 方案一 从第一次请求开始,通过redis 对请求进行计数并开始设置过期时间;以后的每一次请求都会去判断是否超过了请求次数。但该方案存在明显存在以下**两个不足**: 1. 在更新redis的value时无法同步过期时间,即存在一定的时间间隔,导致实际过期时间比预定过期时间要长。 2. 过期时间何时开始倒计时取决于第一次请求的到达时间:即**静态限流**,只能限定固定区间内的访问次数。 ``` if (redisUtil.hasKey(key.toString())) { // 当前已访问次数 int sum = (int) redisUtil.getCacheObject(key.toString()); if (sum >= count) { log.error("超过请求次数,请稍后再试, 过期时间:{}", redisUtil.getExpire(key.toString())); throw new LimitationException("超过请求次数,请稍后再试"); } else { // 修改redis的vaule,但不修改过期时间 // redisUtil.getExpire获取的是分钟 Date date = new Date(System.currentTimeMillis() + redisUtil.getExpire(key.toString()) * 1000); redisUtil.setCacheObject(key.toString(), sum + 1); // 为什么一直是当前时间: 因为你在上一步刚没有设置过期时间 redisUtil.expireAt(key.toString(), date); log.info("key剩余过期时间:{}", redisUtil.getExpire(key.toString())); } } else { // 第一次访问 redisUtil.setCacheObject(key.toString(), 1, time, TimeUnit.MILLISECONDS); } ``` ## 方案二 对每一次请求都设置一个key,并且设置过期时间;通过计算当前时间内redis中的key的个数(模糊查询)来判断该请求是否被接受。这样既解决了过期时间的同步问题,也实现了区间内访问次数的控制,达到了正在意义上的限流措施(**动态限流**:过期key不会计算在内)。 但该方案有**一个明显的缺陷**是会导致key过多,即如果间隔时间可能会很长,导致key一直没有过期。 ``` Collection keys = redisUtil.keys("*" + key.toString() + "*"); int size = keys.size() + 1; if (size > count) { log.error("超过请求次数,请稍后再试; 请求方法:{}", methodName); throw new LimitationException("超过请求次数,请稍后再试"); } else { /** * 区间范围内限流 */ redisUtil.setCacheObject(key.toString()+size, size, time, TimeUnit.MILLISECONDS); } ``` ## 方案三 使用redis自带的INCR命令:value自增但不影响过期时间;**但该方案能是静态限流** ``` if (redisUtil.hasKey(key.toString())){ // 不是第一次访问 int sum = (int) redisUtil.getCacheObject(key.toString()); if (sum >= count){ log.error("超过请求次数,请稍后再试, 过期时间:{}", redisUtil.getExpire(key.toString())); throw new LimitationException("超过请求次数,请稍后再试"); }else{ redisUtil.incr(key.toString()); } }else{ // 第一次访问 redisUtil.setCacheObject(key.toString(), 1, time, TimeUnit.MILLISECONDS); } ``` ## 方案四(推荐) 使用redis的列表存储每次访问的时间戳,每次请求到达先清理“过期”的元素(**滑动窗口**) ``` public boolean checkLimitation(String key, long time, int count, Integer interval){ List cacheList = redisUtil.getCacheList(key); // 删除过期的时间戳 cacheList.removeIf(o -> (long)o + interval < time); if (cacheList.size() >= count){ // 超过限制次数 return false; }else { redisUtil.addList(key, time); return true; } } ```