This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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 &lt;= 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 &lt;= 0) return 0;
if (n == 1) return 1;
prev = 0;
cur = 1;
for (i = 2; i &lt;= 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)
这些都在考察你如何通过操作系统,协调使用系统资源的能力。
另外,除了硬核的知识技能外,你也不要忽略软技能,这也能在面试中加分。比如,任何大厂都非常强调团队协作,如果员工遇到难题时,只会闷头冥想,这样的时间成本太高,既有可能延迟项目进度,也不利于充分发挥大厂高手如林、资源丰富的优势。所以,如果你在面试中,表现出善于沟通、乐于求助的特性,都是加分项。
以上就是我对大厂面试的一些沉淀和思考,不知道你有没有感同身受呢?如果你在面试中也遇到过一些特别开放、有区分度的面试题,或者作为面试官你有哪些喜欢用的面试题,欢迎分享出来,我们一起探讨。
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。

View 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报文拷贝到用户态内存中。所以此时会将连接内存池扩展到1KBclient_header_buffer_size指令可以配置来拷贝消息内容如果在这段时间之内没有接收完请求则返回失败并关闭连接。
<img src="https://static001.geekbang.org/resource/image/17/63/171329643c8f003yy47bcd0d1b5f5963.jpg" alt="">
### 2. 处理请求
当接收完HTTP请求行和HEADER后就清楚了这是一个什么样的请求此时会再分配另一个默认为4KBrequest_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 offNginx会从上游接收到一点响应就立刻往下游发一些。
<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锁这是因为操作系统提供了reuseportLinux3.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_cacheNginx可以将文件句柄、统计信息等写入缓存中提升性能。
<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="">
以上就是今天的加餐分享,有任何问题欢迎在留言区中提出。

