[京东技术]声明:本文转载自微信公众号“开涛的博客”,转载务必声明。html
在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。缓存的目的是提高系统访问速度和增大系统能处理的容量,可谓是抗高并发流量的银弹;而降级是当服务出问题或者影响到核心流程的性能则须要暂时屏蔽掉,待高峰或者问题解决后再打开;而有些场景并不能用缓存和降级来解决,好比稀缺资源(秒杀、抢购)、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页),所以需有一种手段来限制这些场景的并发/请求量,即限流。node
限流的目的是经过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则能够拒绝服务(定向到错误页或告知资源没有了)、排队或等待(好比秒杀、评论、下单)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)。nginx
通常开发高并发系统常见的限流有:限制总并发数(好比数据库链接池、线程池)、限制瞬时并发数(如nginx的limit_conn模块,用来限制瞬时并发链接数)、限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率);其余还有如限制远程接口调用速率、限制MQ的消费速率。另外还能够根据网络链接数、网络流量、CPU或内存负载等来限流。git
先有缓存这个银弹,后有限流来应对618、双十一高并发流量,在处理高并发问题上能够说是如虎添翼,不用担忧瞬间流量致使系统挂掉或雪崩,最终作到有损服务而不是不服务;限流须要评估好,不可乱用,不然会正常流量出现一些奇怪的问题而致使用户抱怨。github
在实际应用时也不要太纠结算法问题,由于一些限流算法实现是同样的只是描述不同;具体使用哪一种限流技术仍是要根据实际场景来选择,不要一味去找最佳模式,白猫黑猫能解决问题的就是好猫。redis
因在实际工做中遇到过许多人来问如何进行限流,所以本文会详细介绍各类限流手段。那么接下来咱们从限流算法、应用级限流、分布式限流、接入层限流来详细学习下限流技术手段。算法
限流算法
常见的限流算法有:令牌桶、漏桶。计数器也能够进行粗暴限流实现。sql
令牌桶算法数据库
令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述以下:编程
-
假设限制2r/s,则按照500毫秒的固定速率往桶中添加令牌;
-
桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝;
-
当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上;
-
若是桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。
漏桶算法
漏桶做为计量工具(The Leaky Bucket Algorithm as a Meter)时,能够用于流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述以下:
-
一个固定容量的漏桶,按照常量固定速率流出水滴;
-
若是桶是空的,则不需流出水滴;
-
能够以任意速率流入水滴到漏桶;
-
若是流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。
令牌桶和漏桶对比:
-
令牌桶是按照固定速率往桶中添加令牌,请求是否被处理须要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;
-
漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;
-
令牌桶限制的是平均流入速率(容许突发请求,只要有令牌就能够处理,支持一次拿3个令牌,4个令牌),并容许必定程度突发流量;
-
漏桶限制的是常量流出速率(即流出速率是一个固定常量值,好比都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;
-
令牌桶容许必定程度的突发,而漏桶主要目的是平滑流入速率;
-
两个算法实现能够同样,可是方向是相反的,对于相同的参数获得的限流效果是同样的。
另外有时候咱们还使用计数器来进行限流,主要用来限制总并发数,好比数据库链接池、线程池、秒杀的并发数;只要全局总请求数或者必定时间段的总请求数设定的阀值则进行限流,是简单粗暴的总数量限流,而不是平均速率限流。
到此基本的算法就介绍完了,接下来咱们首先看看应用级限流。
应用级限流
限流总并发/链接/请求数
对于一个应用系统来讲必定会有极限并发/请求数,即总有一个TPS/QPS阀值,若是超了阀值则系统就会不响应用户请求或响应的很是慢,所以咱们最好进行过载保护,防止大量请求涌入击垮系统。
若是你使用过Tomcat,其Connector 其中一种配置有以下几个参数:
acceptCount:若是Tomcat的线程都忙于响应,新来的链接会进入队列排队,若是超出排队大小,则拒绝链接;
maxConnections: 瞬时最大链接数,超出的会排队等待;
maxThreads:Tomcat能启动用来处理请求的最大线程数,若是请求处理量一直远远大于最大线程数则可能会僵死。
详细的配置请参考官方文档。另外如Mysql(如max_connections)、Redis(如tcp-backlog)都会有相似的限制链接数的配置。
限流总资源数
若是有的资源是稀缺资源(如数据库链接、线程),并且可能有多个系统都会去使用它,那么须要限制应用;可使用池化技术来限制总资源数:链接池、线程池。好比分配给每一个应用的数据库链接是100,那么本应用最多可使用100个资源,超出了能够等待或者抛异常。
限流某个接口的总并发/请求数
若是接口可能会有突发访问状况,但又担忧访问量太大形成崩溃,如抢购业务;这个时候就须要限制这个接口的总并发/请求数总请求数了;由于粒度比较细,能够为每一个接口都设置相应的阀值。可使用Java中的AtomicLong进行限流:
try {
if(atomic.incrementAndGet() > 限流数) {
//拒绝请求
}
//处理请求
} finally {
atomic.decrementAndGet();
}
适合对业务无损的服务或者须要过载保护的服务进行限流,如抢购业务,超出了大小要么让用户排队,要么告诉用户没货了,对用户来讲是能够接受的。而一些开放平台也会限制用户调用某个接口的试用请求量,也能够用这种计数器方式实现。这种方式也是简单粗暴的限流,没有平滑处理,须要根据实际状况选择使用;
限流某个接口的时间窗请求数
即一个时间窗口内的请求数,如想限制某个接口/服务每秒/每分钟/天天的请求数/调用量。如一些基础服务会被不少其余系统调用,好比商品详情页服务会调用基础商品服务调用,可是怕由于更新量比较大将基础服务打挂,这时咱们要对每秒/每分钟的调用量进行限速;一种实现方式以下所示:
LoadingCache<Long, AtomicLong> counter =
CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.build(new CacheLoader<Long, AtomicLong>() {
@Override
public AtomicLong load(Long seconds) throws Exception {
return new AtomicLong(0);
}
});
long limit = 1000;
while(true) {
//获得当前秒
long currentSeconds = System.currentTimeMillis() / 1000;
if(counter.get(currentSeconds).incrementAndGet() > limit) {
System.out.println("限流了:" + currentSeconds);
continue;
}
//业务处理
}
咱们使用Guava的Cache来存储计数器,过时时间设置为2秒(保证1秒内的计数器是有的),而后咱们获取当前时间戳而后取秒数来做为KEY进行计数统计和限流,这种方式也是简单粗暴,刚才说的场景够用了。
平滑限流某个接口的请求数
以前的限流方式都不能很好地应对突发请求,即瞬间请求可能都被容许从而致使一些问题;所以在一些场景中须要对突发请求进行整形,整形为平均速率请求处理(好比5r/s,则每隔200毫秒处理一个请求,平滑了速率)。这个时候有两种算法知足咱们的场景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法实现,可直接拿来使用。
Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。
SmoothBursty
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
将获得相似以下的输出:
0.0
0.198239
0.196083
0.200609
0.199599
0.19961
1、RateLimiter.create(5) 表示桶容量为5且每秒新增5个令牌,即每隔200毫秒新增一个令牌;
2、limiter.acquire()表示消费一个令牌,若是当前桶中有足够令牌则成功(返回值为0),若是桶中没有令牌则暂停一段时间,好比发令牌间隔是200毫秒,则等待200毫秒后再去消费令牌(如上测试用例返回的为0.198239,差很少等待了200毫秒桶中才有令牌可用),这种实现将突发请求速率平均为了固定请求速率。
再看一个突发示例:
System.out.println(limiter.acquire(5));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
将获得相似以下的输出:
0.0
0.98745
0.183553
0.199909
limiter.acquire(5)表示桶的容量为5且每秒新增5个令牌,令牌桶算法容许必定程度的突发,因此能够一次性消费5个令牌,但接下来的limiter.acquire(1)将等待差很少1秒桶中才能有令牌,且接下来的请求也整形为固定速率了。
System.out.println(limiter.acquire(10));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
将获得相似以下的输出:
0.0
1.997428
0.192273
0.200616
同上边的例子相似,第一秒突发了10个请求,令牌桶算法也容许了这种突发(容许消费将来的令牌),但接下来的limiter.acquire(1)将等待差很少2秒桶中才能有令牌,且接下来的请求也整形为固定速率了。
接下来再看一个突发的例子:
System.out.println(limiter.acquire());
Thread.sleep(2000L);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
将获得相似以下的输出:
0.0
0.0
0.0
0.0
0.499876
0.495799
1、建立了一个桶容量为2且每秒新增2个令牌;
2、首先调用limiter.acquire()消费一个令牌,此时令牌桶能够知足(返回值为0);
3、而后线程暂停2秒,接下来的两个limiter.acquire()都能消费到令牌,第三个limiter.acquire()也一样消费到了令牌,到第四个时就须要等待500毫秒了。
此处能够看到咱们设置的桶容量为2(即容许的突发量),这是由于SmoothBursty中有一个参数:最大突发秒数(maxBurstSeconds)默认值是1s,突发量/桶容量=速率*maxBurstSeconds,因此本示例桶容量/突发量为2,例子中前两个是消费了以前积攒的突发量,而第三个开始就是正常计算的了。令牌桶算法容许将一段时间内没有消费的令牌暂存到令牌桶中,留待将来使用,并容许将来请求的这种突发。
SmoothBursty经过平均速率和最后一次新增令牌的时间计算出下次新增令牌的时间的,另外须要一个桶暂存一段时间内没有使用的令牌(便可以突发的令牌数)。另外RateLimiter还提供了tryAcquire方法来进行无阻塞或可超时的令牌消费。
由于SmoothBursty容许必定程度的突发,会有人担忧若是容许这种突发,假设忽然间来了很大的流量,那么系统极可能扛不住这种突发。所以须要一种平滑速率的限流工具,从而系统冷启动后慢慢的趋于平均固定速率(即刚开始速率小一些,而后慢慢趋于咱们设置的固定速率)。Guava也提供了SmoothWarmingUp来实现这种需求,其能够认为是漏桶算法,可是在某些特殊场景又不太同样。
SmoothWarmingUp建立方式:RateLimiter.create(doublepermitsPerSecond, long warmupPeriod, TimeUnit unit)
permitsPerSecond表示每秒新增的令牌数,warmupPeriod表示在从冷启动速率过渡到平均速率的时间间隔。
示例以下:
for(int i = 1; i < 5;i++) {
System.out.println(limiter.acquire());
}
Thread.sleep(1000L);
for(int i = 1; i < 5;i++) {
System.out.println(limiter.acquire());
}
将获得相似以下的输出:
0.0
0.51767
0.357814
0.219992
0.199984
0.0
0.360826
0.220166
0.199723
0.199555
速率是梯形上升速率的,也就是说冷启动时会以一个比较大的速率慢慢到平均速率;而后趋于平均速率(梯形降低到平均速率)。能够经过调节warmupPeriod参数实现一开始就是平滑固定速率。
到此应用级限流的一些方法就介绍完了。假设将应用部署到多台机器,应用级限流方式只是单应用内的请求限流,不能进行全局限流。所以咱们须要分布式限流和接入层限流来解决这个问题。
分布式限流
分布式限流最关键的是要将限流服务作成原子化,而解决方案可使使用redis+lua或者nginx+lua技术进行实现,经过这两种技术能够实现的高并发和高性能。
首先咱们来使用redis+lua实现时间窗内某个接口的请求数限流,实现了该功能后能够改造为限流总并发/请求数和限制总资源数。Lua自己就是一种编程语言,也可使用它实现复杂的令牌桶或漏桶算法。
redis+lua实现中的lua脚本:
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call("INCRBY", key, "1")) --请求数+1
if current > limit then --若是超出限流大小
return 0
elseif current == 1 then --只有第一次访问须要设置2秒的过时时间
redis.call("expire", key,"2")
end
return 1
如上操做因是在一个lua脚本中,又因Redis是单线程模型,所以是线程安全的。如上方式有一个缺点就是当达到限流大小后仍是会递增的,能够改形成以下方式实现:
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --若是超出限流大小
return 0
else --请求数+1,并设置2秒过时
redis.call("INCRBY", key,"1")
redis.call("expire", key,"2")
return 1
end
以下是Java中判断是否须要限流的代码:
String luaScript = Files.toString(new File("limit.lua"), Charset.defaultCharset());
Jedis jedis = new Jedis("192.168.147.52", 6379);
String key = "ip:" + System.currentTimeMillis()/ 1000; //此处将当前时间戳取秒数
Stringlimit = "3"; //限流大小
return (Long)jedis.eval(luaScript,Lists.newArrayList(key), Lists.newArrayList(limit)) == 1;
}
由于Redis的限制(Lua中有写操做不能使用带随机性质的读操做,如TIME)不能在Redis Lua中使用TIME获取时间戳,所以只好从应用获取而后传入,在某些极端状况下(机器时钟不许的状况下),限流会存在一些小问题。
使用Nginx+Lua实现的Lua脚本:
local function acquire()
local lock =locks:new("locks")
local elapsed, err =lock:lock("limit_key") --互斥锁
local limit_counter =ngx.shared.limit_counter --计数器
local key = "ip:" ..os.time()
local limit = 5 --限流大小
local current =limit_counter:get(key)
if current ~= nil and current + 1> limit then --若是超出限流大小
lock:unlock()
return 0
end
if current == nil then
limit_counter:set(key, 1, 1) --第一次须要设置过时时间,设置key的值为1,过时时间为1秒
else
limit_counter:incr(key, 1) --第二次开始加1便可
end
lock:unlock()
return 1
end
ngx.print(acquire())
实现中咱们须要使用lua-resty-lock互斥锁模块来解决原子性问题(在实际工程中使用时请考虑获取锁的超时问题),并使用ngx.shared.DICT共享字典来实现计数器。若是须要限流则返回0,不然返回1。使用时须要先定义两个共享字典(分别用来存放锁和计数器数据):
- http {
- ……
- lua_shared_dict locks 10m;
- lua_shared_dict limit_counter 10m;
- }
有人会纠结若是应用并发量很是大那么redis或者nginx是否是能抗得住;不过这个问题要从多方面考虑:你的流量是否是真的有这么大,是否是能够经过一致性哈希将分布式限流进行分片,是否是能够当并发量太大降级为应用级限流;对策很是多,能够根据实际状况调节;像在京东使用Redis+Lua来限流抢购流量,通常流量是没有问题的。
对于分布式限流目前遇到的场景是业务上的限流,而不是流量入口的限流;流量入口限流应该在接入层完成,而接入层笔者通常使用Nginx。
接入层限流
接入层一般指请求流量的入口,该层的主要目的有:负载均衡、非法请求过滤、请求聚合、缓存、降级、限流、A/B测试、服务质量监控等等,能够参考笔者写的《使用Nginx+Lua(OpenResty)开发高性能Web应用》。
对于Nginx接入层限流可使用Nginx自带了两个模块:链接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module。还可使用OpenResty提供的Lua限流模块lua-resty-limit-traffic进行更复杂的限流场景。
limit_conn用来对某个KEY对应的总的网络链接数进行限流,能够按照如IP、域名维度进行限流。limit_req用来对某个KEY对应的请求的平均速率进行限流,并有两种用法:平滑模式(delay)和容许突发模式(nodelay)。
ngx_http_limit_conn_module
limit_conn是对某个KEY对应的总的网络链接数进行限流。能够按照IP来限制IP维度的总链接数,或者按照服务域名来限制某个域名的总链接数。可是记住不是每个请求链接都会被计数器统计,只有那些被Nginx处理的且已经读取了整个请求头的请求链接才会被计数器统计。
配置示例:
limit_conn_zone$binary_remote_addr zone=addr:10m;
limit_conn_log_level error;
limit_conn_status 503;
...
server {
...
location /limit {
limit_conn addr 1;
}
limit_conn:要配置存放KEY和计数器的共享内存区域和指定KEY的最大链接数;此处指定的最大链接数是1,表示Nginx最多同时并发处理1个链接;
limit_conn_zone:用来配置限流KEY、及存放KEY对应信息的共享内存区域大小;此处的KEY是“$binary_remote_addr”其表示IP地址,也可使用如$server_name做为KEY来限制域名级别的最大链接数;
limit_conn_status:配置被限流后返回的状态码,默认返回503;
limit_conn_log_level:配置记录被限流后的日志级别,默认error级别。
limit_conn的主要执行过程以下所示:
1、请求进入后首先判断当前limit_conn_zone中相应KEY的链接数是否超出了配置的最大链接数;
2.1、若是超过了配置的最大大小,则被限流,返回limit_conn_status定义的错误状态码;
2.2、不然相应KEY的链接数加1,并注册请求处理完成的回调函数;
3、进行请求处理;
4、在结束请求阶段会调用注册的回调函数对相应KEY的链接数减1。
limt_conn能够限流某个KEY的总并发/请求数,KEY能够根据须要变化。
按照IP限制并发链接数配置示例:
首先定义IP维度的限流区域:
接着在要限流的location中添加限流逻辑:
limit_conn perip 2;
echo "123";
}
即容许每一个IP最大并发链接数为2。
使用AB测试工具进行测试,并发数为5个,总的请求数为5个:
将获得以下access.log输出:
[08/Jun/2016:20:10:51+0800] [1465373451.802] 200
[08/Jun/2016:20:10:51+0800] [1465373451.803] 200
[08/Jun/2016:20:10:51 +0800][1465373451.803] 503
[08/Jun/2016:20:10:51 +0800][1465373451.803] 503
[08/Jun/2016:20:10:51 +0800][1465373451.803] 503
此处咱们把access log格式设置为log_format main '[$time_local] [$msec] $status';分别是“日期 日期秒/毫秒值 响应状态码”。
若是被限流了,则在error.log中会看到相似以下的内容:
按照域名限制并发链接数配置示例:
首先定义域名维度的限流区域:
接着在要限流的location中添加限流逻辑:
limit_conn perserver 2;
echo "123";
}
即容许每一个域名最大并发请求链接数为2;这样配置能够实现服务器最大链接数限制。
ngx_http_limit_req_module
limit_req是漏桶算法实现,用于对指定KEY对应的请求进行限流,好比按照IP维度限制请求速率。
配置示例:
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
limit_conn_log_level error;
limit_conn_status 503;
...
server {
...
location /limit {
limit_req zone=one burst=5 nodelay;
}
limit_req:配置限流区域、桶容量(突发容量,默认0)、是否延迟模式(默认延迟);
limit_req_zone:配置限流KEY、及存放KEY对应信息的共享内存区域大小、固定请求速率;此处指定的KEY是“$binary_remote_addr”表示IP地址;固定请求速率使用rate参数配置,支持10r/s和60r/m,即每秒10个请求和每分钟60个请求,不过最终都会转换为每秒的固定请求速率(10r/s为每100毫秒处理一个请求;60r/m,即每1000毫秒处理一个请求)。
limit_conn_status:配置被限流后返回的状态码,默认返回503;
limit_conn_log_level:配置记录被限流后的日志级别,默认error级别。
limit_req的主要执行过程以下所示:
1、请求进入后首先判断最后一次请求时间相对于当前时间(第一次是0)是否须要限流,若是须要限流则执行步骤2,不然执行步骤3;
2.1、若是没有配置桶容量(burst),则桶容量为0;按照固定速率处理请求;若是请求被限流,则直接返回相应的错误码(默认503);
2.2、若是配置了桶容量(burst>0)且延迟模式(没有配置nodelay);若是桶满了,则新进入的请求被限流;若是没有满则请求会以固定平均速率被处理(按照固定速率并根据须要延迟处理请求,延迟使用休眠实现);
2.3、若是配置了桶容量(burst>0)且非延迟模式(配置了nodelay);不会按照固定速率处理请求,而是容许突发处理请求;若是桶满了,则请求被限流,直接返回相应的错误码;
3、若是没有被限流,则正常处理请求;
4、Nginx会在相应时机进行选择一些(3个节点)限流KEY进行过时处理,进行内存回收。
场景2.1测试
首先定义IP维度的限流区域:
限制为每秒500个请求,固定平均速率为2毫秒一个请求。
接着在要限流的location中添加限流逻辑:
limit_req zone=test;
echo "123";
}
即桶容量为0(burst默认为0),且延迟模式。
使用AB测试工具进行测试,并发数为2个,总的请求数为10个:
将获得以下access.log输出:
[08/Jun/2016:20:25:56+0800] [1465381556.410] 200
[08/Jun/2016:20:25:56 +0800][1465381556.410] 503
[08/Jun/2016:20:25:56 +0800][1465381556.411] 503
[08/Jun/2016:20:25:56+0800] [1465381556.411] 200
[08/Jun/2016:20:25:56 +0800][1465381556.412] 503
[08/Jun/2016:20:25:56 +0800][1465381556.412] 503
虽然每秒容许500个请求,可是由于桶容量为0,因此流入的请求要么被处理要么被限流,没法延迟处理;另外平均速率在2毫秒左右,好比1465381556.410和1465381556.411被处理了;有朋友会说这固定平均速率不是1毫秒嘛,其实这是由于实现算法没那么精准形成的。
若是被限流在error.log中会看到以下内容:
2016/06/08 20:25:56 [error] 6130#0: *1962limiting requests, excess: 1.000 by zone "test", client: 127.0.0.1,server: _, request: "GET /limit HTTP/1.0", host:"localhost"
若是被延迟了在error.log(日志级别要INFO级别)中会看到以下内容:
2016/06/10 09:05:23 [warn] 9766#0: *97021delaying request, excess: 0.368, by zone "test", client: 127.0.0.1,server: _, request: "GET /limit HTTP/1.0", host:"localhost"
场景2.2测试
首先定义IP维度的限流区域:
为了方便测试设置速率为每秒2个请求,即固定平均速率是500毫秒一个请求。
接着在要限流的location中添加限流逻辑:
limit_req zone=test burst=3;
echo "123";
}
固定平均速率为500毫秒一个请求,通容量为3,若是桶满了新的请求被限流,不然能够进入桶中排队并等待(实现延迟模式)。
为了看出限流效果咱们写了一个req.sh脚本:
sleep 0.3
ab -c 6 -n 6 http://localhost/limit
首先进行6个并发请求6次URL,而后休眠300毫秒,而后再进行6个并发请求6次URL;中间休眠目的是为了能跨越2秒看到效果,若是看不到以下的效果能够调节休眠时间。
将获得以下access.log输出:
[09/Jun/2016:08:46:43+0800] [1465433203.959] 200
[09/Jun/2016:08:46:43 +0800][1465433203.959] 503
[09/Jun/2016:08:46:43 +0800][1465433203.960] 503
[09/Jun/2016:08:46:44+0800] [1465433204.450] 200
[09/Jun/2016:08:46:44+0800] [1465433204.950] 200
[09/Jun/2016:08:46:45 +0800][1465433205.453] 200
[09/Jun/2016:08:46:45 +0800][1465433205.766] 503
[09/Jun/2016:08:46:45 +0800][1465433205.766] 503
[09/Jun/2016:08:46:45 +0800][1465433205.767] 503
[09/Jun/2016:08:46:45+0800] [1465433205.950] 200
[09/Jun/2016:08:46:46+0800] [1465433206.451] 200
[09/Jun/2016:08:46:46+0800] [1465433206.952] 200
桶容量为3,即桶中在时间窗口内最多流入3个请求,且按照2r/s的固定速率处理请求(即每隔500毫秒处理一个请求);桶计算时间窗口(1.5秒)=速率(2r/s)/桶容量(3),也就是说在这个时间窗口内桶最多暂存3个请求。所以咱们要以当前时间往前推1.5秒和1秒来计算时间窗口内的总请求数;另外由于默认是延迟模式,因此时间窗内的请求要被暂存到桶中,并以固定平均速率处理请求:
第一轮:有4个请求处理成功了,按照漏桶桶容量应该最多3个才对;这是由于计算算法的问题,第一次计算因没有参考值,因此第一次计算后,后续的计算才能有参考值,所以第一次成功能够忽略;这个问题影响很小能够忽略;并且按照固定500毫秒的速率处理请求。
第二轮:由于第一轮请求是突发来的,差很少都在1465433203.959时间点,只是由于漏桶将速率进行了平滑变成了固定平均速率(每500毫秒一个请求);而第二轮计算时间应基于1465433203.959;而第二轮突发请求差很少都在1465433205.766时间点,所以计算桶容量的时间窗口应基于1465433203.959和1465433205.766来计算,计算结果为1465433205.766这个时间点漏桶为空了,能够流入桶中3个请求,其余请求被拒绝;又由于第一轮最后一次处理时间是1465433205.453,因此第二轮第一个请求被延迟到了1465433205.950。这里也要注意固定平均速率只是在配置的速率左右,存在计算精度问题,会有一些误差。
若是桶容量改成1(burst=1),执行req.sh脚本能够看到以下输出:
09/Jun/2016:09:04:30+0800] [1465434270.362] 200
[09/Jun/2016:09:04:30 +0800][1465434270.371] 503
[09/Jun/2016:09:04:30 +0800] [1465434270.372]503
[09/Jun/2016:09:04:30 +0800][1465434270.372] 503
[09/Jun/2016:09:04:30 +0800][1465434270.372] 503
[09/Jun/2016:09:04:30+0800] [1465434270.864] 200
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.178] 503
[09/Jun/2016:09:04:31 +0800][1465434271.179] 503
[09/Jun/2016:09:04:31+0800] [1465434271.366] 200
桶容量为1,按照每1000毫秒一个请求的固定平均速率处理请求。
场景2.3测试
首先定义IP维度的限流区域:
为了方便测试配置为每秒2个请求,固定平均速率是500毫秒一个请求。
接着在要限流的location中添加限流逻辑:
limit_req zone=test burst=3 nodelay;
echo "123";
}
桶容量为3,若是桶满了直接拒绝新请求,且每秒2最多两个请求,桶按照固定500毫秒的速率以nodelay模式处理请求。
为了看到限流效果咱们写了一个req.sh脚本:
sleep 1
ab -c 6 -n 6 http://localhost/limit
sleep 0.3
ab -c 6 -n 6 http://localhost/limit
sleep 0.3
ab -c 6 -n 6 http://localhost/limit
sleep 0.3
ab -c 6 -n 6 http://localhost/limit
sleep 2
ab -c 6 -n 6 http://localhost/limit
将获得相似以下access.log输出:
[09/Jun/2016:14:30:11+0800] [1465453811.754] 200
[09/Jun/2016:14:30:11+0800] [1465453811.755] 200
[09/Jun/2016:14:30:11+0800] [1465453811.755] 200
[09/Jun/2016:14:30:11+0800] [1465453811.759] 200
[09/Jun/2016:14:30:11 +0800][1465453811.759] 503
[09/Jun/2016:14:30:11 +0800][1465453811.759] 503
[09/Jun/2016:14:30:12+0800] [1465453812.776] 200
[09/Jun/2016:14:30:12+0800] [1465453812.776] 200
[09/Jun/2016:14:30:12 +0800][1465453812.776] 503
[09/Jun/2016:14:30:12 +0800][1465453812.777] 503
[09/Jun/2016:14:30:12 +0800][1465453812.777] 503
[09/Jun/2016:14:30:12 +0800][1465453812.777] 503
[09/Jun/2016:14:30:13 +0800] [1465453813.095]503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.097] 503
[09/Jun/2016:14:30:13 +0800][1465453813.098] 503
[09/Jun/2016:14:30:13+0800] [1465453813.425] 200
[09/Jun/2016:14:30:13 +0800][1465453813.425] 503
[09/Jun/2016:14:30:13 +0800][1465453813.425] 503
[09/Jun/2016:14:30:13 +0800][1465453813.426] 503
[09/Jun/2016:14:30:13 +0800][1465453813.426] 503
[09/Jun/2016:14:30:13 +0800][1465453813.426] 503
[09/Jun/2016:14:30:13+0800] [1465453813.754] 200
[09/Jun/2016:14:30:13 +0800][1465453813.755] 503
[09/Jun/2016:14:30:13 +0800][1465453813.755] 503
[09/Jun/2016:14:30:13 +0800][1465453813.756] 503
[09/Jun/2016:14:30:13 +0800][1465453813.756] 503
[09/Jun/2016:14:30:13 +0800][1465453813.756] 503
[09/Jun/2016:14:30:15+0800] [1465453815.278] 200
[09/Jun/2016:14:30:15+0800] [1465453815.278] 200
[09/Jun/2016:14:30:15+0800] [1465453815.278] 200
[09/Jun/2016:14:30:15 +0800][1465453815.278] 503
[09/Jun/2016:14:30:15 +0800][1465453815.279] 503
[09/Jun/2016:14:30:15 +0800][1465453815.279] 503
[09/Jun/2016:14:30:17+0800] [1465453817.300] 200
[09/Jun/2016:14:30:17+0800] [1465453817.300] 200
[09/Jun/2016:14:30:17+0800] [1465453817.300] 200
[09/Jun/2016:14:30:17+0800] [1465453817.301] 200
[09/Jun/2016:14:30:17 +0800][1465453817.301] 503
[09/Jun/2016:14:30:17 +0800][1465453817.301] 503
桶容量为3(,即桶中在时间窗口内最多流入3个请求,且按照2r/s的固定速率处理请求(即每隔500毫秒处理一个请求);桶计算时间窗口(1.5秒)=速率(2r/s)/桶容量(3),也就是说在这个时间窗口内桶最多暂存3个请求。所以咱们要以当前时间往前推1.5秒和1秒来计算时间窗口内的总请求数;另外由于配置了nodelay,是非延迟模式,因此容许时间窗内突发请求的;另外从本示例会看出两个问题:
第一轮和第七轮:有4个请求处理成功了;这是由于计算算法的问题,本示例是若是2秒内没有请求,而后接着忽然来了不少请求,第一次计算的结果将是不正确的;这个问题影响很小能够忽略;
第五轮:1.0秒计算出来是3个请求;此处也是因计算精度的问题,也就是说limit_req实现的算法不是很是精准的,假设此处当作相对于2.75的话,1.0秒内只有1次请求,因此仍是容许1次请求的。
若是限流出错了,能够配置错误页面:
recursive_error_pages on;
error_page 503 //www.jd.com/error.aspx;
limit_conn_zone/limit_req_zone定义的内存不足,则后续的请求将一直被限流,因此须要根据需求设置好相应的内存大小。
此处的限流都是单Nginx的,假设咱们接入层有多个nginx,此处就存在和应用级限流相同的问题;那如何处理呢?一种解决办法:创建一个负载均衡层将按照限流KEY进行一致性哈希算法将请求哈希到接入层Nginx上,从而相同KEY的将打到同一台接入层Nginx上;另外一种解决方案就是使用Nginx+Lua(OpenResty)调用分布式限流逻辑实现。
lua-resty-limit-traffic
以前介绍的两个模块使用上比较简单,指定KEY、指定限流速率等就能够了,若是咱们想根据实际状况变化KEY、变化速率、变化桶大小等这种动态特性,使用标准模块就很难去实现了,所以咱们须要一种可编程来解决咱们问题;而OpenResty提供了lua限流模块lua-resty-limit-traffic,经过它能够按照更复杂的业务逻辑进行动态限流处理了。其提供了limit.conn和limit.req实现,算法与nginx limit_conn和limit_req是同样的。
此处咱们来实现ngx_http_limit_req_module中的【场景2.2测试】,不要忘记下载lua-resty-limit-traffic模块并添加到OpenResty的lualib中。
配置用来存放限流用的共享字典:
如下是实现【场景2.2测试】的限流代码limit_req.lua:
local rate = 2 --固定平均速率 2r/s
local burst = 3 --桶容量
local error_status = 503
local nodelay = false --是否须要不延迟处理
local lim, err = limit_req.new("limit_req_store", rate, burst)
if not lim then --没定义共享字典
ngx.exit(error_status)
end
local key = ngx.var.binary_remote_addr --IP维度的限流
--流入请求,若是请求须要被延迟则delay > 0
local delay, err = lim:incoming(key, true)
if not delay and err == "rejected" then --超出桶大小了
ngx.exit(error_status)
end
if delay > 0 then --根据须要决定是延迟或者不延迟处理
if nodelay then
--直接突发处理了
else
ngx.sleep(delay) --延迟处理
end
end
即限流逻辑再nginx access阶段被访问,若是不被限流继续后续流程;若是须要被限流要么sleep一段时间继续后续流程,要么返回相应的状态码拒绝请求。
在分布式限流中咱们使用了简单的Nginx+Lua进行分布式限流,有了这个模块也可使用这个模块来实现分布式限流。
另外在使用Nginx+Lua时也能够获取ngx.var.connections_active进行过载保护,即若是当前活跃链接数超过阈值进行限流保护。
//限流
end
nginx也提供了limit_rate用来对流量限速,如limit_rate 50k,表示限制下载速度为50k。
到此笔者在工做中涉及的限流用法就介绍完,这些算法中有些容许突发,有些会整形为平滑,有些计算算法简单粗暴;其中令牌桶算法和漏桶算法实现上是相似的,只是表述的方向不太同样,对于业务来讲没必要刻意去区分它们;所以须要根据实际场景来决定如何限流,最好的算法不必定是最适用的。
参考资料
https://en.wikipedia.org/wiki/Token_bucket
https://en.wikipedia.org/wiki/Leaky_bucket
http://redis.io/commands/incr
http://nginx.org/en/docs/http/ngx_http_limit_req_module.html
http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html
https://github.com/openresty/lua-resty-limit-traffic
http://nginx.org/en/docs/http/ngx_http_core_module.html#limit_rate
===========================
欢迎关注个人我的公众号