你或许听说过“架构”或“系统设计”这两个术语,它们在开发者的求职面试中经常出现,尤其是大型科技公司的招聘人员喜欢提这方面的问题。
这篇教程将深入软件架构的基本概念,帮你做好系统设计面试的准备。
因为系统设计是一个庞大的主题,所以这篇文章并不会面面俱到。但如果你是一名初中级开发者的话,这应该能为你奠定坚实的基础。
你可以从这里深入挖掘其它资源。我已经在文章底部列出了一些我喜爱的资源。
我已经将这篇教程按照主题划分成了很多小模块,我建议你将它加入到书签。我发现 间隔性学习与重复 是获取知识的宝贵工具,真让人难以置信。我已将本教程设计成很多小片段,以便你进行间隔性重复记忆。
第一节:网络与协议(IP、DNS、HTTP、TCP 等)
第二节:存储、延迟与吞吐量
第三节:可用性
第四节:缓存
第五节:代理
第六节:负载均衡
第七节:一致性哈希
第八节:数据库
第九节:领导选取
第十节:轮询、流、套接字
第十一节:端点保护
第十二节:消息与发布-订阅
第十三节:必备小知识
咱们开始吧!
第一节:网络与协议
“协议(protocol)”是一个很花哨的词,它在英语中的含义与在计算机科学中的含义截然不同。它表示一套管理事物的规章制度,一种“官方步骤”或“做事情时必须采用的官方做法”。
为了让人们连接到彼此通信的机器和代码,他们需要一个可以在其上进行通信的网络。但是通信也需要一些规则、结构和协商好的步骤。
因此,网络协议(network protocols)是管理给定网络上机器与软件之间如何通信通信的协议。我们热爱的万维网就是网络的一个例子。
你可能听过因特网时代中一些最常见的网络协议,比如 HTTP、TCP/IP 等等。让我们将它们分解成基础构造块吧。
因特网协议
把因特网协议(IP,Internet Protocol)视为是协议层的基础吧,它是一个基础协议,指导我们如何实现几乎所有的因特网内通信。
因特网协议上的消息(message)通常以“数据包”的形式进行通信。数据包(packet)是一小团信息(2^16 字节),它的核心结构包含两节:协议头(Header)和数据(Data)。
协议头包含与数据包及其内部数据的有关的元数据,比如源 IP 地址(数据包源自哪里)和目的 IP 地址(数据包的目的地)。很明显,这是将信息从一个点发送到另一个点的基础——你需要“从哪来”的地址和“去往哪”的地址。
每个连接到使用 IP 协议通信的 计算机网络的设备都会被赋予一个数字标签,这个标签就是 IP 地址。IP 地址分为公有地址和私有地址,当前有两个版本 IP 协议。新版本被称为 IPv6,它正被逐渐采用,因为 IPv4 地址快耗尽了。
我们在这篇文章中要考虑的其它协议都建立在 IP 之上,就像你最最喜欢的软件语言有建立在它上面的库和框架一样。
传输控制协议
传输控制协议(TCP,Transmission Control Protocol)是一个建立在 IP 之上的实用程序。通过阅读我的文章,你可能知道:我坚信你若要真正理解某个东西是做 什么 的,你就要先理解它 为什么 被发明出来。
创建 TCP 是为了解决 IP 的一个问题。通过 IP 传输的数据通常在多个数据包中发送,由于每个数据包都相当小(2^16 字节),所以多个数据包可能出现:(A) 数据包丢失;(B) 乱序。因此导致传输数据损坏。 TCP 通过保证数据包的 有序传输 解决了这些问题。
因为是建立在 IP 之上的,所以 TCP 数据包除了有 IP 头外,还有 TCP 头。这个 TCP 头包含数据包的顺序和数据包的数量等信息。这保证了另一端接收到的数据是可靠的。TCP 通常因建立在 IP 之上而被称为 TCP/IP。
TCP 需要在传输数据包之前建立源端与目的端之间的连接,这是通过“握手”完成的。连接本身是通过使用数据包建立的:源端告知目的端它想打开一个连接,目的端表示同意,然后一个连接就打开了。
这实际上就是服务器“监听”某个端口时发生的事情——在它开始监听前,会先进行一次握手,然后连接被打开(监听开始)。类似地,连接的一端给另一端发送一个打算关闭连接的消息,这就会终止该连接。
超文本传输协议
超文本传输协议(HTTP,Hyper Text Transfer Protocol)是一种建立在 TCP/IP 之上的抽象,它引入了一个被称为请求-响应模式非常重要的模式,专门用于客户端-服务端交互。
客户端通常是请求信息的机器或系统,而服务器是以信息进行响应的机器或系统。浏览器是客户端,而 Web 服务器是服务器。当一台服务器向另一台服务器请求数据时,前者是客户端,后者是服务器(赘述,我知道)。
所以这个请求-响应环路在 HTTP 下有自己的规则,这也标准化了因特网间的信息传输方式。
在这层抽象上,我们通常不需要过于担心 IP 和 TCP。然而,HTTP 中的请求与响应不仅有头部,还有主体部分。它们包含了能被开发者设置的数据。
HTTP 请求与响应可以被看作键-值对形式的消息,它们和 JavaScript 中的对象以及 Python 中的字典非常相似,但又有所不同。
下图展示了 HTTP 报文的内容和 HTTP 请求与响应消息中的键-值对。
同 HTTP 一起的还有一些“动词”或“方法”,它们是一些指令,告诉你将要执行何种操作。例如,常见的 HTTP 方法有“GET”、“POST”、“PUT”、“DELETE”和“PATCH”,但远不止这些。找一下上图中起始行中的 HTTP 动词。
第二节:存储、延迟与吞吐量
存储
存储(storage)和保存信息有关。你编写的任何应用、系统或服务都需要保存和检索数据,这正是存储的两个基本功能。
但是存储并不只和保存数据有关,它还涉及到数据的取出。我们使用数据库(database)来实现存储。数据库是一个软件层,它帮助我们保存和检索数据。
保存(storing)和检索(retrieving)这两种主要的操作类型又被称为放置(set)和获取(get)、存储(store)和取出(fetch)、写(write)和读(read),等等。要与存储交互,你需要经过数据库,它是你执行这些基础操作的中间人。
“存储”一词有时能让我们傻傻地从物理的角度思考它。如果我把我的自行车“存储”在仓库,我就能预料到下次打开仓库时它就在那里。
但是,计算机世界中有时并不会这样。存储可以大体分为两种类型:“内存(Memory)”存储和“磁盘(Disk)”存储。
在这两种类型中,磁盘存储往往更稳健,也更具“永久性”(并不是真的永久(permanent),所以我们经常使用“持久化的(persistent)”存储取而代之)。磁盘存储是一种持久化存储,这意味着只要你把东西保存到磁盘,不管断电,还是重启服务器,那些数据都将“持久保存”,不会丢失。
然而,如果你让数据驻留在“内存”中,数据通常会在关机、重启或断电时被擦除。
你每天都在使用的计算机同时拥有这两种类型的存储。你的硬盘是“持久化的”磁盘存储,而你的 RAM 是瞬时的内存存储。
在服务器上,如果你正在记录的数据只在该服务器的会话(session)中有用,那么将它保存在内存中是合理的。这种方式比将东西写入到持久化数据库更快,更实惠。
例如,单个会话可能指用户登录,使用你的网站。在用户退出之后,你可能并不需要紧紧抓住这次会话期间收集到的数据不放。
但是,你会把你任何你想留住的数据(比如购物车历史)放到持久化的磁盘存储中。这样,你可以在用户再次登录时访问那些数据,给用户提供一个无缝的使用体验。
好了,这些似乎非常简单和基础,但实际上并非如此。这是个入门。存储可以变得非常复杂,如果你看一看存储产品和解决方案的规模,很快就会头晕目眩了。
这是因为不同的使用场景需要不同类型的存储。为你的系统选择正确的存储方式的关键取决于很多因素和应用的需求,还有用户如何与之交互。其它的因素包括:
- 数据的形状(结构),或者
- 数据需要具有的何种可用性(对你的存储来说,什么级别的停机时间是可以出现的),或者
- 可伸缩性(你需要怎样的数据读写速度,以及这些读写是并发(同时)进行的还是顺序进行的)等等,或者
- 持久性——如果你使用分布式存储处理停机,那么各存储之间的数据一致性如何?
这些问题和结论需要你仔细权衡。一致性的重要程度高于速度吗?你需要数据库抗住每分钟数百万次操作,还是仅用于每晚更新?我将会在后续章节中讨论这些概念,如果你不知道它们是什么,也不用担心。
延迟
随着你在支撑前端应用方面的系统设计经验的增加,你会不断听到“延迟”或“吞吐量”这两个术语。总的来说,它们对应用和系统的使用体验和性能至关重要。这些术语的使用普遍都倾向于超出预期范围或脱离上下文,但让我们来解决掉这个问题吧。
延迟(Latency) 只是对持续时间的度量。什么是持续时间呢?某个动作的持续时间是指完成某事或产生结果所经历的时间。例如:对于从系统中的一个地方移动到另一个地方的数据来说,你可以将持续时间看成延迟(lag),也可以简单地把它看成是完成某个操作所花费的时间。
延迟最常见的理解就是“往返”的网络请求——前端网站(客户端)从给服务器发送查询,到收到服务器返回的响应所花费的时长。
当加载一个网站时,你想要加载过程尽可能的快、整个过程尽可能的流畅。换句话说,你想要的是 低 延迟。快速查找就意味着低延迟。所以在数组中(高延迟,因为你需要迭代数组中的每个元素,找出你想要的那一个)寻找一个值比在哈希表中(低延迟,因为你只需要通过键就可以在“常量”时间内获得数据,不需要进行迭代)更慢。
类似地,从内存读取数据比从磁盘读取数据要快得多(阅读更多)。但是这两种方式都存在延迟,你的需求会决定为何种数据选择何种类型的存储。
这样看来,延迟是速度的对立面。你想要更高的速度,就是想要更低的延迟。速度(尤其是像通过 HTTP 这样的网络调用)也由距离决定。所以,伦敦到另一座城市的延迟 将受该城市与伦敦之间的距离的影响。
想象一下,如果你要设计一个避免 ping 远程服务器的系统,但是为你的系统将数据保存到内存又不太可行。这些都是权衡点,它们让系统设计变得复杂、充满挑战,并且趣味十足!
例如,新闻网站比起加载速度可能更看重正常运行的时间和可用性,而多人游戏可能要求可用性和超低延迟。这些需求将决定基础设施的设计和相关投资,从而支持系统的特殊需求。
吞吐量
吞吐量(Throughput)可以被理解为机器或系统的最大容量。它经常在工厂中使用,用于计算装配线一个小时或一天或其它时间计量单位内能完成的工作量。
例如,对一条每小时能够组装二十辆汽车的装配线来说,每小时二十辆汽车就是它的吞吐量。在计算机中,吞吐量指的是单位时间内传送的数据量。所以一个 512 Mbps 的连接就是一种吞吐量的度量方式——512 Mb(兆字节)每秒。
现在想象一下 freeCodeCamp 的服务器。如果它每秒收到一百万个请求,而它只能处理八十万个请求,它的吞吐量就是八十万每秒。你最终可能会用二进制位(bit)的形式而不是请求数量来衡量吞吐量,所以它将会是 N 位每秒。
这个示例中有一个 瓶颈(bottleneck),因为服务器一秒内最多只能 N 位的数据,但是请求的数据比这个值要高。瓶颈因此成为了系统的约束,系统能达到的最快速度就是它的 最慢瓶颈。
如果一台服务器每秒可以处理一百位,另一台服务器每秒可以处理一百二十位,第三台服务器每秒只能处理五十位,那么整个系统将会以 50bps 的速度进行处理,因为它就是整个系统的约束——它阻挡住了系统中其它服务器的速度。
所以,在瓶颈之外的任何地方增加吞吐量都可能会徒劳无功,你可以先在 最低瓶颈 处增加 吞吐量。
为了增加吞吐量,你可以购买更多的硬件(横向伸缩),也可以增加现有硬件的容量和性能(纵向伸缩),还可以采用另外几种方式。
有时候,增加吞吐量可能只是一个临时的解决方案,所以一个好的系统设计师会全面考虑伸在伸缩给定系统的最佳方式,包括分离请求(或任何其它形式的“负载”),并把他们分发到其它资源上,等等。要牢记的关键点就是:什么是吞吐量,约束或瓶颈又是什么,这些约束和瓶颈是如何影响系统的。
固定延迟和吞吐量并不是独立的、通用的解决方案,它们也不互相关联。它们在整个系统中都有影响和需要考虑的地方,所以了解整个系统以及随时间加入到系统中的需求的本质非常重要。
第三节:系统可用性
软件工程师的目标是建立可靠系统。可靠系统始终能够满足用户需要,任何时候只要用户有需要,都会被满足。可靠性(reliability)的一个关键部分是可用性(availability)。
将可用性看成系统的弹性(resiliency)是很有帮助的。如果系统足够稳健,能够处理好网络、数据库和服务器等中的故障,就可以被看成是一个容错(fault-tolerant)系统——容错让系统变得可靠。
当然,从多种意义上来说,系统其各个部件的总和,如果可用性关系到终端用户在网站或应用上的使用体验,那么每个部件都需要是 高可用的。
量化可用性
为了量化一个系统的可用性,我们计算给定时间段内系统主要功能和操作处于可用状态下的时长(正常运行时间)所占的百分比。
大多数关键业务系统都需要具有近乎完美的可用性。那些支持具有尖峰和低谷的高可变需求和负载的系统,在非高峰时期的可用性可以稍微低一点。
这一切都取决于系统的使用和性质。但是一般来说,即便是那些有着不变的需求或只“按需”得到保障的系统也需要有高可用性。
想想一个你用来备份照片的网站。你并不总是需要来这个网站检索数据——它主要是用来为你存储东西的。你还是希望每次登录网站时它都是可用的,哪怕只是下载一张照片。
一种不同类型的可用性可以放在像黑色星期五或网络星期一这样的大型电商购物日中进行理解。在这些特定的日子中,需求猛涨,成千上万的用户尝试同时访问订单。这就需要极其可靠和高度可用的系统设计来支撑那些负载了。
高可用性的一个商业原因很简单:网站的任何停机时间都会导致金钱的损失。并且,这会对网站的声誉造成非常恶劣的影响。例如,在一个服务是被 其它 业务用来提供服务的场景中,如果 AWS S3 挂了,那么包括 Netflix 在内的很多公司都会遭殃,这可 不是什么好事儿。
所以上线时间对成功尤为重要。值得牢记的一点是:商业可用性数字是根据年度可用性计算的,所以 0.1% 的停机时间(即 99.9% 的可用性)就是 一年 8.77 小时。
因此,上线听起来时间极高。看见像 99.99% 上线时间(每年的停机时间仅为 52.6 分钟)的东西是很普遍的,这就是为什么现在普遍使用术语“nines”;来指代正常运行时间——担保正常运行时间中(9 的个数)。
当今世界中,大规模或关键服务宕机是不可接受的。这就是为何现在将“five nines”看成了理想的可用性标准,因为它表示 每年 的停机时间只有五分钟多一点。
服务级别协议
为了使得在线服务具有竞争力并满足市场的期望,在线服务提供商通常会提供一个服务级别协议/保证(Service Level Agreements/Assurances)。它们是一系列保证的服务级别指标,99.999% 的正常运行时间就是其中之一,它也经常作为高级订阅服务的一部分。
至于数据库和云服务提供商,如果客户的核心用途可以证明了该指标是符合期望的,它甚至可以在试用或免费套餐中提供。
在很多情况下,如果未能满足 SLA,客户会因提供商未能满足保证而得到信用或其它形式的补偿。Google's SLA for the Maps API 就是一个例子。
SLA 因此成为了设计系统时整个商业和技术要考虑的关键部分。考虑可用性是否是系统某个部分的关键需求,以及系统的哪些部分需要具有高可用性尤其重要。
设计高可用系统
在设计一个高可用(HA,high availability)系统时,你需要减少或消除“单点故障”。单点故障是系统中的一个部件,它的故障会导致系统丢失可用性。
你可以通过在设计系统时引入“冗余”来去除单点故障。冗余(redundancy)就是为对高可用性有影响的关键元素准备一个或多个替代品(即备份)。
所以,如果你的应用要求用户在使用前必须得到认证,但你又只有一个认证服务和后端,然后它挂了,那么由于它是单点故障,你的系统就没法被使用了。通过准备两个或多个处理认证的服务,你添加了冗余,消除(或减少)了单点故障。
因此,你需要理解并将你的系统拆分为各个部件。找出最有可能导致单点故障的那些部件(那些无法容忍这种错误的部件),以及那些可以容忍错误的部件。因为高可用工程需要进行权衡,而有些权衡可能会在时间、金钱和资源方面非常昂贵。
第四节:缓存
缓存(caching)是一项用于提升系统性能的技术,非常基础,也很容易理解。它帮助降低系统中的“延迟”。
在我们的日常生活中,缓存在大多数时候都是用作一种常识。如果我们住在超市的隔壁,我们还是会想要购买一些基本用品,并将它们保存在冰箱或者柜橱中,这就是缓存。我们总是可以在每次想要食物的时候走出门,来到隔壁,然后购买它们——但是如果储藏室或冰箱中有这些东西的话,我们就减少了做东西吃的时间,那就是缓存。
常见缓存场景
类似地,在软件方面,如果我们经常依赖某些数据,我们就会想把那些数据缓存起来,以便应用可以执行得更快。
从内存中检索数据比磁盘要快的多,因为网络请求中存在延迟。实际上,很多网站都使用 CDN 缓存数据(尤其是那些很少改变的内容),以便为最终用户提供更快的服务,降低后端服务器的负载。
缓存的另一个使用场景就是后端需要进行一些计算密集型和耗时的工作。缓存之前的结果可以让你在线性 O(N) 到常量 O(1) 的时间内进行查找,非常有帮助。
同样地,如果你的服务器需要进行多次网络请求和 API 调用才能拿到响应请求所需要的完整数据,缓存数据就能帮你减少网络调用的次数,还有延迟。
如果你的系统有客户端(前端)、服务器和数据库(后端),那么缓存可以被放到客户端上(比如浏览器缓存),也可以放到客户端和服务器之间(比如 CDN),还可以放到服务器本身。这将减少去往数据库的网络调用。
所以缓存可以位于系统中的多个位置或多个级别,包括硬件(CPU)级别。
处理过期数据
你可能已经注意到了上面的示例对“读”操作的处理是隐式的。写操作在主要原则上也没什么不同,只是加了以下的考虑:
- 写操作要求缓存与数据库保持同步
- 这可能会增加复杂度,因为有更多的操作要执行,对未同步或“过期”数据的处理需要仔细分析
- 新的设计原则可能需要实现对同步的处理——它应该是同步进行,还是异步进行?如果异步进行,那么时间间隔取多大?
- 数据“驱逐”或更新和数据的刷新,从而保证缓存数据是最新的。这包括像 LIFO、FIFO、LRU 和 LFU 这样的技术。
所以让我们用一些概括性的、非约束性的结论结束这一节吧。通常来说,在保存静态或不经常改变的数据时,以及变化源很有可能是单个操作而不是用户产生的操作时,缓存表现最好。
在数据的一致性和新鲜度非常关键的场合下,缓存可能就不是最优的解决方案了,除非系统中有一个一个部件在高效地刷新着缓存,并且刷新间隔还不会反过来影响到应用的用户体验。
代理
代理。啥?我们中的很多人都听说过代理服务器(proxy servers)。我们或许已经在一些自己的 PC 或 Mac 软件上见过关于添加和配置代理服务器的配置项,或者是一些关于如何“通过代理”访问的配置。
所以让我们来看看这个相对简单而又被广泛使用的的重要技术吧。英语中的代理一词与计算机中的代理毫不相关,所以我们先从它的定义开始。
现在你可以将大多数含义从脑海中驱逐出去,只留下一个关键词:“代替(substitute)”。
在计算机中,代理通常是一个服务器,并且是一个扮演客户端与另一台服务器之间的中间人角色的服务器。它就是位于客户端与服务器之间的一段代码,这就是代理的关键。
也许你需要复习一下,或者还不太确定客户端和服务器的定义:“客户端”是一个进程(代码)或机器,它从另一个进程或机器(“服务器”)请求数据。当浏览器向一个后端服务器请求数据时,它就是客户端。
服务器为客户端提供服务,但它也可以是客户端——当它从数据库检索数据时,数据库就是服务器,服务器本身既是(数据库的)客户端,又是 前端客户端(浏览器)的服务器。
如你所见,客户端-服务器关系是双向的。所以一个东西可以同时是客户端和服务器。如果有一个接收请求的中间人服务器,它将请求发给另一个服务,然后转发这个服务给源客户端的响应,那么它就是一个代理服务器。
后面我们将把客户端称作客户端,把服务器称作服务器,把代理称作客户端和服务器之间的东西。
所以当客户端通过代理给服务器发送请求时,代理有时会对服务器掩盖客户端的身份,请求中携带的 IP 地址可能是代理的而不是源客户端的。
对于你们当中那些想要访问网站或下载受限资源(例如从种子网络或被所在国家禁止的网站)的人来说,你们可以识别出这个模式——它就是建立 VPN 的原理。
在我们继续深入之前,我想先说一下:术语代理在使用时通常指的是“转发”代理。转发代理就是在客户与服务器通信过程中代表(或代替)客户端的代理。
这与反向代理不同,反向代理代表的是服务器。图上它看起来和转发代理一样是位于客户端和服务器之间的代理,数据流与客户端<->代理<->服务器一样。
二者关键的区别是:反向代理是设计用来代替服务器的。通常客户端根本不知道网络请求是通过代理路由并且是代理将其传递给内部服务器的(对服务器的响应也是这么做的)。
所以,在转发代理中,服务器将不会知道客户端的请求和响应经过了代理。而在反向代理中,客户端经不会知道请求和响应都是经过代理路由的。
代理有点偷偷摸摸的 :)
但是在系统设计中,尤其是对复杂系统来说,代理很有用,反向代理特别有用。你的反向代理可以委派大量你不希望在主服务器处理的任务,它可以是一个网关、一个筛选器、一个负载均衡器和全能助手。
所以代理非常有用,但你可能并不知道为何如此。再一次,如果你读过我的其它材料,你就会知道我坚信:你只有在知道某些东西 为何 存在之后,才能对它们有正确的认识——只知道它们做 什么 并不够。
第六节:负载均衡
如果你考虑一下负载(load)和均衡(balance)这两个词,你就会开始对它们在计算机世界中所做的事情有了一个直觉。当服务器同时收到大量请求时,它可能会慢下来(吞吐量下降、延迟上升)。达到一定程度之后,服务器甚至会宕机(丧失可用性)。
你可以赋予服务器更多的能力(纵向伸缩),也可以添加更多的服务器(横向伸缩)。但是现在你要解决的是如何将进来的请求分布到不同的服务器上去——哪些请求被路由到哪些服务器以及如何保证这些服务器也不会过载?换句话说,你如何均衡并分配请求负载?
我们现在进入负载均衡器(load balancer)部分。由于这篇文章是一篇对原则和概念的介绍,所以不可避免地,它们都是非常简单的解释。负载均衡器的任务是位于客户端和服务器之间(但是它们也可以被放在其它的地方)并负责将传入请求分发到多个服务器上去,从而不断给终端用户(客户端)带来快速、流畅和可靠的体验。
所以负载均衡器就像指挥交通的交管人员一样。它们这样做是为了维持可用性和吞吐量。
在理解负载均衡器被放置到系统架构中的哪个位置时,你可以看到负载均衡器可以被看作 反向代理。但是负载均衡器也可以被插入到其它地方——其它有着信息交换的部件之间,例如你的服务器和数据库之间。
均衡操作——服务端选择策略
所以负载均衡器是如何决定如何路由和分配请求流量的呢?首先,你每新增一台服务器,就需要让负载均衡器知道又多了一台可以将流量路由过去的候选服务器。
如果你去掉了一台服务器,负载均衡器也需要知道。配置信息确保负载均衡器知道它的访问列表中有多少台服务器以及哪些服务器是可用的。持续通知负载均衡器每台服务器的负载级别、状态、可用性、当前任务等信息也是可能的。
将负载均衡器配置为知道可以重定向到哪些服务器后,我们需要制定最佳路由策略,以确保请求被适当地分配到可用服务器之间。
一种简单的方法就是:负载均衡器随机选择一个服务器,然后将每个传入请求转发过去。但是可想而知,随机化会导致负载的“不均衡”分配,一些服务器获得的负载比其它服务器多,这可能会给整个系统的性能带来负面影响。
轮询和加权轮询
另一种可以被直观理解的方法叫做“轮询(round robin)”。这是很多人处理循环处理列表的方式。你从列表中的第一个元素开始,依次向下移动,在抵达最后一个元素之后,回到顶点,再次向下处理这个列表。
负载均衡器也可以这么做,它只需要以固定的顺序循环迭代所有的可用服务器。这种负载方式以一种易于理解和可预测的模式完成了多个服务器之间负载的均匀分发。
通过给一些服务“加权”,你可以轮询会变得更加“花哨”。在标准的轮询中,每台服务器都被赋予了相等的权重(所有的服务器的权重都为 1)。但是当你对服务器分开加权后,就能让一些服务器有着更低的权重(比如 0.5,如果它们更弱的话),而另一些服务器会有更高的权重,比如 0.7 或 0.9 甚至 1。
然后整个流量将会根据权重的比例被划分并根据服务器对大量请求的处理能力呈比例分配。
基于负载的服务器选择
更加复杂的负载均衡器可以根据当前的容量、性能和列表中的服务器负载进行决策,根据当前负载并计算出如何才能达到最高吞吐量、最低延迟等,然后动态分配负载。负载均衡器监控每台服务器的性能,据此决定哪些服务器不能处理新的请求。
基于 IP 哈希的选择
你可以配置负载均衡器对传入请求的 IP 地址进行哈希,然后使用哈希值决定将请求转发到哪台服务器。如果我有五台可用的服务器,那么哈希函数就会被设计返回五个哈希值中的一个,所以肯定会有一台服务器被指派去处理这个请求。
如果你想让来自某个国家或地区的请求从该地区中最能满足需求的服务器获取数据,或者你的服务器通过缓存请求来实现更快的处理,基于 IP 哈希的路由策略会非常有用。
在后一种场景中,你可能想确保请求去往那台之前已经缓存相同请求的服务器,因为这会提高处理和响应请求的速度与性能。
如果你的每台服务器都维护着独立的缓存,负载均衡器也不总是将相同的请求转发到同一台服务器,结果可能就是服务器重复做着之前已经做过的工作,因为之前的请求去了另一台服务器,而你又不能利用那些缓存数据进行优化。
基于路径或服务的选择
你也可以让负载均衡器根据请求的“路径”或功能或被提供的服务路由请求。例如,如果你正从一家网上花店买花,加载“Bouquets on Special”的请求可能被发送到一台服务器,而信用卡支付的请求可能被发送到另一台服务器。
如果二十位访问者中实际上只有一位买了花,那么你可以让一个较小的服务器处理支付,让一个更大的服务器处理所有的浏览流量。
混合包
和其它东西一样,你可以达到更高和更细层次的复杂度。你可以有多个负载均衡器,它们各自有着不同的服务器选择策略!此外,如果你的系统是一个非常大的高流量系统,你或许需要 负载均衡器的负载均衡器……
最终,你需要不断地往系统中添加组件,直到系统的性能适应了你的需求(你的需求可能看起来平缓,或者随时间缓慢上升,或者容易出现峰值)。
我们已经讲了 VPN(用于转发代理)和负载均衡(用于反向代理),但是 这里 还有更多的例子。
第七节:一致性哈希
负载均衡中的哈希是我们要理解的稍微有点棘手的概念之一,因此它有了自己单独的一节。
为了理解它,请首先了解 哈希在概念上是如何工作的。简单来说,哈希将输入转换成一个固定长度的值,通常是一个整数(哈希值)。
对于一个好的哈希算法或函数而言,一个关键的原则是函数本身必须是可确定的,也就是相同的输入在传入函数之后会得到相同的输出。所以,可确定性(determinstic)表示:如果我传入了一个字符串“Code”(大小写敏感)然后函数生成的哈希值为 11002,那么每次我传入“Code”时,它就必须生成整数“11002”。如果我传入“code”,它就会生成另一个不同的数字(这个数字也是不变的)。
有时,哈希函数可能会为多个输入生成相同的哈希值——不要担心,我们还有应对方式。实际上,唯一输入的范围越大,出现这种情况的可能性就越大。但是当不只一个输入生成相同的输出时,就产生了“冲突(collision)”。
牢记这一点,让我们把它用到路由和将请求转发到服务器的过程中。假设你有五台用于分配负载的服务器,一种容易理解的方法就是对传入请求(可能是 IP 地址,或者一些其它的客户端信息)进行哈希,为每个请求生成哈希值。然后你就可以对这个哈希值进行取模运算,运算的右操作数为服务器的数量。
例如,你的负载均衡器的伪代码可能长这样:
request#1 => hashes to 34
request#2 => hashes to 23
request#3 => hashes to 30
request#4 => hashes to 14
// 你有五台服务器 => [Server A, Server B, Server C, Server D, Server E]
// 所以对每个请求的哈希值进行模 5 运算……
request#1 => hashes to 34 => 34 % 5 = 4 => send this request to servers[4] => Server E
request#2 => hashes to 23 => 23 % 5 = 3 => send this request to servers[3] => Server D
request#3 => hashes to 30 => 30 % 5 = 0 => send this request to servers[0] => Server A
如你所见,哈希函数生成了大量可能的值,取模运算符把这个值的范围变小了,刚好映射到了服务器的数量。
你肯定会遇到不同的请求被映射到同一台服务器,这还好,只要在所有服务器上的所有分配是“一致的”就行。
添加服务器,处理故障服务器
所以,如果我们向其发送流量的服务器挂了会怎样呢?哈希函数(我们上面的那一段伪代码)仍然认为还有五台服务器,取模运算符会生成 0-4 这个范围的数字。但是由于一台服务器挂了,我们只有四台服务器了,可我们还是向那台挂了的服务器发送流量。糟糕。
相反,我们可以加入第六台服务器,但它 永远 都不会收到任何流量,因为我们的模操作数为 5,它永远也不会产生一个将第六台服务器包括进来的数字。更糟糕了。
// 添加第六台服务器
servers => [Server A, Server B ,Server C ,Server D ,Server E, Server F]
// 将模操作数改成 6
request#1 => hashes to 34 => 34 % 6 = 4 => send this request to servers[4] => Server E
request#2 => hashes to 23 => 23 % 6 = 5 => send this request to servers[5] => Server F
request#3 => hashes to 30 => 30 % 6 = 0 => send this request to servers[0] => Server A
我们发现服务器数字在取模后变了(尽管,在这个示例中,request#1 和 request#3 没有改变——但这只是一个特例)。
于是,现在总共有一半的请求(其它示例中可能会更多!)被路由到了新的服务器,我们因此失去了之前缓存在服务器上的数据的所带来的好处。
例如,request#4 之前去往 Server E,但是现在去了 Server C。Server E 中所有与 request#4 有关的缓存数据都没用了,因为现在请求都去了 Server C。你可以为一台宕机的服务器计算一个类似的问题,但是取模函数仍然给它发送请求。
在这个微型系统中,它看似没啥影响。但在一个超大规模的系统中,这就是一个糟糕的结果。系统设计失败。
显然,一个简单地通过哈希分配请求的系统不能很好地处理错误。
流行的解决方案——一致性哈希
不幸的是,我觉得用文字并不足以描述这一部分。一致性哈希(consistent hashing)最好是通过可视化进行理解。但是到目前为止,本篇文章的目的是给你关于该问题的直观想法,为什么会出现这个问题,以及常规解决方案可能的不足。牢记这一点。
正如我们讨论的,普通哈希的关键问题在于:当 (A) 一个服务器宕机了,仍然有流量被路由到它,并且 (B) 你添加了一台新的服务器,分配方式本质上已经变了,因此你失去了之前的缓存带来的好处。
在深入一致性哈希时,有两个重要的点需要牢记:
- 一致性哈希 没有消除问题,尤其是 B。但是它确实减少了很多问题。一开始你可能想知道为什么一致性哈希那么重要,底层的缺点依然存在,确实如此,但是它们变少了,并且一致性哈希本身也是对大规模系统的一个有效改进。
- 一致性哈希对传入请求 和服务器 进行哈希操作,哈希结果因此陷入一系列(连续)的值。这个细节非常重要。
请在观看下面推荐的解释一致性哈希的视频时牢记这一点,否则它的效果就没那么明显了。
我强烈推荐这个视频,它融入了这些原则,却没有过多烦扰你的细节。
如果你在理解消化为什么这个策略在负载均衡中很重要时遇到了任何问题,我建议你先休息一下,然后回到 负载均衡一节,再次阅读。除非你在工作中遇到过这个问题,否则所有的这些都会变得非常抽象,这并不罕见。
第八节:数据库
我们简要地考虑了为满足许多不同使用场景设计的不同类型的存储方案(数据库),并且其中的一些和其它的相比,更适用于特定的任务。站在一个非常高的视角,数据库可以被分类成两种类型:关系型(Relational)和非关系型(Non-Relational)。
关系型数据库
关系型数据库 是一种严格执行数据库中所存事物之间关系的数据库。这些关系通常可能会要求数据库将每个东西(称为“实体”)表示成一个结构化表,表中有零行或多行(“记录”,“条目”)和一列或多列(“属性”,“字段”)。
通过在实体上强制推行这种结构,我们可以确保每个数据项/条目/记录都具有正确的数据。它带来了更好的一致性,以及形成实体间更精密关系的能力。
你可以从下面记录“Baby”(实体)数据的表中看到这个结构。表中的每条记录(“条目”)都有四个字段,它们表示与那个宝宝有关的数据。这是一个经典的关系型数据库结构(也是一个被称为模式的规范实体结构)。