本文主要参考 System Design Interview: An Insider’s Guide(CHAPTER 6)
键值存储(key-value store),也被称为键值数据库(key-value database),是一个非关系型数据库。每一个独特的标识符都被存储为一个带有相关值的键。这种数据配对被称为 "键-值 "对。 在一个键值对中,键必须是唯一的,与键相关的值可以通过键来访问。键可以是纯文本或散列值。出于性能方面的考虑,短键的效果更好。键是什么样子的?这里有几个例子:
• Plain text key: “last_logged_in_at”
• Hashed key: 253DDEC4
键值对中的值可以是字符串、列表、对象等。在键值存储中,值通常被视为不透明的对象,如Amazon dynamo , Memcached , Redis , 等等。
下面是一个键值存储的数据片段:
你需要设计一个支持以下操作的键值存储。
put(key, value) // insert “value” associated with “key”
get(key) // get “value” associated with “key”
没有完美的设计。每种设计都会在读、写和内存使用的权衡方面达到特定的平衡。另一个必须做出的权衡是在一致性和可用性之间。我们希望设计了一个包括以下特点的键值存储:
一个键值对的大小很小:小于10KB。
有能力存储大数据。
高可用性。系统响应迅速,即使在故障期间也是如此。
高可扩展性。系统可以被扩展以支持大型数据集。
自动扩展。服务器的增加/删除应该是基于流量的自动。
可调整的一致性。
低延迟。
开发一个部署在单个服务器中的键值存储很容易。一个直观的方法是将键值对存储在一个哈希表中,这样可以将所有东西都保存在内存中。尽管内存访问速度很快,但由于空间的限制,把所有东西都放在内存中可能是不可能的。有两种优化方法可以用来在单个服务器中容纳更多的数据:
数据压缩
只在内存中存储经常使用的数据,其余的存储在磁盘上
即使有这些优化,单个服务器也会很快达到其容量。需要一个分布式键值存储来支持海量数据。
分布式键值存储也被称为分布式哈希表,它将键值对分布在许多服务器上。在设计分布式系统时,理解CAP(Consistency一致性、Availability可用性、Partition Tolerance分区容忍度)理论很重要。
CAP理论指出,一个分布式系统不可能同时提供这三种保证中的两种以上:一致性、可用性和分区容忍度。让我们建立几个定义。
一致性:一致性意味着所有客户在同一时间看到相同的数据,无论他们连接到哪个节点。
可用性:可用性意味着任何请求数据的客户端都能得到响应,即使有些节点发生了故障。
分区容忍度:分区表示两个节点之间的通信中断。 分区容忍度意味着尽管网络分区,系统仍能继续运行。
CAP理论指出,必须牺牲三个属性中的一个来支持三个属性中的两个,如图6-1所示。
现在,键值存储是根据它们支持的两个CAP特性来分类的。
CP(一致性和分区容忍)系统:CP键值存储支持一致性和分区容忍,同时牺牲了可用性。
AP(可用性和分区容忍度)系统:一个AP键值存储支持可用性和分区容忍度,同时牺牲了一致性。
CA(一致性和可用性)系统:CA键值存储支持一致性和可用性,同时牺牲了分区容忍度。由于网络故障是不可避免的,一个分布式系统必须容忍网络分区。因此,CA系统不能存在于现实世界的应用中。
你在上面读到的主要是定义部分。为了使它更容易理解,让我们看一下一些具体的例子。在分布式系统中,数据通常被多次复制。假设数据被复制在三个复制节点上,如图6-2所示,n1、n2和n3。
在理想的世界里,网络分区永远不会发生。写入n1的数据会自动复制到n2和n3。这就实现了一致性和可用性。
在一个分布式系统中,分区是无法避免的,当分区发生时,我们必须在一致性和可用性之间做出选择。在图6-3中,n3发生故障,无法与n1和n2通信。如果客户端向n1或n2写数据,数据就不能传播到n3。如果数据被写入n3,但还没有传播到n1和n2,n1和n2就会有陈旧的数据。
如果我们选择一致性大于可用性(CP系统),我们必须阻止对n1和n2的所有写操作,以避免这三个服务器之间的数据不一致,这使得系统不可用。银行系统通常有极高的一致性要求。例如,对银行系统来说,显示最新的余额信息是至关重要的。如果由于网络分区而发生不一致,在不一致问题解决之前,银行系统会返回一个错误。
然而,如果我们选择可用性大于一致性(AP系统),系统就会一直接受读取,即使它可能返回陈旧的数据。对于写,n1和n2将继续接受写,当网络分区解决后,数据将被同步到n3。 选择适合你使用情况的正确CAP保证是建立分布式键值存储的一个重要步骤。
我们将讨论以下用于构建键值存储的核心组件和技术。
下面的内容主要是基于三个流行的键值存储系统。Dynamo、Cassandra和BigTable。
对于大型应用来说,将完整的数据集装入一台服务器是不可行的。实现这一目标的最简单方法是将数据分成较小的分区,并将其存储在多个服务器中。在对数据进行分区时,有两个挑战。
一致性hash是解决这些问题的一个很好的技术。
首先,服务器被放置在一个哈希环上。在图 6-4 中,由 s0、s1、…、s7 表示的八个服务器被放置在哈希环上。
接下来,将key散列到同一个环上,并存储在顺时针方向移动时遇到的第一个服务器上。例如,key0 使用此逻辑存储在 s1 中。
使用一致性哈希对数据进行分区具有以下优点:
为了实现高可用性和可靠性,数据必须在N个服务器上进行异步复制,其中N是一个可配置参数。这N个服务器的选择采用以下逻辑:在一个key被映射到哈希环上的某个位置后,从该位置顺时针走,选择环上的前N个服务器来存储数据副本。在图6-5(N=3)中,key0被复制在s1、s2和s3。
对于虚拟节点,环上的前N个节点可能由少于N个物理服务器拥有。为了避免这个问题,我们在执行顺时针行走逻辑时只选择唯一的服务器。 由于停电、网络问题、自然灾害等原因,同一数据中心的节点经常在同一时间发生故障。为了提高可靠性,副本被放置在不同的数据中心,并且数据中心通过高速网络连接。
由于数据在多个节点上复制,因此必须跨副本同步。 Quorum 共识可以保证读写操作的一致性。让我们首先建立一些定义。
N = 副本数
W = 大小为 W 的写入仲裁。要使写入操作被视为成功,必须从 W 个副本确认写入操作。
R = 大小为 R 的读取仲裁。要使读取操作被视为成功,读取操作必须等待至少 R 个副本的响应。
考虑下图 6-6 中 N = 3 的示例。
W = 1并不意味着数据被写入了一台服务器。例如,在图6-6的配置中,数据是在s0、s1和s2复制的。W = 1意味着协调器必须在写操作被认为是成功之前收到至少一个确认。例如,如果我们从s1得到一个确认,我们就不再需要等待s0和s2的确认了。协调器在客户端和节点之间充当代理角色
W、R和N的配置是一个典型的延迟和一致性之间的权衡。如果W = 1或R = 1,一个操作就会快速返回,因为协调者只需要等待任何一个副本的响应。如果W或R > 1,系统提供更好的一致性;然而,查询会更慢,因为协调者必须等待最慢的副本的响应。
如果W + R > N,强一致性得到保证,因为必须至少有一个重叠节点拥有最新的数据以保证一致性。
如何配置N、W和R以适应我们的使用情况?下面是一些可能的设置。
如果R = 1,W = N,系统被优化为快速读取。
如果W = 1,R = N,系统被优化为快速写入。
如果W + R > N,强一致性得到保证(通常N = 3,W = R = 2)。
如果W + R <= N,强一致性就得不到保证。
根据需求,可以调整W、R、N的值,以达到所需的一致性水平
一致性模型是设计键值存储时需要考虑的其他重要因素。一致性模型定义了数据的一致性程度,存在着广泛的可能的一致性模型。
强一致性通常是通过强迫一个副本不接受新的读/写,直到每个副本都同意当前的写来实现。这种方法对于高可用系统来说并不理想,因为它可能会阻止新的操作。Dynamo和Cassandra采用最终一致性,这是我们推荐的键值存储的一致性模型。从并发写入来看,最终一致性允许不一致的值进入系统,并迫使客户端读取这些值来进行调节。下一节将解释调和如何与版本管理一起工作。
复制提供了高可用性,但会导致副本之间的不一致。版本化和向量锁用于解决不一致问题。版本化意味着将每个数据修改视为新的不可变数据版本。在讨论版本化之前,让我们通过一个例子来解释不一致是如何发生的: 如图 6-7 所示,副本节点 n1 和 n2 具有相同的值。让我们将此值称为原始值。服务器 1 和服务器 2 获取相同的 get(“name”) 操作值
接下来,服务器 1 将名称更改为“johnSanFrancisco”,服务器 2 将名称更改为“johnNewYork”,如图 6-8 所示。这两个变化是同时进行的。现在,我们有冲突的值,称为版本 v1 和 v2。
在这个例子中,原始值可以被忽略,因为修改是基于它的。然而,没有明确的方法来解决最后两个版本的冲突。为了解决这个问题,我们需要一个可以检测冲突和调和冲突的版本系统。矢量时钟是解决这个问题的一个常用技术。让我们研究一下矢量时钟是如何工作的。
矢量时钟是一个与数据项相关的[server, version]对。它可以用来检查一个版本是否先于、成功或与其他版本冲突。
假设一个矢量钟用D([S1, v1], [S2, v2], …, [Sn, vn])表示,其中D是一个数据项,v1是一个版本计数器,s1是一个服务器号码,等等。如果数据项D被写入服务器Si,系统必须执行以下任务之一。
如图6-9所示,用一个具体的例子来解释上述的抽象逻辑。
使用矢量时钟,如果Y的矢量时钟中每个参与者的版本计数器大于或等于版本X中的计数器,就很容易知道版本X是版本Y的祖先(即没有冲突)。例如,矢量时钟D([s0, 1], [s1, 1])是D([s0, 1], [s1, 2])的祖先。因此,没有冲突被记录下来。
同样地,如果在Y的向量钟中有任何参与者的计数器小于X中的相应计数器,你就可以知道一个版本X是Y的兄弟姐妹(即存在冲突)。D([s0, 1], [s1, 2]) 和 D([s0, 2], [s1, 1])。
尽管矢量时钟可以解决冲突,但有两个明显的缺点。首先,矢量时钟增加了客户端的复杂性,因为它需要实现冲突解决逻辑。
第二,向量钟中的 [server:version] 对可能会迅速增长。为了解决这个问题,我们为长度设置了一个阈值,如果超过了这个阈值,最古老的对就会被删除。这可能会导致和解的效率低下,因为不能准确地确定子孙关系。然而,根据Dynamo论文[4],亚马逊还没有在生产中遇到这个问题;因此,对于大多数公司来说,这可能是一个可以接受的解决方案。
与任何大规模的系统一样,故障不仅是不可避免的,而且是常见的。处理故障情况是非常重要的。本节首先介绍检测故障的技术。然后,介绍常见的故障解决策略。
在一个分布式系统中,仅仅因为另一台服务器这么说,就认为一台服务器停机是不够的。通常情况下,至少需要两个独立的信息源来标记一个服务器停机。 如图6-10所示,全联通组播是一个直接的解决方案。然而,当系统中存在许多服务器时,这是不高效的。
更好的解决方案是使用分散式故障检测方法,如 gossip 协议。 Gossip 协议的工作原理如下:
每个节点维护一个节点成员列表,其中包含成员 ID 和心跳计数器。
每个节点定期增加其心跳计数器。
每个节点定期向一组随机节点发送心跳,这些节点依次传播到另一组节点。
一旦节点收到心跳,成员列表将更新为最新信息。
如果心跳没有增加超过预定义的时间,则该成员被视为离线。
如图6-11所示。
节点s0维护着左侧所示的节点成员列表。
节点s0注意到节点s2(成员ID=2)的心跳计数器已经很长时间没有增加了。
节点s0向一组随机节点发送包括s2的信息的心跳。一旦其他节点确认s2的心跳计数器长时间没有更新,节点s2就会被标记下来,并将这个信息传播给其他节点。
通过 gossip 协议检测到故障后,系统需要部署一定的机制来保证可用性。在严格的仲裁方法中,可以阻止读取和写入操作,如仲裁共识部分所示。一种称为“sloppy quorum”的技术用于提高可用性。系统没有强制执行法定人数要求,而是选择前 W 个健康的服务器进行写入,并选择前 R 个健康的服务器进行哈希环上的读取。离线服务器被忽略。如果由于网络或服务器故障导致服务器不可用,另一台服务器将临时处理请求。当宕机的服务器启动时,更改将被推回以实现数据一致性。这个过程称为提示切换(Hinted handoff)。由于图 6-12 中 s2 不可用,读写操作将暂时由 s3 处理。当 s2 重新上线时,s3 会将数据交还给 s2。
提示切换用于处理临时故障。如果副本永久不可用怎么办?为了处理这种情况,我们实现了一个反熵协议来保持副本同步。反熵涉及比较副本上的每条数据并将每个副本更新到最新版本。 Merkle 树用于不一致检测和最小化传输的数据量。
“哈希树或 Merkle 树是一种树,其中每个非叶节点都用其子节点的标签或值(如果是叶)的哈希值进行标记。哈希树允许对大型数据结构的内容进行有效和安全的验证”。
假设key空间从 1 到 12,以下步骤展示了如何构建 Merkle 树。突出显示的框表示不一致。
第 1 步:将key空间划分为桶(在我们的示例中为 4 个),如图 6-13 所示。桶用作根级节点以保持树的有限深度。
第 2 步:创建桶后,使用统一的散列方法对桶中的每个键进行散列(图 6-14)。
第 3 步:为每个桶创建一个哈希节点(图 6-15)。
第 4 步:通过计算子节点的哈希值向上构建树直到根节点(图 6-16)。
要比较两个Merkle树,首先要比较根哈希值。如果根哈希值匹配,则两个服务器有相同的数据。如果根哈希值不一致,那么就比较左边的子哈希值,然后是右边的子哈希值。你可以遍历树,找到哪些桶没有被同步,只同步这些桶。 使用Merkle树,需要同步的数据量与两个副本之间的差异成正比,而不是它们包含的数据量。在现实世界的系统中,桶的大小是相当大的。例如,一个可能的配置是每十亿个键有一百万个桶,所以每个桶只包含1000个键。
数据中心中断可能由于停电、网络中断、自然灾害等原因发生。要构建能够处理数据中心中断的系统,跨多个数据中心复制数据非常重要。即使一个数据中心完全离线,用户仍然可以通过其他数据中心访问数据。
现在我们已经讨论了设计键值存储的不同技术考虑,我们可以将注意力转移到架构图上,如图 6-17 所示。
该架构的主要特点如下:
由于设计是去中心化的,每个节点执行许多任务,如图6-18所示。
图6-19解释了在一个写请求被引导到一个特定的节点后会发生什么。请注意,建议的写/读路径的设计主要是基于Cassandra的架构。
读取请求被引导到一个特定的节点后,它首先检查数据是否在内存缓存中。如果是,数据就会被返回给客户端,如图6-20所示。
如果数据不在内存中,就会从磁盘中检索出来。我们需要一个有效的方法来找出哪个SSTable中包含key。布隆过滤器通常被用来解决这个问题。 当数据不在内存中时,其读取路径如图6-21所示。
下表总结了分布式键值存储的特点和相应的技术。
目标/问题 | 技术 |
---|---|
存储海量数据的能力 | 使用一致性哈希将数据分发给不同的服务器 |
高可靠读取 | 数据复制;多数据中心 |
高可靠写入 | 版本化和使用向量时钟解决冲突 |
数据分区 | 一致性哈希 |
增量可扩展性 | 一致性哈希 |
异构性 | 一致性哈希 |
可调节的一致性 | Quorun共识 |
处理暂时性故障 | Sloppy quorum和提示切换(hinted handoff) |
处理永久性故障 | Merkle树 |
处理数据中心故障 | 跨数据中心复制 |
如果大家对文章内容有疑问或建议,欢迎评论区留言,我们可以一起讨论,共同提高
转载请标明原作者:https://blog.csdn.net/qq_36622751