View 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 框架标准化了不同应用程序间实时通讯的方式。
- DALData Access Layer数据访问层框架标准化了应用程序和数据库之间通讯的方式。
所以,**虽然分布式系统中会运用中间件,但分布式系统却不仅仅停留在用了什么中间件上。**你需要清楚每一类中间件背后是对什么进行了标准化,它的目的是什么,带来了哪些副作用,等等。只有如此,你才能真正识别不同技术框架之间的区别,找到真正适合当前系统的技术框架。
那么标准是拍脑袋决定的吗?肯定不是,正如前面所说每一次标准化都是有目的的,需要产生价值。比如,大部分中间件都具备这样一个价值:
>
为了在软件系统的迭代过程中,避免将精力过多地花费在某个子功能下众多差异不大的选项中。
在现实中,这点更多时候出现在技术层面的中间件里,比如,数据库访问框架的作用是为了标准化操作不同数据库的差异,使得上层应用程序不用纠结于该怎么与 MySQL 交互或者该怎么与 SQL SERVER 交互。因为与业务相比,技术层面“稳定”多了,所以做标准化更有价值,更能获得长期收益。但“稳定”是相对的,哪怕单纯在业务层面也存在相对稳定的部分。
比如,你可以想象一下“盛饭”的场景,在大多数情况下其中相对稳定的是什么,不稳定的是什么。想完之后看下面的示例:
```
...
基类:人
继承基类的子类:男人、女人
基类:碗
继承基类的子类:大碗、小碗、汤碗
基类:勺子
继承基类的子类:铁勺、陶瓷勺、塑料勺
function 盛饭(参数 人,参数 碗,参数 勺子){
do 人拿起碗
do 人拿起勺子
do 人用勺子舀起饭
do 人把勺子放到碗的上方并倒下
}
...
```
从这个示例里我们发现,不稳定的部分都已经成为变量了,那么剩下的这个方法体起到的作用和前面提到的中间件是一样的,它标准化了盛饭的过程。所以识别相对稳定的部分是什么,如何把它们提炼出来,并且围绕这些点进行标准化,才是我们需要掌握的能力。而锻炼这个能力和需要这个能力的地方同样并不局限于分布式系统。
**列举这些现象只是想说,我们在认知一个分布式系统的时候,内在胜于表象,掌握一个扎实的理论基本功更为重要。**而且,这些训练场无处不在。
## 海市蜃楼般的“分布式系统”
我相信,自从进入移动时代以来,各种高大上的系统架构图越来越频繁地出现,你的眼前充斥着各种主流、非主流的眼花缭乱的技术框架。你不由得肃然起敬一番,心中呐喊着:“对,这就是我想去的地方,我想参与甚至实现一个这样牛逼的分布式系统,再也不想每天只是增删改查了。”
得不到的事物总是美好的,但往往我们也会过度地高估它的美好。与此类似,高大上的架构图背后呈现的系统的确也是一个成熟分布式系统的样貌,但我们要清楚一点:罗马不是一日建成的。
而且,“分布式”这个词只是意味着形态上是散列状的,而“一分为二”和“一分为 N”本质上并没有区别。所以很多小项目或者大型项目的初期所搭配的基础套餐“单程序 + 单数据库”,同样可以理解为分布式系统,其中遇到的问题很多同样也存在于成熟的分布式系统中。
想象一下,下面的场景是否在“单程序 + 单数据库”项目中出现过?
- log 记录执行成功,但是数据库的数据没发生变化;
- 进程内的缓存数据更新了,但是数据库更新失败了。
这里我们停顿 30 秒,思考一下为什么会出现这些问题?
这里需要我们先思考一下“软件”是什么。 软件的本质是一套代码,而代码只是一段文字,除了提供文字所表述的信息之外,本身无法“动”起来。但是,想让它“动”起来,使其能够完成一件我们指定的事情,前提是需要一个宿主来给予它生命。这个宿主就是计算机,它可以让代码变成一连串可执行的“动作”,然后通过数据这个“燃料”的触发,“动”起来。这个持续的活动过程,又被描述为一个运行中的“进程”。
那么除了我们开发的系统是软件,数据库也是软件,前者负责运算,后者负责存储运算后的结果(也可称为“状态”),分工协作。
所以,“单程序 + 单数据库”为什么也是分布式系统这个问题就很明白了。因为我们所编写的程序运行时所在的进程,和程序中使用到的数据库所在的进程,并不是同一个。也因此导致了,让这两个进程(系统)完成各自的部分,而后最终完成一件完整的事,变得不再像由单个个体独自完成这件事那么简单。这就如“两人三足”游戏一样,如何尽可能地让外部看起来像是一个整体、自然地前进。
**所以,我们可以这么理解,涉及多个进程协作才能提供一个完整功能的系统就是“分布式系统”。**
那么再回到上面举例的两个场景,我们在思考“单程序 + 单数据库”项目中遇到的这些问题背后的原因和解决它的过程时,与我们在一个成熟的分布式系统中的遭遇是一样的,例如数据一致性。当然,这只是分布式系统核心概念的冰山一角。
维基百科对“分布式系统”的宏观定义是这样的:
>
分布式系统是一种其组件位于不同的联网计算机上的系统,然后通过互相传递消息来进行通信和协调。为了达到共同的目标,这些组件会相互作用。
我们可以再用大小关系来解释它:把需要进行大量计算的工程数据分割成小块,由多台计算机分别计算,然后将结果统一合并得出数据结论的科学。这本质上就是“分治”。而“单程序 + 单数据库”组合的系统也包含了至少两个进程,“麻雀虽小五脏俱全”,这也是“分布式系统”。
## 小结
现在,我们搞清楚了,**看待一个“分布式系统”的时候,内在胜于表象。以及,只要涉及多个进程协作才能提供一个完整功能的系统,就是“分布式系统”。**
我相信还有很多其他景象出现你的脑海中,但这大多数都是分布式系统的本质产生的“化学反应”,进而形成的结果。如果停留在这些表象上,那么我们最终将无法寻找到“分布式系统”的本质,也就无法得到真正的“道”,更不会真正具备驾驭这些形态各异的“分布式系统”的能力。
所以,希望你在学习分布式系统的时候,不要因追逐“术”而丢了“道”。没有“道”只有“术”是空壳,最终会走火入魔,学得越多,会越混乱,到处都是矛盾和疑惑。
以上就是张帆老师的分享,他的观点与本专栏也是不谋而合的。他认为:我们不仅要清楚具体场景下的最佳实践,还要明白为什么这样做,以及该如何去权衡不同方案。我们务必要修炼好自己的内功,形成一套完整的知识体系,完成核心“骨架”的塑造。而在此之后,你自己在课外学习时,就可以去填充“血肉”部分,逐渐丰满自己。未来,大家的区别就在于胖一点和瘦一点,但只要能很好地完成工作,胖瘦又有何影响呢?
最后,有关“分布式系统优化”你还有什么问题吗?欢迎在留言区中一起讨论。

