如何构建可扩展和高可用性的键值存储系统

本文首发于公众号:更AI (power_ai),欢迎关注,编程、AI干货及时送!

键值存储,也被称为键值数据库,是一种非关系型数据库。每个唯一标识符以键的形式存储,与其关联的值一起。这种数据配对被称为“键值”对。

在一个键值对中,键必须是唯一的,可以通过键访问与键关联的值。键可以是纯文本或哈希值。出于性能考虑,短键更好。键长什么样子呢?以下是一些例子:

  • 纯文本键:“last_logged_in_at”
  • 哈希键:253DDEC4

键值对中的值可以是字符串、列表、对象等。在键值存储中,值通常被视为一个不透明的对象,比如 Amazon dynamo [1],Memcached [2],Redis [3]等。

以下是键值存储中的一段数据片段:

image-20230525200227341

在本章中,你被要求设计一个支持以下操作的键值存储:

  • put(key, value) // 插入与“键”关联的“值”
  • get(key) // 获取与“键”关联的“值”

理解问题并建立设计范围

没有完美的设计。每种设计都实现了特定的平衡,关于读取、写入和内存使用的权衡。还需要做出的权衡是一致性和可用性之间。在这一章,我们设计的键值存储包含以下特点:

  • 键值对的大小小:小于10 KB。
  • 能够存储大数据。
  • 高可用性:系统在故障期间也能快速响应。
  • 高可扩展性:系统可以扩展以支持大数据集。
  • 自动扩展:根据流量自动添加/删除服务器。
  • 可调整的一致性。
  • 低延迟。

单服务器键值存储

开发一个位于单个服务器中的键值存储是容易的。直观的方法是在哈希表中存储键值对,将所有内容保留在内存中。尽管内存访问速度快,但由于空间限制,可能无法将所有内容装入内存。可以进行两种优化,以便在单个服务器中存储更多数据:

  • 数据压缩
  • 只在内存中存储经常使用的数据,其余的存储在磁盘上

即使进行了这些优化,单个服务器的容量也可能很快达到极限。需要分布式键值存储来支持大数据。

分布式键值存储

分布式键值存储也被称为分布式哈希表,它将键值对分布在多个服务器中。设计分布式系统时,理解CAP(C一致性,A可用性,P分区容忍性)理论是很重要的。

CAP定理

CAP定理指出,分布式系统不可能同时提供以下三种保证的两种以上:一致性、可用性和分区容忍性。让我们来明确一些定义。

一致性:一致性意味着所有客户端在同一时间看到的数据是一致的,无论他们连接到哪个节点。

可用性:可用性意味着任何请求数据的客户端都能得到响应,即使一些节点挂了。

分区容忍性:分区表示两个节点之间的通信中断。分区容忍性意味着系统在网络分区的情况下仍能继续运行。

CAP定理指出,必须牺牲三个属性中的一个,以支持其他两个属性,如图6-1所示。

image-20230525200252771

如今,键值存储根据它们支持的两个CAP特性进行分类:

CP(一致性和分区容忍性)系统:CP键值存储支持一致性和分区容忍性,而牺牲了可用性。

AP(可用性和分区容忍性)系统:AP键值存储支持可用性和分区容忍性,而牺牲了一致性。

CA(一致性和可用性)系统:CA键值存储支持一致性和可用性,而牺牲了分区容忍性。由于网络故障不可避免,分布式系统必须容忍网络分区。因此,实际应用中不存在CA系统。

以上你读到的主要是定义部分。为了更易于理解,让我们看一些具体的例子。在分布式系统中,数据通常会被复制多次。假设数据在三个副本节点n1n2n3上复制,如图6-1所示。

理想情况

在理想世界中,网络分区永不发生。写入n1的数据会自动复制到n2n3。既达成了一致性,也实现了可用性。

image-20230525200306382

现实世界的分布式系统

在分布式系统中,分区是无法避免的,当分区发生时,我们必须在一致性和可用性之间做出选择。在图6-3中,n3宕机,无法与n1n2通信。如果客户端向n1n2写入数据,数据将无法传播到n3。如果数据写入n3但尚未传播到n1n2,那么n1n2将有陈旧的数据。

image-20230525200323569

