保障服务稳定的三大利器:熔断降级、服务限流和故障模拟。限流可是Nginx接入层面的限流,也可以是服务层面的限流。
使用Nginx做服务限流
http
{
limit_req_zone$binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location /test/ {
limit_reqzone=mylimit;
proxy_passhttp://backend;
}
}
}
其中4到8行定义的是一个服务器接口。而第2行和第6行配合完成了一个限流设置,下面解释一下这两行做的事情:
limit_req_zone命令在Nginx的配置文件中专门用于定义限流,它必须被放在http块中,否则无法生效,因为该命令只在http中被定义。
该字段包含三个参数:
第一个参数,就是键(key),即值binary_remote_addr,它是Nginx内置的一个值,代表的是客户端的IP地址的二进制表示。因此换言之,我们的示例配置,是希望限流系统以客户端的IP地址为键进行限流。
对Nginx有经验的读者可能还知道有一个Nginx内置值为binary_remote_addr是Nginx的社区推荐用值,因为它是二进制表达,占用的空间一般比字符串表达的$remote_addr要短一些,在寸土寸金的限流系统中尤为重要。
第二个参数是限流配置的共享内存占用(zone)。为了性能优势,Nginx将限流配置放在共享内存中,供所有Nginx的进程使用,因为它占用的是内存,所以我们希望开发者能够指定一个合理的、既不浪费又能存储足够信息的空间大小。根据实践经验,1MB的空间可以储存16000个IP地址。
该参数的语法是用冒号隔开的两个部分,第一部分是给该部分申请的内存一个名字,第二部分是我们希望申请的内存大小。
因此,在该声明中,我们声明了一个名叫mylimit(我的限制)的内存空间,然后它的大小是10M,即可以存储160000个IP地址,对于实验来说足够了。
第三个配置就是访问速率(rate)了,格式是用左斜杠隔开的请求数和时间单位。这里的访问速率就是最大速率,因此10r/s就是每秒10个请求。通过这台Nginx服务器访问后端服务器的请求速率无法超过每秒10个请求。
注意到第5行声明了一个资源位置/test/,因此我们第6行的配置就是针对这个资源的,通俗地说,我们在第6行的配置是针对特定API的,这个API就是路径为/test/的API,而其真正路径就是第8行声明的http://backend。注意,这个URL是不存在的,实际操作中,读者需要将它换成你已经开发好的业务逻辑所在的位置,Nginx在这里的作用只是一个反向代理,它自己本身没有资源。
第6行中,我们使用limit_req命令,声明该API需要一个限流配置,而该限流配置所在位置(zone)就是mylimit。
这样一来,所有发往该API的请求会先读到第6行的限流配置,然后根据该限流配置mylimit的名称找到声明在第2行的参数,然后决定该请求是否应该被拒绝。
但是这样还不够。不要忘了,Nginx使用的漏桶算法,不是时间窗口算法,我们前文介绍中说过,漏桶算法是有两个参数可以配置的!
(4)配置峰值。Nginx漏桶算法的峰值属性在API中设置。参数名为burst。如下:
http {
limit_req_zone$binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location /test/ {
limit_reqzone=mylimit burst=20;
proxy_passhttp://backend;
}
}
}
在第6行中,我们只需要在声明limit_req的同时,指定burst就可以了,此处我们指定burst为20,即漏桶算法中我们的“桶”最多可以接受20个请求。这样一个Nginx的限流系统就配置完毕了,但实际操作中,我们还可能需要很多别的功能,下面笔者就介绍几个很有用的配置技巧。
image
1.****加快Nginx****转发速度
相对于传统的漏桶算法慢吞吞地转发请求的缺陷,Nginx实现了一种漏桶算法的优化版,允许开发者指定快速转发,而且还不影响正常的限流功能。开发者只需要在指定limit_req的一行中指定burst之后指定另一个参数nodelay,就可以在请求总数没有超过burst指定值的情况下,迅速转发所有请求了。如下所示:
http {
limit_req_zone$binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location /test/ {
limit_reqzone=mylimit burst=20 nodelay;
proxy_passhttp://backend;
}
}
}
读者可能会担忧:这种情况下,会不会出现所有请求都被快速转发,然后接下来又有没有超过burst数量的请求出现,再次被快速转发,就好像固定窗口算法的漏洞一样,从而超过我们本来希望它能限制到的上限数量呢?答案是不会。Nginx的快速转发是这样实现的:· 当有没有超过burst上限的请求数量进入系统时,快速转发,然后将当前桶中可以填充的数量标为0;****· 按照我们设置的rate在1秒中内缓慢增加桶中余额,以我们的配置为例,每隔100毫秒,增加1个空位;****· 在增加空位的过程中,进来超过空位数量的请求,直接拒绝。举例而言,配置如上所示,假如在某个瞬时有25个请求进入系统,Nginx会先转发20个(或21个,取决于瞬时情况),然后拒绝剩下的4个请求,并将当前桶中数量标为0,然后接下来的每100毫秒,缓慢恢复1个空位。这样我们可以看到,Nginx既做到了快速转发消息,又不会让后端服务器承担过多的流量。
image2.****为限流系统配置日志级别
限流系统会提前拒绝请求,因此,我们在业务服务器上是肯定看不到这些请求的。假如我们收到一个报告说某用户在使用网站的时候出现错误,但是我们在业务服务器上又找不到相关的日志,我们如何确定是不是限流造成的呢?只有限流系统的日志才能说明问题。因此,我们需要Nginx打印出它拒绝掉的请求的信息。但同时,Nginx打印的限流日志默认是错误(error),如果我们设置了一个基于日志错误扫描的警报,它扫到的限流错误,真的是我们希望给自己发警报的情况吗?配置请求的位置就在资源中,使用的命令是limit_req_log_level,如下:
http {
limit_req_zone$binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location /test/ {
limit_reqzone=mylimit burst=20;
limit_req_log_level warn;
proxy_passhttp://backend;
}
}
}
在第7行中,我们将Nginx的日志改为了警告(warn)。
image
3.****更换Nginx****的限流响应状态码
前文我们就说过,从语义上来说,限流的HTTP标准响应状态码是429,但是如果读者拿上述的配置文件直接去测试,会发现Nginx返回的是503(服务不可用)。到底应该返回什么状态码,是一个偏程序哲学的问题,此处我们不讨论,我们只讨论:如何让Nginx返回我们指定的状态码?答案也是在同一个资源中,它的配置命令是limit_req_status,然后我们指定它为429即可:
http {
limit_req_zone$binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
location /test/ {
limit_reqzone=mylimit burst=20;
limit_req_log_level warn;
limit_req_status 429;
proxy_passhttp://backend;
}
}
}
为什么需要限流
按照服务的调用方,可以分为以下几种类型服务
1、与用户打交道的服务
比如web服务、对外API,这种类型的服务有以下几种可能导致机器被拖垮:
-
用户增长过快(这是好事)
-
因为某个热点事件(微博热搜)
-
竞争对象爬虫
-
恶意的刷单
这些情况都是无法预知的,不知道什么时候会有10倍甚至20倍的流量打进来,如果真碰上这种情况,扩容是根本来不及的(弹性扩容都是虚谈,一秒钟你给我扩一下试试)
2、对内的RPC服务
一个服务A的接口可能被BCDE多个服务进行调用,在B服务发生突发流量时,直接把A服务给调用挂了,导致A服务对CDE也无法提供服务。 这种情况时有发生,解决方案有两种: 1、每个调用方采用线程池进行资源隔离 2、使用限流手段对每个调用方进行限流
限流算法实现
常见的限流算法有:计数器、令牌桶、漏桶。
1、计数器算法
采用计数器实现限流有点简单粗暴,一般我们会限制一秒钟的能够通过的请求数,比如限流qps为100,算法的实现思路就是从第一个请求进来开始计时,在接下去的1s内,每来一个请求,就把计数加1,如果累加的数字达到了100,那么后续的请求就会被全部拒绝。等到1s结束后,把计数恢复成0,重新开始计数。
具体的实现可以是这样的:对于每次服务调用,可以通过 AtomicLong#incrementAndGet()
方法来给计数器加1并返回最新值,通过这个最新值和阈值进行比较。
这种实现方式,相信大家都知道有一个弊端:如果我在单位时间1s内的前10ms,已经通过了100个请求,那后面的990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为“突刺现象”
2、漏桶算法
为了消除"突刺现象",可以采用漏桶算法实现限流,漏桶算法这个名字就很形象,算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。
不管服务调用方多么不稳定,通过漏桶算法进行限流,每10毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。
image在算法实现方面,可以准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。
这种算法,在使用过后也存在弊端:无法应对短时间的突发流量。
3、令牌桶算法
从某种意义上讲,令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。
在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。
放令牌这个动作是持续不断的进行,如果桶中令牌数达到上限,就丢弃令牌,所以就存在这种情况,桶中一直有大量的可用令牌,这时进来的请求就可以直接拿到令牌执行,比如设置qps为100,那么限流器初始化完成一秒后,桶中就已经有100个令牌了,这时服务还没完全启动好,等启动完成对外提供服务时,该限流器可以抵挡瞬时的100个请求。所以,只有桶中没有令牌时,请求才会进行等待,最后相当于以一定的速率执行。
image实现思路:可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。
实现
幸运的是,通过Google开源的guava包,我们可以很轻松的创建一个令牌桶算法的限流器。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
通过RateLimiter类的create方法,创建限流器。
public class RateLimiterMain {
public static void main(String[] args) {
RateLimiter rateLimiter = RateLimiter.create(10);
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
rateLimiter.acquire()
System.out.println("pass");
}
}).start();
}
}
}
其实Guava提供了多种create方法,方便创建适合各种需求的限流器。在上述例子中,创建了一个每秒生成10个令牌的限流器,即100ms生成一个,并最多保存10个令牌,多余的会被丢弃。
rateLimiter提供了acquire()和tryAcquire()接口 1、使用acquire()方法,如果没有可用令牌,会一直阻塞直到有足够的令牌。 2、使用tryAcquire()方法,如果没有可用令牌,就直接返回false。 3、使用tryAcquire()带超时时间的方法,如果没有可用令牌,就会判断在超时时间内是否可以等到令牌,如果不能,就返回false,如果可以,就阻塞等待。
集群限流
前面讨论的几种算法都属于单机限流的范畴,但是业务需求五花八门,简单的单机限流,根本无法满足他们。
比如为了限制某个资源被每个用户或者商户的访问次数,5s只能访问2次,或者一天只能调用1000次,这种需求,单机限流是无法实现的,这时就需要通过集群限流进行实现。
如何实现?为了控制访问次数,肯定需要一个计数器,而且这个计数器只能保存在第三方服务,比如redis。
大概思路:每次有相关操作的时候,就向redis服务器发送一个incr命令,比如需要限制某个用户访问/index接口的次数,只需要拼接用户id和接口名生成redis的key,每次该用户访问此接口时,只需要对这个key执行incr命令,在这个key带上过期时间,就可以实现指定时间的访问频率。
网友评论