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,141 @@
<audio id="audio" title="19 | 如何通过监控找到性能瓶颈?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/c8/71acd68a7f1f14f7b3c33f7c1ca117c8.mp3"></audio>
你好,我是陶辉。
从这一讲开始,我们将进入分布式系统层面,站在更宏观的角度去探讨系统性能的优化。
如果优化系统性能时,只是依据自己的经验,对感觉存在性能提升空间的代码,无一例外地做一遍优化,这既是一件事倍功半的事,也很容易遗漏下关键的优化点,无法大幅提升系统的性能。根据帕累托法则(也叫二八定律),**只有优化处于性能瓶颈的那些少量代码,才能用最小的成本获得最大的收益。**
然而,找到性能瓶颈却不是一件容易的事。我们通常会采用各种监控手段来发现性能瓶颈,但**如果监控动作自身的开发成本过高,或者施行监控时显著降低了业务请求的性能,或者无法全面覆盖潜在的问题,都会影响性能优化目标的实现。**
这一讲我将介绍2个简单而又行之有效的方案分别从微观上快速地找出进程内的瓶颈函数以及从宏观上找出整个分布式系统中的瓶颈组件。这样我们就可以事半功倍地去优化系统性能。
## 单机:如何通过火焰图找到性能瓶颈?
对于工作在一台主机上的进程而言有许多监控方案可用于寻找性能瓶颈。比如在Linux下你可以通过iostat命令监控磁盘状态也可以通过top命令监控CPU、内存的使用。这些方案都是在旁敲侧击着寻找性能瓶颈然而有一种**最直接有效的方式,就是从代码层面直接寻找调用次数最频繁、耗时最长的函数,通常它就是性能瓶颈。**
要完成这样的目标通常还有3个约束条件。
1. **没有代码侵入性。**比如,在函数执行的前后,分别打印一行日志记录时间,这当然能获取到函数的调用频次和执行时长,但并不可取,它的开发、维护成本太高了。
1. **覆盖到代码中的全部函数。**如果只是监控事先猜测的、有限的几个函数,往往会因为思维死角,遗漏掉真正的瓶颈函数。因此,只有监控所有函数的执行情况,并以一种全局的、直观的方式分析聚合后的监控结果,才能快速、准确地找到性能瓶颈。
1. **搭建环境的成本足够低。**我曾经使用过systemtap来监控函数的执行状况搭建这样的监控环境需要很多步骤比如要重新编译Linux内核这些过于繁琐的成本会阻碍很多应用开发者的性能优化脚步。
带着这3个约束条件最合适的方案已经呼之欲出了那就是[Brendan Gregg](https://en.wikipedia.org/wiki/Brendan_Gregg) 发明的[火焰图](http://www.brendangregg.com/flamegraphs.html),以及为火焰图提供监控数据的工具。我们先来直观感受下火焰图到底是什么样子:
<img src="https://static001.geekbang.org/resource/image/9c/d8/9ca4ff41238f067341d70edbf87c4cd8.png" alt="">
火焰图可以将监控期间涉及到的所有函数调用栈都列出来就像上图对Nginx worker进程的监控一样函数非常密集因此通常采用可缩放、可互动的[SVG矢量格式](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics)图片来展示。由于极客时间暂时还没法直接展示矢量图片,所以我把矢量图片放在了我的个人网站上,你可以点击[这个链接](http://www.taohui.pub/nginx_perf.svg)跳转到矢量图片页面。在SVG图片上你可以用Ctrl+F通过名称搜索函数而且这是支持正则表达式匹配的。你还可以点击函数方块只查看它相关的函数调用栈。
火焰图中最重要的信息就是表示函数执行时间的X轴以及表示函数调用栈的Y轴。
先来看X轴。X轴由多个方块组成每个方块表示一个函数**其长度是定时采样得到的函数调用频率,因此你可以简单粗暴地把它近似为执行时间。**需要注意的是如果函数A中调用了函数B、C、D监控采样时会形成A-&gt;B、A-&gt;C、A-&gt;D这3个函数调用栈但火焰图会将3个调用栈中表示A函数的方块合并并将B、C、D放在上一层中并以字母表顺序排列它们。这样更有助于你找到耗时最长的函数。
再来看Y轴它表示函数的调用栈。这里我们既可以看到内核API的函数调用栈也可以看到用户态函数的调用栈非常强大。如果你正在学习开源组件的源码推荐你先生成火焰图再对照图中的Y轴调用栈理解源码中函数的调用关系。注意**函数方块的颜色是随机的,并没有特别的意义,只是为了在视觉上更容易区分开。**
结合X轴和Y轴我们再来看如何使用火焰图找到性能瓶颈。
首先,火焰图很容易从全局视角,通过寻找长方块,找到调用频率最高的几个函数。
其次函数方块长有些是因为函数自身执行了很消耗CPU的代码而有些则是调用的子函数耗时长造成的。怎么区分这两种场景呢很简单**如果函数方块的长度远大于调用栈中子函数方块的长度之和那么这个函数就执行了比较耗费CPU的计算。**比如下图中执行TLS握手的ngx_ssl_handshake_handler函数自身并没有消耗CPU多少计算力。
<img src="https://static001.geekbang.org/resource/image/14/c2/14c155f5ef577fa2aa6a127145b2acc2.png" alt="">
而更新Nginx时间的ngx_time_update函数就不同它在做时间格式转换时消耗了许多CPU如下图所示
<img src="https://static001.geekbang.org/resource/image/b8/67/b8b40ab7b4d358b45e600d8721b1d867.png" alt="">
这样,我们可以直观地找到最耗时的函数,再有针对性地优化它的性能。
怎么生成火焰图呢在Linux上这非常简单因为Linux内核默认支持perf工具你可以用perf生成函数的采样数据再用FlameGraph脚本生成火焰图。当然如果你在使用Java、GoLang、Python等高级语言也可以使用各自语言生态中封装过的Profile类工具生成采样数据。这里我以最基本的perf、FlameGraph为例介绍下生成火焰图的5步流程。
首先你可以通过yum或者apt-get安装perf工具再通过git来下载FlameGraph
```
yum install perf
git clone --depth 1 https://github.com/brendangregg/FlameGraph.git
```
接着针对运行中的进程PID使用perf采样函数的调用频率对于C/C++语言,为了能够显示完整的函数栈,你需要在编译时加入-g选项如下所示
```
perf record -F 99 -p 进程PID -g --call-graph dwarf
```
上述命令行中各参数的含义,可以参见[这里](http://www.brendangregg.com/perf.html)。
再将二进制信息转换为ASCII格式的文件方便FlameGraph处理
```
perf script &gt; out.perf
```
再然后需要汇聚函数调用栈转化为FlameGraph生成火焰图的数据格式
```
FlameGraph/stackcollapse-perf.pl out.perf &gt; out.folded
```
最后一步生成SVG格式的矢量图片
```
FlameGraph/flamegraph.pl out.folded &gt; out.svg
```
需要注意,**上面的火焰图只能找到消耗CPU计算力最多的函数因此它也叫做On CPU火焰图由于CPU工作时会发热所以色块都是暖色调。**有些使用了阻塞API的代码则会导致进程休眠On CPU火焰图无法找到休眠时间最长的函数此时可以使用Off CPU火焰图它按照函数引发进程的休眠时间的长短确定函数方块的长度。由于进程休眠时不使用CPU所以色块会使用冷色调。如下图所示
<img src="https://static001.geekbang.org/resource/image/82/8f/823f2666f8f8b57714e751501f178a8f.png" alt="">
图片来源:[http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html](http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html),你可以点击[这个链接](http://www.brendangregg.com/FlameGraphs/off-mysqld-busy.svg)查看SVG图片。
生成Off CPU火焰图的步骤稍微繁琐点你可以参照[这个页面](http://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html)。关于火焰图的设计思路和详细使用方式,推荐你参考[Brendan Gregg](https://en.wikipedia.org/wiki/Brendan_Gregg) 的[这篇论文](https://queue.acm.org/detail.cfm?id=2927301)。
## 分布式系统:如何通过全链路监控找到性能瓶颈?
说完微观上性能瓶颈的定位,我们再来看宏观上的分布式系统,它通过网络把不同类型的硬件、操作系统、编程语言组合在一起,结构很复杂,因此,现在我们的目标变成寻找更大尺度的瓶颈组件。
当然与单机不同的是找到分布式系统的性能瓶颈组件后除了可以继续通过火焰图优化组件中某个进程的性能外还有下面这样3个新收益。
1. 首先,可以通过简单的扩容动作,利用负载均衡机制提升性能。
1. 其次,在云计算和微服务时代,扩容可以在分钟级时间内完成。因此,如果性能瓶颈可以实时检测到,就可以自动化地完成扩容和流量迁移。这可以提升服务器资源的利用率。
1. 最后,既然能够找到瓶颈组件,同样也能找出资源富裕的组件,这样就可以通过反向的缩容,减少服务器资源的浪费。
接下来我将从4个角度介绍分布式系统中的性能监控体系。
首先监控基于日志来做对系统的侵入性最低因此你要把格式多样化的文本日志进行结构化管理。我们知道1个分布式系统会涉及到多种编程语言这样各组件基于完全异构的中间件产生的日志格式也是五花八门如果定义一个统一的日志格式标准再要求每个组件遵照它重新开发一个版本这样代价太高基本无法实现
比较幸运的是几乎每个组件都会输出文本类型的访问日志而且它们通常包括访问对象例如URL、错误码、处理时间等共性信息。因此可以针对每个组件特定格式的文本日志写一个正则表达式将我们需要的结构化信息提取出来。这样结构化数据的提取是在组件之外的对于组件中的代码也没有影响。
其次同时搜集系统中每个组件的日志并汇总在一起这样日志数据的规模就非常可观每天可能会产生TB级别的数据因此关系型数据库并不可取因为它们为了照顾事务、关联查询等操作不具备很好的可伸缩性。同时结构化的日志源于我们必须针对某类请求、某类组件做聚合分析因此纯粹的Key/Value数据库也无法实现这类功能。因此像HBase这样的半结构化列式数据库往往是监控数据的首选落地形式。
再次我们分析日志时首先会从横向上聚合分析成百上千个组件中时延、吞吐量、错误码等数据项的统计值。这样计算力就是一个很大的问题因此通常选择支持Map Reduce算法的系统比如Hadoop进行计算并将结果以可视化的方式展现出来协助性能分析。由于计算时间比较久所以这也称为离线计算。其次会在纵向上监控一个请求在整个生命周期内各个参与组件的性能状态。因此必须设计一个请求ID能够贯穿在各个组件内使用由于分布式系统中的请求数量非常大这个ID的碰撞概率必须足够低。所以请求ID通常会同时包含时间和空间元素比如IP地址。这个方案还有个很威武的名字叫做全链路监控可以参考Google的[这篇论文](https://storage.googleapis.com/pub-tools-public-publication-data/pdf/36356.pdf)),它还可以用于组件依赖关系的优化。
最后你还得对性能瓶颈做实时监控这是实现分布式系统自动化扩容、缩容的前提。由于监控数据规模庞大所以我们通常要把流量在时间维度上分片仅对每个时间小窗口中有限的数据做快速的增量计算。像Storm、Spark、Flink都是这样的实时流计算中间件你可以基于它们完成实时数据的汇聚分析。
以上是我对分布式系统性能监控方案的一些思考。
一提到分布式系统涉及到的知识点就非常多。好在我们后续的课程对MapReduce、实时流计算等还有进一步的分析因此监控方案中对它们就一带而过了。
## 小结
这一讲,我们介绍了单机上如何通过火焰图找到性能瓶颈函数,以及在分布式系统中,如何通过全链路监控找到性能瓶颈组件。
在Linux系统中你可以用内核支持的perf工具快速地生成火焰图。其他高级编程语言生态中都有类似的Profiler工具可生成火焰图。
火焰图中可以看到函数调用栈它对你分析源码很有帮助。图中方块的长度表示函数的调用频率当采样很密集时你可以把它近似为函数的执行时长。父方块长度减去所有子方块长度的和就表示了父函数自身代码对CPU计算力的消耗。因此火焰图可以直观地找到调用次数最频繁且最耗时的函数。
除了上面介绍的On CPU火焰图你也可以用同样的工具生成Off CPU火焰图它用于找到频率引发进程休眠进而降低了性能的瓶颈函数。
对于分布式系统性能监控还有利于系统的扩容和缩容运维。搭建性能监控体系包括以下四个关键点首先要把五花八门的日志用正则表达式提取为结构化的监控数据其次用半结构化的列式数据库存放集群中的所有日志便于后续的汇聚分析第三使用统一的请求ID将各组件串联在一起并使用MapReduce算法对大量的监控数据做离线分析最后通过实时流计算框架对监控数据做实时汇聚分析。
性能监控是一个很大的话题,这节课我只希望能从不同的角度带给你思考,课后还需要你进行更深入的探索。
## 思考题
最后给你留一道练习题请你参考On CPU火焰图的制作对你熟悉的程序做一张Off CPU火焰图看看能带给你哪些新的启发期待你的总结。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,97 @@
<audio id="audio" title="20 | CAP理论怎样舍弃一致性去换取性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/d2/00e03249adac4f0c4b3dec4df91c99d2.mp3"></audio>
你好,我是陶辉。
上一讲我们介绍了如何通过监控找到性能瓶颈,从这一讲开始,我们将具体讨论如何通过分布式系统来提升性能。
在第一部分课程中,我介绍了多种提升单机处理性能的途径,然而,进程的性能必然受制于一台服务器上各硬件的处理能力上限。如果需要进一步地提升服务性能,那只有整合多台主机组成分布式系统才能办到。
然而,当多台主机通过网络协同处理用户请求时,如果主机上的进程含有数据状态,那么必然需要在多台主机之间跨网络同步数据,由于网络存在时延,也并不稳定,因此不可靠的数据同步操作将会增加请求的处理时延。
CAP理论指出当数据同时存放在多个主机上时可用性与一致性是不可兼得的。根据CAP的指导性思想我们可以通过牺牲一致性来提升可用性中的核心因素性能。当然在实践中对一致性与性能并不是非黑即白的选择而是从概率上进行不同程度的取舍。
这一讲,我们将基于分布式系统中的经典理论,从总体上看看如何设计一致性模型,通过牺牲部分数据的一致性来提升性能。
## 如何权衡性能与一致性?
首先,这节课针对的是**有状态服务**的性能优化。所谓有状态服务是指进程会在处理完请求后仍然保存着影响下次请求结果的用户数据而无状态服务则只从每个请求的输入参数中获取数据在请求处理完成后并不保存任何会话信息。因此无状态服务拥有最好的可伸缩性但涉及数据持久化时则必须由有状态服务处理这是CAP理论所要解决的问题。
什么是CAP理论呢这是2000年[University of California, Berkeley](https://en.wikipedia.org/wiki/University_of_California,_Berkeley) 的计算机教授[Eric Brewer](https://en.wikipedia.org/wiki/Eric_Brewer_(scientist))也是谷歌基础设施VP提出的理论。所谓CAP是以下3个单词的首字母缩写它们都是分布式系统最核心的特性
- **C**onsistency一致性
- **A**vailability可用性
- **P**artition tolerance分区容错性
我们通过以下3张示意图快速理解下这3个词的意义。下图中N1、N2两台主机上运行着A进程和B进程它们操作着同一个用户的数据数据的初始值是V0这里N1和N2主机就处于不同的Partition分区中如下所示
<img src="https://static001.geekbang.org/resource/image/74/f4/744834f9a5dd04244e5f83719fb3f6f4.jpg" alt="">
正常情况下当用户请求到达N1主机上的A进程并将数据V0修改为V1后A进程将会把这一修改行为同步到N2主机上的B进程最终N1、N2上的数据都是V1这就保持了系统的Consistency一致性。
<img src="https://static001.geekbang.org/resource/image/23/25/23ab7127cbc89bcbe83c2a3669d66125.jpg" alt="">
然而一旦N1和N2之间网络异常数据同步行为就会失败。这时N1和N2之间数据不一致如果我们希望在分区间网络不通的情况下N2能够继续为用户提供服务就必须容忍数据的不一致此时系统的Availability可用性更高系统的并发处理能力更强比如Cassandra数据库。
<img src="https://static001.geekbang.org/resource/image/a7/fb/a7302dc2491229a019aa27f043ba08fb.jpg" alt="">
反之如果A、B进程一旦发现数据同步失败那么B进程自动拒绝新请求仅由A进程独立提供服务那么虽然降低了系统的可用性但保证了更强的一致性比如MySQL的主备同步模式。
这就是CAP中三者只能取其二的简要示意对于这一理论2002年MIT的[Seth Gilbert](http://lpd.epfl.ch/sgilbert/) 、 [Nancy Lynch](http://people.csail.mit.edu/lynch/) 在[这篇论文](https://dl.acm.org/doi/pdf/10.1145/564585.564601)中,证明了这一理论。当然,[可用性](https://en.wikipedia.org/wiki/High_availability)是一个很大的概念,它描述了分布式系统的持续服务能力,如下表所示:
<img src="https://static001.geekbang.org/resource/image/0c/df/0c11b949c35e4cce86233843ccb152df.jpg" alt="">
当用户、流量不断增长时,系统的性能将变成衡量可用性的关键因素。当我们希望拥有更强的性能时,就不得不牺牲数据的一致性。当然,一致性并不是只有是和否这两种属性,我们既可以从时间维度上设计短暂不一致的同步模型,也可以从空间维度上为不含有因果、时序关系的用户数据设计并发模型。
在学术上通常会按照2个维度对[一致性模型](https://en.wikipedia.org/wiki/Consistency_model#Client-centric_consistency_models%5B19%5D)分类。首先是从数据出发设计出的一致性模型,比如顺序一致性必须遵照读写操作的次序来保持一致性,而弱一些的因果一致性则允许不具备因果关系的读写操作并发执行。其次是从用户出发设计出的一致性模型,比如单调读一致性保证客户端不会读取到旧值,而单调写一致性则保证写操作是串行的。
实际工程中一致性与可用性的边界会模糊很多,因此又有了[最终一致性](https://en.wikipedia.org/wiki/Eventual_consistency)这样一个概念这个“最终”究竟是多久将由业务特性、网络故障率等因素综合决定。伴随最终一致性的是BASE理论
- **B**asically **A**vailable基本可用性
- **S**oft state软状态
- **E**ventually consistent最终一致性
BASE与CAP一样并没有很精确的定义它们最主要的用途是从大方向上给你指导性的思想。接下来我们看看如何通过最终一致性来提升性能。
## 怎样舍弃一致性提升性能?
服务的性能,主要体现在请求的时延和系统的并发性这两个方面,而它们都与上面提到的最终一致性有关。我通常会把分布式系统分为纵向、横向两个维度,其中**纵向是请求的处理路径,横向则是同类服务之间的数据同步路径。**这样,在纵向上在离客户端更近的位置增加数据的副本,并把它存放在处理速度更快的物理介质上,就可以作为缓存降低请求的时延;而在横向上对数据增加副本,并在这些主机间同步数据,这样工作在数据副本上的进程也可以同时对客户端提供服务,这就增加了系统的并发性,如下图所示:
<img src="https://static001.geekbang.org/resource/image/5a/06/5a6dbac922c500eea108d374dccc6406.png" alt="">
我们先来看纵向上的缓存,是如何在降低请求处理时延时,保持最终一致性的。
缓存可以由读、写两个操作触发更新,[[第15课]](https://time.geekbang.org/column/article/242667) 介绍过的HTTP私有缓存就是工作在浏览器上基于读操作触发的缓存。
<img src="https://static001.geekbang.org/resource/image/9d/ab/9dea133d832d8b7ab642bb74b48502ab.png" alt="">
工作在代理服务器上的HTTP共享缓存也是由读操作触发的。这些缓存与源服务器上的数据最终是一致的但在CacheControl等HTTP头部指定的时间内缓存完全有可能与源数据不同这就是牺牲一致性来提升性能的典型例子。
事实上也有很多以写操作触发缓存更新的设计它们通常又分为write back和write through两种模式。其中**write back牺牲了更多的一致性但带来了更低的请求时延。<strong>比如[[第4课]](https://time.geekbang.org/column/article/232676) 介绍过的Linux磁盘高速缓存就采用了write back这种设计它虽然是单机内的一种缓存设计但在分布式系统中缓存的设计方式也是一样的。而**write through会在更新数据成功后再更新缓存虽然带来了很好的一致性但写操作的时延会更久</strong>,如下图所示:
<img src="https://static001.geekbang.org/resource/image/de/ac/de0ed171a392b63a87af28b9aa6ec7ac.png" alt="">
write through的一致性非常好它常常是我们的首选设计。然而一旦缓存到源数据的路径很长、延时很高的时候就值得考虑write back模式此时一致性模型虽然复杂了许多但可以带来显著的性能提升。比如机械磁盘相对内存延时高了很多因此磁盘高速缓存会采用write back模式。
虽然缓存也可以在一定程度上增加系统的并发处理连接,但这更多是缘于缓存采用了更快的算法以及存储介质带来的收益。从水平方向上,在更多的主机上添加数据副本,并在其上用新的进程提供服务,这才是提升系统并发处理能力最有效的手段。此时,进程间同步数据主要包含两种方式:同步方式以及异步方式,前者会在每个更新请求完成前,将数据成功同步到每个副本后,请求才会处理完成,而后者则会在处理完请求后,才开始异步地同步数据到副本中,如下图所示:
<img src="https://static001.geekbang.org/resource/image/d7/f6/d770cf24f61d671ab1f0c1a6170627f6.png" alt="">
同步方式下系统的一致性最好但由于请求的处理时延包含了副本间的通讯时长所以性能并不好。而异步方式下系统的一致性要差特别是在主进程宕机后副本上的进程很容易出现数据丢失但异步方式的性能要好得多这不只由于更新请求的返回更快而且异步化后主进程可以基于合并、批量操作等技巧进行效率更高的数据同步。比如MySQL的主备模式下默认就使用异步方式同步数据当然你也可以改为同步方式包括Full-synchronous Replication同步所有数据副本后请求才能返回以及Semi-synchronous Replication仅成功同步1个副本后请求就可以返回两种方式。
当然在扩容、宕机恢复等场景下副本之间的数据严重不一致如果仍然基于单个操作同步数据时间会很久性能很差。此时应结合定期更新的Snapshot快照以及实时的Oplog操作日志协作同步数据这样效率会高很多。其中快照是截止时间T0时所有数据的状态而操作日志则是T0时间到当下T1之间的所有更新操作这样副本载入快照恢复至T0时刻的数据后再通过有限的操作日志与主进程保持一致。
## 小结
这一讲我们介绍了分布式系统中的CAP理论以及根据CAP如何通过一致性来换取性能。
CAP理论指出可用性、分区容错性、一致性三者只能取其二因此当分布式系统需要服务更多的用户时只能舍弃一致性换取可用性中的性能因子。当然性能与一致性并不是简单的二选一而是需要你根据网络时延、故障概率设计出一致性模型在提供高性能的同时保持时间、空间上可接受的最终一致性。
具体的设计方法可以分为纵向上添加缓存横向上添加副本进程两种做法。对于缓存的更新write through模式保持一致性更容易但写请求的时延偏高而一致性模型更复杂的write back模式时延则更低适用于性能要求很高的场景。
提升系统并发性可以通过添加数据副本,并让工作在副本上的进程同时对用户提供服务。副本间的数据同步是由写请求触发的,其中包括同步、异步两种同步方式。异步方式的最终一致性要差一些,但写请求的处理时延更快。在宕机恢复、系统扩容时,采用快照加操作日志的方式,系统的性能会好很多。
## 思考题
最后留给你一道讨论题当数据副本都在同一个IDC内部时网络时延很低带宽大而且近乎免费使用。然而一旦数据副本跨IDC后特别是IDC之间物理距离很遥远那么网络同步就变得很昂贵。这两种不同场景下你是如何考虑一致性模型的它与性能之间有多大的影响期待你的分享。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,103 @@
<audio id="audio" title="21 | AKF立方体怎样通过可扩展性来提高性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/41/0d/415yy34cdbfd00e6df7a974e24d5a70d.mp3"></audio>
你好,我是陶辉。
上一讲我们谈到,调低一致性可以提升有状态服务的性能。这一讲我们扩大范围,结合无状态服务,看看怎样提高分布式系统的整体性能。
当你接收到运维系统的短信告警,得知系统性能即将达到瓶颈,或者会议上收到老板兴奋的通知,接下来市场开缰拓土,业务访问量将要上一个大台阶时,一定会马上拿起计算器,算算要加多少台机器,系统才能扛得住新增的流量。
然而,有些服务虽然可以通过加机器提升性能,但可能你加了一倍的服务器,却发现系统的吞吐量没有翻一倍。甚至有些服务无论你如何扩容,性能都没有半点提升。这缘于我们扩展分布式系统的方向发生了错误。
当我们需要分布式系统提供更强的性能时,该怎样扩展系统呢?什么时候该加机器?什么时候该重构代码?扩容时,究竟该选择哈希算法还是最小连接数算法,才能有效提升性能?
在面对Scalability可伸缩性问题时我们必须有一个系统的方法论才能应对日益复杂的分布式系统。这一讲我将介绍AKF立方体理论它定义了扩展系统的3个维度我们可以综合使用它们来优化性能。
## 如何基于AKF X轴扩展系统
AKF立方体也叫做[scala cube](https://en.wikipedia.org/wiki/Scale_cube)它在《The Art of Scalability》一书中被首次提出旨在提供一个系统化的扩展思路。AKF把系统扩展分为以下三个维度
- X轴直接水平复制应用进程来扩展系统。
- Y轴将功能拆分出来扩展系统。
- Z轴基于用户信息扩展系统。
如下图所示:
<img src="https://static001.geekbang.org/resource/image/61/aa/61633c7e7679fd10b915494b72abb3aa.jpg" alt="">
我们日常见到的各种系统扩展方案都可以归结到AKF立方体的这三个维度上。而且我们可以同时组合这3个方向上的扩展动作使得系统可以近乎无限地提升性能。为了避免对AKF的介绍过于抽象下面我用一个实际的例子带你看看这3个方向的扩展到底该如何应用。
假定我们开发一个博客平台用户可以申请自己的博客帐号并在其上发布文章。最初的系统考虑了MVC架构将数据状态及关系模型交给数据库实现应用进程通过SQL语言操作数据模型经由HTTP协议对浏览器客户端提供服务如下图所示
<img src="https://static001.geekbang.org/resource/image/cd/e0/cda814dcfca8820c81024808fe96b1e0.jpg" alt="">
在这个架构中处理业务的应用进程属于无状态服务用户数据全部放在了关系数据库中。因此当我们在应用进程前加1个负载均衡服务后就可以通过部署更多的应用进程提供更大的吞吐量。而且初期增加应用进程RPS可以获得线性增长很实用。
<img src="https://static001.geekbang.org/resource/image/d7/62/d7f75f3f5a5e6f8a07b1c47501606962.png" alt="">
这就叫做沿AKF X轴扩展系统。这种扩展方式最大的优点就是开发成本近乎为零而且实施起来速度快在搭建好负载均衡后只需要在新的物理机、虚拟机或者微服务上复制程序就可以让新进程分担请求流量而且不会影响事务Transaction的处理。
当然AKF X轴扩展最大的问题是只能扩展无状态服务当有状态的数据库出现性能瓶颈时X轴是无能为力的。例如当用户数据量持续增长关系数据库中的表就会达到百万、千万行数据SQL语句会越来越慢这时可以沿着AKF Z轴去分库分表提升性能。又比如当请求用户频率越来越高那么可以把单实例数据库扩展为主备多实例沿Y轴把读写功能分离提升性能。下面我们先来看AKF Y轴如何扩展系统。
## 如何基于AKF Y轴扩展系统
当数据库的CPU、网络带宽、内存、磁盘IO等某个指标率先达到上限后系统的吞吐量就达到了瓶颈此时沿着AKF X轴扩展系统是没有办法提升性能的。
在现代经济中更细分、更专业的产业化、供应链分工可以给社会带来更高的效率而AKF Y轴与之相似当遇到上述性能瓶颈后拆分系统功能使得各组件的职责、分工更细也可以提升系统的效率。比如当我们将应用进程对数据库的读写操作拆分后就可以扩展单机数据库为主备分布式系统使得主库支持读写两种SQL而备库只支持读SQL。这样主库可以轻松地支持事务操作且它将数据同步到备库中也并不复杂如下图所示
<img src="https://static001.geekbang.org/resource/image/86/74/865885bb7213e62b8e1b715d85c9a974.png" alt="">
当然上图中如果读性能达到了瓶颈我们可以继续沿着AKF X轴用复制的方式扩展多个备库提升读SQL的性能可见AKF多个轴完全可以搭配着协同使用。
拆分功能是需要重构代码的它的实施成本比沿X轴简单复制扩展要高得多。在上图中通常关系数据库的客户端SDK已经支持读写分离所以实施成本由中间件承担了这对我们理解Y轴的实施代价意义不大所以我们再来看从业务上拆分功能的例子。
当这个博客平台访问量越来越大时,一台主库是无法扛住所有写流量的。因此,基于业务特性拆分功能,就是必须要做的工作。比如,把用户的个人信息、身份验证等功能拆分出一个子系统,再把文章、留言发布等功能拆分到另一个子系统,由无状态的业务层代码分开调用,并通过事务组合在一起,如下图所示:
<img src="https://static001.geekbang.org/resource/image/3b/af/3bba7bc19965bb9b01c058e67a6471af.png" alt="">
这样每个后端的子应用更加聚焦于细分的功能它的数据库规模会变小也更容易优化性能。比如针对用户登录功能你可以再次基于Y轴将身份验证功能拆分用Redis等服务搭建一个基于LRU算法淘汰的缓存系统快速验证用户身份。
然而沿Y轴做功能拆分实施成本非常高需要重构代码并做大量测试工作上线部署也很复杂。比如上例中要对数据模型做拆分如同一个库中的表拆分到多个库中或者表中的字段拆到多张表中设计组件之间的API交互协议重构无状态应用进程中的代码为了完成升级还要做数据迁移等等。
解决数据增长引发的性能下降问题除了成本较高的AKF Y轴扩展方式外沿Z轴扩展系统也很有效它的实施成本更低一些下面我们具体看一下。
## 如何基于AKF Z轴扩展系统
不同于站在服务角度扩展系统的X轴和Y轴AKF Z轴则从用户维度拆分系统它不仅可以提升数据持续增长降低的性能还能基于用户的地理位置获得额外收益。
仍然以上面虚拟的博客平台为例当注册用户数量上亿后无论你如何基于Y轴的功能去拆分表即“垂直”地拆分表中的字段都无法使得关系数据库单个表的行数在千万级以下这样表字段的B树索引非常庞大难以完全放在内存中最后大量的磁盘IO操作会拖慢SQL语句的执行。
这个时候关系数据库最常用的分库分表操作就登场了它正是AKF沿Z轴拆分系统的实践。比如已经含有上亿行数据的User用户信息表可以分成10个库每个库再分成10张表利用固定的哈希函数就可以把每个用户的数据映射到某个库的某张表中。这样单张表的数据量就可以降低到1百万行左右如果每个库部署在不同的服务器上具体的部署方式视访问吞吐量以及服务器的配置而定它们处理的数据量减少了很多却可以独占服务器的硬件资源性能自然就有了提升。如下图所示
<img src="https://static001.geekbang.org/resource/image/dc/83/dc9e29827c26f89ff3459b5c99313583.png" alt="">
分库分表是关系数据库中解决数据增长压力的最有效办法但分库分表同时也导致跨表的查询语句复杂许多而跨库的事务几乎难以实现因此这种扩展的代价非常高。当然如果你使用的是类似MySQL这些成熟的关系数据库整个生态中会有厂商提供相应的中间件层使用它们可以降低Z轴扩展的代价。
再比如最开始我们采用X轴复制扩展的服务它们的负载均衡策略很简单只需要选择负载最小的上游服务器即可比如RoundRobin或者最小连接算法都可以达到目的。但若上游服务器通过Y轴扩展开启了缓存功能那么考虑到缓存的命中率就必须改用Z轴扩展的方式基于用户信息做哈希规则下的新路由尽量将同一个用户的请求命中相同的上游服务器才能充分提高缓存命中率。
Z轴扩展还有一个好处就是可以充分利用IDC与用户间的网速差选择更快的IDC为用户提供高性能服务。网络是基于光速传播的当IDC跨城市、国家甚至大洲时用户访问不同IDC的网速就会有很大差异。当然同一地域内不同的网络运营商之间也会有很大的网速差。
例如你在全球都有IDC或者公有云服务器时就可以通过域名为当地用户就近提供服务这样性能会高很多。事实上CDN技术就基于IP地址的位置信息就近为用户提供静态资源的高速访问。
下图中我使用了2种Z轴扩展系统的方式。首先是基于客户端的地理位置选择不同的IDC就近提供服务。其次是将不同的用户分组比如免费用户组与付费用户组这样在业务上分离用户群体后还可以有针对性地提供不同水准的服务。
<img src="https://static001.geekbang.org/resource/image/35/3b/353d8515d40db25eebee23889a3ecd3b.png" alt="">
沿AKF Z轴扩展系统可以解决数据增长带来的性能瓶颈也可以基于数据的空间位置提升系统性能然而它的实施成本比较高尤其是在系统宕机、扩容时一旦路由规则发生变化会带来很大的数据迁移成本[第24讲] 我将要介绍的一致性哈希算法,其实就是用来解决这一问题的。
## 小结
这一讲我们介绍了如何基于AKF立方体的X、Y、Z三个轴扩展系统提升性能。
X轴扩展系统时实施成本最低只需要将程序复制到不同的服务器上运行再用下游的负载均衡分配流量即可。X轴只能应用在无状态进程上故无法解决数据增长引入的性能瓶颈。
Y轴扩展系统时实施成本最高通常涉及到部分代码的重构但它通过拆分功能使系统中的组件分工更细因此可以解决数据增长带来的性能压力也可以提升系统的总体效率。比如关系数据库的读写分离、表字段的垂直拆分或者引入缓存都属于沿Y轴扩展系统。
Z轴扩展系统时实施成本也比较高但它基于用户信息拆分数据后可以在解决数据增长问题的同时基于地理位置就近提供服务进而大幅度降低请求的时延比如常见的CDN就是这么提升用户体验的。但Z轴扩展系统后一旦发生路由规则的变动导致数据迁移时运维成本就会比较高。
当然X、Y、Z轴的扩展并不是孤立的我们可以同时应用这3个维度扩展系统。分布式系统非常复杂AKF给我们提供了一种自上而下的方法论让我们能够针对不同场景下的性能瓶颈以最低的成本提升性能。
## 思考题
最后给你留一道思考题我们在谈到Z轴扩展时比如关系数据库的分库分表提到了基于哈希函数来设置路由规则请结合[[第3讲]](https://time.geekbang.org/column/article/232351) 的内容谈谈你认为应该如何设计哈希函数才能使它满足符合Z轴扩展的预期期待你的总结。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,103 @@
<audio id="audio" title="22 | NWR算法如何修改读写模型以提升性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/d5/da638bf0ab1924f7014b1259334f43d5.mp3"></audio>
你好,我是陶辉。
前两讲我们介绍数据库的扩展时写请求仍然在操作中心化的Master单点这在很多业务场景下都是不可接受的。这一讲我将介绍对于无单点的去中心化系统非常有用的NWR算法它可以灵活地平衡一致性与性能。
最初我们仅在单机上部署数据库一旦性能到达瓶颈我们可以基于AKF Y轴将读写分离这样多个Slave从库将读操作分流后写操作就可以独享Master主库的全部性能。然而主库作为中心化的单点一旦宕机未及时同步到从库的数据就有可能丢失。而且这一架构下主库的故障还会导致整个系统瘫痪。
去中心化系统中没有“Master主库”这一概念数据存放在多个Replication冗余节点上且这些节点间地位均等所以没有单点问题。为了保持强一致性系统可以要求修改数据时必须同时写入所有冗余节点才能向客户端返回成功。但这样系统的可用性一定很成问题毕竟大规模分布式系统中出现故障是常态写入全部节点的操作根本无法容错任何1个节点宕机都会造成写操作失败。而且同步节点过多也会导致写操作性能低下。
NWR算法提供了一个很棒的读写模型可以解决上述问题。这里的“NWR”是指在去中心化系统中将1份数据存放在N个节点上每次操作时写W个节点、读R个节点只要调整W、R与N的关系就能动态地平衡一致性与性能。NWR在NoSQL数据库中有很广泛的应用比如Amazon的Dynamo和开源的Cassandra这些数据库往往跨越多个IDC数据中心包含成千上万个物理机节点适用于海量数据的存储与处理。
这一讲我们将介绍NWR算法的原理包括它是怎样调整读写模型来提升性能的以及Cassandra数据库是如何使用NWR算法的。
## 从鸽巢原理到NWR算法
NWR算法是由鸽巢原理得来的如果10只鸽子放入9个鸽巢那么有1个鸽巢内至少有2只鸽子这就是鸽巢原理如下图所示
[<img src="https://static001.geekbang.org/resource/image/83/17/835a454f1ecb8d6edb5a1c2059082d17.jpg" alt="" title="图片来源https://zh.wikipedia.org/wiki/%E9%B4%BF%E5%B7%A2%E5%8E%9F%E7%90%86">](https://zh.wikipedia.org/wiki/%E9%B4%BF%E5%B7%A2%E5%8E%9F%E7%90%86)
你可以用反证法证明它。鸽巢原理虽然简单,但它有许多很有用的推论。比如[[第3课]](https://time.geekbang.org/column/article/232351) 介绍了很多解决哈希表冲突的方案,那么,哈希表有没有可能完全不出现冲突呢?**鸽巢原理告诉我们只要哈希函数输入主键的值范围大于输出索引出现冲突的概率就一定大于0只要存放元素的数量超过哈希桶的数量就必然会发生冲突。**
基于鸽巢原理David K. Gifford在1979年首次提出了[Quorum](https://en.wikipedia.org/wiki/Quorum_(distributed_computing)) 算法(参见《[Weighted Voting for Replicated Data](https://dl.acm.org/doi/epdf/10.1145/800215.806583)》论文解决去中心化系统冗余数据的一致性问题。而Quorum算法提出如果冗余数据存放在N个节点上且每次写操作成功写入W个节点其他N - W个节点将异步地同步数据而读操作则从R个节点中选择并读出正确的数据只要确保W + R &gt; N同1条数据的读、写操作就不能并发执行这样客户端就总能读到最新写入的数据。特别是当W &gt; N/2时同1条数据的修改必然是顺序执行的。这样分布式系统就具备了强一致性这也是NWR算法的由来。
比如若N为3那么设置W和R为2时在保障系统强一致性的同时还允许3个节点中1个节点宕机后系统仍然可以提供读、写服务这样的系统具备了很高的可用性。当然R和W的数值并不需要一致如何调整它们取决于读、写请求数量的比例。比如当N为5时如果系统读多写少时可以将W设为4而R设为2这样读操作的性能会更好。
NWR算法最早应用在Amazon推出的[Dynamo](https://en.wikipedia.org/wiki/Dynamo_(storage_system)) 数据库中你可以参见2007年Amazon发表的[《Dynamo: Amazons Highly Available Key-value Store》](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf)论文。2008年Dynamo的作者Avinash Lakshman跳槽到FaceBook开发了Dynamo的开源版数据库[Cassandra](https://zh.wikipedia.org/wiki/Cassandra)它是目前最流行的NoSQL数据库之一在Apple、Netflix、360等公司得到了广泛的应用。想必你对NWR算法的很多细节并不清楚那么接下来我们以Cassandra为例看看NWR是如何应用在实际工程中的。
## Cassandra数据库是如何使用NWR算法的
1个Cassandra分布式系统可以由多个IDC数据中心、数万个服务器节点构成这些节点间使用RPC框架通信由于Cassandra推出时gRPC参见[[第18课]](https://time.geekbang.org/column/article/247812)还没有诞生因此它使用的是性能相对较低的Thrift RPC框架Thrift的优点是支持的开发语言更多。同时Cassandra虽然使用宽列存储模型每行最多可以包含[20亿列](https://docs.datastax.com/en/cql-oss/3.x/cql/cql_reference/refLimits.html)数据),但**数据的分布是基于行Key进行的**它和Dynamo一样使用了一致性哈希算法将Key对应的数据存储在多个节点中。关于一致性哈希算法我们会在 [第24课] 再详细介绍。
Cassandra对客户端提供一种类SQL的[CQL](https://cassandra.apache.org/doc/latest/cql/index.html) 语言你可以使用下面这行CQL语句设定数据存储的冗余节点个数也就是NWR算法中的N也称为Replication Factor
```
CREATE KEYSPACE excalibur
WITH REPLICATION = {'class' : 'NetworkTopologyStrategy', 'dc1' : 3};
```
上面这行CQL语句设置了每行数据在数据中心DC1中存储3份冗余即N = 3接下来我们通过下面的CQL语句将读R、写W的节点数都设置为1
```
cqlsh&gt; CONSISTENCY ONE
Consistency level set to ONE.
cqlsh&gt; CONSISTENCY
Current consistency level is ONE.
```
**此时Cassandra的性能最高但达成最终一致性的耗时最长丢数据风险也最大。**如果业务上对丢失少量数据不太在意可以采用这种模型。此时修改数据时客户端会并发地向3个存储节点写入数据但只要1个节点返回成功Cassandra就会向客户端返回写入成功如下图所示
[<img src="https://static001.geekbang.org/resource/image/74/1d/742a430b92bb3b235294805b7073991d.png" alt="" title="该图片及以下图片来源https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml">](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml)
上图中的系统由12个主机节点构成由于数据采用一致性哈希算法分片故构成了一个节点环。其中本次写入的数据被分布到1、3、6这3个节点中存储。客户端可以随机连接到系统中的任何一个节点访问Cassandra此时该节点被称为Coordinator Node由它根据NWR的值来选择一致性模型访问存储节点。
再来看读取数据的流程。下图中作为Coordinator Node的节点10首先试图读取节点1中的数据但发现节点1已经宕机于是改选节点6并获取到数据由于R = 1于是立刻向客户端返回成功。
<img src="https://static001.geekbang.org/resource/image/50/d6/5038a63ce8a5cd23fcb6ba2e14b59cd6.jpg" alt="">
如果我们将R、W都设置成2这就满足了R + W &gt; N(3)的场景此时系统具备了强一致性。客户端读写数据时必须有2个节点返回才算操作成功。比如下图中读取数据时只有接收到节点1、节点6的返回操作才算成功。
<img src="https://static001.geekbang.org/resource/image/8d/0f/8dc00f0a82676cb54d21880e7b60c20f.jpg" alt="">
上图中的蓝色线叫做Read repair如果节点3上的数据不一致那么本次读操作可以将它修复为正确的数据。说完正常场景我们再来看当一个节点出现异常时NWR是如何保持强一致性的。
下图中客户端1在第2步同时向3个存储节点写入了数据由于节点1、3返回成功所以写入操作实际已经完成了但是节点6由于网络故障却一直没有收到Coordinator Node发来的写入操作。在强一致性的约束下客户端2在第5步发起的读请求必须能获取到第2步写入的数据。然而客户端2连接的Coordinator Node与客户端1不同它选择了节点3和节点6这两个节点上的数据并不一致。**根据不同的timestamp时间戳Coordinator Node发现节点3上的数据才是最后写入的数据因此选择其上的数据返回客户端。这也叫Last-Write-Win策略。**
[<img src="https://static001.geekbang.org/resource/image/4b/fa/4bc3308298395b7a57d9d540a79aa7fa.jpg" alt="" title="图片来源https://blog.scottlogic.com/2017/10/06/cassandra-eventual-consistency.html">](https://blog.scottlogic.com/2017/10/06/cassandra-eventual-consistency.html)
Cassandra提供了一个简单的方法用于设置读写节点数量都过半满足强一致性的要求如下所示
```
cqlsh&gt; CONSISTENCY QUORUM
Consistency level set to QUORUM.
cqlsh&gt; CONSISTENCY
Current consistency level is QUORUM.
```
最后我们再来看看多数据中心的部署方式。下图中2个数据中心各设置N = 3其中R、W则采用QUORUM一致性模型。当客户端发起写请求到达节点10这个Coordinator Node后它选择本IDC Alpha的1、3、6节点存储数据其中节点3、6返回成功后IDC Alpha便更新成功。同时找到另一IDC Beta的节点11存储数据并由节点11将数据同步给节点4和节点8。其中只要节点4返回成功IDC Beta也就成功更新了数据此时Coordinator Node会向客户端返回写入成功。
<img src="https://static001.geekbang.org/resource/image/5f/00/5fe2fa80bb20e04d25c41ed5986c0c00.jpg" alt="">
读取数据时这2个IDC内必须由4个存储节点返回数据才满足QUORUM一致性的要求。下图中Coordinator Node获取到了IDC Alpha中节点1、3、6的返回以及IDC Beta中节点11的返回就可以基于timestamp时间戳选择最新的数据返回客户端。而且Coordinator Node会并发地发起Read repair试图修复IDC Beta中可能存在不一致的节点4和8。
<img src="https://static001.geekbang.org/resource/image/59/19/59564438445fb26d2e8993a50a23df19.jpg" alt="">
Cassandra还有许多一致性模型比如LOCAL_QUORUM只要求本地IDC内有多数节点响应即可而EACH_QUORUM则要求每个IDC内都必须有多数节点返回成功注意这与上图中IDC Alpha中有3个节点返回而IDC Beta则只有1个节点返回的QUORUM是不同的。你可以从[这个页面](https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml/dmlConfigConsistency.html)找到Cassandra支持的所有一致性模型但无论如何变化都只是在引入数据中心、机架等概念后局部性地调节NWR而已。
## 小结
这一讲我们介绍了鸽巢原理以及由此推导出的NWR算法并以流行的NoSQL数据库Cassandra为例介绍了NWR在分布式系统中的实践。
当鸽子的数量超过了鸽巢后就要注定某一个鸽巢内一定含有两只以上的鸽子同样的道理只要读、写操作涉及的节点超过半数就注定读写操作总包含一个含有正确数据的节点。NWR算法将这一原理一般化为只要读节点数R + 写节点数W &gt; 存储节点数N特别是W &gt; N/2时就能使去中心的分布式系统获得强一致性。
支持上万节点的Cassandra数据库就使用了NWR算法来保持一致性。当然Cassandra支持多种一致性模型当你需要更强劲的性能时你可以令R + W &lt; N当业务变化导致需要增强系统的一致性时你可以实时地修改R、W。Cassandra也支持跨数据中心部署此时的一致性模型更为复杂但仍然将NWR算法作为实现基础。
## 思考题
最后给你留一道讨论题。你还知道哪些有状态服务使用了NWR算法吗它与NWR在Cassandra中的应用有何不同欢迎你在留言区中分享也期待你能从大家的留言中总结出更一般化的规律。
感谢你的阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,150 @@
<audio id="audio" title="23 | 负载均衡选择Nginx还是OpenResty" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/60/dce2221dc30eeaabba4852a18b7f5960.mp3"></audio>
你好,我是陶辉。
在[[第21讲]](https://time.geekbang.org/column/article/252741) 介绍AKF立方体时我们讲过只有在下游添加负载均衡后才能沿着X、Y、Z三个轴提升性能。这一讲我们将介绍最流行的负载均衡Nginx、OpenResty看看它们是如何支持AKF扩展体系的。
负载均衡通过将流量分发给新增的服务器提升了系统的性能。因此我们对负载均衡最基本的要求就是它的吞吐量要远大于上游的应用服务器否则扩展能力会极为有限。因此目前性能最好的Nginx以及在Nginx之上构建的OpenResty通常是第一选择。
系统接入层的负载均衡常通过Waf防火墙承担着网络安全职责系统内部的负载均衡则通过权限、流量控制等功能承担着API网关的职责CDN等边缘节点上的负载均衡还会承担边缘计算的任务。如果负载均衡不具备高度开放的设计或者推出时间短、社区不活跃**我们就无法像搭积木一样,从整个生态中低成本地构建出符合需求的负载均衡。**
很幸运的是Nginx完全符合上述要求它性能一流而且非常稳定。从2004年诞生之初Nginx的模块化设计就未改变过这样16年来累积下的各种Nginx模块都可以复用。它的[2-clause BSD-like license](https://opensource.org/licenses/BSD-2-Clause) 源码许可协议极其开放即使修改源码后仍然可作商业用途因此Nginx之上延伸出了TEngine、OpenResty、Kong等生态这大大扩展了Nginx的能力边界。
接下来我们就以Nginx以及建立了Lua语言生态的OpenResty为例看看负载均衡是怎样扩展系统的以及Nginx和同源的OpenResty有何不同。
## 负载均衡是如何扩展系统提升性能的?
通过AKF立方体X轴扩展系统时负载均衡只需要能够透传协议并选择负载最低的上游应用作为流量分发对象即可。这样三层网络层、四层传输层负载均衡都可用于扩展系统甚至在单个局域网内你还可以使用二层数据链路层负载均衡。其中分发流量的路由算法既可以使用RoundRobin轮转算法也可以基于TCP连接或者UDP Session使用最少连接数算法如下图所示
<img src="https://static001.geekbang.org/resource/image/a6/yd/a614d25af06a6439874a12d7748afyyd.jpg" alt="">
然而基于AKF Y轴扩展系统时负载均衡必须根据功能来分发请求也就是说它必须解析完应用层协议才能明白这是什么请求。因此如LVS这样工作在三层和四层的负载均衡就无法满足需求了我们需要Nginx这样的七层应用层负载均衡它能够从请求中获取到描述功能的关键信息并以此为依据路由请求。比如当HTTP请求中的URL描述功能时Nginx就可以用location匹配URL再基于location来路由请求如下图所示
<img src="https://static001.geekbang.org/resource/image/19/73/198619be633e7db39e6c8c817078b673.jpg" alt="">
基于AKF Z轴扩展时如果只是使用了网络报文中的源IP地址那么三层、四层负载均衡都能胜任。然而如果需要帐号、访问对象等用户信息扩展系统仍然只能使用七层负载均衡从请求中获得。比如Nginx可以通过$变量获取到URL参数或者HEADER头部的值再以此作为路由算法的输入参数。
因此,**七层负载均衡是分布式系统提升性能的必备工具。**除了基于各种路由策略分发流量提高性能及可用性如宕机迁移负载均衡还需要完成上、下游协议间的适配、转换。例如考虑到信息安全跑在公网上的外部协议常基于TLS/SSL协议而在效率优先的企业内网中一般不会使用大幅降低性能的TLS协议因此负载均衡需要拥有卸载或者装载TLS层的能力。
再比如下游客户端多样且难以保持一致比如IE6这个古董浏览器仍然存在于当下的互联网中因此常使用HTTP协议与服务器通讯而上游组件则依据开发团队或者系统架构的特点会选择CGI、uWSGI、gRPC等协议这样负载均衡还得拥有转换各种协议的功能。Nginx可以通过反向代理模块轻松适配各类协议如下所示通过stream模块Nginx也支持四层负载均衡
<img src="https://static001.geekbang.org/resource/image/ey/93/eyy0dfeeb4783de585789f1c5b768393.jpg" alt="">
从性能角度Nginx支持C10M级别的并发连接其原因你可以参考本专栏第1、2部分的内容。从功能角度良好的模块化设计使得Nginx可以完成各类协议的适配不只包括第3部分课程介绍过的通用协议甚至支持Redis、MySQL等专有协议。因此Nginx是目前最好用的负载均衡。
## Nginx上为什么可以执行Lua代码
OpenResty也非常流行其实它就是Nginx只是通过扩展的C模块支持在Nginx中嵌入Lua语言这样Lua模块构建出的生态就可以与C模块协作使用大幅度提升了开发效率。我们先来看下OpenResty与Nginx之间的关系。
OpenResty源代码由**官方Nginx、第三方C模块、Lua语言模块以及一些工具脚本**构成。编译Nginx时OpenResty将自己的第三方C模块按照Nginx的规则添加到可执行文件中包括ngx_http_lua_module和ngx_stream_lua_module这两个C模块它们允许Lua语言运行在Nginx进程中如下图所示
<img src="https://static001.geekbang.org/resource/image/a7/8e/a73f1f14bec13dddd497aeb4a5393b8e.jpg" alt="">
**Lua模块既能够享受Nginx的高性能又通过“协程”**(参见[[第5讲]](https://time.geekbang.org/column/article/233629)**、Lua脚本语言提高了开发效率**这也是OpenResty最大的优点。我们先来看看Lua语言是怎么嵌入到Nginx中的。
**Nginx在进程启动、处理请求时提供了许多钩子函数允许第三方C模块将其代码放在这些钩子函数中执行。同时Nginx还允许C模块自行解析nginx.conf中出现的配置项。**这种架构允许OpenResty将Lua代码写进nginx.conf文件再基于[LuaJIT](https://luajit.org/) 即时编译到Nginx中执行。
ngx_http_lua_module模块也正是通过OpenResty提供的以下11个指令嵌入Lua代码的
- 在Nginx启动时嵌入Lua代码包括master进程启动时执行的**init_by_lua**指令以及每个worker进程启动时执行的**init_worker_by_lua**指令。
- 在重写URL、访问权限控制等预处理阶段嵌入Lua代码包括解析TLS协议后的**ssl_certificate_by_lua**指令基于openssl的回调函数实现设置动态变量的**set_by_lua**指令重写URL阶段的**rewrite_by_lua**指令,以及控制访问权限的**access_by_lua**指令。
- 生成HTTP响应时嵌入Lua代码包括直接生成响应的**content_by_lua**指令,连接上游服务前的**balancer_by_lua**指令,处理响应头部的**header_filter_by_lua**指令,以及处理响应包体的**body_filter_by_lua**指令。
- 记录access.log日志时嵌入Lua代码通过**log_by_lua**指令实现。
如下图所示:
<img src="https://static001.geekbang.org/resource/image/97/c3/9747cc2830cb65513ea0f4e5603b9fc3.jpg" alt="">
ngx_stream_lua_module模块与之类似这里不再赘述。
当然如果Lua代码只是可以在Nginx进程中执行它是无法处理用户请求的。我们还需要让Lua代码与Nginx中的C代码互相调用去获取、改变HTTP请求、响应的内容。因此ngx_http_lua_module和ngx_stream_lua_module这两个模块通过[FFI](https://luajit.org/ext_ffi.html) 技术将C函数通过Ngx库中的Lua API暴露给纯Lua代码如下图所示
<img src="https://static001.geekbang.org/resource/image/b3/58/b30b937e7866b9e588ce3e0427b1b758.jpg" alt="">
这样通过nginx.conf文件中的11个指令以及FFI技术提供的SDKLua代码才真正可以处理请求Lua生态从这里开始延伸因此OpenResty上还提供了进一步提高开发效率的Lua模块库参见/usr/local/openresty/lualib/目录。关于FFI技术及OpenResty提供的HTTP SDK你可以参考 [《Nginx核心知识100讲》中的第147-154课](https://time.geekbang.org/course/intro/100020301)。
## Nginx与OpenResty的差别在哪里
清楚了OpenResty与Nginx间的相同之处我们再来看二者默认编译出的Nginx可执行程序有何不同之处。
首先看版本差异。当你在[官网](http://nginx.org/en/download.html)下载Nginx时会发现有3类版本Mainline、Stable和Legacy。其中Mainline是单号版本**它是含有最新功能的主线版本迭代速度最快。Stable是mainline版本稳定运行一段时间后将单号大版本转换为双号的稳定版本**比如1.18.0就是由1.17.10转换而来。Legacy则是曾经的稳定版本如下图所示
<img src="https://static001.geekbang.org/resource/image/63/59/63yy17990a31578a4e3ba00af7b67859.jpg" alt="">
你可以通过源代码中的CHANGES文件通过4种不同类型的变更查看版本间的差异包括
- 表示新功能的**Feature**比如下图中HTTP服务新增的auth_delay指令。
- 表示已修复问题的**Bugfix**。
- 表示已知特性变更的**Change**比如Nginx曾经允许HTTP请求头部中出现多个Host头部但在1.17.9这个Change之后这类HTTP请求将作为非法请求处理。
- 表示安全升级的**Security**比如1.15.6版本就修复了CVE-2018-16843等3个安全问题。
<img src="https://static001.geekbang.org/resource/image/c3/7e/c366e0f928dfc149da5a4df8cb15c27e.jpg" alt="">
当你安装好了OpenResty或者Nginx后你可以通过nginx -v命令查看它们的版本。你会发现**2014年以后发布的OpenResty都是运行在单号Mainline版本上的**
```
# /usr/local/nginx/sbin/nginx -v
nginx version: nginx/1.18.0
# /usr/local/openresty/nginx/sbin/nginx -v
nginx version: openresty/1.15.8.3
```
通常我们会选择稳定的Stable版本OpenResty为什么会选择单号的Mainline版本呢这是因为**Nginx与OpenResty的版本发布频率完全不同**2012年后Nginx每年大约发布10多个版本如下图所示versions统计了每年发布的版本数图中其他3条拆线统计了每年feature、bugfix、change的变更次数
<img src="https://static001.geekbang.org/resource/image/bd/68/bd39d1fd2abbcf1787a34aeb008fa368.jpg" alt="">
OpenResty每年的版本更新频率是个位数特别是从2018年到现在OpenResty只发布了4个版本。所以**为了使用尽量运行在最新版本上OpenResty选择了Mainline单号版本。**
你可能会想OpenResty并没有修改Nginx的源代码为什么不能由用户在官方Nginx上自行添加C模块实现OpenResty的安装呢这源于部分OpenResty C模块没有按照Nginx架构指定的顺序添加到模块列表中而且它们的编译环境也过于复杂。因此OpenResty放弃了Nginx官方的configure文件用户需要使用OpenResty改造过的configure脚本编译Nginx。
再来看模块间的差异。如果你留意OpenResty与Nginx间二进制文件的体积会发现使用默认配置时**OpenResty的可执行文件大了5倍**如下所示:
```
# ls -s --block-size=1 /usr/local/openresty/nginx/sbin/nginx
16437248 /usr/local/openresty/nginx/sbin/nginx
# ls -s --block-size=1 /usr/local/nginx/sbin/nginx
3851568 /usr/local/openresty/nginx/sbin/nginx
```
这由2个原因所致。
首先官方Nginx提供的四层负载均衡功能由18个STREAM模块实现、TLS协议处理功能默认都是不添加到Nginx中的而OpenResty的configure脚本将其改为了默认模块。当然如果你在编译官方Nginx时加入以下选项
```
./configure --with-stream --with-stream_ssl_module --with-stream_ssl_preread_module --with-http_ssl_module
```
那么从官方模块上Nginx就与OpenResty完全一致了此时再观察二进制文件的体积会发现它翻了一倍
```
# ls -s --block-size=1 /usr/local/openresty/nginx/sbin/nginx
6999144 /usr/local/openresty/nginx/sbin/nginx
```
其次OpenResty添加了近20个第三方C模块除了前文介绍过支持Lua语言的2个模块外还有支持Redis、Memcached、MySQL等服务的模块。这些模块编译时还需要链接依赖的软件库因此它们又将Nginx可执行文件的体积增加了1倍多。
除版本、模块外OpenResty与Nginx间还有一些小的差异比如Nginx使用了GCC编译器的-O1优化参数而OpenResty则使用了-O2优化参数。再比如官方Nginx最新版本的Makefile支持upgrade参数简化了热升级操作。当然这些小改动并不重要只要修改configure脚本就能做到。
我们到底该如何在二者中选择呢我认为如果不使用Lua语言那么我建议使用Nginx。官方Nginx的Stable版本更稳定可执行文件的体积也更小。**如果你需要使用OpenResty、TEngine中的部分C模块可以通过add-module选项将其加入到官方Nginx中。**
如果所有C模块都无法满足业务需求你就应该选择OpenResty。注意Lua语言给你带来极大灵活性的同时也会引入许多不确定性。比如如果你调用了会导致进程休眠的Lua阻塞函数比如封装了系统调用的原生Lua库或者第三方服务提供的同步SDK将会导致Nginx正在处理数万并发请求的C模块同时进入休眠从而让服务的性能大幅度下降。
## 小结
这一讲我们介绍了负载均衡的工作方式以及Nginx、OpenResty这两个最流行的负载均衡之间的异同。
任何负载均衡都能从AKF X轴水平扩展系统但只有能够解析应用层协议获取到请求的功能、用户身份、访问对象等信息才能够沿AKF Y轴、Z轴全方位扩展系统。因此七层负载均衡是分布式系统提升性能的利器。
Nginx的开放式架构允许第三方模块通过10多个钩子函数在不同的生命周期中处理请求。同时还允许C模块自行解析nginx.conf配置文件。这样OpenResty就通过2个C模块将Lua代码用LuaJIT编译到Nginx中执行并通过FFI技术将C函数暴露给Lua代码。这就是OpenResty允许Lua语言与C模块协同处理请求的原因。
OpenResty虽然就是Nginx但由于版本发布频率低于官方Nginx因此使用了单号的Mainline版本以获得Nginx的最新特性。由于OpenResty默认加入了四层负载均衡和TLS协议处理功能还新增了近20个第三方C模块这造成它编译出的Nginx体积大了5倍。如果无须使用Lua语言就能够满足业务需求我推荐你使用Nginx。
## 思考题
最后留给你一道思考题。Nginx、OpenResty都有着丰富、活跃的生态这也是我们选择开源软件的一个前提。这节课我们提到开放的设计是形成生态的一个必要因素然而这二者都有许多没有形成生态的竞争对手你认为开源软件构建出庞大的生态还需要哪些充分条件呢欢迎你在留言区与大家一起探讨。
感谢阅读如果你觉得这节课让你对负载均衡有了更深的了解搞清楚了OpenResty与Nginx间的差别也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,92 @@
<audio id="audio" title="24 | 一致性哈希:如何高效地均衡负载?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/52/26/5273de634444c7426365df786d67ef26.mp3"></audio>
你好,我是陶辉。
还记得我们在[[第22讲]](https://time.geekbang.org/column/article/254600) 谈到的Cassandra数据库吗它将服务器节点组成一个环来存储数据所使用的就是一致性哈希算法。那这一讲我们就来看看一致性哈希算法是怎样工作的。
使用哈希算法扩展系统时,最大的问题在于代表哈希桶的服务器节点数发生变化时,哈希函数就改变了,数据与节点间的映射关系自然发生了变化,结果大量数据就得在服务器间迁移。特别是含有多份冗余数据的系统,迁移工作量更是会成倍提高。
同时为了在整体上更加充分地使用IT资源我们必须解决分布式系统扩展时可能出现的两个问题数据分布不均衡和访问量不均衡。比如对于包含10个服务器节点、持久化1亿条用户数据的有状态服务如果采用关键字模10key%10的哈希算法作为路由策略就很难保证每个节点处理1千万条数据那些可能还未达到一半设计容量的节点会浪费大量磁盘空间。
即使节点间存储的数据非常均匀但这些数据间的活跃程度也不相同存放热点数据较多的节点访问量非常大很容易率先达到CPU瓶颈在许多主机节点还很空闲时我们就得扩容系统。
特别是我们很难保证集群内的所有节点都是同构的如果哈希算法不能区别对待不同配置的服务器也会抬高IT成本。
一致性哈希算法可以解决上述问题,它在许多流行的开源软件上都有很广泛的应用。这一讲我们将介绍一致性哈希算法的工作原理,以及如何通过虚拟节点提升算法的均衡性。
## 如何减少扩容、缩容时迁移的数据量?
在主机硬件达到性能瓶颈后有状态服务可以沿AKF立方体Z轴参见[[第21讲]](https://time.geekbang.org/column/article/252741)基于哈希算法扩展为分布式系统。下图系统中拥有5个节点哈希算法将每条数据的关键字模5得出的数字作为哈希桶序号从而将数据映射到节点上如果关键字是字符串或者其他结构化数据可以先通过其他哈希算法转换为整数再进行模运算
<img src="https://static001.geekbang.org/resource/image/9b/d7/9bd05076f931e8fe4b871cd88942abd7.jpg" alt="">
这个方案实现简单运算速度也快但它最大的问题是在系统扩容或者缩容时必须迁移改变了映射关系的数据。然而取模哈希函数中基数的变化往往会导致绝大部分映射关系改变比如上例中的5个关键字在下图中集群节点数即基数从5降为4时原映射关系全部失效这5条数据都得迁移到其他节点
<img src="https://static001.geekbang.org/resource/image/58/d2/58c7eyye48db0d85c5eb3563aa04d4d2.jpg" alt="">
1997年发布的《[Consistent Hashing and Random Trees](https://www.akamai.com/us/en/multimedia/documents/technical-publication/consistent-hashing-and-random-trees-distributed-caching-protocols-for-relieving-hot-spots-on-the-world-wide-web-technical-publication.pdf)》论文提出了[一致性哈希](https://zh.wikipedia.org/wiki/%E4%B8%80%E8%87%B4%E5%93%88%E5%B8%8C)算法可以大幅度减少数据迁移量。一致性哈希算法是通过以下2个步骤来建立数据与主机节点间映射关系的
- 首先将关键字经由通用的哈希函数映射为32位整型哈希值。这些哈希值会形成1个环最大的数字 ${2^{32}}$ 相当于0。
- 其次设集群节点数为N将哈希环由小至大分成N段每个主机节点处理哈希值落在该段内的数据。比如下图中当节点数N等于3且均匀地分段时节点0处理哈希值在 [0, $\frac{1}{3} * {2^{32}}$] 范围内的关键字节点1处理 [$\frac{1}{3} * {2^{32}}$, $\frac{2}{3} * {2^{32}}$] 范围内的关键字而节点2则处理的范围是 [$\frac{2}{3} * {2^{32}}$, ${2^{32}}$]
<img src="https://static001.geekbang.org/resource/image/d7/91/d7864bfc037382a3f391e3f2d0492b91.jpg" alt="">
**当然,在生产环境中主机节点很可能是异构的,所以我们要给高规格的服务器节点赋予更高的权重。一致性哈希算法改变节点的权重非常简单,只需要给每个节点分配更大的弧长即可。**例如如果上图中的节点0拥有更高的硬件配置那么可以将原本均匀分布的3个节点调整为2:1:1的权重这样节点0处理的哈希值范围调整为 [0, ${2^{31}}$]节点1的处理范围调整为 [${2^{31}}$, ${3} * {2^{30}}$]节点2的处理范围调整为 [${3} * {2^{30}}$, ${2^{32}}$],如下图所示:
<img src="https://static001.geekbang.org/resource/image/0c/0d/0c4dac0ea1c9484e639a9830d508350d.jpg" alt="">
而扩容、缩容时虽然节点数发生了变化但只要小幅度调整环上各节点的位置就不会导致大量数据的迁移。比如下图中我们将3个节点的集群扩容为4个节点只需要将节点0上一半的数据迁移至节点3即可其他节点不受影响
<img src="https://static001.geekbang.org/resource/image/e0/e7/e0c34abb43bfb8c7680683fa48f3e7e7.jpg" alt="">
接下来我们从成本上分析下一致性哈希算法的优劣。假设总数据条数为M而节点个数为N先来看映射函数的时间复杂度。传统的哈希算法以N为基数执行取模运算时间复杂度为O(1)(参见[[第3讲]](https://time.geekbang.org/column/article/232351)一致性哈希算法需要将关键字先转换为32位整型这1步的时间复杂度也是O(1)),再根据哈希环中各节点的处理范围,找到所属的节点。**由于所有节点是有序排列的所以采用二分法可以在O(logN)时间复杂度内,完成关键字到节点位置的映射。**
再来评估下数据的迁移规模。节点变化会导致传统哈希算法的映射结果不可控最坏情况下所有数据都需要迁移所以它的数据迁移规模是O(M);对于一致性哈希算法,我们可以通过调整节点位置,任意设定迁移规模。**在环中各节点均匀分布的情况下数据迁移规模是O(M/N)。**
因此一致性哈希算法的缺点是将映射函数的时间复杂度从O(1)提高到了O(logN)它的优点是将数据迁移规模从O(M)降低至O(M/N)。**由于数据条数M远大于主机节点数N而且数据迁移的成本很大所以一致性哈希算法更划算它的适用场景也更广**
## 如何通过虚拟节点提高均衡度?
一致性哈希算法虽然降低了数据的迁移量,但却遗留了两个问题没有解决。
首先,如果映射后哈希环中的数字分布不均匀,就会导致各节点处理的数据不均衡,从而降低了系统的运行效率与性能。在无法找出分布规律时,我们也无法通过调整环中节点的权重,平衡各节点处理的数据量。
其次容灾与扩容时哈希环上的相邻节点容易受到过大影响。比如下图中当节点0宕机后根据一致性哈希算法的规则其上数据应该全部迁移到相邻的节点1上这样节点1的数据量、访问量都会迅速增加1倍一旦新增的压力超过了节点1的处理能力上限就会导致节点1崩溃进而形成雪崩式的连锁反应
<img src="https://static001.geekbang.org/resource/image/2a/bc/2a20eb528c335345c6ca8422e1011bbc.jpg" alt="">
系统扩容时也面临着同样的问题,除非同时调整环中各节点的位置,否则扩容节点也只会减轻相邻节点的负载。
当数据存在多份冗余时,这两类问题会被进一步放大。
那如何提高均衡性呢?**在真实的数据节点与哈希环之间引入一个虚拟节点层,就可以解决上述问题。**例如下图中的集群含有4个节点但我们并不直接将哈希环分为4份而是将它均匀地分为32份并赋予32个虚拟节点因此每个虚拟节点会处理 ${2^{27}}$ 个哈希值再将32个虚拟节点通过某个哈希函数比如CRC32映射到4个真实节点上比如图中8个绿色虚拟节点皆由同色的主机节点0处理
<img src="https://static001.geekbang.org/resource/image/3c/c3/3c68ea2ccccd94000a39927590b0d0c3.jpg" alt="">
这样如果图中绿色的节点0宕机按照哈希环上数据的迁移规则8个绿色虚拟节点上的数据就会沿着顺时针方向分别迁移至相邻的虚拟节点上最终会迁移到真实节点1橙色、节点2蓝色、节点3水红色上。所以宕机节点上的数据会迁移到其他所有节点上。
扩容时也是一样的通过虚拟节点环新增节点可以分担现有全部节点的压力。至于虚拟节点为什么可以让数据的分布更均衡这是因为在虚拟节点与真实节点间又增加了一层哈希映射哈希函数会将原本不均匀的数字进一步打散。上图为了方便你理解每个真实节点仅包含8个虚拟节点这样能起到的均衡效果其实很有限。而在实际的工程中虚拟节点的数量会大很多比如Nginx的一致性哈希算法每个权重为1的真实节点就含有[160个](http://nginx.org/en/docs/http/ngx_http_upstream_module.html#hash)虚拟节点。
当然,有了虚拟节点后,为异构的服务器节点设置权重也更方便。只需要为权重高的真实节点,赋予更多的虚拟节点即可。注意,**虚拟节点增多虽然会提升均衡性,但也会消耗更多的内存与计算力。**
上面我们仅讨论了数据分布的均衡性,当热点数据导致访问量不均衡时,因为这一新维度的信息还没有反馈在系统中,所以你需要搜集各节点的访问量信息,基于它来动态地调整真实节点的权重,进而从热点数据更多的节点中迁移出部分数据,以此提高均衡性。
## 小结
这一讲我们介绍了一致性哈希算法的工作原理。
传统哈希函数中主机节点的变化会导致大量数据发生迁移。一致性哈希算法将32位哈希值构成环并将它分段赋予各节点这样扩容、缩容动作就只影响相邻节点大幅度减少了数据迁移量。一致性哈希算法虽然将数据的迁移量从O(M)降为O(M/N)却也将映射函数的时间复杂度从O(1)提高到O(logN)但由于节点数量N并不会很大所以一致性哈希算法的性价比还是很高的。
当哈希值分布不均匀时,数据分布也不会均衡。在哈希环与真实节点间,添加虚拟节点层,可以通过新的哈希函数,分散不均匀的数据。每个真实节点含有的虚拟节点数越多,数据分布便会越均衡,但同时也会消耗更多的内存与计算力。
虚拟节点带来的最大优点,是宕机时由所有节点共同分担流量缺口,这避免了可能产生的雪崩效应。同时,扩容的新节点也会分流所有节点的压力,这也提升了系统整体资源的利用率。
## 思考题
最后留给你一道思考题。提升数据分布、访问的平衡性并不是只有一致性哈希这一个方案。比如我们将数据与节点的映射关系放在另一个服务中持久化存储通过反向代理或者客户端SDK在访问数据节点前先从元数据服务中获取到数据的映射关系再访问对应的节点也是可以的如下图所示
<img src="https://static001.geekbang.org/resource/image/fd/39/fd66edb150de7ec2fd2c0a86b2639539.png" alt="">
你觉得上述方案与一致性哈希相比,有何优劣?各自适用的场景又是什么?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课让你掌握了一致性哈希算法这个新工具,并能够用它提升分布式系统的运行效率,也欢迎把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,148 @@
<audio id="audio" title="25 | 过期缓存:如何防止缓存被流量打穿?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/40/86433285f33958625ce602f778ce5b40.mp3"></audio>
你好,我是陶辉。
这一讲我们将对一直零散介绍的缓存做个全面的总结,同时讨论如何解决缓存被流量打穿的场景。
在分布式系统中缓存无处不在。比如浏览器会缓存用户CookieCDN会缓存图片负载均衡会缓存TLS的握手信息Redis会缓存用户的sessionMySQL会缓存select查询出的行数据HTTP/2会用动态表缓存传输过的HTTP头部TCP Socket Buffer会缓存TCP报文Page Cache会缓存磁盘IOCPU会缓存主存上的数据等等。
只要系统间的访问速度有较大差异缓存就能提升性能。如果你不清楚缓存的存在两个组件间重合的缓存就会带来不必要的复杂性同时还增大了数据不一致引发错误的概率。比如MySQL为避免自身缓存与Page Cache的重合就使用直接IO绕过了磁盘高速缓存。
缓存提升性能的幅度不只取决于存储介质的速度还取决于缓存命中率。为了提高命中率缓存会基于时间、空间两个维度更新数据。在时间上可以采用LRU、FIFO等算法淘汰数据而在空间上则可以预读、合并连续的数据。如果只是简单地选择最流行的缓存管理算法就很容易忽略业务特性从而导致缓存性能的下降。
在分布式系统中,缓存服务会为上游应用挡住许多流量。如果只是简单的基于定时器淘汰缓存,一旦热点数据在缓存中失效,超载的流量会立刻打垮上游应用,导致系统不可用。
这一讲我会系统地介绍缓存及其数据变更策略同时会以Nginx为例介绍过期缓存的用法。
## 缓存是最有效的性能提升工具
在计算机体系中,各类硬件的访问速度天差地别。比如:
- CPU访问缓存的耗时在10纳秒左右访问内存的时延则翻了10倍
- 如果访问SSD固态磁盘时间还要再翻个1000倍达到100微秒
- 如果访问机械硬盘对随机小IO的访问要再翻个100倍时延接近10毫秒
- 如果跨越网络访问时延更要受制于主机之间的物理距离。比如杭州到伦敦相距9200公里ping时延接近200毫秒。当然网络传输的可靠性低很多一旦报文丢失TCP还需要至少1秒钟才能完成报文重传。
可见最快的CPU缓存与最慢的网络传输有1亿倍的速度差距一旦高速、低速硬件直接互相访问前者就会被拖慢运行速度。因此**我们会使用高速的存储介质创建缓冲区,通过预处理、批处理以及缓冲数据的反复命中,提升系统的整体性能。**
不只是硬件层面软件设计对访问速度的影响更大。比如对关系数据库的非索引列做条件查询时间复杂度是O(N)而对Memcached做Key/Value查询时间复杂度则是O(1)所以在海量数据下两者的性能差距远高于硬件。因此RabbitMQ、Kafka这样的消息服务也会充当高速、低速应用间的缓存。
如果两个实体之间的访问时延差距过大,还可以通过多级缓存,逐级降低访问速度差,提升整体性能。比如[[第1讲]](https://time.geekbang.org/column/article/230194) 我们介绍过CPU三级缓存每级缓存越靠近CPU速度越快容量也越小以此缓解CPU频率与主存的速度差提升CPU的运行效率。
<img src="https://static001.geekbang.org/resource/image/98/y9/986779b16684c3a250ded39066ea5yy9.png" alt="">
再比如下图的Web场景中浏览器的本地缓存、操作系统内核中的TCP缓冲区参见[[第11讲]](https://time.geekbang.org/column/article/239176)、负载均衡中的TLS握手缓存、应用服务中的HTTP响应缓存、MySQL中的查询缓存等每一级缓存都缓解了上下游间不均衡的访问速度通过缩短访问路径降低了请求时延通过异步访问、批量处理提升了系统效率。当然缓存使用了简单的Key/Value结构因此可以用哈希表、查找树等容器做索引这也提升了访问速度。
<img src="https://static001.geekbang.org/resource/image/96/81/96f9fcdc192de9282830f8f7a1b75a81.png" alt="">
从系统底层到业务高层缓存都大有用武之地。比如在Django这个Python Web Server中既可以使用视图缓存将动态的HTTP响应缓存下来
```
from django.views.decorators.cache import cache_page
@cache_page(60 * 15)
def my_view(request):
...
```
也可以使用[django-cachealot](https://django-cachalot.readthedocs.io/en/latest/) 这样的中间件将所有SQL查询结果缓存起来:
```
INSTALLED_APPS = [
...
'cachalot',
...
]
```
还可以在更细的粒度上使用Cache API中的get、set等函数将较为耗时的运算结果存放在缓存中
```
cache.set('online_user_count', counts, 3600)
user_count = cache.get('online_user_count')
```
这些缓存的应用场景大相径庭,但数据的更新方式却很相似,下面我们来看看缓存是基于哪些原理来更新数据的。
## 缓存数据的更新方式
缓存的存储容量往往小于原始数据集,这有许多原因,比如:
- 缓存使用了速度更快的存储介质,而这类硬件的单位容量更昂贵,因此从经济原因上只能选择更小的存储容量;
- 负载均衡可以将上游服务的动态响应转换为静态缓存,从时间维度上看,上游响应是无限的,这样负载均衡的缓存容量就一定会不足;
- 即使桌面主机的磁盘容量达到了TB级但浏览器要对用户访问的所有站点做缓存就不可能缓存一个站点上的全部资源在一对多的空间维度下缓存一样是稀缺资源。
因此,我们必须保证在有限的缓存空间内,只存放会被多次访问的热点数据,通过提高命中率来提升系统性能。要完成这个目标,必须精心设计向缓存中添加哪些数据,缓存溢出时淘汰出哪些冷数据。我们先来看前者。
通常,缓存数据的添加或者更新,都是由用户请求触发的,这往往可以带来更高的命中率。比如,当读请求完成后,将读出的内容放入缓存,基于时间局部性原理,它有很高的概率被后续的读请求命中。[[第15讲]](https://time.geekbang.org/column/article/242667) 介绍过的HTTP缓存就采用了这种机制。
对于磁盘操作,还可以基于空间局部性原理,采用预读算法添加缓存数据(参考[[第4讲]](https://time.geekbang.org/column/article/232676) 介绍的PageCache。比如当我们统计出连续两次读IO的操作范围也是连续的就可以判断这是一个顺序读IO如果这个读IO获取32KB的数据就可以在这次磁盘中多读出128KB的数据放在缓存这会带来2个收益
- 首先通过减少定位时间提高了磁盘工作效率。机械磁盘容量大价格低它的顺序读写速度由磁盘旋转速度与存储密度决定通常可以达到100MB/s左右。然而由于机械转速难以提高服务器磁盘的转速也只有10000转/s磁头定位与旋转延迟大约消耗了8毫秒因此对于绝大部分时间花在磁头定位上的随机小IO比如4KB读写吞吐量只有几MB。
- 其次,当后续的读请求命中提前读入缓存的数据时,请求时延会大幅度降低,这提升了用户体验。
而且并不是只有单机进程才能使用预读算法。比如公有云中的云磁盘之所以可以实时地挂载到任意虚拟机上就是因为它实际存放在类似HDFS这样的分布式文件系统中。因此云服务会在宿主物理机的内存中缓存虚拟机发出的读写IO由于网络传输的成本更高所以预读效果也更好。
写请求也可以更新缓存,你可以参考[[第20讲]](https://time.geekbang.org/column/article/251062) 我们介绍过write through和write back方式。其中write back采用异步调用回写数据能通过批量处理提升性能。比如Linux在合并IO的同时也会像电梯运行一样每次使磁头仅向一个方向旋转写入数据提升机械磁盘的工作效率因此得名为电梯调度算法。
说完数据的添加我们再来看2种最常见的缓存淘汰算法。
首先来看[FIFO](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))(First In, First Out)先入先出淘汰算法。[[第16讲]](https://time.geekbang.org/column/article/245966) 介绍的HTTP/2动态表会将HTTP/2连接上首次出现的HTTP头部缓存在客户端、服务器的内存中。由于它们基于相同的规则生成所以拥有相同的动态表序号。这样传输1-2个字节的表序号要比传输几十个字节的头部划算得多。当内存容量超过SETTINGS_HEADER_TABLE_SIZE阈值时会基于FIFO算法将最早缓存的HTTP头部淘汰出动态表。
[<img src="https://static001.geekbang.org/resource/image/7b/fa/7b4ed2f6e8e0e0379a89a921b587a7fa.jpg" alt="" title="图片参见wikihttps://en.wikipedia.org/wiki/FIFO_(computing_and_electronics)">](https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics))
再比如[[第14讲]](https://time.geekbang.org/column/article/241632) 介绍的TLS握手很耗时所以我们可以将密钥缓存在客户端、服务器中等再次建立连接时通过session ID迅速恢复TLS会话。由于内存有限服务器必须及时淘汰过期的密钥其中Nginx也是采用FIFO队列淘汰TLS缓存的。
其次LRU(Less Recently Used)也是最常用的淘汰算法比如Redis服务就通过它来淘汰数据OpenResty在进程间共享数据的shared_dict在达到共享内存最大值后也会通过LRU算法淘汰数据。LRU通常使用双向队列实现时间复杂度为O(1)),队首是最近访问的元素,队尾就是最少访问、即将淘汰的元素。当访问了队列中某个元素时,可以将其移动到队首。当缓存溢出需要淘汰元素时,直接删除队尾元素,如下所示:
<img src="https://static001.geekbang.org/resource/image/21/80/21f4cfa8c5a92db8eaf358901a7f8780.png" alt="">
以上我只谈了缓存容量到达上限后的淘汰策略为了避免缓存与源数据不一致在传输成本高昂的分布式系统中通常会基于过期时间来淘汰缓存。比如HTTP响应中的Cache-Control、Expires或者Last-Modified头部都会用来设置定时器响应过期后会被淘汰出缓存。然而一旦热点数据被淘汰出缓存那么来自用户的流量就会穿透缓存到达应用服务。由于缓存服务性能远大于应用服务过大的流量很可能会将应用压垮。因此过期缓存并不能简单地淘汰下面我们以Nginx为例看看如何利用过期缓存提升系统的可用性。
## Nginx是如何防止流量打穿缓存的
当热点缓存淘汰后大量的并发请求会同时回源上游应用其实这是不必要的。比如下图中Nginx的合并回源功能开启后Nginx会将多个并发请求合并为1条回源请求并锁住所有的客户端请求直到回源请求返回后才会更新缓存同时向所有客户端返回响应。由于Nginx可以支持C10M级别的并发连接因此可以很轻松地锁住这些并发请求降低应用服务的负载。
<img src="https://static001.geekbang.org/resource/image/3e/2d/3ec9f8f4251af844770d24c756a6432d.png" alt="">
启用合并回源功能很简单只需要在nginx.conf中添加下面这条指令即可
```
proxy_cache_lock on;
```
当1个请求回源更新时其余请求将会默认等待如果5秒可由proxy_cache_lock_timeout修改后缓存依旧未完成更新这些请求也会回源但它们的响应不会用于更新缓存。同时第1个回源请求也有时间限制如果到达5秒可由proxy_cache_lock_age修改后未获得响应就会放行其他并发请求回源更新缓存。
如果Nginx上的缓存已经过期未超过proxy_cache_path中inactive时间窗口的过期缓存并不会被删除且上游服务此时已不可用那有没有办法可以通过Nginx提供降级服务呢所谓“服务降级”是指部分服务出现故障后通过有策略地放弃一些可用性来保障核心服务的运行这也是[[第20讲]](https://time.geekbang.org/column/article/251062) BASE理论中Basically Available的实践。如果Nginx上持有着过期的缓存那就可以通过牺牲一致性向用户返回过期缓存以保障基本的可用性。比如下图中Nginx会直接将过期缓存返回给客户端同时也会一直试图更新缓存。
<img src="https://static001.geekbang.org/resource/image/d0/e5/d086aa5b326c21ce7609a2b7b98f86e5.png" alt="">
开启过期缓存功能也很简单添加下面2行指令即可
```
proxy_cache_use_stale updating;
proxy_cache_background_update on;
```
当然上面两条Nginx指令只是开启了最基本的功能如果你想进一步了解它们的用法可以观看[《Nginx核心知识100讲》第102课](https://time.geekbang.org/course/detail/100020301-76629)。
## 小结
这一讲我们系统地总结了缓存的工作原理以及Nginx解决缓存穿透问题的方案。
当组件间的访问速度差距很大时,直接访问会降低整体性能,在二者之间添加更快的缓存是常用的解决方案。根据时间局部性原理,将请求结果放入缓存,会有很大概率被再次命中,而根据空间局部性原理,可以将相邻的内容预取至缓存中,这样既能通过批处理提升效率,也能降低后续请求的时延。
由于缓存容量小于原始数据集因此需要将命中概率较低的数据及时淘汰出去。其中最常用的淘汰算法是FIFO与LRU它们执行的时间复杂度都是O(1),效率很高。
由于缓存服务的性能远大于上游应用一旦大流量穿透失效的缓存到达上游后就可能压垮应用。Nginx作为HTTP缓存使用时可以打开合并回源功能减轻上游压力。在上游应用宕机后还可以使用过期缓存为用户提供降级服务。
## 思考题
最后,留给你一道讨论题。缓存并不总是在提高性能,回想一下,在你的实践中,有哪些情况是增加了缓存服务,但并没有提高系统性能的?原因又是什么?欢迎你在留言区与大家一起分享。
感谢阅读,如果你觉得这节课让你加深了对缓存的理解,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,92 @@
<audio id="audio" title="26 | 应用层多播:如何快速地分发内容?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/37/3feb9595eea01aa4e439c2eec0828a37.mp3"></audio>
你好,我是陶辉。
[[第7讲]](https://time.geekbang.org/column/article/235302) 我们曾介绍了网络层的IP协议是如何支持多播的这节课我们再来从应用层看看如何实现多播功能。
当你的分布式集群只有十多个节点时每次发布版本时尽可以从发布服务器将新版本的安装包通过ftp、scp、wget等工具分发到各个节点中。可是一旦集群规模达到成千上万个节点时再这么做就会带来很大的问题文件分发的时长高达几个小时甚至会打挂文件源终止分发流程。在微服务环境中这点尤为明显 毕竟每个Docker镜象的体积动辄就在数百兆字节以上。
虽然网络层的IP协议允许通过路由器、交换机实现高效的多播但IP层很难实现文件的可靠传输而且跨越多个局域网时路由器等网络设备对IP多播的支持也不好。此时通过应用层的接力传播就能通过多播思想大幅提升系统的传输效率解决上述问题。
除了分发文件场景外应用层多播协议也常用于完全去中心化的分布式系统特别是在管理成千上万个节点的状态时非常有用。比如Gossip就是这样一个多播协议[[第22讲]](https://time.geekbang.org/column/article/254600) 介绍过的Cassandra数据库使用它来同步节点间的状态比特币通过它传输账本数据Redis集群也使用它同步Redis进程间的状态。
那么这节课我们就重点介绍应用层中的多播协议并以阿里的蜻蜓、Cassandra中的Gossip协议为例看看它们的工作原理。
## 认识应用层的多播协议
之所以需要应用层的多播协议是因为网络层的IP多播功能参见[[第7讲]](https://time.geekbang.org/column/article/235302) 的IP广播与组播有以下4个方面的问题
- 从功能上看IP多播缺失了质量控制、可靠性传输等特性无法满足绝大部分场景中的要求
- 从管理上看,监控跨网络的多播报文并不容易,多播地址的管理也很复杂,而且多播很容易造成网络洪峰及安全问题;
- 从市场上看,单播报文在终端上的计费很成熟,相反,运营商对多播报文的计费要困难许多,相对缺乏推进动力;
- 从产业协作上看IP多播必须由各设备厂商的路由器、交换机配合由于网络层是由内核实现的所以还要同步升级操作系统。
这些因素都使得网络层的多播功能难以推广因此各类基于IP单播功能的应用层多播协议应运而生。可能有些同学对于单播、多播这些网络知识还不熟悉下图我将传统单播、网络层多播、应用层多播放在一起对比着看你可以很容易看到它们的区别。
这里仍然以版本发布场景为例主机1是发布节点它需要将文件分发到其余3台主机上其中传统单播协议的玩法是在主机2、3、4上分别向主机1发起下载文件命令因此主机1上有3条TCP下载链路图中我用3种不同的颜色表示。很明显此时主机1的下行流量一定会成为瓶颈而且随着集群规模的扩大网络链路中的路由器也会很快达到流量瓶颈。
<img src="https://static001.geekbang.org/resource/image/34/7a/3457fac0f6811d21357188c48fe5867a.png" alt="">
再来看效率最高的网络层多播主机1可以仅通过1次报文传输将文件分发到所有主机上。在整个流程中仅由路由器将网络报文扩散到相邻的主机、路由器上没有任何多余的动作。如同上述所说网络层多播的使用有很多困难这里列出它是为了方便与应用层多播作对比。
在上图的应用层多播中文件传输分为两个阶段。首先主机2、3直接从主机1中下载文件参见绿色与红色线。其次当主机3完成下载后主机4再从主机3上下载文件参见蓝色线。可见相对于IP多播**应用层多播有以下3个缺点**
首先网络效率下降了不少这由2个原因所致
- 数据在网络中有冗余比如主机1将数据重复发送了2次参见红色、绿色线主机3既接收了一次数据也发送了一次数据
- 虽然图中主机4从同一局域网中的主机3下载数据但在复杂的互联网环境中应用层很难掌握完整的路由器组网信息主机4一旦跨网络从主机2上下载数据这就增加了路由器A及网络链路的负载效率进一步下降。
其次由于传输路径变长了文件传输的总完成时间也变长了。比如主机4的总传输路径是主机1 -&gt; 路由器A -&gt; 路由器B -&gt; 主机3 -&gt; 路由器B -&gt; 主机4既多出了主机3、路由器B这两个传输环节而且进入主机3后协议栈的处理深度也增加了。
最后单次传输路径中引入了功能复杂的主机相比仅由网络设备参与的IP多播可靠性、稳定性也降低了。
说完缺点,我们再来看应用层多播的优点。
- 首先它回避了IP多播的问题无须改变现有组网环境也不需要管理组播IP地址立刻就可以应用在当下的生产环境中
- 其次,在数以万计的大规模集群下,单一发布源很容易被流量打爆,进而导致分发流程停止,应用层多播可以避免这一问题;
- 再次,通过应用层节点的接力分发,整个传输带宽被大幅度提高了,分发速度有了数量级上的飞跃;
- 最后如果分发集群跨越不同传输成本的网络比如多个区域IDC构成的集群在应用层也很容易控制分发策略进而减少高成本网络的数据传输量提升经济性。
所以,综合来说,**集群规模越大,应用层多播的优势也越大。**实际上十多年前我们在使用BT、迅雷下载时就已经接触到应用层多播协议了接下来我们结合2个服务器端的案例看看多播协议的实现与应用。
## 应用层多播协议是如何工作的?
其实,应用层多播主要是指一种[P2PPeer to Peer](https://zh.wikipedia.org/wiki/%E5%B0%8D%E7%AD%89%E7%B6%B2%E8%B7%AF)网络传输思想任何基于IP单播的通讯协议都可以拿来使用。比如针对于在分布式集群中分发软件安装包的场景完全可以使用HTTP协议实现应用层的分发阿里巴巴开源的[Dragonfly蜻蜓](https://github.com/DarLiner/Dragonfly)就是这么做的。
蜻蜓拥有1个中心化的集群服务节点SuperNode其中既可以直接保存着源文件也可以通过HTTP缓存加速第三方文件源比如Docker仓库。集群中的每个节点都要启动dfget进程替代了传统的wget它就像我们平时使用的迅雷在下载文件的同时也会将自己下载完成的文件传输给其他节点。其中通过HTTP的Range文件分段下载规范dfget就可以实现断点续传、多线程下载等功能如下图所示
[<img src="https://static001.geekbang.org/resource/image/2f/00/2fbba7f194bd3bcabded582467056700.png" alt="" title="图片及下图来源https://github.com/DarLiner/Dragonfly/blob/master/docs/zh/architecture.md">](https://github.com/DarLiner/Dragonfly/blob/master/docs/zh/architecture.md)
各个dfget程序间通过SuperNode协调传输关系这样绝大部分dfget程序并不需要从SuperNode节点下载文件。比如上图中的节点C通过HTTP Range协议从节点A中下载了文件的第1块同时并发地从节点B中下载了文件的第2块最后再把这些Block块拼接为完整的文件。
这里你可能会想这不就是一个P2P下载工具么是的但你站在集群运维的角度这就是基于应用层多播协议的文件分发工具。当每个节点部署dfget服务后新版本安装包发布时就可以由SuperNode节点推送经由各个dfget进程以多播的形式分发下去此时性能会获得大幅度的提升。
<img src="https://static001.geekbang.org/resource/image/36/82/36de22b49038a6db2b8bc7ce953e5c82.png" alt="">
上图是传统的wget单播与蜻蜓多播分发文件的性能对比图。我们可以看到传统方式下**分发客户端越多Y轴总分发时长X轴就越大特别是1200个以上的并发节点下载文件时会直接将文件源打爆。**而采用应用层多播方式后,下载时长要低得多,而且伴随着节点数的增加,下载时长也不会增长。
蜻蜓虽然传输效率很高但SuperNode却是保存着全局信息的中心节点一旦宕机就会造成系统不可用。我们再来看去中心化的[Gossip流言协议](https://en.wikipedia.org/wiki/Gossip_protocol)是如何实现应用层多播的。
Gossip协议也叫epidemic传染病协议工作原理如下图所示。在这个分布式网络中并没有任何中心化节点但只要第1个种子节点被感染为红色后每个节点只需要感染其相邻的、有限的几个节点最终就能快速感染网络中的所有节点即仅保证最终一致性
<img src="https://static001.geekbang.org/resource/image/fa/b5/fa710dc6de9aa9238fee647ffdb69eb5.gif" alt="">
当然所谓的“感染”就是数据的传输这一算法由1987年发布的《[Epidemic algorithms for replicated database maintenance](http://bitsavers.trailing-edge.com/pdf/xerox/parc/techReports/CSL-89-1_Epidemic_Algorithms_for_Replicated_Database_Maintenance.pdf)》论文提出同时证明了算法的收敛概率。Cassandra数据库、Fabric区块链、Consul系统等许多去中心化的分布式系统都使用Gossip协议管理集群中的节点状态。以Cassandra为例每秒钟每个节点都会随机选择1到3个相邻节点通过默认的7000端口传输包含节点状态的心跳信息这样集群就可以快速发现宕机或者新增的节点。
[<img src="https://static001.geekbang.org/resource/image/79/fe/7918df00c24e6e78122d5a70bd6bd2fe.png" alt="" title="图片来源https://www.linkedin.com/pulse/gossip-protocol-inside-apache-cassandra-soham-saha">](https://www.linkedin.com/pulse/gossip-protocol-inside-apache-cassandra-soham-saha)
## 小结
这一讲我们介绍了应用层的多播协议。
网络层的IP多播功能有限对网络环境也有过多的要求所以很难通过多播协议提升传输效率。基于IP单播协议如TCP或者UDP在应用代码层面实现分布式节点间的接力转发就可以实现应用层的多播功能。
在分布式集群的文件分发场景中,阿里开源的[Dragonfly蜻蜓](https://github.com/DarLiner/Dragonfly)可以将发布节点上的源文件通过HTTP协议推送到集群中的每个节点上其中每个节点在应用层都参与了多播流量分发的实现。当节点数到达千、万级时蜻蜓仍然能保持较低的分发时延避免发布节点被下行流量打爆。
在完全去中心化的分布式集群中每个节点都没有准确的全局信息此时可以使用Gossip流言协议通过仅向有限的相邻节点发送消息完成整个集群的数据同步实现最终一致性。因此Gossip协议常用于大规模分布集群中的节点状态同步。
## 思考题
最后留给你一道讨论题。在5G完成设备层的组网后类似华为NewIP这样的基础协议层也在做相应的重构其中[Multicast VPN协议](https://support.huawei.com/enterprise/en/doc/EDOC1000173015/e0de8568/overview-of-rosen-mvpn)就将现有IPv4无法在公网中推广的多播功能在VPN逻辑链路层实现了。你对未来多播协议的发展又是如何看的欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课让你了解到应用层的多播协议,而通过它可以大幅度提升分布式集群的网络传输效率的话,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,85 @@
<audio id="audio" title="27 | 消息队列:如何基于异步消息提升性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/6e/27bfeeac9216d1b7fe0cda975446256e.mp3"></audio>
你好,我是陶辉。
在前26讲中我们介绍了许多异步实现机制这节课我们来看看如何通过[消息队列](https://zh.wikipedia.org/wiki/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97)提升分布式系统的性能。
异步通讯是最常用的性能提升方式比如gRPC提供的异步API或者基于write-back模式向缓存写入数据时系统性能都可以提高。然而对于复杂的大规模分布式系统这些分散、孤立的异步实现机制无法解决以下问题
- 组件间耦合在一起,不只迭代变更时更为困难,而且当它们之间的性能有差异时,吞吐量较低的组件就会成为系统瓶颈;
- 当业务在时间上具有明显的峰谷访问差异时,实现**削峰填谷**需要一定的开发成本;
- 实现BASE理论中的Basically Available并不容易
- 每个组件都要自行维护负载均衡组件,以此提供可伸缩性;
- 每个组件的请求格式、日志都不尽相同,因此系统总体的监控成本相对较高;
- 批量处理请求、异步化都可以提升性能,但每个组件独立实现这些基础功能付出的成本并非完全必要。
想必你肯定听过Kafka、RabbitMQ、RocketMQ这些流行的消息队列吧通过消息队列实现组件间的异步交互方式上述问题就会迎刃而解。这一讲我们就来看看如何在分布式系统中使用消息队列以及高可用性又是如何保证的。
## 消息队列解决了哪些问题?
当进程中需要交互的两个模块性能差距很大时我们会基于FIFO先入先出队列实现生产者消费者模型通过调整生产者、消费者的数量实现线程间的负载均衡。而且生产者仅将任务添加至队列首部就可以返回这种异步操作释放了它的性能。比如[[第13课]](https://time.geekbang.org/column/article/240656) 中接收心跳包的分发线程性能要比处理心跳包的工作线程性能高得多,两者间就通过单机上的消息队列提高了整体性能。
<img src="https://static001.geekbang.org/resource/image/18/21/1865eebccb3f65c6b56d526124c8e421.png" alt="">
把单机中的FIFO队列放大到分布式系统中就形成了独立的消息队列服务。此时生产者、消费者的角色从线程变成了网络中的独立服务生产者可以向消息队列发布多种消息多个消费者也可以订阅同一种消息如下图所示
<img src="https://static001.geekbang.org/resource/image/9c/7c/9ca376f54a0631b7e83e9fa024e3427c.png" alt="">
总结一下的话消息队列就具备了以下7个优点
1. 降低了系统的耦合性。比如上图中组件2发布了一条用户注册成功消息原本只有负责通知用户注册结果的组件3在处理如果组件4需要立刻开启新用户的营销工作只需要同时向消息队列订阅即可再比如组件2、组件3、组件4通讯时并不需要统一应用层协议或者RPC接口所有参与方只需要与消息队列服务的SDK打交道。
1. 可伸缩性很容易实现。比如当组件3的性能不足时添加订阅消息的新实例就可以通过水平扩展提升消费能力。反之也可以扩展组件1提升消息的生产能力。
1. 天然实现“削峰填谷”功能。消息队列服务会将消息持久化存储在磁盘中,在高峰期来不及处理的消息,会在低谷期被消费者服务处理完。通常,消息队列会使用廉价、高容量的机械磁盘存放消息,可以轻松缓存住高峰期超载的全部请求。
1. 提高了系统可用性。首先,持久化到磁盘中的消息,在宕机故障时比内存中的请求有更高的可用性;其次,消息队列可以隔离故障,比如,消费者服务宕机后,生产者服务短期内不会受到影响;再次,当总吞吐量超过性能上限时,还可以设置不同的消息优先级,通过服务降级保障系统的基本可用性。
1. 消息队列的生产者天然具备异步功能,这降低了生产者的请求处理时延,提升了用户体验。
1. [[第21课]](https://time.geekbang.org/column/article/252741) 介绍过基于AKF Y轴拆分功能可以降低数据规模而且组件间分工更细也会带来更深入的性能优化。当消息队列作为通讯方式时这种“事件驱动”的分布式系统很容易通过消息实现服务拆分成本会低很多。
1. 消息队列服务对于各种消息的发布、消费情况都有统计,因此,从消息中就能获得业务的实时运行状态,以极低的成本实现系统的监控。
正是因为这么多的优点所以消息队列成为了多数分布式系统必备的基础设施。而且消息队列自身也拥有很高的性能比如RabbitMQ单机每秒可以处理10万条消息而Kafka单机每秒甚至可以处理百万条消息。消息队列的性能为什么如此夸张呢除了消息队列处理逻辑简单外还有一个重要原因就是消息的产生、消费在时间上是连续的这让消息队列在以下优化点上能获得很高的收益
- 首先在网络通讯中很容易通过批量处理提高网络效率。比如生产者快速发布消息时Kafka的客户端SDK会自动聚集完一批消息再一次性发送给Broker这样网络报文的有效载荷比会很高。
- 其次在数据写入磁盘的过程中由于时序性特征存放消息的文件仅以追加形式变更这样多数情况下机械硬盘的磁头仅朝一个方向转动这让磁盘写入速度可以轻松达到100MB/s。
- 最后由于消费者也是按照FIFO规则有序接收消息的这样消息队列的缓存就可以通过批量预读等优化方式大幅提高读操作的缓存命中率。
而且,目前主流消息队列都支持集群架构,因此消息队列自身一般不会是性能瓶颈。
## 消息队列的服务质量是如何保证的?
为了提升整个分布式系统的性能我们在处理消息时还需要在生产端、消费端以及消息队列的监控上做到以下3件事
- 首先虽然生产者会异步地发布消息但毕竟需要接收到消息队列的确认才构成完整的发布流程。网络传输是相对漫长、不可控的所以在高性能场景中生产者应基于多线程或者非阻塞Socket发布消息以提高并发能力。
- 其次当消费端性能不足需要扩容时必须同步增加消息队列服务中的队列在Kafka中叫做分区才能允许新增的消费节点并行接收消息提高消息的处理能力。否则当多个消费者消费同一消息队列时消息的有序性会导致多个消费节点串行处理消息无法发挥出它们的全部性能如下图所示
<img src="https://static001.geekbang.org/resource/image/bc/46/bc9b57604b2f15cf6fb95d64af99c546.png" alt="">
- 最后,如果通过监控发现消息的消费能力小于生产能力,那就必须及时扩容消费端,或者降低消息的发布速度,否则消息就会积压,最终导致系统不可用。
接下来我们再来看消息队列的QoSQuality of Service是如何保证的消息在传递过程中会不会丢失以及接收方会不会重复消费消息。在[MQTT协议](https://en.wikipedia.org/wiki/MQTT)中给消息队列定义了三种QoS级别
- at most once每条消息最多只被传送一次这意味着消息有可能丢失
- at least once每条消息至少会传送一次这意味着消息可能被重复消费
- exactly once每条消息恰好只传送一次这是最完美的状态。
需要at most once约束的场景较罕见因此目前绝大部分消息队列服务提供的QoS约束都是at least once它是通过以下3点做到的
- 生产端发布消息时,只有消息队列确定写入磁盘后,才会返回成功;
- 为防止消息队列服务出现故障后丢消息我们也需要将数据存放在多个副本节点中。第4部分课程介绍的许多高可用策略消息队列都会采用比如Kafka就是使用[[第22课]](https://time.geekbang.org/column/article/254600) 介绍过的NWR算法来选出副本中的Leader节点再经由它同步数据副本。
- 消费端必须在消费完消息(而不是收到消息)后,才能向消息队列服务返回成功。
这样消息队列就能以很高的可用性提供at least once级别的QoS。而exactly once是在at least once的基础上通过[幂等性idempotency](https://en.wikipedia.org/wiki/Idempotence) 实现的。对于一条“幂等性消息”无论消费1次还是多次结果都是一样的。因此Kafka通过消息事务和幂等性约束实现了[exactly once语义](https://kafka.apache.org/documentation/#upgrade_11_exactly_once_semantics)其中发布消息时Kafka会创建全局唯一的递增ID这样传输消息时它就能低成本地去除重复的消息通过幂等性为单队列实现exactly once语义针对生产者向多个分区发布同一条消息的场景消息事务通过“要么全部成功要么全部失败”也实现了exactly once语义。
## 小结
这一讲我们介绍了消息队列及其用法。
消息队列可以解耦分布式系统,其缓存的消息提供了削峰填谷功能,将消息持久化则提高了系统可用性,共享队列则为系统提供了可伸缩性,而且统计消息就可以监控整个系统,因此消息队列已成为当下分布式系统的必备基础设施。
虽然消息队列自身拥有优秀的性能,但若想提高使用效率,我们就需要确保在生产端实现网络传输上的并发,在消费端扩容时同步增加队列或者分区,并且需要持续监控系统,确保消息的生产能力小于消费能力,防止消息积压。
消息队列的Qos提供三种语义其中at most once很少使用而主流的at least once由消息持久化时的冗余以及生产端、消息端使用消息的方式共同保障。Kafka通过幂等性、事务消息这两个特性在at least once的基础上提供了exactly once语义。
## 思考题
最后,留给你一道讨论题。你在实践中使用过消息队列吗?它主要帮你解决了哪些问题?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有所帮助,也欢迎把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="28 | MapReduce如何通过集群实现离线计算" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1f/e9/1f52881d9daaa1a3aac4ffda0fbae5e9.mp3"></audio>
你好,我是陶辉。
接下来的2节课我将介绍如何通过分布式集群优化计算任务。这一讲我们首先来看对于有边界静态数据的离线计算下一讲再来看对无边界数据流的实时计算。
对大量数据做计算时我们通常会采用分而治之的策略提升计算速度。比如单机上基于递归、分治思想实现的快速排序、堆排序时间复杂度只有O(N*logN)这比在原始数据集上工作的插入排序、冒泡排序要快得多O(N<sup>2</sup>)。然而当单机磁盘容量无法存放全部数据或者受限于CPU频率、核心数量单机的计算时间远大于可接受范围时我们就需要在分布式集群上使用分治策略。
比如大规模集群每天产生的日志量是以TB为单位计算的这种日志分析任务单台服务器的处理能力是远远不够的。我们需要将计算任务分解成单机可以完成的小任务由分布式集群并行处理后再从中间结果中归并得到最终的运算结果。这一过程由Google抽象为[MapReduce](https://zh.wikipedia.org/wiki/MapReduce) 模式实现在Hadoop等分布式系统中。
虽然MapReduce已经有十多个年头的历史了但它仍是分布式计算的基石这种编程思想在新出现的各种技术中都有广泛的应用。比如当在单机上使用TensorFlow完成一轮深度学习的时间过久或者单颗GPU显存无法存放完整的神经网络模型时就可以通过Map思想把数据或者模型分解给多个TensorFlow实例并行计算后再根据Reduce思想合并得到最终结果。再比如知识图谱也是通过MapReduce思想并行完成图计算任务的。
接下来我们就具体看看如何在分布式集群中实现离线计算以及MapReduce是怎样提供SQL语言接口的。
## 分而治之:如何实现集群中的批量计算?
分而治之的思想在分布式系统中广为使用,比如[[第21讲]](https://time.geekbang.org/column/article/252741) 介绍过的AKF立方体Z轴扩展就是基于用户的请求缩小集群中单个节点待处理的数据量比如下图中当关系数据库中单表行数达到千万行以上时此时不得不存放在磁盘中的索引将会严重降低SQL语句的查询速度。而执行分库分表后由应用或者中间层的代理分解查询语句待多个不足百万行的表快速返回查询结果后再归并为最终的结果集。
<img src="https://static001.geekbang.org/resource/image/71/81/712a0a73b71090abcaa7ac552f402181.png" alt="">
与上述的IO类任务不同并非所有的计算任务都可以基于分治策略分解为可以并发执行的子任务。比如[[第14讲]](https://time.geekbang.org/column/article/241632) 介绍过的基于[CBC分组模式](https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F)的AES加密算法就无法分解执行如下图所示每16个字节的块在加密时都依赖前1个块的加密结果这样的计算过程既无法利用多核CPU也无法基于MapReduce思想放在多主机上并发执行。
[<img src="https://static001.geekbang.org/resource/image/2b/3b/2b8bca7a74eb5f98125098e271d0973b.jpg" alt="" title="图片源自https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F">](https://zh.wikipedia.org/zh-hans/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F)
我们再来看可以使用MapReduce的计算任务其中最经典的例子是排序Google在构建倒排索引时要为大量网页排序。当使用插入排序不熟悉插入排序的同学可以想象自己拿了一手乱牌然后在手中一张张重新插入将其整理有序在整个数据集上操作时计算的时间复杂度是O(N<sup>2</sup>)但快排、堆排序、归并排序等算法的时间复杂度只有O(N*logN),这就是通过分治策略,缩小子问题数据规模实现的。
比如下图是在8个数字上使用归并排序算法进行排序的流程。我们将数组递归地进行3log8轮对半拆分后每个子数组就只有2个元素。对2个元素排序只需要进行1次比较就能完成。接着再将有序的子数组不断地合并就可以得到完整的有序数组。
<img src="https://static001.geekbang.org/resource/image/8e/71/8e9f75013bcb26ae2befec6ff8739971.png" alt="">
其中将两个含有N/2个元素的有序子数组比如1、3、7、19和4、8、11、25合并为一个有序数组时只需要做N/2到N-1次比较图中只做了5次比较速度非常快。因此比较次数乘以迭代轮数就可以得出时间复杂度为O(N*logN)。
同样的道理引申到分布式系统中就成为了MapReduce模式。其中原始数据集要通过SPLIT步骤拆分到分布式系统中的多个节点中而每个节点并发执行用户预定义的MAP函数最后将MAP运算出的结果通过用户预定义的REDUCE函数归并为最终的结果。比如上例中我们可以将8个元素拆分到2个节点中并行计算其中每个节点究竟是继续采用归并排序还是使用其他排序算法这由预定义的MAP函数决定。当MAP函数生成有序的子数组后REDUCE函数再将它们归并为完整的有序数组具体如下图所示
<img src="https://static001.geekbang.org/resource/image/72/15/72bb89540bae52a46e69a5d802680715.png" alt="">
当面对TB、PB级别的数据时MapReduce思想就成了唯一的解决方案。当然在实际软件工程中实现MapReduce的框架要比上面的示意图复杂许多毕竟在大规模分布式系统中故障每时每刻都会发生如何分发数据、调度节点执行MAP映射、监控计算节点等都需要精心的设计。特别是当单个节点的磁盘无法存放下全部数据时常常使用类似HDFS的分布式文件系统存放数据所以MapReduce框架往往还需要对接这样的系统来获取数据具体如下图所示
[<img src="https://static001.geekbang.org/resource/image/4f/39/4f3182c6334ec0c7b67e69b6ded2e839.png" alt="" title="图片来源http://a4academics.com/tutorials/83-hadoop/840-map-reduce-architecture">](http://a4academics.com/tutorials/83-hadoop/840-map-reduce-architecture)
而且生产环境中的任务远比整数排序复杂得多所以写对Map、Reduce函数并不容易。另一方面大部分数据分析任务又是高度相似的所以我们没有必要总是直接编写Map、Reduce函数实现发布式系统的离线计算。由于SQL语言支持聚合分析、表关联还内置了许多统计函数很适合用来做数据分析它的学习成本又非常低所以大部分MapReduce框架都提供了类SQL语言的接口可以替代自行编写Map、Reduce函数。接下来我们看看SQL语言统计数据时Map、Reduce函数是怎样工作的。
## SQL是如何简化MapReduce模式的
我们以最常见的Web日志分析为例观察用SQL语言做统计时MapReduce流程是怎样执行的。举个例子Nginx的access.log访问日志是这样的基于默认的combined格式
```
127.0.0.1 - - [18/Jul/2020:10:16:15 +0800] &quot;GET /loginuserid=101 HTTP/1.1&quot; 200 56 &quot;-&quot; &quot;curl/7.29.0&quot;
```
你可以通过正则表达式取出客户端IP地址、用户名、HTTP响应码这样就可以生成结构化的数据表格
<img src="https://static001.geekbang.org/resource/image/e9/e7/e9fcf8e7529f973b1679af93333b4ee7.jpg" alt="">
如果我们想按照客户端IP、HTTP响应码聚合统计访问次数基于通用的SQL规则就可以写出下面这行SQL语句
```
select ClientIp, StatusCode, count(*) from access_log group by ClientIp, StatusCode
```
而建立在MapReduce之上的框架比如Hive会将它翻译成如下图所示的MapReduce流程
<img src="https://static001.geekbang.org/resource/image/4c/f9/4cb7443e0f9cdf2ba77fbbe230487ff9.png" alt="">
其中我们假定5行数据被拆分到2个节点中执行Map函数其中它们分别基于2行、3行这样小规模的数据集生成了正确的聚合统计结果。接着在Shuffle步骤基于key关键字排序后再交由Reduce函数归并出正确的结果。
除了这个例子中的count函数像max求最大值、min求最小值、distinct去重、sum求和、avg求平均数、median求中位数、stddev求标准差等函数都很容易分解为子任务并发执行最后归并出最终结果。
当多个数据集之间需要做交叉统计时SQL中的join功能包括内连接、左外连接、右外连接、全连接四种模式也很容易做关联查询。此时我们可以在并行计算的Map函数中把where条件中的关联字段作为key关键字经由Reduce阶段实现结果的关联。
由于MapReduce操作的数据集非常庞大还需要经由网络调度多台服务器才能完成计算因此任务的执行时延至少在分钟级所以通常不会服务于用户的实时请求而只是作为离线的异步任务将运算结果写入数据库。
## 小结
这一讲我们介绍了在集群中使用分治算法统计大规模数据的MapReduce模式。
当数据量很大或者计算时间过长时如果计算过程可以被分解为并发执行的子任务就可以基于MapReduce思想利用分布式集群的计算力完成任务。其中用户可以预定义在节点中并发执行的Map函数以及将Map输出的列表合并为最终结果的Reduce函数。
虽然MapReduce将并行计算抽象为统一的模型但开发Map、Reduce函数的成本还是太高了于是针对高频场景许多MapReduce之上的框架提供了类SQL语言接口通过group by的聚合、join连接以及各种统计函数我们就可以利用整个集群完成数据分析。
MapReduce模式针对的是静态数据也叫有边界数据它更多用于业务的事前或者事后处理流程中而做事中处理时必须面对实时、不断增长的无边界数据流此时MapReduce就无能为力了。下一讲我们将介绍处理无边界数据的流式计算框架。
## 思考题
最后留给你一道思考题。你遇到过哪些计算任务是无法使用MapReduce模式完成的欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,91 @@
<audio id="audio" title="29 | 流式计算:如何通过集群实现实时计算?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/88/5f8498f16a0c7f1286c952770e7ea788.mp3"></audio>
你好,我是陶辉。
上节课我们介绍了在有边界的存量数据上进行的MapReduce离线计算这节课我们来看看对于无边界数据怎样实时地完成流式计算。
对于不再变化的存量数据可以通过分而治之的MapReduce技术将数据划分到多台主机上并行计算由于待处理的数据量很大我们只能获得分钟级以上的时延。当面对持续实时产生动态数据的场景时业务上通常需要在秒级时延中及时地拿到运算结果。
比如,商家为了拉新促活,会为特定的用户群体(比如新用户或者不活跃用户)推出优惠活动,为了防止“羊毛党”通过大量主机并行地**“薅羊毛”**系统要能实时地聚合分析所有优惠券的使用者特点再基于业务规则及时地封掉“羊毛党”帐号或者IP地址才可以控制住风险范围提高营销活动的收益。那对于整个系统持续生成的大量订单数据怎样才能提供秒级的聚合分析结果呢
最初的流式计算方案是在时间维度上定期地将数据分片再基于MapReduce思想在空间维度的多台主机上实现并行计算这样也能获得实时计算结果。然而对每片数据执行批量计算想要在秒级甚至毫秒级拿到计算结果并不容易。当网络不稳定时数据会因为报文延误而乱序简单的基于时序分片会导致计算结果失真。当数据之间具有明显的业务关系时固定的时间窗口更是难以得到预期的分析结果。
接下来我们就深入学习一下流式计算的工作原理,以及流式计算常用的数据分片窗口。
## 流式计算是如何实现的?
在数据库、HDFS等分布式系统中存放的静态数据由于拥有清晰的边界所以被称为InBound Data有边界数据。然而线上运行中的互联网产品生命周期并不确定它产生的数据有明确的开始却没有截止时间点。对于这样有始无终的实时数据流我们把它称为OutBound Data无边界数据如下图所示
<img src="https://static001.geekbang.org/resource/image/bf/a0/bfe991c2d38eb5c2f3f2d271d7fbd7a0.jpg" alt="">
从业务需求上看有边界数据与无边界数据的计算目的是完全不同的。比如对于分布式监控系统我们需要基于IP地址、用户帐号、请求类型等许多特征进行定时的聚合统计例如获取每分钟内所有请求处理时延的平均值、中位数、最大值等监控系统性能。此时可以根据请求执行结果的产生时间对数据进行分片计算。比如下表中有7条监控数据需要求出每分钟请求时延的平均值。
<img src="https://static001.geekbang.org/resource/image/f1/36/f124f93afffdb388660d4236418ff236.jpg" alt="">
如果我们按照分钟整点对数据进行分片就可以在02:00时对蓝色的消息1、2求出窗口内的平均时延192毫秒并立刻返回结果。之后当接收完红色的消息3、4、5后在第2分钟结束时再对3个数字求出平均值。以此类推。
**这种设计思想就是基于固定时间窗口的批处理解决方案。**当然,并不是一定要等到时间窗口结束时,才对这一批次的所有数据统一计算。我们完全可以在每个消息到来时,就计算出中间状态,当所在的时间窗口结束时,再将中间状态转换为最终结果。仍然以上表为例,我们可以在每个监控事件到达时,计算出请求时延和以及当前窗口内的事件个数,这样,在窗口结束时我们只需要将时延和除以事件个数,就能得到平均值。
<img src="https://static001.geekbang.org/resource/image/7b/y5/7b5015d8221b32150c4bd1bfcd17byy5.jpg" alt="">
因此,中间状态可以更均衡地使用计算资源,提高流式计算的整体性能。我们既可以把中间状态放在内存中,也可以把它持久化到本地磁盘中获取更高的可用性,为了方便计算节点的调度,我们通常还会将备份状态存放至远端的数据库。
[<img src="https://static001.geekbang.org/resource/image/e6/56/e6518cbc0f727a9f5fde40cdccbd2f56.png" alt="" title="图片来源https://flink.apache.org/">](https://flink.apache.org/)
当然流式计算最主要的性能提升思路还是基于MapReduce思想将同一窗口的数据从空间维度中分发到不同的计算节点进行并行的Map计算再将Map映射出的结果Reduce为最终结果。由于流式计算天然是基于消息事件驱动的因此它往往直接从Kafka等消息队列中获取输入数据如同[[第27讲]](https://time.geekbang.org/column/article/261094) 的介绍,消息队列很容易协助流式计算实现数据拆分。
到这里,我们已经看到了实现流式计算的基本思路,其中基于固定时间窗口的数据划分方式还有很大的改进空间,目前它还无法解决较为复杂的有状态计算。所谓有状态计算,是指在时间窗口内,不同的消息之间会互相作用并影响最终的计算结果,比如求平均值就是这样一个例子,每个新到达的数据都会影响中间状态值。
<img src="https://static001.geekbang.org/resource/image/ce/da/ce5ef3b3fe17b5032b906a1f1658a6da.jpg" alt="">
相反无状态计算处理到达的数据时并不涉及窗口内的其他数据处理流程要简单的多。例如当监控到请求时延超过3秒时就产生一条告警。此时只需要单独地判断每个消息中的时延数据就能够得到计算结果。
在真实的业务场景中有状态计算还要更复杂。比如对两个不同的数据源可以理解为数据库中的表做join连接时采用内连接、外连接这两种不同的连接方式就会影响到我们的时间窗口长度。再比如当不同的事件具有逻辑关系时窗口长度则应该由业务规则确定不同的请求可能拥有不等的窗口大小。接下来我们再来看看流式计算中的几种常见窗口。
## 如何通过窗口确定待计算的数据?
首先来看滑动窗口它是从固定窗口衍生出的一种窗口。我们继续延续求每分钟平均值的例子当业务上需要更平滑的曲线时可以通过每20秒求最近1分钟请求时延的平均值实现这就是滑动窗口其中窗口长度则是1分钟但每次计算完并不会淘汰窗口中的全部数据而只是将步长向后移动20秒即只淘汰最早20秒中的数据。当窗口长度与步长一致时滑动窗口就退化成了固定窗口。
当然我们还可以把窗口的计量单位从时间改为事件个数此时可以称为计数窗口。仍然延续上面的例子固定计数窗口可以改为求每100个访问记录的平均时延滑动计数窗口可以改为每10条记录中求最近100个记录的平均时延。由于消息本身是有时序的所以这些都可以称为时间驱动的窗口。事实上还有另外一种事件驱动的窗口与此完全不同如下图所示
<img src="https://static001.geekbang.org/resource/image/75/57/75dd62dd3a90c027a4e8ae95389dea57.jpg" alt="">
固定窗口、滑动窗口并不会解析业务字段区别对待图中不同的Key关键字这就很难解决以下这类场景当需要统计用户在一个店铺内浏览的商品数量时就需要针对用户的店铺停留时长来设计动态的窗口大小。毕竟不同的用户在不同的店铺内停留时长不可能相同此时动态的窗口大小可以通过事件来驱动我们称为会话窗口。
事实上,我们还面临着信息统计准确性上的问题。在基于时间驱动的窗口中,这里的时间其实是事件到达流式系统时产生的系统处理时间,而不是事件发生的时间。仍然以访问日志为例,每条日志都有明确的请求访问时间,但在分布式系统传输时,由于网络波动的传输时延,以及各主机节点应用层的处理时延,这些事件到达流式计算框架的顺序已经发生了变化。如果仍然以固定的时间窗口来处理,就会得到错误的统计结果。
为了避免乱序事件扰乱统计结果我们可以使用水位线Watermark减少乱序概率。比如下图中消息队列中的数字表示事件时间其中事件7先于事件3、5到达了流式计算系统
<img src="https://static001.geekbang.org/resource/image/dd/56/dda5bbfa757d04d41b26d5a97b4bca56.jpg" alt="">
如果设置了水位4窗口就不再以事件顺序严格划分而是通过水位上的时间来划分窗口这样事件7就会放在第2个窗口中处理
<img src="https://static001.geekbang.org/resource/image/bd/ce/bdcc6f9a03f5572882c0f59e5e8db2ce.jpg" alt="">
当然并不是有了水位线第1个窗口就会无限制的等下去。在经历一个时间段后第1个窗口会认定窗口关闭这未必准确它会处理3、1、3、2这4个事件。基于业务规则下一个水位被设置为9
<img src="https://static001.geekbang.org/resource/image/7f/46/7fc2a8daa079d547a75bb257f08cd346.jpg" alt="">
这样第2个窗口会处理6、5、7事件而事件9就放在了第3个窗口中处理
<img src="https://static001.geekbang.org/resource/image/c0/0b/c032e4d98cfc51f29aeb4da6f4ec370b.jpg" alt="">
以此类推。根据业务特性和经验值,找到最大乱序时间差,再基于此设置合适的水位线,就能减轻乱序事件的影响。
## 小结
这一讲我们介绍了流式计算的实现原理,以及常用的几种分片窗口。
对于无边界的实时数据流我们可以在时间维度上将其切分到不同的窗口中再将每个窗口内的数据从空间维度上分发到不同的节点并行计算在窗口结束时汇总结果这就实现了流式计算。Apache [Flink](https://zh.wikipedia.org/wiki/Apache_Flink)、[Spark](https://en.wikipedia.org/wiki/Apache_Spark)、[Storm](https://en.wikipedia.org/wiki/Apache_Storm) 等开源产品都是这样的流式计算框架。
通过不同的窗口划分规则可以实现不同的计算目的包括以时间驱动的固定窗口、滑动窗口和计数窗口以及以事件驱动的会话窗口。为了避免乱序事件的影响还可以通过携带超时时间的Watermark水位基于事件发生时间更精准地划分窗口。
## 思考题
最后留给你一道讨论题。你知道Lambda架构吗它通过分开部署的MapReduce、流式计算系统分别完成离线计算与实时流计算如下图所示
[<img src="https://static001.geekbang.org/resource/image/bc/0f/bc3760eaf9e5789c2459fdb4e03ea00f.png" alt="" title="图片来源https://www.oreilly.com/radar/questioning-the-lambda-architecture/">](https://www.oreilly.com/radar/questioning-the-lambda-architecture/)
这套系统的IT成本很高因此大家致力于使用一套系统同时解决这两个问题。你认为这种解决方案是如何实现的你又是如何看待流式计算发展方向的欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给你的朋友。

View File

@@ -0,0 +1,153 @@
<audio id="audio" title="30 | 如何权衡关系数据库与NoSQL数据库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/d8/c0beca9aab73685fc6efce7d396c7fd8.mp3"></audio>
你好,我是陶辉。
到了第4部分课程的最后一讲我们来结合前面介绍过的知识点看看面对NoSQL、关系数据库时该如何选择。
在分布式系统中我们会同时使用多种数据库。比如你可能会在Redis中存放用户Session会话将业务数据拆解为由行、列构成的二维表存储在MySQL中将需要全文检索的数据放在ElasticSearch中将知识图谱放在Neo4j图数据库中将数据量、访问量很大的数据放在Cassandra列式数据库或者MongoDB文档型数据库中等等。
选择数据库时我们的依据可能是访问速度比如基于哈希表的Redis查询复杂度只有O(1)也可能从事务的支持程度上选择了关系数据库甚至从应用层的开发效率上还给它添加了Hibernate等ORM框架也可能从处理数据的体量上选择了NoSQL数据库。可是除了各种实现层面上的差异外各类NoSQL与关系数据库之间有没有最本质的区别在实际工程中我们可否从此入手确定大方向再从细微处选择不同的实现
在我看来答案就在于“关系”这两个字这也是我权衡数据库时最先考虑的前提。接下来我们就沿着关系数据库的特性看看NoSQL数据库究竟做了哪些改变我们又该如何选择它们。
## 关系数据库的优点
关系数据库对业务层开发效率的提升有很大帮助。下面我们先基于一个简单的例子,看看关系数据库有何优点。疫情期间新增了一批能够测量体温的考勤机,通过关系数据库我们新建了用户、考勤机、考勤记录三张表,如下图所示:
<img src="https://static001.geekbang.org/resource/image/60/25/60870a7eebd59fbcfb87298c3ed31025.jpg" alt="">
在关系数据库中表中的每行数据由多个从属于列的单一值比如数字、字符串构成。虽然表中可以存放任意行数据但列却是预先定义且不变的因此我们很容易通过行、列交汇处的单一值进行关联操作进而完成各类业务目的不同的查询。比如业务开发者可以通过下面这行SQL语句找到体温超过37度的员工上报其姓名、测量时间以及所在地理位置
```
select user.name, record.time, machine.location from user, record, machine where user.id = record.user_id and machine.id = record.machine_id and record.temporature &gt; 37;
```
运营人员则可以通过下面这行SQL语句找出各类考勤机的使用频率
```
select count(*), machine.id from machine, record where machine.id = record.machine_id group py machine.id;
```
因此,关系数据库**可以通过预定义的关系,由数据库自身完成复杂的逻辑计算,为不同的场景提供数据服务。**由于不同的数据间具有了关系,关系数据库还提供了“[Transaction事务](https://en.wikipedia.org/wiki/Database_transaction)”用于保证相关数据间的一致性这大大释放了应用开发者的生产力。所谓“事务”会同时具有ACID4个特性
**Atomicity原子性**指多个SQL语句组成了一个逻辑单位执行时要么全部成功要么全部失败。
**Consistency一致性**,指数据库只能从一个一致性状态转换到另一个一致性状态。即使数据库发生了重启,仍然得维持一致性。
**Isolation隔离性**由于数据库可以支持多个连接并发操作因此并发的事务间必须互相隔离才有意义。SQL标准定义了以下4种隔离级别
- READ UNCOMMITTED未提交读它表示在事务A还未提交时并发执行的事务B已经可以看到事务A改变的数据。这种隔离级别会带来很多问题因此很少使用。
- READ COMMITTED提交读它表示当事务A未提交时事务B看不到事务A改变的任何数据这是PostgreSQL数据库的默认隔离级别。
- REPEATABLE READ可重复读指在READ COMMITTED的基础上解决了脏读问题。所谓脏读是指在一个事务内多次读取到同一数据时结果可能不一致。这是MySQL数据库的默认隔离级别。
- SERIALIZABLE可串行化它通过对每一行数据加锁使得所有事务串行执行虽然隔离性最好但也大大降低了数据库的并发性所以很少使用。
**Durability持久性**,指一旦事务提交,事务所做的修改必须永久性地保存到数据库中。
可见事务的ACID特性简化了本应由应用层完成的流程这也是关系数据库与NoSQL数据库之间最大的差别。除事务外关系数据库还在以下4点上降低了应用层的开发成本
- 无论是商业版的Oracle还是开源的MySQL、PostgreSQL**只要是关系数据库就拥有同样的数据模型,因此它们可以通过**[SQL](https://zh.wikipedia.org/wiki/SQL)** 语言为应用层提供标准化、几乎没有差异的访问接口;**
- 生产级的数据库对持久化都有良好的支持,全面的冷备、热备方案提供了很高的可用性;
- 通过索引、缓存等特性,当行数在亿级以下时,关系数据库的性能并不低;
- 关系数据库支持还不错的并发度,一般可以服务于上千个并发连接。
所以应用层会将许多计算任务放在关系数据库中在此基础上还诞生了MVC等将数据层从业务中剥离、以关系数据库为中心的架构。
## 关系数据库的问题
虽然基于单一值的关系映射提供了事务等许多功能但同时也引入了3个问题。
首先,内存中的数据结构非常多样,难以直接映射到行列交汇处的单一值上。不过,**这个问题可以通过**[ORMObject-relational mapping](https://en.wikipedia.org/wiki/Object-relational_mapping)**框架解决。**比如Python中的Django ORM框架可以将上述3张表映射为内存中的3个类
```
from django.db import models
class User(models.Model):
name = models.CharField(max_length=20)
class Machine(models.Model):
location = models.CharField(max_length=100)
class Record(models.Model):
time = models.DateTimeField()
temporature = models.FloatField()
user = models.ForeignKey(User)
machine= models.ForeignKey(Machine)
```
ORM框架会为每张表生成id字段而Record表将User和Machine表中的id字段作为外键ForeignKey互相关联在一起。于是这3个类就映射了数据库中的那3张表而内存中的对象即类的实例则映射为每张表中的一行数据。在ORM框架下找到体温大于37度员工的那串长SQL可以转化为OOP中的函数调用如下所示
```
#gte表示大于等于
records = Record.objects.filter(temporature__gte = 37)
for r in records:
print(r.user.name, r.machine.location, r.time)
```
相比起SQL语句映射后的OO编程要简单许多。
其次为了实现关系映射每张表中的字段都得预先定义好一旦在产品迭代过程中数据模型发生了变化便需要同步完成以下3件事
- 修改表结构;
- 修改应用层操作数据的代码;
- 根据新的规则转换、迁移已有数据。
在ORM中我们可以把这3步放在一个migration迁移脚本中完成。当然如果数据迁移成本高、时间长可以设计更复杂的灰度迁移方案。
**最后是关系数据库固有的可伸缩性问题这是各类NoSQL数据库不断诞生的主要原因。**在[[第21讲]](https://time.geekbang.org/column/article/252741) 中我们介绍过沿AKF X轴扩展的复制型主从结构然而单点主库无法解决数据持续增长引入的性能问题。
<img src="https://static001.geekbang.org/resource/image/0c/16/0cf179648bf05bf38fb192c7ca797916.png" alt="">
沿AKF Z轴扩展数据库虽然能够降低数据规模但分库分表后单一值关系引申出的ACID事务不能基于高时延、会抖动的网络传输强行实现否则会导致性能大幅下降这样的可用性是分布式系统无法接受的。
<img src="https://static001.geekbang.org/resource/image/44/f4/44c738618f8e947372969be96c525cf4.png" alt="">
因此在单机上设计出的关系数据库难以处理PB级的大数据。而NoSQL数据库放弃了单一值数据模型非常适合部署在成千上万个节点的分布式环境中。
## NoSQL数据库是如何解决上述问题的
虽然所有的NoSQL数据库都无法实现标准的SQL语言接口但NoSQL绝不是“No SQL拒绝SQL语言”的意思。当然NoSQL也不是“Not Only SQL不只是SQL语言”的意思否则Oracle也能算NoSQL数据库了。实际上没有必要纠结NoSQL的字面含义NoSQL数据库只是放弃了与分布式环境相悖的ACID事务提供了另一种聚合数据模型从而拥有可伸缩性的非关系数据库。
NoSQL数据库可以分为以下4类
[Key/Value数据库](https://en.wikipedia.org/wiki/Key%E2%80%93value_database),通常基于哈希表实现(参见[[第3讲]](https://time.geekbang.org/column/article/232351)性能非常好。其中Value的类型通常由应用层代码决定当然Redis这样的Key/Value数据库还可以将Value定义为列表、哈希等复合结构。
[文档型数据库](https://en.wikipedia.org/wiki/Document-oriented_database)在Key/Value数据库中由于没有预定义的值结构所以只能针对Key执行查询这大大限制了使用场景。**文档型数据库将Value扩展为XML、JSON比如MongoDB等数据结构于是允许使用者在文档型数据库的内部解析复合型的Value结构再通过其中的单一值进行查询这就兼具了部分关系数据库的功能。**
[列式数据库](https://en.wikipedia.org/wiki/Column-oriented_DBMS),比如[[第22讲]](https://time.geekbang.org/column/article/254600) 介绍过的Cassandra。列式数据库基于Key来映射行再通过列名进行二级映射同时它基于列来安排存储的拓扑结构这样当仅读写大量行中某个列时操作的数据节点、磁盘非常集中磁盘IO、网络IO都会少很多。列式数据库的应用场景非常有针对性比如博客文章标签的行数很多但在做数据分析时往往只读取标签列这就很适合使用列式数据库。再比如**通过倒排索引实现了全文检索的ElasticSearch就适合使用列式存储存放Doc Values这样做排序、聚合时非常高效。**
[图数据库](https://en.wikipedia.org/wiki/Graph_database)在社交关系、知识图谱等场景中携带各种属性的边可以表示节点间的关系由于节点的关系数量多而且非常容易变化所以关系数据库的实现成本很高而图数据库既没有固定的数据模型遍历关系的速度也非常快很适合处理这类问题。当然我们日常见到的主要是前3类NoSQL数据库。
相对于关系数据库NoSQL在性能和易用性上都有明显的优点。
首先我们来看可用性及性能这是NoSQL数据库快速发展的核心原因
- NoSQL数据库的可伸缩性都非常好。虽然许多文档型、列式数据库都提供了类SQL语言接口但这只是为了降低用户的学习成本它们对跨节点事务的支持极其有限。因此这些NoSQL数据库可以放开手脚基于Key/Value模型沿AKF Z轴将系统扩展到上万个节点。
- 在数据基于Key分片后很容易通过[[第28讲]](https://time.geekbang.org/column/article/264122) 介绍过的MapReduce思想提高系统的计算能力。比如MongoDB很自然的就在查询接口中提供了[MapReduce](https://docs.mongodb.com/manual/core/map-reduce/) 函数。
- 通过冗余备份NoSQL可以提供优秀的容灾能力。比如Redis、Cassandra等数据库都可以基于[[第22讲]](https://time.geekbang.org/column/article/254600) 介绍过的NWR算法灵活地调整CAP权重。
- 如果每个Key中Value存放的复合数据已经能满足全部业务需求那么NoSQL的单机查询速度也会优于关系数据库。
其次再来看易用性这主要体现在我们可以低成本地变更Value结构。虽然NoSQL数据库支持复合型Value结构但并不限定结构类型。比如文档型数据库中同一个表中的两行数据其值可以是完全不同的JSON结构同样的列式数据库中两行数据也可以拥有不同的列数。因此当数据结构改变时只需要修改应用层操作数据的代码并不需要像关系数据库那样同时修改表结构以及迁移数据。
那么到底该如何选择关系数据库与NoSQL数据库呢其实沿着“单一值关系”这一线索我们已经找到了各自适用的场景。
如果多个业务数据间互相关联我们需要从多个不同的角度分析、计算并保持住相关数据的一致性那么关系数据库最为适合。一旦数据行数到了亿级别以上就需要放弃单一值结构将单行数据聚合为复合结构放在可以自由伸缩的NoSQL数据库中。此时我们无法寄希望于NoSQL数据库提供ACID事务只能基于二段式提交等算法在应用层代码中自行实现事务。
## 小结
这一讲我们介绍了关系数据库与NoSQL数据库各自的特点及其适用场景。
关系数据库通过行、列交汇处的单一值实现了多种数据间的关联。通过统一的SQL接口用户可以在数据库中实现复杂的计算任务。为了维持关联数据间的一致性关系数据库提供了拥有ACID特性的事务提升了应用层的开发效率。
虽然单一值无法映射内存中的复合数据结构但通过ORM框架关系数据库可以将表映射为面向对象编程中的类将每行数据映射为对象继续降低开发成本。然而关系数据库是为单机设计的一旦将事务延伸到分布式系统中执行成本就会高到影响基本的可用性。因此关系数据库的可伸缩性是很差的。
NoSQL数据库基于Key/Value数据模型可以提供几乎无限的可伸缩性。同时将Value值进一步设计为复合结构后既可以增加查询方式的多样性也可以通过MapReduce提升系统的计算能力。实际上关系数据库与每一类NoSQL数据库都有明显的优缺点我们可以从数据模型、访问方式、数据容量上观察它们结合具体的应用场景权衡取舍。
## 思考题
最后留给你一道讨论题。你在选择NoSQL与关系数据库时是如何考虑的欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给身边的朋友。

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种常见“绊脚石”及其应对策略那通过了解它们我们是否就有十足的信心能达到性能巅峰了呢
其实在实践中,我们往往还是很难的,特别是当前微服务大行其道,决定我们系统性能的因素往往是下游微服务,而下游微服务可能来源于不同的团队或组织。这时,已经不再单纯是技术本身的问题了,而是沟通、协调甚至是制度等问题。但是好在对于下游微服务,我们依然可以使用上面的分析思路来找出问题所在,不过通过上面的各种分析你也可以知道,让性能做到极致还是很难的,总有一些情况超出我们的预期,例如我们使用的磁盘发生损害,彻底崩溃前也会引起性能下降。
另外一个值得思考的问题是,是否有划算的成本收益比去做无穷无尽的优化,当然对于技术极客来说,能不能、让不让解决问题也许不是最重要的,剥丝抽茧、了解真相才是最有成就感的事儿。
感谢阅读,希望今天的分享能让你有所收获!如果你发现除了上述我介绍的那些“绊脚石”外,还有其它一些典型情况存在,也欢迎你在留言区中分享出来作为补充。

View File

@@ -0,0 +1,166 @@
<audio id="audio" title="01 | CPU缓存怎样写代码能够让CPU执行得更快" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/1d/7b26aa9f7d8ba80b35c0ecaba009151d.mp3"></audio>
你好,我是陶辉。
这是课程的第一讲我们先从主机最重要的部件CPU开始聊聊如何通过提升CPU缓存的命中率来优化程序的性能。
任何代码的执行都依赖CPU通常使用好CPU是操作系统内核的工作。然而当我们编写计算密集型的程序时CPU的执行效率就开始变得至关重要。由于CPU缓存由更快的SRAM构成内存是由DRAM构成的而且离CPU核心更近如果运算时需要的输入数据是从CPU缓存而不是内存中读取时运算速度就会快很多。所以了解CPU缓存对性能的影响便能够更有效地编写我们的代码优化程序性能。
然而很多同学并不清楚CPU缓存的运行规则不知道如何写代码才能够配合CPU缓存的工作方式这样便放弃了可以大幅提升核心计算代码执行速度的机会。而且越是底层的优化适用范围越广CPU缓存便是如此它的运行规则对分布式集群里各种操作系统、编程语言都有效。所以一旦你能掌握它集群中巨大的主机数量便能够放大优化效果。
接下来我们就看看CPU缓存结构到底是什么样的又该如何优化它
## CPU的多级缓存
刚刚我们提到CPU缓存离CPU核心更近由于电子信号传输是需要时间的所以离CPU核心越近缓存的读写速度就越快。但CPU的空间很狭小离CPU越近缓存大小受到的限制也越大。所以综合硬件布局、性能等因素CPU缓存通常分为大小不等的三级缓存。
CPU缓存的材质SRAM比内存使用的DRAM贵许多所以不同于内存动辄以GB计算它的大小是以MB来计算的。比如在我的Linux系统上离CPU最近的一级缓存是32KB二级缓存是256KB最大的三级缓存则是20MBWindows系统查看缓存大小可以用wmic cpu指令或者用[CPU-Z](https://www.cpuid.com/softwares/cpu-z.html)这个工具)。
<img src="https://static001.geekbang.org/resource/image/de/87/deff13454dcb6b15e1ac4f6f538c4987.png" alt="">
你可能注意到三级缓存要比一、二级缓存大许多倍这是因为当下的CPU都是多核心的每个核心都有自己的一、二级缓存但三级缓存却是一颗CPU上所有核心共享的。
程序执行时会先将内存中的数据载入到共享的三级缓存中再进入每颗核心独有的二级缓存最后进入最快的一级缓存之后才会被CPU使用就像下面这张图。
<img src="https://static001.geekbang.org/resource/image/92/0c/9277d79155cd7f925c27f9c37e0b240c.jpg" alt="">
缓存要比内存快很多。CPU访问一次内存通常需要100个时钟周期以上而访问一级缓存只需要4~5个时钟周期二级缓存大约12个时钟周期三级缓存大约30个时钟周期对于2GHZ主频的CPU来说一个时钟周期是0.5纳秒。你可以在LZMA的[Benchmark](https://www.7-cpu.com/)中找到几种典型CPU缓存的访问速度
如果CPU所要操作的数据在缓存中则直接读取这称为缓存命中。命中缓存会带来很大的性能提升**因此我们的代码优化目标是提升CPU缓存的命中率。**
当然缓存命中率是很笼统的具体优化时还得一分为二。比如你在查看CPU缓存时会发现有2个一级缓存比如Linux上就是上图中的index0和index1这是因为CPU会区别对待指令与数据。比如“1+1=2”这个运算“+”就是指令会放在一级指令缓存中而“1”这个输入数字则放在一级数据缓存中。虽然在冯诺依曼计算机体系结构中代码指令与数据是放在一起的但执行时却是分开进入指令缓存与数据缓存的因此我们要分开来看二者的缓存命中率。
## 提升数据缓存的命中率
我们先来看数据的访问顺序是如何影响缓存命中率的。
比如现在要遍历二维数组其定义如下这里我用的是伪代码在GitHub上我为你准备了可运行验证的C/C++、Java[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array),你可以参照它们编写出其他语言的可执行代码):
```
int array[N][N];
```
你可以思考一下用array[j][i]和array[i][j]访问数组元素,哪一种性能更快?
```
for(i = 0; i &lt; N; i+=1) {
for(j = 0; j &lt; N; j+=1) {
array[i][j] = 0;
}
}
```
在我给出的GitHub地址上的C++代码实现中前者array[j][i]执行的时间是后者array[i][j]的8倍之多请参考[traverse_2d_array.cpp](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_2d_array)如果使用Python代码traverse_2d_array.py由于数组容器的差异性能差距不会那么大
为什么会有这么大的差距呢这是因为二维数组array所占用的内存是连续的比如若长度N的值为2那么内存中从前至后各元素的顺序是
```
array[0][0]array[0][1]array[1][0]array[1][1]。
```
如果用array[i][j]访问数组元素则完全与上述内存中元素顺序一致因此访问array[0][0]时缓存已经把紧随其后的3个元素也载入了CPU通过快速的缓存来读取后续3个元素就可以。如果用array[j][i]来访问,访问的顺序就是:
```
array[0][0]array[1][0]array[0][1]array[1][1]
```
此时内存是跳跃访问的如果N的数值很大那么操作array[j][i]时是没有办法把array[j+1][i]也读入缓存的。
到这里我们还有2个问题没有搞明白
1. 为什么两者的执行时间有约7、8倍的差距呢
1. 载入array[0][0]元素时,缓存一次性会载入多少元素呢?
其实这两个问题的答案都与CPU Cache Line相关它定义了缓存一次载入数据的大小Linux上你可以通过coherency_line_size配置查看它通常是64字节。
<img src="https://static001.geekbang.org/resource/image/7d/de/7dc8d0c5a1461d9aed086e7a112c01de.png" alt="">
因此我测试的服务器一次会载入64字节至缓存中。当载入array[0][0]时若它们占用的内存不足64字节CPU就会顺序地补足后续元素。顺序访问的array[i][j]因为利用了这一特点所以就会比array[j][i]要快。也正因为这样当元素类型是4个字节的整数时性能就会比8个字节的高精度浮点数时速度更快因为缓存一次载入的元素会更多。
**因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。**
再来看为什么执行时间相差8倍。在二维数组中其实第一维元素存放的是地址第二维存放的才是目标元素。由于64位操作系统的地址占用8个字节32位操作系统是4个字节因此每批Cache Line最多也就能载入不到8个二维数组元素所以性能差距大约接近8倍。用不同的步长访问数组也能验证CPU Cache Line对性能的影响可参考我给你准备的[Github](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/traverse_1d_array)上的测试代码)。
关于CPU Cache Line的应用其实非常广泛如果你用过Nginx会发现它是用哈希表来存放域名、HTTP头部等数据的这样访问速度非常快而哈希表里桶的大小如server_names_hash_bucket_size它默认就等于CPU Cache Line的值。由于所存放的字符串长度不能大于桶的大小所以当需要存放更长的字符串时就需要修改桶大小但Nginx官网上明确建议它应该是CPU Cache Line的整数倍。
<img src="https://static001.geekbang.org/resource/image/4f/2b/4fa0080e0f688bd484fe701686e6262b.png" alt="">
为什么要做这样的要求呢就是因为按照cpu cache line比如64字节来访问内存时不会出现多核CPU下的伪共享问题可以**尽量减少访问内存的次数**。比如若桶大小为64字节那么根据地址获取字符串时只需要访问一次内存而桶大小为50字节会导致最坏2次访问内存而70字节最坏会有3次访问内存。
如果你在用Linux操作系统可以通过一个名叫Perf的工具直观地验证缓存命中的情况可以用yum install perf或者apt-get install perf安装这个工具这个[网址](http://www.brendangregg.com/perf.html)中有大量案例可供参考)。
执行perf stat可以统计出进程运行时的系统信息通过-e选项指定要统计的事件如果要查看三级缓存总的命中率可以指定缓存未命中cache-misses事件以及读取缓存次数cache-references事件两者相除就是缓存的未命中率用1相减就是命中率。类似的通过L1-dcache-load-misses和L1-dcache-loads可以得到L1缓存的命中率此时你会发现array[i][j]的缓存命中率远高于array[j][i]。
当然perf stat还可以通过指令执行速度反映出两种访问方式的优劣如下图所示instructions事件指明了进程执行的总指令数而cycles事件指明了运行的时钟周期二者相除就可以得到每时钟周期所执行的指令数缩写为IPC。如果缓存未命中则CPU要等待内存的慢速读取因此IPC就会很低。array[i][j]的IPC值也比array[j][i]要高得多):
<img src="https://static001.geekbang.org/resource/image/29/1c/29d4a9fa5b8ad4515d7129d71987b01c.png" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/94/3d/9476f52cfc63825e7ec836580e12c53d.png" alt="">
## 提升指令缓存的命中率
说完数据的缓存命中率,再来看指令的缓存命中率该如何提升。
我们还是用一个例子来看一下。比如有一个元素为0到255之间随机数字组成的数组
```
int array[N];
for (i = 0; i &lt; TESTN; i++) array[i] = rand() % 256;
```
接下来要对它做两个操作一是循环遍历数组判断每个数字是否小于128如果小于则把元素的值置为0二是将数组排序。那么先排序再遍历速度快还是先遍历再排序速度快呢
```
for(i = 0; i &lt; N; i++) {
if (array [i] &lt; 128) array[i] = 0;
}
sort(array, array +N);
```
我先给出答案先排序的遍历时间只有后排序的三分之一参考GitHub中的[branch_predict.cpp代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict)。为什么会这样呢这是因为循环中有大量的if条件分支而CPU**含有分支预测器**。
当代码中出现if、switch等语句时意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行比如if还是else中的指令就可以提前把这些指令放在缓存中CPU执行时就会很快。当数组中的元素完全随机时分支预测器无法有效工作而当array数组有序时分支预测器会动态地根据历史命中数据对未来进行预测命中率就会非常高。
究竟有多高呢我们还是用Linux上的perf来做个验证。使用 -e选项指明branch-loads事件和branch-load-misses事件它们分别表示分支预测的次数以及预测失败的次数。通过L1-icache-load-misses也能查看到一级缓存中指令的未命中情况。
下图是我在GitHub上为你准备的验证程序执行的perf分支预测统计数据代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/branch_predict)),你可以看到,先排序的话分支预测的成功率非常高,而且一级指令缓存的未命中率也有大幅下降。
<img src="https://static001.geekbang.org/resource/image/29/72/2902b3e08edbd1015b1e9ecfe08c4472.png" alt="">
<img src="https://static001.geekbang.org/resource/image/95/60/9503d2c8f7deb3647eebb8d68d317e60.png" alt="">
C/C++语言中编译器还给应用程序员提供了显式预测分支概率的工具如果if中的条件表达式判断为“真”的概率非常高我们可以用likely宏把它括在里面反之则可以用unlikely宏。当然CPU自身的条件预测已经非常准了仅当我们确信CPU条件预测不会准且我们能够知晓实际概率时才需要加入这两个宏。
```
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (likely(a == 1)) …
```
## 提升多核CPU下的缓存命中率
前面我们都是面向一个CPU核心谈数据及指令缓存的然而现代CPU几乎都是多核的。虽然三级缓存面向所有核心但一、二级缓存是每颗核心独享的。我们知道即使只有一个CPU核心现代分时操作系统都支持许多进程同时运行。这是因为操作系统把时间切成了许多片微观上各进程按时间片交替地占用CPU这造成宏观上看起来各程序同时在执行。
因此若进程A在时间片1里使用CPU核心1自然也填满了核心1的一、二级缓存当时间片1结束后操作系统会让进程A让出CPU基于效率并兼顾公平的策略重新调度CPU核心1以防止某些进程饿死。如果此时CPU核心1繁忙而CPU核心2空闲则进程A很可能会被调度到CPU核心2上运行这样即使我们对代码优化得再好也只能在一个时间片内高效地使用CPU一、二级缓存了下一个时间片便面临着缓存效率的问题。
因此操作系统提供了将进程或者线程绑定到某一颗CPU上运行的能力。如Linux上提供了sched_setaffinity方法实现这一功能其他操作系统也有类似功能的API可用。我在GitHub上提供了一个示例程序代码见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/1-cpu_cache/cpu_migrate)你可以看到当多线程同时执行密集计算且CPU缓存命中率很高时如果将每个线程分别绑定在不同的CPU核心上性能便会获得非常可观的提升。Perf工具也提供了cpu-migrations事件它可以显示进程从不同的CPU核心上迁移的次数。
## 小结
今天我给你介绍了CPU缓存对程序性能的影响。这是很底层的性能优化它对各种编程语言做密集计算时都有效。
CPU缓存分为数据缓存与指令缓存对于数据缓存我们应在循环体中尽量操作同一块内存上的数据由于缓存是根据CPU Cache Line批量操作数据的所以顺序地操作连续内存数据时也有性能提升。
对于指令缓存有规律的条件分支能够让CPU的分支预测发挥作用进一步提升执行效率。对于多核系统如果进程的缓存命中率非常高则可以考虑绑定CPU来提升缓存命中率。
## 思考题
最后请你思考下多线程并行访问不同的变量这些变量在内存布局是相邻的比如类中的多个变量此时CPU缓存就会失效为什么又该如何解决呢欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="02 | 内存池:如何提升内存分配的效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/72/4dd6f9a040c1699d6c4daf496517a472.mp3"></audio>
你好,我是陶辉。
上一讲我们提到高频地命中CPU缓存可以提升性能。这一讲我们把关注点从CPU转移到内存看看如何提升内存分配的效率。
或许有同学会认为我又不写底层框架内存分配也依赖虚拟机并不需要应用开发者了解。如果你也这么认为我们不妨看看这个例子在Linux系统中用Xmx设置JVM的最大堆内存为8GB但在近百个并发线程下观察到Java进程占用了14GB的内存。为什么会这样呢
这是因为绝大部分高级语言都是用C语言编写的包括Java申请内存必须经过C库而C库通过预分配更大的空间作为内存池来加快后续申请内存的速度。这样预分配的6GB的C库内存池就与JVM中预分配的8G内存池叠加在一起造成了Java进程的内存占用超出了预期。
掌握内存池的特性既可以避免写程序时内存占用过大导致服务器性能下降或者进程OOMOut Of Memory内存溢出被系统杀死还可以加快内存分配的速度。在系统空闲时申请内存花费不了多少时间但是对于分布式环境下繁忙的多线程服务获取内存的时间会上升几十倍。
另一方面,内存池是非常底层的技术,当我们理解它后,可以更换适合应用场景的内存池。在多种编程语言共存的分布式系统中,内存池有很广泛的应用,优化内存池带来的任何微小的性能提升,都将被分布式集群巨大的主机规模放大,从而带来整体上非常可观的收益。
接下来,我们就通过对内存池的学习,看看如何提升内存分配的效率。
## 隐藏的内存池
实际上在你的业务代码与系统内核间往往有两层内存池容易被忽略尤其是其中的C库内存池。
当代码申请内存时首先会到达应用层内存池如果应用层内存池有足够的可用内存就会直接返回给业务代码否则它会向更底层的C库内存池申请内存。比如如果你在Apache、Nginx等服务之上做模块开发这些服务中就有独立的内存池。当然Java中也有内存池当通过启动参数Xmx指定JVM的堆内存为8GB时就设定了JVM堆内存池的大小。
你可能听说过Google的TCMalloc和FaceBook的JEMalloc它们也是C库内存池。当C库内存池无法满足内存申请时才会向操作系统内核申请分配内存。如下图所示
<img src="https://static001.geekbang.org/resource/image/89/6a/893edd82d03c628fae83b95bd4fbba6a.jpg" alt="">
回到文章开头的问题Java已经有了应用层内存池为什么还会受到C库内存池的影响呢这是因为除了JVM负责管理的堆内存外Java还拥有一些堆外内存由于它不使用JVM的垃圾回收机制所以更稳定、持久处理IO的速度也更快。这些堆外内存就会由C库内存池负责分配这是Java受到C库内存池影响的原因。
其实不只是Java几乎所有程序都在使用C库内存池分配出的内存。C库内存池影响着系统下依赖它的所有进程。我们就以Linux系统的默认C库内存池Ptmalloc2来具体分析看看它到底对性能发挥着怎样的作用。
C库内存池工作时会预分配比你申请的字节数更大的空间作为内存池。比如说当主进程下申请1字节的内存时Ptmalloc2会预分配132K字节的内存Ptmalloc2中叫Main Arena应用代码再申请内存时会从这已经申请到的132KB中继续分配。
如下所示(你可以在[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/alloc_address)找到示例程序注意地址的单位是16进制
```
# cat /proc/2891/maps | grep heap
01643000-01664000 rw-p 00000000 00:00 0 [heap]
```
当我们释放这1字节时Ptmalloc2也不会把内存归还给操作系统。Ptmalloc2认为与其把这1字节释放给操作系统不如先缓存着放进内存池里仍然当作用户态内存留下来进程再次申请1字节的内存时就可以直接复用这样速度快了很多。
你可能会想132KB不多呀为什么这一讲开头提到的Java进程会被分配了几个GB的内存池呢这是因为**多线程与单线程的预分配策略并不相同**。
每个**子线程预分配的内存是64MB**Ptmalloc2中被称为Thread Arena32位系统下为1MB64位系统下为64MB。如果有100个线程就将有6GB的内存都会被内存池占用。当然并不是设置了1000个线程就会预分配60GB的内存子线程内存池最多只能到8倍的CPU核数比如在32核的服务器上最多只会有256个子线程内存池但这也非常夸张了16GB64MB * 256 = 16GB的内存将一直被Ptmalloc2占用。
回到本文开头的问题Linux下的JVM编译时默认使用了Ptmalloc2内存池因此每个线程都预分配了64MB的内存这造成含有上百个Java线程的JVM多使用了6GB的内存。在多数情况下这些预分配出来的内存池可以提升后续内存分配的性能。
然而Java中的JVM内存池已经管理了绝大部分内存确实不能接受莫名多出来6GB的内存那该怎么办呢既然我们知道了Ptmalloc2内存池的存在就有两种解决办法。
首先可以调整Ptmalloc2的工作方式。**通过设置MALLOC_ARENA_MAX环境变量可以限制线程内存池的最大数量**当然线程内存池的数量减少后会影响Ptmalloc2分配内存的速度。不过由于Java主要使用JVM内存池来管理对象这点影响并不重要。
其次可以更换掉Ptmalloc2内存池选择一个预分配内存更少的内存池比如Google的TCMalloc。
这并不是说Google出品的TCMalloc性能更好而是在特定的场景中的选择不同。而且盲目地选择TCMalloc很可能会降低性能否则Linux系统早把默认的内存池改为TCMalloc了。
TCMalloc和Ptmalloc2是目前最主流的两个内存池接下来我带你通过对比TCMalloc与Ptmalloc2内存池看看到底该如何选择内存池。
## 选择Ptmalloc2还是TCMalloc
先来看TCMalloc适用的场景**它对多线程下小内存的分配特别友好。**
比如在2GHz的CPU上分配、释放256K字节的内存Ptmalloc2耗时32纳秒而TCMalloc仅耗时10纳秒测试代码参见[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark))。**差距超过了3倍为什么呢**这是因为Ptmalloc2假定如果线程A申请并释放了的内存线程B可能也会申请类似的内存所以它允许内存池在线程间复用以提升性能。
因此每次分配内存Ptmalloc2一定要加锁才能解决共享资源的互斥问题。然而加锁的消耗并不小。如果你监控分配速度的话会发现单线程服务调整为100个线程Ptmalloc2申请内存的速度会变慢10倍。TCMalloc针对小内存做了很多优化每个线程独立分配内存无须加锁所以速度更快
而且,**线程数越多Ptmalloc2出现锁竞争的概率就越高。**比如我们用40个线程做同样的测试TCMalloc只是从10纳秒上升到25纳秒只增长了1.5倍而Ptmalloc2则从32纳秒上升到137纳秒增长了3倍以上。
下图是TCMalloc作者给出的性能测试数据可以看到线程数越多二者的速度差距越大。所以**当应用场景涉及大量的并发线程时换成TCMalloc库也更有优势**
<img src="https://static001.geekbang.org/resource/image/56/37/56c77fdf3a130fce4c98943f494c9237.png" alt="" title="图片来源TCMalloc : Thread-Caching Malloc">
那么为什么GlibC不把默认的Ptmalloc2内存池换成TCMalloc呢**因为Ptmalloc2更擅长大内存的分配。**
比如单线程下分配257K字节的内存Ptmalloc2的耗时不变仍然是32纳秒但TCMalloc就由10纳秒上升到64纳秒增长了5倍以上**现在TCMalloc反过来比Ptmalloc2慢了1倍**这是因为TCMalloc特意针对小内存做了优化。
多少字节叫小内存呢TCMalloc把内存分为3个档次小于等于256KB的称为小内存从256KB到1M称为中等内存大于1MB的叫做大内存。TCMalloc对中等内存、大内存的分配速度很慢比如我们用单线程分配2M的内存Ptmalloc2耗时仍然稳定在32纳秒但TCMalloc已经上升到86纳秒增长了7倍以上。
所以,**如果主要分配256KB以下的内存特别是在多线程环境下应当选择TCMalloc否则应使用Ptmalloc2它的通用性更好。**
## 从堆还是栈上分配内存?
不知道你发现没有刚刚讨论的内存池中分配出的都是堆内存如果你把在堆中分配的对象改为在栈上分配速度还会再快上1倍具体测试代码可以在[这里](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark)找到)!为什么?
可能有同学还不清楚堆和栈内存是如何分配的,我先简单介绍一下。
如果你使用的是静态类型语言那么不使用new关键字分配的对象大都是在栈中的。比如
```
C/C++/Java语言int a = 10;
```
否则通过new或者malloc关键字分配的对象则是在堆中的
```
C语言int * a = (int*) malloc(sizeof(int));
C++语言int * a = new int;
Java语言int a = new Integer(10);
```
另外对于动态类型语言无论是否使用new关键字内存都是从堆中分配的。
了解了这一点之后,我们再来看看,为什么从栈中分配内存会更快。
这是因为由于每个线程都有独立的栈所以分配内存时不需要加锁保护而且栈上对象的尺寸在编译阶段就已经写入可执行文件了执行效率更高性能至上的Golang语言就是按照这个逻辑设计的即使你用new关键字分配了堆内存但编译器如果认为在栈中分配不影响功能语义时会自动改为在栈中分配。
当然,在栈中分配内存也有缺点,它有功能上的限制。一是, 栈内存生命周期有限它会随着函数调用结束后自动释放在堆中分配的内存并不随着分配时所在函数调用的结束而释放它的生命周期足够使用。二是栈的容量有限如CentOS 7中是8MB字节如果你申请的内存超过限制会造成栈溢出错误比如递归函数调用很容易造成这种问题而堆则没有容量限制。
**所以,当我们分配内存时,如果在满足功能的情况下,可以在栈中分配的话,就选择栈。**
## 小结
最后我们对这一讲做个小结。
进程申请内存的速度,以及总内存空间都受到内存池的影响。知道这些隐藏内存池的存在,是提升分配内存效率的前提。
隐藏着的C库内存池对进程的内存开销有很大的影响。当进程的占用空间超出预期时你需要清楚你正在使用的是什么内存池它对每个线程预分配了多大的空间。
不同的C库内存池都有它们最适合的应用场景例如TCMalloc对多线程下的小内存分配特别友好而Ptmalloc2则对各类尺寸的内存申请都有稳定的表现更加通用。
内存池管理着堆内存,它的分配速度比不上在栈中分配内存。只是栈中分配的内存受到生命周期和容量大小的限制,应用场景更为有限。然而,如果有可能的话,尽量在栈中分配内存,它比内存池中的堆内存分配速度快很多!
OK今天我们从内存分配的角度聊了分布式系统性能提升的内容希望学习过今天的内容后你知道如何最快速地申请到内存了解你正在使用的内存池并清楚它对进程最终内存大小的影响。即使对第三方组件我们也可以通过LD_PRELOAD环境变量在程序启动时更换最适合的C库内存池Linux中通过LD_PRELOAD修改动态库来更换内存池参见[示例代码](https://github.com/russelltao/geektime_distrib_perf/tree/master/2-memory/benchmark))。
内存分配时间虽然不起眼,但时刻用最快的方法申请内存,正是高手与初学者的区别,相似算法的性能差距就体现在这些编码细节上,希望你能够重视它。
## 思考题
最后,留给你一个思考题。分配对象时,除了分配内存,还需要初始化对象的数据结构。内存池对于初始化对象有什么帮助吗?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,127 @@
<audio id="audio" title="03 | 索引:如何用哈希表管理亿级对象?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/18/66f9041f6fc31d73e3d6c5783a852718.mp3"></audio>
你好,我是陶辉。
上一讲我们谈到Ptmalloc2为子线程预分配了64MB内存池虽然增大了内存消耗但却加快了分配速度这就是**以空间换时间**的思想。
在内存有限的单片机上运行嵌入式程序时,我们会压缩数据的空间占用,**以时间换空间**;但在面向海量用户的分布式服务中,**使用更多的空间建立索引,换取更短的查询时间**,则是我们管理大数据的常用手段。
比如现在需要管理数亿条数据每条数据上有许多状态有些请求在查询这些状态有些请求则会根据业务规则有条件地更新状态有些请求会新增数据每条数据几十到几百字节。如果需要提供微秒级的访问速度该怎么实现注意以上非功能性约束并不苛刻对于低ARPU即每用户平均收入低的应用使用更少的资源实现同等功能非常重要。
这种情况你会面对大量数据,显然,遍历全部数据去匹配查询关键字,会非常耗时。如果使用额外的空间为这些数据创建索引,就可以基于索引实现快速查找,这是常用的解决方案。比如,我们用标准库里提供的字典类容器存放对象,就是在数据前增加了索引,其本质就是以空间换时间。
当然索引有很多哈希表、红黑树、B树都可以在内存中使用如果我们需要数据规模上亿后还能提供微秒级的访问速度**那么作为最快的索引,哈希表是第一选择。**
## 为什么选择哈希表?
为什么说哈希表是最快的索引呢?我们怎么**定量评价**索引快慢呢?
实地运行程序统计时间不是个好主意,因为它不只受数据特性、数据规模的影响,而且难以跨环境比较。巴菲特说过:“**近似的正确好过精确的错误。**”用**近似的时间复杂度**描述运行时间,好过实地运行得出的精确时间。
“时间复杂度”经过了详细的数学运算它的运算过程我就不详细展开讲了。时间复杂度可以很好地反映运行时间随数据规模的变化趋势就如下图中横轴是数据规模纵轴是运行时间随着数据规模的增长水平直线1不随之变化也就是说运行时间不变是最好的曲线。用大O表示法描述时间复杂度哈希表就是常量级的O(1)数据规模增长不影响它的运行时间所以Memcached、Redis都在用哈希表管理数据。
<img src="https://static001.geekbang.org/resource/image/e5/c8/e5e07bd2abe9f0f15df1b43fdf25f9c8.jpg" alt="" title="图片来自英文wiki">
为什么哈希表能做到O(1)时间复杂度呢?
首先,哈希表基于数组实现,而数组可以根据下标随机访问任意元素。数组之所以可以随机访问,是因为它**由连续内存承载**,且**每个数组元素的大小都相等**。于是,当我们知道下标后,把下标乘以元素大小,再加上数组的首地址,就可以获得目标访问地址,直接获取数据。
其次哈希函数直接把查询关键字转换为数组下标再通过数组的随机访问特性获取数据。比如如果关键字是字符串我们使用BKDR哈希算法将其转换为自然数再以哈希数组大小为除数对它进行求余就得到了数组下标。如下图所示字符串abc经过哈希函数的运算得到了下标39于是数据就存放在数组的第39个元素上。注意这是个**很糟糕**的哈希函数它使用的基数是256即2的8次方下文我们会解释它为什么糟糕。
<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">
这样,**哈希函数的执行时间是常量数组的随机访问也是常量时间复杂度就是O(1)。**
实际上并非只有哈希表的时间复杂度是O(1)另一种索引“位图”它的时间复杂度也是O(1)。不过本质上它是哈希表的变种限制每个哈希桶只有1个比特位所以虽然它消耗的空间更少但仅用于辅助数据的主索引快速判断对象是否存在。
位图常用于解决缓存穿透的问题也常用于查找数组中的可用对象比如下图中通过批量判断位图数组的比特位对CPU缓存也很友好找到数据数组中的对应元素。
<img src="https://static001.geekbang.org/resource/image/bf/f5/bf2e4f574be8af06c285b3fc78d7b0f5.jpg" alt="">
当然logN也是不错的曲线随着数据规模的增长运行时间的增长是急剧放缓的。红黑树的时间复杂度就是O(logN)。如果需求中需要做范围查询、遍历由于哈希表没办法找到关键字相邻的下一个元素所以哈希表不支持这类操作我们可以选择红黑树作为索引。采用二分法的红黑树检索1万条数据需要做14次运算1亿条也只需要27次而已。
如果红黑树过大内存中放不下时可以改用B树将部分索引存放在磁盘上。磁盘访问速度要比内存慢很多但B树充分考虑了机械磁盘寻址慢、顺序读写快的特点通过多分支降低了树高减少了磁盘读写次数。
综合来看,不考虑范围查询与遍历操作,在追求最快速度的条件下,哈希表是最好的选择。
然而,在生产环境用哈希表管理如此多的数据,必然面临以下问题:
- 首先,面对上亿条数据,为了保证可靠性,需要做灾备恢复,我们可以结合快照+oplog方式恢复数据但内存中的哈希表如何快速地序列化为快照文件
- 其次简单的使用标准库提供的哈希表处理如此规模的数据会导致内存消耗过大因为每多使用一个8字节的指针或者叫引用都会被放大亿万倍此时该如何实现更节约内存的个性化哈希表
- 再次,哈希表频繁发生冲突时,速度会急剧降低,我们该通过哪些手段减少冲突概率?
接下来,我们就来看看,如何解决以上问题,用哈希表有效地管理亿级数据。
## 内存结构与序列化方案
事实上**对于动态(元素是变化的)哈希表,我们无法避免哈希冲突。**比如上例中“abc”与“cba”这两个字符串哈希后都会落到下标39中这就产生了冲突。有两种方法解决哈希冲突
1. **链接法**落到数组同一个位置中的多个数据通过链表串在一起。使用哈希函数查找到这个位置后再使用链表遍历的方式查找数据。Java标准库中的哈希表就使用链接法解决冲突。
1. **开放寻址法**插入时若发现对应的位置已经占用或者查询时发现该位置上的数据与查询关键字不同开放寻址法会按既定规则变换哈希函数例如哈希函数设为H(key,i)顺序地把参数i加1计算出下一个数组下标继续在哈希表中探查正确的位置。
我们该选择哪种方法呢?
由于生产级存放大量对象的哈希表是需要容灾的比如每隔一天把哈希表数据定期备份到另一台服务器上。当服务器宕机而启动备用服务器时首先可以用备份数据把哈希表恢复到1天前的状态再通过操作日志oplog把1天内的数据载入哈希表这样就可以最快速的恢复哈希表。所以为了能够传输首先必须把哈希表序列化。
链接法虽然实现简单,还允许**存放元素个数大于数组的大小**也叫装载因子大于1但链接法序列化数据的代价很大因为使用了指针后内存是不连续的。
**开放寻址法**确保所有对象都在数组里就可以把数组用到的这段连续内存原地映射到文件中参考Linux中的mmapJava等语言都有类似的封装再通过备份文件的方式备份哈希表。虽然操作系统会自动同步内存中变更的数据至文件但备份前还是需要主动刷新内存参考Linux中的msync它可以按地址及长度来分段刷新以减少msync的耗时以确定备份数据的精确时间点。而新的进程启动时可以通过映射磁盘中的文件到内存快速重建哈希表提供服务。
**如果能将数据完整的放进数组,那么开放寻址法已经解决了序列化问题,所以我们应该选择开放寻址法**
但是,有两个因素使得我们必须把数据放在哈希桶之外:
1. 每条数据有上百字节;
1. 哈希表中一定会有很多空桶(没有存放数据)。空桶的比例越高(装载因子越小),冲突概率也会越低,但如果每个空桶都占用上百字节,亿级规模会轻松把浪费的内存放大许多倍。
**所以,我们要把数据从哈希表中分离出来,提升哈希表的灵活性(灵活调整装载因子)**。此时,该如何序列化哈希表以外的数据呢?最快速的序列化方案,还是像开放寻址法的散列表一样,使用定长数组存放对象,通过原地映射文件的方式序列化数据。由于数据未必是定长的,所以又分为两种情况。
**一、数据的长度是固定的。**可以用另一个数组D存放数据其中D的大小是待存放元素的最大数量注意D可以远小于哈希数组的大小。如果哈希表是动态的支持新建与删除元素操作还需要把数组D中空闲的位置构建一个单链表新建时从链表头取元素删除时将元素归还至链表头部。
<img src="https://static001.geekbang.org/resource/image/7e/e8/7e0636fc6d9a70d6d4de07da678da6e8.jpg" alt="">
**二、数据的长度并不固定。**此时可以采用有限个定长数组存放数据用以空间换时间的思想加快访问速度。如下图中D1数组存放长度小于L1的数据D2数组存放长度在L1和L2之间的数据以此类推。而哈希表数组H中每个桶用i位存放该数据在哪个数组中用j位存放数组下标。查找数据时前i位寻找数组后j位作为数组下标直接访问数据。
<img src="https://static001.geekbang.org/resource/image/17/82/17f3f4e9e949a49a4ce7a50bbf1d4f82.jpg" alt="">
在这两种情况里哈希桶不需要存放8字节64位的地址。因为或许数组D的大小不到1亿也就是说你最多只需要寻址1亿条数据这样30位足够使用。要知道减少哈希桶的尺寸就意味着同等内存下可以扩大哈希数组从而降低装载因子。
## 降低哈希表的冲突概率
虽然哈希冲突有解决方案但若是所有元素都发生了冲突哈希表的时间复杂度就退化成了O(N),即每查找一次都要遍历所有数据。所以,为了获得与数据规模无关的常量级时间,我们必须减少冲突的概率,而减少冲突概率有两个办法,**第一个办法是调优哈希函数,第二个办法就是扩容。**
我们先看调优哈希函数。什么是好的哈希函数呢?首先它的计算量不能大,其次应尽量降低冲突概率。回到开头的那个哈希函数:
<img src="https://static001.geekbang.org/resource/image/41/59/419bc11f032ebcefaa6a3eb5c1a39759.jpg" alt="">
这个哈希函数使得“abc”和“cba”两个关键字都落在了下标39上造成了哈希冲突是因为它**丢失了字母的位置信息**。BKDR是优秀的哈希算法但它不能以2<sup>8</sup> 作为基数,这会导致字符串分布不均匀。事实上,我们应当找一个合适的**素数作为基数**比如31Java标准库的BKDR哈希算法就以它为基数它的计算量也很小n*31可以通过先把n左移5位再减去n的方式替换n*31 == n&lt;&lt;5 - n
一次位移加一次减法要比一次乘法快得多。当然图中的哈希函数之所以会丢失位置信息是因为以2<sup>8</sup> 作为基数的同时又把2<sup>8</sup>-1作为除数所致数学较好的同学可以试着推导证明这里只需要记住**基数必须是素数**就可以了。
当哈希函数把高信息量的关键字压缩成更小的数组下标时,**一定会丢失信息**。我们希望只丢失一些无关紧要的信息尽量多地保留区分度高的信息。这需要分析关键字的特点、分布规律。比如对于11位手机号前3位接入号区分度最差中间4位表示地域的数字信息量有所增强最后4位个人号信息量最高。如果哈希桶只有1万个那么通过phonenum%10000最大化保留后4位信息就是个不错的选择。
再比如QQ 号似乎不像手机号的数字分布那么有特点然而如果静态的统计存量QQ号就会发现最后1位为0的号码特别多数字更讨人欢喜区分度很低。这样哈希函数应当主动降低最后1位的信息量减少它对哈希表位置的影响。比如QQ号%100就放大了最后1位的信息增大了哈希冲突而用QQ号%101**101是素数效果更好******作为哈希函数就降低了最后1位的影响。
**接下来我们看看减少哈希冲突概率的第二个办法,扩容。**装载因子越接近于1冲突概率就会越大。我们不能改变元素的数量只能通过扩容提升哈希桶的数量减少冲突。
由于哈希函数必须确保计算出的下标落在数组范围中,而扩容会增加数组的大小,进而影响哈希函数,因此,扩容前存放在哈希表中的所有元素,它们在扩容后的数组中位置都发生了变化。所以,扩容需要新老哈希表同时存在,通过遍历全部数据,用新的哈希函数把关键字放到合适的新哈希桶中。可见,扩容是一个极其耗时的操作,尤其在元素以亿计的情况下。
那么,在耗时以小时计的扩容过程中,如何持续提供正常服务呢?其实,只要把一次性的迁移过程,分为多次后台迁移,且提供服务时能够根据迁移情况选择新老哈希表即可。如果单机内存可以存放下新老两张哈希表,那么动态扩容不需要跨主机。反之,扩容过程将涉及新老哈希表所在的两台服务器,实现更为复杂,但原理是相同的。
## 小结
今天我们介绍了如何用哈希表管理上亿条数据。为什么选择哈希表因为哈希表的运行时间不随着业务规模增长而变化。位图本质上是哈希表的变种不过它常用于配合主索引快速判断数据的状态。因为哈希表本身没办法找到关键字相邻的下一个元素所以哈希表不支持范围查询与遍历。如果业务需要支持范围查询时我们需要考虑红黑树、B树等索引它们其实并不慢。当索引太大必须将一部分从内存中移到硬盘时B树就是一个很好的选择。
使用哈希表,你要注意几个关键问题。
1. 生产环境一定要考虑容灾,而把哈希表原地序列化为文件是一个解决方案,它能保证新进程快速恢复哈希表。解决哈希冲突有链接法和开放寻址法,而后者更擅长序列化数据,因此成为我们的首选 。
1. 亿级数据下,我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存,再把它们用于提升哈希数组的大小,就可以通过降低装载因子来减少哈希冲突,提升速度。
1. 优化哈希函数也是降低哈希冲突的重要手段,我们需要研究关键字的特征与分布,设计出快速、使关键字均匀分布的哈希函数。在课程的第四部分,集群的负载均衡也用到了哈希函数及其设计思想,只不过,哈希桶从一段内存变成了一台服务器。
再延伸说一点,哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗,我们都需要依赖时间复杂度这个工具。当然,索引在某些场景下也会降低性能。例如添加、删除元素时,更新索引消耗的时间就是新增的。但相对于整体的收益,这些消耗是微不足道的。
## 思考题
最后留给大家一个思考题,你用过哪些其他类型的索引?基于怎样的应用场景和约束,才选择使用这些索引的?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,127 @@
<audio id="audio" title="04 | 零拷贝:如何高效地传输文件?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/3d/f674dd9b550efc1310ee509bd656693d.mp3"></audio>
你好,我是陶辉。
上一讲我们谈到当索引的大小超过内存时就会用磁盘存放索引。磁盘的读写速度远慢于内存所以才针对磁盘设计了减少读写次数的B树索引。
**磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。**
因此针对磁盘的优化技术层出不穷比如零拷贝、直接IO、异步IO等等。这些优化技术为了降低操作时延、提升系统的吞吐量围绕着内核中的磁盘高速缓存也叫PageCache去减少CPU和磁盘设备的工作量。
这些磁盘优化技术和策略虽然很有效,但是理解它们并不容易。只有搞懂内核操作磁盘的流程,灵活正确地使用,才能有效地优化磁盘性能。
这一讲我们就通过解决“如何高效地传输文件”这个问题来分析下磁盘是如何工作的并且通过优化传输文件的性能带你学习现在热门的零拷贝、异步IO与直接IO这些磁盘优化技术。
## 你会如何实现文件传输?
服务器提供文件传输功能,需要将磁盘上的文件读取出来,通过网络协议发送到客户端。如果需要你自己编码实现这个文件传输功能,你会怎么实现呢?
通常你会选择最直接的方法从网络请求中找出文件在磁盘中的路径后如果这个文件比较大假设有320MB可以在内存中分配32KB的缓冲区再把文件分成一万份每份只有32KB这样从文件的起始位置读入32KB到缓冲区再通过网络API把这32KB发送到客户端。接着重复一万次直到把完整的文件都发送完毕。如下图所示
<img src="https://static001.geekbang.org/resource/image/65/ee/6593f66902b337ec666551fe2c6f5bee.jpg" alt="">
不过这个方案性能并不好,主要有两个原因。
首先,它至少**经历了4万次用户态与内核态的上下文切换。**因为每处理32KB的消息就需要一次read调用和一次write调用每次系统调用都得先从用户态切换到内核态等内核完成任务后再从内核态切换回用户态。可见每处理32KB就有4次上下文切换重复1万次后就有4万次切换。
上下文切换的成本并不小,虽然一次切换仅消耗几十纳秒到几微秒,但高并发服务会放大这类时间的消耗。
其次,这个方案做了**4万次内存拷贝对320MB文件拷贝的字节数也翻了4倍到了1280MB。**很显然过多的内存拷贝无谓地消耗了CPU资源降低了系统的并发处理能力。
所以要想提升传输文件的性能,需要从**降低上下文切换的频率和内存拷贝次数**两个方向入手。
## 零拷贝如何提升文件传输性能?
首先,我们来看如何降低上下文切换的频率。
为什么读取磁盘文件时一定要做上下文切换呢这是因为读取磁盘或者操作网卡都由操作系统内核完成。内核负责管理系统上的所有进程它的权限最高工作环境与用户进程完全不同。只要我们的代码执行read或者write这样的系统调用一定会发生2次上下文切换首先从用户态切换到内核态当内核执行完任务后再切换回用户态交由进程代码执行。
因此如果想减少上下文切换次数就一定要减少系统调用的次数。解决方案就是把read、write两次系统调用合并成一次在内核中完成磁盘与网卡的数据交换。
其次,我们应该考虑如何减少内存拷贝次数。
每周期中的4次内存拷贝其中与物理设备相关的2次拷贝是必不可少的包括把磁盘内容拷贝到内存以及把内存拷贝到网卡。但另外2次与用户缓冲区相关的拷贝动作都不是必需的因为在把磁盘文件发到网络的场景中**用户缓冲区没有必须存在的理由**。
如果内核在读取文件后直接把PageCache中的内容拷贝到Socket缓冲区待到网卡发送完毕后再通知进程这样就只有2次上下文切换和3次内存拷贝。
<img src="https://static001.geekbang.org/resource/image/bf/a1/bf80b6f858d5cb49f600a28f853e89a1.jpg" alt="">
如果网卡支持SG-DMAThe Scatter-Gather Direct Memory Access技术还可以再去除Socket缓冲区的拷贝这样一共只有2次内存拷贝。
<img src="https://static001.geekbang.org/resource/image/0a/77/0afb2003d8aebaee763d22dda691ca77.jpg" alt="">
**实际上,这就是零拷贝技术。**
它是操作系统提供的新函数同时接收文件描述符和TCP socket作为输入参数这样执行时就可以完全在内核态完成内存拷贝既减少了内存拷贝次数也降低了上下文切换次数。
而且零拷贝取消了用户缓冲区后不只降低了用户内存的消耗还通过最大化利用socket缓冲区中的内存间接地再一次减少了系统调用的次数从而带来了大幅减少上下文切换次数的机会
你可以回忆下没用零拷贝时为了传输320MB的文件在用户缓冲区分配了32KB的内存把文件分成1万份传送然而**这32KB是怎么来的**为什么不是32MB或者32字节呢这是因为在没有零拷贝的情况下我们希望内存的利用率最高。如果用户缓冲区过大它就无法一次性把消息全拷贝给socket缓冲区如果用户缓冲区过小则会导致过多的read/write系统调用。
那用户缓冲区为什么不与socket缓冲区大小一致呢这是因为**socket缓冲区的可用空间是动态变化的**它既用于TCP滑动窗口也用于应用缓冲区还受到整个系统内存的影响我在《Web协议详解与抓包实战》第5部分课程对此有详细介绍这里不再赘述。尤其在长肥网络中它的变化范围特别大。
**零拷贝使我们不必关心socket缓冲区的大小。**比如调用零拷贝发送方法时尽可以把发送字节数设为文件的所有未发送字节数例如320MB也许此时socket缓冲区大小为1.4MB那么一次性就会发送1.4MB到客户端而不是只有32KB。这意味着对于1.4MB的1次零拷贝仅带来2次上下文切换而不使用零拷贝且用户缓冲区为32KB时经历了176次4 * 1.4MB/32KB上下文切换。
综合上述各种优点,**零拷贝可以把性能提升至少一倍以上!**对文章开头提到的320MB文件的传输当socket缓冲区在1.4MB左右时只需要4百多次上下文切换以及4百多次内存拷贝拷贝的数据量也仅有640MB这样不只请求时延会降低处理每个请求消耗的CPU资源也会更少从而支持更多的并发请求。
此外零拷贝还使用了PageCache技术通过它零拷贝可以进一步提升性能我们接下来看看PageCache是如何做到这一点的。
## PageCache磁盘高速缓存
回顾上文中的几张图你会发现读取文件时是先把磁盘文件拷贝到PageCache上再拷贝到进程中。为什么这样做呢有两个原因所致。
第一,由于磁盘比内存的速度慢许多,所以我们应该想办法把读写磁盘替换成读写内存,比如把磁盘中的数据复制到内存中,就可以用读内存替换读磁盘。但是,内存空间远比磁盘要小,内存中注定只能复制一小部分磁盘中的数据。
选择哪些数据复制到内存呢通常刚被访问的数据在短时间内再次被访问的概率很高这也叫“时间局部性”原理用PageCache缓存最近访问的数据当空间不足时淘汰最久未被访问的缓存即LRU算法。读磁盘时优先到PageCache中找一找如果数据存在便直接返回这便大大提升了读磁盘的性能。
第二读取磁盘数据时需要先找到数据所在的位置对于机械磁盘来说就是旋转磁头到数据所在的扇区再开始顺序读取数据。其中旋转磁头耗时很长为了降低它的影响PageCache使用了**预读功能**。
也就是说虽然read方法只读取了0-32KB的字节但内核会把其后的32-64KB也读取到PageCache这后32KB读取的成本很低。如果在32-64KB淘汰出PageCache前进程读取到它了收益就非常大。这一讲的传输文件场景中这是必然发生的。
从这两点可以看到PageCache的优点它在90%以上场景下都会提升磁盘性能,**但在某些情况下PageCache会不起作用甚至由于多做了一次内存拷贝造成性能的降低。**在这些场景中使用了PageCache的零拷贝也会损失性能。
具体是什么场景呢就是在传输大文件的时候。比如你有很多GB级的文件需要传输每当用户访问这些大文件时内核就会把它们载入到PageCache中这些大文件很快会把有限的PageCache占满。
然而由于文件太大文件中某一部分内容被再次访问到的概率其实非常低。这带来了2个问题首先由于PageCache长期被大文件占据热点小文件就无法充分使用PageCache它们读起来变慢了其次PageCache中的大文件没有享受到缓存的好处但却耗费CPU或者DMA多拷贝到PageCache一次。
所以高并发场景下为了防止PageCache被大文件占满后不再对小文件产生作用**大文件不应使用PageCache进而也不应使用零拷贝技术处理。**
## 异步IO + 直接IO
高并发场景处理大文件时应当使用异步IO和直接IO来替换零拷贝技术。
仍然回到本讲开头的例子当调用read方法读取文件时实际上read方法会在磁盘寻址过程中阻塞等待导致进程无法并发地处理其他任务如下图所示
<img src="https://static001.geekbang.org/resource/image/9e/4e/9ef6fcb7da58a007f8f4e3e67442df4e.jpg" alt="">
异步IO异步IO既可以处理网络IO也可以处理磁盘IO这里我们只关注磁盘IO可以解决阻塞问题。它把读操作分为两部分前半部分向内核发起读请求但**不等待数据就位就立刻返回**此时进程可以并发地处理其他任务。当内核将磁盘中的数据拷贝到进程缓冲区后进程将接收到内核的通知再去处理数据这是异步IO的后半部分。如下图所示
<img src="https://static001.geekbang.org/resource/image/15/f3/15d33cf599d11b3188253912b21e4ef3.jpg" alt="">
从图中可以看到异步IO并没有拷贝到PageCache中这其实是异步IO实现上的缺陷。经过PageCache的IO我们称为缓存IO它与虚拟内存系统耦合太紧导致异步IO从诞生起到现在都不支持缓存IO。
绕过PageCache的IO是个新物种我们把它称为直接IO。对于磁盘异步IO只支持直接IO。
直接IO的应用场景并不多主要有两种第一应用程序已经实现了磁盘文件的缓存不需要PageCache再次缓存引发额外的性能消耗。比如MySQL等数据库就使用直接IO第二高并发下传输大文件我们上文提到过大文件难以命中PageCache缓存又带来额外的内存拷贝同时还挤占了小文件使用PageCache时需要的内存因此这时应该使用直接IO。
当然直接IO也有一定的缺点。除了缓存外内核IO调度算法会试图缓存尽量多的连续IO在PageCache中最后**合并**成一个更大的IO再发给磁盘这样可以减少磁盘的寻址操作另外内核也会**预读**后续的IO放在PageCache中减少磁盘操作。直接IO绕过了PageCache所以无法享受这些性能提升。
有了直接IO后异步IO就可以无阻塞地读取文件了。现在大文件由异步IO和直接IO处理小文件则交由零拷贝处理至于判断文件大小的阈值可以灵活配置参见Nginx的directio指令
## 小结
基于用户缓冲区传输文件时过多的内存拷贝与上下文切换次数会降低性能。零拷贝技术在内核中完成内存拷贝天然降低了内存拷贝次数。它通过一次系统调用合并了磁盘读取与网络发送两个操作降低了上下文切换次数。尤其是由于拷贝在内核中完成它可以最大化使用socket缓冲区的可用空间从而提高了一次系统调用中处理的数据量进一步降低了上下文切换次数。
零拷贝技术基于PageCache而PageCache缓存了最近访问过的数据提升了访问缓存数据的性能同时为了解决机械磁盘寻址慢的问题它还协助IO调度算法实现了IO合并与预读这也是顺序读比随机读性能好的原因这进一步提升了零拷贝的性能。几乎所有操作系统都支持零拷贝如果应用场景就是把文件发送到网络中那么我们应当选择使用了零拷贝的解决方案。
不过零拷贝有一个缺点就是不允许进程对文件内容作一些加工再发送比如数据压缩后再发送。另外当PageCache引发负作用时也不能使用零拷贝此时可以用异步IO+直接IO替换。我们通常会设定一个文件大小阈值针对大文件使用异步IO和直接IO而对小文件使用零拷贝。
事实上PageCache对写操作也有很大的性能提升因为write方法在写入内存中的PageCache后就会返回速度非常快由内核负责异步地把PageCache刷新到磁盘中这里不再展开。
这一讲我们从零拷贝出发看到了文件传输场景中内核在幕后所做的工作。这里面的性能优化技术要么减少了磁盘的工作量比如PageCache缓存要么减少了CPU的工作量比如直接IO要么提高了内存的利用率比如零拷贝。你在学习其他磁盘IO优化技术时可以延着这三个优化方向前进看看究竟如何降低时延、提高并发能力。
## 思考题
最后留给你一个思考题异步IO一定不会阻塞进程吗如果阻塞了进程该如何解决呢欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,107 @@
<audio id="audio" title="05 | 协程:如何快速地实现高并发服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/da/49/da9581b5c37a49649cdcaeb7485dc649.mp3"></audio>
你好,我是陶辉。
上一讲谈到,零拷贝通过减少上下文切换次数,提升了文件传输的性能。事实上高并发服务也是通过降低切换成本实现的,这一讲我们来看看它是如何做到的。
如果你需要访问多个服务来完成一个请求的处理比如实现文件上传功能时首先访问Redis缓存验证用户是否登陆再接收HTTP消息中的body并保存在磁盘上最后把文件路径等信息写入MySQL数据库中你会怎么做
用阻塞API写同步代码最简单但一个线程同一时间只能处理一个请求有限的线程数导致无法实现万级别的并发连接过多的线程切换也抢走了CPU的时间从而降低了每秒能够处理的请求数量。
为了达到高并发你可能会选择一个异步框架用非阻塞API把业务逻辑打乱到多个回调函数通过多路复用实现高并发然而由于业务代码过度关注并发细节需要维护很多中间状态不但Bug率会很高项目的开发速度也上不去产品及时上线存在风险。
如果想兼顾开发效率,又能保证高并发,协程就是最好的选择。它可以在保持异步化运行机制的同时,用同步方式写代码,这在实现高并发的同时,缩短了开发周期,是高性能服务未来的发展方向。
你会发现,解决高并发问题的技术一直在变化,从多进程、多线程,到异步化、协程,面对不同的场景,它们都在用各自不同的方式解决问题。我们就来看看,高并发的解决方案是怎么演进的,协程到底解决了什么问题,它又该如何应用。
## 如何通过切换请求实现高并发?
我们知道主机上资源有限一颗CPU、一块磁盘、一张网卡如何同时服务上百个请求呢多进程模式是最初的解决方案。内核把CPU的执行时间切分成许多时间片timeslice比如1秒钟可以切分为100个10毫秒的时间片每个时间片再分发给不同的进程通常每个进程需要多个时间片才能完成一个请求。
这样虽然微观上比如说就这10毫秒时间CPU只能执行一个进程但宏观上1秒钟执行了100个时间片于是每个时间片所属进程中的请求也得到了执行**这就实现了请求的并发执行。**
不过,每个进程的内存空间都是独立的,这样用多进程实现并发就有两个缺点:一是内核的管理成本高,二是无法简单地通过内存同步数据,很不方便。于是,多线程模式就出现了,多线程模式通过共享内存地址空间,解决了这两个问题。
然而共享地址空间虽然可以方便地共享对象但这也导致一个问题那就是任何一个线程出错时进程中的所有线程会跟着一起崩溃。这也是如Nginx等强调稳定性的服务坚持使用多进程模式的原因。
事实上,无论基于多进程还是多线程,都难以实现高并发,这由两个原因所致。
首先单个线程消耗的内存过多比如64位的Linux为每个线程的栈分配了8MB的内存还预分配了64MB的内存作为堆内存池你可以从[[第2讲]](https://time.geekbang.org/column/article/230221) 中找到Linux系统为什么这么做。所以我们没有足够的内存去开启几万个线程实现并发。
其次,切换请求是内核通过切换线程实现的,什么时候会切换线程呢?不只时间片用尽,**当调用阻塞方法时内核为了让CPU充分工作也会切换到其他线程执行。**一次上下文切换的成本在几十纳秒到几微秒间当线程繁忙且数量众多时这些切换会消耗绝大部分的CPU运算能力。
下图以上一讲介绍过的磁盘IO为例描述了多线程中使用阻塞方法读磁盘2个线程间的切换方式。
<img src="https://static001.geekbang.org/resource/image/a7/1e/a7729794e84cbb4a295454c6f2005c1e.jpg" alt="">
那么,怎么才能实现高并发呢?**把上图中本来由内核实现的请求切换工作,交由用户态的代码来完成就可以了**异步化编程通过应用层代码实现了请求切换降低了切换成本和内存占用空间。异步化依赖于IO多路复用机制比如Linux的epoll或者Windows上的iocp同时必须把阻塞方法更改为非阻塞方法才能避免内核切换带来的巨大消耗。Nginx、Redis等高性能服务都依赖异步化实现了百万量级的并发。
下图描述了异步IO的非阻塞读和异步框架结合后是如何切换请求的。
<img src="https://static001.geekbang.org/resource/image/5f/8e/5f5ad4282571d8148d87416c8f8fa88e.jpg" alt="">
**然而,写异步化代码很容易出错。**因为所有阻塞函数,都需要通过非阻塞的系统调用拆分成两个函数。虽然这两个函数共同完成一个功能,但调用方式却不同。第一个函数由你显式调用,第二个函数则由多路复用机制调用。这种方式违反了软件工程的内聚性原则,函数间同步数据也更复杂。特别是条件分支众多、涉及大量系统调用时,异步化的改造工作会非常困难。
有没有办法既享受到异步化带来的高并发,又可以使用阻塞函数写同步化代码呢?
协程可以做到,**它在异步化之上包了一层外衣,兼顾了开发效率与运行效率。**
## 协程是如何实现高并发的?
协程与异步编程相似的地方在于,它们必须使用非阻塞的系统调用与内核交互,把切换请求的权力牢牢掌握在用户态的代码中。但不同的地方在于,协程把异步化中的两段函数,封装为一个阻塞的协程函数。这个函数执行时,会使调用它的协程无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数的结果满足后,协程框架再选择合适的时机,切换回它所在的协程继续执行。如下图所示:
<img src="https://static001.geekbang.org/resource/image/e4/57/e47ec54ff370cbda4528e285e3378857.jpg" alt="">
看起来非常棒,然而,异步化是通过回调函数来完成请求切换的,业务逻辑与并发实现关联在一起,很容易出错。协程不需要什么“回调函数”,它允许用户调用“阻塞的”协程方法,用同步编程方式写业务逻辑。
那协程的切换是如何完成的呢?
实际上,**用户态的代码切换协程,与内核切换线程的原理是一样的。**内核通过管理CPU的寄存器来切换线程我们以最重要的栈寄存器和指令寄存器为例看看协程切换时如何切换程序指令与内存。
每个线程有独立的栈而栈既保留了变量的值也保留了函数的调用关系、参数和返回值CPU中的栈寄存器SP指向了当前线程的栈而指令寄存器IP保存着下一条要执行的指令地址。因此从线程1切换到线程2时首先要把SP、IP寄存器的值为线程1保存下来再从内存中找出线程2上一次切换前保存好的寄存器值写入CPU的寄存器这样就完成了线程切换。其他寄存器也需要管理、替换原理与此相同不再赘述。
协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态:
<img src="https://static001.geekbang.org/resource/image/a8/f7/a83d7e0f37f35353c6347aa76c8184f7.jpg" alt="">
从协程1切换到协程2后的状态如下图所示
<img src="https://static001.geekbang.org/resource/image/25/3f/25d2dcb8aa4569e5de741469f03aa73f.jpg" alt="">
创建协程时,会从进程的堆中(参见[[第2讲]](https://time.geekbang.org/column/article/230221)分配一段内存作为协程的栈。线程的栈有8MB而协程栈的大小通常只有几十KB。而且C库内存池也不会为协程预分配内存它感知不到协程的存在。这样更低的内存占用空间为高并发提供了保证毕竟十万并发请求就意味着10万个协程。当然栈缩小后就尽量不要使用递归函数也不能在栈中申请过多的内存这是实现高并发必须付出的代价。
由此可见协程就是用户态的线程。然而为了保证所有切换都在用户态进行协程必须重新封装所有的阻塞系统调用否则一旦协程触发了线程切换会导致这个线程进入休眠状态进而其上的所有协程都得不到执行。比如普通的sleep函数会让当前线程休眠由内核来唤醒线程而协程化改造后sleep只会让当前协程休眠由协程框架在指定时间后唤醒协程。再比如线程间的互斥锁是使用信号量实现的而信号量也会导致线程休眠协程化改造互斥锁后同样由框架来协调、同步各协程的执行。
**所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件。**比如MySQL官方提供的客户端SDK它使用了阻塞socket做网络访问会导致线程休眠必须用非阻塞socket把SDK改造为协程函数后才能在协程中使用。
当然,并不是所有的函数都能用协程改造。比如[[第4讲]](https://time.geekbang.org/column/article/232676) 提到的异步IO它虽然是非阻塞的但无法使用PageCache降低了系统吞吐量。如果使用缓存IO读文件在没有命中PageCache时是可能发生阻塞的。
这种时候,如果对性能有更高的要求,就需要把线程与协程结合起来用,把可能阻塞的操作放在线程中执行,通过生产者/消费者模型与协程配合工作。
实际上面对多核系统也需要协程与线程配合工作。因为协程的载体是线程而一个线程同一时间只能使用一颗CPU所以通过开启更多的线程将所有协程分布在这些线程中就能充分使用CPU资源。
除此之外为了让协程获得更多的CPU时间还可以设置所在线程的优先级比如Linux下把线程的优先级设置到-20就可以每次获得更长的时间片。另外[[第1讲]](https://time.geekbang.org/column/article/230194) 曾谈到CPU缓存对程序性能的影响为了减少CPU缓存失效的比例还可以把线程绑定到某个CPU上增加协程执行时命中CPU缓存的机率。
虽然这一讲中谈到协程框架在调度协程,然而,你会发现,很多协程库只提供了创建、挂起、恢复执行等基本方法,并没有协程框架的存在,需要业务代码自行调度协程。这是因为,这些通用的协程库并不是专为服务器设计的。服务器中可以由客户端网络连接的建立,驱动着创建出协程,同时伴随着请求的结束而终止。在协程的运行条件不满足时,多路复用框架会将它挂起,并根据优先级策略选择另一个协程执行。
因此使用协程实现服务器端的高并发服务时并不只是选择协程库还要从其生态中找到结合IO多路复用的协程框架这样可以加快开发速度。
## 小结
这一讲,我们从高并发的应用场景入手,分析了协程出现的背景和实现原理,以及它的应用范围。你会发现,协程融合了多线程与异步化编程的优点,既保证了开发效率,也提升了运行效率。
有限的硬件资源下,多线程通过微观上时间片的切换,实现了同时服务上百个用户的能力。多线程的开发成本虽然低,但内存消耗大,切换次数过多,无法实现高并发。
异步编程方式通过非阻塞系统调用和多路复用,把原本属于内核的请求切换能力,放在用户态的代码中执行。这样,不仅减少了每个请求的内存消耗,也降低了切换请求的成本,最终实现了高并发。然而,异步编程违反了代码的内聚性,还需要业务代码关注并发细节,开发成本很高。
协程参考内核通过CPU寄存器切换线程的方法在用户态代码中实现了协程的切换既降低了切换请求的成本也使得协程中的业务代码不用关注自己何时被挂起何时被执行。相比异步编程中要维护一堆数据结构表示中间状态协程直接用代码表示状态大大提升了开发效率。
在协程中调用的所有API都需要做非阻塞的协程化改造。优秀的协程生态下常用服务都有对应的协程SDK方便业务代码使用。开发高并发服务时与IO多路复用结合的协程框架可以与这些SDK配合自动挂起、切换协程进一步提升开发效率。
协程并不是完全与线程无关首先线程可以帮助协程充分使用多核CPU的计算力其次遇到无法协程化、会导致内核切换的阻塞函数或者计算太密集从而长时间占用CPU的任务还是要放在独立的线程中执行以防止它影响所有协程的执行。
## 思考题
最后,留给你一个思考题,你用过协程吗?觉得它还有什么优点?如果没有在生产环境中使用协程,原因是什么?欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课有所收获,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="06 | 锁:如何根据业务场景选择合适的锁?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/d0/5a3e4fb278572544e11504f5b9ecd7d0.mp3"></audio>
你好,我是陶辉。
上一讲我们谈到了实现高并发的不同方案,这一讲我们来谈谈如何根据业务场景选择合适的锁。
我们知道,多线程下为了确保数据不会出错,必须加锁后才能访问共享资源。我们最常用的是互斥锁,然而,还有很多种不同的锁,比如自旋锁、读写锁等等,它们分别适用于不同的场景。
比如高并发场景下,要求每个函数的执行时间必须都足够得短,这样所有请求才能及时得到响应,如果你选择了错误的锁,数万请求同时争抢下,很容易导致大量请求长期取不到锁而处理超时,系统吞吐量始终维持在很低的水平,用户体验非常差,最终“高并发”成了一句空谈。
怎样选择最合适的锁呢?首先我们必须清楚加锁的成本究竟有多大,其次我们要分析业务场景中访问共享资源的方式,最后则要预估并发访问时发生锁冲突的概率。这样,我们才能选对锁,同时实现高并发和高吞吐量这两个目标。
今天,我们就针对不同的应用场景,了解下锁的选择和使用,从而减少锁对高并发性能的影响。
## 互斥锁与自旋锁:休眠还是“忙等待”?
我们常见的各种锁是有层级的最底层的两种锁就是互斥锁和自旋锁其他锁都是基于它们实现的。互斥锁的加锁成本更高但它在加锁失败时会释放CPU给其他线程自旋锁则刚好相反。
**当你无法判断锁住的代码会执行多久时,应该首选互斥锁,互斥锁是一种独占锁。**什么意思呢当A线程取到锁后互斥锁将被A线程独自占有当A没有释放这把锁时其他线程的取锁代码都会被阻塞。
阻塞是怎样进行的呢?**对于99%的线程级互斥锁而言,阻塞都是由操作系统内核实现的**比如Linux下它通常由内核提供的信号量实现。当获取锁失败时内核会将线程置为休眠状态等到锁被释放后内核会在合适的时机唤醒线程而这个线程成功拿到锁后才能继续执行。如下图所示
<img src="https://static001.geekbang.org/resource/image/74/8a/749fc674c55136bd455725b79c9e0c8a.jpg" alt="">
互斥锁通过内核帮忙切换线程,简化了业务代码使用锁的难度。
但是,线程获取锁失败时,增加了两次上下文切换的成本:从运行中切换为休眠,以及锁释放时从休眠状态切换为运行中。上下文切换耗时在几十纳秒到几微秒之间,或许这段时间比锁住的代码段执行时间还长。而且,线程主动进入休眠是高并发服务无法容忍的行为,这让其他异步请求都无法执行。
如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。
自旋锁比互斥锁快得多因为它通过CPU提供的CAS函数全称Compare And Swap在用户态代码中完成加锁与解锁操作。
我们知道加锁流程包括2个步骤第1步查看锁的状态如果锁是空闲的第2步将锁设置为当前线程持有。
在没有CAS操作前多个线程同时执行这2个步骤是会出错的。比如线程A执行第1步发现锁是空闲的但它在执行第2步前线程B也执行了第1步B也发现锁是空闲的于是线程A、B会同时认为它们获得了锁。
CAS函数把这2个步骤合并为一条硬件级指令。这样第1步比较锁状态和第2步锁变量赋值将变为不可分割的原子指令。于是设锁为变量lock整数0表示锁是空闲状态整数pid表示线程ID那么CAS(lock, 0, pid)就表示自旋锁的加锁操作CAS(lock, pid, 0)则表示解锁操作。
多线程竞争锁的时候加锁失败的线程会“忙等待”直到它拿到锁。什么叫“忙等待”呢它并不意味着一直执行CAS函数生产级的自旋锁在“忙等待”时会与CPU紧密配合 它通过CPU提供的PAUSE指令减少循环等待时的耗电量对于单核CPU忙等待并没有意义此时它会主动把线程休眠。
如果你对此感兴趣,可以阅读下面这段生产级的自旋锁,看看它是怎么执行“忙等待”的:
```
while (true) {
//因为判断lock变量的值比CAS操作更快所以先判断lock再调用CAS效率更高
if (lock == 0 &amp;&amp; CAS(lock, 0, pid) == 1) return;
if (CPU_count &gt; 1 ) { //如果是多核CPU“忙等待”才有意义
for (n = 1; n &lt; 2048; n &lt;&lt;= 1) {//pause的时间应当越来越长
for (i = 0; i &lt; n; i++) pause();//CPU专为自旋锁设计了pause指令
if (lock == 0 &amp;&amp; CAS(lock, 0, pid)) return;//pause后再尝试获取锁
}
}
sched_yield();//单核CPU或者长时间不能获取到锁应主动休眠让出CPU
}
```
在使用层面上自旋锁与互斥锁很相似实现层面上它们又完全不同。自旋锁开销少在多核系统下一般不会主动产生线程切换很适合在用户态切换请求的编程方式有助于高并发服务充分利用多颗CPU。但如果被锁住的代码执行时间过长CPU资源将被其他线程在“忙等待”中长时间占用。
当取不到锁时,互斥锁用“线程切换”来面对,自旋锁则用“忙等待”来面对。**这是两种最基本的处理方式,更高级别的锁都会选择其中一种来实现,比如读写锁就既可以基于互斥锁实现,也可以基于自旋锁实现。**
下面我们来看一看读写锁能带来怎样的性能提升。
## 允许并发持有的读写锁
**如果你能够明确区分出读和写两种场景,可以选择读写锁。**
读写锁由读锁和写锁两部分构成,仅读取共享资源的代码段用读锁来加锁,会修改资源的代码段则用写锁来加锁。
读写锁的优势在于,当写锁未被持有时,多个线程能够并发地持有读锁,这提高了共享资源的使用率。多个读锁被同时持有时,读线程并不会修改共享资源,所以它们的并发执行不会产生数据错误。
而一旦写锁被持有后,不只读线程必须阻塞在获取读锁的环节,其他获取写锁的写线程也要被阻塞。写锁就像互斥锁和自旋锁一样,是一种独占锁;而读锁允许并发持有,则是一种共享锁。
**因此,读写锁真正发挥优势的场景,必然是读多写少的场景,否则读锁将很难并发持有。**
实际上,读写锁既可以倾向于读线程,又可以倾向于写线程。前者我们称为读优先锁,后者称为写优先锁。
读优先锁更强调效率它期待锁能被更多的线程持有。简单看下它的工作特点当线程A先持有读锁后即使线程B在等待写锁后续前来获取读锁的线程C仍然可以立刻加锁成功因为这样就有A、C 这2个读线程在并发持有锁效率更高。
我们再来看写优先的读写锁。同样的情况下线程C获取读锁会失败它将被阻塞在获取锁的代码中这样只要线程A释放读锁后线程B马上就可以获取到写锁。如下图所示
<img src="https://static001.geekbang.org/resource/image/7b/c6/7b5f4e4bb3370b89b90c1bf83cb58fc6.jpg" alt="">
读优先锁并发性更好,但问题也很明显。如果读线程源源不断地获取读锁,写线程将永远获取不到写锁。写优先锁可以保证写线程不会饿死,但如果新的写线程源源不断地到来,读线程也可能被饿死。
那么,能否兼顾二者,避免读、写线程饿死呢?
**用队列把请求锁的线程排队,按照先来后到的顺序加锁即可,当然读线程仍然可以并发,只不过不能插队到写线程之前。**Java中的ReentrantReadWriteLock读写锁就支持这种排队的公平读写锁。
如果不希望取锁时线程主动休眠,还可以用自旋锁实现读写锁。到底应该选择“线程切换”还是“忙等待”方式实现读写锁呢?除去读写场景外,这与选择互斥锁和自旋锁的方法相同,就是根据加锁代码执行时间的长短来选择,这里就不再赘述了。
## 乐观锁:不使用锁也能同步
事实上,无论互斥锁、自旋锁还是读写锁,都属于悲观锁。
什么叫悲观锁呢?它认为同时修改资源的概率很高,很容易出现冲突,所以访问共享资源前,先加上锁,总体效率会更优。然而,如果并发产生冲突的概率很低,就不必使用悲观锁,而是使用乐观锁。
所谓“乐观”,就是假定冲突的概率很低,所以它采用的“加锁”方式是,先修改完共享资源,再验证这段时间内有没有发生冲突。如果没有其他线程在修改资源,那么操作完成。如果发现其他线程已经修改了这个资源,就放弃本次操作。
至于放弃后如何重试,则与业务场景相关,虽然重试的成本很高,但出现冲突的概率足够低的话,还是可以接受的。可见,**乐观锁全程并没有加锁,所以它也叫无锁编程。**
无锁编程中,验证是否发生了冲突是关键。该怎么验证呢?这与具体的场景有关。
比如说在线文档。Web中的在线文档是怎么实现多人编辑的用户A先在浏览器中编辑某个文档之后用户B也打开了相同的页面开始编辑可是用户B最先编辑完成提交这一过程用户A却不知道。当A提交他改完的内容时A、B之间的并行修改引发了冲突。
Web服务是怎么解决这种冲突的呢它并没有限制用户先拿到锁后才能编辑文档这既因为冲突的概率非常低也因为加解锁的代价很高。Web中的方案是这样的让用户先改着但需要浏览器记录下修改前的文档版本号这通过下载文档时返回的HTTP ETag头部实现。
当用户提交修改时浏览器在请求中通过HTTP If-Match头部携带原版本号服务器将它与文档的当前版本号比较一致后新的修改才能生效否则提交失败。如下图所示如果你想了解这一过程的细节可以阅读 [《Web协议详解与抓包实战》第28课](https://time.geekbang.org/course/detail/175-98914)
<img src="https://static001.geekbang.org/resource/image/1d/f0/1db3bb24d896fabeebf68359384214f0.jpg" alt="">
乐观锁除了应用在Web分布式场景在数据库等单机上也有广泛的应用。只是面向多线程时最后的验证步骤是通过CPU提供的CAS操作完成的。
乐观锁虽然去除了锁操作,但是一旦发生冲突,重试的成本非常高。所以,**只有在冲突概率非常低,且加锁成本较高时,才考虑使用乐观锁。**
## 小结
这一讲我们介绍了高并发下同步资源时,如何根据应用场景选择合适的锁,来优化服务的性能。
互斥锁能够满足各类功能性要求,特别是被锁住的代码执行时间不可控时,它通过内核执行线程切换及时释放了资源,但它的性能消耗最大。需要注意的是,协程的互斥锁实现原理完全不同,它并不与内核打交道,虽然不能跨线程工作,但效率很高。(如果你希望进一步了解协程,可以阅读[[第5讲]](https://time.geekbang.org/column/article/233629)。)
如果能够确定被锁住的代码取到锁后很快就能释放,应该使用更高效的自旋锁,它特别适合基于异步编程实现的高并发服务。
如果能区分出读写操作,读写锁就是第一选择,它允许多个读线程同时持有读锁,提高了并发性。读写锁是有倾向性的,读优先锁很高效,但容易让写线程饿死,而写优先锁会优先服务写线程,但对读线程亲和性差一些。还有一种公平读写锁,它通过把等待锁的线程排队,以略微牺牲性能的方式,保证了某种线程不会饿死,通用性更佳。
另外,读写锁既可以使用互斥锁实现,也可以使用自旋锁实现,我们应根据场景来选择合适的实现。
当并发访问共享资源冲突概率非常低的时候可以选择无锁编程。它在Web和数据库中有广泛的应用。然而一旦冲突概率上升就不适合使用它因为它解决冲突的重试成本非常高。
总之,不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。在此之上,选择更合适的锁能够大幅提升高并发服务的性能!
## 思考题
最后,留给你一道思考题,上一讲我们提到协程中也有各种锁,你觉得协程中可以用自旋锁或者互斥锁吗?如果不可以,那协程中的锁是怎么实现的?欢迎你在留言区与我探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,129 @@
<audio id="audio" title="14 | 优化TLS/SSL性能该从何下手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/79/797e80fb602ca9d9c4146edef5928279.mp3"></audio>
你好,我是陶辉。
从这一讲开始,我们进入应用层协议的处理。
信息安全在当下越来越重要绝大多数站点访问时都使用https://替代了http://这就是在用TLS/SSL协议下文简称为TLS协议来保障应用层消息的安全。但另一方面你会发现很多图片类门户网站还在使用http://这是因为TLS协议在对信息加解密的同时必然会降低性能和用户体验这些站点在权衡后选择了性能优先。
实际上TLS协议由一系列加密算法及规范组成这些算法的安全性和性能各不相同甚至与你的系统硬件相关。比如当主机的CPU支持AES-NI指令集时选择AES对称加密算法便可以大幅提升性能。然而要想选择合适的算法需要了解算法所用到的一些数学知识而很多同学由于忽视了数学原理便难以正确地配置TLS算法。
同时TLS协议优化时也需要了解网络和软件工程知识比如我们可以在网络的不同位置缓存密钥来优化性能。而且TLS协议还可以优化其他应用层协议的性能比如从HTTP/1升级到HTTP/2协议便可以通过TLS协议减少1个RTT的时间。
优化TLS性能究竟该从何下手呢在我看来主要有两个方向一是对称加密算法的性能优化二是如何高效地协商密钥。下面我们来详细看看优化细节。
## 如何提升对称加密算法的性能?
如果你用Wireshark等工具对HTTPS请求抓包分析会发现在TCP传输层之上的消息全是乱码这是因为TCP之上的TLS层把HTTP请求用对称加密算法重新进行了编码。**当然用Chrome浏览器配合Wireshark可以解密消息帮助你分析TLS协议的细节**(具体操作方法可参考[《Web协议详解与抓包实战》第51课](https://time.geekbang.org/course/detail/175-104932))。
现代对称加密算法的特点是,即使把加密流程向全社会公开,攻击者也从公网上截获到密文,但只要他没有拿到密钥,就无法从密文中反推出原始明文。如何同步密钥我们稍后在谈,先来看如何优化对称加密算法。
目前主流的对称加密算法叫做AESAdvanced Encryption Standard它在性能和安全上表现都很优秀。而且它不只在访问网站时最为常用甚至你日常使用的WINRAR等压缩软件也在使用AES算法见[官方FAQ](https://www.win-rar.com/encryption-faq.html?&amp;L=0))。**因此AES是我们的首选对称加密算法**下面来看看AES算法该如何优化。
**AES只支持3种不同的密钥长度分别是128位、192位和256位它们的安全性依次升高运算时间也更长。**比如当密钥为128比特位时需要经过十轮操作其中每轮要用移位法、替换法、异或操作等对明文做4次变换。而当密钥是192位时则要经过12轮操作密钥为256比特位时则要经过14轮操作如下图所示。
[<img src="https://static001.geekbang.org/resource/image/8a/28/8ae363f2b0b8cb722533b596b9201428.png" alt="" title="AES128的10轮加密流程[br]此图由Ahmed Ghanim Wadday上传于www.researchgate.net">](http://www.researchgate.net)
密钥越长虽然性能略有下降但安全性提升很多。比如早先的DES算法只有56位密钥在1999年便被破解。**在TLS1.2及更早的版本中仍然允许通讯双方使用DES算法这是非常不安全的行为你应该在服务器上限制DES算法套件的使用**Nginx上限制加密套件的方法参见《Nginx 核心知识100讲》[第96课](https://time.geekbang.org/course/detail/138-75878) 和[第131课](https://time.geekbang.org/course/detail/138-79618)。也正因为密钥长度对安全性的巨大影响美国政府才不允许出口256位密钥的AES算法。
只有数百比特的密钥到底该如何对任意长度的明文加密呢主流对称算法会将原始明文分成等长的多组明文再分别用密钥生成密文最后把它们拼接在一起形成最终密文。而AES算法是按照128比特16字节对明文进行分组的最后一组不足128位时会填充0或者随机数。为了防止分组后密文出现明显的规律造成攻击者容易根据概率破解出原文我们就需要对每组的密钥做一些变换**这种分组后变换密钥的算法就叫做分组密码工作模式下文简称为分组模式它是影响AES性能的另一个因素。**
[<img src="https://static001.geekbang.org/resource/image/46/d1/460d594465b9eb9a04426c6ee35da4d1.png" alt="" title="优秀的分组密码工作模式[br]更难以从密文中发现规律图参见wiki">](https://zh.wikipedia.org/wiki/%E5%88%86%E7%BB%84%E5%AF%86%E7%A0%81%E5%B7%A5%E4%BD%9C%E6%A8%A1%E5%BC%8F)
比如CBC分组模式中只有第1组明文加密完成后才能对第2组加密因为第2组加密时会用到第1组生成的密文。因此CBC必然无法并行计算。在材料科学出现瓶颈、单核频率不再提升的当下CPU都在向多核方向发展而CBC分组模式无法使用多核的并行计算能力性能受到很大影响。**所以通常我们应选择可以并行计算的GCM分组模式这也是当下互联网中最常见的AES分组算法。**
由于AES算法中的替换法、行移位等流程对CPU指令并不友好所以Intel在2008年推出了支持[AES-NI指令集](https://zh.wikipedia.org/wiki/AES%E6%8C%87%E4%BB%A4%E9%9B%86)的CPU能够将AES算法的执行速度从每字节消耗28个时钟周期参见[这里](https://www.cryptopp.com/benchmarks-p4.html)降低至3.5个时钟周期(参见[这里](https://groups.google.com/forum/#!msg/cryptopp-users/5x-vu0KwFRk/CO8UIzwgiKYJ)。在Linux上你可以用下面这行命令查看CPU是否支持AES-NI指令集
```
# sort -u /proc/crypto | grep module |grep aes
module : aesni_intel
```
**因此如果CPU支持AES-NI特性那么应选择AES算法否则可以选择[CHACHA20](https://tools.ietf.org/html/rfc7539) 对称加密算法它主要使用ARX操作add-rotate-xorCPU执行起来更快。**
说完对称加密算法的优化,我们再来看加密时的密钥是如何传递的。
## 如何更快地协商出密钥?
无论对称加密算法有多么安全一旦密钥被泄露信息安全就是一纸空谈。所以TLS建立会话的第1个步骤是在握手阶段协商出密钥。
早期解决密钥传递的是[RSA](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) 密钥协商算法。当你部署TLS证书到服务器上时证书文件中包含一对公私钥参见[非对称加密](https://zh.wikipedia.org/wiki/%E5%85%AC%E5%BC%80%E5%AF%86%E9%92%A5%E5%8A%A0%E5%AF%86)其中公钥会在握手阶段传递给客户端。在RSA密钥协商算法中客户端会生成随机密钥事实上是生成密钥的种子参数并使用服务器的公钥加密后再传给服务器。根据非对称加密算法公钥加密的消息仅能通过私钥解密这样服务器解密后双方就得到了相同的密钥再用它加密应用消息。
**RSA密钥协商算法的最大问题是不支持前向保密**[Forward Secrecy](https://zh.wikipedia.org/wiki/%E5%89%8D%E5%90%91%E4%BF%9D%E5%AF%86)一旦服务器的私钥泄露过去被攻击者截获的所有TLS通讯密文都会被破解。解决前向保密的是[DHDiffieHellman密钥协商算法](https://zh.wikipedia.org/wiki/%E8%BF%AA%E8%8F%B2-%E8%B5%AB%E7%88%BE%E6%9B%BC%E5%AF%86%E9%91%B0%E4%BA%A4%E6%8F%9B)。
我们简单看下DH算法的工作流程。通讯双方各自独立生成随机的数字作为私钥而后依据公开的算法计算出各自的公钥并通过未加密的TLS握手发给对方。接着根据对方的公钥和自己的私钥双方各自独立运算后能够获得相同的数字这就可以作为后续对称加密时使用的密钥。**即使攻击者截获到明文传递的公钥查询到公开的DH计算公式后在不知道私钥的情况下也是无法计算出密钥的。**这样DH算法就可以在握手阶段生成随机的新密钥实现前向保密。
<img src="https://static001.geekbang.org/resource/image/9f/1d/9f5ab0e7f64497c825a927782f58f31d.png" alt="">
DH算法的计算速度很慢如上图所示计算公钥以及最终的密钥时需要做大量的乘法运算而且为了保障安全性这些数字的位数都很长。为了提升DH密钥交换算法的性能诞生了当下广为使用的[ECDH密钥交换算法](https://zh.wikipedia.org/wiki/%E6%A9%A2%E5%9C%93%E6%9B%B2%E7%B7%9A%E8%BF%AA%E8%8F%B2-%E8%B5%AB%E7%88%BE%E6%9B%BC%E9%87%91%E9%91%B0%E4%BA%A4%E6%8F%9B)**ECDH在DH算法的基础上利用[ECC椭圆曲线](https://zh.wikipedia.org/wiki/%E6%A4%AD%E5%9C%86%E6%9B%B2%E7%BA%BF)特性,可以用更少的计算量计算出公钥以及最终的密钥。**
依据解析几何,椭圆曲线实际对应一个函数,而不同的曲线便有不同的函数表达式,目前不被任何已知专利覆盖的最快椭圆曲线是[X25519曲线](https://en.wikipedia.org/wiki/Curve25519)它的表达式是y<sup>2</sup> = x<sup>3</sup> + 486662x<sup>2</sup> + x。因此当通讯双方协商使用X25519曲线用于ECDH算法时只需要传递X25519这个字符串即可。在Nginx上你可以使用ssl_ecdh_curve指令配置想使用的曲线
```
ssl_ecdh_curve X25519:secp384r1;
```
选择密钥协商算法是通过ssl_ciphers指令完成的
```
ssl_ciphers 'EECDH+ECDSA+AES128+SHA:RSA+AES128+SHA';
```
可见ssl_ciphers可以同时配置对称加密算法及密钥强度等信息。注意当ssl_prefer_server_ciphers设置为on时ssl_ciphers指定的多个算法是有优先顺序的**我们应当把性能最快且最安全的算法放在最前面。**
提升密钥协商速度的另一个思路是减少密钥协商的次数主要包括以下3种方式。
首先最为简单有效的方式是在一个TLS会话中传输多组请求对于HTTP协议而言就是使用长连接在请求中加入Connection: keep-alive头部便可以做到。
其次客户端与服务器在首次会话结束后缓存下session密钥并用唯一的session ID作为标识。这样下一次握手时客户端只要把session ID传给服务器且服务器在缓存中找到密钥后为了提升安全性缓存会定期失效双方就可以加密通讯了。这种方式的问题在于当N台服务器通过负载均衡提供TLS服务时客户端命中上次访问过的服务器的概率只有1/N所以大概率它们还得再次协商密钥。
session ticket方案可以解决上述问题它把服务器缓存密钥改为由服务器把密钥加密后作为ticket票据发给客户端由客户端缓存密文。其中集群中每台服务器对session加密的密钥必须相同这样客户端携带ticket密文访问任意一台服务器时都能通过解密ticket获取到密钥。
当然使用session缓存或者session ticket既没有前向安全性应对[重放攻击](https://en.wikipedia.org/wiki/Replay_attack)也更加困难。提升TLS握手性能的更好方式是把TLS协议升级到1.3版本。
## 为什么应当尽快升级到TLS1.3
TLS1.3(参见[RFC8446](https://tools.ietf.org/html/rfc8446)对性能的最大提升在于它把TLS握手时间从2个RTT降为1个RTT。
在TLS1.2的握手中先要通过Client Hello和Server Hello消息协商出后续使用的加密算法再互相交换公钥并计算出最终密钥。**TLS1.3中把Hello消息和公钥交换合并为一步这就减少了一半的握手时间**,如下图所示:
[<img src="https://static001.geekbang.org/resource/image/49/20/4924f22447eaf0cc443aac9b2d483020.png" alt="" title="TLS1.3相对TLS1.2减少了1个RTT的握手时间[br]图片来自www.ssl2buy.com">](https://www.ssl2buy.com/wiki/tls-1-3-protocol-released-move-ahead-to-advanced-security-and-privacy)
那TLS1.3握手为什么只需要1个RTT就可以完成呢因为TLS1.3支持的密钥协商算法大幅度减少了这样客户端尽可以把常用DH算法的公钥计算出来并与协商加密算法的HELLO消息一起发送给服务器服务器也作同样处理这样仅用1个RTT就可以协商出密钥。
而且TLS1.3仅支持目前最安全的几个算法比如openssl中仅支持下面5种安全套件
- TLS_AES_256_GCM_SHA384
- TLS_CHACHA20_POLY1305_SHA256
- TLS_AES_128_GCM_SHA256
- TLS_AES_128_CCM_8_SHA256
- TLS_AES_128_CCM_SHA256
相较起来TLS1.2支持各种古老的算法,中间人可以利用[降级攻击](https://en.wikipedia.org/wiki/Downgrade_attack)在握手阶段把加密算法替换为不安全的算法从而轻松地破解密文。如前文提到过的DES算法由于密钥位数只有56位很容易破解。
因此,**无论从性能还是安全角度上你都应该尽快把TLS版本升级到1.3。**你可以用[这个网址](https://www.ssllabs.com/ssltest/index.html)测试当前站点是否支持TLS1.3。
<img src="https://static001.geekbang.org/resource/image/a8/57/a816a361d7f47303cbfeb10035a96d57.png" alt="">
如果不支持,还可以参见[每日一课《TLS1.3原理及在Nginx上的应用》](https://time.geekbang.org/dailylesson/detail/100028440)升级Nginx到TLS1.3版本。
## 小结
这一讲我们介绍了TLS协议的优化方法。
应用消息是通过对称加密算法编码的而目前AES还是最安全的对称加密算法。不同的分组模式也会影响AES算法的性能而GCM模式能够充分利用多核CPU的并行计算能力所以AES_GCM是我们的首选。当你的CPU支持AES-NI指令集时AES算法的执行会非常快否则可以考虑对CPU更友好的CHACHA20算法。
再来看对称加密算法的密钥是如何传递的它决定着TLS系统的安全也对HTTP小对象的传输速度有很大影响。DH密钥协商算法速度并不快因此目前主要使用基于椭圆曲线的ECDH密钥协商算法其中不被任何专利覆盖的X25519椭圆曲线速度最快。为了减少密钥协商次数我们应当尽量通过长连接来复用会话。在TLS1.2及早期版本中session缓存和session ticket也能减少密钥协商时的计算量但它们既没有前向安全性也更难防御重放攻击所以为了进一步提升性能应当尽快升级到TLS1.3。
TLS1.3将握手时间从2个RTT降为1个RTT而且它限制了目前已经不再安全的算法这样中间人就难以用降级攻击来破解密钥。
密码学的演进越来越快,加密与破解总是在道高一尺、魔高一丈的交替循环中发展,当下安全的算法未必在一年后仍然安全。而且,当量子计算机真正诞生后,它强大的并行计算能力可以轻松地暴力破解当下还算安全的算法。然而,这种划时代的新技术出现时总会有一个时间窗口,而在窗口内也会涌现出能够防御住量子破解的新算法。所以,我们应时常关注密码学的进展,更换更安全、性能也更优秀的新算法。
## 思考题
最后留给你一道思考题TLS体系中还有许多性能优化点比如在服务器上部署[OSCP Stapling](https://zh.wikipedia.org/wiki/OCSP%E8%A3%85%E8%AE%A2)用于更快地发现过期证书也可以提升网站的访问性能你还用过哪些方式优化TLS的性能呢欢迎你在留言区与我探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="15 | 如何提升HTTP/1.1性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/09/71766c707c12801cc505d62333674409.mp3"></audio>
你好,我是陶辉。
上一讲介绍了为应用层信息安全保驾护航的TLS/SSL协议这一讲我们来看看最常用的应用层协议HTTP/1.1该如何优化。
由于门槛低、易监控、自表达等特点HTTP/1.1在互联网诞生之初就成为最广泛使用的应用层协议。然而它的性能却很差最为人诟病的是HTTP头部的传输占用了大量带宽。由于HTTP头部使用ASCII编码方式这造成它往往达到几KB而且滥用的Cookie头部进一步增大了体积。与此同时REST架构的无状态特性还要求每个请求都得重传HTTP头部这就使消息的有效信息比重难以提高。
你可能听说过诸如缓存、长连接、图片拼接、资源压缩等优化HTTP协议性能的方式这些优化方案有些从底层的传输层优化入手有些从用户使用浏览器的体验入手有些则从服务器资源的编码入手五花八门导致我们没有系统化地优化思路往往在性能提升上难尽全功。
那么如何全面地提升HTTP/1.1协议的性能呢我认为在不升级协议的情况下有3种优化思路首先是通过缓存避免发送HTTP请求其次如果不得不发起请求那么就得思考如何才能减少请求的个数最后则是减少服务器响应的体积。
接下来我们就沿着这3个思路看看具体的优化方法。
## 通过缓存避免发送HTTP请求
如果不走网络就能获得HTTP响应这样性能肯定最高。HTTP协议设计之初就考虑到了这一点缓存能够让客户端在免于发送HTTP请求的情况下获得服务器的资源。
缓存到底是如何做到的呢其实很简单它从时间维度上做文章把第1份请求及其响应保存在客户端的本地磁盘上其中请求的URL作为关键字部分HTTP头部也会加入关键字例如确定服务器域名的Host头部而响应就是值。这样后续发起相同的请求时就可以先在本地磁盘上通过关键字查找如果找到了就直接将缓存作为服务器响应使用。读取本地磁盘耗时不过几十毫秒这远比慢了上百倍且不稳定的网络请求快得多。
<img src="https://static001.geekbang.org/resource/image/9d/ab/9dea133d832d8b7ab642bb74b48502ab.png" alt="">
你可能会问服务器上的资源更新后客户端并不知道它若再使用过期的缓存就会出错这该如何解决因此服务器会在发送响应时预估一个过期时间并在响应头部中告诉客户端而客户端一旦发现缓存过期则重新发起网络请求。HTTP协议控制缓存过期的头部非常多而且通常这是在服务器端设置的我会在本专栏的第4部分“分布式系统优化”结合服务器操作再来介绍这里暂时略过。
当然过期的缓存也仍然可以提升性能如下图所示当客户端发现缓存过期后会取出缓存的摘要摘要是从第1次请求的响应中拿到的把它放在请求的Etag头部中再发给服务器。而服务器获取到请求后会将本地资源的摘要与请求中的Etag相比较如果不同那么缓存没有价值重新发送最新资源即可如果摘要与Etag相同那么仅返回不含有包体的304 Not Modified响应告知客户端缓存仍然有效即可这就省去传递可能高达千百兆的文件资源。
<img src="https://static001.geekbang.org/resource/image/a3/62/a394b7389d0cdb5f0866223681e19b62.png" alt="">
至于Etag摘要究竟是怎样生成的各类Web服务器的处理方式不尽相同比如Nginx会将文件大小和修改时间拼接为一个字符串作为文件摘要详见[《Nginx核心知识100讲》第97课](https://time.geekbang.org/course/detail/138-76358))。过期缓存在分布式系统中可以有效提升可用性,[第25课] 还会站在反向代理的角度介绍过期缓存的用法。
浏览器上的缓存只能为一个用户使用故称为私有缓存。代理服务器上的缓存可以被许多用户使用所以称为共享缓存。可见共享缓存带来的性能收益被庞大的客户端群体放大了。你可以看到在下面的REST架构图中表示缓存的$符号缓存的英文名称是cache由于它的发音与cash很像所以许多英文文档中用美元符号来表示缓存既存在于User Agent浏览器中也存在于Proxy正向代理服务器和Gateway反向代理上。
<img src="https://static001.geekbang.org/resource/image/b6/ff/b6950aa62483c56438c41bc4b2f43bff.png" alt="" title="图片来自网络">
可见缓存与互联网世界的网络效率密切相关用好缓存是提升HTTP性能最重要的手段。
## 如何降低HTTP请求的次数
如果不得不发起请求就应该尽量减少HTTP请求的次数这可以从减少重定向次数、合并请求、延迟发送请求等3个方面入手。
首先来看看什么是重定向请求一个资源由于迁移、维护等原因从url1移至url2后在客户端访问原先的url1时服务器不能简单粗暴地返回错误而是通过302响应码及Location头部告诉客户端资源已经改到url2了而客户端再用url2发起新的HTTP请求。
可见重定向增加了请求的数量。尤其客户端在公网中时由于公网速度慢、成本高、路径长、不稳定而且为了信息安全性还要用TLS协议加密这些都降低了网络性能。从上面的REST架构图可以看到HTTP请求会经过多个代理服务器如果将重定向工作交由代理服务器完成就能减少网络消耗如下图所示
<img src="https://static001.geekbang.org/resource/image/0d/2a/0d1701737956ce65f3d5ec8fa8009f2a.png" alt="">
更进一步客户端还可以缓存重定向响应。RFC规范定义了5个重定向响应码如下表所示其中客户端接收到301和308后都可以将重定向响应缓存至本地之后客户端会自动用url2替代url1访问网络资源。
<img src="https://static001.geekbang.org/resource/image/85/70/85b55f50434fc1acd2ead603e5c57870.jpg" alt="">
其次我们来看如何合并请求。当多个访问小文件的请求被合并为一个访问大文件的请求时这样虽然传输的总资源体积未变但减少请求就意味着减少了重复发送的HTTP头部同时也减少了TCP连接的数量因而省去了TCP握手和慢启动过程消耗的时间参见第12课。我们具体看几种合并请求的方式。
一些WEB页面往往含有几十、上百个小图片用[CSS Image Sprites技术](https://www.tutorialrepublic.com/css-tutorial/css-sprites.php)可以将它们合成一张大图片而浏览器获得后可以根据CSS数据把它切割还原为多张小图片这可以大幅减少网络消耗。
[<img src="https://static001.geekbang.org/resource/image/e4/ed/e4a760a95542701d99b8a38ccddaebed.png" alt="" title="图片来自[黑染枫林的CSDN博客]">](https://blog.csdn.net/weixin_38055381/article/details/81504716)
类似地在服务器端用webpack等打包工具将Javascript、CSS等资源合并为大文件也能起到同样的效果。
除此以外还可以将多媒体资源用base64编码后以URL的方式嵌入HTML文件中以减少小请求的个数参见[RFC2397](https://tools.ietf.org/html/rfc2397))。
<img src="https://static001.geekbang.org/resource/image/27/fb/27aa119e0104f41a6718987cdf71c8fb.png" alt="">
用Chrome浏览器开发者工具的Network面板可以轻松地判断各站点是否使用了这种技术。关于Network面板的用法我们在此就不赘述了可参考[《Web协议详解与抓包实战》第9课](https://time.geekbang.org/course/detail/175-93594),那里有详细的介绍)。
<img src="https://static001.geekbang.org/resource/image/ab/74/ab75920b0c43e4f7fcbc5933005afb74.png" alt="">
当然这种合并请求的方式也会带来一个新问题当其中一个资源发生变化后客户端必须重新下载完整的大文件这显然会带来额外的网络消耗。在落后的HTTP/1.1协议中合并请求还算一个不错的解决方案在下一讲将介绍的HTTP/2出现后这种技术就没有用武之地了。
最后我们还可以从浏览页面的体验角度上减少HTTP请求的次数。比如有些HTML页面上引用的资源其实在当前页面上用不上而是供后续页面使用的这就可以使用懒加载[lazy loading](https://zh.wikipedia.org/wiki/%E6%83%B0%E6%80%A7%E8%BC%89%E5%85%A5) 技术延迟发起请求。
当不得不发起请求时,还可以从服务器的角度通过减少响应包体的体积来提升性能。
## 如何重新编码以减少响应的大小?
减少资源体积的唯一方式是对资源重新编码压缩,其中又分为[无损压缩](https://zh.wikipedia.org/wiki/%E6%97%A0%E6%8D%9F%E6%95%B0%E6%8D%AE%E5%8E%8B%E7%BC%A9)与[有损压缩](https://zh.wikipedia.org/wiki/%E6%9C%89%E6%8D%9F%E6%95%B0%E6%8D%AE%E5%8E%8B%E7%BC%A9)两种。
先来看无损压缩,这是指压缩后不会损失任何信息,可以完全恢复到压缩前的原样。因此,文本文件、二进制可执行文件都会使用这类压缩方法。
源代码也是文本文件,但它有自身的语法规则,所以可以依据语法先做一轮压缩。比如[jQuery](https://jquery.com/download/) 是用javascript语言编写的库而标准版的jQuery.js为了帮助程序员阅读含有许多空格、回车等符号但机器解释执行时并不需要这些符号。因此根据语法规则将这些多余的符号去除掉就可以将jQuery文件的体积压缩到原先的三分之一。
<img src="https://static001.geekbang.org/resource/image/de/ba/de0fc2a64a100bc83fc48eb2606fedba.png" alt="">
接着可以基于信息熵原理进行通用的无损压缩这需要对原文建立统计模型将出现频率高的数据用较短的二进制比特序列表示而将出现频率低的数据用较长的比特序列表示。我们最常见的Huffman算法就是一种执行速度较快的实践在下一讲的HTTP/2协议中还会用到它。在上图中可以看到最小版的jQuery文件经过Huffman等算法压缩后体积还会再缩小三分之二。
支持无损压缩算法的客户端会在请求中通过Accept-Encoding头部明确地告诉服务器
```
Accept-Encoding: gzip, deflate, br
```
而服务器也会在响应的头部中告诉客户端包体中的资源使用了何种压缩算法Nginx开启资源压缩的方式参见《Nginx核心知识100讲》[第131课](https://time.geekbang.org/course/detail/138-79618)和[第134课](https://time.geekbang.org/course/detail/138-79621)
```
content-encoding: gzip
```
虽然目前gzip使用最为广泛但它的压缩效率以及执行速度其实都很一般Google于2015年推出的[Brotli](https://zh.wikipedia.org/wiki/Brotli) 算法在这两方面表现都更优秀也就是上文中的br其对比数据如下
[<img src="https://static001.geekbang.org/resource/image/62/cb/62e01433ad8ef23ab698e7c47b7cc8cb.png" alt="" title="图片来源:[https://quixdb.github.io/squash-benchmark/]">](https://quixdb.github.io/squash-benchmark/)
再来看有损压缩它通过牺牲质量来提高压缩比主要针对的是图片和音视频。HTTP请求可以通过Accept头部中的q质量因子参见[RFC7231](https://tools.ietf.org/html/rfc7231#section-5.3.2)),告诉服务器期望的资源质量:
```
Accept: audio/*; q=0.2, audio/basic
```
先来看图片的压缩。目前压缩比较高的开源算法是Google在2010年推出的[WebP格式](https://zh.wikipedia.org/wiki/WebP),你可以在[这个页面](https://isparta.github.io/compare-webp/index.html#12345)看到它与png格式图片的对比图。对于大量使用图片的网站使用它代替传统格式会有显著的性能提升。
动态的音视频压缩比要比表态的图片高很多!由于音视频数据有时序关系,且时间连续的帧之间变化很小,因此可以在静态的关键帧之后,使用增量数据表达后续的帧,因此在质量略有损失的情况下,音频体积可以压缩到原先的几十分之一,视频体积则可以压缩到几百分之一,比图片的压缩比要高得多。因此,对音视频做有损压缩,能够大幅提升网络传输的性能。
对响应资源做压缩不只用于HTTP/1.1协议事实上它对任何信息传输场景都有效消耗一些CPU计算力在事前或者事中做压缩通常会给性能带来不错的提升。
## 小结
这一讲我们从三个方面介绍了HTTP/1.1协议的优化策略。
首先客户端缓存响应可以在有效期内避免发起HTTP请求。即使缓存过期后如果服务器端资源未改变仍然可以通过304响应避免发送包体资源。浏览器上的私有缓存、服务器上的共享缓存都对HTTP协议的性能提升有很大意义。
其次是降低请求的数量如将原本由客户端处理的重定向请求移至代理服务器处理可以减少重定向请求的数量。或者从体验角度使用懒加载技术延迟加载部分资源也可以减少请求数量。再比如将多个文件合并后再传输能够少传许多HTTP头部而且减少TCP连接数量后也省去握手和慢启动的消耗。当然合并文件的副作用是小文件的更新会导致整个合并后的大文件重传。
最后可以通过压缩响应来降低传输的字节数选择更优秀的压缩算法能够有效降低传输量比如用Brotli无损压缩算法替换gzip或者用WebP格式替换png等格式图片等。
但其实在HTTP/1.1协议上做优化效果总是有限的下一讲我们还将介绍在URL、头部等高层语法上向前兼容的HTTP/2协议它在性能上有大幅度提升是如gRPC等应用层协议的基础。
## 思考题
除了我今天介绍的方法以外使用KeepAlive长连接替换短连接也能提升性能你还知道有哪些提升HTTP/1.1性能的方法吗?欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="16 | HTTP/2是怎样提升性能的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/fb/ef86354bab99bd133610c355793d2efb.mp3"></audio>
你好,我是陶辉。
上一讲我们从多个角度优化HTTP/1的性能但获得的收益都较为有限而直接将其升级到兼容HTTP/1的HTTP/2协议性能会获得非常大的提升。
HTTP/2协议既降低了传输时延也提升了并发性已经被主流站点广泛使用。多数HTTP头部都可以被压缩90%以上的体积这节约了带宽也提升了用户体验像Google的高性能协议gRPC也是基于HTTP/2协议实现的。
目前常用的Web中间件都已支持HTTP/2协议然而如果你不清楚它的原理对于Nginx、Tomcat等中间件新增的流、推送、消息优先级等HTTP/2配置项你就不知是否需要调整。
同时许多新协议都会参考HTTP/2优秀的设计如果你不清楚HTTP/2的性能究竟高在哪里也就很难对当下其他应用层协议触类旁通。而且HTTP/2协议也并不是毫无缺点到2020年3月时它的替代协议[HTTP/3](https://zh.wikipedia.org/wiki/HTTP/3) 已经经历了[27个草案](https://tools.ietf.org/html/draft-ietf-quic-http-27)推出在即。HTTP/3的目标是优化传输层协议它会保留HTTP/2协议在应用层上的优秀设计。如果你不懂HTTP/2也就很难学会未来的HTTP/3协议。
所以这一讲我们就将介绍HTTP/2对HTTP/1.1协议都做了哪些改进从消息的编码、传输等角度说清楚性能提升点这样你就能理解支持HTTP/2的中间件为什么会提供那些参数以及如何权衡HTTP/2带来的收益与付出的升级成本。
## 静态表编码能节约多少带宽?
HTTP/1.1协议最为人诟病的是ASCII头部编码效率太低浪费了大量带宽。HTTP/2使用了静态表、动态表两种编码技术合称为HPACK极大地降低了HTTP头部的体积搞清楚编码流程你自然就会清楚服务器提供的http2_max_requests等配置参数的意义。
我们以一个具体的例子来观察编码流程。每一个HTTP/1.1请求都会有Host头部它指示了站点的域名比如
```
Host: test.taohui.tech\r\n
```
算上冒号空格以及结尾的\r\n它占用了24字节。**使用静态表及Huffman编码可以将它压缩为13字节也就是节约了46%的带宽!**这是如何做到的呢?
我用Chrome访问站点test.taohui.tech并用Wireshark工具抓包关于如何用Wireshark抓HTTP/2协议的报文如果你还不太清楚可参见[《Web协议详解与抓包实战》第51课](https://time.geekbang.org/course/detail/175-104932)下图高亮的头部就是第1个请求的Host头部其中每8个蓝色的二进制位是1个字节报文中用了13个字节表示Host头部。
<img src="https://static001.geekbang.org/resource/image/09/1f/097e7f4549eb761c96b61368c416981f.png" alt="">
HTTP/2能够用13个字节编码原先的24个字节是依赖下面这3个技术。
首先基于二进制编码,就不需要冒号、空格和\r\n作为分隔符转而用表示长度的1个字节来分隔即可。比如上图中的01000001就表示Host而10001011及随后的11个字节表示域名。
其次使用静态表来描述Host头部。什么是静态表呢HTTP/2将61个高频出现的头部比如描述浏览器的User-Agent、GET或POST方法、返回的200 SUCCESS响应等分别对应1个数字再构造出1张表并写入HTTP/2客户端与服务器的代码中。由于它不会变化所以也称为静态表。
<img src="https://static001.geekbang.org/resource/image/5c/98/5c180e1119c1c0eb66df03a9c10c5398.png" alt="">
这样收到01000001时根据[RFC7541](https://tools.ietf.org/html/rfc7541) 规范前2位为01时表示这是不包含Value的静态表头部
<img src="https://static001.geekbang.org/resource/image/cd/37/cdf16023ab2c2f4f67f0039b8da47837.png" alt="">
再根据索引000001查到authority头部Host头部在HTTP/2协议中被改名为authority。紧跟的字节表示域名其中首个比特位表示域名是否经过Huffman编码而后7位表示了域名的长度。在本例中10001011表示域名共有11个字节8+2+1=11且使用了Huffman编码。
最后使用静态Huffman编码可以将16个字节的test.taohui.tech压缩为11个字节这是怎么做到的呢根据信息论高频出现的信息用较短的编码表示后可以压缩体积。因此在统计互联网上传输的大量HTTP头部后HTTP/2依据统计频率将ASCII码重新编码为一张表参见[这里](https://tools.ietf.org/html/rfc7541#page-27)。test.taohui.tech域名用到了10个字符我把这10个字符的编码列在下表中。
<img src="https://static001.geekbang.org/resource/image/81/de/81d2301553c825a466b1f709924ba6de.jpg" alt="">
这样接收端在收到下面这串比特位最后3位填1补位通过查表请注意每个字符的颜色与比特位是一一对应的就可以快速解码为
<img src="https://static001.geekbang.org/resource/image/57/50/5707f3690f91fe54045f4d8154fe4e50.jpg" alt="">
由于8位的ASCII码最小压缩为5位所以静态Huffman的最大压缩比只有5/8。关于Huffman编码是如何构造的你可以参见[每日一课《HTTP/2 能带来哪些性能提升?》](https://time.geekbang.org/dailylesson/detail/100028441)。
## 动态表编码能节约多少带宽?
虽然静态表已经将24字节的Host头部压缩到13字节**但动态表可以将它压缩到仅1字节这就能节省96%的带宽!**那动态表是怎么做到的呢?
你可能注意到当下许多页面含有上百个对象而REST架构的无状态特性要求下载每个对象时都得携带完整的HTTP头部。如果HTTP/2能在一个连接上传输所有对象那么只要客户端与服务器按照同样的规则对首次出现的HTTP头部用一个数字标识随后再传输它时只传递数字即可这就可以实现几十倍的压缩率。所有被缓存的头部及其标识数字会构成一张表它与已经传输过的请求有关是动态变化的因此被称为动态表。
静态表有61项所以动态表的索引会从62起步。比如下图中的报文中访问test.taohui.tech的第1个请求有13个头部需要加入动态表。其中Host: test.taohui.tech被分配到的动态表索引是74索引号是倒着分配的
<img src="https://static001.geekbang.org/resource/image/69/e0/692a5fad16d6acc9746e57b69b4f07e0.png" alt="">
这样后续请求使用到Host头部时只需传输1个字节11001010即可。其中首位1表示它在动态表中而后7位1001010值为64+8+2=74指向服务器缓存的动态表第74项
<img src="https://static001.geekbang.org/resource/image/9f/31/9fe864459705513bc361cee5eafd3431.png" alt="">
静态表、Huffman编码、动态表共同完成了HTTP/2头部的编码其中前两者可以将体积压缩近一半而后者可以将反复传输的头部压缩95%以上的体积!
<img src="https://static001.geekbang.org/resource/image/c0/0c/c08db9cb2c55cb05293c273b8812020c.png" alt="">
那么是否要让一条连接传输尽量多的请求呢并不是这样。动态表会占用很多内存影响进程的并发能力所以服务器都会提供类似http2_max_requests这样的配置限制一个连接上能够传输的请求数量通过关闭HTTP/2连接来释放内存。**因此http2_max_requests并不是越大越好通常我们应当根据用户浏览页面时访问的对象数量来设定这个值。**
## 如何并发传输请求?
HTTP/1.1中的KeepAlive长连接虽然可以传输很多请求但它的吞吐量很低因为在发出请求等待响应的那段时间里这个长连接不能做任何事而HTTP/2通过Stream这一设计允许请求并发传输。因此HTTP/1.1时代Chrome通过6个连接访问页面的速度远远比不上HTTP/2单连接的速度具体测试结果你可以参考这个[页面](https://http2.akamai.com/demo)。
为了理解HTTP/2的并发是怎样实现的你需要了解Stream、Message、Frame这3个概念。HTTP请求和响应都被称为Message消息它由HTTP头部和包体构成承载这二者的叫做Frame帧它是HTTP/2中的最小实体。Frame的长度是受限的比如Nginx中默认限制为8Khttp2_chunk_size配置因此我们可以得出2个结论HTTP消息可以由多个Frame构成以及1个Frame可以由多个TCP报文构成TCP MSS通常小于1.5K)。
再来看Stream流它与HTTP/1.1中的TCP连接非常相似当Stream作为短连接时传输完一个请求和响应后就会关闭当它作为长连接存在时多个请求之间必须串行传输。在HTTP/2连接上理论上可以同时运行无数个Stream这就是HTTP/2的多路复用能力它通过Stream实现了请求的并发传输。
[<img src="https://static001.geekbang.org/resource/image/b0/c8/b01f470d5d03082159e62a896b9376c8.png" alt="" title="图片来源https://developers.google.com/web/fundamentals/performance/http2">](https://developers.google.com/web/fundamentals/performance/http2)
虽然RFC规范并没有限制并发Stream的数量但服务器通常都会作出限制比如Nginx就默认限制并发Stream为128个http2_max_concurrent_streams配置以防止并发Stream消耗过多的内存影响了服务器处理其他连接的能力。
HTTP/2的并发性能比HTTP/1.1通过TCP连接实现并发要高。这是因为**当HTTP/2实现100个并发Stream时只经历1次TCP握手、1次TCP慢启动以及1次TLS握手但100个TCP连接会把上述3个过程都放大100倍**
HTTP/2还可以为每个Stream配置1到256的权重权重越高服务器就会为Stream分配更多的内存、流量这样按照资源渲染的优先级为并发Stream设置权重后就可以让用户获得更好的体验。而且Stream间还可以有依赖关系比如若资源A、B依赖资源C那么设置传输A、B的Stream依赖传输C的Stream即可如下图所示
[<img src="https://static001.geekbang.org/resource/image/9c/97/9c068895a9d2dc66810066096172a397.png" alt="" title="图片来源https://developers.google.com/web/fundamentals/performance/http2">](https://developers.google.com/web/fundamentals/performance/http2)
## 服务器如何主动推送资源?
HTTP/1.1不支持服务器主动推送消息因此当客户端需要获取通知时只能通过定时器不断地拉取消息。HTTP/2的消息推送结束了无效率的定时拉取节约了大量带宽和服务器资源。
<img src="https://static001.geekbang.org/resource/image/f0/16/f0dc7a3bfc5709adc434ddafe3649316.png" alt="">
HTTP/2的推送是这么实现的。首先所有客户端发起的请求必须使用单号Stream承载其次所有服务器进行的推送必须使用双号Stream承载最后服务器推送消息时会通过PUSH_PROMISE帧传输HTTP头部并通过Promised Stream ID告知客户端接下来会在哪个双号Stream中发送包体。
<img src="https://static001.geekbang.org/resource/image/a1/62/a1685cc8e24868831f5f2dd961ad3462.png" alt="">
在SDK中调用相应的API即可推送消息而在Web资源服务器中可以通过配置文件做简单的资源推送。比如在Nginx中如果你希望客户端访问/a.js时服务器直接推送/b.js那么可以这么配置
```
location /a.js {
http2_push /b.js;
}
```
服务器同样也会控制并发推送的Stream数量如http2_max_concurrent_pushes配置以减少动态表对内存的占用。
## 小结
这一讲我们介绍了HTTP/2的高性能是如何实现的。
静态表和Huffman编码可以将HTTP头部压缩近一半的体积但这只是连接上第1个请求的压缩比。后续请求头部通过动态表可以压缩90%以上这大大提升了编码效率。当然动态表也会导致内存占用过大影响服务器的总体并发能力因此服务器会限制HTTP/2连接的使用时长。
HTTP/2的另一个优势是实现了Stream并发这节约了TCP和TLS协议的握手时间并减少了TCP的慢启动阶段对流量的影响。同时Stream之间可以用Weight权重调节优先级还可以直接设置Stream间的依赖关系这样接收端就可以获得更优秀的体验。
HTTP/2支持消息推送从HTTP/1.1的拉模式到推模式信息传输效率有了巨大的提升。HTTP/2推消息时会使用PUSH_PROMISE帧传输头部并用双号的Stream来传递包体了解这一点对定位复杂的网络问题很有帮助。
HTTP/2的最大问题来自于它下层的TCP协议。由于TCP是字符流协议在前1字符未到达时后接收到的字符只能存放在内核的缓冲区里即使它们是并发的Stream应用层的HTTP/2协议也无法收到失序的报文这就叫做队头阻塞问题。解决方案是放弃TCP协议转而使用UDP协议作为传输层协议这就是HTTP/3协议的由来。
<img src="https://static001.geekbang.org/resource/image/38/d1/3862dad08cecc75ca6702c593a3c9ad1.png" alt="">
## 思考题
最后留给你一道思考题。为什么HTTP/2要用静态Huffman查表法对字符串编码基于连接上的历史数据统计信息做动态Huffman编码不是更有效率吗欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,110 @@
<audio id="audio" title="17 | Protobuf是如何进一步提高编码效率的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/20/8e66aeee4819a94f8edbf01c09c26320.mp3"></audio>
你好,我是陶辉。
上一讲介绍的HTTP/2协议在编码上拥有非常高的空间利用率这一讲我们看看相比其中的HPACK编码技术Protobuf又是通过哪些新招式进一步提升编码效率的。
Google在2008年推出的Protobuf是一个针对具体编程语言的编解码工具。它面向Windows、Linux等多种平台也支持Java、Python、Golang、C++、Javascript等多种面向对象编程语言。使用Protobuf编码消息速度很快消耗的CPU计算力也不多而且编码后的字符流体积远远小于JSON等格式能够大量节约昂贵的带宽因此gRPC也把Protobuf作为底层的编解码协议。
然而很多同学并不清楚Protobuf到底是怎样做到这一点的。这样当你希望通过更换通讯协议这个高成本手段提升整个分布式系统的性能时面对可供选择的众多通讯协议仅凭第三方的性能测试报告你仍将难以作出抉择。
而且面对分布式系统中的疑难杂症往往需要通过分析抓取到的网络报文确定到底是哪个组件出现了问题。可是由于Protobuf编码太过紧凑即使对照着Proto消息格式文件在不清楚编码逻辑时你也很难解析出消息内容。
下面我们将基于上一讲介绍过的HPACK编码技术看看Protobuf是怎样进一步缩减编码体积的。
## 怎样用最少的空间编码字段名?
消息由多个名、值对组成比如HTTP请求中头部Host: www.taohui.pub就是一个名值对其中Host是字段名称而www.taohui.pub是字段值。我们先来看Protobuf如何编码字段名。
对于多达几十字节的HTTP头部HTTP/2静态表仅用一个数字来表示其中映射数字与字符串对应关系的表格被写死在HTTP/2实现框架中。这样的编码效率非常高**但通用的HTTP/2框架只能将61个最常用的HTTP头部映射为数字它能发挥出的作用很有限。**
动态表可以让更多的HTTP头部编码为数字在上一讲的例子中动态表将Host头部减少了96%的体积效果惊人。但动态表生效得有一个前提必须在一个会话连接上反复传输完全相同的HTTP头部。**如果消息字段在1个连接上只发送了1次或者反复传输时字段总是略有变动动态表就无能为力了。**
有没有办法既使用静态表的预定义映射关系,又享受到动态表的灵活多变呢?**其实只要把由HTTP/2框架实现的字段名映射关系交由应用程序自行完成即可。**而Protobuf就是这么做的。比如下面这段39字节的JSON消息虽然一目了然但字段名name、id、sex其实都是多余的因为客户端与服务器的处理代码都清楚字段的含义。
```
{&quot;name&quot;:&quot;John&quot;,&quot;id&quot;:1234,&quot;sex&quot;:&quot;MALE&quot;}
```
Protobuf将这3个字段名预分配了3个数字定义在proto文件中
```
message Person {
string name = 1;
uint32 id = 2;
enum SexType {
MALE = 0;
FEMALE = 1;
}
SexType sex = 3;
}
```
接着通过protoc程序便可以针对不同平台、编程语言将它生成编解码类最后通过类中自动生成的SerializeToString方法将消息序列化编码后的信息仅有11个字节。其中报文与字段的对应关系我放在下面这张图中。
<img src="https://static001.geekbang.org/resource/image/12/b4/12907732b38fd0c0f41330985bb02ab4.png" alt="">
从图中可以看出Protobuf是按照字段名、值类型、字段值的顺序来编码的由于编码极为紧凑所以分析时必须基于二进制比特位进行。比如红色的00001、00010、00011等前5个比特位就分别代表着name、id、sex字段。
图中字段值的编码方式我们后面再解释这里想必大家会有疑问如果只有5个比特位表示字段名的值那不是限制消息最多只有31个2<sup>5</sup> - 1字段吗当然不是字段名的序号可以从1到536870911即2<sup>29</sup> - 1可是多数消息不过只有几个字段这意味着可以用很小的序号表示它们。因此对于小于16的序号Protobuf仅有5个比特位表示这样加上3位值类型只需要1个字节表示字段名。对于大于16小于2027的序号也只需要2个字节表示。
Protobuf可以用1到5个字节来表示一个字段名因此每个字节的第1个比特位保留它为0时表示这是字段名的最后一个字节。下表列出了几种典型序号的编码值请把黑色的二进制位从右至左排列比如2049应为000100000000001即2048+1
<img src="https://static001.geekbang.org/resource/image/43/33/43983f7fcba1d26eeea952dc0934d833.jpg" alt="">
说完字段名,我们再来看字段值是如何编码的。
## 怎样高效地编码字段值?
Protobuf对不同类型的值采用6种不同的编码方式如下表所示
<img src="https://static001.geekbang.org/resource/image/b2/67/b20120a8bac33d985275b5a2768ad067.jpg" alt="">
字符串用Length-delimited方式编码顾名思义在值长度后顺序添加ASCII字节码即可。比如上文例子中的John对应的ASCII码如下表所示
<img src="https://static001.geekbang.org/resource/image/9f/cb/9f472ea914f98a81c03a7ad309f687cb.jpg" alt="">
这样,"John"需要5个字节进行编码如下图所示绿色表示长度紫色表示ASCII码
<img src="https://static001.geekbang.org/resource/image/6e/ae/6e45b5c7bb5e8766f6baef8c0e8b7bae.png" alt="">
这里需要注意字符串长度的编码逻辑与字段名相同当长度小于1282<sup>7</sup>1个字节就可以表示长度。若长度从128到163842<sup>14</sup>则需要2个字节以此类推。
由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。**如果你的消息中含有大量字符串那么使用Huffman等算法压缩后再编码效果更好。**
我们再来看id1234这个数字是如何编码的。其实Protobuf中所有数字的编码规则是一致的字节中第1个比特位仅用于指示由哪些字节编码1个数字。例如图中的1234将由14个比特位00010011010010表示1024+128+64+16+2正好是1234
**由于消息中的大量数字都很小,这种编码方式可以带来很高的空间利用率!**当然如果你确定数字很大这种编码方式不但不能节约空间而且会导致原先4个字节的大整数需要用5个字节来表示时你也可以使用fixed32、fixed64等类型定义数字。
Protobuf还可以通过enum枚举类型压缩空间。回到第1幅图sex: FEMALE仅用2个字节就编码完成正是枚举值FEMALE使用数字1表示所达到的效果。
<img src="https://static001.geekbang.org/resource/image/c9/c7/c9b6c10399a34d7a0e577a0397cd5ac7.png" alt="">
而且由于Protobuf定义了每个字段的默认值因此当消息使用字段的默认值时Protobuf编码时会略过该字段。以sex: MALE为例由于MALE=0是sex的默认值因此在第2幅示例图中这2个字节都省去了。
另外当使用repeated语法将多个数字组成列表时还可以通过打包功能提升编码效率。比如下图中对numbers字段添加101、102、103、104这4个值后如果不使用打包功能共需要8个字节编码其中每个数字前都需要添加字段名。而使用打包功能后仅用6个字节就能完成编码显然列表越庞大节约的空间越多。
<img src="https://static001.geekbang.org/resource/image/ce/47/ce7ed2695b1e3dd869b59c438ee66147.png" alt="">
在Protobuf2版本中需要显式设置 [packed=True] 才能使用打包功能而在Protobuf3版本中这是默认功能。
最后,从[这里](https://github.com/protocolbuffers/protobuf/blob/master/docs/performance.md)可以查看Protobuf的编解码性能测试报告你能看到在保持高空间利用率的前提下Protobuf仍然拥有飞快的速度
## 小结
这一讲我们介绍了Protobuf的编码原理。
通过在proto文件中为每个字段预分配1个数字编码时就省去了完整字段名占用的空间。而且数字越小编码时用掉的空间也越小实际网络中大量传输的是小数字这带来了很高的空间利用率。Protobuf的枚举类型也通过类似的原理用数字代替字符串可以节约许多空间。
对于字符串Protobuf没有做压缩因此如果消息中的字符串比重很大时建议你先压缩后再使用Protobuf编码。对于拥有默认值的字段Protobuf编码时会略过它。对于repeated列表使用打包功能可以仅用1个字段前缀描述所有数值它在列表较大时能带来可观的空间收益。
## 思考题
下一讲我将介绍gRPC协议它结合了HTTP/2与Protobuf的优点在应用层提供方便而高效的RPC远程调用协议。你也可以提前思考下既然Protobuf的空间效率远甚过HPACK技术为什么gRPC还要使用HTTP/2协议呢
在Protobuf的性能测试报告中C++语言还拥有arenas功能你可以通过option cc_enable_arenas = true语句打开它。请结合[[第2讲]](https://time.geekbang.org/column/article/230221) 的内容谈谈arenas为什么能提升消息的解码性能欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,147 @@
<audio id="audio" title="18 | 如何通过gRPC实现高效远程过程调用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/e8/e894713ff9692c1c167f23df35b9b8e8.mp3"></audio>
你好,我是陶辉。
这一讲我们将以一个实战案例基于前两讲提到的HTTP/2和ProtoBuf协议看看gRPC如何将结构化消息编码为网络报文。
直接操作网络协议编程容易让业务开发过程陷入复杂的网络处理细节。RPC框架以编程语言中的本地函数调用形式向应用开发者提供网络访问能力这既封装了消息的编解码也通过线程模型封装了多路复用对业务开发很友好。
其中Google推出的gRPC是性能最好的RPC框架之一它支持Java、JavaScript、Python、GoLang、C++、Object-C、Android、Ruby等多种编程语言还支持安全验证等特性得到了广泛的应用比如微服务中的Envoy、分布式机器学习中的TensorFlow甚至华为去年推出重构互联网的New IP技术都使用了gRPC框架。
然而网络上教你使用gRPC框架的教程很多却很少去谈gRPC是如何编码消息的。这样一旦在大型分布式系统中出现疑难杂症需要通过网络报文去定位问题发生在哪个系统、主机、进程中时你就会毫无头绪。即使我们掌握了HTTP/2和Protobuf协议但若不清楚gRPC的编码规则还是无法分析抓取到的gRPC报文。而且gRPC支持单向、双向的流式RPC调用编程相对复杂一些定位流式RPC调用引发的bug时更需要我们掌握gRPC的编码原理。
这一讲我就将以gRPC官方提供的example[data_transmisstion](https://github.com/grpc/grpc/tree/master/examples/python/data_transmission) 为例介绍gRPC的编码流程。在这一过程中会顺带回顾HTTP/2和Protobuf协议加深你对它们的理解。虽然这个示例使用的是Python语言但基于gRPC框架你可以轻松地将它们转换为其他编程语言。
## 如何使用gRPC框架实现远程调用
我们先来简单地看下gRPC框架到底是什么。RPC的全称是Remote Procedure Call即远程过程调用它通过本地函数调用封装了跨网络、跨平台、跨语言的服务访问大大简化了应用层编程。其中函数的入参是请求而函数的返回值则是响应。
gRPC就是一种RPC框架在你定义好消息格式后针对你选择的编程语言gRPC为客户端生成发起RPC请求的Stub类以及为服务器生成处理RPC请求的Service类服务器只需要继承、实现类中处理请求的函数即可。如下图所示很明显gRPC主要服务于面向对象的编程语言。
<img src="https://static001.geekbang.org/resource/image/c2/a1/c20e6974a05b5e71823aec618fc824a1.jpg" alt="">
gRPC支持QUIC、HTTP/1等多种协议但鉴于HTTP/2协议性能好应用场景又广泛因此HTTP/2是gRPC的默认传输协议。gRPC也支持JSON编码格式但在忽略编码细节的RPC调用中高效的Protobuf才是最佳选择因此这一讲仅基于HTTP/2和Protobuf介绍gRPC的用法。
gRPC可以简单地分为三层包括底层的数据传输层中间的框架层框架层又包括C语言实现的核心功能以及上层的编程语言框架以及最上层由框架层自动生成的Stub和Service类如下图所示
[<img src="https://static001.geekbang.org/resource/image/2a/4a/2a3f82f3eaabd440bf1ee449e532944a.png" alt="" title="图片来源https://platformlab.stanford.edu/Seminar%20Talks/gRPC.pdf">](https://platformlab.stanford.edu/Seminar%20Talks/gRPC.pdf)
接下来我们以官网上的[data_transmisstion](https://github.com/grpc/grpc/tree/master/examples/python/data_transmission) 为例先看看如何使用gRPC。
构建Python语言的gRPC环境很简单你可以参考官网上的[QuickStart](https://grpc.io/docs/quickstart/python/)。
使用gRPC前先要根据Protobuf语法编写定义消息格式的proto文件。在这个例子中只有1种请求和1种响应且它们很相似各含有1个整型数字和1个字符串如下所示
```
package demo;
message Request {
int64 client_id = 1;
string request_data = 2;
}
message Response {
int64 server_id = 1;
string response_data = 2;
}
```
请注意这里的包名demo以及字段序号1、2都与后续的gRPC报文分析相关。
接着定义service所有的RPC方法都要放置在service中这里将它取名为GRPCDemo。GRPCDemo中有4个方法后面3个流式访问的例子我们呆会再谈先来看简单的一元访问模式SimpleMethod 方法它定义了1个请求对应1个响应的访问形式。其中SimpleMethod的参数Request是请求返回值Response是响应。注意分析报文时会用到这里的类名GRPCDemo以及方法名SimpleMethod。
```
service GRPCDemo {
rpc SimpleMethod (Request) returns (Response);
}
```
用grpc_tools中的protoc命令就可以针对刚刚定义的service生成含有GRPCDemoStub类和GRPCDemoServicer类的demo_pb2_grpc.py文件实际上还包括完成Protobuf编解码的demo_pb2.py应用层将使用这两个类完成RPC访问。我简化了官网上的Python客户端代码如下所示
```
with grpc.insecure_channel(&quot;localhost:23333&quot;) as channel:
stub = demo_pb2_grpc.GRPCDemoStub(channel)
request = demo_pb2.Request(client_id=1,
request_data=&quot;called by Python client&quot;)
response = stub.SimpleMethod(request)
```
示例中客户端与服务器都在同一台机器上通过23333端口访问。客户端通过Stub对象的SimpleMethod方法完成了RPC访问。而服务器端的实现也很简单只需要实现GRPCDemoServicer父类的SimpleMethod方法返回response响应即可
```
class DemoServer(demo_pb2_grpc.GRPCDemoServicer):
def SimpleMethod(self, request, context):
response = demo_pb2.Response(
server_id=1,
response_data=&quot;Python server SimpleMethod Ok!!!!&quot;)
return response
```
可见gRPC的开发效率非常高接下来我们分析这次RPC调用中消息是怎样编码的。
## gRPC消息是如何编码的
**定位复杂的网络问题,都需要抓取、分析网络报文。**如果你在Windows上抓取网络报文可以使用Wireshark工具可参考[《Web协议详解与抓包实战》第37课](https://time.geekbang.org/course/detail/175-100973)如果在Linux上抓包可以使用tcpdump工具可参考[第87课](https://time.geekbang.org/course/detail/175-118169))。当然,你也可以从[这里](https://github.com/russelltao/geektime_distrib_perf/blob/master/18-gRPC/data_transmission.pkt)下载我抓取好的网络报文用Wireshark打开它。需要注意23333不是HTTP常用的80或者443端口所以Wireshark默认不会把它解析为HTTP/2协议。你需要鼠标右键点击报文选择“解码为”Decode as将23333端口的报文设置为HTTP/2解码器如下图所示
<img src="https://static001.geekbang.org/resource/image/74/f7/743e5038dc22676a0d48b56c453c1af7.png" alt="">
图中蓝色方框中TCP连接的建立过程请参见[[第9讲]](https://time.geekbang.org/column/article/237612)而HTTP/2会话的建立可参见[《Web协议详解与抓包实战》第52课](https://time.geekbang.org/course/detail/175-105608)还是比较简单的如果你都清楚就可以直接略过。我们重点看红色方框中的gRPC请求与响应点开请求可以看到下图中的信息
<img src="https://static001.geekbang.org/resource/image/ba/41/ba4d9e9a6ce212e94a4ced829eeeca41.png" alt="">
先来分析蓝色方框中的HTTP/2头部。请求中有2个关键的HTTP头部path和content-type它们决定了RPC方法和具体的消息编码格式。path的值为“/demo.GRPCDemo/SimpleMethod”通过“/包名.服务名/方法名”的形式确定了RPC方法。content-type的值为“application/grpc”确定消息编码使用Protobuf格式。如果你对其他头部的含义感兴趣可以看下这个[文档](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)注意这里使用了ABNF元数据定义语言如果你还不了解ABNF可以看下[《Web协议详解与抓包实战》第4课](https://time.geekbang.org/course/detail/175-93589))。
HTTP/2包体并不会直接存放Protobuf消息而是先要添加5个字节的Length-Prefixed Message头部其中用4个字节明确Protobuf消息的长度1个字节表示消息是否做过压缩即上图中的桔色方框。为什么要多此一举呢这是因为gRPC支持流式消息即在HTTP/2的1条Stream中通过DATA帧发送多个gRPC消息而Length-Prefixed Message就可以将不同的消息分离开。关于流式消息我们在介绍完一元模式后再加以分析。
最后分析Protobuf消息这里仅以client_id字段为例对上一讲的内容做个回顾。在proto文件中client_id字段的序号为1因此首字节00001000中前5位表示序号为1的client_id字段后3位表示字段的值类型是varint格式的数字因此随后的字节00000001表示字段值为1。序号为2的request_data字段请你结合上一讲的内容试着做一下解析看看字符串“called by Python client”是怎样编码的。
再来看服务器发回的响应点开Wireshark中的响应报文后如下图所示
<img src="https://static001.geekbang.org/resource/image/b8/cf/b8e71a1b956286b2def457c2fae78bcf.png" alt="">
其中DATA帧同样包括Length-Prefixed Message和Protobuf与RPC请求如出一辙这里就不再赘述了我们重点看下HTTP/2头部。你可能留意到响应头部被拆成了2个部分其中grpc-status和grpc-message是在DATA帧后发送的这样就允许服务器在发送完消息后再给出错误码。关于gRPC的官方错误码以及message描述信息是如何取值的你可以参考[这个文档。](https://github.com/grpc/grpc/blob/master/doc/statuscodes.md)
这种将部分HTTP头部放在包体后发送的技术叫做Trailer[RFC7230文档](https://tools.ietf.org/html/rfc7230#page-39)对此有详细的介绍。其中RPC请求中的TE: trailers头部就说明客户端支持Trailer头部。在RPC响应中grpc-status头部都会放在最后发送因此它的帧flags的EndStream标志位为1。
可以看到gRPC中的HTTP头部与普通的HTTP请求完全一致因此它兼容当下互联网中各种七层负载均衡这使得gRPC可以轻松地跨越公网使用。
## gRPC流模式的协议编码
说完一元模式我们再来看流模式RPC调用的编码方式。
所谓流模式是指RPC通讯的一方可以在1次RPC调用中持续不断地发送消息这对订阅、推送等场景很有用。流模式共有3种类型包括客户端流模式、服务器端流模式以及两端双向流模式。在[data_transmisstion](https://github.com/grpc/grpc/tree/master/examples/python/data_transmission) 官方示例中对这3种流模式都定义了RPC方法如下所示
```
service GRPCDemo {
rpc ClientStreamingMethod (stream Request) returns Response);
rpc ServerStreamingMethod (Request) returns (stream Response);
rpc BidirectionalStreamingMethod (stream Request) returns (stream Response);
}
```
不同的编程语言处理流模式的代码很不一样这里就不一一列举了但通讯层的流模式消息编码是一样的而且很简单。这是因为HTTP/2协议中每个Stream就是天然的1次RPC请求每个RPC消息又已经通过Length-Prefixed Message头部确立了边界这样在Stream中连续地发送多个DATA帧就可以实现流模式RPC。我画了一张示意图你可以对照它理解抓取到的流模式报文。
<img src="https://static001.geekbang.org/resource/image/4b/e0/4b1b9301b5cbf0e0544e522c2a8133e0.jpg" alt="">
## 小结
这一讲介绍了gRPC怎样使用HTTP/2和Protobuf协议编码消息。
在定义好消息格式以及service类中的RPC方法后gRPC框架可以为编程语言生成Stub和Service类而类中的方法就封装了网络调用其中方法的参数是请求而方法的返回值则是响应。
发起RPC调用后我们可以这么分析抓取到的网络报文。首先分析应用层最外层的HTTP/2帧根据Stream ID找出一次RPC调用。客户端HTTP头部的path字段指明了service和RPC方法名而content-type则指明了消息的编码格式。服务器端的HTTP头部被分成2次发送其中DATA帧发送完毕后才会发送grpc-status头部这样可以明确最终的错误码。
其次分析包体时可以通过Stream中Length-Prefixed Message头部确认DATA帧中含有多少个消息因此可以确定这是一元模式还是流式调用。在Length-Prefixed Message头部后则是Protobuf消息按照上一讲的内容进行分析即可。
## 思考题
最后留给你一道练习题。gRPC默认并不会压缩字符串你可以通过在获取channel对象时加入grpc.default_compression_algorithm参数的形式要求gRPC压缩消息此时Length-Prefixed Message中1个字节的压缩位将会由0变为1。你可以观察下执行压缩后的gRPC消息有何不同欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,51 @@
<audio id="audio" title="开篇词 | 万变不离其宗,性能优化也有章可循" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/28/ff7e40ee58c5bdbcd660049c17625228.mp3"></audio>
你好,我是陶辉。欢迎你和我一起学习“系统性能优化”。
从2004年毕业到现在无论我在华为、思科、腾讯、阿里巴巴等等哪家公司我的工作总是与“性能”相伴从底层、应用层到前端我一直都很关心系统如何能够服务更多的用户提供更快的体验。换句话说啊**性能优化最根本的目的,还是要跟上业务的发展脚步。**
在我看来程序员所做的工作就是把现实世界中的问题用数据、模型来抽象再用计算机的计算能力把问题解决掉虽然IT设备的运行速度日新月异但业务要处理的数据在现实世界中近乎是无限的而我们的计算能力相对则极为有限。所以我们需要提升计算的效率需要更苛刻地使用计算机和网络设备做性价比更高的事儿。
而从十几年的性能优化工作中我发现,性能不只对产品的攻城掠地至关重要,它也是程序员价值的重要体现,特别是它在工作面试、技术等级晋升上也扮演着核心角色。
比如,在大多数拥有技术职级晋升体系的公司里,为了保障公平性,一般都是由跨部门的专家组成评委会的,而**其他部门的高级专家在不熟悉候选人业务的情况下,只能去考察底层的硬核知识,而性能问题又是最有区分度的问题。**如果你始终埋头在业务中,不关心更通用的性能优化方法论,将在技术等级晋升上非常吃亏。
再比如,你在面试互联网大厂时,面试官总会问许多超出工作范围的性能问题,为什么会这样呢?当然你可以感慨甚至抱怨,这不就是“面试造火箭,入职拧螺丝”嘛,但你也可以从面试官的角度来看这个问题,你会发现**性能就是最好的面试题,它从算法到架构,既能考察候选人的潜力,也能考察候选人的工程能力。**如果候选人具备系统的性能优化方法论那么无论在架构设计还是应用模块开发上他的代码可扩展性都会更好消耗的计算力、带宽、磁盘等IT资源也更少
这么看来不论是为了满足业务发展的需求还是在面试、晋升场景中有更好的表现如果你希望成为高薪高效的10X程序员那么系统地学习性能优化就是一门必修课。
那具体应该从哪里入手呢?当然是看需求。当下的后端几乎都是**分布式系统**,那么对应的,我们面对的课题也就是**如何全面提升复杂集群的性能**。然而如果你在Google上搜索如何优化分布式系统的性能你只能找到孤零零的几篇文章而谈到分布式系统的多数书籍也都在讨论容错、事务、流控等概念的实现很少有文章介绍如何优化整个系统的性能。这恰恰就是我想做这门课的初衷。
我希望把自己这些年来在分布式性能领域所遇到的问题和解决方案,归纳总结、抽离萃取,梳理出一条**系统化的性能学习路径**交付给你,告诉你我眼中的性能问题本质是怎样的。在我看来啊,性能优化的本质就是最大化整个系统的综合效率,为了达到这个目标,我们需要从空间、时间维度上,不断地优化基础资源间的协作方式。
文稿中我给你总结了一份系统性能优化核心关注点的知识脑图,你可以同步看一下:
<img src="https://static001.geekbang.org/resource/image/e7/3d/e7aef5a7653c2ae6c1060e290a7a343d.jpg" alt="">
通过这份脑图你会发现我是从4个方面来梳理的这其实就是我们在提升一个新系统的性能时可以入手的4个层次。
首先你可以从提升单机进程的性能入手包括高效地使用主机的CPU、内存、磁盘等硬件通过并发编程提升吞吐量根据业务特性选择合适的算法。
其次,分布式系统是由各个组件通过网络连接在一起,所以优化传输层网络可以让所有组件同时受益。具体优化时,你可以从降低请求的时延,提升总体吞吐量两个方向入手。
再次呢你要对业务消息采用更高效的编码方式这既包括协议头、包体的优化也包括TLS安全层的性能提升。具体优化时既要深入静态编码也要从动态的增量编码上优化。同时调整消息的交互方式也能提升性能。
最后我们再从集群整体上进行架构层面的优化。基于ACP、AKF、NWR等分布式理论我们的优化方向仍然是降低时延和提升吞吐量但实现方式则要运用分而治之的思想调度集群中的所有结点协作配合完成性能优化目标。
我这么介绍完吧我猜你心里可能会有一些问题好像这些内容都挺熟悉呀是的咱们这门课的知识点并不是非常新的前沿知识而是我们每天coding时都在面对的日常问题。这里的关键是我们得**系统地**掌握这些知识点,在心中构建出性能优化树状知识图谱,然后我们才能更有底气地优化整个系统的性能。
另外,你可能也会感觉到,哎,好像性能优化也没那么复杂吧,感觉知识点不多呀!如果你是这么想的,那我要先夸夸你,我们就是要有这样的信心,先不给自己设限,不能还没开始就怕了。不过有了信心和动力后呢,也还是要再听我多说几句。
性能问题其实是计算机体系的底层问题,它涉及到的知识面非常广,我们的课程不可能覆盖全部领域。我最希望给到你的,是基于自己的经历和经验,对知识做一次筛选和过滤,把我已经构建起来的性能优化体系给到你,但同时,我能保证我们在解决性能优化中一些典型问题的同时,可以关联到绝大部分领域,对于任何一个领域,如果你需要进一步深入学习,你也能够知道自己的目标和路径。
好了,课程我就介绍到这里,你准备好了吗?那在正式开始之前,我们先来定下学习目标吧。
如果你需要从架构层面优化整个系统,那么这门课可以拓展你的知识面,告诉你如何优化架构才能让整体服务获得最大性能。那你的学习目标就要聚焦在知识体系上,你需要阶段性地做总结分享。
如果你刚开始接触性能优化,这门课则可以给你打牢基础,告诉你影响性能的底层因素,在实践中优化你的程序,看到立竿见影的效果。那我对你的要求就是每节课都要认真完成课后思考题,学习过程中有任何问题都要及时提问,不积压不懂的地方。
当然了,我的“要求”主要是供你参考的,欢迎你在留言区写写自己的学习计划。最后,我还特别希望听你说说,你目前的工作状态,是否意识到了性能问题的重要性,是否已经在解决很多性能相关的棘手问题,是否自己也总结了一些性能优化思路,分享出来,我们一起交流。
讲到最后,我都有点激动了,我一直认为性能优化并非是架构师的专属技能,只要我们有清晰的路径,积硅步成千里,我们都可以用更好的体验、更低的成本来服务更多的用户,还能轻松应对大厂面试,完成公司技术体系内的晋升,拿到更高的薪资。
那还等啥,从现在开始,我们就一起解锁分布式系统的性能优化吧!

View File

@@ -0,0 +1,35 @@
<audio id="audio" title="加餐1特别福利陶辉视频课精选" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/fb/446617da0682a4f27db0147f366059fb.mp3"></audio>
你好,我是陶辉。
不知道你的期中考试成绩如何呢?是否发现了一些新的问题?成绩好的同学请继续努力了,咱们还有一半的课程未学,希望你能一如既往地跟着我深挖系统性能,这门课的内容设计是由浅入深的,第三、四模块会重点往集群方向转了,内容相对会更加烧脑。成绩略差的同学也不要气馁,就像我上节课说的,问题驱动是一种很好的学习方式,查漏补缺才是这场期中考试的目的。
聊完考试我就来兑现承诺了精选3节我的视频课作为福利送给你它们来自[《Web协议详解与抓包实战》](https://time.geekbang.org/course/detail/100026801-93596)这3节课都是与咱们专栏有强关联的。
我还记得在开篇词中有位同学问我,学习咱们专栏需要哪些基础?我是这么回复的:
1. 对操作系统的原理有简单的了解比如文件是怎么读取的HTTP请求是怎么发送和接收的
1. 对网络协议要有所了解毕竟分布式系统就是靠网络将操作系统上的进程连接在一起。这块《Web协议详解与抓包实战》可以先学下。
1. 大致知道什么是分布式系统,这块网上的文章有很多。
其中第1点和第3点我想还是很容易的所以我就在第2点上为你加码了。以下就是3节视频课你可以直接点击观看。
## 第1节
与[第7课](https://time.geekbang.org/column/article/235302)有联系的[《Web协议详解与抓包实战》第117课](https://time.geekbang.org/course/detail/175-134405)。我在第7课讲解组播时有推荐你如果想要进一步了解组播的细节可以观看以下视频课。
<video poster="https://media001.geekbang.org/359a2787ee244329b9adcfb4f38fd412/snapshots/608274439cdf483488b53feb2b2f4484-00005.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/1025bb60-16d1595a668-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/92a7232de62042c09b0192682f2061e0/684692ecfc454b199a21406d5b8df606-9a4d4abb9d5d9dc2132014b8cbb6c37c-sd.m3u8" type="application/x-mpegURL"></video>
## 第2节
与[第14课](https://time.geekbang.org/column/article/241632)有联系的[《Web协议详解与抓包实战》第51课](https://time.geekbang.org/course/detail/175-104932)。我在第14课讲解用 Chrome 浏览器配合 Wireshark 解密消息,可以帮助你分析 TLS 协议的细节时,具体的操作方法有推荐你观看以下视频课。
<video poster="https://media001.geekbang.org/5a1316079a344db5ae9798c970997767/snapshots/dd8e0c6373844445a4c5990446e11009-00005.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/498cd722-16ce7fc8c98-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/ed16e26efdcc4ba4a3710cf85a9dde0b/df98d3463dbe413d831f65320139d9ff-678b414d8e306ddfff2f96407f7708fa-sd.m3u8" type="application/x-mpegURL"></video>
## 第3节
与[第15课](https://time.geekbang.org/column/article/242667)有联系的[《Web协议详解与抓包实战》第9课](https://time.geekbang.org/course/detail/175-93594)。第15课我们介绍了 Chrome 浏览器开发者工具的 Network 面板通过它可以快速判断各站点使用了哪些HTTP性能优化技术有关 Network 面板的详细用法,我推荐你观看以下视频课。
<video poster="https://media001.geekbang.org/75c859f0bfca445089a915bac4bd6177/snapshots/dbb7b8e460b5464baf8abee0e321dc3c-00005.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/fe4a99b62946f2c31c2095c167b26f9c/324bfc16-16ce90f3288-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/f1e2b9b587064da1b6f6474c49ac5e77/bb389aca12374babb1ecb9138780dc25-d423ec6794e8818946dc711c6d62cfce-sd.m3u8" type="application/x-mpegURL"></video>
今天的特别福利就到这里这3节视频课对于你学习咱们专栏有一定的辅助作用希望你能把它作为拓展认真学完。如果你有更多想要了解的内容和期待的学习资料欢迎在留言区中提出。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="加餐2 |答疑精选:这些问题你都清楚吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/f6/edc0559bbff8e9f79c70143c52615bf6.mp3"></audio>
你好我是陶辉。今天是期中周的第2篇加餐按照约定这节课我从115课的留言区精选出了15个问题这里一部分是与内容强相关的还有一部分是属于拓展型的问题选择标准就是是否存在增量信息以及问题价值希望你能从别人的疑问中进行一次自检引发更多的思考。
## 第1课
鲤鲤鱼我们集群有一个问题某一台物理机的CPU会被Hadoop yarn的查询任务打满并且占用最多的pid在不停的变化我查看了TIME_WAIT的个数好像也不是很多在顶峰的时候还没达到一万能够持续一两个小时。这个问题您有没有什么思路呢
作者:解决性能问题,一般有两种方法:经验派和“理论”派。前者就是基于自己的经验概率,将能想到的优化方法都试一遍,这种方式通常又有效又快速,但无法解决复杂的问题。而所谓理论派,就是沿着固定的思路,使用二分法,从高至低慢慢下沉到细节。
具体到你的问题我建议你先看看CPU占用的是用户态还是系统态用户态的话就要分析代码了系统态还要进一步分析。火焰图通常是个很好的办法虽然搭能画火焰图的环境很麻烦但这种底层方法很有效第19课会具体讲到火焰图的用法
## 第2课
alan老师好这节课真好第一次了解到内存池也是有层次的。我遇到一个问题想请教一下我有一个和数据库交互的Groovy程序运行起来后会占用很大内存启动时将Xmx设置为多少该程序的内存占用就不会超过Xmx指定的上限。比如Xmx=10g程序就稳定占10g内存但如果不限制的话最高见过占用30G左右。这个您觉得有什么可能的原因吗
作者每种Java虚拟机都有自己独特的垃圾回收机制有时为了时间更快就会牺牲更多的内存空间这是正常的。我建议在服务器上长时间运行的Java进程一定要通过Xmx去明确内存占用否则内存不可控会很麻烦。
## 第3课
杨文宇:链表的内存地址不连续是如何影响序列化的?老师能具体说一下吗?
作者当数组外还有链表中的元素时序列化就必须遍历所有元素比如至少要做1次循环把每1个遍历到的元素的值序列化写入至另一段内存中。而使用闭散列时可以直接将这个数组占用的内存作为序列化后的数据来直接落地或使用。
## 第4课
helloworld“第二读取磁盘数据时需要先找到数据所在的位置对于机械磁盘来说就是旋转磁头到数据所在的扇区再开始顺序读取数据。其中旋转磁头耗时很长为了降低它的影响PageCache 使用了预读功能。”那是不是使用SSD这类固态硬盘不用旋转磁头PageCache就没有很大的影响
作者对的其实当下的操作系统对SSD磁盘的支持还不够当SSD广泛应用时文件系统还需要跟上得获得很大的性能提升才可以。
## 第5课
Robust“然而共享地址空间虽然可以方便地共享对象但这也导致一个问题那就是任何一个线程出错时进程中的所有线程会跟着一起崩溃。”这里的出错应该表示一些特殊的错误吧或者是说和共享内存有关的错误比如申请不到内存等。老师我理解得没错吧
作者:这里指无法恢复的错误,不仅是内存申请错误,比如访问已经释放的资源,且没有捕获异常或者无法捕获异常,进而操作系统只能杀死线程时,进程里的其它线程也会被杀死。
## 第6课
范闲用户态的协程不能用互斥或者自旋会进入内核态与其设计初衷相悖Python里面用的yield。
作者:是的,用户态协程需要用户态的代码将锁重新实现一遍,其中实现时不能用到内核提供的系统调用。
## 第7课
重返归途:广播功能属于双工吗?当多个客户机向主机响应时,会有性能瓶颈吗?
作者:广播不是双工,因为广播是由网络设备实现的,所以服务器无法感知到每个客户端的响应,因此客户端对服务器的响应,与本次广播消息链路无关,它必须是另一个通道。
## 第8课
龙龙:“因此,哪怕有 1 千万并发连接,也能保证 1 万 RPS 的处理能力,这就是 epoll 能在 C10M 下实现高吞吐量的原因。”老师这段话我不太理解1千万的并发连接只有1万的RPS这能算高吞吐量吗相当于每秒只有1000个人中的1个人得到响应。还是我理解错了您表述的是另一层意思
作者这里有2层意思都是服务于epoll的设计思想这一个目的。
1. 这段话的上下文是指单次获取网络事件时你可以理解为调用epoll_wait系统调用它的速度与并发连接总数无关相对于之前的select/poll系统调用它们都与并发连接总数相关因此epoll_wait速度很快这是实现高吞吐量的关键。
1. 有些应用会长时间保持TCP长连接但并没有消息通讯比如GPS等IoT设备与服务器之间的通讯此时1千万并发连接下如果能够维持1万RPS这也是只有epoll才能做到的poll/select是不可能做到的。
## 第9课
Geek_007看评论区很多同学都说是长连接普通的HTTP keep-alive会不会有坑三大运营商或者中间网络设备都会将超过一定时间的链接drop掉。如果没有H2这种ping保活的机制有可能客户端长链接莫名其妙的就被drop掉客户端只能依赖超时来感知异常反倒是影响性能了。
作者是的不只网络设备一些代理服务器为了减轻自己的负担也会把长连接断掉比如Nginx默认关闭75秒没有数据交互的keep-alive长连接。
## 第10课
安排TTL每一跳减少1这些怎么和MSL对应起来呢每一跳减少的1相当于1秒
作者不是这是一个预估值所谓每一跳是指每经过一个路由器网络设备将IP头部中的TTL字段减少1并不等于1秒通常推荐的TTL的初始值是64。
## 第11课
Trident带宽时延积如何衡量呢网络时延不是固定的是要多次取样计算平均网络时延然后估算出这个时延积吗
作者:是的,需要多次取样做估算,再乘以带宽,得到带宽时延积。
## 第12课
妥协为什么报文5之后的ack都是ack6呀
作者TCP是有序的字符流因此接收方收完报文5后只能接收报文6但现在却接收到了报文7、8、9、10此时接收方该怎么办呢
当然它可以当做不知道什么也不做坐等报文6的到来。报文6什么时候会到呢RTO时间超时后发送方会重发报文6因为发送方一直没收到ACK7但是RTO是很长的时间接收方直接反复地传递ACK6这样发送方就能明白报文6丢了它可以提前重发报文6。这叫做快速重传
## 第13课
有铭:“寻找宕机服务时,只要看队列首部最老的心跳包,距现在是否超过 5 秒,如果超过 5 秒就认定宕机。”这里的逻辑无法理解。如果要用这种方式检测心跳,那么肯定要不停地把队列首部的心跳包移除,让新的心跳包从尾部加入,那么如果这个加入的过程卡一点,岂不是就会误判?
作者这种设计下还必须限制每次移除心跳包的数量分多次执行以防止加入过程长时间得不到执行。而且在这种极限场景下必须监控CPU的使用率如果长期维持在高占用率可能你的集群规模已经超大要每秒处理数百万心跳包那么应当通过扩容更多的CPU核、分布式系统等其它方案来解决这已经不是单台机器能解决的了。
## 第14课
东郭请问老师我在Nginx配置中不管ssl_certificate和ssl_certificate_key是否配置ecc证书抓包查看服务器的server hello响应中的Cipher Suite字段都是TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256这是正常的吗
作者这是正常的TLS握手阶段Nginx的Cipher Suite要通过ssl_ciphers指令来配合支持的suites并可通过ssl_prefer_server_ciphers指定优先选用的算法。证书只是包含了密钥、身份等信息它们与密钥协商方式、对称加密算法并无关系。
## 第15课
Geek_007FB也搞了一套压缩算法ZSTD对比起来也比gzip性能强很多不清楚这些压缩算法的原理是啥怎么对比另外普通的JSON和PB有不同适合的压缩算法吗怎么比较呢
作者:
<li>
压缩算法的原理都是基于香农的信息论将高频出现的信息用更少的比特编码。虽然原理是一致的但实现上却有很大的差别比如Huffman通过建立Huffman树来生成编码而LZ77却是通过滑动窗口这就造成了压缩比、压缩速度都很不相同。
</li>
<li>
比较它们的优劣主要看3个指标
</li>
- 压缩比比如Brotli的压缩比好于ZSTD
- 压缩与解压的速度比如ZSTD比gzip速度快
- 浏览器等中间件的支持程度现在几乎所有浏览器都支持Brotli即br但ZSTD少有支持。
1. 普通的JSON和PB没有最适合的算法还是要针对具体场景比较方法参见我刚刚说的那3个指标。
今天的答疑精选就到这里,期待大家能一如既往的在留言区进行交流,如果有更多问题,也欢迎一并提出。

View File

@@ -0,0 +1,19 @@
<audio id="audio" title="期中考试|行至半程,你的收获如何呢?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/20/a949d9f1c8356954ae36645160619b20.mp3"></audio>
你好,我是陶辉。
时间过得真快从4月27日课程上线转眼已经一月有余了不知道你的收获如何呢在这期间我收到了很多同学的反馈很感谢大家的认可也非常开心能与你一起交流技术。
那从我个人而言呢,这已经是我在极客时间开的第三门课了,前两门都是视频课。那切换到文字专栏的话,其实是完全不同的感受,视频课可以通过演示把问题讲清楚;而文字专栏则要反复打磨内容,并且和写书的方式还不一样,从每节课的内容设计、讲述方式到大纲以及具体细节的编辑,真真是掉了不少头发。
目前专栏已经更新一半了,前两个模块我们已经学完了,包括单主机如何提升性能,以及到了瓶颈后开始使用网络编程会出现的一些问题,第三模块我们也接触了一点点。学到这里,相信你应该感觉到了,系统性能问题的涉及面就是很广很深,往往需要在多个环境中反复验证分析才可以。那每清楚一个问题,你的实力其实就会有一定的沉淀,直到跳跃式的进步。
这其实也是我本人在学习性能优化过程中一个很深的体会即问题驱动。特别是我在腾讯、阿里时不断增长的业务流量导致系统需要持续地进行优化和扩容再然后就自然地进行系统化总结我那时候就发现大学里的《数据结构》课白学了所以我又重头开始学习《算法导论》然后发现《网络原理》课白学了又开始学习《TCP/IP协议详解》等等当然这里不是为了吐槽而是想告诉你学习时选对教材真的很重要。**这就是一个螺旋上升的过程。**
所以阶段性的验证和总结其实还是非常重要的这里我特别设置了这场期中考试精选了20个问题知识范围就是咱们专栏的115课希望你能认真完成发现问题及时解决。
**这里特别说明一下:**我们的期中考试为期一周从6月3日开始到6月9日结束这期间我们会暂停更新正文内容也就是说我们会从6月10日开始更新第16课。你可以好好利用这一周的时间去回顾一下前15课的知识做一个巩固。另外在这一周中我还会提供两篇加餐给你一篇是福利加餐我会从我的视频课中精选出3节和本专栏具有强关联的课程送给你另一篇是答疑精选期待你能借由大家的问题和增量信息去引发更多的思考。
有标准才有追求,有追求才有动力,有动力才有进步,真心希望性能优化能成为你职业生涯的重武器。最后,来挑战一下吧,开启你的期中考试之旅。
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=170&amp;exam_id=396)

View File

@@ -0,0 +1,8 @@
你好,我是陶辉。
《系统性能调优必知必会》这个专栏已经结课有段时间了,很感谢大家的支持,也非常开心能与各位交流技术。为认真学习的你点赞!
为了让你更好地检测自己的学习成果我特意做了一套期末测试题。题目共有20道均为多选满分100分快来挑战一下吧
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=206&amp;exam_id=574)

View File

@@ -0,0 +1,119 @@
<audio id="audio" title="07 | 性能好,效率高的一对多通讯该如何实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/99/b9c73eae64c2fc4965eb3496960b5699.mp3"></audio>
你好,我是陶辉。从这一讲开始,我们将从单机进入网络层面的性能优化。
我们接触过的绝大多数通讯方式无论是面向连接的HTTP协议还是无连接的DNS协议都是一对一收发消息的。其实除了一对一还有一对多的通讯方式它在网络资源的利用上效率要比一对一高得多。这种一对多的通讯方式在局域网中有很广泛的应用常见的ARP欺骗、泛洪攻击等都是通过一对多通讯进行的。
当应用场景中用一对多代替一对一通讯时发送方性能会获得很大的提升整个局域网的效率也会提高。比如源主机的带宽只有1Gbps如果采用一对一的方式向100个客户端发送流媒体这100台主机的带宽之和不会超过1Gbps。但采用一对多传输时总带宽就可以达到100Gbps。
除了能提升性能以外,由于一对多通讯可同时向所有主机发送消息,这就在功能层面上可以替换许多人工操作。比如分布式系统的服务发现,使用人工配置既容易出错,速度也慢,而用广播就可以轻松实现自动化服务发现。
一对多通讯协议一直在发展在运营商的IPTV网络的视频直播中它就得到了广泛的应用。即使你暂时不会用到一对多这种方式也应当了解下它是怎么工作的熟悉它的工作原理后还能更深入地理解一对一通讯协议。
这一讲,我们就来学习如何实现一对多通讯。
## 广播是怎么实现的?
一对多通讯分为两种:对局域网内所有主机发送消息的叫做**广播**,而对部分主机发送消息的,则叫做**组播**。我们先来看一下广播是怎么实现的。
使用广播要改用UDP协议。可能你会问为什么不能使用最熟悉的TCP协议呢这要从TCP协议的分层说起。
1978年在TCP协议迭代了3个版本后才被Jon PostelIANA创始人提出违反了网络分层原则网络层和传输层耦合在一起很难扩展。于是在TCP的第4个迭代版本中把协议一分为二包括网络层IP协议和传输层TCP协议这也是今天的IP协议被称为IPv4的原因
<img src="https://static001.geekbang.org/resource/image/95/02/95d8fe24b6343fef34579f240696dd02.jpg" alt="">
当你访问Internet站点时IP协议会将数据通过网络设备穿越多个卫星、光纤等网络才能送到服务器。而网络设备天然就拥有广播能力当它在一个网络端口上收到主机发来的报文时可以向其他端口上的所有主机重发一遍这就是广播如下图所示
<img src="https://static001.geekbang.org/resource/image/ce/f0/ce45d52f5c87bcae9c4adae8056c21f0.jpg" alt="">
**虽然IP协议已经具有广播功能但实际编程中并不会直接使用IP协议发送广播因为它很难与进程关联起来。**
根据网络分层模型上层协议可以使用下层协议的功能所以传输层协议拥有IP协议的广播能力。同时传输层通过端口号把网络报文和进程关联在了一起就像TCP的80端口把HTTP消息与Nginx等Web Server关联在一起。
<img src="https://static001.geekbang.org/resource/image/13/ac/13d968cbacd229f242764557957d3bac.jpg" alt="">
然而传输层的TCP协议为了保证可靠性建立了逻辑上的连接概念由于一个连接上只能有两方所以TCP无法进行一对多通讯。而传输层的UDP协议无需建立连接所以我们常用UDP协议发送广播。
广播的性能高有两个原因:首先,交换机直接转发给接收方,要比从发送方到接收方的传输路径更短。其次,原本需要发送方复制多份报文再逐一发送至各个接受者的工作,被交换机完成了,这既分担了发送方的负载,也充分使用了整个网络的带宽。
那么交换机收到消息后怎么知道这是广播报文并转发给整个网络呢我们知道以太网中的数据链路层通过硬件的MAC地址来传播消息交换机就通过报文的MAC地址来确定是否需要广播。**当交换机收到目标MAC地址是ff:ff:ff:ff:ff:ff的报文时便知道这是一个广播报文**才会将它转发给局域网中的所有主机否则只会转发给MAC地址对应端口上的主机。
不过我们写代码时无法控制底层的MAC地址只能填写目标IP地址。什么样的目标IP地址会生成广播MAC地址呢**如果只是对所在子网进行广播那么使用受限广播地址255.255.255.255就可以了;如果局域网划分了多个子网,主机需要向其他子网广播,则需要正确地设置直接广播地址(路由器需要打开直接广播功能)。**
## 如何正确地设置直接广播IP地址
怎么设置直接广播的IP地址呢我们首先得了解IP地址的构成。
由于IP协议需要跨越多个网络工作所以IP地址被一分为二包括前边的网络ID和后边的主机ID其中**网络ID用于不同网络间的寻址而主机ID则用于在本地局域网内通讯。**
举个例子如果你的局域网IP地址是192.168.0.101那么网络ID就是192.168.0而主机ID则是101这里假定网络管理员没有继续划分C类子网
这是因为以192.168打头的IP地址被称为C类地址而C类地址中最后1个十进制数字代表主机ID。如果IP地址是172.16.20.227这就是B类地址此时172.16是网络ID而20.227才是主机ID。
所以IP地址的前缀数字不同主机ID的划分位置也不同。事实上IP地址一共被划分为A、B、C、D、E5个类别它的划分依据正是IP地址转换为二进制后用前4个比特位作为依据的。如果第1个比特位为0这就是A类地址它的网络ID是IP地址的第1到第8比特位而主机ID则是随后的24个比特位。如果局域网IP地址第1个数字是10这就是A类私有地址局域网中的地址不能在公网中使用统称为私有地址或者内网地址
<img src="https://static001.geekbang.org/resource/image/4f/e1/4f9c58b60404e2f874802b6a2ec207e1.jpg" alt="">
类似的若前2个比特位是10则是B类地址它的主机ID是后16位如果前3个比特位为110这就是C类地址它的主机ID是后8位。显然以192打头的地址前3位正是110所以这是C类地址。
<img src="https://static001.geekbang.org/resource/image/d6/5e/d67b7a52601f80027576c866cf80e25e.jpg" alt="">
此外还有D类组播地址和E类预留实验地址。
清楚了划分方法后再来看如何通过修改主机ID将IP地址改为直接广播地址。如果你留心观察IP地址会发现主机ID不会出现全0和全1这两种情况这是因为全0和全1有特殊用途其中全0特指它自己所以0.0.0.0可以指代本机IP而全1表示全部主机。
**所以主机ID的比特位全部设为1后就是广播地址。**比如192.168.0.101是C类地址把主机ID从101改为255后就可以用192.168.0.255发送广播了。
然而事情到这并没有完。一个A类网络可以容纳千万台主机B类网络则只能容纳6万多台主机C类网络则最多容纳254台主机。仅有的三类网络中主机数量差距太大而世界上存在各种规模的企业它们所需网络中的主机规模千差万别上述划分方式太过单一无法满足各类企业的需求**于是诞生了CIDR这种新的划分方式它通过子网掩码或者叫Netmask可以在任意的位置将IP地址拆分为网络ID和主机ID扩展了A、B、C三类网络的用法。**
当你查看主机的IP地址时就会看到其后跟着一个类似IP地址的子网掩码。子网掩码必须把它展开成二进制才能使用这样掩码前N位为1时就表示IP地址的前N位是网络ID而掩码后面剩余的位全是0表示IP地址对应的位是主机ID。
比如若192.168.0.101的子网掩码是255.255.255.192就表示IP地址的前26位是网络ID后6位是主机ID将主机ID置为全1后就得到了它的广播地址192.168.0.127,如下图所示:
<img src="https://static001.geekbang.org/resource/image/65/08/6586d4ec875f63b19993b78c7a11e808.jpg" alt="">
到这里我们设置好IP地址后再把socket句柄设置SO_BROADCAST属性就可以发送广播了。广播虽然有很多优点可是一旦被滥用很容易产生网络风暴所以路由器默认是不转发广播报文的。
## 用更精准的组播来做服务发现
当你用UDP广播来做分布式系统的服务发现会遇到这样一个问题若并非网络内的所有主机都属于分布式系统那么当指定了端口的UDP广播报文到达其他主机时会怎么样呢这些广播报文在这3个步骤后会被丢弃
- 第1步网卡设备收到报文后查看报文中的目标MAC地址是否与本机的MAC地址匹配如果不匹配就会丢弃。广播MAC地址默认匹配继续交由上层的IP协议栈处理
- 第2步IP协议栈查看目标IP地址是否为本机IP地址不匹配也会丢弃报文。上文介绍过的广播IP地址同样默认匹配交由传输层协议继续处理。
- 第3步传输层检查目标端口是否有进程在监听如果没有则丢弃报文反之则交付给进程处理。不属于集群的主机自然不会启动服务监听端口在这一步才会丢弃广播报文。
<img src="https://static001.geekbang.org/resource/image/1c/b3/1c8b6032474debdd2a4d4569a1752ab3.jpg" alt="">
可见对于不属于分布式集群的主机而言广播报文既占用了它们的带宽这3步协议栈的操作也消耗了CPU的计算力。有什么办法能缩小广播的范围消除它加在无关主机上的负载呢
组播可以做到。组播是一种“定向广播”它设定了一个虚拟组用组播IP来标识。这个虚拟组中可以包含多个主机的IP当向对应的组播IP发送消息时仅在这个组内的主机才能收到消息。
组播IP与常见的单播IP不同它是前文介绍过5类IP地址中的D类地址32位IP地址的前4位必须是1110因此组播IP地址的范围是从224.0.0.0到239.255.255.255。
当设置好组播IP地址后还要通过管理组播地址的IGMP协议Internet Group Management Protocol将主机IP地址添加进虚拟组中。编程语言提供的setsockopt函数就可以操作IGMP协议管理组播地址。比如使用参数IP_ADD_MEMBERSHIP就能够向虚拟组中增加IP而IP_DROP_MEMBERSHIP则可以从组中去除某个主机的IP。
[这里](https://github.com/russelltao/geektime-webprotocol/tree/master/python%E7%A4%BA%E4%BE%8B%E4%BB%A3%E7%A0%81)有一个可运行的python测试代码供你参考。如果你想进一步了解组播的细节可以观看[《Web协议详解与抓包实战》第117课](https://time.geekbang.org/course/detail/175-134405)。
组播相对于广播而言 除了能够更精准的管理组播范围还能够跨越多个网络工作。当然如果将多个网络中的IP加入同一虚拟组时需要涉及到的路由器都可以正确地处理这些IP地址且都能支持IGMP协议。
## 小结
最后我们对这一讲做一个总结。
由于一对多通讯能够充分利用整体网络的性能,而且通过交换机能够同时向许多主机发送消息,所以在局域网内有广泛的应用。
在TCP协议分层后IP协议天然就支持一对多通讯方式。TCP协议面向连接的特性使它放弃了一对多的通讯方式而UDP协议则继承了IP协议的这一功能。所以在一对多通讯场景中我们会选择UDP协议。
正确输入广播地址的前提是理解IP地址如何划分为网络ID和主机ID。当主机ID所有的比特位改为全1时IP地址就表示该网络下的所有主机这就是广播地址。当向广播地址发送UDP消息时网络中的所有主机都会收到。广播在局域网中有广泛的应用转换IP地址与MAC地址的ARP协议就是用广播实现的。
广播对无关的主机增加了不必要的负担而组播可以更精准地“定向”广播。组播地址也被称为D类地址它描述的虚拟组要通过IGMP协议管理。网络API中的setsockopt函数可以通过IGMP协议向虚拟组中添加或者删除IP地址。当路由器支持IGMP协议时组播就可以跨越多个网络实现更广泛的一对多通讯。
广播和组播能够充分地使用全网带宽,也通过交换机等网络设备分散了发送主机的负载。但它很难对每台接收主机提供定制化服务,这样可靠传输就很难实现。这使得它们在更关注及时性、对丢包不敏感的流媒体直播中更有应用前景。
这一讲我们介绍了许多网络概念,这些也是理解后续内容的基础。从下一讲开始,我们将进入更复杂的一对一通讯协议。
## 思考题
最后,请你思考下,你使用或者了解过哪些一对多的通讯协议?它们的优缺点,以及未来的发展方向又是什么?欢迎你留言与我探讨。
感谢阅读,如果你觉得今天学习的内容对你有帮助,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,102 @@
<audio id="audio" title="08 | 事件驱动C10M是如何实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/6a/0550ddef1be2a819b2704e790c3e916a.mp3"></audio>
你好,我是陶辉。
上一讲介绍了广播与组播这种一对多通讯方式,从这一讲开始,我们回到主流的一对一通讯方式。
早些年我们谈到高并发总是会提到C10K这是指服务器同时处理1万个TCP连接。随着服务器性能的提升近来我们更希望单台服务器的并发能力可以达到C10M也就是同时可以处理1千万个TCP连接。从C10K到C10M实现技术并没有本质变化都是用事件驱动和异步开发实现的。[[第5讲]](https://time.geekbang.org/column/article/233629) 介绍过的协程,也是依赖这二者实现高并发的。
做过异步开发的同学都知道处理基于TCP的应用层协议时一个请求的处理代码必须被拆分到多个回调函数中由异步框架在相应的事件生成时调用它们。这就是事件驱动方式它通过减少上下文切换次数实现了C10M级别的高并发。
不过做应用开发的同学往往不清楚什么叫做“事件”不了解处理HTTP请求的回调函数与事件间的关系。这样在高并发下当多个HTTP请求争抢执行时涉及资源分配、释放等重要工作的回调函数就可能在错误的时间被调用进而引发一系列问题。比如不同的回调函数对应不同的事件如果某个函数执行时间过长就会影响其他请求可能导致大量请求出现超时而处理失败。
这一讲我们就来介绍一下事件是怎样产生的它是如何驱动请求执行的多路复用技术是怎样协助实现异步开发的理解了这些你也就明白了这种事件驱动的解决方案知道了怎么样实现C10M。
## 事件是怎么产生的?
要了解“事件驱动”的运作机制,首先就要搞清楚到底什么是事件。这就需要你对网络原理有深入的理解了。
简单来说从网络中接收到一个报文就可能产生一个事件。如上一讲介绍过的UDP请求就是最简单的例子一个UDP请求通常仅由一个网络报文组成所以当收到一个UDP报文就意味着收到一个请求它会生成一个事件进而触发回调函数执行。
不过常见的HTTP等协议都是基于TCP实现的。由于TCP是一种面向字节流的协议HTTP请求的大小并不受限制当一个HTTP请求的大小超过TCP报文的最大长度时请求会被拆分到多个报文中运输在接收端的缓冲区中重组、排序。因此并不是每个到达的TCP报文都能生成事件的。
如果不理解事件和TCP报文的关系就没法准确地掌握处理HTTP请求的函数何时被调用。当然作为应用开发工程师我们无须在意实现细节只要了解TCP连接建立、关闭以及消息的发送和接收这四个场景中报文与事件间的关系就可以了。
事件并没有你想象中那么复杂它只有两种类型读事件与写事件其中读事件表示有到达的消息需要处理而写事件表示可以发送消息TCP连接的写缓冲区中有可用空间。我们先从三次握手建立连接说起这一过程会产生一读、一写两个事件。
由于TCP允许双向传输所以**建立连接时,会依次在连接的两个方向上建立通道。**主动发起连接的一方叫做客户端,被动监听端口等待连接的一方叫做服务器。
客户端首先发送SYN报文给服务器而服务器收到后回复ACK和SYN这里我们只需要知道产生事件的过程即可下一讲会详细介绍这两个报文的含义**当它们到达客户端时,双向连接中由客户端到服务器的通道就建立好了,此时客户端就已经可以发送请求了,因此客户端会产生写事件。**接着,**客户端发送ACK报文到达服务器后服务器上会产生读事件**因为进程原本在监听80等端口此时有新连接建立成功应当调用accept函数读取这个连接所以这是一个读事件。
<img src="https://static001.geekbang.org/resource/image/73/98/73b9d890c7087531b51180ada6e65f98.png" alt="">
在建立好的TCP连接上收发消息时读事件对应着接收到对方的消息这很好理解。写事件则稍微复杂些我们举个例子加以说明。假设要发送一个2MB的请求**当调用write函数发送时会先把内存中的数据拷贝到写缓冲区中后再发送到网卡上。**
为何要多此一举呢这是因为在对方没有明确表示收到前TCP会通过定时器重发写缓冲区中的数据保证消息能够到达对方。写缓冲区是有大小限制的我在[第10讲]中会详细介绍。这里假设写缓冲区只有1MB所以调用write发送2MB数据时write函数的返回值只有1MB表示写缓冲区已用尽。当收到对方发来的ACK报文后缓冲区中的数据才能释放就会产生写事件通知进程发送剩余的那1MB数据。
<img src="https://static001.geekbang.org/resource/image/c5/7a/c524965bee6407bd716c7dc33bdd437a.png" alt="">
如同建立连接需要双向建立一样关闭连接也需要双方各自关闭每个方向的通道。主动关闭的一方发送FIN报文到达被动方后内核自动回复ACK报文这表示从主动方到被动方的通道已经关闭。**但被动方到主动方的通道也需要关闭所以此时被动方会产生读事件提醒被动方调用close函数关闭连接。**
<img src="https://static001.geekbang.org/resource/image/b7/96/b73164fd504cc2574066f526ebee7596.png" alt="">
这样我们就清楚了TCP报文如何产生事件也明白回调函数何时执行了。然而同步代码拆分成多个异步函数成本并不低咱们手里拿着事件驱动这个锤子可不能看到什么都像是钉子。
什么样的代码值得基于事件来做拆分呢还得回到高性能这个最终目标上来。我们知道做性能优化一定要找出性能瓶颈针对瓶颈做优化性价比才最高。对于服务器来说对最慢的操作做异步化改造才能值回开发效率的损失。而服务里对资源的操作速度由快到慢依次是CPU、内存、磁盘和网络。CPU和内存的执行速度都是纳秒级的无须考虑事件驱动而磁盘和网络都可以采用事件驱动的异步方式处理。
相对而言网络不只速度慢而且波动很大既受制于连接对端的性能也受制于网络传输路径。把操作网络的同步API改为事件驱动的异步API收益最大。而磁盘特别是机械硬盘访问速度虽然不快但它最慢时也不过几十毫秒是可控的。而且目前磁盘异步IO技术参见[[第4讲]](https://time.geekbang.org/column/article/232676)还不成熟它绕过了PageCache性能损失很大。所以当下的事件驱动主要就是指网络事件。
## 该怎样处理网络事件?
有了网络事件的概念后,我们再来看用户态代码如何处理事件。
网络事件是由内核产生的进程该怎样获取到它们呢如epoll这样的多路复用技术可以帮我们做到。多路复用是通讯领域的词汇有些抽象但原理确很简单。
比如一条高速的光纤上允许多个用户用较低的网速同时通讯这就是多路复用。同样道理一个进程虽然任一时刻只能处理一个请求但处理每个请求产生的事件时若耗时控制在1毫秒以内这样1秒钟就可以处理数千个请求从更长的时间维度上看多个请求复用了一个进程也叫做多路复用或者叫做时分多路复用。我们熟知的epoll就是内核提供给用户态的多路复用接口进程可以通过它从内核中获取事件。
epoll是如何获取网络事件的呢最简单的方法就是在获取事件时把所有并发连接传给内核再由内核返回产生了事件的连接再处理这些连接对应的请求即可。epoll前的select等多路复用函数就是这么干的。
然而C10M意味着有一千万个连接若每个socket是4字节那么1千万连接就是40M字节。这样每收集一次事件就需要从用户态复制40M字节到内核态。而且高性能Server必须及时地处理网络事件所以每隔几十毫秒就要收集一次事件性能消耗巨大。
epoll为了降低性能消耗把获取事件拆分成两步。
- 第一步把需要监控的socket传给内核epoll_ctl函数它仅在连接建立等有限的时机调用
- 第二步收集事件epoll_wait函数便不用传递socket了这样就把socket的重复传递改为了一次传递降低了性能损耗。
由于网卡的处理能力有限千兆网卡下每秒只能接收100MB左右的数据如果每个请求约10KB那么每秒大概有1万个请求到达、10万个事件需要处理。这样即使每隔100毫秒收集一次事件调用epoll_wait每次也不过只有1万个事件100000 Event/s * 0.1s = 10000 Event/s需要处理只要保证处理一个事件的平均时间小于10微秒多核处理器可以做到100毫秒内就可以处理完这些事件100ms = 10us * 10000。 因此哪怕有1千万并发连接也能保证1万RPS的处理能力这就是epoll能在C10M下实现高吞吐量的原因。
进程获取到产生事件的socket后又该如何处理它呢这里的核心约束是处理任何一个事件的耗时都应该是微秒级或者毫秒级否则就会延误其他事件的处理不只降低了用户的体验而且会形成恶性循环。
我们知道为了应对网络的不确定性每个参与网络通讯的进程都会为请求设置超时时间。一旦某个socket上的事件迟迟不被处理当客户端的超时定时器触发时客户端往往会关闭连接并重发请求这会让服务器雪上加霜。
怎样保证处理一个事件的时间不会太长呢? 我们把处理事件的代码分为三类来看。
第一类是计算任务虽然内存、CPU的速度很快然而循环执行也可能耗时达到秒级。所以如果一定要引入需要密集计算才能完成的请求为了不阻碍其他事件的处理要么把这样的请求放在独立的线程中完成要么把请求的处理过程拆分成多段确保每段能够快速执行完同时每段执行完都要均等地处理其他事件这样通过放慢该请求的处理时间就保障了其他请求的及时处理。
第二类会读写磁盘由于磁盘的写入操作使用了PageCache的延迟写特性当write函数返回时只是复制到了内存中所以写入操作很快。磁盘的读取操作就比较慢了这时通常要把大文件的读取拆分成许多份每份仅有几十KB降低单次操作的耗时。
第三类是通过网络访问上游服务。与处理客户端请求相似我们必须使用非阻塞socket用事件驱动方式处理请求。需要注意的是许多网络服务提供的SDK都是基于阻塞socket实现的使用前必须先做完非阻塞改造。比如Memcached的官方SDK是用阻塞socket实现的Nginx如果直接使用该SDK访问它性能就会一落千丈。正确的访问方式是使用第三方提供的ngx_http_memcached_module模块它用非阻塞socket重新封装了SDK。
总之网络报文到达后内核就产生了读、写事件而epoll函数使得进程可以高效地收集到这些事件。接下来要确保在进程中处理每个事件的时间足够短才能及时地处理所有请求这个过程中既要避免阻塞socket的使用也要把耗时过长的操作拆成多份执行。最终通过快速、及时、均等地执行所有事件异步Server实现了高并发。
## 小结
最后我们对这一讲做个小结。异步服务改为从事件层面处理请求在epoll这样的多路复用机制协助下最终实现了C10M级别的高并发服务。
事件有很多种网络消息的传输既慢又不可控所以用网络事件驱动请求的性价比最高。这样就需要你了解TCP报文是如何产生事件的。
TCP连接建立时会在客户端产生写事件在服务器端产生读事件。连接关闭时则会在被动关闭端产生读事件。在连接上收发消息时也会产生事件其中发送消息前的写事件与内核分配的缓冲区有关。
清楚了事件与TCP报文的关系后可以用多路复用技术获取事件其中epoll是佼佼者它取消了收集事件时重复传递的大量socket参数给C10M的实现提供了基础。
你需要注意的是处理epoll收集到的事件时必须保证处理一个事件的平均时间在毫秒级以内。传统的阻塞socket是做不到的所以必须用非阻塞socket替换阻塞socket。如果事件的回调函数耗时过长也得拆分为多个耗时短的函数用多次事件比如定时器事件的触发来替代。
虽然我们有了上述的事件驱动方案但实现C10M还需要更谨慎地使用不过数百GB的服务器内存。关于如何降低内存的消耗可以关注[[第2讲]](https://time.geekbang.org/column/article/230221) 提到的内存池,[第11讲] 还会介绍如何减少连接缓冲区的空间占用。
这一讲我们介绍了事件驱动的总体方案但C10M需要高效的用心几乎所有服务器资源所以我们还得通过Linux更精细地控制TCP的行为接下来的3讲我们将深入Linux讨论如何优化TCP的性能。
## 思考题
最后留给你一个思考题需要CPU做密集计算的请求该如何拆分到事件驱动框架中呢欢迎你在留言区留言与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,159 @@
<audio id="audio" title="09 | 如何提升TCP三次握手的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/72/9e/726a6d37e4a50655c75344829823169e.mp3"></audio>
你好,我是陶辉。
上一讲我们提到TCP在三次握手建立连接、四次握手关闭连接时是怎样产生事件的这两个过程中TCP连接经历了复杂的状态变化既容易导致编程出错也有很大的优化空间。这一讲我们看看在Linux操作系统下如何优化TCP的三次握手流程提升握手速度。
TCP是一个可以双向传输的全双工协议所以需要经过三次握手才能建立连接。三次握手在一个HTTP请求中的平均时间占比在10%以上在网络状况不佳、高并发或者遭遇SYN泛洪攻击等场景中如果不能正确地调整三次握手中的参数就会对性能有很大的影响。
TCP协议是由操作系统实现的调整TCP必须通过操作系统提供的接口和工具这就需要理解Linux是怎样把三次握手中的状态暴露给我们以及通过哪些工具可以找到优化依据并通过哪些接口修改参数。
因此这一讲我们将介绍TCP握手过程中各状态的意义并以状态变化作为主线看看如何调整Linux参数才能提升握手的性能。
## 客户端的优化
客户端和服务器都可以针对三次握手优化性能。相对而言,主动发起连接的客户端优化相对简单一些,而服务器需要在监听端口上被动等待连接,并保存许多握手的中间状态,优化方法更为复杂一些。我们首先来看如何优化客户端。
三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输TCP协议的许多特性都是依赖序列号实现的比如流量控制、消息丢失后的重发等等这也是三次握手中的报文被称为SYN的原因因为SYN的全称就叫做Synchronize Sequence Numbers。
<img src="https://static001.geekbang.org/resource/image/c5/aa/c51d9f1604690ab1b69e7c4feb2f31aa.jpg" alt="">
三次握手虽然由操作系统实现但它通过连接状态把这一过程暴露给了我们我们来细看下过程中出现的3种状态的意义。客户端发送SYN开启了三次握手此时在客户端上用netstat命令后续查看连接状态都使用该命令可以看到**连接的状态是SYN_SENT**顾名思义就是把刚SYN发送出去
```
tcp 0 1 172.16.20.227:39198 129.28.56.36:81 SYN_SENT
```
客户端在等待服务器回复的ACK报文。正常情况下服务器会在几毫秒内返回ACK但如果客户端迟迟没有收到ACK会怎么样呢客户端会重发SYN**重试的次数由tcp_syn_retries参数控制**默认是6次
```
net.ipv4.tcp_syn_retries = 6
```
第1次重试发生在1秒钟后接着会以翻倍的方式在第2、4、8、16、32秒共做6次重试最后一次重试会等待64秒如果仍然没有返回ACK才会终止三次握手。所以总耗时是1+2+4+8+16+32+64=127秒超过2分钟。
如果这是一台有明确任务的服务器,你可以根据网络的稳定性和目标服务器的繁忙程度修改重试次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。
<img src="https://static001.geekbang.org/resource/image/a3/8f/a3c5e77a228478da2a6e707054043c8f.png" alt="">
## 服务器端的优化
当服务器收到SYN报文后服务器会立刻回复SYN+ACK报文既确认了客户端的序列号也把自己的序列号发给了对方。此时服务器端出现了新连接状态是SYN_RCVRCV是received的缩写。这个状态下服务器必须建立一个SYN半连接队列来维护未完成的握手信息当这个队列溢出后服务器将无法再建立新连接。
<img src="https://static001.geekbang.org/resource/image/c3/82/c361e672526ee5bb87d5f6b7ad169982.png" alt="">
新连接建立失败的原因有很多怎样获得由于队列已满而引发的失败次数呢netstat -s命令给出的统计结果中可以得到。
```
# netstat -s | grep &quot;SYNs to LISTEN&quot;
1192450 SYNs to LISTEN sockets dropped
```
这里给出的是队列溢出导致SYN被丢弃的个数。注意这是一个累计值如果数值在持续增加则应该调大SYN半连接队列。**修改队列大小的方法是设置Linux的tcp_max_syn_backlog 参数:**
```
net.ipv4.tcp_max_syn_backlog = 1024
```
如果SYN半连接队列已满只能丢弃连接吗并不是这样**开启syncookies功能就可以在不使用SYN队列的情况下成功建立连接。**syncookies是这么做的服务器根据当前状态计算出一个值放在己方发出的SYN+ACK报文中发出当客户端返回ACK报文时取出该值验证如果合法就认为连接建立成功如下图所示。
<img src="https://static001.geekbang.org/resource/image/0d/c0/0d963557347c149a6270d8102d83e0c0.png" alt="">
Linux下怎样开启syncookies功能呢修改tcp_syncookies参数即可其中值为0时表示关闭该功能2表示无条件开启功能而1则表示仅当SYN半连接队列放不下时再启用它。由于syncookie仅用于应对SYN泛洪攻击攻击者恶意构造大量的SYN报文发送给服务器造成SYN半连接队列溢出导致正常客户端的连接无法建立这种方式建立的连接许多TCP特性都无法使用。所以应当把tcp_syncookies设置为1仅在队列满时再启用。
```
net.ipv4.tcp_syncookies = 1
```
当客户端接收到服务器发来的SYN+ACK报文后就会回复ACK去通知服务器同时己方连接状态从SYN_SENT转换为ESTABLISHED表示连接建立成功。服务器端连接成功建立的时间还要再往后到它收到ACK后状态才变为ESTABLISHED。
如果服务器没有收到ACK就会一直重发SYN+ACK报文。当网络繁忙、不稳定时报文丢失就会变严重此时应该调大重发次数。反之则可以调小重发次数。**修改重发次数的方法是调整tcp_synack_retries参数**
```
net.ipv4.tcp_synack_retries = 5
```
tcp_synack_retries 的默认重试次数是5次与客户端重发SYN类似它的重试会经历1、2、4、8、16秒最后一次重试后等待32秒若仍然没有收到ACK才会关闭连接故共需要等待63秒。
服务器收到ACK后连接建立成功此时内核会把连接从SYN半连接队列中移出再移入accept队列等待进程调用accept函数时把连接取出来。如果进程不能及时地调用accept函数就会造成accept队列溢出最终导致建立好的TCP连接被丢弃。
实际上丢弃连接只是Linux的默认行为我们还可以选择向客户端发送RST复位报文告诉客户端连接已经建立失败。打开这一功能需要将tcp_abort_on_overflow参数设置为1。
```
net.ipv4.tcp_abort_on_overflow = 0
```
**通常情况下应当把tcp_abort_on_overflow设置为0因为这样更有利于应对突发流量。**举个例子当accept队列满导致服务器丢掉了ACK与此同时客户端的连接状态却是ESTABLISHED进程就在建立好的连接上发送请求。只要服务器没有为请求回复ACK请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成accept队列满那么当accept队列有空位时再次接收到的请求报文由于含有ACK仍然会触发服务器端成功建立连接。所以**tcp_abort_on_overflow设为0可以提高连接建立的成功率只有你非常肯定accept队列会长期溢出时才能设置为1以尽快通知客户端。**
那么怎样调整accept队列的长度呢**listen函数的backlog参数就可以设置accept队列的大小。事实上backlog参数还受限于Linux系统级的队列长度上限当然这个上限阈值也可以通过somaxconn参数修改。**
```
net.core.somaxconn = 128
```
当下各监听端口上的accept队列长度可以通过ss -ltn命令查看但accept队列长度是否需要调整该怎么判断呢还是通过netstat -s命令给出的统计结果可以看到究竟有多少个连接因为队列溢出而被丢弃。
```
# netstat -s | grep &quot;listen queue&quot;
14 times the listen queue of a socket overflowed
```
如果持续不断地有连接因为accept队列溢出被丢弃就应该调大backlog以及somaxconn参数。
## TFO技术如何绕过三次握手
以上我们只是在对三次握手的过程进行优化。接下来我们看看如何绕过三次握手发送数据。
三次握手建立连接造成的后果就是HTTP请求必须在一次RTTRound Trip Time从客户端到服务器一个往返的时间后才能发送Google对此做的统计显示三次握手消耗的时间在HTTP请求完成的时间占比在10%到30%之间。
<img src="https://static001.geekbang.org/resource/image/1b/a8/1b9d8f49d5a716470481657b07ae77a8.png" alt="">
因此Google提出了TCP fast open方案简称[TFO](https://tools.ietf.org/html/rfc7413)客户端可以在首个SYN报文中就携带请求这节省了1个RTT的时间。
接下来我们就来看看TFO具体是怎么实现的。
**为了让客户端在SYN报文中携带请求数据必须解决服务器的信任问题。**因为此时服务器的SYN报文还没有发给客户端客户端是否能够正常建立连接还未可知但此时服务器需要假定连接已经建立成功并把请求交付给进程去处理所以服务器必须能够信任这个客户端。
TFO到底怎样达成这一目的呢它把通讯分为两个阶段第一阶段为首次建立连接这时走正常的三次握手但在客户端的SYN报文会明确地告诉服务器它想使用TFO功能这样服务器会把客户端IP地址用只有自己知道的密钥加密比如AES加密算法作为Cookie携带在返回的SYN+ACK报文中客户端收到后会将Cookie缓存在本地。
之后如果客户端再次向服务器建立连接就可以在第一个SYN报文中携带请求数据同时还要附带缓存的Cookie。很显然这种通讯方式下不能再采用经典的“先connect再write请求”这种编程方法而要改用sendto或者sendmsg函数才能实现。
服务器收到后会用自己的密钥验证Cookie是否合法验证通过后连接才算建立成功再把请求交给进程处理同时给客户端返回SYN+ACK。虽然客户端收到后还会返回ACK但服务器不等收到ACK就可以发送HTTP响应了这就减少了握手带来的1个RTT的时间消耗。
<img src="https://static001.geekbang.org/resource/image/7a/c3/7ac29766ba8515eea5bb331fce6dc2c3.png" alt="">
当然为了防止SYN泛洪攻击服务器的TFO实现必须能够自动化地定时更新密钥。
Linux下怎么打开TFO功能呢这要通过tcp_fastopen参数。由于只有客户端和服务器同时支持时TFO功能才能使用**所以tcp_fastopen参数是按比特位控制的。其中第1个比特位为1时表示作为客户端时支持TFO第2个比特位为1时表示作为服务器时支持TFO**所以当tcp_fastopen的值为3时比特为0x11就表示完全支持TFO功能。
```
net.ipv4.tcp_fastopen = 3
```
## 小结
这一讲我们沿着三次握手的流程介绍了Linux系统的优化方法。
当客户端通过发送SYN发起握手时可以通过tcp_syn_retries控制重发次数。当服务器的SYN半连接队列溢出后SYN报文会丢失从而导致连接建立失败。我们可以通过netstat -s给出的统计结果判断队列长度是否合适进而通过tcp_max_syn_backlog参数调整队列的长度。服务器回复SYN+ACK报文的重试次数由tcp_synack_retries参数控制网络稳定时可以调小它。为了应对SYN泛洪攻击应将tcp_syncookies参数设置为1它仅在SYN队列满后开启syncookie功能保证连接成功建立。
服务器收到客户端返回的ACK后会把连接移入accept队列等待进程调用accept函数取出连接。如果accept队列溢出默认系统会丢弃ACK也可以通过tcp_abort_on_overflow参数用RST通知客户端连接建立失败。如果netstat统计信息显示大量的ACK被丢弃后可以通过listen函数的backlog参数和somaxconn系统参数提高队列上限。
TFO技术绕过三次握手使得HTTP请求减少了1个RTT的时间。Linux下可以通过tcp_fastopen参数开启该功能。
从这一讲可以看出虽然TCP是由操作系统实现的但Linux通过多种方式提供了修改TCP功能的接口供我们优化TCP的性能。下一讲我们再来探讨四次握手关闭连接时Linux怎样帮助我们优化其性能。
## 思考题
最后,留给你一个思考题,关于三次握手建立连接,你做过哪些优化?效果如何?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="10 | 如何提升TCP四次挥手的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/0e/0fd9ccef046f64c2131adb2e84135b0e.mp3"></audio>
你好,我是陶辉。
上一节课,我们介绍了建立连接时的优化方法,这一节课再来看四次挥手关闭连接时,如何优化性能。
close和shutdown函数都可以关闭连接但这两种方式关闭的连接不只功能上有差异控制它们的Linux参数也不相同。close函数会让连接变为孤儿连接shutdown函数则允许在半关闭的连接上长时间传输数据。TCP之所以具备这个功能是因为它是全双工协议但这也造成四次挥手非常复杂。
四次挥手中你可以用netstat命令观察到6种状态。其中你多半看到过TIME_WAIT状态。网上有许多文章介绍怎样减少TIME_WAIT状态连接的数量也有文章说TIME_WAIT状态是必不可少、不能优化掉的。这两种看似自相矛盾的观点之所以存在就在于优化连接关闭时不能仅基于主机端的视角还必须站在整个网络的层次上才能给出正确的解决方案。
Linux为四次挥手提供了很多控制参数有些参数的名称与含义并不相符。例如tcp_orphan_retries参数中有orphan孤儿却同时对非孤儿连接也生效。而且错误地配置这些参数不只无法针对高并发场景提升性能还会降低资源的使用效率甚至引发数据错误。
这一讲我们将基于四次挥手的流程介绍Linux下的优化方法。
## 四次挥手的流程
你想没想过,为什么建立连接是三次握手,而关闭连接需要四次挥手呢?
这是因为TCP不允许连接处于半打开状态时就单向传输数据所以在三次握手建立连接时服务器会把ACK和SYN放在一起发给客户端其中ACK用来打开客户端的发送通道SYN用来打开服务器的发送通道。这样原本的四次握手就降为三次握手了。
<img src="https://static001.geekbang.org/resource/image/74/51/74ac4e70ef719f19270c08201fb53a51.png" alt="">
但是当连接处于半关闭状态时TCP是允许单向传输数据的。为便于下文描述**接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。**当主动方关闭连接时被动方仍然可以在不调用close函数的状态下长时间发送数据此时连接处于半关闭状态。这一特性是TCP的双向通道互相独立所致却也使得关闭连接必须通过四次挥手才能做到。
**互联网中往往服务器才是主动关闭连接的一方。**这是因为HTTP消息是单向传输协议服务器接收完请求才能生成响应发送完响应后就会立刻关闭TCP连接这样及时释放了资源能够为更多的用户服务。
这就使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。
了解了这一点之后,我们再来看四次挥手的流程。
<img src="https://static001.geekbang.org/resource/image/e2/b7/e2ef1347b3b4590da431dc236d9239b7.png" alt="">
**其实四次挥手只涉及两种报文FIN和ACK。**FIN就是Finish结束连接的意思谁发出FIN报文就表示它将不再发送任何数据关闭这一方向的传输通道。ACK是Acknowledge确认的意思它用来通知对方你方的发送通道已经关闭。
当主动方关闭连接时会发送FIN报文此时主动方的连接状态由ESTABLISHED变为FIN_WAIT1。当被动方收到FIN报文后内核自动回复ACK报文连接状态由ESTABLISHED变为CLOSE_WAIT顾名思义它在等待进程调用close函数关闭连接。当主动方接收到这个ACK报文后连接状态由FIN_WAIT1变为FIN_WAIT2主动方的发送通道就关闭了。
再来看被动方的发送通道是如何关闭的。当被动方进入CLOSE_WAIT状态时进程的read函数会返回0这样开发人员就会有针对性地调用close函数进而触发内核发送FIN报文此时被动方连接的状态变为LAST_ACK。当主动方收到这个FIN报文时内核会自动回复ACK同时连接的状态由FIN_WAIT2变为TIME_WAITLinux系统下大约1分钟后TIME_WAIT状态的连接才会彻底关闭。而被动方收到ACK报文后连接就会关闭。
## 主动方的优化
关闭连接有多种方式比如进程异常退出时针对它打开的连接内核就会发送RST报文来关闭。RST的全称是Reset复位的意思它可以不走四次挥手强行关闭连接但当报文延迟或者重复传输时这种方式会导致数据错乱所以这是不得已而为之的关闭连接方案。
安全关闭连接的方式必须通过四次挥手它由进程调用close或者shutdown函数发起这二者都会向对方发送FIN报文shutdown参数须传入SHUT_WR或者SHUT_RDWR才会发送FIN区别在于close调用后哪怕对方在半关闭状态下发送的数据到达主动方进程也无法接收。
**此时这个连接叫做孤儿连接如果你用netstat -p命令会发现连接对应的进程名为空。而shutdown函数调用后即使连接进入了FIN_WAIT1或者FIN_WAIT2状态它也不是孤儿连接进程仍然可以继续接收数据。**关于孤儿连接的概念,下文调优参数时还会用到。
主动方发送FIN报文后连接就处于FIN_WAIT1状态下该状态通常应在数十毫秒内转为FIN_WAIT2。只有迟迟收不到对方返回的ACK时才能用netstat命令观察到FIN_WAIT1状态。此时**内核会定时重发FIN报文其中重发次数由tcp_orphan_retries参数控制**注意orphan虽然是孤儿的意思该参数却不只对孤儿连接有效事实上它对所有FIN_WAIT1状态下的连接都有效默认值是0特指8次
```
net.ipv4.tcp_orphan_retries = 0
```
如果FIN_WAIT1状态连接有很多你就需要考虑降低tcp_orphan_retries的值。当重试次数达到tcp_orphan_retries时连接就会直接关闭掉。
**对于正常情况来说调低tcp_orphan_retries已经够用但如果遇到恶意攻击FIN报文根本无法发送出去。**这是由TCP的2个特性导致的。
- 首先TCP必须保证报文是有序发送的FIN报文也不例外当发送缓冲区还有数据没发送时FIN报文也不能提前发送。
- 其次TCP有流控功能当接收方将接收窗口设为0时发送方就不能再发送数据。所以当攻击者下载大文件时就可以通过将接收窗口设为0导致FIN报文无法发送进而导致连接一直处于FIN_WAIT1状态。
解决这种问题的方案是调整tcp_max_orphans参数
```
net.ipv4.tcp_max_orphans = 16384
```
顾名思义,**tcp_max_orphans 定义了孤儿连接的最大数量。**当进程调用close函数关闭连接后无论该连接是在FIN_WAIT1状态还是确实关闭了这个连接都与该进程无关了它变成了孤儿连接。Linux系统为防止孤儿连接过多导致系统资源长期被占用就提供了tcp_max_orphans参数。如果孤儿连接数量大于它新增的孤儿连接将不再走四次挥手而是直接发送RST复位报文强制关闭。
当连接收到ACK进入FIN_WAIT2状态后就表示主动方的发送通道已经关闭接下来将等待对方发送FIN报文关闭对方的发送通道。这时**如果连接是用shutdown函数关闭的连接可以一直处于FIN_WAIT2状态。但对于close函数关闭的孤儿连接这个状态不可以持续太久而tcp_fin_timeout控制了这个状态下连接的持续时长。**
```
net.ipv4.tcp_fin_timeout = 60
```
它的默认值是60秒。这意味着对于孤儿连接如果60秒后还没有收到FIN报文连接就会直接关闭。这个60秒并不是拍脑袋决定的它与接下来介绍的TIME_WAIT状态的持续时间是相同的我们稍后再来回答60秒的由来。
TIME_WAIT是主动方四次挥手的最后一个状态。当收到被动方发来的FIN报文时主动方回复ACK表示确认对方的发送通道已经关闭连接随之进入TIME_WAIT状态等待60秒后关闭为什么呢我们必须站在整个网络的角度上才能回答这个问题。
TIME_WAIT状态的连接在主动方看来确实已经关闭了。然而被动方没有收到ACK报文前连接还处于LAST_ACK状态。如果这个ACK报文没有到达被动方被动方就会重发FIN报文。重发次数仍然由前面介绍过的tcp_orphan_retries参数控制。
如果主动方不保留TIME_WAIT状态会发生什么呢此时连接的端口恢复了自由身可以复用于新连接了。然而被动方的FIN报文可能再次到达这既可能是网络中的路由器重复发送也有可能是被动方没收到ACK时基于tcp_orphan_retries参数重发。这样**正常通讯的新连接就可能被重复发送的FIN报文误关闭。**保留TIME_WAIT状态就可以应付重发的FIN报文当然其他数据报文也有可能重发所以TIME_WAIT状态还能避免数据错乱。
我们再回过头来看看为什么TIME_WAIT状态要保持60秒呢这与孤儿连接FIN_WAIT2状态默认保留60秒的原理是一样的**因为这两个状态都需要保持2MSL时长。MSL全称是Maximum Segment Lifetime它定义了一个报文在网络中的最长生存时间**报文每经过一次路由器的转发IP头部的TTL字段就会减1减到0时报文就被丢弃这就限制了报文的最长存活时间
为什么是2 MSL的时长呢这其实是相当于至少允许报文丢失一次。比如若ACK在一个MSL内丢失这样被动方重发的FIN会在第2个MSL内到达TIME_WAIT状态的连接可以应对。为什么不是4或者8 MSL的时长呢你可以想象一个丢包率达到百分之一的糟糕网络连续两次丢包的概率只有万分之一这个概率实在是太小了忽略它比解决它更具性价比。
**因此TIME_WAIT和FIN_WAIT2状态的最大时长都是2 MSL由于在Linux系统中MSL的值固定为30秒所以它们都是60秒。**
虽然TIME_WAIT状态的存在是有必要的但它毕竟在消耗系统资源比如TIME_WAIT状态的端口就无法供新连接使用。怎样解决这个问题呢
**Linux提供了tcp_max_tw_buckets 参数当TIME_WAIT的连接数量超过该参数时新关闭的连接就不再经历TIME_WAIT而直接关闭。**
```
net.ipv4.tcp_max_tw_buckets = 5000
```
当服务器的并发连接增多时相应地同时处于TIME_WAIT状态的连接数量也会变多此时就应当调大tcp_max_tw_buckets参数减少不同连接间数据错乱的概率。
当然tcp_max_tw_buckets也不是越大越好毕竟内存和端口号都是有限的。有没有办法让新连接复用TIME_WAIT状态的端口呢如果服务器会主动向上游服务器发起连接的话就可以把tcp_tw_reuse参数设置为1它允许作为客户端的新连接在安全条件下使用TIME_WAIT状态下的端口。
```
net.ipv4.tcp_tw_reuse = 1
```
当然要想使tcp_tw_reuse生效还得把timestamps参数设置为1它满足安全复用的先决条件对方也要打开tcp_timestamps
```
net.ipv4.tcp_timestamps = 1
```
老版本的Linux还提供了tcp_tw_recycle参数它并不要求TIME_WAIT状态存在60秒很容易导致数据错乱不建议设置为1。
```
net.ipv4.tcp_tw_recycle = 0
```
所以在Linux 4.12版本后,直接取消了这一参数。
## 被动方的优化
当被动方收到FIN报文时就开启了被动方的四次挥手流程。内核自动回复ACK报文后连接就进入CLOSE_WAIT状态顾名思义它表示等待进程调用close函数关闭连接。
内核没有权力替代进程去关闭连接因为若主动方是通过shutdown关闭连接那么它就是想在半关闭连接上接收数据。**因此Linux并没有限制CLOSE_WAIT状态的持续时间。**
当然大多数应用程序并不使用shutdown函数关闭连接所以当你用netstat命令发现大量CLOSE_WAIT状态时要么是程序出现了Bugread函数返回0时忘记调用close函数关闭连接要么就是程序负载太高close函数所在的回调函数被延迟执行了。此时我们应当在应用代码层面解决问题。
由于CLOSE_WAIT状态下连接已经处于半关闭状态所以此时进程若要关闭连接只能调用close函数再调用shutdown关闭单向通道就没有意义了内核就会发出FIN报文关闭发送通道同时连接进入LAST_ACK状态等待主动方返回ACK来确认连接关闭。
如果迟迟等不到ACK内核就会重发FIN报文重发次数仍然由tcp_orphan_retries参数控制这与主动方重发FIN报文的优化策略一致。
至此,由一方主动发起四次挥手的流程就介绍完了。需要你注意的是,**如果被动方迅速调用close函数那么被动方的ACK和FIN有可能在一个报文中发送这样看起来四次挥手会变成三次挥手这只是一种特殊情况不用在意。**
我们再来看一种特例,如果连接双方同时关闭连接,会怎么样?
此时上面介绍过的优化策略仍然适用。两方发送FIN报文时都认为自己是主动方所以都进入了FIN_WAIT1状态FIN报文的重发次数仍由tcp_orphan_retries参数控制。
<img src="https://static001.geekbang.org/resource/image/04/52/043752a3957d36f4e3c82cd83d472452.png" alt="">
接下来双方在等待ACK报文的过程中都等来了FIN报文。这是一种新情况所以连接会进入一种叫做CLOSING的新状态它替代了FIN_WAIT2状态。此时内核回复ACK确认对方发送通道的关闭仅己方的FIN报文对应的ACK还没有收到。所以CLOSING状态与LAST_ACK状态下的连接很相似它会在适时重发FIN报文的情况下最终关闭。
## 小结
我们对这一讲的内容做个小结。
今天我们讲了四次挥手的流程,你需要根据主动方与被动方的连接状态变化来调整系统参数,使它在特定网络条件下更及时地释放资源。
四次挥手的主动方为了应对丢包允许在tcp_orphan_retries次数内重发FIN报文。当收到ACK报文连接就进入了FIN_WAIT2状态此时系统的行为依赖这是否为孤儿连接。
如果这是close函数关闭的孤儿连接那么在tcp_fin_timeout秒内没有收到对方的FIN报文连接就直接关闭反之shutdown函数关闭的连接则不受此限制。毕竟孤儿连接可能在重发次数内存在数分钟之久为了应对孤儿连接占用太多的资源tcp_max_orphans定义了最大孤儿连接的数量超过时连接就会直接释放。
当接收到FIN报文并返回ACK后主动方的连接进入TIME_WAIT状态。这一状态会持续1分钟为了防止TIME_WAIT状态占用太多的资源tcp_max_tw_buckets定义了最大数量超过时连接也会直接释放。当TIME_WAIT状态过多时还可以通过设置tcp_tw_reuse和tcp_timestamps为1 将TIME_WAIT状态的端口复用于作为客户端的新连接。
被动关闭的连接方应对非常简单它在回复ACK后就进入了CLOSE_WAIT状态等待进程调用close函数关闭连接。因此出现大量CLOSE_WAIT状态的连接时应当从应用程序中找问题。当被动方发送FIN报文后连接就进入LAST_ACK状态在未等来ACK时会在tcp_orphan_retries参数的控制下重发FIN报文。
至此TCP连接建立、关闭时的性能优化就介绍完了。下一讲我们将专注在TCP上传输数据时如何优化内存的使用效率。
## 思考题
最后给你留一个思考题。你知道关闭连接时的SO_LINGER选项吗它希望用四次挥手替代RST关闭连接的方式防止浏览器没有接收到完整的HTTP响应。请你思考一下SO_LINGER会怎么影响主动方连接的状态变化SO_LINGER上的超时时间是怎样与系统配置参数协作的欢迎你在留言区与我一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/aa/a84702c6b4bca2042ce0b5d5ca0f68aa.mp3"></audio>
你好,我是陶辉。
我们在[[第8课]](https://time.geekbang.org/column/article/236921) 中讲了如何从C10K进一步到C10M不过这也意味着TCP占用的内存翻了一千倍服务器的内存资源会非常紧张。
如果你在Linux系统中用free命令查看内存占用情况会发现一栏叫做buff/cache它是系统内存似乎与应用进程无关。但每当进程新建一个TCP连接buff/cache中的内存都会上升4K左右。而且当连接传输数据时就远不止增加4K内存了。这样几十万并发连接就在进程内存外又增加了GB级别的系统内存消耗。
这是因为TCP连接是由内核维护的内核为每个连接建立的内存缓冲区既要为网络传输服务也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小就无法充分使用网络带宽TCP传输速度就会很慢如果连接的内存配置过大那么服务器内存会很快用尽新连接就无法建立成功。因此只有深入理解Linux下TCP内存的用途才能正确地配置内存大小。
这一讲我们就来看看Linux下的TCP缓冲区该如何修改才能在高并发下维持TCP的高速传输。
## 滑动窗口是怎样影响传输速度的?
我们知道TCP必须保证每一个报文都能够到达对方它采用的机制就是报文发出后必须收到接收方返回的ACK确认报文Acknowledge确认的意思。如果在一段时间内称为RTOretransmission timeout没有收到这个报文还得重新发送直到收到ACK为止。
**可见TCP报文发出去后并不能立刻从内存中删除因为重发时还需要用到它。**由于TCP是由内核实现的所以报文存放在内核缓冲区中这也是高并发下buff/cache内存增加很多的原因。
事实上,确认报文被收到的机制非常复杂,它受制于很多因素。我们先来看第一个因素,**速度**。
如果我们发送一个报文收到ACK确认后再发送下一个报文会有什么问题显然发送每个报文都需要经历一个RTT时延RTT的值可以用ping命令得到。要知道因为网络设备限制了报文的字节数所以每个报文的体积有限。
比如以太网报文最大只有1500字节而发送主机到接收主机间要经历多个广域网、局域网其中最小的设备决定了网络报文的最大字节数在TCP中这个值叫做MSSMaximum Segment Size它通常在1KB左右。如果RTT时延是10ms那么它们的传送速度最多只有1KB/10ms=100KB/s可见这种确认报文方式太影响传输速度了。
<img src="https://static001.geekbang.org/resource/image/8c/a8/8c97985f1ed742b458d0c00c3155aba8.png" alt="">
**提速的方式很简单,并行地批量发送报文,再批量确认报文即可。**比如发送一个100MB的文件如果MSS值为1KB那么需要发送约10万个报文。发送方大可以同时发送这10万个报文再等待它们的ACK确认。这样发送速度瞬间就达到100MB/10ms=10GB/s。
然而,这引出了另一个问题,接收方有那么强的处理能力吗?**接收方的处理能力**,这是影响确认机制的第二个因素(网络也没有这么强的处理能力,下一讲会介绍应对网络瓶颈的拥塞控制技术)。
<img src="https://static001.geekbang.org/resource/image/f9/85/f9e14ba29407da48bf55a7c24c7af585.png" alt="">
当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,网络效率非常低。怎么限制发送方的速度呢?
**接收方把它的处理能力告诉发送方,使其限制发送速度即可,这就是滑动窗口的由来。**接收方根据它的缓冲区可以计算出后续能够接收多少字节的报文这个数字叫做接收窗口。当内核接收到报文时必须用缓冲区存放它们这样剩余缓冲区空间变小接收窗口也就变小了当进程调用read函数后数据被读入了用户空间内核缓冲区就被清空这意味着主机可以接收更多的报文接收窗口就会变大。
因此接收窗口并不是恒定不变的那么怎么把时刻变化的窗口通知给发送方呢TCP报文头部中的窗口字段就可以起到通知的作用。
当发送方从报文中得到接收方的窗口大小时就明白了最多能发送多少字节的报文这个数字被称为发送方的发送窗口。如果不考虑下一讲将要介绍的拥塞控制发送方的发送窗口就是接收方的接收窗口由于报文有传输时延t1时刻的接收窗口在t2时刻才能到达发送端因此这两个窗口并不完全等价
<img src="https://static001.geekbang.org/resource/image/9a/f1/9a9385d4e5343285201e0242809c16f1.jpg" alt="">
从上图中可以看到窗口字段只有2个字节因此它最多能表达2<sup>16</sup> 即65535字节大小的窗口之所以不是65536是因为窗口可以为0此时叫做窗口关闭上一讲提到的关闭连接时让FIN报文发不出去以致于服务器的连接都处于FIN_WAIT1状态就是通过窗口关闭技术实现的这在RTT为10ms的网络中也只能到达6MB/s的最大速度在当今的高速网络中显然并不够用。
[RFC1323](https://tools.ietf.org/html/rfc1323) 定义了扩充窗口的方法但Linux中打开这一功能需要把tcp_window_scaling配置设为1此时窗口的最大值可以达到1GB2<sup>30</sup>)。
```
net.ipv4.tcp_window_scaling = 1
```
这样看来只要进程能及时地调用read函数读取数据并且接收缓冲区配置得足够大那么接收窗口就可以无限地放大发送方也就无限地提升发送速度。很显然这是不可能的因为网络的传输能力是有限的当发送方依据发送窗口发送超过网络处理能力的报文时路由器会直接丢弃这些报文。因此缓冲区的内存并不是越大越好。
## 带宽时延积如何确定最大传输速度?
缓冲区到底该设置为多大呢我们知道TCP的传输速度受制于发送窗口与接收窗口以及网络传输能力。其中两个窗口由缓冲区大小决定进程调用read函数是否及时也会影响它。如果缓冲区大小与网络传输能力匹配那么缓冲区的利用率就达到了最大值。
怎样计算出网络传输能力呢?带宽描述了网络传输能力,但它不能直接使用,因为它与窗口或者说缓冲区的计量单位不同。带宽是单位时间内的流量 它表达的是速度比如你家里的宽带100MB/s而窗口和缓冲区的单位是字节。当网络速度乘以时间才能得到字节数差的这个时间这就是网络时延。
当最大带宽是100MB/s、网络时延是10ms时这意味着客户端到服务器间的网络一共可以存放100MB/s * 0.01s = 1MB的字节。这个1MB是带宽与时延的乘积所以它就叫做带宽时延积缩写为BDPBandwidth Delay Product。这1MB字节存在于飞行中的TCP报文它们就在网络线路、路由器等网络设备上。如果飞行报文超过了1MB就一定会让网络过载最终导致丢包。
由于发送缓冲区决定了发送窗口的上限,而发送窗口又决定了已发送但未确认的飞行报文的上限,因此,发送缓冲区不能超过带宽时延积,因为超出的部分没有办法用于有效的网络传输,且飞行字节大于带宽时延积还会导致丢包;而且,缓冲区也不能小于带宽时延积,否则无法发挥出高速网络的价值。
## 怎样调整缓冲区去适配滑动窗口?
这么看来,我们只要把缓冲区设置为带宽时延积不就行了吗?**比如当我们做socket网络编程时通过设置socket的SO_SNDBUF属性就可以设定缓冲区的大小。**
然而,这并不是个好主意,因为不是每一个请求都能够达到最大传输速度,比如请求的体积太小时,在**慢启动**(下一讲会谈到)的影响下,未达到最大速度时请求就处理完了。再比如网络本身也会有波动,未必可以一直保持最大速度。
**因此,时刻让缓冲区保持最大,太过浪费内存了。**
到底该如何设置缓冲区呢?
我们可以使用Linux的**缓冲区动态调节功能**解决上述问题。其中缓冲区的调节范围是可以设置的。先来看发送缓冲区它的范围通过tcp_wmem配置
```
net.ipv4.tcp_wmem = 4096 16384 4194304
```
其中第1个数值是动态范围的下限第3个数值是动态范围的上限。而中间第2个数值则是初始默认值。
发送缓冲区完全根据需求自行调整。比如一旦发送出的数据被确认而且没有新的数据要发送就可以把发送缓冲区的内存释放掉。而接收缓冲区的调整就要复杂一些先来看设置接收缓冲区范围的tcp_rmem
```
net.ipv4.tcp_rmem = 4096 87380 6291456
```
它的数值与tcp_wmem类似第1、3个值是范围的下限和上限第2个值是初始默认值。发送缓冲区自动调节的依据是待发送的数据接收缓冲区由于只能被动地等待接收数据它该如何自动调整呢
**可以依据空闲系统内存的数量来调节接收窗口。**如果系统的空闲内存很多,就可以把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而对方的发送速度就会通过增加飞行报文来提升。反之,内存紧张时就会缩小缓冲区,这虽然会减慢速度,但可以保证更多的并发连接正常工作。
发送缓冲区的调节功能是自动开启的而接收缓冲区则需要配置tcp_moderate_rcvbuf为1来开启调节功能
```
net.ipv4.tcp_moderate_rcvbuf = 1
```
接收缓冲区调节时怎么判断空闲内存的多少呢这是通过tcp_mem配置完成的
```
net.ipv4.tcp_mem = 88560 118080 177120
```
tcp_mem的3个值是Linux判断系统内存是否紧张的依据。当TCP内存小于第1个值时不需要进行自动调节在第1和第2个值之间时内核开始调节接收缓冲区的大小大于第3个值时内核不再为TCP分配新内存此时新连接是无法建立的。
在高并发服务器中,为了兼顾网速与大量的并发连接,**我们应当保证缓冲区的动态调整上限达到带宽时延积而下限保持默认的4K不变即可。而对于内存紧张的服务而言调低默认值是提高并发的有效手段。**
同时如果这是网络IO型服务器那么**调大tcp_mem的上限可以让TCP连接使用更多的系统内存这有利于提升并发能力。**需要注意的是tcp_wmem和tcp_rmem的单位是字节而tcp_mem的单位是页面大小。而且**千万不要在socket上直接设置SO_SNDBUF或者SO_RCVBUF这样会关闭缓冲区的动态调整功能。**
## 小结
我们对这一讲的内容做个小结。
实现高并发服务时由于必须把大部分内存用在网络传输上所以除了关注应用内存的使用还必须关注TCP内核缓冲区的内存使用情况。
TCP使用ACK确认报文实现了可靠性又依赖滑动窗口既提升了发送速度也兼顾了接收方的处理能力。然而默认的滑动窗口最大只能到65KB要想提升发送速度必须提升滑动窗口的上限在Linux下是通过设置tcp_window_scaling为1做到的。
滑动窗口定义了飞行报文的最大字节数当它超过带宽时延积时就会发生丢包。而当它小于带宽时延积时就无法让TCP的传输速度达到网络允许的最大值。因此滑动窗口的设计必须参考带宽时延积。
内核缓冲区决定了滑动窗口的上限但我们不能通过socket的SO_SNFBUF等选项直接把缓冲区大小设置为带宽时延积因为TCP不会一直维持在最高速上过大的缓冲区会减少并发连接数。Linux带来的缓冲区自动调节功能非常有效我们应当把缓冲区的上限设置为带宽时延积。其中发送缓冲区的调节功能是自动打开的而接收缓冲区需要把tcp_moderate_rcvbuf设置为1来开启其中调节的依据根据tcp_mem而定。
这样高效地配置内存后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。这一讲我们谈了内核缓冲区对传输速度的影响,下一讲我们再来看如何调节发送速度以匹配不同的网络能力。
## 思考题
最后请你观察下Linux系统下连接建立时、发送接收数据时buff/cache内存的变动情况。用我们这一讲介绍的原理解释系统内存的变化现象。欢迎你在留言区与我沟通互动。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="12 | 如何调整TCP拥塞控制的性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/4c/b79a03a9a6ba059cfe329e3f4ccccb4c.mp3"></audio>
你好,我是陶辉。
上一讲我们谈到接收主机的处理能力不足时是通过滑动窗口来减缓对方的发送速度。这一讲我们来看看当网络处理能力不足时又该如何优化TCP的性能。
如果你阅读过TCP协议相关的书籍一定看到过慢启动、拥塞控制等名词。这些概念似乎离应用开发者很远然而如果没有拥塞控制整个网络将会锁死所有消息都无法传输。
而且如果你在开发分布式集群中的高并发服务理解拥塞控制的工作原理就可以在内核的TCP层提升所有进程的网络性能。比如你可能听过2013年谷歌把初始拥塞窗口从3个MSS最大报文长度左右提升到10个MSS将Web站点的网络性能提升了10%以上而有些高速CDN站点甚至把初始拥塞窗口提升到70个MSS。
特别是近年来谷歌提出的BBR拥塞控制算法已经应用在高版本的Linux内核中从它在YouTube上的应用可以看到在高性能站点上网络时延有20%以上的降低,传输带宽也有提高。
Linux允许我们调整拥塞控制算法但是正确地设置参数还需要深入理解拥塞控制对TCP连接的影响。这一讲我们将沿着网络如何影响发送速度这条线看看如何调整Linux下的拥塞控制参数。
## 慢启动阶段如何调整初始拥塞窗口?
上一讲谈到,只要接收方的读缓冲区足够大,就可以通过报文中的接收窗口,要求对方更快地发送数据。然而,网络的传输速度是有限的,它会直接丢弃超过其处理能力的报文。而发送方只有在重传定时器超时后,才能发现超发的报文被网络丢弃了,发送速度提不上去。更为糟糕的是,如果网络中的每个连接都按照接收窗口尽可能地发送更多的报文时,就会形成恶性循环,最终超高的网络丢包率会使得每个连接都无法发送数据。
解决这一问题的方案叫做拥塞控制它包括4个阶段我们首先来看TCP连接刚建立时的慢启动阶段。由于TCP连接会穿越许多网络所以最初并不知道网络的传输能力为了避免发送超过网络负载的报文TCP只能先调低发送窗口关于发送窗口你可以参考[[第11讲]](https://time.geekbang.org/column/article/239176)),减少飞行中的报文来让发送速度变慢,这也是“慢启动”名字的由来。
让发送速度变慢是通过引入拥塞窗口全称为congestion window缩写为CWnd类似地接收窗口叫做rwnd发送窗口叫做swnd实现的它用于避免网络出现拥塞。上一讲我们说过如果不考虑网络拥塞发送窗口就等于对方的接收窗口而考虑了网络拥塞后发送窗口则应当是拥塞窗口与对方接收窗口的最小值
```
swnd = min(cwnd, rwnd)
```
这样,发送速度就综合考虑了接收方和网络的处理能力。
虽然窗口的计量单位是字节但为了方便理解通常我们用MSS作为描述窗口大小的单位其中MSS是TCP报文的最大长度。
如果初始拥塞窗口只有1个MSS当MSS是1KB而RTT时延是100ms时发送速度只有10KB/s。所以当没有发生拥塞时拥塞窗口必须快速扩大才能提高互联网的传输速度。因此慢启动阶段会以指数级扩大拥塞窗口扩大规则是这样的发送方每收到一个ACK确认报文拥塞窗口就增加1个MSS比如最初的初始拥塞窗口也称为initcwnd是1个MSS经过4个RTT就会变成16个MSS。
虽然指数级提升发送速度很快但互联网中的很多资源体积并不大多数场景下在传输速度没有达到最大时资源就已经下载完了。下图是2010年Google对Web对象大小的CDF累积分布统计大多数对象在10KB左右。
<img src="https://static001.geekbang.org/resource/image/1a/23/1a93f6622d30ef5a138b37ce7f94e323.png" alt="" title="图片来源《An Argument for Increasing TCP's Initial Contestion Window》">
这样当MSS是1KB时多数HTTP请求至少包含10个报文即使以指数级增加拥塞窗口也需要至少4个RTT才能传输完参见下图
<img src="https://static001.geekbang.org/resource/image/86/5c/865b14ad43c828fdf494b541bb810f5c.png" alt="">
因此2013年TCP的初始拥塞窗口调整到了10个MSS参见[RFC6928](https://tools.ietf.org/html/rfc6928)这样1个RTT内就可以传输10KB的请求。然而如果你需要传输的对象体积更大BDP带宽时延积很大时完全可以继续提高初始拥塞窗口的大小。下图是2014年、2017年全球主要CDN厂商初始拥塞窗口的变化可见随着网速的增加初始拥塞窗口也变得更大了。
[<img src="https://static001.geekbang.org/resource/image/20/d2/20bdd4477ed2d837d398a3b43020abd2.png" alt="" title="图片来源https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/">](https://blog.imaginea.com/look-at-tcp-initcwnd-cdns/)
因此你可以根据网络状况和传输对象的大小调整初始拥塞窗口的大小。调整前先要清楚你的服务器现在的初始拥塞窗口是多大。你可以通过ss命令查看当前拥塞窗口
```
# ss -nli|fgrep cwnd
cubic rto:1000 mss:536 cwnd:10 segs_in:10621866 lastsnd:1716864402 lastrcv:1716864402 lastack:1716864402
```
再通过ip route change命令修改初始拥塞窗口
```
# ip route | while read r; do
ip route change $r initcwnd 10;
done
```
当然,更大的初始拥塞窗口以及指数级的提速,连接很快就会遭遇网络拥塞,从而导致慢启动阶段的结束。
## 出现网络拥塞时该怎么办?
以下3种场景都会导致慢启动阶段结束
1. 通过定时器明确探测到了丢包;
1. 拥塞窗口的增长到达了慢启动阈值ssthresh全称为slow start threshold也就是之前发现网络拥塞时的窗口大小
1. 接收到重复的ACK报文可能存在丢包。
我们先来看第1种场景在规定时间内没有收到ACK报文这说明报文丢失了网络出现了严重的拥塞必须先降低发送速度再进入拥塞避免阶段。不同的拥塞控制算法降低速度的幅度并不相同比如CUBIC算法会把拥塞窗口降为原先的0.8倍也就是发送速度降到0.8倍)。此时,我们知道了多大的窗口会导致拥塞,因此可以把慢启动阈值设为发生拥塞前的窗口大小。
再看第2种场景虽然还没有发生丢包但发送方已经达到了曾经发生网络拥塞的速度拥塞窗口达到了慢启动阈值接下来发生拥塞的概率很高所以进入**拥塞避免阶段,此时拥塞窗口不能再以指数方式增长,而是要以线性方式增长**。接下来拥塞窗口会以每个RTT增加1个MSS的方式代替慢启动阶段每收到1个ACK就增加1个MSS的方式。这里可能有同学会有疑问在第1种场景发生前慢启动阈值是多大呢事实上[RFC5681](https://tools.ietf.org/html/rfc5681#page-5) 建议最初的慢启动阈值尽可能的大这样才能在第1、3种场景里快速发现网络瓶颈。
第3种场景最为复杂。我们知道TCP传输的是字节流而“流”是天然有序的。因此当接收方收到不连续的报文时就可能发生报文丢失或者延迟等待发送方超时重发太花时间了为了缩短重发时间**快速重传算法便应运而生。**
当连续收到3个重复ACK时发送方便得到了网络发生拥塞的明确信号通过重复ACK报文的序号我们知道丢失了哪个报文这样不等待定时器的触发立刻重发丢失的报文可以让发送速度下降得慢一些这就是快速重传算法。
出现拥塞后,发送方会缩小拥塞窗口,再进入前面提到的拥塞避免阶段,用线性速度慢慢增加拥塞窗口。然而,**为了平滑地降低速度,发送方应当先进入快速恢复阶段,在失序报文到达接收方后,再进入拥塞避免阶段。**
那什么是快速恢复呢我们不妨把网络看成一个容器上一讲中说过它可以容纳BDP字节的报文每当接收方从网络中取出一个报文发送方就可以增加一个报文。当发送方接收到重复ACK时可以推断有失序报文离开了网络到达了接收方的缓冲区因此可以再多发送一个报文。如下图所示
<img src="https://static001.geekbang.org/resource/image/92/d9/92980476c93766887cc260f03c5d50d9.png" alt="">
这里你要注意第6个报文在慢启动阶段丢失接收方收到失序的第7个报文会触发快速重传算法它必须立刻返回ACK6。而发送方接收到第1个重复ACK6报文时就从慢启动进入了快速重传阶段**此刻的重复ACK不会扩大拥塞窗口。**当连续收到3个ACK6时发送方会重发报文6并把慢启动阈值和拥塞窗口都降到之前的一半3个MSS再进入快速恢复阶段。按照规则由于收到3个重复ACK所以拥塞窗口会增加3个MSS。之后收到的2个ACK让拥塞窗口增加到了8个MSS直到收到期待的ACK12发送方才会进入拥塞避免阶段。
慢启动、拥塞避免、快速重传、快速恢复共同构成了拥塞控制算法。Linux上提供了更改拥塞控制算法的配置你可以通过tcp_available_congestion_control配置查看内核支持的算法列表
```
net.ipv4.tcp_available_congestion_control = cubic reno
```
再通过tcp_congestion_control配置选择一个具体的拥塞控制算法
```
net.ipv4.tcp_congestion_control = cubic
```
但有件事你得清楚,拥塞控制是控制网络流量的算法,主机间会互相影响,在生产环境更改之前必须经过完善的测试。
## 基于测量的拥塞控制算法
上文介绍的是传统拥塞控制算法,它是以丢包作为判断拥塞的依据。然而,网络刚出现拥塞时并不会丢包,而真的出现丢包时,拥塞已经非常严重了。如下图所示,像路由器这样的网络设备,都会有缓冲队列应对突发的、超越处理能力的流量:
<img src="https://static001.geekbang.org/resource/image/47/85/4732f8f97aefcb26334f4e7d1d096185.png" alt="">
当缓冲队列为空时,传输速度最快。一旦队列开始积压,每个报文的传输时间需要增加排队时间,网速就变慢了。而当队列溢出时,才会出现丢包,基于丢包的拥塞控制算法在这个时间点进入拥塞避免阶段,显然太晚了。因为升高的网络时延降低了用户体验,而且从丢包到重发这段时间,带宽也会出现下降。
进行拥塞控制的最佳时间点,是缓冲队列刚出现积压的时刻,**此时,网络时延会增高,但带宽维持不变,这两个数值的变化可以给出明确的拥塞信号**,如下图所示:
<img src="https://static001.geekbang.org/resource/image/2c/ca/2cbda9079294ed5da6617f0f0e83acca.png" alt="" title="图片来源网络传输速度_RTT与飞行报文的关系">
这种以测量带宽、时延来确定拥塞的方法在丢包率较高的网络中应用效果尤其好。2016年Google推出的BBR算法全称Bottleneck Bandwidth and Round-trip propagation time就是测量驱动的拥塞控制算法它在YouTube站点上应用后使得网络时延下降了20%以上传输带宽也有5%左右的提升。
当然,测量驱动的拥塞算法并没有那么简单,因为网络会波动,线路也会变化,算法必须及时地响应网络变化,这里不再展开算法细节,你可以在我的[这篇博客](https://www.taohui.pub/2019/08/07/%e4%b8%80%e6%96%87%e8%a7%a3%e9%87%8a%e6%b8%85%e6%a5%9agoogle-bbr%e6%8b%a5%e5%a1%9e%e6%8e%a7%e5%88%b6%e7%ae%97%e6%b3%95%e5%8e%9f%e7%90%86/)中找到BBR算法更详细的介绍。
Linux 4.9版本之后都支持BBR算法开启BBR算法仍然使用tcp_congestion_control配置
```
net.ipv4.tcp_congestion_control=bbr
```
## 小结
我们对这一讲的内容做个小结。
当TCP连接建立成功后拥塞控制算法就会发生作用首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口Linux上可以通过route ip change命令修改它。通常在带宽时延积较大的网络中应当调高初始拥塞窗口。
丢包以及重复的ACK都是明确的拥塞信号此时发送方就会调低拥塞窗口减速同时修正慢启动阈值。这样将来再次到达这个速度时就会自动进入拥塞避免阶段用线性速度代替慢启动阶段的指数速度提升窗口大小。
当然重复ACK意味着发送方可以提前重发丢失报文快速重传算法定义了这一行为。同时为了使得重发报文的过程中发送速度不至于出现断崖式下降TCP又定义了快速恢复算法发送方在报文重新变得有序后结束快速恢复进入拥塞避免阶段。
但以丢包作为网络拥塞的信号往往为时已晚于是以BBR算法为代表的测量型拥塞控制算法应运而生。当飞行中报文数量不变而网络时延升高时就说明网络中的缓冲队列出现了积压这是进行拥塞控制的最好时机。Linux高版本支持BBR算法你可以通过tcp_congestion_control配置更改拥塞控制算法。
## 思考题
最后,请你思考下,快速恢复阶段的拥塞窗口,在报文变得有序后反而会缩小,这是为什么?欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="13 | 实战:单机如何实现管理百万主机的心跳服务?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/3b/5a3eeb1aa7620126176097d6fa78a23b.mp3"></audio>
你好,我是陶辉。
这一讲我们将结合前12讲以一个可管理百万主机集群的心跳服务作为实战案例看看所有高性能服务的设计思路。
首先解释下什么是心跳服务。集群中的主机如果宕机,那么管理服务必须及时发现,并做相应的容灾处理,比如将宕机主机的业务迁移到新的虚拟机上等等。怎么做到及时发现呢?可以要求每台主机定时上报心跳包,考虑到网络报文的延迟,如果管理服务在几个上报周期内未收到心跳,则认为主机宕机。当新主机加入集群后,心跳服务也可以及时识别并告知管理服务。
这就是心跳服务要解决的核心问题虽然很简单可是如果集群规模达到百万台虚拟机或者微服务进程这就不再简单了。多核CPU、内存使用效率、网络带宽时延积等都必须纳入你的考虑因为此时心跳包占用的网络带宽已经接近网卡上限仅调动一颗CPU的计算力去处理就会大量丢包百万级的对象、网络连接也很容易造成内存OOM。甚至判断宕机的算法都要重新设计降低时间复杂度后才能够应对超大集群的心跳管理。
解决这种集群规模下的性能问题,需要深入掌握底层基础知识,用系统化的全局思维去解决问题,这也是程序员薪资差异的重要分水岭。接下来,我们先实现更高效的核心算法,再设计高并发服务的架构,最后再来看传输层协议的选择。
## 如何设计更快的宕机判断算法?
通过心跳包找到宕机的主机需要一套算法比如用for循环做一次遍历找到停止上报心跳的主机就可以实现然而正如[[第 3 讲]](https://time.geekbang.org/column/article/232351) 所说,**当管理的对象数量级很大时,算法复杂度会严重影响程序性能**,遍历算法此时并不可取。我们先分析下这个算法的时间复杂度。
如果用红黑树这里用红黑树是因为它既支持遍历也可以实现对数时间复杂度的查询操作存放主机及其最近一次上报时间那么新主机上报心跳被发现的流程时间复杂度仅为O(logN)这是查询红黑树的成本。寻找宕机服务的流程需要对红黑树做全量遍历用当前时间去比较每个主机的上次心跳时间时间复杂度就是O(N)
如果业务对时间灵敏度要求很高就意味着需要频繁地执行O(N)级的遍历当N也就是主机数量很大时耗时就很可观了。而且寻找宕机服务和接收心跳包是两个流程如果它们都在单线程中执行那么寻找宕机服务的那段时间就不能接收心跳包会导致丢包如果使用多线程并发执行因为两个流程都需要操作红黑树所以要使用到互斥锁而当这两个流程争抢锁的频率很高时性能也会急剧下降。
**其实这个算法的根本问题在于,判断宕机的流程做了大量的重复工作。**比如主机每隔1秒上报一次心跳而考虑到网络可能丢包故5秒内失去心跳就认为宕机这种情况下如果主机A在第10秒时失去心跳那么第11、12、13、14这4秒对主机A的遍历都是多余的只有第15秒对主机A的遍历才有意义。于是每次遍历平均浪费了4/5的计算量。
如何设计快速的宕机判断算法呢?其实,这是一个从一堆主机中寻找宕机服务的信息题。**根据香农的理论,引入更多的信息,才能减少不确定性降低信息熵,从而减少计算量。就像心跳包间是有时间顺序的,上面的宕机判断算法显然忽略了接收到它们的顺序。**比如主机A的上次心跳包距现在4秒了而主机B距现在只有1秒显然不应同等对待。
于是我们引入存放心跳包的先入先出队列这就保存了心跳包的时序关系。新的心跳包进入队列尾部而老的心跳包则从队列首部退出这样寻找宕机服务时只要看队列首部最老的心跳包距现在是否超过5秒如果超过5秒就认定宕机同时把它取出队列否则证明队列中不存在宕机服务维持队列不变。
当然这里并没有解决如何发现新主机的问题。我们还需要一个能够执行高效查询的容器存放所有主机及其状态。红黑树虽然不慢但我们不再需要遍历容器所以可以选择更快的、查询时间复杂度为O(1)的哈希表存放主机信息(非标哈希表的实现参见[[第 3 讲]](https://time.geekbang.org/column/article/232351))。如下图所示。
<img src="https://static001.geekbang.org/resource/image/32/41/322fff00d232ebc1c85694babfb37541.png" alt="">
当然队列中的心跳包并不是只能从队首删除否则判断宕机流程的时间复杂度仍然是O(N)。实际上,每当收到心跳包时,如果对应主机的上一个心跳包还在队列中,那么可以直接把它从队列中删除。显然,计算在线主机何时宕机,只需要最新的心跳包,老的心跳包没有必要存在。因此,这个队列为每个主机仅保留最新的那个心跳包。如下图所示:
<img src="https://static001.geekbang.org/resource/image/97/94/97cae81094f213896989158cf9baf594.png" alt="">
这样判断宕机的速度会非常快它的计算量等于实际发生宕机的主机数量。同时接收心跳包并发现新主机的流程因为只需要做一次哈希表查询时间复杂度也只有O(1)。
<img src="https://static001.geekbang.org/resource/image/6b/d9/6ba31e4046ba0f578659bf423e4553d9.png" alt="">
这样,新算法通过**以空间换时间**的思想,虽然使用了更加占用空间的哈希表,并新增了有序队列容器,但将宕机和新主机发现这两个流程都优化到了常量级的时间复杂度。尤其是宕机流程的计算量非常小,它仅与实际宕机服务的数量有关,这就允许我们将宕机判断流程插入到心跳包的处理流程中,以微观上的分时任务实现宏观上的并发,同时也避免了对哈希表的加锁。
## 如何设计高并发架构?
有了核心算法,还需要充分利用服务器资源的架构,才能实现高并发。
一颗1GHZ主频的CPU意味着一秒钟只有10亿个时钟周期可以工作如果心跳服务每秒接收到100万心跳包就要求它必须在1000个时钟周期内处理完一个心跳包。这无法做到因为每一个汇编指令的执行需要多个时钟周期参见[CPI](https://en.wikipedia.org/wiki/Cycles_per_instruction)一条高级语言的语句又由多条汇编指令构成而中间件提供的反序列化等函数又需要很多条语句才能完成。另外内核从网卡上读取报文执行协议分析需要的时钟周期也要算到这1000个时钟周期里。
因此选择只用一颗CPU为核心的单线程开发模式一定会出现计算力不足不能及时接收报文从而使得缓冲区溢出的问题最终导致大量丢包。所以我们必须选择多线程或者多进程开发模式。多进程之间干扰更小但内存不是共享的数据同步较为困难因此案例中我们还是选择多线程开发模式。
使用多线程后我们需要解决3个问题。
第一是负载均衡我们应当把心跳包尽量均匀分配到不同的工作线程上处理。比如接收网络报文的线程基于主机名或者IP地址用哈希算法将心跳包分发给工作线程处理这样每个工作线程只处理特定主机的心跳相互间不会互相干扰从而可以无锁编程。
第二是多线程同步。分发线程与工作线程间可以采用生产者-消费者模型传递心跳包,然而多线程间传递数据要加锁,为了减少争抢锁对系统资源的消耗,需要做到以下两点:
- 由于工作线程多过分发线程(接收心跳包消耗的资源更少),所以每个工作线程都配发独立的缓冲队列及操作队列的互斥锁;
- 为避免线程执行主动切换,必须使用自旋锁,关于锁的选择你可以看[[第 6 讲]](https://time.geekbang.org/column/article/234548)。如下图所示:
<img src="https://static001.geekbang.org/resource/image/27/76/2726b6c9b73325583f8491a822a22476.png" alt="">
第三要解决CPU亲和性问题。从[[第 1 讲]](https://time.geekbang.org/column/article/232351) 我们可以看到CPU缓存对计算速度的影响很大如果线程频繁地切换CPU会导致缓存命中率下降降低性能此时将线程绑定到特定的CPU就是一个解决方案NUMA架构也会对CPU亲和性产生影响这里略过
这样通过上述的多线程架构就可以有效地使用CPU。当然除了CPU内存的使用效率也很重要。[[第2讲]](https://time.geekbang.org/column/article/230221) 中我们提到TCMalloc相比Linux默认的PtMalloc2内存池在多线程下分配小块内存的速度要快得多所以对于心跳服务应当改用TCMalloc申请内存。而且如果心跳包对象的格式已经固定你还可以建立一个心跳包资源池循环往复的使用这进一步减少了构造、销毁心跳包对象所消耗的计算力。
由于服务重启后一个心跳周期内就可以获得所有心跳包,所以并不需要将数据持久化到磁盘上。如果你想进一步了解磁盘优化,可以再看下[[第 4 讲]](https://time.geekbang.org/column/article/232676)。
## 如何选择心跳包网络协议?
最后我们再来看看心跳包的协议该选择TCP还是UDP实现。
网络报文的长度是受限的,[MTU](https://zh.wikipedia.org/wiki/%E6%9C%80%E5%A4%A7%E4%BC%A0%E8%BE%93%E5%8D%95%E5%85%83)Maximum Transmission Unit定义了最大值。比如以太网中MTU是1500字节如果TCP或者UDP试图传送大于1500字节的报文IP协议就会把报文拆分后再发到网络中并在接收方组装回原来的报文。然而IP协议并不擅长做这件事拆包组包的效率很低因此TCP协议宁愿自己拆包详见[[第 11 讲]](https://time.geekbang.org/column/article/239176))。
所以如果心跳包长度小于MTU那么UDP协议是最佳选择。如果心跳包长度大于MTU那么最好选择TCP协议面对复杂的TCP协议还需要解决以下问题。
首先一台服务器到底能同时建立多少TCP连接要回答这个问题得先从TCP四元组谈起它唯一确定一个TCP连接。TCP四元组分别是&lt;源IP、目的IP、源端口、目的端口&gt;其中前两者在IP头部中后两者在TCP头部中。
<img src="https://static001.geekbang.org/resource/image/e0/2f/e05b4dcffa30fe5ec5a3a85511f0db2f.png" alt="">
由于IPv4地址为4个字节参见[[第 7 讲]](https://time.geekbang.org/column/article/235302)、端口为2个字节所以当服务器IP地址和监听端口固定时并发连接数的上限则是2<sup>(32+16)</sup>
当然,这么高的并发连接需要很多条件,其中之一就是增加单个进程允许打开的最大句柄数(包括操作系统允许的最大句柄数/proc/sys/fs/file-nr因为Linux下每个连接都要用掉一个文件句柄。当然作为客户端的主机如果想用足2<sup>16</sup>&nbsp;端口还得修改ip_local_port_range配置扩大客户端的端口范围
```
net.ipv4.ip_local_port_range = 32768 60999
```
其次基于TCP协议实现百万级别的高并发必须使用基于事件驱动的全异步开发模式参见[[第 8 讲]](https://time.geekbang.org/column/article/236921)。而且TCP协议的默认配置并没有考虑高并发场景所以我们还得在以下4个方面优化TCP协议
1. 三次握手建立连接的过程需要优化,详见[[第 9 讲]](https://time.geekbang.org/column/article/237612)
1. 四次挥手关闭连接的过程也需要优化,详见[[第 10 讲]](https://time.geekbang.org/column/article/238388)
1. 依据网络带宽时延积重新设置TCP缓冲区详见[[第 11讲]](https://time.geekbang.org/column/article/239176)
1. 优化拥塞控制算法,详见[[第 12 讲]](https://time.geekbang.org/column/article/239621)。
最后还有一个问题需要我们考虑。网络中断时并没有任何信息通知服务器此时该如何发现并清理服务器上的这些僵死连接呢KeepAlive机制允许服务器定时向客户端探测连接是否存活。其中每隔tcp_keepalive_time秒执行一次探测。
```
net.ipv4.tcp_keepalive_time = 7200
```
每次探测的最大等待时间是tcp_keepalive_intvl 秒。
```
net.ipv4.tcp_keepalive_intvl = 75
```
超时后内核最多尝试tcp_keepalive_probes次仍然没有反应就会及时关闭连接。
```
net.ipv4.tcp_keepalive_probes = 9
```
当然如果在应用层通过心跳能及时清理僵死TCP连接效果会更好。
从上述优化方案可见TCP协议的高并发优化方案还是比较复杂的这也是享受TCP优势时我们必须要付出的代价。
## 小结
这一讲以我实践过的项目为案例,介绍了高并发服务的设计思路。
核心算法对性能的影响最大为了设计出高效的算法我们必须分析出时间复杂度充分寻找、利用已知信息减少算法的计算量。在心跳服务这个案例中利用好心跳包的时序就可以把计算宕机的时间复杂度从O(N) 降为O(1)。
有了好的算法还需要好的架构才能高效地调动系统资源。当摩尔定律在CPU频率上失效后CPU都在向多核发展所以高性能必须充分使用多核的计算力。此时我们需要谨慎设计多线程间的负载均衡和数据同步尽量减少访问共享资源带来的损耗。选择与业务场景匹配的内存池也很重要对于RPS上百万的服务来说申请内存的时间不再是一个忽略项。
选择网络协议时如果消息长度大于MTU那么选择TCP更有利但TCP解决了流控、可靠性等很多问题优化起来较为困难。对于不要求可靠传输长度通常不大的心跳包来说UDP协议通常是更好的选择。
## 思考题
最后,还是留给你点思考题。你遇到过心跳服务吗?它是怎么设计的?还有哪些优化空间?欢迎你在留言区与我探讨。
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

View File

@@ -0,0 +1,10 @@
你好,我是陶辉。
截至今天,本专栏结课已经有两周的时间了,十分感谢你的支持。
那学习了3个多月不知道你有哪些特别想和我说的话呢对于本专栏的内容你还有什么建议或意见为了能更好地了解你的想法以便我后面酌情安排加餐在此希望能得到更多的反馈听到更多的声音。
所以我特别邀请你填写毕业问卷另外今天也是有奖征集的最后1天啦欢迎大家畅所欲言
[<img src="https://static001.geekbang.org/resource/image/73/35/7307786d2cfb778683e6c294ef382235.jpg" alt="">](https://jinshuju.net/f/TSUkPN)

View File

@@ -0,0 +1,10 @@
你好!
截至今天,本专栏有奖收集毕业问卷阶段就结束了,十分感谢你的参与。现在我们来公布一下获奖用户名单。
在这里,我首先要感谢各位同学给我们的反馈,你们的声音可以促使我们精益求精。在这些反馈中,我们看到了很多非常有价值的信息,也收获了很多的支持与肯定。在此,我们精选出了反馈最为具体、丰富,最有实际价值的 5 位用户,送出“价值 99 元的极客时间课程阅码”,或者“极客时间原创 Be Curious 效率手册”。中奖名单如下:
<img src="https://static001.geekbang.org/resource/image/61/8c/618e32c255bef7114c8069c5761bf58c.jpg" alt="">
恭喜这 5 位同学,也再次感谢所有参与调研的同学。希望大家今后还能多多支持,给予宝贵意见。

View File

@@ -0,0 +1,53 @@
<audio id="audio" title="结束语 | 从业 IT 20年后我将最看重什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/10/388a42f4627692c3a8079ddd65508b10.mp3"></audio>
你好,我是陶辉。
经过3个多月的更新咱们这门课今天就正式结课了很感谢你的一路相伴我有收获很多的分享和故事这份信任弥足珍贵。
走到今天我入行已经有20年了那作为结束语最后我特别想和你聊聊我的一些心得体会就说说我最看重的两大能力吧希望能给你的未来发展带来一些正反馈
每次聊天提到程序员大部分人的脑海中总会跳出两个词996与35岁危机它们都反应了1个现实程序员的竞争压力太大了。**其实老板是无法逼迫你996的只有大量虎视眈眈盯着你职位、薪酬包的竞争者才能让你心甘情愿的加班让你为了度过35岁危机而不停地试图突破天花板**为什么程序员的供给量这么大呢?这既来源于中国庞大的人口基数,也因为资本、人才密集的互联网行业,为年轻人提供了罕见的高薪资、高成长机会。
程序员的平均薪资远高于其他职业这造成大量职场新人转行入互联网从Javascript、Android、Python等工程师做起这些语言形成闭环的路径更短入门更快寻求更好的个人发展。我有一些半路转行的朋友经常见到他们凌晨还在提交代码痛苦的转行过程让他们的自我驱动力无比强大。
各类院校也在为这个职业输送大量人才。在我上大学那会,只有计算机科学与技术这一个编程专业。现在,软件工程、网页设计各类专业层出不穷,而且,计算机课程已经成为各理工专业必修的基础课。
所以程序员普遍年轻35+的我已经是我们团队年纪最大的程序员了,这在其他行业实在是不可思议的事!另一方面,互联网仍然是一个朝阳行业,变化是永恒的主题。在开源盛行的当下,注定会有层出不穷却又不太稳定的新框架,只有深入的学习才能用好、改进它。所以,我们常常会让自己“忙”起来、“学”起来,这样似乎可以忘却焦虑。
**那焦虑是什么呢?在我看来,就是对未来的自己是否仍具备竞争力的不确定感。**如果只是在不停地学习新语言、新框架那么35岁的你竞争力一定不如25岁的你因为对框架的熟练度是不值几倍薪资差距的。我认为有两个能力可以消除这种不确定性它们不会因为新技术迭代每次都让你数据清零后重新开始。
## 构建知识体系最需要什么?
**首先,是不局限于一招一式、构建知识体系的能力。**当你还未对某个领域形成知识体系时,只能解决曾经遇到过的相似问题,或者仅涉及单一知识点、能够从网上查询到答案的简单问题。而基于新技术做架构设计,或者定位涉及多个系统的复杂问题时,知识体系是最值得依赖的灯塔,它能指引你前进的方向。
知识体系可以将散落在脑海中的知识,通过逻辑联系在一起,形成庞大的网络。**知识点之间的联系线条越多,网络就越健壮,越能应对不确定性;网络越大,覆盖的领域越广,你能解决问题的价值就越高!**所以,我们需要结网的能力!
比如你学习了某个人脸识别框架的用法通过API把它集成到系统中此时该技术与已有知识体系是割裂的。在你了解CNN网络知道它只不过是一种聚类函数后就能通过数学知识把它联结到知识网络中在了解到密集浮点运算下保持精度的方案与主流IEEE-754方案间的差别后你又可以从数值计算维度上与常用的编程语言关联起来从GPU、CPU对并行计算的设计差别上你还可以在计算体系架构这条线上增强知识体系从分布式模型训练系统中找到分而治之的思想你就可以从算法上连接网络等等。
概括下的话我认为构建知识体系最需要的其实是底层知识两个看似无关的技术可以再往下看一层找找它们共同的理论基础建立逻辑关联。我认为下面3个底层知识对结网的帮助最大
- 数据结构与算法,我推荐你精读《算法导论》这本书,程序是由数据与算法构成的,这条线几乎可以连接所有技术点。
- 计算机网络知识,它可以连接所有涉及互联网的技术,这也是我推出[《Web协议详解与抓包实战》](https://time.geekbang.org/course/intro/100026801)这门课的初衷。
- 最后是操作系统知识,毕竟所有的软件都需要通过操作系统才能操作硬件。
另外咱们在给知识体系添砖加瓦时一定要注意知识的正确性否则网络越密后续修复成本就越高。我一般建议你尽量去源头寻找第一手知识虽然有时这并不容易。比如学习HTTP/3协议时我们只能去看那5份RFC文档但RFC文档的结构设计是作为参考手册使用的它并不适合首次学习此时我们还可以借鉴一些权威高手基于一手知识分享的“二手”文章这能让你更快地看懂RFC。
## 好的“表达力”能为你插上翅膀
**第2个最能对抗“焦虑”的是表达力。**做技术的同学通常对跟自己技术水平差不多,但因为表达力更好、从而拥有更高职位的同学不屑一顾,我曾经也是这样,总觉得咱靠的是“真才实料”,不是嘴皮子。这是一个很大的误区,它与你的职业发展密切相关。
如果团队里有3位同学1位技术最好却不擅言辞1位擅长管理、调动团队氛围1位擅长表达你觉得谁的职业发展最好呢其实是最后那位因为当团队向上级汇报工作时他最容易让团队的工作得到认可从而获取更多的资源把事情做成使人人都有好处。久而久之他就会获得更多的提拔机会。
我想你多半遇到过那种沟通起来特“费劲”的人,说话总是找不到重点,这样他就只能做一些简单的、不需要协作的工作。现代社会是需要高度协作的,如果没办法说清楚你的工作成果,你的价值就会大大缩水。当然,好的表达力不是无中生有,也不是能说会道,毕竟你面对的都是专业人士。
在职场中,由于沟通对象时间有限,所以采用金字塔方式表达效果最好。比如,当我向老板汇报工作时,我会在第一时间讲结论,然后再按照重要性顺序讲论据。否则,他同时要处理的事务比我广得多,如果我不能快速让他抓住重点,就很容易失去这次沟通机会。再比如我写这个专栏时,大家同时订阅了那么多课程,时间非常珍贵,我必须在每节课起始就开宗明义地给出场景,把各种对立面引发的冲突列出来,如果它能聚焦你的视线,我就会在正文中层层递进地讲下去,每一段总是为了引出下一段,防止在碎片化阅读时代里丢掉你的注意力。
那如果你想学习金字塔表达方式的话,我十分建议你精读《金字塔原理》这本书,这也是写作本专栏时,我的编辑推荐给我的,对我的帮助非常大,现学现卖推荐给你。
总结而言,我希望你拥有“结网能力”。构建广泛、结实的知识网络,可以帮助你提升竞争力、减轻“焦虑”,面对新技术时不用清零重来;但当你无法将新学的知识纳入已有知识体系时,不妨把眼光放低点,从底层技术中找找关联。 我还希望你拥有“表达能力”。再硬核的知识体系也需要通过优秀的表达力,转换为动听的语言和精练的文字,再通过互联网跨越时空,让你跳出公司,在整个行业中提升影响力、竞争力。
希望这2种能力能帮助你在大的技术洪流中站稳脚跟从无数的竞争对手中脱颖而出。最后祝你工作顺利也希望这门课能成为你抵抗焦虑的武器之一。
最后的最后我还为你准备了一份毕业问卷希望你能用2分钟的时间填写一下。大胆表达自己期待听到你的声音
[<img src="https://static001.geekbang.org/resource/image/05/ca/05d7e6b6ccff9f23968349fff4a5d2ca.jpg" alt="">](https://jinshuju.net/f/TSUkPN)