内网写的第一篇文章终于来了~
概述
今天写的这片关于限流文章,会以我们所熟悉的秒杀系统
作业一个项目的代入点。 会从: 技术原理,技术选型,使用场景等多方面来介绍。
什么是限流
讲一个大家都懂的例子: 三峡大坝排水
- 三峡水库的存水:可以理解是我们秒杀活动的用户
- 放闸 : 活动开始
- 排水 :秒杀成功的用户
如果没有 闸口 在, 受到的影响是啥? 下游的村庄经受洪水灾难,而对应你的系统也是一样的崩溃!
可能大家有疑问,如果我没有做这个蓄水的动作(三峡没有那么多水),我是不是就不需要做限流了呢? 其实不然,我们都知道 三峡解决了多少历史上造成的洪灾问题,这里找了个科普链接。
那对应到我们的秒杀系统
上, 我们怎么知道我们的系统会在哪个时间点来一波用户暴增呢?如果这时候你没做好准备,是不是就造成了这批用户的流失?而且系统瘫痪,对存量用户也有影响。双输
我要这铁棒有何用~
所以,限流就是我们系统的定海神针, 让我们的系统风平浪静。
最后再以一批数据来说明一下限流的实际场景:
1个商品
1秒内
100个名额
5000个用户
1000个进入下单页面
4000个超时页面
100个下单
900个库存不足
结果:
100个成功下单
4900个抢单失败
限流量: 1000
思考题
求:我这个服务最大并发量多少?
怎么限流
简单画了个调用链路
H5/客户端 -> Nginx -> Tomcat -> 秒杀系统 -> DB
简单梳理为
- 网关限流
- Nginx 限流
- Tomcat 限流
- 服务端限流
- 单机限流
- 分布式限流
网关限流
Nginx 限流
Nginx自带了两个限流模块:
- 连接数限流模块 ngx_http_limit_conn_module
- 漏桶算法实现的请求限流模块 ngx_http_limit_req_module
1、ngx_http_limit_conn_module
主要用于限制脚本攻击,如果我们的秒杀活动开始,一个黑客(假装有,毕竟我们的系统要做大做强!)写了脚本来攻击,会造成我们带宽被浪费,大量无效请求产生,对于这类请求, 我们可以通过对 ip 的连接数进行限制。
我们可以在nginx_conf的http{}中加上如下配置实现限制:
#限制每个用户的并发连接数,取名one
limit_conn_zone $binary_remote_addr zone=one:10m;
#配置异常日志,和状态码
limit_conn_log_level error;
limit_conn_status 503;
# 在server{} 限制用户并发连接数为1
limit_conn one 1;
2、ngx_http_limit_req_module
上面说的 是 ip 的连接数, 那么如果我们要控制请求数呢? 限制的方法是通过使用漏斗算法,每秒固定处理请求数,推迟过多请求。如果请求的频率超过了限制域配置的值,请求处理会被延迟或被丢弃,所以所有的请求都是以定义的频率被处理的。
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
#设置每个IP桶的数量为5
limit_req zone=one burst=5;
3、怎么理解 连接数,请求数限流
- 连接数限流(ngx_http_limit_conn_module) 每个IP,我们只会接待一个,只有当这个IP 处理结束了, 我才会接待下一位。(单位时间内,只有一个连接在处理)
有味道的解读:厕所(IP)限制只有一个坑了,只有当我上完了,才能下一个人上。
- 请求数限流(ngx_http_limit_req_module) 通过 漏桶算法 ,按照单位时间放行请求,也不管你服务器能不能处理完,我就放,哎,就是放!
有味道的解读:厕所有五个坑,我一分钟放5个人进去,下一分钟再放5个人进去。 里面可能有5个人,也可能有10个人,我也不清楚。
4、怎么选择?
可能面试官在听到你对 nginx 的限流那么了解后,会问你在什么情况下使用哪种限流策略
- IP限流:可以在活动开始前进行配置,也可以用于预防脚本攻击(IP代理的情况另说)
- 请求数限流: 日程可以配置,保护我们的服务器在突发流量造成的崩溃
漏桶算法
漏桶算法的主要概念如下:
- 一个固定容量的漏桶,按照常量固定速率流出水滴;
- 如果桶是空的,则不需流出水滴;
- 可以以任意速率流入水滴到漏桶;
- 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。
Tomcat 限流
这个其实不太好用,但是也了解一下吧~
可能现在的童鞋,对 Tomcat 也不太了解了,毕竟 SpringBoot 里面封装了 Tomcat ,让开发者越来越懒惰了,但是人类进化,根本原因就是懒,所以也未尝不是一件好事。
在 Tomcat 的配置文件中, 有一个 maxThreads
<Connector port="8080" connectionTimeout="30000" protocol="HTTP/1.1"
maxThreads="1000" redirectPort="8000" />
这个好像没啥好介绍的了,如果你碰到你压测的时候,并发上不去,可以检查一下这个配置。
之前面试的时候,面试官有问过我 Tomcat 的问题:
Tomcat 默认最大连接数是多少?
你们服务器的线程数设置了多少?
线程占用内存是多少?
总结
结合我们的 秒杀系统
de ,那么在介绍我们系统的时候,我们可以说,在限流这块,从网关角度,我们可以使用了 Nginx 的 ngx_http_limit_conn_module 模块,针对 IP 在单位时间内只允许一个请求,避免用户多次请求,减轻服务的压力。在进入到订单界面后,在单位时间内,会产生多次请求, 可以使用 ngx_http_limit_req_module 模块,针对请求数做限流,避免由于 IP 限制,导致订单丢失。
除此之外,在服务上线前,我们针对服务器进行了最大并发的压测(如200并发),因此在 Tomcat 允许的最大请求中,设置为(300,稍微上调,有其他请求)。
服务器限流
单机限流
如果我们的系统部署,是只有一台机器,那我们可以直接使用 单机限流的方案(毕竟你一台机器还要用分布式限流,是不是有点过了~)
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1.1-jre</version>
</dependency>
实例代码
public static void main(String[] args) throws InterruptedException { // 每秒产生 1 个令牌 RateLimiter rt = RateLimiter.create(1, 1, TimeUnit.SECONDS); System.out.println("try acquire token: " + rt.tryAcquire(1) + " time:" + System.currentTimeMillis()); System.out.println("try acquire token: " + rt.tryAcquire(1) + " time:" + System.currentTimeMillis()); Thread.sleep(2000); System.out.println("try acquire token: " + rt.tryAcquire(1) + " time:" + System.currentTimeMillis()); System.out.println("try acquire token: " + rt.tryAcquire(1) + " time:" + System.currentTimeMillis()); System.out.println("-------------分隔符-----------------"); }
RateLimiter.tryAcquire() 和 RateLimiter.acquire() 两个方法都通过限流器获取令牌,
1、tryAcquire
支持传入等待时间,通过 canAcquire 判断最早一个生成令牌时间,判断是否进行等待下一个令牌的获取。
public boolean tryAcquire(int permits, long timeout, TimeUnit unit); private boolean canAcquire(long nowMicros, long timeoutMicros) { return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros; }
示例代码:
public static void main(String[] args) throws InterruptedException { // 每秒产生 1 个令牌 RateLimiter rt = RateLimiter.create(1, 3, TimeUnit.SECONDS); System.out.println("try acquire token: " + rt.tryAcquire(1,TimeUnit.SECONDS) + " time:" + System.currentTimeMillis()); System.out.println("try acquire token: " + rt.tryAcquire(5,TimeUnit.SECONDS) + " time:" + System.currentTimeMillis()); Thread.sleep(10000); System.out.println("-------------分隔符-----------------"); System.out.println("try acquire token: " + rt.tryAcquire(1,TimeUnit.SECONDS) + " time:" + System.currentTimeMillis()); System.out.println("try acquire token: " + rt.tryAcquire(1,TimeUnit.SECONDS) + " time:" + System.currentTimeMillis()); }
输出结果:
2、acquire
acquire 为阻塞等待获取令牌,通过查看源码可以看出同步加锁操作:
示例代码:
RateLimiter rt = RateLimiter.create(1); // 每秒产生 1 个令牌 for (int i = 0; i < 11; i++) { new Thread(() -> { // 获取 1 个令牌 rt.acquire(); System.out.println("try acquire token success,time:" +System.currentTimeMillis() + " ThreaName:"+Thread.currentThread().getName()); }).start(); }
输出结果:
令牌算法
上面说到了几个概念, 在nignx 我们提到的是 漏斗算法,在 RateLimiter 这里我们提到的是令牌算法
我们可以通过上面这个图来进行解释,有一个容量有限的桶,令牌以固定的速率添加到这个桶里面。由于桶的容量是有限的,所以不可能无限制的往里面添加令牌,如果令牌到达桶的时候,桶是满的,那么这个令牌就被抛弃了。每次请求,n个数量的令牌从桶里面被移除,如果桶里面的令牌数少于n,那么该请求就会被拒绝或阻塞。
这里有几个关键的属性
/** The currently stored permits. */ double storedPermits; //目前令牌数量 /** The maximum number of stored permits. */ double maxPermits; //最大令牌数量 private long nextFreeTicketMicros = 0L; //下一个令牌获取时间
在获取令牌前,会有一个判断规则,判断当前获取令牌时间,是否满足上一次令牌时间获取 - 生产令牌时间,
比如 :我这次获取令牌时间为 100 秒,令牌生成时间为 10秒 一个,那么当我 105秒过来拿的时候, 不管令牌桶有没有令牌,我都没办法获取到令牌。
private boolean canAcquire(long nowMicros, long timeoutMicros) { return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros; }
这里是重点!!!
那么令牌桶当中的令牌数量(存量)到底有什么用呢? 针对不同的请求,我们可以设定需要不同数量的令牌,优先级高的,只需要1个令牌即可;优先级低的,则需要多个令牌。 那么当获取令牌时间到了之后, 进行下一层判断,令牌数是否足够, 优先级高的请求(需要令牌数量比较少的),可以马上放行!!!!!
在 RateLimit 中刷新令牌的算法:
void resync(long nowMicros) { // if nextFreeTicket is in the past, resync to now if (nowMicros > nextFreeTicketMicros) { double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros(); storedPermits = min(maxPermits, storedPermits + newPermits); nextFreeTicketMicros = nowMicros; } }
集群限流
随着我们秒杀系统做大做强,一台机器肯定不能满足我们的诉求了,那么我们的部署架构就会衍生成为下面这个架构图(简版)
在将集群限流前,提个思考问题:
集群部署我们就不能用单机部署的方案了吗?
答案肯定是可以的, 我们可以将单机限流 的方案拓展到集群每一台机器,那么每天机器都是复用了相同的一套限流代码(RateLimit 实现)。
那么这个方案存在什么问题呢?
- 流量分配不均
- 误限,错限
- 更新不及时
主要讲一下 误限 , 我们服务端接收到的请求,都是有 nginx 进行分发,如果某个时间段,由于请求的分配不均(60,30,10比例分配,限流50qps),会触发第一台机器的限流,而对于集群而言,我的整体限流阀值为 150 qps,现在 100qps 就限流了, 那肯定不行哇!
Redis 实现
参考文档: https://juejin.cn/post/6844904161604009997
我们可以借助 Redis 的有序集合 ZSet 来实现时间窗口算法限流,实现的过程是先使用 ZSet 的 key 存储限流的 ID,score 用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回 false 执行限流操作,负责允许执行业务逻辑,并且在 ZSet 中添加一条有效的访问记录。
此实现方式存在的缺点有两个:
- 使用 ZSet 存储有每次的访问记录,如果数据量比较大时会占用大量的空间,比如 60s 允许 100W 访问时;
- 此代码的执行非原子操作,先判断后增加,中间空隙可穿插其他业务逻辑的执行,最终导致结果不准确。
限流中间件
Sentinel 是阿里中间件团队研发的面向分布式服务架构的轻量级高可用流量控制组件,主要以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度来帮助用户保护服务的稳定性。
限流中间件的原理是在太有东西了,我这里简单列了一下他们之间的一些区别,后续会单独写一篇文章来分享 Sentinel 的实现原理!
滑动窗口算法
在 Sentinel 和 Hystrix 的底层实现,都是采用了滑动窗口,这里接简单来描述一下什么是滑动窗口,在1S 内, 我允许通过 5个请求, 分别处于 0~200ms,200~400ms以此类推,当时间点来到1.2s 的时候,我们的时间区间变成了 200ms ~ 1200ms。 那么第一个请求,就不在统计的区间范围内了, 我们目前总的 请求数为 4, 因此能够再接受一个新的请求进来处理!
总结
想闲扯一下,在我画的那张图中,我列出了 Hystrix(豪猪),Sentinel(哨兵)和蚂蚁内源的Guardian(守卫)。他们都有一个共性: 保护。豪猪有坚硬的刺保护柔软的身体,哨兵和守卫则保护着身后的家人。
当面试官问你为什么要使用限流的时候, 你应该第一反应就是保护系统,保护系统不受伤害!这才是你为什么要用到限流的各种策略的根本原因。
在讨论到高可用的时候,我们会想到,削峰,限流和熔断。 他们的目标都是为了保护我们的系统,提升系统的可用率,我们常说的系统可用率 几个9,这些数据都是由各种高可用的策略来保护的。
如果本篇文章有任何错误,请批评指教,不胜感激 !