你或许听说过“架构”或“系统设计”这两个术语,它们在开发者的求职面试中经常出现,尤其是大型科技公司的招聘人员喜欢提这方面的问题。

这篇教程将深入软件架构的基本概念,帮你做好系统设计面试的准备。

因为系统设计是一个庞大的主题,所以这篇文章并不会面面俱到。但如果你是一名初中级开发者的话,这应该能为你奠定坚实的基础。

你可以从这里深入挖掘其它资源。我已经在文章底部列出了一些我喜爱的资源。

我已经将这篇教程按照主题划分成了很多小模块,我建议你将它加入到书签。我发现 间隔性学习与重复 是获取知识的宝贵工具,真让人难以置信。我已将本教程设计成很多小片段,以便你进行间隔性重复记忆。

第一节:网络与协议(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 请求与响应消息中的键-值对。

image-44
来自:https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages

同 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)级别。

处理过期数据

你可能已经注意到了上面的示例对“读”操作的处理是隐式的。写操作在主要原则上也没什么不同,只是加了以下的考虑:

  • 写操作要求缓存与数据库保持同步
  • 这可能会增加复杂度,因为有更多的操作要执行,对未同步或“过期”数据的处理需要仔细分析
  • 新的设计原则可能需要实现对同步的处理——它应该是同步进行,还是异步进行?如果异步进行,那么时间间隔取多大?
  • 数据“驱逐”或更新和数据的刷新,从而保证缓存数据是最新的。这包括像 LIFOFIFOLRULFU 这样的技术。

所以让我们用一些概括性的、非约束性的结论结束这一节吧。通常来说,在保存静态或不经常改变的数据时,以及变化源很有可能是单个操作而不是用户产生的操作时,缓存表现最好。

在数据的一致性和新鲜度非常关键的场合下,缓存可能就不是最优的解决方案了,除非系统中有一个一个部件在高效地刷新着缓存,并且刷新间隔还不会反过来影响到应用的用户体验。

代理

代理。啥?我们中的很多人都听说过代理服务器(proxy servers)。我们或许已经在一些自己的 PC 或 Mac 软件上见过关于添加和配置代理服务器的配置项,或者是一些关于如何“通过代理”访问的配置。

所以让我们来看看这个相对简单而又被广泛使用的的重要技术吧。英语中的代理一词与计算机中的代理毫不相关,所以我们先从它的定义开始。

Screen-Shot-2020-03-08-at-12.57.03-pm
来自:https://www.merriam-webster.com/dictionary/proxy

现在你可以将大多数含义从脑海中驱逐出去,只留下一个关键词:“代替(substitute)”。

在计算机中,代理通常是一个服务器,并且是一个扮演客户端与另一台服务器之间的中间人角色的服务器。它就是位于客户端与服务器之间的一段代码,这就是代理的关键。

也许你需要复习一下,或者还不太确定客户端和服务器的定义:“客户端”是一个进程(代码)或机器,它从另一个进程或机器(“服务器”)请求数据。当浏览器向一个后端服务器请求数据时,它就是客户端。

服务器为客户端提供服务,但它也可以是客户端——当它从数据库检索数据时,数据库就是服务器,服务器本身既是(数据库的)客户端,又是 前端客户端(浏览器)的服务器。

image-22
来自:https://teoriadeisegnali.it/appint/html/altro/bgnet/clientserver.html#figure2

如你所见,客户端-服务器关系是双向的。所以一个东西可以同时是客户端和服务器。如果有一个接收请求的中间人服务器,它将请求发给另一个服务,然后转发这个服务给源客户端的响应,那么它就是一个代理服务器。

后面我们将把客户端称作客户端,把服务器称作服务器,把代理称作客户端和服务器之间的东西。

所以当客户端通过代理给服务器发送请求时,代理有时会对服务器掩盖客户端的身份,请求中携带的 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) 你添加了一台新的服务器,分配方式本质上已经变了,因此你失去了之前的缓存带来的好处。

在深入一致性哈希时,有两个重要的点需要牢记:

  1. 一致性哈希 没有消除问题,尤其是 B。但是它确实减少了很多问题。一开始你可能想知道为什么一致性哈希那么重要,底层的缺点依然存在,确实如此,但是它们变少了,并且一致性哈希本身也是对大规模系统的一个有效改进。
  2. 一致性哈希对传入请求 和服务器 进行哈希操作,哈希结果因此陷入一系列(连续)的值。这个细节非常重要。

