mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-22 18:03:45 +08:00
mod
This commit is contained in:
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐3 | 大厂面试到底在考些什么?.md
Normal file
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐3 | 大厂面试到底在考些什么?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="加餐3 | 大厂面试到底在考些什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/f4/25898f60f11ee83113ec3959db806df4.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。这节课我们换换脑子,聊一个相对轻松点的话题——大厂面试。
|
||||
|
||||
2004年我毕业于西安交通大学计算机科学与技术专业,此后16年来既在华为、腾讯、思科、阿里巴巴这样的大厂工作过,也在两家几十人的创业公司工作过,在这种对比下,我对大厂面试的考核点很有心得体会。
|
||||
|
||||
作为候选人我拿到过很多大厂offer,作为面试官也考核过数百位同学的技术水平,因此,今天这节课我会兼顾面试官与候选人的视角,分享如何拿下一线大厂的技术面试。
|
||||
|
||||
## 大厂面试到底在考些什么?
|
||||
|
||||
相信绝大多数同学都经历过技术面试,你肯定发现,小厂与大厂的面试题差距很大,其中,**大厂特别关注程序性能**,为什么呢?在我看来有这样3个原因:
|
||||
|
||||
首先,大厂产品的用户基数大,任何微小的性能提升都会被庞大的用户数放大。因此,员工具备性能优先的思维,有利于提升产品竞争力。
|
||||
|
||||
其次,大厂经常会重新造轮子,不管原因是什么,造轮子都需要深厚的底层知识,而性能是其中的核心要素。而且,愿意花时间去掌握底层知识的候选人,学习动力更强,潜力也会更好。
|
||||
|
||||
最后,大厂待遇好,成长空间大,是典型的稀缺资源,大家都打破了头往里挤。在这样优中选优的情况下,有区分度的性能题就是最好的面试题,通过快速筛选不同档次的候选人,可以节约招聘成本。
|
||||
|
||||
那么,对于候选人来说,到底怎样才能答好性能面试题呢?首先,背网上流传的大厂面试题,绝对不是个好主意,这是因为大厂的面试题并不是固定的,往往都是考官自备的面试题,这与每位考官的个人经历有关,所以你押中面试题的概率非常低。
|
||||
|
||||
而且,面试并不是为了找出最优秀的那位候选人(这样的候选人手里往往拿着许多优厚的offer,签下他并不容易),而是将大量的候选人分出层次,再按照团队的业务发展、技术方向、薪资规划来发放offer。这样的话,面试题就必须是开放的,在各个层次上都有考核点,有内涵更有外延,任何一个点考官都可以展开了聊上个把小时。你背的知识点,未必是考官感兴趣会展开了问的点。
|
||||
|
||||
所以,大厂面试考核的是技能、潜力,而不是知识,面试前的刷题不是为了背答案,而是通过练习来提升技能!
|
||||
|
||||
下面我就以1道算法题为例,带你看看大厂面试中都在考哪些技能点。
|
||||
|
||||
## 举例:1道算法题可以考核多少知识点?
|
||||
|
||||
题目:请用你熟悉的一门编程语言实现Fibnacci函数。
|
||||
|
||||
Fibnacci是中学代数提过的一个函数,在自然界中广泛存在,美学中的黄金分割点也与它相关。可能有些同学还不熟悉Fibnacci函数,它的定义如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/03/fc992b73d1ac9c00d2a377a746f46803.png" alt="">
|
||||
|
||||
比如Fib(6)=Fib(5)+Fib(4)=5+3=8。
|
||||
|
||||
我个人非常喜欢用这道面试题,因为它有很好的区分度,至少能考核候选人6个方面的能力。
|
||||
|
||||
首先它像所有编码题一样,可以判断候选人是否至少熟练使用一门编程语言,特别是在不依赖编辑器错误提示的情况下,能不能在白板上手写出高质量的代码。这通常是大厂的基本要求。
|
||||
|
||||
其次,Fibnacci函数很显然非常适合用递归函数实现,大多数候选人都可以写出递归函数,比如:
|
||||
|
||||
```
|
||||
Fib(n) {
|
||||
if (n <= 0) return 0;
|
||||
if (n == 1) return 1;
|
||||
return Fib(n-1)+Fib(n-2);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到递归函数其实并不难,然而面试官会很自然地追问2个问题,这才是考核点:
|
||||
|
||||
首先,递归函数在系统层面有什么问题?
|
||||
|
||||
这其实是在考你是否知道栈溢出问题。每调用一次函数,需要将函数参数、返回值压栈,而操作系统为每个线程分配的栈空间是有限的,比如Linux通常只有8MB。因此,当数字n过大时,很容易导致StackOverFlow错误。
|
||||
|
||||
其次,这段递归代码的时间复杂度是多少?
|
||||
|
||||
如果你还没有时间复杂度的概念,请再次阅读[[第3讲]](https://time.geekbang.org/column/article/232351)。《算法导论》中的递归树很适合用于猜测算法的时间复杂度,下图是Fibnacci(6)展开的计算量,可见,递归树中所有节点的数量,就是递归函数的时间复杂度:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/83/ca/83dd19f2399619474e6abb224714beca.png" alt="" title="图片来源:https://medium.com/launch-school/recursive-fibonnaci-method-explained-d82215c5498e">](https://medium.com/launch-school/recursive-fibonnaci-method-explained-d82215c5498e)
|
||||
|
||||
如果你仔细数一数,会发现随着n的增大,节点数大致接近2<sup>n</sup> 个,因此其时间复杂度为O(2<sup>n</sup>)。当然,树中的节点数与Fibnacci数列相关,大致为O(1.618<sup>n</sup>)个(书中还介绍了2种求解递归函数时间复杂度的方法:代入法和主定理,请参考书中第4章)。
|
||||
|
||||
最后,这道题还可以考察候选人能否运用逆向思维,通过一个循环实现递归函数的效果。递归法的时间复杂度之所以达到了O(2<sup>n</sup>),是因为做了大量的重复运算。比如求Fibnacci(6)时,Fibnacci(2)重复执行了4次。如果对n从小向大做递推运算,重复使用已经计算完成的数字,就能大大减少计算量,如下所示:
|
||||
|
||||
```
|
||||
Fib(n) {
|
||||
if (n <= 0) return 0;
|
||||
if (n == 1) return 1;
|
||||
prev = 0;
|
||||
cur = 1;
|
||||
for (i = 2; i <= n; i = i + 1) {
|
||||
next = cur + prev;
|
||||
prev = cur;
|
||||
cur = next;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样,只通过1次循环,就能以O(n)的时间复杂度完成Fibnacci数列的计算。这与动态规划的实现思路是一致的。注意,使用递推法时如果不小心,会采用数组存放计算出的每个结果,这样空间复杂度就从O(1)到了O(n),这也是一个考核点。
|
||||
|
||||
当然,我们还可以通过公式直接计算出数列的值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/f5/a3a4f74473a79053d8b6d32acb2f80f5.png" alt="">
|
||||
|
||||
但纯粹背下这个公式并没有意义,因为你写出来后,会有2个问题等着你:
|
||||
|
||||
首先,如何使用高等数学推导出这个公式?如果你能够答出来,那么证明你的数学功底很好,在数据挖掘、人工智能人才短缺的当下,这对你在大厂内部的职业发展很有好处!
|
||||
|
||||
其次,上面这个公式有大量的浮点运算,在数学中数字可以是无限长的,但在计算机工程体系中,任何类型都有最大长度(比如浮点类型通常是64个比特位),所以对于根号5这样的无理数,小数点后的数字会出现四舍五入而不精确,而且当n非常大时,有限的内存还会导致数据溢出。因此上述的公式法并不能直接使用,如果你能回答出适合计算机使用的矩阵解法(请参考[wiki](https://en.wikipedia.org/wiki/Fibonacci_number),这里不再列出矩阵法的细节),那就更完美了。
|
||||
|
||||
可见,这么一道简单的题目,就可以考察递归编码能力、递推解法、公式解法、矩阵解法、时间复杂度的推算、计算机浮点运算特性等许多知识点。而且,随着你回答时涉及到更多的知识点,面试官会基于自己的经验进一步延伸提问。所以我不推荐你押题,把基础技能掌握好才是最有效的面试备战法。
|
||||
|
||||
这道题目只是入门的算法题,如果你应聘的是Google、头条之类非常重视算法的公司,那么你还必须掌握动态规划、贪心算法、图算法等高级算法等等。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们只是详细讲解了算法题,其实从系统工程、网络协议上也可以从性能优化这个方向快速区分出候选人的能力水平。比如:
|
||||
|
||||
- 我在开课直播时提出的关于并发的问题,多进程和多线程、协程实现的并发编程,各自的优势和劣势是什么(你可以参考[[第5讲]](https://time.geekbang.org/column/article/233629))?
|
||||
- 或者TCP连接的close_wait状态出现时应当如何解决(你可以参考[[第10讲]](https://time.geekbang.org/column/article/238388))?
|
||||
|
||||
这些都在考察你如何通过操作系统,协调使用系统资源的能力。
|
||||
|
||||
另外,除了硬核的知识技能外,你也不要忽略软技能,这也能在面试中加分。比如,任何大厂都非常强调团队协作,如果员工遇到难题时,只会闷头冥想,这样的时间成本太高,既有可能延迟项目进度,也不利于充分发挥大厂高手如林、资源丰富的优势。所以,如果你在面试中,表现出善于沟通、乐于求助的特性,都是加分项。
|
||||
|
||||
以上就是我对大厂面试的一些沉淀和思考,不知道你有没有感同身受呢?如果你在面试中也遇到过一些特别开放、有区分度的面试题,或者作为面试官你有哪些喜欢用的面试题,欢迎分享出来,我们一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。
|
198
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐4|百万并发下Nginx的优化之道.md
Normal file
198
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐4|百万并发下Nginx的优化之道.md
Normal file
@@ -0,0 +1,198 @@
|
||||
<audio id="audio" title="加餐4|百万并发下Nginx的优化之道" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/05/f9059e9d4546b7cc008c871345767805.mp3"></audio>
|
||||
|
||||
你好,我是专栏编辑冬青。今天的课程有点特别,作为一期加餐,我为你带来了陶辉老师在GOPS 2018 · 上海站的分享,以文字讲解+ PPT的形式向你呈现。今天的内容主要集中在Nginx的性能方面,希望能给你带来一些系统化的思考,帮助你更有效地去做Nginx。
|
||||
|
||||
## 优化方法论
|
||||
|
||||
今天的分享重点会看这样两个问题:
|
||||
|
||||
- 第一,如何有效使用每个连接分配的内存,以此实现高并发。
|
||||
- 第二,在高并发的同时,怎样提高QPS。
|
||||
|
||||
当然,实现这两个目标,既可以从单机中的应用、框架、内核优化入手,也可以使用类似F5这样的硬件设备,或者通过DNS等方案实现分布式集群。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/24/1a69ba079c318c227c9ccff842714424.jpg" alt="">
|
||||
|
||||
而Nginx最大的限制是网络,所以将网卡升级到万兆,比如10G或者40G吞吐量就会有很大提升。作为静态资源、缓存服务时,磁盘也是重点关注对象,比如固态硬盘的IOPS或者BPS,要比不超过1万转每秒的机械磁盘高出许多。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/2c/4aecd5772e4d164dc414d1f473440f2c.jpg" alt="">
|
||||
|
||||
这里我们重点看下CPU,如果由操作系统切换进程实现并发,代价太大,毕竟每次都有5微秒左右的切换成本。Nginx将其改到进程内部,由epoll切换ngx_connection_t连接的处理,成本会非常低。OpenResty切换Lua协程,也是基于同样的方式。这样,CPU的计算力会更多地用在业务处理上。
|
||||
|
||||
从整体上看,只有充分、高效地使用各类IT资源,才能减少RTT时延、提升并发连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/24/9d4721babd048bed55968c4f8bbeaf24.jpg" alt="">
|
||||
|
||||
## 请求的“一生”
|
||||
|
||||
只有熟悉Nginx处理HTTP请求的流程,优化时才能做到有的放矢。
|
||||
|
||||
首先,我们要搞清楚Nginx的模块架构。Nginx是一个极其开放的生态,它允许第三方编写的C模块与框架协作,共同处理1个HTTP请求。比如,所有的请求处理模块会构成一个链表,以PipeAndFilter这种架构依次处理请求。再比如,生成HTTP响应后,所有过滤模块也会依次加工。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/4d/8bb5620111efd7086b3fa89b1b7a3d4d.jpg" alt="">
|
||||
|
||||
### 1. 请求到来
|
||||
|
||||
试想一下,当用户请求到来时,服务器到底会做哪些事呢?首先,操作系统内核会将完成三次握手的连接socket,放入1个ACCEPT队列(如果打开了reuseport,内核会选择某个worker进程对应的队列),某个Nginx Worker进程事件模块中的代码,需要调用accept函数取出socket。
|
||||
|
||||
建立好连接并分配ngx_connection_t对象后,Nginx会为它分配1个内存池,它的默认大小是512字节(可以由connection_pool_size指令修改),只有这个连接关闭的时候才会去释放。
|
||||
|
||||
接下来Nginx会为这个连接添加一个默认60秒(client_header_timeout指令可以配置)的定时器,其中,需要将内核的socket读缓冲区里的TCP报文,拷贝到用户态内存中。所以,此时会将连接内存池扩展到1KB(client_header_buffer_size指令可以配置)来拷贝消息内容,如果在这段时间之内没有接收完请求,则返回失败并关闭连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/63/171329643c8f003yy47bcd0d1b5f5963.jpg" alt="">
|
||||
|
||||
### 2. 处理请求
|
||||
|
||||
当接收完HTTP请求行和HEADER后,就清楚了这是一个什么样的请求,此时会再分配另一个默认为4KB(request_pool_size指令可以修改,这里请你思考为什么这个请求内存池比连接内存池的初始字节数多了8倍?)的内存池。
|
||||
|
||||
Nginx会通过协议状态机解析接收到的字符流,如果1KB内存还没有接收到完整的HTTP头部,就会再从请求内存池上分配出32KB,继续接收字符流。其中,这32KB默认是分成4次分配,每次分配8KB(可以通过large_client_header_buffers指令修改),这样可以避免为少量的请求浪费过大的内存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/64/f8b2e2c3734188c4f00e8002f0966964.jpg" alt="">
|
||||
|
||||
接下来,各类HTTP处理模块登场。当然,它们并不是简单构成1个链表,而是通过11个阶段构成了一个二维链表。其中,第1维长度是与Web业务场景相关的11个阶段,第2维的长度与每个阶段中注册的HTTP模块有关。
|
||||
|
||||
这11个阶段不用刻意死记,你只要掌握3个关键词,就能够轻松地把他们分解开。首先是5个阶段的预处理,包括post_read,以及与rewrite重写URL相关的3个阶段,以及URL与location相匹配的find_config阶段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/86/a048b12f5f79fee43856ecf449387786.jpg" alt="">
|
||||
|
||||
其次是访问控制,包括限流限速的preaccess阶段、控制IP访问范围的access阶段和做完访问控制后的post_access阶段。
|
||||
|
||||
最后则是内容处理,比如执行镜象分流的precontent阶段、生成响应的content阶段、记录处理结果的log阶段。
|
||||
|
||||
每个阶段中的HTTP模块,会在configure脚本执行时就构成链表,顺序地处理HTTP请求。其中,HTTP框架允许某个模块跳过其后链接的本阶段模块,直接进入下一个阶段的第1个模块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/fb/0ea57bd24be1fdae15f860b926cc25fb.jpg" alt="">
|
||||
|
||||
content阶段会生成HTTP响应。当然,其他阶段也有可能生成HTTP响应返回给客户端,它们通常都是非200的错误响应。接下来,会由HTTP过滤模块加工这些响应的内容,并由write_filter过滤模块最终发送到网络中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/39/f4fc5bc3ef64498ac6882a902f927539.jpg" alt="">
|
||||
|
||||
### 3. 请求的反向代理
|
||||
|
||||
Nginx由于性能高,常用来做分布式集群的负载均衡服务。由于Nginx下游通常是公网,网络带宽小、延迟大、抖动大,而上游的企业内网则带宽大、延迟小、非常稳定,因此Nginx需要区别对待这两端的网络,以求尽可能地减轻上游应用的负载。
|
||||
|
||||
比如,当你配置proxy_request_buffering on指令(默认就是打开的)后,Nginx会先试图将完整的HTTP BODY接收完,当内存不够(默认是16KB,你可以通过client_body_buffer_size指令修改)时还会保存到磁盘中。这样,在公网上漫长的接收BODY流程中,上游应用都不会有任何流量压力。
|
||||
|
||||
接收完请求后,会向上游应用建立连接。当然,Nginx也会通过定时器来保护自己,比如建立连接的最长超时时间是60秒(可以通过proxy_connect_timeout指令修改)。
|
||||
|
||||
当上游生成HTTP响应后,考虑到不同的网络特点,如果你打开了proxy_buffering on(该功能也是默认打开的)功能,Nginx会优先将内网传来的上游响应接收完毕(包括存储到磁盘上),这样就可以关闭与上游之间的TCP连接,减轻上游应用的并发压力。最后再通过缓慢的公网将响应发送给客户端。当然,针对下游客户端与上游应用,还可以通过proxy_limit_rate与limit_rate指令限制传输速度。如果设置proxy_buffering off,Nginx会从上游接收到一点响应,就立刻往下游发一些。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/90/9c3d5be8ecc6b287a0cb4fc09ab0c690.jpg" alt="">
|
||||
|
||||
### 4. 返回响应
|
||||
|
||||
当生成HTTP响应后,会由注册为HTTP响应的模块依次加工响应。同样,这些模块的顺序也是由configure脚本决定的。由于HTTP响应分为HEADER(包括响应行和头部两部分)、BODY,所以每个过滤模块也可以决定是仅处理HEADER,还是同时处理HEADER和BODY。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/e8/2506dfed0c4792a7a1be390c1c7979e8.jpg" alt="">
|
||||
|
||||
因此,OpenResty中会提供有header_filter_by_lua和body_filter_by_lua这两个指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/81/c495fb95fed3b010a3fcdd26afd08c81.jpg" alt="">
|
||||
|
||||
## 应用层优化
|
||||
|
||||
### 1. 协议
|
||||
|
||||
应用层协议的优化可以带来非常大的收益。比如HTTP/1 HEADER的编码方式低效,REST架构又放大了这一点,改为HTTP/2协议后就大有改善。Nginx对HTTP/2有良好的支持,包括上游、下游,以及基于HTTP/2的gRPC协议。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/91/eebe2bcd1349d51ee1d3cb60a238a391.jpg" alt="">
|
||||
|
||||
### 2. 压缩
|
||||
|
||||
对于无损压缩,信息熵越大,压缩效果就越好。对于文本文件的压缩来说,Google的Brotli就比Gzip效果好,你可以通过[https://github.com/google/ngx_brotli](https://github.com/google/ngx_brotli) 模块,让Nginx支持Brotli压缩算法。
|
||||
|
||||
对于静态图片通常会采用有损压缩,这里不同压缩算法的效果差距更大。目前Webp的压缩效果要比jpeg好不少。对于音频、视频则可以基于关键帧,做动态增量压缩。当然,只要是在Nginx中做实时压缩,就会大幅降低性能。除了每次压缩对CPU的消耗外,也不能使用sendfile零拷贝技术,因为从磁盘中读出资源后,copy_filter过滤模块必须将其拷贝到内存中做压缩,这增加了上下文切换的次数。更好的做法是提前在磁盘中压缩好,然后通过add_header等指令在响应头部中告诉客户端该如何解压。
|
||||
|
||||
### 3. 提高内存使用率
|
||||
|
||||
只在需要时分配恰当的内存,可以提高内存效率。所以下图中Nginx提供的这些内存相关的指令,需要我们根据业务场景谨慎配置。当然,Nginx的内存池已经将内存碎片、小内存分配次数过多等问题解决了。必要时,通过TcMalloc可以进一步提升Nginx申请系统内存的效率。
|
||||
|
||||
同样,提升CPU缓存命中率,也可以提升内存的读取速度。基于cpu cache line来设置哈希表的桶大小,就可以提高多核CPU下的缓存命中率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/a7/aa7727a2dbf6a3a22c2bf933327308a7.jpg" alt="">
|
||||
|
||||
### 4. 限速
|
||||
|
||||
作为负载均衡,Nginx可以通过各类模块提供丰富的限速功能。比如limit_conn可以限制并发连接,而limit_req可以基于leacky bucket漏斗原理限速。对于向客户端发送HTTP响应,可以通过limit_rate指令限速,而对于HTTP上游应用,可以使用proxy_limit_rate限制发送响应的速度,对于TCP上游应用,则可以分别使用proxy_upload_rate和proxy_download_rate指令限制上行、下行速度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/af/3e7dbd21efc06b6721ea7b0c08cd95af.jpg" alt="">
|
||||
|
||||
### 5. Worker间负载均衡
|
||||
|
||||
当Worker进程通过epoll_wait的读事件获取新连接时,就由内核挑选1个Worker进程处理新连接。早期Linux内核的挑选算法很糟糕,特别是1个新连接建立完成时,内核会唤醒所有阻塞在epoll_wait函数上的Worker进程,然而,只有1个Worker进程,可以通过accept函数获取到新连接,其他进程获取失败后重新休眠,这就是曾经广为人知的“惊群”现象。同时,这也很容易造成Worker进程间负载不均衡,由于每个Worker进程绑定1个CPU核心,当部分Worker进程中的并发TCP连接过少时,意味着CPU的计算力被闲置了,所以这也降低了系统的吞吐量。
|
||||
|
||||
Nginx早期解决这一问题,是通过应用层accept_mutex锁完成的,在1.11.3版本前它是默认开启的:accept_mutex on;
|
||||
|
||||
其中负载均衡功能,是在连接数达到worker_connections的八分之七后,进行次数限制实现的。
|
||||
|
||||
我们还可以通过accept_mutex_delay配置控制负载均衡的执行频率,它的默认值是500毫秒,也就是最多500毫秒后,并发连接数较少的Worker进程会尝试处理新连接:accept_mutex_delay 500ms;
|
||||
|
||||
当然,在1.11.3版本后,Nginx默认关闭了accept_mutex锁,这是因为操作系统提供了reuseport(Linux3.9版本后才提供这一功能)这个更好的解决方案。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/7a/5f5f833b51f322ae963bde06c7f66f7a.jpg" alt="">
|
||||
|
||||
图中,横轴中的default项开启了accept_mutex锁。我们可以看到,使用reuseport后,QPS吞吐量有了3倍的提高,同时处理时延有明显的下降,特别是时延的波动(蓝色的标准差线)有大幅度的下降。
|
||||
|
||||
### 6. 超时
|
||||
|
||||
Nginx通过红黑树高效地管理着定时器,这里既有面对TCP报文层面的配置指令,比如面对下游的send_timeout指令,也有面对UDP报文层面的配置指令,比如proxy_responses,还有面对业务层面的配置指令,比如面对下游HTTP协议的client_header_timeout。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fy/f7/fyyb03d85d4b8312873b476888a1a0f7.jpg" alt="">
|
||||
|
||||
### 7. 缓存
|
||||
|
||||
只要想提升性能必须要在缓存上下工夫。Nginx对于七层负载均衡,提供各种HTTP缓存,比如http_proxy模块、uwsgi_proxy模块、fastcgi_proxy模块、scgi_proxy模块等等。由于Nginx中可以通过变量来命名日志文件,因此Nginx很有可能会并行打开上百个文件,此时通过open_file_cache,Nginx可以将文件句柄、统计信息等写入缓存中,提升性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/15/452d0ecf0fcd822c69e1df859fdeb115.jpg" alt="">
|
||||
|
||||
### 8. 减少磁盘IO
|
||||
|
||||
Nginx虽然读写磁盘远没有数据库等服务要多,但由于它对性能的极致追求,仍然提供了许多优化策略。比如为方便统计和定位错误,每条HTTP请求的执行结果都会写入access.log日志文件。为了减少access.log日志对写磁盘造成的压力,Nginx提供了批量写入、实时压缩后写入等功能,甚至你可以在另一个服务器上搭建rsyslog服务,然后配置Nginx通过UDP协议,将access.log日志文件从网络写入到 rsyslog中,这完全移除了日志磁盘IO。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/da/d55cb817bb727a097ffc4dfe018539da.jpg" alt="">
|
||||
|
||||
## 系统优化
|
||||
|
||||
最后,我们来看看针对操作系统内核的优化。
|
||||
|
||||
首先是为由内核实现的OSI网络层(IP协议)、传输层(TCP与UDP协议)修改影响并发性的配置。毕竟操作系统并不知道自己会作为高并发服务,所以很多配置都需要进一步调整。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/15/d58dc3275745603b7525f690479d6615.jpg" alt="">
|
||||
|
||||
其次,优化CPU缓存的亲和性,对于Numa架构的服务器,如果Nginx只使用一半以下的CPU核心,那么就让Worker进程只绑定一颗CPU上的核心。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/c2/8f073d7222yy8e823ce1a7c16b945fc2.jpg" alt="">
|
||||
|
||||
再次,调整默认的TCP网络选项,更快速地发现错误、重试、释放资源。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/d3/326cf5a1cb8a8522b89eb19e7ca357d3.jpg" alt="">
|
||||
|
||||
还可以减少TCP报文的往返次数。比如FastOpen技术可以减少三次握手中1个RTT的时延,而增大初始拥塞窗口可以更快地达到带宽峰值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/09/6f0de237bd54cf2edf8cdcfb606c8c09.jpg" alt="">
|
||||
|
||||
还可以提高硬件资源的利用效率,比如当你在listen指令后加入defer选项后,就使用了TCP_DEFER_ACCEPT功能,这样epoll_wait并不会返回仅完成三次握手的连接,只有连接上接收到的TCP数据报文后,它才会返回socket,这样Worker进程就将原本2次切换就降为1次了,虽然会牺牲一些即时性,但提高了CPU的效率。
|
||||
|
||||
Linux为TCP内存提供了动态调整功能,这样高负载下我们更强调并发性,而低负载下则可以更强调高传输速度。
|
||||
|
||||
我们还可以将小报文合并后批量发送,通过减少IP与TCP头部的占比,提高网络效率。在nginx.conf文件中打开tcp_nopush、tcp_nodelay功能后,都可以实现这些目的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/04/ac69ce7af1abbf4df4bc0b42f288d304.jpg" alt="">
|
||||
|
||||
为了防止处理系统层网络栈的CPU过载,还可以通过多队列网卡,将负载分担到多个CPU中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/82/6bacf6f3cyy3ffd730c6eb2f664fe682.jpg" alt="">
|
||||
|
||||
为了提高内存、带宽的利用率,我们必须更精确地计算出BDP,也就是通过带宽与ping时延算出的带宽时延积,决定socket读写缓冲区(影响滑动窗口大小)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/7d/1389fd8fea84cef63e6438de1e18587d.jpg" alt="">
|
||||
|
||||
Nginx上多使用小于256KB的小内存,而且我们通常会按照CPU核数开启Worker进程,这样一种场景下,TCMalloc的性能要远高于Linux默认的PTMalloc2内存池。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/a8/56267e1yye31a7af808c8793c894b0a8.jpg" alt="">
|
||||
|
||||
作为Web服务器,Nginx必须重写URL以应对网址变化,或者应用的维护,这需要正则表达式的支持。做复杂的URL或者域名匹配时,也会用到正则表达式。优秀的正则表达式库,可以提供更好的执行性能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/f0/556b3359b43f2a641ef2bc4a13a334f0.jpg" alt="">
|
||||
|
||||
以上就是今天的加餐分享,有任何问题欢迎在留言区中提出。
|
142
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐5 | 如何理解分布式系统?.md
Normal file
142
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐5 | 如何理解分布式系统?.md
Normal file
@@ -0,0 +1,142 @@
|
||||
<audio id="audio" title="加餐5 | 如何理解分布式系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/41/8919966c3d3e5f51394646efb5bcf241.mp3"></audio>
|
||||
|
||||
你好,我是陶辉。课程到现在也已经接近尾声,我看到有的同学已经开始在掉队了。所以今天这讲,我准备来回答大家的一些高频问题。
|
||||
|
||||
咱们目前正在学习的这一模块叫“分布式系统优化”,我给你讲了监控、CAP、负载均衡、一致性哈希,说实话,这些知识都不简单,你如果觉得有点难,那也别气馁,因为它确实得多琢磨,我自己一开始学习的时候也是这样。
|
||||
|
||||
不过,我发现,在这个模块中,很多同学似乎对分布式有什么误解,有的人说分布式就是多台机器,有的人说分布式就是微服务,总之,大家各有自己的理解。于是,我就想着给你写篇加餐,来系统聊聊这个话题。
|
||||
|
||||
不过,在查资料的过程中,我发现InfoQ上已经有一篇文章很好地回答了这个问题。于是,经过编辑冬青的努力,我们找到了作者张帆,申请到了那篇文章的授权,在这里交付给你。
|
||||
|
||||
如果现在让你阐述一下什么是“分布式系统”,你脑子里第一下跳出来的是什么?我想,此时可以用苏东坡先生的一句诗,来形象地描述大家对分布式系统的认识:
|
||||
|
||||
>
|
||||
横看成岭侧成峰,远近高低各不同。
|
||||
|
||||
|
||||
## “分布式系统”等于 SOA、ESB、微服务这些东西吗?
|
||||
|
||||
我觉得每个人脑子里一下子涌现出来的肯定是非常具象的东西,就像下面这些:
|
||||
|
||||
>
|
||||
“分布式系统”等于 SOA、ESB、微服务这些东西吗?
|
||||
|
||||
|
||||
如果你一下子想到的是 XX 中心、XX 服务,意味着你把服务化的模式(SOA、ESB、微服务)和分布式系统错误地划上了等号。
|
||||
|
||||
那么,什么是“服务化”呢?服务化就像企业当中将相同岗位的人员划分到同一个部门管理,以此来收敛特定的工作入口,再进行二次分配,以提高人员利用率和劳动成果的复用度。服务化的本质是“分治”,而“分治”的前提是先要拆,然后才谈得上如何治。这时,高内聚、低耦合的思想在拆分过程中起到了一个非常重要的作用,因为这可以尽可能地降低拆分后不同组件间进行协作的复杂度。所以重要的是“怎么拆”,还有如何循序渐进地拆,而这个过程中你究竟是采用了何种服务化模式(比如 SOA、ESB、微服务等)并不是关键。
|
||||
|
||||
为什么说“怎么拆”最重要呢?我来举个例子,企业的组织架构包括三种模型:职能型、项目型、矩阵型。你可以把这里的企业理解为一个“分布式系统”,把后面的 3 种模型理解为这个分布式系统的 3 种形态。作为这个“系统”的所有人,你需要考虑如何拆分它,才能使得各功能组件相互之间可以更好地协作。假设,你要将一个总计 10000 名员工的企业按“职能型”拆分成 20 个部门,得到的结果是每个部门 500 人。
|
||||
|
||||
这时,如果工作是流水线式的上下游关系。一个部门完工了再交给下一个部门。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/de/7c2f6a058fc79b87b2a041819e392bde.jpg" alt="">
|
||||
|
||||
那么这时候是高内聚、低耦合的。因为一个工种只与另一个工种产生了关联,并且仅有一次。
|
||||
|
||||
但如果工作需要频繁的由不同职能的人员同时进行,就会导致同一个部门可能与多个部门产生联系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/8c/271961e7dd4ca0e9d626afb52e926d8c.jpg" alt="">
|
||||
|
||||
那么,这时是低内聚、高耦合的。因为一个工种需要和其他多个工种产生关联并且远不止一次。
|
||||
|
||||
可以看到服务化体现了“分治”的效果,这也是分布式系统的核心思想,因此从“分治”这个本质上来看,服务化的确是分布式系统,但分布式系统不仅仅停留在那些服务化的模式上。
|
||||
|
||||
我相信,你在工作中参与开发的任何软件系统,到处都存在着需要拆分的地方,除非它的功能极简到只需要计算一个 1+1。比如,当我们在电商平台点击“提交订单”的时候,会涉及生成订单、扣除积分、扣除库存等等动作。电商系统初期所有的功能可能都在一个系统里面,那么这些操作可以写在一个方法体里吗?我想只要代码能够成功运行,大部分人是不会管你怎么写的。但是如果这时需要增加一个红包功能呢?相信你或多或少遇到过在几百上千行代码中去增改功能的事情,其中的痛苦应该深有体会。
|
||||
|
||||
要解决这个问题就是要做拆分,通过梳理、归类,将不同的紧密相关的部分收敛到一个独立的逻辑体中,这个逻辑体可以是函数、类以及命名空间,等等。所以,从这个角度来说“分治”的问题其实早就存在我们的工作中,就看我们是否有去关注它了。因此,这并不只是我们在进行服务化时才需要考虑的问题。
|
||||
|
||||
那么如何才能做好这个事情,更好的拆分能力正是我们需要掌握的。如果只是因为看到其他人这么拆,我也这么拆,根据“二八原则”,或许“依样画葫芦”可以达到 80% 的契合度,但是往往那剩下的 20% 会是耗费我们 80% 精力的“大麻烦”。要知道,**只有掌握了核心主旨,才能更快地找到最理想的高内聚、低耦合方案。**
|
||||
|
||||
## “分布式系统”是各种中间件吗?
|
||||
|
||||
又或许,听到分布式系统,你想到了某某 MQ 框架、某某 RPC 框架、某某 DAL 框架,把运用中间件和分布式系统错误地划上了等号。
|
||||
|
||||
这里需要你搞清楚的是,中间件起到的是标准化的作用。中间件只是承载这些标准化想法的介质、工具,可以起到引导和约束的效果,以此大大降低系统的复杂度和协作成本。我们来分别看一下:
|
||||
|
||||
- MQ 框架标准化了不同应用程序间非实时异步通信的方式。
|
||||
- RPC 框架标准化了不同应用程序间实时通讯的方式。
|
||||
- DAL(Data Access Layer,数据访问层)框架标准化了应用程序和数据库之间通讯的方式。
|
||||
|
||||
所以,**虽然分布式系统中会运用中间件,但分布式系统却不仅仅停留在用了什么中间件上。**你需要清楚每一类中间件背后是对什么进行了标准化,它的目的是什么,带来了哪些副作用,等等。只有如此,你才能真正识别不同技术框架之间的区别,找到真正适合当前系统的技术框架。
|
||||
|
||||
那么标准是拍脑袋决定的吗?肯定不是,正如前面所说每一次标准化都是有目的的,需要产生价值。比如,大部分中间件都具备这样一个价值:
|
||||
|
||||
>
|
||||
为了在软件系统的迭代过程中,避免将精力过多地花费在某个子功能下众多差异不大的选项中。
|
||||
|
||||
|
||||
在现实中,这点更多时候出现在技术层面的中间件里,比如,数据库访问框架的作用是为了标准化操作不同数据库的差异,使得上层应用程序不用纠结于该怎么与 MySQL 交互或者该怎么与 SQL SERVER 交互。因为与业务相比,技术层面“稳定”多了,所以做标准化更有价值,更能获得长期收益。但“稳定”是相对的,哪怕单纯在业务层面也存在相对稳定的部分。
|
||||
|
||||
比如,你可以想象一下“盛饭”的场景,在大多数情况下其中相对稳定的是什么,不稳定的是什么。想完之后看下面的示例:
|
||||
|
||||
```
|
||||
...
|
||||
基类:人
|
||||
继承基类的子类:男人、女人
|
||||
|
||||
基类:碗
|
||||
继承基类的子类:大碗、小碗、汤碗
|
||||
|
||||
基类:勺子
|
||||
继承基类的子类:铁勺、陶瓷勺、塑料勺
|
||||
|
||||
function 盛饭(参数 人,参数 碗,参数 勺子){
|
||||
do 人拿起碗
|
||||
do 人拿起勺子
|
||||
do 人用勺子舀起饭
|
||||
do 人把勺子放到碗的上方并倒下
|
||||
|
||||
}
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
从这个示例里我们发现,不稳定的部分都已经成为变量了,那么剩下的这个方法体起到的作用和前面提到的中间件是一样的,它标准化了盛饭的过程。所以识别相对稳定的部分是什么,如何把它们提炼出来,并且围绕这些点进行标准化,才是我们需要掌握的能力。而锻炼这个能力和需要这个能力的地方同样并不局限于分布式系统。
|
||||
|
||||
**列举这些现象只是想说,我们在认知一个分布式系统的时候,内在胜于表象,掌握一个扎实的理论基本功更为重要。**而且,这些训练场无处不在。
|
||||
|
||||
## 海市蜃楼般的“分布式系统”
|
||||
|
||||
我相信,自从进入移动时代以来,各种高大上的系统架构图越来越频繁地出现,你的眼前充斥着各种主流、非主流的眼花缭乱的技术框架。你不由得肃然起敬一番,心中呐喊着:“对,这就是我想去的地方,我想参与甚至实现一个这样牛逼的分布式系统,再也不想每天只是增删改查了。”
|
||||
|
||||
得不到的事物总是美好的,但往往我们也会过度地高估它的美好。与此类似,高大上的架构图背后呈现的系统的确也是一个成熟分布式系统的样貌,但我们要清楚一点:罗马不是一日建成的。
|
||||
|
||||
而且,“分布式”这个词只是意味着形态上是散列状的,而“一分为二”和“一分为 N”本质上并没有区别。所以,很多小项目或者大型项目的初期所搭配的基础套餐“单程序 + 单数据库”,同样可以理解为分布式系统,其中遇到的问题很多同样也存在于成熟的分布式系统中。
|
||||
|
||||
想象一下,下面的场景是否在“单程序 + 单数据库”项目中出现过?
|
||||
|
||||
- log 记录执行成功,但是数据库的数据没发生变化;
|
||||
- 进程内的缓存数据更新了,但是数据库更新失败了。
|
||||
|
||||
这里我们停顿 30 秒,思考一下为什么会出现这些问题?
|
||||
|
||||
这里需要我们先思考一下“软件”是什么。 软件的本质是一套代码,而代码只是一段文字,除了提供文字所表述的信息之外,本身无法“动”起来。但是,想让它“动”起来,使其能够完成一件我们指定的事情,前提是需要一个宿主来给予它生命。这个宿主就是计算机,它可以让代码变成一连串可执行的“动作”,然后通过数据这个“燃料”的触发,“动”起来。这个持续的活动过程,又被描述为一个运行中的“进程”。
|
||||
|
||||
那么除了我们开发的系统是软件,数据库也是软件,前者负责运算,后者负责存储运算后的结果(也可称为“状态”),分工协作。
|
||||
|
||||
所以,“单程序 + 单数据库”为什么也是分布式系统这个问题就很明白了。因为我们所编写的程序运行时所在的进程,和程序中使用到的数据库所在的进程,并不是同一个。也因此导致了,让这两个进程(系统)完成各自的部分,而后最终完成一件完整的事,变得不再像由单个个体独自完成这件事那么简单。这就如“两人三足”游戏一样,如何尽可能地让外部看起来像是一个整体、自然地前进。
|
||||
|
||||
**所以,我们可以这么理解,涉及多个进程协作才能提供一个完整功能的系统就是“分布式系统”。**
|
||||
|
||||
那么再回到上面举例的两个场景,我们在思考“单程序 + 单数据库”项目中遇到的这些问题背后的原因和解决它的过程时,与我们在一个成熟的分布式系统中的遭遇是一样的,例如数据一致性。当然,这只是分布式系统核心概念的冰山一角。
|
||||
|
||||
维基百科对“分布式系统”的宏观定义是这样的:
|
||||
|
||||
>
|
||||
分布式系统是一种其组件位于不同的联网计算机上的系统,然后通过互相传递消息来进行通信和协调。为了达到共同的目标,这些组件会相互作用。
|
||||
|
||||
|
||||
我们可以再用大小关系来解释它:把需要进行大量计算的工程数据分割成小块,由多台计算机分别计算,然后将结果统一合并得出数据结论的科学。这本质上就是“分治”。而“单程序 + 单数据库”组合的系统也包含了至少两个进程,“麻雀虽小五脏俱全”,这也是“分布式系统”。
|
||||
|
||||
## 小结
|
||||
|
||||
现在,我们搞清楚了,**看待一个“分布式系统”的时候,内在胜于表象。以及,只要涉及多个进程协作才能提供一个完整功能的系统,就是“分布式系统”。**
|
||||
|
||||
我相信还有很多其他景象出现你的脑海中,但这大多数都是分布式系统的本质产生的“化学反应”,进而形成的结果。如果停留在这些表象上,那么我们最终将无法寻找到“分布式系统”的本质,也就无法得到真正的“道”,更不会真正具备驾驭这些形态各异的“分布式系统”的能力。
|
||||
|
||||
所以,希望你在学习分布式系统的时候,不要因追逐“术”而丢了“道”。没有“道”只有“术”是空壳,最终会走火入魔,学得越多,会越混乱,到处都是矛盾和疑惑。
|
||||
|
||||
以上就是张帆老师的分享,他的观点与本专栏也是不谋而合的。他认为:我们不仅要清楚具体场景下的最佳实践,还要明白为什么这样做,以及该如何去权衡不同方案。我们务必要修炼好自己的内功,形成一套完整的知识体系,完成核心“骨架”的塑造。而在此之后,你自己在课外学习时,就可以去填充“血肉”部分,逐渐丰满自己。未来,大家的区别就在于胖一点和瘦一点,但只要能很好地完成工作,胖瘦又有何影响呢?
|
||||
|
||||
最后,有关“分布式系统优化”你还有什么问题吗?欢迎在留言区中一起讨论。
|
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐6|分布式系统的本质是什么?.md
Normal file
113
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐6|分布式系统的本质是什么?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="加餐6|分布式系统的本质是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/bb/f759fec4d4bd781dbd4ed98892d080bb.mp3"></audio>
|
||||
|
||||
你好,我是编辑冬青。上一期加餐我们分享了张帆老师的一篇文章,从总体上聊了聊分布式系统,那作为系列分享,这期加餐我还为你带来了张帆老师的另一篇文章,进一步聊聊分布式系统的本质。这里交付给你,期待能给你带来更多的收获!
|
||||
|
||||
## 分布式系统的价值
|
||||
|
||||
谈到分布式系统的价值,可能就得从 1953 年说起了。在这一年,埃布·格罗希(Herb Grosch)提出了一个他观察得出的规律——Grosch 定律。维基百科中是这样描述的:
|
||||
|
||||
>
|
||||
计算机性能随着成本的平方而增加。如果计算机 A 的成本是计算机 B 的两倍,那么计算机 A 的速度应该是计算机 B 的四倍。
|
||||
|
||||
|
||||
这一论断与当时的大型机技术非常吻合,因而使得许多机构都尽其所能购买最大的单个大型机。其实,这也非常符合惯性思维,简单粗暴。
|
||||
|
||||
然而,1965 年高登·摩尔(Gordon Moore)提出了摩尔定律。经过几年的发展,人们发现摩尔定律的预测是符合现实的。这就意味着,集中式系统的运算能力每隔一段时间才能提升一倍。
|
||||
|
||||
那么,到底要隔多久呢?这个“时间”有很多版本,比如广为流传的 18 个月版本,以及 Gordon Moore 本人坚持的 2 年版本。这里我们不用太过纠结于实际情况到底是哪个“时间”版本,因为这其中隐含的意思更重要,即:**如果你的系统需承载的计算量的增长速度大于摩尔定律的预测,那么在未来的某一个时间点,集中式系统将无法承载你所需的计算量。**
|
||||
|
||||
而这只是一个内在因素,真正推动分布式系统发展的催化剂是“经济”因素。
|
||||
|
||||
人们发现,用廉价机器的集合组成的分布式系统,除了可以获得超过 CPU 发展速度的性能外,花费更低,具有更好的性价比,并且还可以根据需要增加或者减少所需机器的数量。
|
||||
|
||||
所以,我们得到一个新结论:**无论是要以低价格获得普通的性能,还是要以较高的价格获得极高的性能,分布式系统都能够满足。并且受规模效应的影响,系统越大,性价比带来的收益越高。**
|
||||
|
||||
之后,进入到互联网快速发展的时期,我们看到了分布式系统相比集中式系统的另一个更明显的优势:更高的可用性。例如,有 10 个能够承载 10000 流量的相同的节点,如果其中的 2 个挂了,只要实际流量不超过 8000,系统依然能够正常运转。
|
||||
|
||||
而这一切的价值,都是建立在分布式系统的“分治”和“冗余”之上的。从全局角度来看,这其实就是分布式系统的本质。
|
||||
|
||||
## 分治
|
||||
|
||||
分治,字面意思是“分而治之”,和我们的大脑在解决问题时的思考方式是一样的。我们可以将整个过程分为 3 步:分解 -> 治理 -> 归并。而分治思想的表现形式多样,分层、分块都是它的体现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/75/d71fdd9d2a5ce164e82e210a6b4cda75.jpg" alt="">
|
||||
|
||||
这么做的好处是:问题越小越容易被解决,并且,只要解决了所有子问题,父问题就都可以被解决了。但是,这么做的时候,需要满足一个最重要的条件:**不同分支上的子问题,不能相互依赖,需要各自独立。**因为一旦包含了依赖关系,子问题和父问题之间就失去了可以被“归并”的意义。在软件开发领域,我们把这个概念称为“**耦合度**”和“**内聚度**”,这两个度量概念非常重要。
|
||||
|
||||
耦合度,指的是软件模块之间相互依赖的程度。比如,每次调用方法 A 之后都需要同步调用方法 B,那么此时方法 A 和 B 间的耦合度是高的。
|
||||
|
||||
内聚度,指的是模块内的元素具有的共同点的相似程度。比如,一个类中的多个方法有很多的共同之处,都是做支付相关的处理,那么这个类的内聚度是高的。
|
||||
|
||||
**内聚度通常与耦合度形成对比。低耦合通常与高内聚相关,反之亦然。**
|
||||
|
||||
所以,当你打算进行分治的时候,耦合度和内聚度就是需要考虑的重点。
|
||||
|
||||
下面我们来看个例子,体会一下耦合度和内聚度的含义(图仅用于表达含义,切勿作其他参考)。假设一个电商平台,为了应对更大的访问量,需要拆分一个同时包含商品、促销的系统。如果垂直拆分,是这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/9d/e33bb57df050557a26a845369a01c49d.jpg" alt="">
|
||||
|
||||
而如果水平拆分,则是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/d5/6b9bd45b4feaeb8980918ff63e6746d5.jpg" alt="">
|
||||
|
||||
假如我们面对的场景仅仅是具体的商品详情展示页面,很显然,用水平拆分的效果会更好。因为传统的商品展示必然会同时展示促销,所以,如果用水平拆分,一次请求即可获取所有数据,内聚度非常高,并且此时模块间完全没有耦合。而如果是垂直拆分的话,就需要同时请求 2 个节点的数据并进行组合,因此耦合度更高、内聚度更差。
|
||||
|
||||
但是,这样的假设在真实的电商场景中是不存在的。从全局来看,订单、购物车、商品列表等许多其他场景也需要促销信息。并且这个时候我们发现引入了一些新的主体,诸如订单、购物车、商品分类等等。这个时候,水平拆分带来的好处越来越小,因为这样只解决了多个耦合中的一个,低耦合丧失了。并且随着商品和促销与外界的关联越来越多,必然有些场景仅仅涉及到商品和促销的其中一个,但是处理的时候,我们还需要避免受到另一个的影响。如此,高内聚也丧失了。
|
||||
|
||||
这个时候,反而通过垂直拆分可以获得更优的耦合度和内聚度,如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/2f/426f0ca71cb9d9aab8b08cb40cc0ef2f.jpg" alt="">
|
||||
|
||||
最高的耦合关系从原先的 6 降到了 4,并且商品和促销各自的处理相互不受影响。
|
||||
|
||||
所以,你会发现随着业务的变化,耦合度与内聚度也会发生变化。因此,及时地进行梳理和调整,可以避免系统的复杂度快速增长,这样才能最大程度地发挥“分治”带来的好处。
|
||||
|
||||
综上,分治可以简化解题的难度,通过高内聚、低耦合的协作关系达到更好的“性能与经济比”,来承载更大的流量。而“冗余”则带来了系统可以 7*24 小时不间断运作的希望。
|
||||
|
||||
## 冗余
|
||||
|
||||
这里的冗余并不等同于代码的冗余、无意义的重复劳动,而是我们有意去做的、人为增加的重复部分。其目的是容许在一定范围内出现故障,而系统不受影响,如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/f5/ed8ebdf12cb4bc7bafc150415fb766f5.jpg" alt="">
|
||||
|
||||
此时,我们可以将冗余的节点部署在一个独立的环境中。这个独立的环境,可能是处于同一个局域网内的不同主机,也可能是在不同的局域网,还可能是在不同的机房。很显然,它们能够应对的故障范围是逐步递增的。
|
||||
|
||||
但是,像这种单纯为了备用而做的冗余,最大的弊端是,如果没有出现故障,那么冗余的这部分资源就白白浪费了,不能发挥任何作用。所以,我们才提出了诸如双主多活、读写分离之类的概念,以提高资源利用率。
|
||||
|
||||
当然,除了软件层面,硬件层面的冗余也是同样的道理。比如,磁盘阵列可以容忍几块之内磁盘损坏,而不会影响整体。
|
||||
|
||||
不过也很显然,当故障影响范围大于你冗余的容量时,系统依然会挂。所以,既然你无法预知故障的发生情况,那么做冗余的时候需要平衡的另一端就是成本。相比更多的冗余,追求更好的性价比更合理一些。
|
||||
|
||||
在我们生活中的冗余也到处存在。比如,大部分的飞机和直升机的发动机都是偶数的,汽车中的电子控制系统的冗余机制等。就好比替身与真身的关系,冗余的就是替身。它可以和真身同时活动,也可以代替真身活动。
|
||||
|
||||
分治和冗余讲究的都是分散化,最终形成一个完整的系统还需要将它们“连接”起来。天下没有免费的午餐,获得分布式系统价值的同时,这个“再连接”的过程就是我们相比集中式系统要做的额外工作。
|
||||
|
||||
### 再连接
|
||||
|
||||
如何将拆分后的各个节点再次连接起来,从模式上来说,主要是去中心化与中心化之分。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/ab/47543893306d54588af427e3yyab2aab.jpg" alt="">
|
||||
|
||||
前者完全消除了中心节点故障带来的全盘出错的风险,却带来了更高的节点间协作成本。后者通过中心节点的集中式管理大大降低了协作成本,但是一旦中心节点故障则全盘出错。
|
||||
|
||||
另外,从技术角度来说,如何选择通信协议和序列化机制,也是非常重要的。
|
||||
|
||||
虽然很多通讯协议和序列化机制完全可以承担任何场景的连接责任,但是不同的协议和序列化机制在适合的场景下才能发挥它最大的优势。比如,需要更高性能的场景运用 TCP 协议优于 HTTP 协议;需要更高吞吐量的场景运用 UDP 协议优于 TCP 协议,等等。
|
||||
|
||||
## 小结
|
||||
|
||||
不管系统的规模发展到多大,合理的拆分,加上合适的连接方式,那么至少会是一个运转顺畅、协作舒服的系统,至少能够正常发挥分布式系统应有的价值。
|
||||
|
||||
如今,我们发现分布式系统还可以发挥更多的作用。比如,只要基于一个统一的上层通信协议,其下层的不同节点可以运用不同的技术栈来发挥不同技术各自的优势,比如用 Go 来应对高并发场景,用 Python 来做数据分析等。再比如,提高交付的速度,如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/8f/8fb6fe3132223a85dbb1e3a35ce6098f.jpg" alt="">
|
||||
|
||||
通过分配不同的团队、人员同时进行多个模块的开发,虽然总的耗时增加了,但是整体的交付速度加快了。
|
||||
|
||||
事物最本质的东西是恒定的、不变的,可以指引我们的工作方向。分布式系统的本质也是这样。例如,这样的“分治”方案耦合度和内聚度是否最优,这样做“冗余”带来的收益是否成本能够接受。只要持续带着这些思考,我们就好像拿着一杆秤,基于它,我们就可以去衡量各种变量影响,然后作权衡。比如成本、时间、人员、性能、易维护等等。也可以基于它去判断什么样的框架、组件、协议更适合当前的环境。
|
||||
|
||||
需要不断的权衡,也意味着分布式系统的设计工作一定不是一步到位,而是循序渐进的。因为过分为未知的未来做更多的考量,最终可能都会打水漂。所以,建议以多考虑 1~2 步为宜。假如以你所在的团队中对重大技术升级的频率来作为参考的话,做可供 2 个升级周期的设计,花一个升级周期的时间先实现第一阶段,下个阶段可以选择直接实现剩下的部分,也可继续进行 2 个升级周期设计,开启一个循环,持续迭代,并且不断修正方向以更贴近现实的发展,就如下图这样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/65/702d945e44414609a4ba116a3c5be965.jpg" alt="">
|
||||
|
||||
以上就是今天的全部内容。最后,互动一下,在你的工作或者学习中,你觉得分布式系统还具备哪些价值呢?欢迎留言!
|
215
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐7|深入剖析HTTP|3协议.md
Normal file
215
极客时间专栏/系统性能调优必知必会/加餐与分享/加餐7|深入剖析HTTP|3协议.md
Normal file
@@ -0,0 +1,215 @@
|
||||
<audio id="audio" title="加餐7|深入剖析HTTP/3协议" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/d7/0b36c01d64c8a2be2dddf6df1b4871d7.mp3"></audio>
|
||||
|
||||
你好,我是陶辉,又见面了。结课并不意味着结束,有好的内容我依然会分享给你。今天这节加餐,整理自今年8月3号我在[Nginx中文社区](https://www.nginx-cn.net/explore)与QCon共同组织的[QCon公开课](https://www.infoq.cn/video/VPK3Zu0xrv6U8727ZSXB?utm_source=in_album&utm_medium=video)中分享的部分内容,主要介绍HTTP/3协议规范、应用场景及实现原理。欢迎一起交流探讨!
|
||||
|
||||
自2017年起,HTTP/3协议已发布了29个Draft,推出在即,Chrome、Nginx等软件都在跟进实现最新的草案。那它带来了哪些变革呢?我们结合HTTP/2协议看一下。
|
||||
|
||||
2015年,HTTP/2协议正式推出后,已经有接近一半的互联网站点在使用它:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/0c/01/0c0277835b0yy731b11d68d44de00601.jpg" alt="" title="图片来自:https://w3techs.com/technologies/details/ce-http2">](https://w3techs.com/technologies/details/ce-http2)
|
||||
|
||||
HTTP/2协议虽然大幅提升了HTTP/1.1的性能,然而,基于TCP实现的HTTP/2遗留下3个问题:
|
||||
|
||||
- 有序字节流引出的**队头阻塞(**[**Head-of-line blocking**](https://en.wikipedia.org/wiki/Head-of-line_blocking)**)**,使得HTTP/2的多路复用能力大打折扣;
|
||||
- **TCP与TLS叠加了握手时延**,建链时长还有1倍的下降空间;
|
||||
- 基于TCP四元组确定一个连接,这种诞生于有线网络的设计,并不适合移动状态下的无线网络,这意味着**IP地址的频繁变动会导致TCP连接、TLS会话反复握手**,成本高昂。
|
||||
|
||||
而HTTP/3协议恰恰是解决了这些问题:
|
||||
|
||||
- HTTP/3基于UDP协议重新定义了连接,在QUIC层实现了无序、并发字节流的传输,解决了队头阻塞问题(包括基于QPACK解决了动态表的队头阻塞);
|
||||
- HTTP/3重新定义了TLS协议加密QUIC头部的方式,既提高了网络攻击成本,又降低了建立连接的速度(仅需1个RTT就可以同时完成建链与密钥协商);
|
||||
- HTTP/3 将Packet、QUIC Frame、HTTP/3 Frame分离,实现了连接迁移功能,降低了5G环境下高速移动设备的连接维护成本。
|
||||
|
||||
接下来我们就会从HTTP/3协议的概念讲起,从连接迁移的实现上学习HTTP/3的报文格式,再围绕着队头阻塞问题来分析多路复用与QPACK动态表的实现。虽然正式的RFC规范还未推出,但最近的草案Change只有微小的变化,所以现在学习HTTP/3正当其时,这将是下一代互联网最重要的基础设施。
|
||||
|
||||
## HTTP/3协议到底是什么?
|
||||
|
||||
就像HTTP/2协议一样,HTTP/3并没有改变HTTP/1的语义。那什么是HTTP语义呢?在我看来,它包括以下3个点:
|
||||
|
||||
- 请求只能由客户端发起,而服务器针对每个请求返回一个响应;
|
||||
- 请求与响应都由Header、Body(可选)组成,其中请求必须含有URL和方法,而响应必须含有响应码;
|
||||
- Header中各Name对应的含义保持不变。
|
||||
|
||||
HTTP/3在保持HTTP/1语义不变的情况下,更改了编码格式,这由2个原因所致:
|
||||
|
||||
首先,是为了减少编码长度。下图中HTTP/1协议的编码使用了ASCII码,用空格、冒号以及 \r\n作为分隔符,编码效率很低。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/91/f8c65f3f1c405f2c87db1db1c421f891.jpg" alt="">
|
||||
|
||||
HTTP/2与HTTP/3采用二进制、静态表、动态表与Huffman算法对HTTP Header编码,不只提供了高压缩率,还加快了发送端编码、接收端解码的速度。
|
||||
|
||||
其次,由于HTTP/1协议不支持多路复用,这样高并发只能通过多开一些TCP连接实现。然而,通过TCP实现高并发有3个弊端:
|
||||
|
||||
- 实现成本高。TCP是由操作系统内核实现的,如果通过多线程实现并发,并发线程数不能太多,否则线程间切换成本会以指数级上升;如果通过异步、非阻塞socket实现并发,开发效率又太低;
|
||||
- 每个TCP连接与TLS会话都叠加了2-3个RTT的建链成本;
|
||||
- TCP连接有一个防止出现拥塞的慢启动流程,它会对每个TCP连接都产生减速效果。
|
||||
|
||||
因此,HTTP/2与HTTP/3都在应用层实现了多路复用功能:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/90/e0/90f7cc7fed1a46b691303559cde3bce0.jpg" alt="" title="图片来自:https://blog.cloudflare.com/http3-the-past-present-and-future/">](https://blog.cloudflare.com/http3-the-past-present-and-future/)
|
||||
|
||||
HTTP/2协议基于TCP有序字节流实现,因此**应用层的多路复用并不能做到无序地并发,在丢包场景下会出现队头阻塞问题**。如下面的动态图片所示,服务器返回的绿色响应由5个TCP报文组成,而黄色响应由4个TCP报文组成,当第2个黄色报文丢失后,即使客户端接收到完整的5个绿色报文,但TCP层不允许应用进程的read函数读取到最后5个报文,并发也成了一纸空谈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/46/12473f12db5359904526d1878bc3c046.gif" alt="">
|
||||
|
||||
当网络繁忙时,丢包概率会很高,多路复用受到了很大限制。因此,**HTTP/3采用UDP作为传输层协议,重新实现了无序连接,并在此基础上通过有序的QUIC Stream提供了多路复用**,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/35/d6/35c3183d5a210bc4865869d9581c93d6.png" alt="" title="图片来自:https://blog.cloudflare.com/http3-the-past-present-and-future/">](https://blog.cloudflare.com/http3-the-past-present-and-future/)
|
||||
|
||||
最早这一实验性协议由Google推出,并命名为gQUIC,因此,IETF草案中仍然保留了QUIC概念,用来描述HTTP/3协议的传输层和表示层。HTTP/3协议规范由以下5个部分组成:
|
||||
|
||||
- QUIC层由[https://tools.ietf.org/html/draft-ietf-quic-transport-29](https://tools.ietf.org/html/draft-ietf-quic-transport-29) 描述,它定义了连接、报文的可靠传输、有序字节流的实现;
|
||||
- TLS协议会将QUIC层的部分报文头部暴露在明文中,方便代理服务器进行路由。[https://tools.ietf.org/html/draft-ietf-quic-tls-29](https://tools.ietf.org/html/draft-ietf-quic-tls-29) 规范定义了QUIC与TLS的结合方式;
|
||||
- 丢包检测、RTO重传定时器预估等功能由[https://tools.ietf.org/html/draft-ietf-quic-recovery-29](https://tools.ietf.org/html/draft-ietf-quic-recovery-29) 定义,目前拥塞控制使用了类似[TCP New RENO](https://tools.ietf.org/html/rfc6582) 的算法,未来有可能更换为基于带宽检测的算法(例如[BBR](https://github.com/google/bbr));
|
||||
- 基于以上3个规范,[https://tools.ietf.org/html/draft-ietf-quic-http-29](https://tools.ietf.org/html/draft-ietf-quic-http-29H) 定义了HTTP语义的实现,包括服务器推送、请求响应的传输等;
|
||||
- 在HTTP/2中,由HPACK规范定义HTTP头部的压缩算法。由于HPACK动态表的更新具有时序性,无法满足HTTP/3的要求。在HTTP/3中,QPACK定义HTTP头部的编码:[https://tools.ietf.org/html/draft-ietf-quic-qpack-16](https://tools.ietf.org/html/draft-ietf-quic-qpack-16)。注意,以上规范的最新草案都到了29,而QPACK相对简单,它目前更新到16。
|
||||
|
||||
自1991年诞生的HTTP/0.9协议已不再使用,但1996推出的HTTP/1.0、1999年推出的HTTP/1.1、2015年推出的HTTP/2协议仍然共存于互联网中(HTTP/1.0在企业内网中还在广为使用,例如Nginx与上游的默认协议还是1.0版本),即将面世的HTTP/3协议的加入,将会进一步增加协议适配的复杂度。接下来,我们将深入HTTP/3协议的细节。
|
||||
|
||||
## 连接迁移功能是怎样实现的?
|
||||
|
||||
对于当下的HTTP/1和HTTP/2协议,传输请求前需要先完成耗时1个RTT的TCP三次握手、耗时1个RTT的TLS握手(TLS1.3),**由于它们分属内核实现的传输层、openssl库实现的表示层,所以难以合并在一起**,如下图所示:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/79/a2/79a1d38d2e39c93e600f38ae3a9a04a2.jpg" alt="" title="图片来自:https://blog.cloudflare.com/http3-the-past-present-and-future/">](https://blog.cloudflare.com/http3-the-past-present-and-future/)
|
||||
|
||||
在IoT时代,移动设备接入的网络会频繁变动,从而导致设备IP地址改变。**对于通过四元组(源IP、源端口、目的IP、目的端口)定位连接的TCP协议来说,这意味着连接需要断开重连,所以上述2个RTT的建链时延、TCP慢启动都需要重新来过。**而HTTP/3的QUIC层实现了连接迁移功能,允许移动设备更换IP地址后,只要仍保有上下文信息(比如连接ID、TLS密钥等),就可以复用原连接。
|
||||
|
||||
在UDP报文头部与HTTP消息之间,共有3层头部,定义连接且实现了Connection Migration主要是在Packet Header中完成的,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/7c/ab3283383013b707d1420b6b4cb8517c.png" alt="">
|
||||
|
||||
这3层Header实现的功能各不相同:
|
||||
|
||||
- Packet Header实现了可靠的连接。当UDP报文丢失后,通过Packet Header中的Packet Number实现报文重传。连接也是通过其中的Connection ID字段定义的;
|
||||
- QUIC Frame Header在无序的Packet报文中,基于QUIC Stream概念实现了有序的字节流,这允许HTTP消息可以像在TCP连接上一样传输;
|
||||
- HTTP/3 Frame Header定义了HTTP Header、Body的格式,以及服务器推送、QPACK编解码流等功能。
|
||||
|
||||
为了进一步提升网络传输效率,Packet Header又可以细分为两种:
|
||||
|
||||
- Long Packet Header用于首次建立连接;
|
||||
- Short Packet Header用于日常传输数据。
|
||||
|
||||
其中,Long Packet Header的格式如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/b4/5ecc19ba9106179cd3443eefc1d6b8b4.png" alt="">
|
||||
|
||||
建立连接时,连接是由服务器通过Source Connection ID字段分配的,这样,后续传输时,双方只需要固定住Destination Connection ID,就可以在客户端IP地址、端口变化后,绕过UDP四元组(与TCP四元组相同),实现连接迁移功能。下图是Short Packet Header头部的格式,这里就不再需要传输Source Connection ID字段了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/26/f41634797dfafeyy4535c3e94ea5f226.png" alt="">
|
||||
|
||||
上图中的Packet Number是每个报文独一无二的序号,基于它可以实现丢失报文的精准重发。如果你通过抓包观察Packet Header,会发现Packet Number被TLS层加密保护了,这是为了防范各类网络攻击的一种设计。下图给出了Packet Header中被加密保护的字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/93/5a77916ce399148d0c2d951df7c26c93.png" alt="">
|
||||
|
||||
其中,显示为E(Encrypt)的字段表示被TLS加密过。当然,Packet Header只是描述了最基本的连接信息,其上的Stream层、HTTP消息也是被加密保护的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/af/9edabebb331bb46c6e2335eda20c68af.png" alt="">
|
||||
|
||||
现在我们已经对HTTP/3协议的格式有了基本的了解,接下来我们通过队头阻塞问题,看看Packet之上的QUIC Frame、HTTP/3 Frame帧格式。
|
||||
|
||||
## Stream多路复用时的队头阻塞是怎样解决的?
|
||||
|
||||
其实,解决队头阻塞的方案,就是允许微观上有序发出的Packet报文,在接收端无序到达后也可以应用于并发请求中。比如上文的动态图中,如果丢失的黄色报文对其后发出的绿色报文不造成影响,队头阻塞问题自然就得到了解决:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/f4/f6dc5b11f8a240b4283dcb8de5b9a0f4.gif" alt="">
|
||||
|
||||
在Packet Header之上的QUIC Frame Header,定义了有序字节流Stream,而且Stream之间可以实现真正的并发。HTTP/3的Stream,借鉴了HTTP/2中的部分概念,所以在讨论QUIC Frame Header格式之前,我们先来看看HTTP/2中的Stream长什么样子:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/ff/4e/ff629c78ac1880939e5eabb85ab53f4e.png" alt="" title="图片来自:https://developers.google.com/web/fundamentals/performance/http2">](https://developers.google.com/web/fundamentals/performance/http2)
|
||||
|
||||
每个Stream就像HTTP/1中的TCP连接,它保证了承载的HEADERS frame(存放HTTP Header)、DATA frame(存放HTTP Body)是有序到达的,多个Stream之间可以并行传输。在HTTP/3中,上图中的HTTP/2 frame会被拆解为两层,我们先来看底层的QUIC Frame。
|
||||
|
||||
一个Packet报文中可以存放多个QUIC Frame,当然所有Frame的长度之和不能大于PMTUD(Path Maximum Transmission Unit Discovery,这是大于1200字节的值),你可以把它与IP路由中的MTU概念对照理解:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/47/3df65a7bb095777f1f8a7fede1a06147.png" alt="">
|
||||
|
||||
每一个Frame都有明确的类型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/5d/0e27cd850c0f5b854fd3385cab05755d.png" alt="">
|
||||
|
||||
前4个字节的Frame Type字段描述的类型不同,接下来的编码也不相同,下表是各类Frame的16进制Type值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/85/45b0dfcd5c59a9c8be1c5c9e6f998085.jpg" alt="">
|
||||
|
||||
在上表中,我们只要分析0x08-0x0f这8种STREAM类型的Frame,就能弄明白Stream流的实现原理,自然也就清楚队头阻塞是怎样解决的了。Stream Frame用于传递HTTP消息,它的格式如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/4c/10874d334349349559835yy4d4c92b4c.png" alt="">
|
||||
|
||||
可见,Stream Frame头部的3个字段,完成了多路复用、有序字节流以及报文段层面的二进制分隔功能,包括:
|
||||
|
||||
- Stream ID定义了一个有序字节流。当HTTP Body非常大,需要跨越多个Packet时,只要在每个Stream Frame中含有同样的Stream ID,就可以传输任意长度的消息。多个并发传输的HTTP消息,通过不同的Stream ID加以区别;
|
||||
- 消息序列化后的“有序”特性,是通过Offset字段完成的,它类似于TCP协议中的Sequence序号,用于实现Stream内多个Frame间的累计确认功能;
|
||||
- Length指明了Frame数据的长度。
|
||||
|
||||
你可能会奇怪,为什么会有8种Stream Frame呢?这是因为0x08-0x0f这8种类型其实是由3个二进制位组成,它们实现了以下3标志位的组合:
|
||||
|
||||
- 第1位表示是否含有Offset,当它为0时,表示这是Stream中的起始Frame,这也是上图中Offset是可选字段的原因;
|
||||
- 第2位表示是否含有Length字段;
|
||||
- 第3位Fin,表示这是Stream中最后1个Frame,与HTTP/2协议Frame帧中的FIN标志位相同。
|
||||
|
||||
Stream数据中并不会直接存放HTTP消息,因为HTTP/3还需要实现服务器推送、权重优先级设定、流量控制等功能,所以Stream Data中首先存放了HTTP/3 Frame:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/61/12b117914de00014f90d1yyf12875861.png" alt="">
|
||||
|
||||
其中,Length指明了HTTP消息的长度,而Type字段(请注意,低2位有特殊用途,在QPACK一节中会详细介绍)包含了以下类型:
|
||||
|
||||
- 0x00:DATA帧,用于传输HTTP Body包体;
|
||||
- 0x01:HEADERS帧,通过QPACK 编码,传输HTTP Header头部;
|
||||
- 0x03:CANCEL_PUSH控制帧,用于取消1次服务器推送消息,通常客户端在收到PUSH_PROMISE帧后,通过它告知服务器不需要这次推送;
|
||||
- 0x04:SETTINGS控制帧,设置各类通讯参数;
|
||||
- 0x05:PUSH_PROMISE帧,用于服务器推送HTTP Body前,先将HTTP Header头部发给客户端,流程与HTTP/2相似;
|
||||
- 0x07:GOAWAY控制帧,用于关闭连接(注意,不是关闭Stream);
|
||||
- 0x0d:MAX_PUSH_ID,客户端用来限制服务器推送消息数量的控制帧。
|
||||
|
||||
总结一下,QUIC Stream Frame定义了有序字节流,且多个Stream间的传输没有时序性要求。这样,HTTP消息基于QUIC Stream就实现了真正的多路复用,队头阻塞问题自然就被解决掉了。
|
||||
|
||||
## QPACK编码是如何解决队头阻塞问题的?
|
||||
|
||||
最后,我们再看下HTTP Header头部的编码方式,它需要面对另一种队头阻塞问题。
|
||||
|
||||
与HTTP/2中的HPACK编码方式相似,HTTP/3中的QPACK也采用了静态表、动态表及Huffman编码:
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/af/94/af869abf09yy1d6d3b0fa5879e300194.jpg" alt="" title="图片来自:https://www.oreilly.com/content/http2-a-new-excerpt/">](https://www.oreilly.com/content/http2-a-new-excerpt/)
|
||||
|
||||
先来看静态表的变化。在上图中,GET方法映射为数字2,这是通过客户端、服务器协议实现层的硬编码完成的。在HTTP/2中,共有61个静态表项:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/21/8cd69a7baf5a02d84e69fe6946d5ab21.jpg" alt="">
|
||||
|
||||
而在QPACK中,则上升为98个静态表项,比如Nginx上的ngx_http_v3_static_table数组所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/10/0c9c5ab8c342eb6545b00cee8f6b4010.png" alt="">
|
||||
|
||||
你也可以从[这里](https://tools.ietf.org/html/draft-ietf-quic-qpack-14#appendix-A)找到完整的HTTP/3静态表。对于Huffman以及整数的编码,QPACK与HPACK并无多大不同,但动态表编解码方式差距很大。
|
||||
|
||||
所谓动态表,就是将未包含在静态表中的Header项,在其首次出现时加入动态表,这样后续传输时仅用1个数字表示,大大提升了编码效率。因此,动态表是天然具备时序性的,如果首次出现的请求出现了丢包,后续请求解码HPACK头部时,一定会被阻塞!
|
||||
|
||||
QPACK是如何解决队头阻塞问题的呢?事实上,QPACK将动态表的编码、解码独立在单向Stream中传输,仅当单向Stream中的动态表编码成功后,接收端才能解码双向Stream上HTTP消息里的动态表索引。
|
||||
|
||||
这里我们又引入了单向Stream和双向Stream概念,不要头疼,它其实很简单。单向指只有一端可以发送消息,双向则指两端都可以发送消息。还记得上一小节的QUIC Stream Frame头部吗?其中的Stream ID别有玄机,除了标识Stream外,它的低2位还可以表达以下组合:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/0a/ae1dbf30467e8a7684d337701f055c0a.png" alt="">
|
||||
|
||||
因此,当Stream ID是0、4、8、12时,这就是客户端发起的双向Stream(HTTP/3不支持服务器发起双向Stream),它用于传输HTTP请求与响应。单向Stream有很多用途,所以它在数据前又多出一个Stream Type字段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/52/b07521a11e65a24cd4b93553127bfc52.png" alt="">
|
||||
|
||||
Stream Type有以下取值:
|
||||
|
||||
- 0x00:控制Stream,传递各类Stream控制消息;
|
||||
- 0x01:服务器推送消息;
|
||||
- 0x02:用于编码QPACK动态表,比如面对不属于静态表的HTTP请求头部,客户端可以通过这个Stream发送动态表编码;
|
||||
- 0x03:用于通知编码端QPACK动态表的更新结果。
|
||||
|
||||
由于HTTP/3的Stream之间是乱序传输的,因此,若先发送的编码Stream后到达,双向Stream中的QPACK头部就无法解码,此时传输HTTP消息的双向Stream就会进入Block阻塞状态(两端可以通过控制帧定义阻塞Stream的处理方式)。
|
||||
|
||||
## 小结
|
||||
|
||||
最后对这一讲的内容做个小结。
|
||||
|
||||
基于四元组定义连接并不适用于下一代IoT网络,HTTP/3创造出Connection ID概念实现了连接迁移,通过融合传输层、表示层,既缩短了握手时长,也加密了传输层中的绝大部分字段,提升了网络安全性。
|
||||
|
||||
HTTP/3在Packet层保障了连接的可靠性,在QUIC Frame层实现了有序字节流,在HTTP/3 Frame层实现了HTTP语义,这彻底解开了队头阻塞问题,真正实现了应用层的多路复用。
|
||||
|
||||
QPACK使用独立的单向Stream分别传输动态表编码、解码信息,这样乱序、并发传输HTTP消息的Stream既不会出现队头阻塞,也能基于时序性大幅压缩HTTP Header的体积。
|
||||
|
||||
如果你觉得这一讲对你理解HTTP/3协议有所帮助,也欢迎把它分享给你的朋友。
|
73
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场1 | 李玥:高并发场景下如何优化微服务的性能?.md
Normal file
73
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场1 | 李玥:高并发场景下如何优化微服务的性能?.md
Normal file
@@ -0,0 +1,73 @@
|
||||
<audio id="audio" title="大咖助场1 | 李玥:高并发场景下如何优化微服务的性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/45/5a/4544de2ab048bede770627380af0065a.mp3"></audio>
|
||||
|
||||
你好,我是李玥。相信这里有部分同学对我是比较熟悉的,我在极客时间开了两门课,分别是[《消息队列高手课》](https://time.geekbang.org/column/intro/212?utm_term=zeusEX4MV&utm_source=geektime&utm_medium=xiangqingye)和[《后端存储实战课》](https://time.geekbang.org/column/intro/100046801)。今天很荣幸受邀来到陶辉老师的专栏做一期分享。
|
||||
|
||||
陶辉老师的这门课程,其中的知识点都是非常“硬核”,因为涉及到计算机操作系统底层的这些运行机制,确实非常抽象。我也看到有些同学在留言区提到,希望能通过一些例子来帮助大家更好地消化一下这些知识。那么这期分享呢,我就来帮陶辉老师做一次科普,帮助同学们把“基础设施优化”这一部分中讲到的一些抽象的概念和方法,用举例子的方式来梳理一遍。总结下的话,就是帮你理清这些问题:
|
||||
|
||||
- 线程到底是如何在CPU中执行的?
|
||||
- 线程上下文切换为什么会影响性能?
|
||||
- 为什么说异步比同步的性能好?
|
||||
- BIO、NIO、AIO到底有什么区别?
|
||||
|
||||
## 为什么线程数越多反而性能越差?
|
||||
|
||||
今天的课程,从一个选择题开始。假设我们有一个服务,服务的业务逻辑和我们每天在做的业务都差不多,根据传入的参数去数据库查询数据,然后执行一些简单的业务逻辑后返回。我们的服务需要能支撑10,000TPS的请求数量,那么数据库的连接池设置成多大合适呢?
|
||||
|
||||
我给你二个选项:
|
||||
|
||||
- A. 32
|
||||
- B. 2048
|
||||
|
||||
我们直接公布答案,选项A是性能更好的选择。连接池的大小直接影响的是,同时请求到数据库服务器的并发数量。那我们直觉的第一印象可能是,并发越多总体性能应该越好才对,事实真的是这样吗?下面我们通过一个例子来探究一下这个问题的答案。
|
||||
|
||||
说有一个工厂,要新上一个车间,车间里面设置了8条流水生产线,每个流水线设置1个工位,那需要安排多少个工人才能达到最佳的效率呢?显然是需要8个工人是吧?工人少了生产线闲置,工人多了也没有工位让他们去工作,工人闲置,8个工人对8条流水线是效率最优解。这里面的车间,就可以类比为一台计算机,工位就是线程,工人就是CPU的核心。通过这个类比,我们就可以得出这样一个结论:**一个8核的CPU,8个线程的情况下效率是最高的。** 这时,每个CPU核心正好对应一个线程。
|
||||
|
||||
这是一个非常理想的情况,它有一个前提就是,流水线上的工人(CPU核心)一直有事情做,没有任何等待。而现实情况下,我们绝大部分的计算程序都做不到像工厂流水线那么高效。我们开发的程序几乎是**请求/响应**的模型,对应到车间的例子,生产模式不太像流水线,更像是来料加工。工人在工位上等着,来了一件原料,工人开始加工,加工完成后,成品被送走,然后再等待下一件,周而复始。对应到计算机程序中,原料就是请求,工人在工位上加工原料的过程,相当于CPU在线程上执行业务逻辑的过程,成品就是响应,或者说是请求的返回值。你可以对照下面这个图来理解上面我们讲的这个例子,以及对应到计算机程序中的概念。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/yy/8c/yy53149254ae8cc325b1bc24e5a6428c.png" alt="">
|
||||
|
||||
来料加工这种情况下,只有8个工位并不能保证8个工人一直满负荷的工作。因为,工人每加工完成一件产品之后,需要等待成品被送出去,下一件原料被送进来,才能开始继续工作。在同一个工位上加工每件产品之间的等待是不可避免的,那怎么才能最大化工人的效率,尽量减少工人等待呢?很简单,增加一些工位就可以了。工人在A工位加工完成一件产品之后,不在A工位等着,马上去另外一个原料已经就绪的B工位继续工作,这样只要工位设置得足够多,就可以保证8个工人一直满负荷工作。
|
||||
|
||||
那同样是8个工人满负荷工作,多工位来料加工这种方式,和上面提到的8条流水线作业的方式,哪种效率更高呢?还是流水线的效率高,是不是?原因是,虽然在这两种方式下,工人们都在满负荷工作,但是,来料加工这种方式下,工人在不同的工位之间切换,也是需要一点点时间的,相比于流水线作业,这部分工时相当于被浪费掉了。
|
||||
|
||||
工人在工位间切换,对应到计算机执行程序的过程,就是CPU在不同的线程之间切换,称为**线程上下文切换**。一次线程上下文切换的时间耗时非常短,大约只有几百个纳秒(ns)。一般来说我们并不需要太关注这个短到不可感知的切换时间,但是,在多线程高并发的场景下,如果没有很好的优化,就有可能出现,CPU在大量线程间频繁地发生切换,累积下来,这个切换时间就很可观了,严重的话就会拖慢服务的总体性能。
|
||||
|
||||
我们再来思考另外一个问题:设置多少个工位最合适呢?工位数量不足时,工人不能满负荷工作,工位数量太多了也不行,工人需要频繁地切换工位,浪费时间。这里面一定存在一个最优工位数,可以让所有工人正好不需要等待且满负荷工作。最优工位数取决于工人的加工速度、等待原料的时长等因素。如果这些参数是确定的,那我们确定这个最佳工位数就不太难了。一般来说,工位的数量设置成工人数量的两三倍就差不多了,如果等待的时间比较长,可能需要五六倍,大致是这样一个数量级。把这个结论对应到计算机系统中就是,**对于一个请求/响应模型的服务,并发线程数设置为CPU核数N倍时性能最佳**,N的大致的经验值范围是[2, 10]。
|
||||
|
||||
有了这个结论,再回过头来看我们课程开始提到的那个数据库连接池问题。数据库服务符合“请求/响应模型”,所以它的并发数量并不是越多越好,根据我们上面得出的结论,大约是CPU核数的几倍时达到最佳性能。这个问题来自于数据库连接池HikariCP的一篇Wiki: [About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing),里面有详细的性能测试数据和计算最佳连接池数量的公式,强烈推荐你课后去看一下。
|
||||
|
||||
## 为什么说异步比同步的性能好?
|
||||
|
||||
然后我们再来思考这样一个问题。我们开发的很多业务服务实际的情况是,并发线程数越多总体性能越好,几百甚至上千个线程才达到最佳性能。这并不符合我们上面说的那个结论啊?什么原因?
|
||||
|
||||
原因是这样的,我们上面这个结论它有一个适用范围,它的适用范围是,像数据库服务这样,只依赖于本地计算资源的服务。
|
||||
|
||||
如果说,我们的业务服务,它在处理请求过程中,还需要去调用其他服务,这种情况就不适用于我们上面所说的结论。这里面的其它服务包括数据库服务或者是下游的业务服务等等。不适用的原因是,我们线程在执行业务逻辑过程中,很大一部分时间都花在等待外部服务上了,在这个等待的过程中,几乎不需要CPU参与。换句话说,每个线程需要的CPU时间是非常少的,这样的情况下,一个CPU核心需要非常多的线程才能把它“喂饱”,这就是为什么这些业务服务需要非常多的线程数,才能达到最佳性能的原因。
|
||||
|
||||
我们刚刚讲过,线程数过多很容易导致CPU频繁的在这些线程之间切换,虽然CPU看起来已经在满负荷运行了,但CPU并没有把所有的时间都用在执行我们的业务逻辑上,其中一部分CPU时间浪费在线程上下文切换上了。怎么来优化这种情况呢?要想让CPU高效地执行业务逻辑,最佳方式就是我们开头提到的流水线,用和CPU核数相同的线程数,通过源源不断地供给请求,让CPU一直不停地执行业务逻辑。**所以优化的关键点是,减少线程的数量**,把线程数量控制在和CPU核数相同的数量级这样一个范围。
|
||||
|
||||
要减少线程数量,有这样两个问题需要解决。
|
||||
|
||||
第一个问题是,如何用少量的线程来处理大量并发请求呢?我们可以用一个请求队列,和一组数量固定的执行线程,来解决这个问题。线程的数量就等于CPU的核数。接收到的请求先放入请求队列,然后分配给执行线程去处理。这样基本上能达到,让每个CPU的核心相对固定到一个线程上,不停地执行业务逻辑这样一个效果。
|
||||
|
||||
第二个问题是,执行线程在需要调用外部服务的时候,如何避免线程等待外部服务,同时还要保证及时处理返回的响应呢?我们希望的情况是,执行线程需要调用外部服务的时候,把请求发送出去之后,不要去等待响应,而是去继续处理下一个请求。等外部请求的响应回来之后,能有一个通知,来触发执行线程再执行后续的业务逻辑,直到给客户端返回响应。这其实就是我们通常所说的**异步IO模型(AIO,Asynchronous I/O)**,这个模型的关键就是,线程不去等待Socket通道上的数据,而是待数据到达时,由操作系统来发起一个通知,触发业务线程来处理。Linux内核从2.6开始才加入了AIO的支持,到目前为止AIO还没有被广泛使用。
|
||||
|
||||
使用更广泛的是**IO多路复用模型(IO Multiplexing)**,IO多路复用本质上还是一种同步IO模型。但是,它允许一个线程同时等待多个Socket通道,任意一个通道上有数据到来,就解除等待去处理。IO多路复用没有AIO那么理想化,但也只是多了一个线程用于等待响应,相比AIO来说,效果也差不了多少,在内核AIO支持还不完善的时代,是一个非常务实且高效的网络IO模型。
|
||||
|
||||
很多编程语言中,都有一些网络IO框架,封装了这些IO模型,来帮我们解决这个问题,比如Java语言中的BIO、NIO、AIO分别对应了同步IO模型、IO多路复用模型和异步IO模型。
|
||||
|
||||
解决了上面这两个问题之后,我们用很少量的线程就可以处理大量的并发请求。这种情况下,负责返回响应的线程和接收请求的线程,不再是同一个线程,这其实就是我们所说的**异步模型**。你可以看到,**异步模型并不会让程序的业务逻辑执行得更快,但是它可以非常有效地避免线程等待,大幅减少CPU在线程上下文切换上浪费的时间。**这样,在同样的计算机配置下,异步模型相比同步模型,可以更高效地利用计算机资源,从而拥有更好的总体的吞吐能力。
|
||||
|
||||
## 小结
|
||||
|
||||
以上就是本节课的全部内容了,我们来简单地做个小结。
|
||||
|
||||
理论上,线程数量设置为CPU核数,并且线程没有等待的情况下,CPU几乎不会发生线程上下文切换,这个时候程序的执行效率是最高的。实际情况下,对于一个请求/响应模型的服务,并发线程数设置为CPU核数N倍时性能最佳。这个N取决于业务逻辑的执行时间、线程等待时间等因素,N的大致的经验值范围是[2, 10]。
|
||||
|
||||
使用异步模型编写微服务,配合异步IO或者IO多路复用,可以有效地避免线程等待,用少量的线程处理大量并发请求,大幅减少线程上下文切换的开销,从而达到提升服务总体性能的效果。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后留给你一道思考题。IO多路复用,它只是一种IO模型,实际上有多种实现。在Linux中,有select、poll、epoll三种实现方式,课后请你去查阅一下资料,看看这三种实现方式有什么区别?
|
||||
|
||||
感谢阅读,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。
|
115
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场2|庄振运:与程序员相关的SSD性能知识.md
Normal file
115
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场2|庄振运:与程序员相关的SSD性能知识.md
Normal file
@@ -0,0 +1,115 @@
|
||||
<audio id="audio" title="大咖助场2|庄振运:与程序员相关的SSD性能知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/48/037884390233cb1cc6b8bbaa3f348b48.mp3"></audio>
|
||||
|
||||
你好,我是庄振运。我是[《性能工程高手课》](https://time.geekbang.org/column/intro/100041101)的专栏作者,很荣幸受邀来到陶辉老师的专栏做一期分享。今天我们来讲一点SSD相关的性能知识。SSD(Solid State Drive)是硬盘的一种,有时候也叫Flash或者固态硬盘。
|
||||
|
||||
最近几年,SSD的发展和演化非常迅速。随着市场规模的增大和技术的进步,SSD的价格也大幅度降低了。在很多实时的后台系统中,SSD几乎已经成了标准配置了。所以了解它的机制和性能,对你的工作会很有益处的。
|
||||
|
||||
相对于传统硬盘HDD(Hard Disk Drive),SSD有完全不同的内部工作原理和全新的特性。有些机制不太容易理解,而且根据你工作的领域,需要理解的深度也不一样。所以,我把这节课的内容按照由浅入深的原则分成了三个层次。
|
||||
|
||||
第一个层次是关注SSD的外部性能指标;第二个层次是了解它的内部工作机制;第三个层次是设计对SSD友好的应用程序。
|
||||
|
||||
## 比HDD更快的硬盘
|
||||
|
||||
很多人对传统硬盘了解较多,毕竟这种硬盘在业界用了好几十年了,很多教科书里面都讲述过。所以,对SSD的性能,我先用对比的方式带你看看它们的外部性能指标和特性。
|
||||
|
||||
一个硬盘的性能最主要体现在这三个指标:IOPS,带宽/吞吐率和访问延迟。**IOPS** (Input/Output Per Second) ,即每秒钟系统能处理的读写请求数量。**访问延迟**,指的是从发起IO请求到存储系统把IO处理完成的时间间隔。**吞吐率**(Throughput)或者带宽(Bandwidth),衡量的是实际数据传输速率。
|
||||
|
||||
对于传统硬盘我们应该比较熟悉它的内部是如何操作的,简单来说,当应用程序发出硬盘IO请求后,这个请求会进入硬盘的IO队列。当轮到这个IO来存取数据时,磁头需要机械运动到数据存放的位置,这就需要磁头寻址到相应的磁道和旋转到相应的扇区,然后才是数据的传输。对于一块普通硬盘而言,随机IO读写延迟就是8毫秒左右,IO带宽大约每秒100MB,而随机IOPS一般是100左右。
|
||||
|
||||
SSD的种类很多,按照技术来说有单层和多层。按照质量和性能来分,有企业级和普通级。根据安装的接口和协议来分,有SAS, SATA, PCIe和NVMe等。
|
||||
|
||||
我用一张表格来对比一下HDD和SSD的三大性能指标的差异。这里考虑比较流行的NVMe协议的SSD。你可以看到,SSD的随机IO延迟比传统硬盘快百倍以上,一般在微妙级别;IO带宽也高很多倍,可以达到每秒几个GB;随机IOPS更是快了上千倍,可以达到几十万。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/04/7c0d537e9334e1d622d40f28168e1c04.jpg" alt="">
|
||||
|
||||
## SSD的性能特性和机制
|
||||
|
||||
SSD的内部工作方式和HDD大相径庭,我们先了解几个概念。
|
||||
|
||||
**单元(Cell)**、**页面(Page)**、**块(Block)**。当今的主流SSD是基于NAND的,它将数字位存储在单元中。每个SSD单元可以存储一位或多位。对单元的每次擦除都会降低单元的寿命,所以单元只能承受一定数量的擦除。单元存储的位数越多,制造成本就越少,SSD的容量也就越大,但是耐久性(擦除次数)也会降低。
|
||||
|
||||
一个页面包括很多单元,典型的页面大小是4KB,页面也是要读写的最小存储单元。SSD上没有“重写”操作,不像HDD可以直接对任何字节重写覆盖。一个页面一旦写入内容后就不能进行部分重写,必须和其它相邻页面一起被整体擦除重置。
|
||||
|
||||
多个页面组合成块。一个块的典型大小为512KB或1MB,也就是大约128或256页。块是擦除的基本单位,每次擦除都是整个块内的所有页面都被重置。
|
||||
|
||||
了解完以上几个基础概念,我们重点看看**IO和垃圾回收(Garbage Collection)** 。对SSD的IO共有三种类型:读取、写入和擦除。读取和写入以页为单位。IO写入的延迟具体取决于磁盘的历史状态,因为如果SSD已经存储了许多数据,那么对页的写入就经常需要移动已有的数据。一般的读写延迟都很低,在微秒级别,远远低于HDD。擦除是以块为单位的。擦除速度相对很慢,通常为几毫秒。所以对同步的IO,发出IO的应用程序可能会因为块的擦除,而经历很大的写入延迟。为了尽量地减少这样的场景,保持空闲块的阈值对于快速的写响应是很有必要的。SSD的垃圾回收(GC)的目的就在于此。GC可以回收用过的块,这样可以确保以后的页写入可以快速分配到一个全新的页。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/70/4b3705248ccb0144516a77849c17dd70.png" alt="">
|
||||
|
||||
**写入放大(Write Amplification, or WA)。** 这是SSD相对于HDD的一个缺点,即实际写入SSD的物理数据量,有可能是应用层写入数据量的多倍。一方面,页级别的写入需要移动已有的数据来腾空页面。另一方面,GC的操作也会移动用户数据来进行块级别的擦除。所以对SSD真正的写操作的数据可能比实际写的数据量大,这就是写入放大。一块SSD只能进行有限的擦除次数,也称为编程/擦除(P/E)周期,所以写入放大效用会缩短SSD的寿命。
|
||||
|
||||
**耗损平衡(Wear Leveling) 。**对每一个块而言,一旦达到最大数量,该块就会死亡。对于SLC块,P/E周期的典型数目是十万次;对于MLC块,P/E周期的数目是一万;而对于TLC块,则可能是几千。为了确保SSD的容量和性能,我们需要在擦除次数上保持平衡,SSD控制器具有这种“耗损平衡”机制可以实现这一目标。在损耗平衡期间,数据在各个块之间移动,以实现均衡的损耗,这种机制也会对前面讲的写入放大推波助澜。
|
||||
|
||||
## 设计对SSD友好的程序
|
||||
|
||||
SSD的IO性能相对于HDD来说,IOPS和访问延迟提升了上千倍,吞吐率也是几十倍,但是SSD的缺点也很明显,有三个:贵、容量小、易损耗。随着技术的发展,这三个缺点近几年在弱化。
|
||||
|
||||
现在越来越多的系统采用SSD来减轻应用程序的IO性能瓶颈。许多部署的结果显示,与HDD相比,SSD带来了极大的应用程序的性能提升。但是,在大多数部署方案中,SSD仅被视为“更快的HDD”,SSD的潜力并未得到充分利用。尽管使用SSD作为存储时应用程序可以获得更好的性能,但是这些收益主要归因于SSD提供的更高的IOPS和带宽。
|
||||
|
||||
进一步讲,如果应用程序的设计充分考虑了SSD的内部机制,从而设计为对SSD友好,则可以更大程度地优化SSD,从而进一步提高应用程序性能,也可以延长SSD的寿命而降低运用成本。接下来我们就看看如何在应用程序层进行一系列SSD友好的设计更改。
|
||||
|
||||
### 为什么要设计SSD友好的软件和应用程序?
|
||||
|
||||
SSD友好的程序可以获得三种好处:
|
||||
|
||||
- 提升应用程序性能;
|
||||
- 提高SSD的 IO效率;
|
||||
- 延长SSD的寿命。
|
||||
|
||||
我分别说明一下。
|
||||
|
||||
**更好的应用程序性能。**尽管从HDD迁移到SSD通常意味着更好的应用程序性能,这主要是得益于SSD的IO性能更好,但在不更改应用程序设计的情况下简单地采用SSD可能无法获得最佳性能。我们曾经有一个应用程序就是如此。该应用程序需要不断写入文件以保存数据,主要性能瓶颈就是硬盘IO。使用HDD时,最大应用程序吞吐量为每秒142个查询(QPS)。无论对应用程序设计进行各种更改还是调优,这都是可以获得的最好性能。
|
||||
|
||||
当迁移到具有相同应用程序的SSD时,吞吐量提高到2万QPS,速度提高了140倍。这主要来自SSD提供的更高IOPS。在对应用程序设计进行进一步优化使其对SSD友好之后,吞吐量提高到10万QPS,与原来的简单设计相比,提高了4倍。
|
||||
|
||||
这其中的秘密就是使用多个并发线程来执行IO,这就利用了SSD的内部并行性。记住,多个IO线程对HDD毫无益处,因为HDD只有一个磁头。
|
||||
|
||||
**更高效的存储IO。** SSD上的最小内部IO单元是一页,比如4KB大小。因此对SSD的单字节读/写必须在页面级进行。应用程序对SSD的写操作可能会导致对SSD上的物理写操作变大,这就是“写放大(WA)”。因为有这个特性,如果应用程序的数据结构或IO对SSD不友好,就会让写放大效果无谓的更大,导致SSD的IO不能被充分利用。
|
||||
|
||||
**更长的使用寿命。**SSD会磨损,因为每个存储单元只能维持有限数量的写入擦除周期。实际上,SSD的寿命取决于四个因素:SSD大小、最大擦除周期数、写入放大系数和应用程序写入速率。例如,假设有一个1TB大小的SSD,一个写入速度为100MB每秒的应用程序和一个擦除周期数为1万的SSD。当写入放大倍数为4时,SSD仅可持续10个月。具有3千个擦除周期和写入放大系数为10的SSD只能使用一个月。鉴于SSD相对较高的成本,我们很希望这些应用程序对SSD友好,从而延长SSD的使用寿命。
|
||||
|
||||
### SSD友好的设计原则
|
||||
|
||||
在设计程序的时候,我们可以把程序设计成对SSD友好,以获得前面提到的三种好处。那有哪些对SSD友好的程序设计呢?我这里总结了四个原则,大体上分为两类:数据结构和IO处理。
|
||||
|
||||
**1.数据结构:避免就地更新的优化**
|
||||
|
||||
传统HDD的寻址延迟很大,因此,使用HDD的应用程序通常经过优化,以执行不需要寻址的就地更新(比如只在一个文件后面写入)。如下图所示,执行随机更新时,吞吐量一般只能达到约170QPS;而对于同一个HDD,就地更新可以达到280QPS,远高于随机更新(如下左图所示)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/fb/42339220b197186548330ayy0b08d9fb.jpg" alt="">
|
||||
|
||||
在设计与SSD配合使用的应用程序时,这些考虑就不再有效了。对SSD而言,随机读写和顺序读写性能类似,就地更新不会获得任何IOPS优势。此外,就地更新实际上会导致SSD性能下降。原因是包含数据的SSD页面无法直接重写,因此在更新存储的数据时,必须先将相应的SSD页面读入SSD缓冲区,然后将数据写入干净页面。 SSD中的“读取-修改-写入”过程与HDD上的直接“仅写入”行为形成鲜明对比。相比之下,SSD上的随机更新就不会引起读取和修改步骤(即仅仅“写入”),因此速度更快。使用SSD,以上相同的应用程序可以通过随机更新或就地更新来达到大约2万QPS(如上右图所示)。
|
||||
|
||||
**2.数据结构:将热数据与冷数据分开**
|
||||
|
||||
对于几乎所有处理存储的应用程序,磁盘上存储的数据的访问概率均不相同。我们考虑这样一个需要跟踪用户活动的社交网络应用程序,对于用户数据存储,简单的解决方案是基于用户属性(例如注册时间)将所有用户压缩在同一位置(例如某个SSD上的文件),以后需要更新热门用户的活动时,SSD需要在页面级别进行访问(即读取/修改/写入)。因此,如果用户的数据大小小于一页,则附近的用户数据也将一起访问。如果应用程序其实并不需要附近用户的数据,则额外的数据不仅会浪费IO带宽,而且会不必要地磨损SSD。
|
||||
|
||||
为了缓解这种性能问题,在将SSD用作存储设备时,应将热数据与冷数据分开。以不同级别或不同方式来进行分隔,例如,存到不同的文件,文件的不同部分或不同的表。
|
||||
|
||||
**3. IO处理:避免长而繁重的写入**
|
||||
|
||||
SSD通常具有GC机制,不断地回收存储块以供以后使用。 GC可以后台或前台方式工作。
|
||||
|
||||
SSD控制器通常保持一个空闲块的阈值。每当可用块数下降到阈值以下时,后台GC就会启动。由于后台GC是异步发生的(即非阻塞),因此它不会影响应用程序的IO延迟,但是,如果块的请求速率超过了GC速率,并且后台GC无法跟上,则将触发前台GC。
|
||||
|
||||
在前台GC期间,必须即时擦除(即阻塞)每个块以供应用程序使用,这时发出写操作的应用程序所经历的写延迟会受到影响。具体来说,释放块的前台GC操作可能会花费数毫秒以上的时间,从而导致较大的应用程序IO延迟。出于这个原因,最好避免进行长时间的大量写入操作,以免永远不使用前台GC。
|
||||
|
||||
**4. IO处理:避免SSD存储太满**
|
||||
|
||||
SSD磁盘存储太满会影响写入放大系数和GC导致的写入性能。在GC期间,需要擦除块以创建空闲块。擦除块前需要移动并保留有效数据才能获得空闲块。有时为了获得一个空闲块,我们需要压缩好几个存储块。每个空闲块的生产需要压缩的块数取决于磁盘的空间使用率。
|
||||
|
||||
假设磁盘满百分比平均为A%,要释放一块,则需要压缩1 /(1-A%)块。显然,SSD的空间使用率越高,将需要移动更多的块以释放一个块,这将占用更多的资源并导致更长的IO等待时间。例如,如果A=80%,则大约移动五个数据块以释放一个块;当A=95%时,将移动约20个块。
|
||||
|
||||
## 总结
|
||||
|
||||
各种存储系统的基础是传统硬盘或者固态硬盘,固态硬盘SSD的IO性能比传统硬盘高很多。如果系统对IOPS或者延迟要求很高,一般都采用SSD。
|
||||
|
||||
现在已经有很多专门针对SSD来设计的文件系统、数据库系统和数据基础架构,它们的性能比使用HDD的系统都有了很大的提升。
|
||||
|
||||
SSD有不同于HDD工作原理,所以在进行应用程序设计的时候,如果可以做到对SSD友好,那么就可以充分发挥SSD的全部性能潜能,应用程序的性能会进一步提高。你可以参考我们今天总结的四个设计原则进行实践。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你留几道思考题。你们公司里面,有哪些后台服务和应用是使用SSD作为存储的?而对这些使用SSD的系统,有没有充分考虑SSD的特性,做深层的优化呢(比如降低损耗)?
|
||||
|
||||
感谢阅读,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。
|
346
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场3|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(上).md
Normal file
346
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场3|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(上).md
Normal file
@@ -0,0 +1,346 @@
|
||||
<audio id="audio" title="大咖助场3|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/28/d883df652733fbbb1d35f7b672b4f428.mp3"></audio>
|
||||
|
||||
你好,我是傅健。这里有部分同学可能认识我,我在极客时间开设了一门视频课[《Netty源码剖析与实战》](https://time.geekbang.org/course/intro/237),很荣幸受邀来到陶辉老师的专栏做一些分享。今天我会围绕这门课程的主题——系统性能调优,结合我自身的工作经历补充一些内容,期待能给你一些新思路。
|
||||
|
||||
其实说起性能调优,我们往往有很多理论依据可以参考,例如针对分布式系统的NWR、CAP等,也有很多实践的“套路”,如绘制火焰图、监控调用链等。当然,这些内容多多少少陶辉老师都有讲到。实际上,不管方式、方法有多少,我们的终极目标都是一致的,那就是在固定的资源条件下,将系统的响应速度调整到极致。
|
||||
|
||||
但是在严谨地评价一个系统性能时,我们很少粗略地使用这种表述:在压力A(如1000 TPS)下,基于软硬件配置B,我们应用的C操作响应时间是D毫秒。而是加上一个百分位,例如:在压力A(如1000 TPS)下,基于软硬件配置B,我们应用的C操作响应时间**99%**已经达到D毫秒。或者当需要提供更为详细的性能报告时,我们提供的往往是类似于下面表格形式的数据来反映性能:不仅包括常见的百分比(95%、99%等或者常见的四分位),也包括平均数、中位数、最大值等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/ca/8f50ee7ca85c3fb40b516a195ec6b6ca.jpg" alt="">
|
||||
|
||||
那为什么要如此“严谨”?不可以直接说达到了某某水平吗?究其原因,还是我们的系统很难达到一个完美的极致,总有一些请求的处理时间超出了我们的“预期”,让我们的系统不够平滑(即常说的系统有“毛刺”)。所以在追求极致的路上,我们的工作重心已经不再是“大刀阔斧”地进行主动性能调优以满足99%的需求,而是着重观察、分析那掉队的1%请求,找出这些“绊脚石”,再各个击破,从而提高我们系统性能的“百分比”。例如,从2个9(99%)再进一步提高到3个9(99.9%)。而实际上,我们不难发现,这些所谓的绊脚石其实都是类似的,所以这期分享我就带你看看究竟有哪些绊脚石,我们结合具体场景总结应对策略。
|
||||
|
||||
## 场景1:重试、重定向
|
||||
|
||||
### 案例
|
||||
|
||||
当我们使用下游服务的API接口时,偶尔会出现延时较大的情况,而这些延时较大的调用最后也能成功,且没有任何明显的时间规律。例如响应延时正常时,API调用性能度量数据如下:
|
||||
|
||||
```
|
||||
{
|
||||
"stepName": "CallRemoteService"
|
||||
"values": {
|
||||
"componentType": "RemoteService",
|
||||
"startTime": "2020-07-06T10:50:41.102Z",
|
||||
"totalDurationInMS": 2,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而响应延时超出预期时,度量数据如下:
|
||||
|
||||
```
|
||||
{
|
||||
"stepName": "CallRemoteService"
|
||||
"values": {
|
||||
"componentType": "RemoteService",
|
||||
"startTime": "2020-07-06T04:31:55.805Z",
|
||||
"totalDurationInMS": 2005,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 解析
|
||||
|
||||
这种情况可以说非常典型了,单从以上度量数据来看,没有什么参考意义,因为所有的性能问题都是这样的特征,即延时增大了。这里面可能的原因也有许多,例如GC影响、网络抖动等等,但是除了这些原因之外,其实最常见、最简单的原因往往都是“重试”。**重试成功前的访问往往都是很慢的,因为可能遇到了各种需要重试的错误,同时重试本身也会增加响应时间。**那作为第一个绊脚石,我们现在就对它进行一个简单的分析。
|
||||
|
||||
以这个案例为例,经过查询后你会发现:虽然最终是成功的,但是我们中途进行了重试,具体而言就是我们在使用HttpClient访问下游服务时,自定义了重试的策略:当遇到ConnectTimeoutException、SocketTimeoutException等错误时直接进行重试。
|
||||
|
||||
```
|
||||
//构建http client
|
||||
CloseableHttpClient httpClient = HttpClients.custom().
|
||||
setConnectionTimeToLive(3, TimeUnit.MINUTES).
|
||||
//省略其它非关键代码
|
||||
setServiceUnavailableRetryStrategy(new DefaultServiceUnavailableRetryStrategy(1, 50)).
|
||||
//设置了一个自定义的重试规则
|
||||
setRetryHandler(new CustomizedHttpRequestRetryHandler()).
|
||||
build();
|
||||
|
||||
//自定义的重试规则
|
||||
@Immutable
|
||||
public class CustomizedHttpRequestRetryHandler implements HttpRequestRetryHandler {
|
||||
|
||||
private final static int RETRY_COUNT = 1;
|
||||
|
||||
CustomizedHttpRequestRetryHandler() {}
|
||||
|
||||
@Override
|
||||
public boolean retryRequest(final IOException exception, final int executionCount, final HttpContext context) {
|
||||
//控制重试次数
|
||||
if (executionCount > RETRY_COUNT) {
|
||||
return false;
|
||||
}
|
||||
//遇到下面这些异常情况时,进行重试
|
||||
if (exception instanceof ConnectTimeoutException || exception instanceof NoHttpResponseException || exception instanceof SocketTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
//省略其它非关键代码
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果查询日志的话(由org.apache.http.impl.execchain.RetryExec#execute输出),我们确实发现了重试的痕迹,且可以完全匹配上我们的请求和时间:
|
||||
|
||||
```
|
||||
[07/06/2020 04:31:57.808][threadpool-0]INFO RetryExec-I/O exception (org.apache.http.conn.ConnectTimeoutException) caught when processing request to {}->http://10.224.86.130:8080: Connect to 10.224.86.130:8080 [/10.224.86.130] failed: connect timed out
|
||||
|
||||
[07/06/2020 04:31:57.808][threadpool-0]INFO RetryExec-Retrying request to {}->http://10.224.86.130:8080
|
||||
|
||||
```
|
||||
|
||||
另外除了针对异常的重试外,我们有时候也需要对于服务的短暂不可用(返回503:SC_SERVICE_UNAVAILABLE)进行重试,正如上文贴出的代码中我们设置了DefaultServiceUnavailableRetryStrategy。
|
||||
|
||||
### 小结
|
||||
|
||||
这个案例极其简单且常见,但是这里我需要额外补充下:假设遇到这种问题,又没有明确给出重试的痕迹(日志等)时,我们应该怎么去判断是不是重试“捣鬼”的呢?
|
||||
|
||||
一般而言,我们可以直接通过下游服务的调用次数数据来核对是否发生了重试。但是如果下游也没有记录,在排除完其它可能原因后,我们仍然不能排除掉重试的原因,因为重试的痕迹可能由于某种原因被隐藏起来了,例如使用的开源组件压根没有打印日志,或者是打印了但是我们应用层的日志框架没有输出。这个时候,我们也没有更好的方法,只能翻阅源码查找线索。
|
||||
|
||||
另外除了直接的重试导致延时增加外,还有一种类似情况也经常发生,即“重定向”,而比较坑的是,对于重定向行为,很多都被“内置”起来了:即不输出INFO日志。例如Apache HttpClient对响应码3XX的处理(参考org.apache.http.impl.client.DefaultRedirectStrategy)只打印了Debug日志:
|
||||
|
||||
```
|
||||
final String location = locationHeader.getValue();
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Redirect requested to location '" + location + "'");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再如,当我们使用Jedis访问Redis集群中的结点时,如果数据不在当前的节点了,也会发生“重定向”,而它并没有打印出日志让我们知道这件事的发生(参考redis.clients.jedis.JedisClusterCommand):
|
||||
|
||||
```
|
||||
private T runWithRetries(final int slot, int attempts, boolean tryRandomNode, JedisRedirectionException redirect) {
|
||||
//省略非关键代码
|
||||
} catch (JedisRedirectionException jre) {
|
||||
// if MOVED redirection occurred,
|
||||
if (jre instanceof JedisMovedDataException) {
|
||||
//此处没有输入任何日志指明接下来的执行会“跳转”了。
|
||||
// it rebuilds cluster's slot cache recommended by Redis cluster specification
|
||||
this.connectionHandler.renewSlotCache(connection);
|
||||
}
|
||||
|
||||
//省略非关键代码
|
||||
return runWithRetries(slot, attempts - 1, false, jre);
|
||||
} finally {
|
||||
releaseConnection(connection);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
综上,重试是最普通的,也是最简单的导致延时增加的“绊脚石”,而重试问题的界定难度取决于自身和下游服务是否有明显的痕迹指明。而对于这种绊脚石的消除,一方面我们应该主动出击,尽量减少引发重试的因素。另一方面,我们一定要控制好重试,例如:
|
||||
|
||||
- 控制好重试的次数;
|
||||
- 错峰重试的时间;
|
||||
- 尽可能准确识别出重试的成功率,以减少不必要的重试,例如我们可以通过“熔断”“快速失败”等机制来实现。
|
||||
|
||||
## 场景2:失败引发轮询
|
||||
|
||||
### 案例
|
||||
|
||||
在使用Apache HttpClient发送HTTP请求时,稍有经验的程序员都知道去控制下超时时间,这样,在连接不上服务器或者服务器无响应时,响应延时都会得到有效的控制,例如我们会使用下面的代码来配置HttpClient:
|
||||
|
||||
```
|
||||
RequestConfig requestConfig = RequestConfig.custom().
|
||||
setConnectTimeout(2 * 1000). //控制连接建立时间
|
||||
setConnectionRequestTimeout(1 * 1000).//控制获取连接时间
|
||||
setSocketTimeout(3 * 1000).//控制“读取”数据等待时间
|
||||
build();
|
||||
|
||||
```
|
||||
|
||||
以上面的代码为例,你能估算出响应时间最大是多少么?上面的代码实际设置了三个参数,是否直接相加就能计算出最大延时时间?即所有请求100%控制在6秒。
|
||||
|
||||
先不说结论,通过实际的生产线观察,我们确实发现大多符合我们的预期:可以说99.9%的响应都控制在6秒以内,但是总有一些“某年某月某天”,发现有一些零星的请求甚至超过了10秒,这又是为什么?
|
||||
|
||||
### 解析
|
||||
|
||||
经过问题跟踪,我们发现我们访问的URL是一个下游服务的域名(大多如此,并不稀奇),而这个域名本身有点特殊,由于负载均衡等因素的考虑,我们将它绑定到了多个IP地址。所以假设这些IP地址中,一些IP地址指向的服务临时不服务时,则会引发轮询,即轮询其它IP地址直到最终成功或失败,而每一次轮询中的失败都会额外增加一倍ConnectTimeout,所以我们发现超过6秒甚至10秒的请求也不稀奇了。我们可以通过HttpClient的源码来验证下这个逻辑(参考org.apache.http.impl.conn.DefaultHttpClientConnectionOperator.connect方法):
|
||||
|
||||
```
|
||||
public void connect(
|
||||
final ManagedHttpClientConnection conn,
|
||||
final HttpHost host,
|
||||
final InetSocketAddress localAddress,
|
||||
final int connectTimeout,
|
||||
final SocketConfig socketConfig,
|
||||
final HttpContext context) throws IOException {
|
||||
final Lookup<ConnectionSocketFactory> registry = getSocketFactoryRegistry(context);
|
||||
final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName());
|
||||
//域名解析,可能解析出多个地址
|
||||
final InetAddress[] addresses = host.getAddress() != null ?
|
||||
new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
|
||||
final int port = this.schemePortResolver.resolve(host);
|
||||
|
||||
//对于解析出的地址,进行连接,如果中途有失败,尝试下一个
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
final InetAddress address = addresses[i];
|
||||
final boolean last = i == addresses.length - 1;
|
||||
|
||||
Socket sock = sf.createSocket(context);
|
||||
conn.bind(sock);
|
||||
|
||||
final InetSocketAddress remoteAddress = new InetSocketAddress(address, port);
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Connecting to " + remoteAddress);
|
||||
}
|
||||
try {
|
||||
//使用解析出的地址执行连接
|
||||
sock = sf.connectSocket(
|
||||
connectTimeout, sock, host, remoteAddress, localAddress, context);
|
||||
conn.bind(sock);
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Connection established " + conn);
|
||||
}
|
||||
//如果连接成功,则直接退出,不继续尝试其它地址
|
||||
return;
|
||||
} catch (final SocketTimeoutException ex) {
|
||||
if (last) {
|
||||
throw new ConnectTimeoutException(ex, host, addresses);
|
||||
}
|
||||
} catch (final ConnectException ex) {
|
||||
if (last) { //如果连接到最后一个地址,还是失败,则抛出异常。如果不是最后一个,则轮询下一个地址进行连接。
|
||||
final String msg = ex.getMessage();
|
||||
if ("Connection timed out".equals(msg)) {
|
||||
throw new ConnectTimeoutException(ex, host, addresses);
|
||||
} else {
|
||||
throw new HttpHostConnectException(ex, host, addresses);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.log.isDebugEnabled()) {
|
||||
this.log.debug("Connect to " + remoteAddress + " timed out. " +
|
||||
"Connection will be retried using another IP address");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过以上代码,我们可以清晰地看出:在一个域名能解析出多个IP地址的场景下,如果其中部分IP指向的服务不可达时,延时就可能会增加。这里不妨再举个例子,对于Redis集群,我们会在客户端配置多个连接节点(例如在SpringBoot中配置spring.redis.cluster.nodes=10.224.56.101:8001,10.224.56.102:8001),通过连接节点来获取整个集群的信息(其它所有节点)。正常情况下,我们都会连接成功,所以我们没有看到长延时情况,但是假设刚初始化时,连接的部分节点不服务了,那这个时候就会连接其它配置的节点,从而导致延时倍增。
|
||||
|
||||
### 小结
|
||||
|
||||
这部分我们主要看了失败引发轮询造成的长延时案例,细心的同学可能会觉得这不就是上一部分介绍的重试么?但是实际上,你仔细体会,两者虽然都旨在提供系统可靠性,但却略有区别:重试一般指的是针对同一个目标进行的再次尝试,而轮询则更侧重对同类目标的遍历。
|
||||
|
||||
另外,除了以上介绍的开源组件(Apache HttpClient和RedisClient)案例外,还有其它一些常见组件都可能因为轮询而造成延时超过预期。例如对于Oracle连接,我们采用下面的这种配置时,也可能会出现轮询的情况:
|
||||
|
||||
```
|
||||
DBURL=jdbc:oracle:thin:@(DESCRIPTION=(load_balance=off)(failover=on)(ADDRESS=(PROTOCOL=TCP)(HOST=10.224.11.91)(PORT=1804))(ADDRESS=(PROTOCOL=TCP)(HOST=10.224.11.92)(PORT=1804))(ADDRESS=(PROTOCOL=TCP)(HOST= 10.224.11.93)(PORT=1804))(CONNECT_DATA=(SERVICE_NAME=xx.yy.com)(FAILOVER_MODE=(TYPE=SELECT)(METHOD=BASIC)(RETRIES=6)(DELAY=5))))
|
||||
DBUserName=admin
|
||||
DBPassword=password
|
||||
|
||||
```
|
||||
|
||||
其实通过对以上三个案例的观察,我们不难得出一个小规律:**假设我们配置了多个同种资源,那么就很有可能存在轮询情况,这种轮询会让延时远超出我们的预期。**只是幸运的是,在大多情况下,轮询第一次就成功了,所以我们很难观察到长延时的情况。针对这种情况造成的延时,我们除了在根源上消除外因,还要特别注意控制好超时时间,假设我们不知道这种情况,我们乐观地设置了一个很大的时间,则实际发生轮询时,这个时间会被放大很多倍。
|
||||
|
||||
这里再回到最开始的案例啰嗦几句,对于HttpClient的访问,是否加上最大轮询时间就是最大的延时时间呢?其实仍然不是,至少我们还忽略了一个时间,即DNS解析花费的时间。这里就不再展开讲了。
|
||||
|
||||
## 场景3:GC的“STW”停顿
|
||||
|
||||
### 案例
|
||||
|
||||
系统之间调用是服务最常见的方式,但这也是最容易发生“掐架”的斗争之地。例如对于组件A,运维或者测试工程师反映某接口偶然性能稍差,而对于这个接口而言,实际逻辑“简单至极”,直接调用组件B的接口,而对于这个接口的调用,平时确实都是接近1ms的:
|
||||
|
||||
```
|
||||
[07/04/2020 07:18:16.495 pid=3160 tid=3078502112] Info:[ComponentA] Send to Component B:
|
||||
[07/04/2020 07:18:16.496 pid=3160 tid=3078502112] Info:[ComponentA] Receive response from B
|
||||
|
||||
```
|
||||
|
||||
而反映的性能掉队不经常发生,而且发生时,也没有没有特别的信息,例如下面这段日志,延时达到了400ms:
|
||||
|
||||
```
|
||||
[07/04/2020 07:16:27.222 pid=4725 tid=3078407904] Info: [ComponentA] Send to Component B:
|
||||
[07/04/2020 07:16:27.669 pid=4725 tid=3078407904] Info: [ComponentA] Receive response from B
|
||||
|
||||
```
|
||||
|
||||
同时,对比下,我们也发现这2次请求其实很近,只有2分钟的差距。那么这又是什么导致的呢?
|
||||
|
||||
### 解析
|
||||
|
||||
对于这种情况,很明显,A组件往往会直接“甩锅”给B组件。于是,B组件工程师查询了日志:
|
||||
|
||||
```
|
||||
[07/04/2020 07:16:27.669][nioEventLoopGroup-4-1]INFO [ComponentB] Received request from Component A
|
||||
[07/04/2020 07:16:27.669][nioEventLoopGroup-4-1]INFO [ComponentB] Response to Component B
|
||||
|
||||
```
|
||||
|
||||
然后B组件也开始甩锅:鉴于我们双方组件传输层都是可靠的,且N年没有改动,那这次的慢肯定是网络抖动造成的了。貌似双方也都得到了理想的理由,但是问题还是没有解决,这种类似的情况还是时而发生,那问题到底还有别的什么原因么?
|
||||
|
||||
鉴于我之前的经验,其实我们早先就知道Java的STW(Stop The World)现象,可以说Java最省心的地方也是最容易让人诟病的地方。即不管怎么优化,或采用更先进的垃圾回收算法,都避免不了GC,而GC会引发停顿。其实上面这个案例,真实的原因确实就是B组件的GC导致了它的处理停顿,从而没有及时接受到A发出的信息,何以见得呢?
|
||||
|
||||
早先在设计B组件时,我们就考虑到未来某天可能会发生类似的事情,所以加了一个GC的跟踪日志,我们先来看看日志:
|
||||
|
||||
```
|
||||
{
|
||||
"metricName": "java_gc",
|
||||
"componentType": "B",
|
||||
"componentAddress": "10.224.3.10",
|
||||
"componentVer": "1.0",
|
||||
"poolName": "test001",
|
||||
"trackingID": "269",
|
||||
"metricType": "innerApi",
|
||||
"timestamp": "2020-07-04T07:16:27.219Z",
|
||||
"values": {
|
||||
"steps": [
|
||||
],
|
||||
"totalDurationInMS": 428
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在07:16:27.219时,发生了GC,且一直持续了428ms,所以最悲观的停顿时间是从219ms到647ms,而我们观察下A组件的请求发送时间222是落在这个区域的,再核对B组件接收这个请求的时间是669,是GC结束后的时间。所以很明显,排除其它原因以后,这明显是受了GC的影响。
|
||||
|
||||
### 小结
|
||||
|
||||
假设某天我们看到零星请求有“掉队”,且没有什么规律,但是又持续发生,我们往往都会怀疑是网络抖动,但是假设我们的组件是部署在同一个网络内,实际上,不大可能是网络原因导致的,而更可能是GC的原因。当然,跟踪GC有N多方法,这里我只是额外贴上了组件B使用的跟踪代码:
|
||||
|
||||
```
|
||||
List<GarbageCollectorMXBean> gcbeans = ManagementFactory.getGarbageCollectorMXBeans();
|
||||
for (GarbageCollectorMXBean gcbean : gcbeans) {
|
||||
LOGGER.info("GC bean: " + gcbean);
|
||||
if (!(gcbean instanceof NotificationEmitter))
|
||||
continue;
|
||||
|
||||
NotificationEmitter emitter = (NotificationEmitter) gcbean;
|
||||
|
||||
//注册一个GC(垃圾回收)的通知回调
|
||||
emitter.addNotificationListener(new NotificationListenerImplementation(), notification -> {
|
||||
return GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION
|
||||
.equals(notification.getType());
|
||||
}, null);
|
||||
|
||||
}
|
||||
|
||||
public final static class NotificationListenerImplementation implements NotificationListener {
|
||||
|
||||
@Override
|
||||
public void handleNotification(Notification notification, Object handback) {
|
||||
GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo
|
||||
.from((CompositeData) notification.getUserData());
|
||||
String gctype = info.getGcAction().replace("end of ", "");
|
||||
//此处只获取major gc的相关信息
|
||||
if(gctype.toLowerCase().contains("major")){
|
||||
long id = info.getGcInfo().getId();
|
||||
long startTime = info.getGcInfo().getStartTime();
|
||||
long duration = info.getGcInfo().getDuration();
|
||||
//省略非关键代码,记录GC相关信息,如耗费多久、开始时间点等。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外同样是停顿,发生的时机不同,呈现的效果也不完全相同,具体问题还得具体分析。至于应对这个问题的策略,就是我们写Java程序一直努力的方向:减少GC引发的STW时间。
|
||||
|
||||
以上是我总结的3种常见“绊脚石”,那其实类似这样的问题还有很多,下一期分享我会再总结出4个场景化的问题,和你一起探讨应对策略。
|
200
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场4|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(下).md
Normal file
200
极客时间专栏/系统性能调优必知必会/加餐与分享/大咖助场4|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(下).md
Normal file
@@ -0,0 +1,200 @@
|
||||
<audio id="audio" title="大咖助场4|傅健:那些年,影响我们达到性能巅峰的常见绊脚石(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/93/984033f1c5437fa23afcc74262afaa93.mp3"></audio>
|
||||
|
||||
你好,我是傅健,又见面了。上一期分享我们总结了3个场景化的问题以及应对策略,这一期我们就接着“系统性能优化”这个主题继续总结。
|
||||
|
||||
## 场景1:资源争用
|
||||
|
||||
### 案例
|
||||
|
||||
一段时间,我们总是监控到一些性能“掉队”的请求,例如平时我们访问Cassandra数据库都在10ms以内,但是偶尔能达到3s,你可以参考下面这个度量数据:
|
||||
|
||||
```
|
||||
{
|
||||
"stepName": "QueryInformation",
|
||||
"values": {
|
||||
"componentType": "Cassandra",
|
||||
"totalDurationInMS": 3548,
|
||||
"startTime": "2018-05-11T08:20:28.889Z",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
持续观察后,我们发现这些掉队的请求都集中在每天8点20分,话说“百果必有因”,这又是什么情况呢?
|
||||
|
||||
### 解析
|
||||
|
||||
这种问题,其实相对好查,因为它们有其发生的规律,这也是我们定位性能问题最基本的手段,即找规律:发生在某一套环境?某一套机器?某个时间点?等等,这些都是非常有用的线索。而这个案例就是固定发生在某个时间点。既然是固定时间点,说明肯定有某件事固定发生在这个点,所以查找问题的方向自然就明了了。
|
||||
|
||||
首先,我们上来排除了应用程序及其下游应用程序定时去做任务的情况。那么除了应用程序自身做事情外,还能是什么?可能我们会想到:运行应用程序的机器在定时做事情。果然,我们查询了机器的CronJob,发现服务器在每天的8点20分(业务低峰期)都会去归档业务的日志,而这波集中的日志归档操作,又带来了什么影响呢?
|
||||
|
||||
日志备份明显是磁盘操作,所以我们要查看的是磁盘的性能影响,如果磁盘都转不动了,可想而知会引发什么。我们一般都会有图形化的界面去监控磁盘性能,但是这里为了更为通用的演示问题,所以我使用了SAR的结果来展示当时的大致情况(说是大致,是因为SAR的历史记录结果默认是以10分钟为间隔,所以只显示8:20分的数据,可能8:21分才是尖峰,但是却不能准确反映):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/88/ba6f20c32d3a3288f4e3467b70b68988.png" alt="">
|
||||
|
||||
从上面的结果我们可以看出,平时磁盘await只要2ms,而8:20分的磁盘操作达到了几百毫秒。磁盘性能的急剧下降影响了应用程序的性能。
|
||||
|
||||
### 小结
|
||||
|
||||
在这个案例中,服务器上的定时日志归档抢占了我们的资源,导致应用程序速度临时下降,即资源争用导致了性能掉队。我们再延伸一点来看,除了这种情况外,还有许多类似的资源争用都有可能引起这类问题,例如我们有时候会在机器上装一些logstash、collectd等监控软件,而这些监控软件如果不限制它们对资源的使用,同样也可能会争用我们很多的资源。诸如此类,不一一枚举,而针对这种问题,很明显有好几种方法可以供我们去尝试解决,以当前的案例为例:
|
||||
|
||||
- 降低资源争用,例如让备份慢一点;
|
||||
- 错峰,错开备份时间,例如不让每个机器都是8点20分去备份,而是在大致一个时间范围内随机时间进行;
|
||||
- 避免资源共享,避免多个机器/虚拟机使用同一块磁盘。
|
||||
|
||||
上面讨论的争用都发生在一个机器内部,实际上,资源争用也常发生在同一资源(宿主、NFS磁盘等)的不同虚拟机之间,这也是值得注意的一个点。
|
||||
|
||||
## 场景2:延时加载
|
||||
|
||||
### 案例
|
||||
|
||||
某日,某测试工程师胸有成竹地抱怨:“你这个API接口性能不行啊,我们每次执行自动化测试时,总有响应很慢的请求。”于是一个常见的争执场景出现了:当面演示调用某个API若干次,结果都是响应极快,测试工程师坚持他真的看到了请求很慢的时候,但是开发又坚定说在我的机器上它就是可以啊。于是互掐不停。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/53/ba0e43c92a1690a8266746157b66d653.jpg" alt="">
|
||||
|
||||
### 解析
|
||||
|
||||
开发者后来苦思冥想很久,和测试工程师一起比较了下到底他们的测试有什么不同,唯一的区别在于测试的时机不同:自动化测试需要做的是回归测试,所以每次都会部署新的包并执行重启,而开发者复现问题用的系统已经运行若干天,那这有什么区别呢?这就引入了我们这部分要讨论的内容——延时加载。
|
||||
|
||||
我们知道当系统去执行某个操作时,例如访问某个服务,往往都是“按需执行”,这非常符合我们的行为习惯。例如,需要外卖点餐时,我们才打开某APP,我们一般不会在还没有点餐的时候,就把APP打开“守株待兔”某次点餐的发生。这种思路写出来的系统,会让我们的系统在上线之时,可以轻装上阵。例如,非常类似下面的Java“延时初始化”伪代码:
|
||||
|
||||
```
|
||||
public class AppFactory{
|
||||
private static App app;
|
||||
synchronized App getApp() {
|
||||
if (App == null)
|
||||
app= openAppAndCompleteInit();
|
||||
return app;
|
||||
}
|
||||
//省略其它非关键代码
|
||||
}
|
||||
|
||||
App app = AppFactory.getApp();
|
||||
app.order("青椒土豆丝");
|
||||
|
||||
```
|
||||
|
||||
但这里的问题是什么呢?假设打开APP的操作非常慢,那等我们需要点餐的时候,就会额外耗费更长的时间(而一旦打开运行在后台,后面不管怎么点都会很快)。这点和我们的案例其实是非常类似的。
|
||||
|
||||
我们现在回到案例,持续观察发现,这个有性能问题的API只出现在第一次访问,或者说只出现在系统重启后的第一次访问。当它的API被触发时,它会看看本地有没有授权相关的信息,如果没有则远程访问一个授权服务拿到相关的认证信息,然后缓存认证信息,最后再使用这个认证信息去访问其它组件。而问题恰巧就出现在访问授权服务器完成初始化操作耗时比较久,所以呈现出第一次访问较慢,后续由于已缓存消息而变快的现象。
|
||||
|
||||
那这个问题怎么解决呢?我们可以写一个“加速器”,说白了,就是把“延时加载”变为“主动加载”。在系统启动之后、正常提供服务之前,就提前访问授权服务器拿到授权信息,这样等系统提供服务之后,我们的第一个请求就很快了。类似使用Java伪代码,对上述的延时加载修改如下:
|
||||
|
||||
```
|
||||
public class AppFactory{
|
||||
private static App app = openAppAndCompleteInit();
|
||||
synchronized App getApp() {
|
||||
return app;
|
||||
}
|
||||
//省略其它非关键代码
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 小结
|
||||
|
||||
延时加载固然很好,可以让我们的系统轻装上阵,写出的程序也符合我们的行为习惯,但是它常常带来的问题是,在第一次访问时可能性能不符合预期。当遇到这种问题时,我们也可以根据它的出现特征来分析是不是属于这类问题,即是否是启动完成后的第一次请求。如果是此类问题,我们可以通过变“被动加载”为“主动加载”的方式来加速访问,从而解决问题。
|
||||
|
||||
但是这里我不得不补充一点,是否在所有场景下,我们都需要化被动为主动呢?实际上,还得具体情况具体分析,例如我们打开一个网页,里面内嵌了很多小图片链接,但我们是否会为了响应速度,提前将这些图片进行加载呢?一般我们都不会这么做。所以具体问题具体分析永远是真理。针对我们刚刚讨论的案例,这种加速只是一次性的而已,而且资源数量和大小都是可控的,所以这种修改是值得,也是可行的。
|
||||
|
||||
## 场景3:网络抖动
|
||||
|
||||
### 案例
|
||||
|
||||
我们来看一则[新闻](https://finance.sina.com.cn/money/bank/bank_hydt/2019-12-05/doc-iihnzhfz3876113.shtml):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/fd/518c5a356e5b956a88be92b2476f8ffd.jpg" alt="">
|
||||
|
||||
类似的新闻还有许多,你可以去搜一搜,然后你就会发现:它们都包含一个关键词——网络抖动。
|
||||
|
||||
### 解析
|
||||
|
||||
那什么是网络抖动呢?网络抖动是衡量网络服务质量的一个指标。假设我们的网络最大延迟为100ms,最小延迟为10ms,那么网络抖动就是90ms,即网络延时的最大值与最小值的差值。差值越小,意味着抖动越小,网络越稳定。反之,当网络不稳定时,抖动就会越大,网络延时差距也就越大,反映到上层应用自然是响应速度的“掉队”。
|
||||
|
||||
为什么网络抖动如此难以避免?这是因为网络的延迟包括两个方面:传输延时和处理延时。忽略处理延时这个因素,假设我们的一个主机进行一次服务调用,需要跨越千山万水才能到达服务器,我们中间出“岔子”的情况就会越多。我们在Linux下面可以使用traceroute命令来查看我们跋山涉水的情况,例如从我的Linux机器到百度的情况是这样的:
|
||||
|
||||
```
|
||||
[root@linux~]# traceroute -n -T www.baidu.com
|
||||
traceroute to www.baidu.com (119.63.197.139), 30 hops max, 60 byte packets
|
||||
1 10.224.2.1 0.452 ms 0.864 ms 0.914 ms
|
||||
2 1.1.1.1 0.733 ms 0.774 ms 0.817 ms
|
||||
3 10.224.16.193 0.361 ms 0.369 ms 0.362 ms
|
||||
4 10.224.32.9 0.355 ms 0.414 ms 0.478 ms
|
||||
5 10.140.199.77 0.400 ms 0.396 ms 0.544 ms
|
||||
6 10.124.104.244 12.937 ms 12.655 ms 12.706 ms
|
||||
7 10.124.104.195 12.736 ms 12.628 ms 12.851 ms
|
||||
8 10.124.104.217 13.093 ms 12.857 ms 12.954 ms
|
||||
9 10.112.4.65 12.586 ms 12.510 ms 12.609 ms
|
||||
10 10.112.8.222 44.250 ms 44.183 ms 44.174 ms
|
||||
11 10.112.0.122 44.926 ms 44.360 ms 44.479 ms
|
||||
12 10.112.0.78 44.433 ms 44.320 ms 44.508 ms
|
||||
13 10.75.216.50 44.295 ms 44.194 ms 44.386 ms
|
||||
14 10.75.224.202 46.191 ms 46.135 ms 46.042 ms
|
||||
15 119.63.197.139 44.095 ms 43.999 ms 43.927 ms
|
||||
|
||||
```
|
||||
|
||||
通过上面的命令结果我们可以看出,我的机器到百度需要很多“路”。当然大多数人并不喜欢使用traceroute来评估这段路的艰辛程度,而是直接使用ping来简单看看“路”的远近。例如通过以下结果,我们就可以看出,我们的网络延时达到了40ms,这时网络延时就可能是一个问题了。
|
||||
|
||||
```
|
||||
[root@linux~]# ping www.baidu.com
|
||||
PING www.wshifen.com (103.235.46.39) 56(84) bytes of data.
|
||||
64 bytes from 103.235.46.39: icmp_seq=1 ttl=41 time=46.2 ms
|
||||
64 bytes from 103.235.46.39: icmp_seq=2 ttl=41 time=46.3 ms
|
||||
|
||||
```
|
||||
|
||||
其实上面这两个工具的使用只是直观反映网络延时,它们都默认了一个潜规则:网络延时越大,网络越抖动。且不说这个规则是否完全正确,至少从结果来看,评估网络抖动并不够直观。
|
||||
|
||||
所以我们可以再寻求一些其它的工具。例如可以使用MTR工具,它集合了tractroute和ping。我们可以看下执行结果:下图中的best和wrst字段,即为最好的情况与最坏的情况,两者的差值也能在一定程度上反映出抖动情况,其中不同的host相当于traceroute经过的“路”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/4f/04a239ef67790671e494cde7416ddb4f.png" alt="">
|
||||
|
||||
### 小结
|
||||
|
||||
对于网络延时造成的抖动,特别是传输延迟造成的抖动,我们一般都是“有心无力”的。只能说,我们需要做好网络延时的抖动监测,从而能真正定位到这个问题,避免直接无证据就“甩锅”于网络。
|
||||
|
||||
另外,在做设计和部署时,我们除了容灾和真正的业务需求,应尽量避免或者说减少“太远”的访问,尽量将服务就近到一个机房甚至一个机柜,这样就能大幅度降低网络延迟,抖动情况也会降低许多,这也是为什么CDN等技术兴起的原因。
|
||||
|
||||
那同一网络的网络延时可以有多好呢?我们可以自己测试下。例如,我的Linux机器如果ping同一个网段的机器,是连1ms都不到的:
|
||||
|
||||
```
|
||||
[root@linux~]# ping 10.224.2.146
|
||||
PING 10.224.2.146 (10.224.2.146) 56(84) bytes of data.
|
||||
64 bytes from 10.224.2.146: icmp_seq=1 ttl=64 time=0.212 ms
|
||||
64 bytes from 10.224.2.146: icmp_seq=2 ttl=64 time=0.219 ms
|
||||
64 bytes from 10.224.2.146: icmp_seq=3 ttl=64 time=0.154 ms
|
||||
|
||||
```
|
||||
|
||||
## 场景4:缓存失效
|
||||
|
||||
### 案例
|
||||
|
||||
在产线上,我们会经常观察到一些API接口调用周期性(固定时间间隔)地出现“长延时”,而另外一些接口调用也会偶尔如此,只是时间不固定。继续持续观察,你会发现,虽然后者时间不够规律,但是它们的出现时间间隔都是大于一定时间间隔的。那这种情况是什么原因导致的呢?
|
||||
|
||||
### 解析
|
||||
|
||||
当我们遇到上述现象,很有可能是因为“遭遇”了缓存失效。缓存的定义与作用这里不多赘述,你应该非常熟悉了。同时我们也都知道,缓存是以空间换时间的方式来提高性能,讲究均衡。在待缓存内容很多的情况下,不管我们使用本地缓存,还是Redis、Memcached等缓存方案,我们经常都会受限于“空间”或缓存条目本身的时效性,而给缓存设置一个失效时间。而当失效时间到来时,我们就需要访问数据的源头从而增加延时。这里以下面的缓存构建代码作为示例:
|
||||
|
||||
```
|
||||
CacheBuilder.newBuilder().maximumSize(10_000).expireAfterWrite(10, TimeUnit.MINUTES).build();
|
||||
|
||||
```
|
||||
|
||||
我们可以看到:一旦缓存后,10分钟就会失效,但是失效后,我们不见得刚好有请求过来。如果这个接口是频繁调用的,则长延时请求的出现频率会呈现出周期性的10分钟间隔规律,即案例描述的前者情况。而如果这个接口调用很不频繁,则我们只能保证在10分钟内的延时是平滑的。当然,这里讨论的场景都是在缓存够用的情况下,如果缓存的条目超过了设定的最大允许值,这可能会提前淘汰一些缓存内容,这个时候长延时请求出现的规律就无章可循了。
|
||||
|
||||
### 小结
|
||||
|
||||
缓存失效是导致偶发性延时增加的常见情况,也是相对来说比较好定位的问题,因为接口的执行路径肯定是不同的,只要我们有充足的日志,即可找出问题所在。另外,针对这种情况,一方面我们可以增加缓存时间来尽力减少长延时请求,但这个时候要求的空间也会增大,同时也可能违反了缓存内容的时效性要求;另一方面,在一些情况下(比如缓存条目较少、缓存的内容可靠性要求不高),我们可以取消缓存的TTL,更新数据库时实时更新缓存,这样读取数据就可以一直通过缓存进行。总之,应情况调整才是王道。
|
||||
|
||||
## 总结
|
||||
|
||||
结合我上一期的分享,我们一共总结了7种常见“绊脚石”及其应对策略,那通过了解它们,我们是否就有十足的信心能达到性能巅峰了呢?
|
||||
|
||||
其实在实践中,我们往往还是很难的,特别是当前微服务大行其道,决定我们系统性能的因素往往是下游微服务,而下游微服务可能来源于不同的团队或组织。这时,已经不再单纯是技术本身的问题了,而是沟通、协调甚至是制度等问题。但是好在对于下游微服务,我们依然可以使用上面的分析思路来找出问题所在,不过通过上面的各种分析你也可以知道,让性能做到极致还是很难的,总有一些情况超出我们的预期,例如我们使用的磁盘发生损害,彻底崩溃前也会引起性能下降。
|
||||
|
||||
另外一个值得思考的问题是,是否有划算的成本收益比去做无穷无尽的优化,当然对于技术极客来说,能不能、让不让解决问题也许不是最重要的,剥丝抽茧、了解真相才是最有成就感的事儿。
|
||||
|
||||
感谢阅读,希望今天的分享能让你有所收获!如果你发现除了上述我介绍的那些“绊脚石”外,还有其它一些典型情况存在,也欢迎你在留言区中分享出来作为补充。
|
Reference in New Issue
Block a user