如果我们选择一致性优于可用性(CP系统),我们必须阻止所有对n1n2的写操作,以避免这三台服务器之间的数据不一致,这会导致系统不可用。银行系统通常有极高的一致性要求。例如,银行系统显示最新的余额信息至关重要。如果由于网络分区导致不一致性,银行系统会在不一致性解决之前返回错误。

然而,如果我们选择可用性优于一致性(AP系统),即使系统可能返回陈旧的数据,它也会继续接受读取。对于写操作,n1n2将继续接受写入,数据将在网络分区解决时同步到n3

选择适合你的使用场景的正确CAP保证,是构建分布式键值存储的重要步骤。你可以和面试官讨论这个问题,然后根据讨论结果设计系统。

系统组件

在本节中,我们将讨论构建键值存储时使用的以下核心组件和技术:

  • 数据分区
  • 数据复制
  • 一致性
  • 不一致性解决
  • 故障处理
  • 系统架构图
  • 写入路径
  • 读取路径

以下内容主要基于三个流行的键值存储系统:Dynamo [4]、Cassandra [5]和BigTable [6]。

数据分区

对于大型应用程序,将完整的数据集装入单个服务器是不可行的。完成这项任务的最简单方式是将数据分割为较小的分区,然后存储在多个服务器中。在对数据进行分区时,有两个挑战:

  • 在多个服务器之间均匀分配数据。
  • 在添加或删除节点时,最小化数据移动。

在第5章讨论的一致性哈希是解决这些问题的好方法。让我们再次回顾一下一致性哈希在高层次上是如何工作的。

  • 首先,将服务器放置在一个哈希环上。在图6-4中,八个服务器,用s0, s1, …, s7表示,被放置在哈希环上。
  • 接下来,将一个键哈希到同一个环上,然后在顺时针方向移动时,它会被存储在遇到的第一个服务器上。例如,key0按照这个逻辑被存储在s1中。

image-20230525200347075

使用一致性哈希来分区数据有以下优点:

自动缩放: 服务器可以根据负载自动添加和删除。

异质性: 服务器的虚拟节点数与服务器容量成正比。例如,容量较大的服务器被分配了更多的虚拟节点。

数据复制

为了实现高可用性和可靠性,数据必须在N个服务器上异步复制,其中N是一个可配置的参数。这些N个服务器是按照以下逻辑选择的:在一个键被映射到哈希环上的某个位置后,从那个位置顺时针行走,并选择环上的前N个服务器存储数据副本。在图6-5中(N = 3),key0s1, s2,s3处复制。

image-20230525200401723

在虚拟节点的情况下,环上的前N个节点可能由少于N个物理服务器拥有。为了避免这个问题,在执行顺时针行走逻辑时,我们只选择唯一的服务器。

由于电源故障、网络问题、自然灾害等原因,同一数据中心的节点经常同时失败。为了更好的可靠性,副本被放置在不同的数据中心,数据中心通过高速网络连接。

一致性

由于数据在多个节点上复制,因此必须在各个副本间进行同步。Quorum(法定人数)共识可以保证读写操作的一致性。首先让我们定义几个概念。

N = 副本的数量

W = 大小为W的写入法定人数。为了认为写操作成功,必须从W个副本得到写操作的确认。

R = 大小为R的读取法定人数。为了认为读操作成功,读操作必须等待至少R个副本的响应。

考虑图6-6中N = 3的以下示例。

image-20230525200418446

W = 1并不意味着数据被写入一个服务器。例如,对于图6-6中的配置,数据在s0s1s2上复制。W = 1意味着协调者在认为写操作成功之前,必须收到至少一个确认。例如,如果我们从s1得到一个确认,我们就不需要再等待来自s0s2的确认。协调者充当客户端和节点之间的代理。

W, RN的配置是延迟和一致性之间的典型权衡。如果W = 1R = 1,则操作迅速返回,因为协调者只需要等待来自任何一个副本的响应。如果WR > 1,系统提供更好的一致性;然而,查询会慢,因为协调者必须等待来自最慢的副本的响应。

如果W + R > N,则保证强一致性,因为必须有至少一个具有最新数据的重叠节点以确保一致性。

如何配置N, W,和R以适应我们的用例?以下是一些可能的设置:

如果R = 1W = N,系统优化为快速读取。

如果W = 1R = N,系统优化为快速写入。