请在观看下面推荐的解释一致性哈希的视频时牢记这一点,否则它的效果就没那么明显了。

我强烈推荐这个视频,它融入了这些原则,却没有过多烦扰你的细节。

一个关于一致性哈希的简单介绍,来自 Hannah Barton

如果你在理解消化为什么这个策略在负载均衡中很重要时遇到了任何问题,我建议你先休息一下,然后回到 负载均衡一节,再次阅读。除非你在工作中遇到过这个问题,否则所有的这些都会变得非常抽象,这并不罕见。

第八节:数据库

我们简要地考虑了为满足许多不同使用场景设计的不同类型的存储方案(数据库),并且其中的一些和其它的相比,更适用于特定的任务。站在一个非常高的视角,数据库可以被分类成两种类型:关系型(Relational)和非关系型(Non-Relational)。

关系型数据库

关系型数据库 是一种严格执行数据库中所存事物之间关系的数据库。这些关系通常可能会要求数据库将每个东西(称为“实体”)表示成一个结构化表,表中有零行或多行(“记录”,“条目”)和一列或多列(“属性”,“字段”)。

通过在实体上强制推行这种结构,我们可以确保每个数据项/条目/记录都具有正确的数据。它带来了更好的一致性,以及形成实体间更精密关系的能力。

你可以从下面记录“Baby”(实体)数据的表中看到这个结构。表中的每条记录(“条目”)都有四个字段,它们表示与那个宝宝有关的数据。这是一个经典的关系型数据库结构(也是一个被称为模式的规范实体结构)。

image-46
来自:https://web.stanford.edu/class/cs101/table-1-data.html

所以理解关系型数据库的关键在于它们是高度结构化的,并且将结构强加给所有的实体。通过确保添加到表的数据符合该结构来强制实施此结构。添加一个模式不允许的高度字段到表将不被准许。

大多数关系型数据库都支持数据库查询语言,叫做 SQL——结构化查询语言。这是一门专为与结构化(关系型)数据库内容交互设计的语言。这两个概念的联系非常紧密,以至于很多人通常将关系型数据库称作“SQL 数据库”(有时发音为“sequel”数据库)。

通常认为,SQL(关系型)数据库比非关系型数据库支持更加复杂的查询(组合不同的字段、过滤器和条件)。数据库本身处理这些查询,然后返回满足的结果。

很多 SQL 数据库迷都说,如果没有这个功能(指复杂查询)的话,你将不得不先取出 所有的 数据,让服务器或客户端把数据加载进 “内存”,然后应用过滤条件——这在小数据集上还好,但是对于有着成千上万的记录和行的大型复杂数据集来说,就会对性能带来非常严重的影响。然而,并不总是这样,我们将会在学习 NoSQL 数据库时看到原因。

一个常见而又倍受喜爱的关系型数据库是 PostgreSQL(通常称为“Postgres”)数据库。

ACID

ACID 事务描述的是良好关系型数据库都将支持的事务的一组特征。ACID = "Atomic, Consistent, Isolation, Durable"。事务是与数据库的交互,通常是读或写操作。

原子性(Atomicity) 要求:当单个事务包含到超过一个操作时,数据库必须保证如果有一个操作失败,整个 事务(所有的操作)也会失败。它就是“要么全部,要么一个也不”。这样,如果事务成功了,那么你会知道所有的子操作都成功了,如果某个操作失败了,那么你会知道所有同它一起的操作都失败了。

例如,如果一个事务包括从两个表中读数据并往三个表写数据,那么如果它们中的任意一个操作失败了,整个事务就失败了。这意味着一个独立的操作都不应该完成。你甚至不希望三个写操作中的一个成功——这会“弄脏”数据库内的数据!

一致性(Consistency) 要求:数据中的每个事务都符合数据库定义的规则,当数据库改变状态(一些信息改变了)时,这个改变是有效的,不会损坏数据。每个事务都将数据库从一个 有效的 状态移动到另一个 有效的 状态。可以这么看待一致性:每个“读”操作都收到最近一次“写”操作的结果。

