为什么要限流 互联网系统通常都要面对高并发请求(如秒杀、抢购等),难免会对后端服务造成高压力,严重甚至会导致系统宕机。为避免这种问题通常会添加限流、降级、熔断等能力,从而使系统更为健壮。
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