停更了很久的《面试补习》
,随着最近的校招来临,也要提上日程了,在梳理八股文
的同时,也能加深自己的理解,希望对各位童鞋有所帮助~
在最近一期的文章 给几位小朋友面试辅导后,我发现了一些问题! 中,有提到面试中,真的童鞋们的项目经验提出了比较多的问题,也不知道有没有人看 orz
主要列了一下项目中的这些问题:
今天写的这片关于限流
文章,也是属于秒杀系统中的一个关键技术点. 会从: 技术原理
,技术选型
,使用场景
等多方面来介绍,让你在面试中,肆意发挥
。
讲一个大家都懂的例子: 三峡大坝排水
如果没有 闸口
在, 受到的影响是啥? 下游的村庄经受洪水灾难
,而对应你的系统也是一样的崩溃
!
可能大家有疑问,如果我没有做这个蓄水的动作
(三峡没有那么多水),我是不是就不需要做限流
了呢? 其实不然,我们都知道 三峡解决了多少历史上造成的洪灾问题,这里找了个科普链接。
那对应到我们的秒杀系统
上, 我们怎么知道我们的系统会在哪个时间点来一波用户暴增
呢?如果这时候你没做好准备,是不是就造成了这批用户的流失?而且系统瘫痪,对存量用户也有影响。双输
我要这铁棒有何用~
所以,限流
就是我们系统的定海神针
, 让我们的系统风平浪静。
最后再以一批数据来说明一下限流
的实际场景:
1个商品
1秒内
100个名额
5000个用户
1000个进入下单页面
4000个超时页面
100个下单
900个库存不足
结果:
100个成功下单
4900个抢单失败
限流量: 1000
思考题
求:我这个服务最大并发量多少?
简单画了个调用链路
H5/客户端
-> Nginx
-> Tomcat
-> 秒杀系统
-> DB
简单梳理为
Nginx自带了两个限流模块:
连接数
限流模块 ngx_http_limit_conn_module漏桶算法实现的请求
限流模块 ngx_http_limit_req_module1、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、怎么理解 连接数,请求数限流
有味道的解读:厕所(IP)限制只有一个坑了,只有当我上完了,才能下一个人上。
漏桶算法
,按照单位时间放行请求,也不管你服务器能不能处理完,我就放,哎,就是放!有味道的解读:厕所有五个坑,我一分钟放5个人进去,下一分钟再放5个人进去。 里面可能有5个人,也可能有10个人,我也不清楚。
4、怎么选择?
可能面试官在听到你对 nginx 的限流那么了解后,会问你在什么情况下使用哪种限流策略
漏桶算法的主要概念如下:
这个其实不太好用,但是也了解一下吧~
可能现在的童鞋,对 Tomcat
也不太了解了,毕竟 SpringBoot
里面封装了 Tomcat
,让开发者越来越懒惰了,但是人类进化,根本原因就是懒,所以也未尝不是一件好事。
在 Tomcat 的配置文件中, 有一个 maxThreads
<Connector port="8080" connectionTimeout="30000" protocol="HTTP/1.1"
maxThreads="1000" redirectPort="8000" />
这个好像没啥好介绍的了,如果你碰到你压测的时候,并发上不去,可以检查一下这个配置。
之前面试的时候,面试官有问过我 Tomcat
的问题:
Tomcat 默认最大连接数是多少?
你们服务器的线程数设置了多少?
线程占用内存是多少?
结合我们的 秒杀系统
,那么在介绍我们系统的时候,我们可以说,在限流这块,从网关角度,我们可以使用了 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()
两个方法都通过限流器获取令牌,
支持传入等待时间,通过 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());
}
输出结果:
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
就限流了, 那肯定不行哇!
参考文档: https://juejin.cn/post/6844904161604009997
我们可以借助 Redis
的有序集合 ZSet
来实现时间窗口算法限流,实现的过程是先使用 ZSet
的 key
存储限流的 ID
,score
用来存储请求的时间,每次有请求访问来了之后,先清空之前时间窗口的访问量,统计现在时间窗口的个数和最大允许访问量对比,如果大于等于最大访问量则返回 false
执行限流操作,负责允许执行业务逻辑,并且在 ZSet
中添加一条有效的访问记录。
此实现方式存在的缺点有两个:
Sentinel
是阿里中间件团队研发的面向分布式服务架构的轻量级高可用流量控制组件,主要以流量为切入点,从流量控制
、熔断降级
、系统负载保护
等多个维度来帮助用户保护服务的稳定性。
限流中间件的原理是在太有东西了,我这里简单裂了一下他们之间的一些区别,后续会单独写一篇文章来分享 Sentinel
的实现原理! 目前可以比较容易理解的就是,底层是基于滑动窗口
的方式实现
在 Sentinel
和 Hystrix
的底层实现,都是采用了滑动窗口
,这里接简单来描述一下什么是滑动窗口,在1S
内, 我允许通过 5个请求, 分别处于 0~200ms
,200~400ms
以此类推,当时间点来到1.2s
的时候,我们的时间区间变成了 200ms ~ 1200ms。 那么第一个请求,就不在统计的区间范围内了, 我们目前总的 请求数为 4
, 因此能够再接受一个新的请求进来处理!
想闲扯一下,在我画的那张图中,我列出了 Hystrix
(豪猪),Sentinel
(哨兵)和蚂蚁内源的Guardian
(守卫)。他们都有一个共性: 保护
。豪猪有坚硬的刺保护柔软的身体,哨兵和守卫则保护着身后的家人。
当面试官问你为什么要使用限流的时候, 你应该第一反应就是保护系统
,保护系统不受伤害!这才是你为什么要用到限流的各种策略的根本原因。
在讨论到高可用的时候,我们会想到,削峰
,限流
和熔断
。 他们的目标都是为了保护我们的系统,提升系统的可用率,我们常说的系统可用率 几个9
,这些数据都是由各种高可用的策略来保护的。
后续的计划:
熔断
,结合 Sentinel
的原理来介绍一下,秒杀系统使用熔断的场景削峰
,结合 RocketMQ
讲一下,削峰的优缺点,引入MQ
带来的成本和风险好了各位,以上就是这篇文章的全部内容了,我后面会每周都更新几篇高质量的大厂面试和常用技术栈相关的文章。感谢大伙能看到这里,如果这个文章写得还不错, 求三连!!! 创作不易,感谢各位的支持和认可,我们下篇文章见!
我是 九灵
,有需要交流的童鞋可以 加我wx,Jayce-K
,关注公众号:Java 补习课
,掌握第一手资料!
如果本篇博客有任何错误,请批评指教,不胜感激 !