隔离性(Isolation) 表示你可以在数据库“并发(concurrently)”(同时)运行多个事务,但是数据库最终的状态看起来就像每个操作串行(按顺序,就像一个操作队列一样)运行结束后一样。我个人认为“隔离性”对概念的描述性不是非常强,但是我猜 ACCD 比 ACID 说起来更难……

持久性(Durability) 保证:一旦数据被保存到数据库中,就会一直在那里。它将会是“持久的”——保存在硬盘,而不是“内存”中。

非关系型数据库

相反,非关系型数据库 就没有那么死板了,或者,换句话说,它的数据有着更加灵活的结构。数据通常以“键-值”对的形式呈现。这种方式的一个简化形式就是一个由“键-值”对对象构成的数组(列表),例如:

// baby names
[
	{ 
    	name: "Jacob",
        rank: ##,
        gender: "M",
        year: ####
    },
    { 
    	name: "Isabella",
        rank: ##,
        gender: "F",
        year: ####
    },
    {
      //...
    },
    
    // ...
]

非关系型数据库也被称为“NoSQL”数据库,当你不想或不需要结构一致的数据时,它能给你提供便利。

和 ACID 性质类似,NoSQL 数据库的性质有时候被称为 BASE:

**基本可用(Basically Abailable)**规定系统保证可用性

软状态(Soft State) 表示系统的状态可能随时间改变,即使没有输入也是如此

最终一致性(Eventual Consistency) 规定系统在一段(短暂)的时间间隔之后会达到一致的状态,除非收到了其它的输入。

由于这些数据库的内核都采用类哈希表结构保存数据,所以它们会非常快、简单、易用,完美适用于缓存、环境变量、配置文件和会话状态等使用场景。这种灵活性使得它们在内存中(比如 Memcached)和持久化存储(比如 DynamoDb)中的使用非常完美。

还有很多“类 JSON” 的数据库,它们被称为文档数据库,比如倍受喜爱的 MongoDb,这些数据库的内核也采用“键-值”存储。

数据库索引

这是一个复杂的话题,所以为了给你一个关于系统设计面试需要哪些知识的高度概述,我将会简单地给出冰山一角。

想象一个有一亿行数据的数据库表,这个表主要被用来查找每条记录的一个或者两个值。为了得到特定行的值,你需要迭代整个表,如果它是非常靠后的记录,那将会花费很长时间!

索引(indexing)是记录的一种快捷方式,它在匹配值时比逐行检查的更加高效。索引通常是一种被添加到数据库的数据结构,它专为促进数据库内 特定 属性(字段)上的快速搜索而设计。

所以,如果人口统计局有一亿两千万条具有名字和年龄的记录,而你最常需要的就是检索属于某个年龄组的人员列表,那么就可以在该数据库的年龄属性上建立索引。

索引是关系型数据库的核心,在非关系型数据库中也广泛使用。因此,从理论上讲,索引的好处可用于两种类型的数据库,这对优化查找时间非常有利。

复制和分片

虽然这些听起来像是一部生物恐怖电影中的东西,但你更有可能每天都在数据库扩展中听到它们。

复制(replication)是指复制(duplicate, make copies of, replicate)你的数据库。你或许记得我们在可用性一节讨论过它。

我们已经考虑了在系统内进行冗余以维持高可用带来的好处。复制在单个数据库宕机时保证数据库的冗余。但是它也引发了如何在副本中同步数据的问题,因为它们要有相同的数据。数据库写操作和更新操作的复制可以同步进行(主数据库发生改变的同时),也可以异步进行。

主库与从库之间同步数据的可接受时间间隔取决于具体的需求——如果你确实需要两个数据库之间的状态是一致的,那么复制操作必须非常快。你也想确保从库上的写操作失败时,主库上的写操作也失败(原子性)。

但是,当你拥有如此多的数据,以至于简单地进行复制可能会解决可用性问题,但不能解决吞吐量和延迟问题(速度)时,你会怎么做?

这个时候你可能会考虑对你的数据进行“分类”,形成“分片(shard)”。一些人也管这个叫数据划分(partitioning),它与硬盘分区完全不同。

数据分片将大数据库拆分为较小的数据库。你可以根据数据结构决定如何对数据进行分片。分片可以像每五百万行一个分片这么简单,也可以采用最适用于当前数据、需要和服务位置的其它策略。