View 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 步:分解 -&gt; 治理 -&gt; 归并。而分治思想的表现形式多样,分层、分块都是它的体现。
<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="">
以上就是今天的全部内容。最后,互动一下,在你的工作或者学习中,你觉得分布式系统还具备哪些价值呢?欢迎留言!

View 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&amp;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="">
其中显示为EEncrypt的字段表示被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的长度之和不能大于PMTUDPath 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一节中会详细介绍包含了以下类型
- 0x00DATA帧用于传输HTTP Body包体
- 0x01HEADERS帧通过QPACK 编码传输HTTP Header头部
- 0x03CANCEL_PUSH控制帧用于取消1次服务器推送消息通常客户端在收到PUSH_PROMISE帧后通过它告知服务器不需要这次推送
- 0x04SETTINGS控制帧设置各类通讯参数
- 0x05PUSH_PROMISE帧用于服务器推送HTTP Body前先将HTTP Header头部发给客户端流程与HTTP/2相似
- 0x07GOAWAY控制帧用于关闭连接注意不是关闭Stream
- 0x0dMAX_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时这就是客户端发起的双向StreamHTTP/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协议有所帮助也欢迎把它分享给你的朋友。

View 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&amp;utm_source=geektime&amp;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核的CPU8个线程的情况下效率是最高的。** 这时每个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模型AIOAsynchronous 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三种实现方式课后请你去查阅一下资料看看这三种实现方式有什么区别
感谢阅读,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。

View 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相关的性能知识。SSDSolid State Drive是硬盘的一种有时候也叫Flash或者固态硬盘。
最近几年SSD的发展和演化非常迅速。随着市场规模的增大和技术的进步SSD的价格也大幅度降低了。在很多实时的后台系统中SSD几乎已经成了标准配置了。所以了解它的机制和性能对你的工作会很有益处的。
相对于传统硬盘HDDHard Disk DriveSSD有完全不同的内部工作原理和全新的特性。有些机制不太容易理解而且根据你工作的领域需要理解的深度也不一样。所以我把这节课的内容按照由浅入深的原则分成了三个层次。
第一个层次是关注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的特性做深层的优化呢比如降低损耗
感谢阅读,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。

