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与关系数据库时是如何考虑的欢迎你在留言区与大家一起探讨。
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给身边的朋友。