第九节:领导选举

让我们再次回到服务器,讨论一个稍微高级点的话题。我们已经了解了 可用性的原则,以及冗余是如何作为增加可用性的一种手段的。在处理到冗余服务器集群的请求路由时,我们还介绍了一些实践中的注意事项。

但是有时候,在这种设置中,多个服务器中的事情没多大差别,这可能会出现只需要一台服务器带头的情况。

例如,你想确保只有一台服务器负责更新某些第三方 API,因为来自不同服务器的多次更新可能导致问题,或者增加第三方的成本。

在这种情况下,你需要选择一台主服务器,然后将更新职责委派给它。这个过程被称为 领导选举

当一个集群中有多台服务器提供冗余时,可以配置它们之间有且只有一个领导。它们也会在检测到领导服务器宕机时,指定另一台服务器顶替领导服务器的位置。

原理非常简单,但细节才是最让人头疼的。真正棘手的部分在于:确保服务器之间保持数据、状态和操作的同步。

例如,始终存在某些事故导致一台或两台服务器断开与其他服务器之间的连接的风险。在这种情况下,工程师们最终会使用一些区块链中的基本理念让服务器集群达成共识。

换句话说,共识算法 用于告知所有服务器一个“达成一致的”值,它们都可以在识别领导服务器的逻辑中依赖这个值。

领导选举通常使用像 etcd 这样的软件实现,它是一个键-值对存储,通过使用领导选举本身和共识算法,它同时提供了可用性 强一致性(这很有价值,也是不寻常的组合)。

所以工程师们可以依赖 etcd 自己的领导选举架构,在他们的系统中进行领导选举。这是通过在像 etcd 这样的服务中存储一个表示当前领导的键-指对来完成的。

因为 etcd 是高可用 强一致性的,所以你总是可以在自己的系统中依赖那个键-值对,它包含集群中关于当前领导是哪台服务器的最终“事实来源”。

第十节:轮询、流、套接字

在这个不断更新、推送通知、流内容和实时数据的现代时代,掌握支撑这些技术的基本原理非常重要。要定期或立即更新应用中的数据,你需要使用以下两种方法中的一种。

轮询

这种方法很简单。如果查看 维基百科词条,你会发现它讲的有点多。所以还是看一看词典中是如何解释它吧,尤其是计算机科学背景下的含义。牢记这个简单的基础。

Screen-Shot-2020-03-14-at-10.25.44-am

轮询(polling)就是简单地让客户端检查服务器,客户端发送一个网络请求请求更新的数据。这些请求通常按照固定的时间间隔发出,比如五秒、十五秒、一分钟或用例要求的任何时间间隔。

每几秒钟进行一次轮询还是和实时大有不同,还以下缺点,特别是你的并发用户在一百万以上的时候:

  • 几乎不变的网络请求(不利于客户端)
  • 几乎不变的入站请求(不利于服务端负载——每秒超过一百万次请求!)

所以快速轮询并不是真的很高效,轮询最好是用在数据更新中的小差别对应用来说不是问题的场景中。

例如,如果你克隆了 Uber,你或许会让司机端应用每五秒发送一次司机的位置数据,让乘客端应用每五秒轮询一次司机的位置。

流(streaming)解决了不断轮询的问题。如果有必要不断访问服务器,最好使用像 web-socket 这样的东西。

这是一个旨在 TCP 上工作的网络通信协议。它在客户端和服务器之间打开一个双向的专用通道(套接字),有点像在两个端点之间的开放热线。

与通常的 TCP/IP 通信不同,这些套接字是“长久的(long-lived)”,以便它向服务器发出的单个请求打开了进行双向数据传输的热线,而不是采用多个独立的请求。我们使用长久一词表示机器之间的套接字连接将会一致持续到连接的一端关闭它,或者网络故障。

你可能还记得我们对 IP、TCP 和 HTTP 的讨论,这些操作在每个请求-响应环中都发送数据的“数据包”。Web-socket 意味着只有一个请求-响应交互(不是你认为的环!),它打开了一个通道,通道两端数据都以“流”的形式发送。