如果W + R > N,保证强一致性(通常N = 3, W = R = 2)。

如果W + R <= N,不能保证强一致性。

根据需求,我们可以调整W, R, N的值,以达到期望的一致性水平。

一致性模型

在设计键值存储时,一致性模型是另一个需要考虑的重要因素。一致性模型定义了数据一致性的程度,存在许多可能的一致性模型:

  • 强一致性:任何读取操作返回的值对应于最新更新的写入数据项的结果。客户端永远不会看到过时的数据。
  • 弱一致性:后续的读取操作可能看不到最新更新的值。
  • 最终一致性:这是弱一致性的一种特定形式。给定足够的时间,所有更新都会被传播,所有副本都是一致的。

强一致性通常是通过强制副本不接受新的读/写操作,直到每个副本都同意当前的写入。这种方法对于高可用系统并不理想,因为它可能阻止新的操作。Dynamo和Cassandra采用最终一致性,这是我们为键值存储推荐的一致性模型。从并发写入中,最终一致性允许不一致的值进入系统,并强制客户端读取值以进行协调。下一节将解释版本控制如何工作以实现协调。

不一致性解决:版本控制

复制提供了高可用性,但导致副本之间的不一致性。版本控制和向量锁被用来解决不一致性问题。版本控制是指将每一次数据修改视为数据的新的不可变版本。在我们讨论版本控制之前,让我们用一个例子来解释不一致性是如何发生的:

如图6-7所示,副本节点n1n2具有相同的值。让我们称这个值为原始值。服务器1服务器2对*get(“name”)*操作获取相同的值。

image-20230525200443156

接下来,服务器1将名字更改为“johnSanFrancisco”,服务器2将名字更改为“johnNewYork”,如图6-8所示。这两次更改是同时进行的。现在,我们有了冲突的值,称为版本v1v2

image-20230525200456151

在这个例子中,原始值可以被忽略,因为修改是基于它的。然而,没有明确的方法来解决最后两个版本的冲突。为了解决这个问题,我们需要一个可以检测冲突和协调冲突的版本系统。向量时钟是解决这个问题的常见技术。让我们看看向量时钟是如何工作的。

向量时钟是与数据项关联的*[服务器, 版本]*对。它可以用来检查一个版本是在其他版本之前,之后,还是与其他版本冲突。

假设向量时钟由D([S1, v1], [S2, v2], …, [Sn, vn])表示,其中D是一个数据项,v1是一个版本计数器,s1是一个服务器编号,等等。如果数据项D被写入到服务器Si,系统必须执行以下任务之一。

  • 如果*[Si, vi]存在,增加vi*。
  • 否则,创建一个新的条目*[Si, 1]*。

以上的抽象逻辑用图6-9中的具体例子来解释。

image-20230525200550038

  1. 客户端将数据项D1写入系统,写入操作由服务器Sx处理,现在Sx有向量时钟D1[(Sx, 1)]

  2. 另一个客户端读取最新的D1,更新它为D2,并将其写回。D2源于D1,所以它覆盖了D1。假设写操作由同一个服务器Sx处理,现在Sx有向量时钟D2([Sx, 2])

  3. 另一个客户端读取最新的D2,更新它为D3,并将其写回。假设写操作由服务器Sy处理,现在Sy有向量时钟D3([Sx, 2], [Sy, 1]))

  4. 另一个客户端读取最新的D2,更新它为D4,并将其写回。假设写操作由服务器Sz处理,现在SzD4([Sx, 2], [Sz, 1]))

  5. 当另一个客户端读取D3D4时,它发现了一个冲突,这个冲突是由于数据项D2SySz同时修改导致的。冲突由客户端解决,更新的数据被发送到服务器。假设写操作由Sx处理,现在SxD5([Sx, 3], [Sy, 1], [Sz, 1])。我们将在稍后解释如何检测冲突。

使用向量时钟,如果版本Y的向量时钟中每个参与者的版本计数器大于或等于版本X中的计数器,就可以轻易地判断出版本X是版本Y的祖先(即没有冲突)。例如,向量时钟*D([s0, 1], [s1, 1])D([s0, 1], [s1, 2])*的祖先。因此,没有记录冲突。

