为什么要限流 互联网系统通常都要面对高并发
请求(如秒杀、抢购等),难免会对后端服务造成高压力,严重甚至会导致系统宕机。为避免这种问题通常会添加限流
、降级
、熔断
等能力,从而使系统更为健壮。
Java领域常见的开源组件有Netflix
的hystrix
,阿里系开源的sentinel(以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性)
等,都是蛮不错的限流熔断框架。
限流维度 QPS和连接数控制 设定IP维度的限流,也可以设置基于单个服务器的限流。在真实环境中通常会设置多个维度的限流规则,比如设定同一个IP每秒访问频率小于10,连接数小于5,再设定每台机器QPS最高1000,连接数最大保持200。
小知识:
吞吐量(TPS)
:指系统在单位时间内处理请求的数量。
QPS 每秒查询率(Query Per Second)
:对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准(类似于TPS,只是应用于特定场景的吞吐量)。
传输速率 有的网站在这方面的限流逻辑做的更细致,比如普通注册用户下载速度为100k/s,购买会员后是10M/s,这背后就是基于用户组或者用户标签的限流逻辑。
黑白名单 如果某个IP在一段时间的访问次数过于频繁,被系统识别为机器人用户或流量攻击,那么这个IP就会被加入到黑名单,从而限制其对系统资源的访问,这就是俗称的“封IP”。
比如爬虫程序爬知乎上的美女图片,或者爬券商系统的股票分时信息,这类爬虫程序都必须实现更换IP的功能,以防被加入黑名单。
有时发现公司的网络无法访问12306这类大型公共网站,这也是因为某些公司的出网IP是同一个地址,因此在访问量过高的情况下,这个IP地址就被对方系统识别,进而被添加到了黑名单。
白名单可以自由穿梭在各种限流规则里,畅行无阻。比如某些电商公司会将超大卖家的账号加入白名单,因为这类卖家往往有自己的一套运维系统,需要对接公司的IT系统做大量的商品发布、补货等等操作。
分布式环境 分布式区别于单机限流的场景,它把整个分布式环境中所有服务器当做一个整体来考量。比如说针对IP的限流,限制1个IP每秒最多10个访问,不管来自这个IP的请求落在了哪台机器上,只要是访问了集群中的服务节点,那么都会受到限流规则的制约。
必须将限流信息保存在一个“中心化”的组件上,这样它就可获取到集群中所有机器的访问状态,目前有两个比较主流的限流方案
:
网关层限流
将限流规则应用在所有流量的入口处
中间件限流
将限流信息存储在分布式环境中某个中间件里(比如Redis缓存),每个组件都可以从这里获取到当前时刻的流量统计,从而决定是拒绝服务还是放行流量
基础算法 固定窗口算法 固定窗口算法又叫计数器算法,是一种简单方便的限流算法。主要通过一个支持原子操作的计数器来累计 1 秒内的请求次数,当 1 秒内计数达到限流阈值时触发拒绝策略。每过 1 秒,计数器重置为 0 开始重新计数。
如:使用 AomicInteger
来进行统计当前正在并发执行的次数,如果超过域值就直接拒绝请求,提示系统繁忙。
但固定窗口算法存在问题,比如当遇到时间窗口的临界突变
时,如 1s 中的后 500 ms 和第 2s 的前 500ms 时,虽然是加起来是 1s时间,却可以被请求 4 次。
滑动窗口算法 滑动窗口算法是对固定窗口算法的改进。既然固定窗口算法在遇到时间窗口的临界突变时会有问题,那么在遇到下一个时间窗口前调整时间窗口是否可以?
每 500ms 滑动一次窗口,可以发现窗口滑动的间隔越短
,时间窗口的临界突变
问题发生的概率也就越小
,不过只要有时间窗口的存在,还是有可能发生时间窗口的临界突变问题
。
漏桶算法
漏桶算法思路可把水
比作是请求
,漏桶
比作是系统处理能力
极限,水先进入到漏桶里,漏桶里的水按一定速率流出,当流出的速率小于流入的速率时,由于漏桶容量有限,后续进入的水直接溢出(拒绝请求),以此实现限流。
令牌桶算法
令牌桶算法
原理可以理解成医院的挂号看病
,只有拿到号以后才可以进行诊病。
系统会维护一个令牌(token)桶
,以一个恒定的速度
往桶里放入令牌
(token),这时如果有请求进来想要被处理,则需要先从桶里获取一个令牌
(token),当桶里没有令牌
(token)可取时,则该请求将被拒绝服务
。令牌桶算法通过控制桶的容量、发放令牌的速率,来达到对请求的限制。
小结
窗口算法实现简单,逻辑清晰,可以很直观的得到当前的QPS情况,但是会有时间窗口的临界突变问题,而且不像桶一样有队列可以缓冲。
漏桶模式消费速率恒定,可以很好的保护自身系统,可以对流量进行整形,但是面对突发流量不能快速响应。
令牌桶模式可以面对突发流量,但是启动时会有缓慢加速的过程,不过常见的开源工具中已经对此优化。
RateLimiter 限流 Google
开源工具包Guava
提供了限流工具类RateLimiter
,该类基于令牌桶算法实现流量限制,使用十分方便,而且十分高效。
常用方法如下,其中create()
与tryAcquire()
是RateLimiter的2个核心方法
方法名
描述
create(double permitsPerSecond)
根据每秒
的固定速率
进行放置permitsPerSecond个令牌来创建RateLimiter
create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)
根据每秒
的固定速率
(permitsPerSecond)和预热期
(warmupPeriod)来创建RateLimiter;在预热时间内,RateLimiter每秒分配的令牌数会平稳增长
,直到预热期结束时达到其最大速率。
acquire()
获取一个令牌,改方法会阻塞直到获取到这一个令牌,返回值为获取到这个令牌花费的时间
acquire(int permits)
获取指定数量的令牌
,该方法也会阻塞,返回值为获取到这 N 个令牌花费的时间
tryAcquire()
判断是否能获取到令牌
,如果不能获取立即返回 false
tryAcquire(int permits)
获取指定数量的令牌
,如果不能获取立即返回false
tryAcquire(long timeout, TimeUnit unit)
判断能否在指定时间内获取到令牌
,如果不能获取立即返回false
tryAcquire(int permits, long timeout, TimeUnit unit)
判断能否在指定时间
内获取到指定令牌
,如果不能获取立即返回false
平滑突发限流(SmoothBursty) 使用 RateLimiter的静态方法创建一个限流器,比如设置每秒放置的令牌数为5
个。返回的RateLimiter对象可以保证1秒内不会给超过5个令牌
,并且以固定速率进行放置
,达到平滑输出的效果。
RateLimiter r = RateLimiter.create(5 );r.acquire();
RateLimiter使用令牌桶算法,会进行令牌的累积
,如果获取令牌
的频率比较低
,则不会导致等待
,直接获取令牌。
平滑预热限流(SmoothwarmingUp) RateLimiter带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。
如下由于设置了预热时间是3秒
,令牌桶一开始并不会0.5秒发一个令牌
,而是随着频率越来越高,在3秒钟之内达到原本设置的频率
,以后就以固定的频率输出。这种功能适合系统刚启动
需要一点时间来“热身”的场景。
RateLimiter r = RateLimiter.create(2 , 3 , TimeUnit.SECONDS);r.acquire();
预消费 RateLimiter由于会累积令牌
,所以可以应对突发流量
。每次请求,acquire获取令牌,但是acquire还有个acquire(int permits)
的重载方法,即允许每次获取多个令牌数
。
public void testRateLimiter2 () { RateLimiter rateLimiter = RateLimiter.create(1 ); double cost = rateLimiter.acquire(1 ); System.out.println("获取1个令牌" + ", 耗时" + cost + "ms" ); cost = rateLimiter.acquire(5 ); System.out.println("获取5个令牌" + ", 耗时" + cost + "ms" ); cost = rateLimiter.acquire(3 ); System.out.println("获取3个令牌" + ", 耗时" + cost + "ms" ); }
获取1个令牌, 耗时0.0ms 获取5个令牌, 耗时0.997237ms 获取3个令牌, 耗时4.996529ms
这就是预消费能力,RateLimiter中允许一定程度突发流量的实现方式
。第二次需要获取5个令牌,指定的是每秒放1个令牌
到桶中,实际上并没有等5秒钟,等桶中积累了5个令牌
,才让acquire成功,而是直接等了1秒钟就成功了。逻辑如下:
第一次请求过来需要获取1个令牌,直接拿到
RateLimiter在1秒钟后
放一个令牌,还上
了第一次请求预支
的1个令牌
1秒钟之后
第二次请求过来需要获得5个令牌,直接拿到
RateLimiter在花了5秒钟放了5个令牌
,还上了第二次请求预支的5个令牌
第三次请求在5秒钟之后
拿到3个令牌
前面的请求
如果流量大于``每秒放置令牌的数量
,允许处理
,但是带来的结果就是后面
的请求延后处理
,从而在整体上达到一个平衡整体处理速率
的效果。
突发流量
的处理,在令牌桶算法中有两种方式:
有足够令牌,才能消费
先消费后,还令牌
先让请求得到处理,再慢慢还上预支的令牌,用户体验得到提升,否则假设预支60个令牌,1分钟之后才能处理请求,不合理也不人性化。
示范案例 依赖 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-aop</artifactId > </dependency > <dependency > <groupId > com.google.guava</groupId > <artifactId > guava</artifactId > <version > 30.1-jre</version > </dependency >
自定义注解 package cn.goitman.annotation;import java.lang.annotation.*;import java.util.concurrent.TimeUnit;@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) public @interface Restrict { String key () default "" ; long limitedNumber () default 1 ; long timeout () default 500 ; TimeUnit timeunit () default TimeUnit.MILLISECONDS; String msg () default "活动火爆,请稍候再试!" ; }
预加载 package cn.goitman.commandrunner;import com.google.common.collect.Maps;import com.google.common.util.concurrent.RateLimiter;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.boot.CommandLineRunner;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import java.util.HashMap;import java.util.Map;import java.util.Set;@Component @Order(0) public class StartRunner implements CommandLineRunner { private static Logger log = LoggerFactory.getLogger(StartRunner.class); public static final Map<String, RateLimiter> rateLimiterMap = Maps.newConcurrentMap(); @Override public void run (String... args) throws Exception { RateLimiter rateLimiter = null ; Map<String, Integer> limitMap = new HashMap <>(); limitMap.put("restrict1" ,1 ); limitMap.put("restrict2" ,2 ); Set<Map.Entry<String, Integer>> entries = limitMap.entrySet(); for (Map.Entry<String, Integer> entry : entries) { rateLimiter = RateLimiter.create(entry.getValue()); log.info("创建令牌桶 : {},大小为{}" , entry.getKey(), entry.getValue()); rateLimiterMap.put(entry.getKey(),rateLimiter); } } }
切面拦截 package cn.goitman.aspect;import cn.goitman.annotation.Restrict;import com.google.common.collect.Maps;import com.google.common.util.concurrent.RateLimiter;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.reflect.MethodSignature;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Component;import java.lang.reflect.Method;import java.util.Map;@Aspect @Component public class RestrictAspect { private static Logger log = LoggerFactory.getLogger(RestrictAspect.class); private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap(); @Around("@annotation(cn.goitman.annotation.Restrict)") public Object around (ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); Restrict restrict = method.getAnnotation(Restrict.class); if (restrict != null ) { String key = restrict.key(); RateLimiter rateLimiter = null ; synchronized (this ) { if (!limitMap.containsKey(key)) { rateLimiter = RateLimiter.create(restrict.limitedNumber()); log.info("创建令牌桶 : {},大小为{}" , key, restrict.limitedNumber()); limitMap.put(key, rateLimiter); } } rateLimiter = limitMap.get(key); boolean acquire = rateLimiter.tryAcquire(restrict.timeout(), restrict.timeunit()); if (!acquire) { log.error("{}:获取令牌失败" , key); return null ; } else { log.info("令牌桶 : {},获取令牌成功" , key); } } return joinPoint.proceed(); } }
限流接口 package cn.goitman.controller;import cn.goitman.annotation.Restrict;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;@RestController public class RestrictController { @GetMapping("/restrict1") @Restrict(key = "restrict1", limitedNumber = 1, msg = "当前排队人数较多,请稍后再试!") public String restrict1 () { return "OK" ; } @GetMapping("/restrict2") @Restrict(key = "restrict2", limitedNumber = 2) public String restrict2 () { return "OK" ; } }
并发测试 模拟1秒内10个并发线程,依次请求restrict1、restrict2接口各一次,日志如下
预加载日志:
2022-03-16 17:09:15.709 [TRACEID:] INFO [main] cn.goitman.commandrunner.StartRunner : 创建令牌桶 : restrict2,大小为2 2022-03-16 17:09:15.710 [TRACEID:] INFO [main] cn.goitman.commandrunner.StartRunner : 创建令牌桶 : restrict1,大小为1
restrict1接口日志:
2022-03-14 16:25:58.886 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 创建令牌桶 : restrict1,大小为1 2022-03-14 16:25:58.900 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict1,获取令牌成功 2022-03-14 16:25:58.900 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:58.900 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:58.901 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:58.901 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:58.902 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:58.934 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:59.038 [TRACEID:] ERROR [http-nio-8080-exec-9] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:59.136 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败 2022-03-14 16:25:59.277 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : restrict1:获取令牌失败
restrict2接口日志:
2022-03-14 16:46:02.434 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 创建令牌桶 : restrict2,大小为2 2022-03-14 16:46:02.436 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict2,获取令牌成功 2022-03-14 16:46:02.451 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:02.559 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:02.671 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:02.767 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:02.868 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:02.935 [TRACEID:] INFO [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict2,获取令牌成功 2022-03-14 16:46:03.060 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:03.153 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : restrict2:获取令牌失败 2022-03-14 16:46:03.433 [TRACEID:] INFO [http-nio-8080-exec-9] cn.goitman.aspect.RestrictAspect : 令牌桶 : restrict2,获取令牌成功
从restrict1和restrict2接口日志可以看出,1秒钟内只有1次(restrict1)或2次(restrict2)获取令牌成功,其他都失败,说明已经成功给接口加上了限流功能。
2022-03-14 21:01:28.034 [TRACEID:] INFO [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 获取令牌成功 2022-03-14 21:01:28.241 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : 获取令牌失败 2022-03-14 21:01:28.330 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 获取令牌失败 2022-03-14 21:01:28.439 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 获取令牌失败 2022-03-14 21:01:28.490 [TRACEID:] INFO [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 获取令牌成功 2022-03-14 21:01:28.676 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 获取令牌失败 2022-03-14 21:01:28.735 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 获取令牌失败 2022-03-14 21:01:29.026 [TRACEID:] INFO [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 获取令牌成功
局限 预加载只能在项目启动时加载一次,不够灵活;有人说为什么不用Redis保存RateLimiter对象呢?
众所周知任何数据存储都需要序列化,而Redis不会主动去做这个事情,看看下图
由图可知:
SmoothRateLimiter继承RateLimiter,两者都是抽象类
(不能实例化)
SmoothRateLimiter有SmoothWarmingUp和SmoothBursty两个默认修饰的匿名内部静态类
(外部无法调用)
只能依赖RateLimiter提供的静态方法来创建具体的子类实例
又有人说Spring的redisTemplate默认会使用java serialization做序列化
,说的没错,但RateLimiter是抽象类,即使使用也会报如下错误
# 非法参数异常,默认的序列化器需要一个Serializable有效负载,但是接收到一个类型为[com.google.common.util.concurrent.SmoothRateLimiter$SmoothBursty]的对象 java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.google.common.util.concurrent.SmoothRateLimiter$SmoothBursty] at org.springframework.core.serializer.DefaultSerializer.serialize(DefaultSerializer.java:43) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE] at org.springframework.core.serializer.Serializer.serializeToByteArray(Serializer.java:56) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE] at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:60) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE] at org.springframework.core.serializer.support.SerializingConverter.convert(SerializingConverter.java:33) ~[spring-core-5.2.8.RELEASE.jar:5.2.8.RELEASE] at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:94) ~[spring-data-redis-2.3.2.RELEASE.jar:2.3.2.RELEASE] at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:127) ~[spring-data-redis-2.3.2.RELEASE.jar:2.3.2.RELEASE] at org.springframework.data.redis.core.DefaultValueOperations.set(DefaultValueOperations.java:235) ~[spring-data-redis-2.3.2.RELEASE.jar:2.3.2.RELEASE]
结论:抽象类不能实例化,就不能序列化,自然Redis保存不了
RateLimiter是单机限流
,假设集群中部署了10台服务器,想要保证集群1000QPS的接口调用量,那么RateLimiter就不适用了;集群流控最常见的方法,还是Redis + Lua 限流。
Redis + Lua 限流 Redis内置了Lua解释器,实现分布式的令牌桶算法,能够很好的满足原子性、事务性的支持,免去了很多的异常逻辑处理:
减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;
原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用。
Lua脚本 简介 Lua是由标准的C语言编写的,源码部分不过2万多行C代码,甚至一个完整的Lua解释器也就200k的大小。
安装 Windows 环境 进入下载网址 下载lua绿色压缩版
配置环境变量
win + R 快捷键进入cmd,验证是否安装配置成功
Mac 环境 建议用brew工具直接执行brew install lua就可以顺利安装,有关brew工具的安装可以参考 https://brew.sh/ 网站,使用brew安装后的目录在/usr/local/Cellar/lua/X.X.X_X
linux 环境 //从官网下载安装包 curl -R -O http://www.lua.org/ftp/lua-5.3.5.tar.gz //解压安装包 tar zxf lua-5.3.5.tar.gz //进入文件夹中 cd lua-5.3.5//如果安装了readline,直接进行↓(若无安装会报错,解决方法看下方) make linux test //安装 sudo make install
如果在make Linux test处系统报错如下:
gcc -std=gnu99 -O2 -Wall -Wextra -DLUA_COMPAT_5_2 -DLUA_USE_LINUX -c -o lua.o lua.c lua.c:82:31: error: readline/readline.h: No such file or directory lua.c:83:30: error: readline/history.h: No such file or directory lua.c: In function ‘pushline’: lua.c:312: warning: implicit declaration of function ‘readline’ lua.c:312: warning: assignment makes pointer from integer without a cast lua.c: In function ‘addreturn’: lua.c:339: warning: implicit declaration of function ‘add_history’ make[2]: *** [lua.o] Error 1 make[2]: Leaving directory `/home/Workspace/lua-5.3.5/src' make[1]: *** [linux] Error 2 make[1]: Leaving directory `/home/Workspace/lua-5.3.5/src' make: *** [linux] Error 2
由于没有下载安装readline, 缺少libreadline-dev。打开终端输入
sudo apt-get install libreadline-dev
创建一个 HelloWorld.lua 文件,验证是否安装成功,代码如下:
执行以下命令:
输出结果为:
IDEA 插件安装 File -> Settings -> Plugins
,搜索lua
,选中EmmyLua
插件安装
示范案例 依赖 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > <exclusions > <exclusion > <artifactId > lettuce-core</artifactId > <groupId > io.lettuce</groupId > </exclusion > </exclusions > </dependency > <dependency > <groupId > redis.clients</groupId > <artifactId > jedis</artifactId > </dependency >
Redis配置 Redis单机简易配置如下,分布式配置可参考 Redis 教程
spring: redis: database: 0 host: 127.0 .0 .1 port: 6379 jedis: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0
自定义注解 在@Restrict
注解类,原基础上加多个expire(过期时间)
属性
package cn.goitman.annotation;import java.lang.annotation.*;import java.util.concurrent.TimeUnit;@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD}) public @interface Restrict { String key () default "" ; long limitedNumber () default 1 ; long expire () default 10 ; long timeout () default 500 ; TimeUnit timeunit () default TimeUnit.MILLISECONDS; String msg () default "活动火爆,请稍候再试!" ; }
切面拦截 package cn.goitman.aspect;import cn.goitman.annotation.Restrict;import com.google.common.base.Preconditions;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.Signature;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.annotation.Pointcut;import org.aspectj.lang.reflect.MethodSignature;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.scripting.support.ResourceScriptSource;import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;import java.util.ArrayList;import java.util.List;@Aspect @Component public class RestrictAspect { private static Logger log = LoggerFactory.getLogger(RestrictAspect.class); @Autowired StringRedisTemplate stringRedisTemplate; private DefaultRedisScript<Long> script; @PostConstruct public void init () { script = new DefaultRedisScript <>(); script.setResultType(Long.class); script.setScriptSource(new ResourceScriptSource (new ClassPathResource ("slidingLimter.lua" ))); log.info("Lua 脚本加载完成!" ); } @Pointcut("@annotation(cn.goitman.annotation.Restrict)") public void restrict () { } @Around("@annotation(restrict)") public Object around (ProceedingJoinPoint joinPoint, Restrict restrict) throws Throwable { Signature signature = joinPoint.getSignature(); if (!(signature instanceof MethodSignature)) { throw new IllegalArgumentException ("@Restrict 注解只能在方法上使用!" ); } String key = restrict.key(); Preconditions.checkNotNull(key); String limitTimes = String.valueOf(restrict.limitedNumber()); String expireTime = String.valueOf(restrict.expire()); List<String> keyList = new ArrayList (); keyList.add(key); Long result = (Long) stringRedisTemplate.execute(script, keyList, expireTime, limitTimes); if (result == 0 ) { log.error(restrict.msg()); return null ; }else { log.info("请求正常!" ); } return joinPoint.proceed(); } }
Lua脚本 在此例举固定窗口限流
和滑动窗口限流
两种Lua脚本,Redis的数据保存方式不同,任选其一。
固定窗口限流 --- --- Created by Nicky. --- blog:goitman.cn | blog.csdn.net/minkeyto --- DateTime: 2022 /3 /17 16 :20 --- 固定窗口限流 --- --- 获取 execute(RedisScript<T> script,List<K> keys,Object... args)方法中的keys值 local key1 = KEYS[1 ] --- 使用 key 做自增操作,初始值为1 local val = redis.call('incr' , key1) --- 查询key的过期时间(未设置过期时间,默认值为-1 ) local ttl = redis.call('ttl' , key1) --- 获取execute(RedisScript<T> script,List<K> keys,Object... args)方法中args参数的第一个和第二个参数 local expire = ARGV[1 ] local number = ARGV[2 ] --- 在redis控制台打印日志 redis.log (redis.LOG_NOTICE,tostring(number)) redis.log (redis.LOG_NOTICE,tostring(expire)) redis.log (redis.LOG_NOTICE, "incr " ..key1.." " ..val); --- 如果 key 值为1 ,设置过期时间 if val == 1 then redis.call('expire' , key1, tonumber(expire)) else --- 如果key已存在,并未设置过期时间的情况下,重新设置过期时间 if ttl == -1 then redis.call('expire' , key1, tonumber(expire)) end end --- 如果自增数大于限流数,触发限流 if val > tonumber(number) then return 0 end --- 未触发限流,正常请求,返回1 return 1
滑动窗口限流 --- --- Created by Nicky. --- blog:goitman.cn | blog.csdn.net/minkeyto --- DateTime: 2022 /3 /17 17 :20 --- 滑动窗口限流 --- --- 移除时间窗口之前的数据 redis.call('zremrangeByScore' , KEYS[1 ], 0 , ARGV[1 ]) --- 统计当前元素数量 local res = redis.call('zcard' , KEYS[1 ]) --- 是否超过阈值,判断key是否存在,不存在则创建一个空的有序集并执行 if (res == nil) or (res < tonumber(ARGV[3 ])) then redis.call('zadd' , KEYS[1 ], ARGV[2 ], ARGV[4 ]) return 1 else return 0 end
并发测试 用回上述限流接口测试并发(限流注解中的过期时间默认为10秒
),日志如下: restrict1接口日志:
2022-03-17 20:27:26.812 [TRACEID:] INFO [main] cn.goitman.aspect.RestrictAspect : Lua 脚本加载完成! 2022-03-17 20:27:27.457 [TRACEID:] INFO [main] org.apache.coyote.http11.Http11NioProtocol : Starting ProtocolHandler ["http-nio-8080"] 2022-03-17 20:27:27.510 [TRACEID:] INFO [main] cn.goitman.ThrottlingApplication : Started ThrottlingApplication in 3.755 seconds (JVM running for 7.728) 2022-03-17 20:27:33.748 [TRACEID:] INFO [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 请求正常! 2022-03-17 20:27:33.748 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:33.844 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:33.931 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:34.023 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:34.205 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:34.231 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:34.358 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:27:34.452 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
restrict2接口日志:
2022-03-17 20:28:43.691 [TRACEID:] INFO [main] cn.goitman.aspect.RestrictAspect : Lua 脚本加载完成! 2022-03-17 20:28:44.343 [TRACEID:] INFO [main] org.apache.coyote.http11.Http11NioProtocol : Starting ProtocolHandler ["http-nio-8080"] 2022-03-17 20:28:44.399 [TRACEID:] INFO [main] cn.goitman.ThrottlingApplication : Started ThrottlingApplication in 4.176 seconds (JVM running for 9.37) 2022-03-17 20:28:49.519 [TRACEID:] INFO [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 请求正常! 2022-03-17 20:28:49.519 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 请求正常! 2022-03-17 20:28:49.520 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:49.572 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:49.698 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:49.776 [TRACEID:] ERROR [http-nio-8080-exec-8] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:49.900 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:49.974 [TRACEID:] ERROR [http-nio-8080-exec-9] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:50.088 [TRACEID:] ERROR [http-nio-8080-exec-2] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:50.203 [TRACEID:] ERROR [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:54.599 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:54.706 [TRACEID:] ERROR [http-nio-8080-exec-8] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:54.814 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:54.936 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:55.067 [TRACEID:] ERROR [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:55.113 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:55.215 [TRACEID:] ERROR [http-nio-8080-exec-5] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:55.311 [TRACEID:] ERROR [http-nio-8080-exec-8] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:55.413 [TRACEID:] ERROR [http-nio-8080-exec-7] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:55.521 [TRACEID:] ERROR [http-nio-8080-exec-10] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:28:59.718 [TRACEID:] INFO [http-nio-8080-exec-4] cn.goitman.aspect.RestrictAspect : 请求正常! 2022-03-17 20:28:59.831 [TRACEID:] INFO [http-nio-8080-exec-1] cn.goitman.aspect.RestrictAspect : 请求正常! 2022-03-17 20:28:59.922 [TRACEID:] ERROR [http-nio-8080-exec-3] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试! 2022-03-17 20:29:00.028 [TRACEID:] ERROR [http-nio-8080-exec-6] cn.goitman.aspect.RestrictAspect : 活动火爆,请稍候再试!
根据日志可看出,正常请求和正常触发限流,说明Lua脚本限流逻辑生效。
Nginx 限流
Windows环境 nginx 资源下载
iP限流
Windows 10 中 hosts 文件位置:C:\Windows\System32\drivers\etc\hosts
Linux 中 hosts 文件位置:/etc/hosts
将上述域名,添加到路由规则当中
vim /usr/local/nginx/conf/nginx.conf
# $binary_remote_addr:binary_目的是缩写内存占用,remote_addr表示通过IP地址来限流 # zone=iplimit:20m:iplimit是一块内存区域(记录访问频率信息),20m是指这块内存区域的大小 # rate=1r/s:每秒放行1个请求 limit_req_zone $binary_remote_addr zone=iplimit:20m rate=1r/s; server{ server_name www.goitman.cn; location /limit/ { proxy_pass http://127.0.0.1:8080/; # zone=iplimit:引用limit_rep_zone中的zone变量 # burst=2:设置一个大小为2的缓冲区域,当大量请求到来,请求数量超过限流频率时,将其放入缓冲区域 # nodelay:缓冲区满了以后,直接返回503异常 limit_req zone=iplimit burst=2 nodelay; } }
访问地址,测试是否限流
www.goitman.cn/limit/restrict1
多维度限流 修改nginx.conf配置
#根据IP地址限制速度 limit_req_zone $binary_remote_addr zone=iplimit:20m rate=10r/s; #根据服务器级别做限流 limit_req_zone $server_name zone=serverlimit:10m rate=1r/s; #根据ip地址的链接数量做限流 limit_conn_zone $binary_remote_addr zone=perip:20m; #根据服务器的连接数做限流 limit_conn_zone $server_name zone=perserver:20m; server{ server_name www.goitman.cn; location /limit/ { proxy_pass http://127.0.0.1:8080/; #基于ip地址的限制 limit_req zone=iplimit burst=2 nodelay; #基于服务器级别做限流 limit_req zone=serverlimit burst=2 nodelay; #基于ip地址的链接数量做限流 最多保持100个链接 limit_conn zone=perip 100; #基于服务器的连接数做限流 最多保持100个链接 limit_conn zone=perserver 1; #配置request的异常返回504(默认为503) limit_req_status 504; limit_conn_status 504; } location /download/ { #前100m不限制速度 limit_rate_affer 100m; #限制速度为256k limit_rate 256k; } }
后语 一般在系统上线时需要通过对系统压测,评估出系统的性能阀值,然后给接口加上合理的限流参数,防止出现大流量请求时直接压垮系统。
源码地址:https://github.com/wangdaicong/spring-boot-project/tree/master/throttling-demo