轮询与所有“常规”的基于 IP 的通信的一大区别就是:轮询让客户端定时给服务器发送获取数据的请求(“拉取”数据),而在流中,客户端“随时等待”着服务器将一些数据“推送”给它。服务器会在数据改变时将其发出,并且客户端总是在监听它。因此,如果数据不断变化,那么它就成为了一个“流”,这可能更加符合用户的需求。

例如,在使用 协作式编码 IDE 时,只要有用户输入了东西,它就可以立马被其它人看到,这就是通过 web-socket 实现的,因为你想要的是实时协作。如果我输入的内容在你输入同样的内容之后才在你的屏幕上显示,或者你等了三分钟才知道我干了啥,那就太糟糕了!

或者想一下多人在线游戏——那是在玩家之间的流式传输游戏数据的绝佳案例!

总之,使用场景决定选择轮询还是流。通常,如果你希望数据是“实时的”,就用流;如果滞后(十五秒也是滞后)对你来说是可以的,那么轮询也是一个不错的选择。但是这完全取决于你有多少并发用户,以及他们是否期望数据是实时的。流式服务的一个常见示例就是 Apache Kafka

第十一节:端点保护

当你构建大规模系统时,保护系统免受过多操作(使用系统时实际上不需要这些操作)就变得很重要了。现在听起来还是非常抽象。但是想一下这个——你有多少次疯狂点击着一个按钮,以为它将会使系统更快?想象一下如果每次点击都 ping 一次服务器,服务器也尝试全部处理它们!如果系统的吞吐量由于某些原因很低(比如服务器正在异常负载下挣扎),那么每次点击都会使系统变得更慢,因为系统必须处理所有的点击!

有时它甚至与保护系统无关。有时你想要限制操作次数,因为那是你提供服务的一部分。例如,你使用了第三方 API 服务的免费套餐,他们只允许你每三十分钟进行二十次请求。如果你在三个分钟内进行了二十一次或三百次请求,那么在前二十次请求后,服务器将不再处理你的请求。

这被称为限流(rate-limiting)。服务器可以通过限流手段限制客户端给定时间段内尝试进行的操作数量。限流可以根据用户计算,也可以根据请求、次数、负载或任何其它东西。通常,一旦某个时间段内的操作超出了限制,服务器会在剩余的时间内返回错误。

好了,现在你可能认为端点“保护”有些夸张。你只是在限制用户从端点获取某些东西的能力。的确如此,但是它面对恶意用户(客户端)时也能提供保护——就像一个机器人正在攻击你的端点一样。为什么会发生那种事情呢?因为用超过服务器处理能力的大量请求淹没服务器是一种策略,这是恶意攻击者让服务器宕机的一种方式,这种方式也能有效击垮服务。它正是 拒绝服务(DoS,Denial of Service)攻击

虽然限流可以抵御 DoS 攻击,但是它本身并不能把你从高级版本的 DoS 攻击——分布式 DoS 中解救出来。这里的分布式只是简单地表示攻击来自多个看似毫不相关的客户端,也没有识别它们是被单个恶意代理控制的有效方式。需要采取其它方式来防止这种协作型分布式攻击。

但是无论如何,对于像我在上面提到的 API 限制这些没那么恐怖的场景而言,限流是一种有用且流行的方式。考虑到限流的工作原理,由于服务器必须先检查限制条件并在必要时强制实行,所以你需要考虑使用何种数据结构和数据库才能使这个检查超级快,以便允许范围内的请求不会拖慢处理该请求的处理速度。另外,如果你将其存储在服务器本身的内存中,那么你就需要保证所有来自给定客户端的请求都会抵达服务器,以便它可以正确地进行限制。为了处理像这样的情形,使用位于服务器之外的独立 Redis 服务非常流行,但是他们将用户详情保存在内存中,这能快速决定用户是否在允许的范围内。

限流可以和你想要的规则一样复杂,但是本节应该涵盖的是基础知识和最常见的用例。

第十二节:消息与发布-订阅

当你设计和构建大规模 分布式系统时,为了让系统可以一起流畅工作,进行系统组件与服务之间信息交换是很重要的。但是如我之前所讲的那样,依赖网络的系统遭受着和网络一样的弱点——它们可经不起折腾。网络故障经常发生。当网络故障时,系统中的组件就无法进行通信,系统可能会降级(最好的情况)或者跟着发生故障(最差的情况)。所以分布式系统需要健壮的机制来确保通信持续进行,或者从停止点恢复,即使系统中的组件之间存在“任意分区”(即故障)也能做到。