同样地,如果在Y的向量时钟中存在任何参与者的计数器小于X中对应的计数器,就可以判断版本XY的兄弟版本(即存在冲突)。例如,以下两个向量时钟表明存在冲突:D([s0, 1], [s1, 2])D([s0, 2], [s1, 1])。

尽管向量时钟可以解决冲突,但有两个显著的缺点。首先,向量时钟增加了客户端的复杂性,因为它需要实现冲突解决逻辑。

其次,向量时钟中的*[服务器:版本]*对可能会迅速增长。为了解决这个问题,我们为长度设定一个阈值,如果超过限制,最旧的对会被删除。这可能会导致在调解时效率低下,因为无法准确地确定后代关系。然而,根据Dynamo论文[4],亚马逊在生产环境中尚未遇到这个问题;因此,对大多数公司来说,这可能是一个可以接受的解决方案。

处理故障

与任何大规模系统一样,故障不仅不可避免,而且很常见。处理故障场景非常重要。在本节中,我们首先介绍检测故障的技术。然后,我们将讨论常见的故障解决策略。

故障检测

在分布式系统中,仅仅因为另一台服务器说某服务器已经宕机,就相信这台服务器已宕机是不够的。通常,至少需要两个独立的信息源才能标记服务器已宕机。

如图6-10所示,全向多播是一个简单直接的解决方案。然而,当系统中有很多服务器时,这是低效的。

image-20230525200611485

更好的解决方案是使用像gossip协议这样的分布式故障检测方法。gossip协议的工作方式如下:

  • 每个节点维护一个节点成员列表,其中包含成员ID和心跳计数器。
  • 每个节点周期性地增加其心跳计数器。
  • 每个节点周期性地向一组随机节点发送心跳,这些节点反过来再向另一组节点传播。
  • 一旦节点接收到心跳,成员列表将更新为最新的信息。
  • 如果心跳在预定的时间段内没有增加,那么这个成员就被认为是离线的。

image-20230525200627631

如图6-11所示:

  • 节点s0维护左侧显示的节点成员列表。
  • 节点s0注意到节点s2(成员ID = 2)的心跳计数器已经很长时间没有增加了。
  • 节点s0将包含s2信息的心跳发送给一组随机节点。一旦其他节点确认s2的心跳计数器已经很长时间没有更新,节点s2就被标记为宕机,并将此信息传播到其他节点。

处理临时故障

通过gossip协议检测到故障后,系统需要部署某些机制以确保可用性。在严格的仲裁(quorum)方法中,如仲裁共识部分所示,读写操作可能会被阻塞。

一种叫做“松散仲裁”[4]的技术被用来提高可用性。系统选择哈希环上的前W个健康服务器进行写操作,选择前R个健康服务器进行读操作,而不是强制执行仲裁要求。离线的服务器将被忽略。

如果一台服务器由于网络或服务器故障而不可用,另一台服务器将临时处理请求。当宕机的服务器恢复后,会将更改推送回去以实现数据一致性。这个过程叫做Hinted Handoff(暗示性移交)。由于图6-12中的s2不可用,s3将临时处理读写操作。当s2恢复在线时,s3会将数据移交回给s2

image-20230525200643811

处理永久性故障

Hinted Handoff(暗示性移交)用于处理临时故障。那么如果副本永久性地不可用呢?为了处理这样的情况,我们实现了一种抗熵协议以保持副本同步。抗熵涉及比较副本上的每一部分数据并将每个副本更新到最新版本。我们使用一个叫做Merkle树的结构来检测不一致性,并最小化传输的数据量。

引用自维基百科[7]:“哈希树或Merkle树是一种树,其中每个非叶节点都被标记为其子节点的标签或值(在叶子节点的情况下)的哈希。哈希树允许对大型数据结构的内容进行高效和安全的验证。”

假设键空间是从1到12,以下步骤展示了如何构建一个Merkle树。高亮的框表示不一致性。

步骤1:将键空间划分为桶(在我们的例子中有4个)如图6-13所示。一个桶被用作根级节点,以维持树的深度限制。

image-20230525200700209

步骤2:一旦创建了桶,就使用统一的哈希方法对桶中的每个键进行哈希(图6-14)。

image-20230525200711090

步骤3:为每个桶创建一个哈希节点(图6-15)。

image-20230525200720689

步骤4:通过计算子节点的哈希值,向上构建树,直到根节点(图6-16)。