View 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个999%再进一步提高到3个999.9%)。而实际上,我们不难发现,这些所谓的绊脚石其实都是类似的,所以这期分享我就带你看看究竟有哪些绊脚石,我们结合具体场景总结应对策略。
## 场景1重试、重定向
### 案例
当我们使用下游服务的API接口时偶尔会出现延时较大的情况而这些延时较大的调用最后也能成功且没有任何明显的时间规律。例如响应延时正常时API调用性能度量数据如下
```
{
&quot;stepName&quot;: &quot;CallRemoteService&quot;
&quot;values&quot;: {
&quot;componentType&quot;: &quot;RemoteService&quot;,
&quot;startTime&quot;: &quot;2020-07-06T10:50:41.102Z&quot;,
&quot;totalDurationInMS&quot;: 2,
&quot;success&quot;: true
}
}
```
而响应延时超出预期时,度量数据如下:
```
{
&quot;stepName&quot;: &quot;CallRemoteService&quot;
&quot;values&quot;: {
&quot;componentType&quot;: &quot;RemoteService&quot;,
&quot;startTime&quot;: &quot;2020-07-06T04:31:55.805Z&quot;,
&quot;totalDurationInMS&quot;: 2005,
&quot;success&quot;: 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 &gt; 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 {}-&gt;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 {}-&gt;http://10.224.86.130:8080
```
另外除了针对异常的重试外我们有时候也需要对于服务的短暂不可用返回503SC_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(&quot;Redirect requested to location '&quot; + location + &quot;'&quot;);
}
```
再如当我们使用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&lt;ConnectionSocketFactory&gt; 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 &lt; 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(&quot;Connecting to &quot; + remoteAddress);
}
try {
//使用解析出的地址执行连接
sock = sf.connectSocket(
connectTimeout, sock, host, remoteAddress, localAddress, context);
conn.bind(sock);
if (this.log.isDebugEnabled()) {
this.log.debug(&quot;Connection established &quot; + 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 (&quot;Connection timed out&quot;.equals(msg)) {
throw new ConnectTimeoutException(ex, host, addresses);
} else {
throw new HttpHostConnectException(ex, host, addresses);
}
}
}
if (this.log.isDebugEnabled()) {
this.log.debug(&quot;Connect to &quot; + remoteAddress + &quot; timed out. &quot; +
&quot;Connection will be retried using another IP address&quot;);
}
}
}
```
通过以上代码我们可以清晰地看出在一个域名能解析出多个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解析花费的时间。这里就不再展开讲了。
## 场景3GC的“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的STWStop The World现象可以说Java最省心的地方也是最容易让人诟病的地方。即不管怎么优化或采用更先进的垃圾回收算法都避免不了GC而GC会引发停顿。其实上面这个案例真实的原因确实就是B组件的GC导致了它的处理停顿从而没有及时接受到A发出的信息何以见得呢
早先在设计B组件时我们就考虑到未来某天可能会发生类似的事情所以加了一个GC的跟踪日志我们先来看看日志
```
{
&quot;metricName&quot;: &quot;java_gc&quot;,
&quot;componentType&quot;: &quot;B&quot;,
&quot;componentAddress&quot;: &quot;10.224.3.10&quot;,
&quot;componentVer&quot;: &quot;1.0&quot;,
&quot;poolName&quot;: &quot;test001&quot;,
&quot;trackingID&quot;: &quot;269&quot;,
&quot;metricType&quot;: &quot;innerApi&quot;,
&quot;timestamp&quot;: &quot;2020-07-04T07:16:27.219Z&quot;,
&quot;values&quot;: {
&quot;steps&quot;: [
],
&quot;totalDurationInMS&quot;: 428
}
}
```
在07:16:27.219时发生了GC且一直持续了428ms所以最悲观的停顿时间是从219ms到647ms而我们观察下A组件的请求发送时间222是落在这个区域的再核对B组件接收这个请求的时间是669是GC结束后的时间。所以很明显排除其它原因以后这明显是受了GC的影响。
### 小结
假设某天我们看到零星请求有“掉队”且没有什么规律但是又持续发生我们往往都会怀疑是网络抖动但是假设我们的组件是部署在同一个网络内实际上不大可能是网络原因导致的而更可能是GC的原因。当然跟踪GC有N多方法这里我只是额外贴上了组件B使用的跟踪代码
```
List&lt;GarbageCollectorMXBean&gt; gcbeans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean gcbean : gcbeans) {
LOGGER.info(&quot;GC bean: &quot; + gcbean);
if (!(gcbean instanceof NotificationEmitter))
continue;
NotificationEmitter emitter = (NotificationEmitter) gcbean;
//注册一个GC(垃圾回收)的通知回调
emitter.addNotificationListener(new NotificationListenerImplementation(), notification -&gt; {
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(&quot;end of &quot;, &quot;&quot;);
//此处只获取major gc的相关信息
if(gctype.toLowerCase().contains(&quot;major&quot;)){
long id = info.getGcInfo().getId();
long startTime = info.getGcInfo().getStartTime();
long duration = info.getGcInfo().getDuration();
//省略非关键代码记录GC相关信息如耗费多久、开始时间点等。
}
}
}
```
另外同样是停顿发生的时机不同呈现的效果也不完全相同具体问题还得具体分析。至于应对这个问题的策略就是我们写Java程序一直努力的方向减少GC引发的STW时间。
以上是我总结的3种常见“绊脚石”那其实类似这样的问题还有很多下一期分享我会再总结出4个场景化的问题和你一起探讨应对策略。