举个例子,想象你正预定飞机票。你得到了一个好价钱,选座,确认订票,甚至已经用信用卡付完款。现在你正等待票证的 PDF 被发到你的收件箱。你等呀等,却一直等不到它的出现。系统中的某个地方出现了无法处理或不能正确恢复的故障。订票系统通常会联系航空公司,访问价格 API,处理实际的航班选择——费用汇总、航班的日期和时间等。当你点击网站的预定界面时,一切都会办理妥当。但是,它不必在几分钟后将票证的 PDF 发送给你。相反,界面可以简单地对你的预定进行完成确认,你可以期待票证稍后出现在收件箱中。对订票来说,这是合理也很常规用户体验,因为支付和票证收据不必同时进行——这两个事件是异步的。这样的系统需要发送消息,确认生成 PDF 的异步服务(服务端点)收到已确认的预定付款和所有详情的通知,然后 PDF 可以被自动生成并发送给你。但是如果这个消息系统出现故障,邮件服务永远都不会知道你的预定信息,自然也就不会生成票证了。

发布者/订阅者消息传递

这是一个非常流行的消息范式(模型),关键概念在于:发布者“发布”消息,订阅者订阅消息。为了提供更好的粒度,消息可以属于某个“主题(topic)”,它就像是一个目录。这些主题就像专用的“频道(channel)”或管道一样,每个管道只处理属于特定主题的消息。订阅者选取想要订阅的主题,然后从中获取消息通知。这个系统的优势就是发布者与订阅者之间是完全解耦的——它们不需要了解彼此。发布者播报,订阅者监听其正在寻找的主题的通知。

服务器通常是消息的发布者,它们通常会发布好几种主题(频道)。特定主题的消费者订阅对那些主题进行订阅。服务器(发布者)与订阅者(可以是另一个服务器)之间没有直接的通信。交互只在发布者与主题之间,主题与订阅者之间发生。

主题中的消息只不过是需要通信的数据,可以呈现为任何你需要的形式。这样一来,你的发布/订阅中的就有四个参与者:发布者、订阅者、主题和消息。

比数据库更好

所以为什么要在这上面费心呢?为什么不把所有的数据都持久化到数据库,然后直接从数据库消费呢?你需要一个对消息进行排队的系统,因为每个消息都对应着任务,这个任务需要该消息的数据才能完成。所以在我们订票的例子中,如果三十五分钟内有一百个人订票,将所有这些都放到数据库并不能解决给这一百个人发邮件的问题。它只不过是保存一百个事务。发布/订阅(Pub/Sub )系统处理通信、对任务进行串行排序,并且将消息持久化到数据库。所以这个系统可以提供很多有用的特性,如“至少一次”交付(消息将不会丢失)、持久化存储、消息排序、“重试”、消息的“重放”,等等。若没有这个系统,只是将消息存储在数据库中并不会帮你确保消息被交付(被消费)并成功完成任务。

有时同一个消息可能会不只一次被一个订阅者消费——通常是因为瞬时网络故障,尽管订阅者消费了消息,但是它没有让发布者知道这一点。所以发布者会把消息再次重发给订阅者,这就是为什么保证“至少一次”而不是“有且只有一次”。多次消费的问题在分布式系统中是不可避免的,因为网络天生就是不可靠的。这会让情况变得更复杂,消息在订阅者一侧触发一个操作,那个操作可以改变数据库内的东西(改变整个应用的状态)。如果单个操作被重复多次,并且每次应用的状态都改变了会怎样?

控制结果——一个或多个结果?

这个新问题的解决方案被称为幂等性(idempotency)——这是一个很重要的概念,但是在你前几次检查它时没那么直观。这个概念看起来可能很复杂(特别是你读维基百科词条的时候),所以就当前的目的而言,这里是 来自 StackOverflow 的一个更加友好的简化版本:

在计算机中,幂等性指使用相同的参数多次调用某个操作的结果与调用一次的结果相同。