image-20230525200732461

要比较两个Merkle树,首先比较根哈希。如果根哈希匹配,那么两个服务器有相同的数据。如果根哈希不同,那么将比较左子节点哈希,然后是右子节点哈希。你可以遍历树来找出哪些桶没有同步,并只同步那些桶。

使用Merkle树,需要同步的数据量与两个副本之间的差异成正比,而不是它们包含的数据量。在真实世界的系统中,桶的大小相当大。例如,可能的配置是每十亿个键有一百万个桶,所以每个桶只包含1000个键。

处理数据中心故障

由于电力中断、网络中断、自然灾害等原因,可能会发生数据中心故障。为了构建一个能够处理数据中心故障的系统,将数据复制到多个数据中心是非常重要的。即使一个数据中心完全离线,用户仍然可以通过其他数据中心访问数据。

系统架构图

现在我们已经讨论了设计键值存储时的不同技术考虑因素,我们可以将注意力转移到架构图上,如图6-17所示。

image-20230525200746256

架构的主要特点如下:

  • 客户端通过简单的API与键值存储进行通信:get(key)put(key,value)
  • 协调器是一个充当客户端和键值存储之间代理的节点。
  • 节点使用一致性哈希在环上分布。
  • 系统完全分散,添加和移动节点可以自动进行。
  • 数据在多个节点上进行复制。
  • 由于每个节点具有相同的责任集,因此没有单点故障。

由于设计是分散的,每个节点执行许多任务,如图6-18所示。

image-20230525200759966

写入路径

图6-19解释了写入请求被定向到特定节点后会发生什么。请注意,写/读路径的建议设计主要基于Cassandra [8]的架构。

image-20230525200811822

  1. 写入请求被持久化在提交日志文件中。

  2. 数据被保存在内存缓存中。

  3. 当内存缓存满了或达到预定义的阈值时,数据被刷新到磁盘上的SSTable [9]。注:排序字符串表(SSTable)是一个排序的对列表。对于希望更多了解SStable的读者,请参考参考资料[9]。

读取路径

在读取请求被定向到特定节点后,首先检查数据是否在内存缓存中。如果是,数据将被返回给客户端,如图6-20所示。

image-20230525200822818

如果数据不在内存中,它将从磁盘中检索出来。我们需要一种有效的方式来找出哪个SSTable包含这个键。布隆过滤器 [10] 通常被用来解决这个问题。

图6-21显示了当数据不在内存中的读取路径。

image-20230525200837584

  1. 系统首先检查数据是否在内存中。如果没有,执行第2步。
  2. 如果数据不在内存中,系统检查布隆过滤器。
  3. 布隆过滤器用于确定哪些SSTable可能包含该键。
  4. SSTable返回数据集的结果。
  5. 数据集的结果返回给客户端。

总结

本章介绍了许多概念和技术。为了帮助你回顾记忆,下表总结了分布式键值存储所使用的特性及对应的技术。

image-20230525200853259

参考资料

[1] 亚马逊 DynamoDB: https://aws.amazon.com/dynamodb/

[2] memcached: https://memcached.org/

[3] Redis: https://redis.io/

[4] Dynamo: 亚马逊的高可用键值存储: https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf

[5] Cassandra: https://cassandra.apache.org/

[6] Bigtable: 一个分布式结构化数据存储系统: https://static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf

[7] 默克尔树: https://en.wikipedia.org/wiki/Merkle_tree

[8] Cassandra架构: https://cassandra.apache.org/doc/latest/architecture/

[9] SStable: https://www.igvita.com/2012/02/06/sstable-and-log-structured-storage-leveldb/

[10] 布隆过滤器 https://en.wikipedia.org/wiki/Bloom_filter

你好,我是拾叁,7年开发老司机、互联网两年外企5年。怼得过阿三老美,也被PR comments搞崩溃过。这些年我打过工,创过业,接过私活,也混过upwork。赚过钱也亏过钱。一路过来,给我最深的感受就是不管学什么,一定要不断学习。只要你能坚持下来,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你还没什么方向,可以先关注我[公众号:更AI (power_ai)],这里会经常分享一些前沿资讯和编程知识,帮你积累弯道超车的资本。

猜你喜欢

转载自blog.csdn.net/smarter_AI/article/details/131819407