mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-25 18:36:48 +08:00
mod
This commit is contained in:
141
极客时间专栏/系统性能调优必知必会/分布式系统优化/19 | 如何通过监控找到性能瓶颈?.md
Normal file
141
极客时间专栏/系统性能调优必知必会/分布式系统优化/19 | 如何通过监控找到性能瓶颈?.md
Normal 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->B、A->C、A->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 > out.perf
|
||||
|
||||
```
|
||||
|
||||
再然后,需要汇聚函数调用栈,转化为FlameGraph生成火焰图的数据格式:
|
||||
|
||||
```
|
||||
FlameGraph/stackcollapse-perf.pl out.perf > out.folded
|
||||
|
||||
```
|
||||
|
||||
最后一步,生成SVG格式的矢量图片:
|
||||
|
||||
```
|
||||
FlameGraph/flamegraph.pl out.folded > 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火焰图,看看能带给你哪些新的启发?期待你的总结。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
97
极客时间专栏/系统性能调优必知必会/分布式系统优化/20 | CAP理论:怎样舍弃一致性去换取性能?.md
Normal file
97
极客时间专栏/系统性能调优必知必会/分布式系统优化/20 | CAP理论:怎样舍弃一致性去换取性能?.md
Normal 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之间物理距离很遥远,那么网络同步就变得很昂贵。这两种不同场景下,你是如何考虑一致性模型的?它与性能之间有多大的影响?期待你的分享。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/21 | AKF立方体:怎样通过可扩展性来提高性能?.md
Normal file
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/21 | AKF立方体:怎样通过可扩展性来提高性能?.md
Normal 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轴扩展的预期?期待你的总结。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/22 | NWR算法:如何修改读写模型以提升性能?.md
Normal file
103
极客时间专栏/系统性能调优必知必会/分布式系统优化/22 | NWR算法:如何修改读写模型以提升性能?.md
Normal 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 > N,同1条数据的读、写操作就不能并发执行,这样客户端就总能读到最新写入的数据。特别是当W > 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: Amazon’s 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> CONSISTENCY ONE
|
||||
Consistency level set to ONE.
|
||||
cqlsh> 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 > 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> CONSISTENCY QUORUM
|
||||
Consistency level set to QUORUM.
|
||||
cqlsh> 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 > 存储节点数N,特别是W > N/2时,就能使去中心的分布式系统获得强一致性。
|
||||
|
||||
支持上万节点的Cassandra数据库,就使用了NWR算法来保持一致性。当然,Cassandra支持多种一致性模型,当你需要更强劲的性能时,你可以令R + W < N,当业务变化导致需要增强系统的一致性时,你可以实时地修改R、W。Cassandra也支持跨数据中心部署,此时的一致性模型更为复杂,但仍然将NWR算法作为实现基础。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后给你留一道讨论题。你还知道哪些有状态服务使用了NWR算法吗?它与NWR在Cassandra中的应用有何不同?欢迎你在留言区中分享,也期待你能从大家的留言中总结出更一般化的规律。
|
||||
|
||||
感谢你的阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。
|
||||
150
极客时间专栏/系统性能调优必知必会/分布式系统优化/23 | 负载均衡:选择Nginx还是OpenResty?.md
Normal file
150
极客时间专栏/系统性能调优必知必会/分布式系统优化/23 | 负载均衡:选择Nginx还是OpenResty?.md
Normal 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技术提供的SDK,Lua代码才真正可以处理请求,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间的差别,也欢迎把它分享给你的朋友。
|
||||
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/24 | 一致性哈希:如何高效地均衡负载?.md
Normal file
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/24 | 一致性哈希:如何高效地均衡负载?.md
Normal 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亿条用户数据的有状态服务,如果采用关键字模10(key%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="">
|
||||
|
||||
你觉得上述方案与一致性哈希相比,有何优劣?各自适用的场景又是什么?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你掌握了一致性哈希算法这个新工具,并能够用它提升分布式系统的运行效率,也欢迎把今天的内容分享给你的朋友。
|
||||
148
极客时间专栏/系统性能调优必知必会/分布式系统优化/25 | 过期缓存:如何防止缓存被流量打穿?.md
Normal file
148
极客时间专栏/系统性能调优必知必会/分布式系统优化/25 | 过期缓存:如何防止缓存被流量打穿?.md
Normal 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>
|
||||
|
||||
你好,我是陶辉。
|
||||
|
||||
这一讲我们将对一直零散介绍的缓存做个全面的总结,同时讨论如何解决缓存被流量打穿的场景。
|
||||
|
||||
在分布式系统中,缓存无处不在。比如,浏览器会缓存用户Cookie,CDN会缓存图片,负载均衡会缓存TLS的握手信息,Redis会缓存用户的session,MySQL会缓存select查询出的行数据,HTTP/2会用动态表缓存传输过的HTTP头部,TCP Socket Buffer会缓存TCP报文,Page Cache会缓存磁盘IO,CPU会缓存主存上的数据,等等。
|
||||
|
||||
只要系统间的访问速度有较大差异,缓存就能提升性能。如果你不清楚缓存的存在,两个组件间重合的缓存就会带来不必要的复杂性,同时还增大了数据不一致引发错误的概率。比如,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="图片参见wiki:https://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缓存使用时,可以打开合并回源功能,减轻上游压力。在上游应用宕机后,还可以使用过期缓存为用户提供降级服务。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。缓存并不总是在提高性能,回想一下,在你的实践中,有哪些情况是增加了缓存服务,但并没有提高系统性能的?原因又是什么?欢迎你在留言区与大家一起分享。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你加深了对缓存的理解,也欢迎把它分享给你的朋友。
|
||||
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/26 | 应用层多播:如何快速地分发内容?.md
Normal file
92
极客时间专栏/系统性能调优必知必会/分布式系统优化/26 | 应用层多播:如何快速地分发内容?.md
Normal 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 -> 路由器A -> 路由器B -> 主机3 -> 路由器B -> 主机4,既多出了主机3、路由器B这两个传输环节,而且进入主机3后,协议栈的处理深度也增加了。
|
||||
|
||||
最后,单次传输路径中引入了功能复杂的主机,相比仅由网络设备参与的IP多播,可靠性、稳定性也降低了。
|
||||
|
||||
说完缺点,我们再来看应用层多播的优点。
|
||||
|
||||
- 首先,它回避了IP多播的问题,无须改变现有组网环境,也不需要管理组播IP地址,立刻就可以应用在当下的生产环境中;
|
||||
- 其次,在数以万计的大规模集群下,单一发布源很容易被流量打爆,进而导致分发流程停止,应用层多播可以避免这一问题;
|
||||
- 再次,通过应用层节点的接力分发,整个传输带宽被大幅度提高了,分发速度有了数量级上的飞跃;
|
||||
- 最后,如果分发集群跨越不同传输成本的网络(比如多个区域IDC构成的集群),在应用层也很容易控制分发策略,进而减少高成本网络的数据传输量,提升经济性。
|
||||
|
||||
所以,综合来说,**集群规模越大,应用层多播的优势也越大。**实际上十多年前,我们在使用BT、迅雷下载时,就已经接触到应用层多播协议了,接下来我们结合2个服务器端的案例,看看多播协议的实现与应用。
|
||||
|
||||
## 应用层多播协议是如何工作的?
|
||||
|
||||
其实,应用层多播主要是指一种[P2P(Peer 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逻辑链路层实现了。你对未来多播协议的发展又是如何看的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你了解到应用层的多播协议,而通过它可以大幅度提升分布式集群的网络传输效率的话,也欢迎你把今天的内容分享给你的朋友。
|
||||
85
极客时间专栏/系统性能调优必知必会/分布式系统优化/27 | 消息队列:如何基于异步消息提升性能?.md
Normal file
85
极客时间专栏/系统性能调优必知必会/分布式系统优化/27 | 消息队列:如何基于异步消息提升性能?.md
Normal 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="">
|
||||
|
||||
- 最后,如果通过监控发现消息的消费能力小于生产能力,那就必须及时扩容消费端,或者降低消息的发布速度,否则消息就会积压,最终导致系统不可用。
|
||||
|
||||
接下来,我们再来看消息队列的QoS(Quality 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语义。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,留给你一道讨论题。你在实践中使用过消息队列吗?它主要帮你解决了哪些问题?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课对你有所帮助,也欢迎把今天的内容分享给你的朋友。
|
||||
89
极客时间专栏/系统性能调优必知必会/分布式系统优化/28 | MapReduce:如何通过集群实现离线计算?.md
Normal file
89
极客时间专栏/系统性能调优必知必会/分布式系统优化/28 | MapReduce:如何通过集群实现离线计算?.md
Normal 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个数字上使用归并排序算法进行排序的流程。我们将数组递归地进行3(log8)轮对半拆分后,每个子数组就只有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] "GET /login?userid=101 HTTP/1.1" 200 56 "-" "curl/7.29.0"
|
||||
|
||||
```
|
||||
|
||||
你可以通过正则表达式取出客户端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模式完成的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给你的朋友。
|
||||
91
极客时间专栏/系统性能调优必知必会/分布式系统优化/29 | 流式计算:如何通过集群实现实时计算?.md
Normal file
91
极客时间专栏/系统性能调优必知必会/分布式系统优化/29 | 流式计算:如何通过集群实现实时计算?.md
Normal 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成本很高,因此大家致力于使用一套系统同时解决这两个问题。你认为这种解决方案是如何实现的?你又是如何看待流式计算发展方向的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给你的朋友。
|
||||
153
极客时间专栏/系统性能调优必知必会/分布式系统优化/30 | 如何权衡关系数据库与NoSQL数据库?.md
Normal file
153
极客时间专栏/系统性能调优必知必会/分布式系统优化/30 | 如何权衡关系数据库与NoSQL数据库?.md
Normal 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 > 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个问题。
|
||||
|
||||
首先,内存中的数据结构非常多样,难以直接映射到行列交汇处的单一值上。不过,**这个问题可以通过**[ORM(Object-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与关系数据库时是如何考虑的?欢迎你在留言区与大家一起探讨。
|
||||
|
||||
感谢阅读,如果你觉得这节课让你有所收获,也欢迎你把今天的内容分享给身边的朋友。
|
||||
Reference in New Issue
Block a user