所以当订阅者处理一个消息两次或三次时,应用的整体状态与消息 第一次 被处理后的状态完全相同。例如,如果在预定机票快要完成时,你输入了信用卡信息,由于系统真的太慢了,你点击了三次“立即支付”按钮……你总不想支付三倍的机票钱吧?你需要幂等性确保 第一次 点击后的每次点击都不会产生其它的购买行为,不会不止一次地从你的信用卡扣款。相反,你可以给你最要好的朋友的 newsfeed (译者注:newsfeed 是 Facebook 的一个功能)发送 N 次完全相同的评论。它们都将显示为单独的评论,除了令人讨厌之外,它们并没有 。另一个例子是在 Medium 文章上“鼓掌(clap)”——每次鼓掌都表示增加一次鼓掌的数量,而不是仅仅一次(译者注:Claps 是 Medium 的一个功能,允许你用它来表示对某篇文章的支持,类似于微信朋友圈的点赞)。后面的这两个例子并不要求幂等性,但是支付那个例子是需要的。

有很多不同的消息系统,系统的选取取决于要解决的用例。通常,人们会参考“基于事件的”架构,即系统依赖“事件(event)”(比如支付票证)消息处理操作(比如发送票证邮件)。最常讨论的消息服务有 Apache Kafka、RabbitMQ、Google Cloud Pub/Sub 和 AWS SNS/SQS。

第十三节:必备小知识

日志

随着时间的推移,你的系统会收集大量的数据。大多数数据都是极其有用的,让你了解系统的健康情况、性能和问题,给你提供有价值的见解,帮你了解谁在使用你的系统、如何使用的、多久使用一次、哪些部分被使用得更多或更少,等等。

这些数据对分析、性能优化和产品改进很有价值。它们还有着巨大的调试价值,不论在开发过程中打印到控制台时,还是在测试和生成环境中找出 bug 时,都非常有用。所以日志也帮助你追溯和审计。

记录日志时要记住的要诀就是将它看成一系列连续事件,这意味着数据变成时间序列数据,并且应该使用经过特别设计的工具和数据库来帮你处理此类数据。

监控

这是日志之后的下一步。它回答了“我使用所有的日志数据干啥?”这个问题。你监控并分析它。你可以建立或使用解析此类数据的工具和服务,将数据呈现为仪表盘或图表,或其它人类可读的方式。

通过将数据保存到专为此类数据(时间序列数据)设计的数据库中,你可以插入其它基于该数据结构和意图构建的工具。

预警

当你积极监控时,也应该安放另一个可以提醒你重大事件的系统。就像一个股票价格超过某个上限或低于某个阈值时出现的警告一样,如果你正在观察的某个指标过高或过低,也可能会发送警报。响应时间(延迟)或错误和故障都是设置警告的好东西,如果它们超过了“可接受”级别,就发送警告。

良好的日志和监控的关键就是确保你的数据随时间推移变得非常一致,因为使用不一致的数据可能导致字段缺失,进而破坏分析工具,或者减少日志带来的好处。

资源

正如所承诺的,一些有用的资源如下:

  1. 一个出色的 Github 仓库,充满了概念、图和预学习资料
  2. Tushar Roy 的 系统设计介绍
  3. Gaurav Sen 的 YouTube 播放列表
  4. SQL vs NoSQL

我希望你喜欢这份长篇指南!

你可以在 Twitter 上向我提问

附言:献给 freeCodeCamp 学员

我真的,真的完全相信你最宝贵的资源就是你的时间、精力和金钱。其中,最重要的资源是时间,因为其他两个都可以得到更新或恢复。所以,如果你在某些事情上面花了时间,确保它会让你离目标更进一步。

考虑到这一点,如果你想在我的身上投入三个小时,找出学习编码的最佳路径(特别是如果你像我一样转了行话),那么就去 我的课程网站 填写表单注册吧(不是弹出框!)。如果你在消息(Leave us a message *)中添加 “I LOVE CODE”,我就会知道你是一个 freeCodeCamp 读者,我将你给发送一个优惠码。因为 freeCodeCamp 给我了一个良好的开始,就像它帮助你一样。

此外:如果你想了解更多信息,可以查阅 freeCodeCamp 播客第 53 集,我在那里同 Quincy(FreeCodeCamp 的创始人)一起分享了我们转行的经验,这些经验可能会对你有所帮助。你也可以访问 iTunesStitcherSpotify 上的播客。

原文:System Design Interview Questions – Concepts You Should Know,作者:Zubin Pratap