View 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你可以参考下面这个度量数据
```
{
&quot;stepName&quot;: &quot;QueryInformation&quot;,
&quot;values&quot;: {
&quot;componentType&quot;: &quot;Cassandra&quot;,
&quot;totalDurationInMS&quot;: 3548,
&quot;startTime&quot;: &quot;2018-05-11T08:20:28.889Z&quot;,
&quot;success&quot;: true
}
}
```
持续观察后我们发现这些掉队的请求都集中在每天8点20分话说“百果必有因”这又是什么情况呢
### 解析
这种问题,其实相对好查,因为它们有其发生的规律,这也是我们定位性能问题最基本的手段,即找规律:发生在某一套环境?某一套机器?某个时间点?等等,这些都是非常有用的线索。而这个案例就是固定发生在某个时间点。既然是固定时间点,说明肯定有某件事固定发生在这个点,所以查找问题的方向自然就明了了。
首先我们上来排除了应用程序及其下游应用程序定时去做任务的情况。那么除了应用程序自身做事情外还能是什么可能我们会想到运行应用程序的机器在定时做事情。果然我们查询了机器的CronJob发现服务器在每天的8点20分业务低峰期都会去归档业务的日志而这波集中的日志归档操作又带来了什么影响呢
日志备份明显是磁盘操作所以我们要查看的是磁盘的性能影响如果磁盘都转不动了可想而知会引发什么。我们一般都会有图形化的界面去监控磁盘性能但是这里为了更为通用的演示问题所以我使用了SAR的结果来展示当时的大致情况说是大致是因为SAR的历史记录结果默认是以10分钟为间隔所以只显示820分的数据可能821分才是尖峰但是却不能准确反映
<img src="https://static001.geekbang.org/resource/image/ba/88/ba6f20c32d3a3288f4e3467b70b68988.png" alt="">
从上面的结果我们可以看出平时磁盘await只要2ms而820分的磁盘操作达到了几百毫秒。磁盘性能的急剧下降影响了应用程序的性能。
### 小结
在这个案例中服务器上的定时日志归档抢占了我们的资源导致应用程序速度临时下降即资源争用导致了性能掉队。我们再延伸一点来看除了这种情况外还有许多类似的资源争用都有可能引起这类问题例如我们有时候会在机器上装一些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(&quot;青椒土豆丝&quot;);
```
但这里的问题是什么呢假设打开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种常见“绊脚石”及其应对策略那通过了解它们我们是否就有十足的信心能达到性能巅峰了呢
其实在实践中,我们往往还是很难的,特别是当前微服务大行其道,决定我们系统性能的因素往往是下游微服务,而下游微服务可能来源于不同的团队或组织。这时,已经不再单纯是技术本身的问题了,而是沟通、协调甚至是制度等问题。但是好在对于下游微服务,我们依然可以使用上面的分析思路来找出问题所在,不过通过上面的各种分析你也可以知道,让性能做到极致还是很难的,总有一些情况超出我们的预期,例如我们使用的磁盘发生损害,彻底崩溃前也会引起性能下降。
另外一个值得思考的问题是,是否有划算的成本收益比去做无穷无尽的优化,当然对于技术极客来说,能不能、让不让解决问题也许不是最重要的,剥丝抽茧、了解真相才是最有成就感的事儿。
感谢阅读,希望今天的分享能让你有所收获!如果你发现除了上述我介绍的那些“绊脚石”外,还有其它一些典型情况存在,也欢迎你在留言区中分享出来作为补充。