本文首发于公众号:更AI (power_ai),欢迎关注,编程、AI干货及时送!
键值存储,也被称为键值数据库,是一种非关系型数据库。每个唯一标识符以键的形式存储,与其关联的值一起。这种数据配对被称为“键值”对。
在一个键值对中,键必须是唯一的,可以通过键访问与键关联的值。键可以是纯文本或哈希值。出于性能考虑,短键更好。键长什么样子呢?以下是一些例子:
- 纯文本键:“last_logged_in_at”
- 哈希键:253DDEC4
键值对中的值可以是字符串、列表、对象等。在键值存储中,值通常被视为一个不透明的对象,比如 Amazon dynamo [1],Memcached [2],Redis [3]等。
以下是键值存储中的一段数据片段:
在本章中,你被要求设计一个支持以下操作的键值存储:
- put(key, value) // 插入与“键”关联的“值”
- get(key) // 获取与“键”关联的“值”
理解问题并建立设计范围
没有完美的设计。每种设计都实现了特定的平衡,关于读取、写入和内存使用的权衡。还需要做出的权衡是一致性和可用性之间。在这一章,我们设计的键值存储包含以下特点:
- 键值对的大小小:小于10 KB。
- 能够存储大数据。
- 高可用性:系统在故障期间也能快速响应。
- 高可扩展性:系统可以扩展以支持大数据集。
- 自动扩展:根据流量自动添加/删除服务器。
- 可调整的一致性。
- 低延迟。
单服务器键值存储
开发一个位于单个服务器中的键值存储是容易的。直观的方法是在哈希表中存储键值对,将所有内容保留在内存中。尽管内存访问速度快,但由于空间限制,可能无法将所有内容装入内存。可以进行两种优化,以便在单个服务器中存储更多数据:
- 数据压缩
- 只在内存中存储经常使用的数据,其余的存储在磁盘上
即使进行了这些优化,单个服务器的容量也可能很快达到极限。需要分布式键值存储来支持大数据。
分布式键值存储
分布式键值存储也被称为分布式哈希表,它将键值对分布在多个服务器中。设计分布式系统时,理解CAP(C一致性,A可用性,P分区容忍性)理论是很重要的。
CAP定理
CAP定理指出,分布式系统不可能同时提供以下三种保证的两种以上:一致性、可用性和分区容忍性。让我们来明确一些定义。
一致性:一致性意味着所有客户端在同一时间看到的数据是一致的,无论他们连接到哪个节点。
可用性:可用性意味着任何请求数据的客户端都能得到响应,即使一些节点挂了。
分区容忍性:分区表示两个节点之间的通信中断。分区容忍性意味着系统在网络分区的情况下仍能继续运行。
CAP定理指出,必须牺牲三个属性中的一个,以支持其他两个属性,如图6-1所示。
如今,键值存储根据它们支持的两个CAP特性进行分类:
CP(一致性和分区容忍性)系统:CP键值存储支持一致性和分区容忍性,而牺牲了可用性。
AP(可用性和分区容忍性)系统:AP键值存储支持可用性和分区容忍性,而牺牲了一致性。
CA(一致性和可用性)系统:CA键值存储支持一致性和可用性,而牺牲了分区容忍性。由于网络故障不可避免,分布式系统必须容忍网络分区。因此,实际应用中不存在CA系统。
以上你读到的主要是定义部分。为了更易于理解,让我们看一些具体的例子。在分布式系统中,数据通常会被复制多次。假设数据在三个副本节点n1,n2和n3上复制,如图6-1所示。
理想情况
在理想世界中,网络分区永不发生。写入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 [4]、Cassandra [5]和BigTable [6]。
数据分区
对于大型应用程序,将完整的数据集装入单个服务器是不可行的。完成这项任务的最简单方式是将数据分割为较小的分区,然后存储在多个服务器中。在对数据进行分区时,有两个挑战:
- 在多个服务器之间均匀分配数据。
- 在添加或删除节点时,最小化数据移动。
在第5章讨论的一致性哈希是解决这些问题的好方法。让我们再次回顾一下一致性哈希在高层次上是如何工作的。
- 首先,将服务器放置在一个哈希环上。在图6-4中,八个服务器,用s0, s1, …, s7表示,被放置在哈希环上。
- 接下来,将一个键哈希到同一个环上,然后在顺时针方向移动时,它会被存储在遇到的第一个服务器上。例如,key0按照这个逻辑被存储在s1中。
使用一致性哈希来分区数据有以下优点:
自动缩放: 服务器可以根据负载自动添加和删除。
异质性: 服务器的虚拟节点数与服务器容量成正比。例如,容量较大的服务器被分配了更多的虚拟节点。
数据复制
为了实现高可用性和可靠性,数据必须在N个服务器上异步复制,其中N是一个可配置的参数。这些N个服务器是按照以下逻辑选择的:在一个键被映射到哈希环上的某个位置后,从那个位置顺时针行走,并选择环上的前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。
在这个例子中,原始值可以被忽略,因为修改是基于它的。然而,没有明确的方法来解决最后两个版本的冲突。为了解决这个问题,我们需要一个可以检测冲突和协调冲突的版本系统。向量时钟是解决这个问题的常见技术。让我们看看向量时钟是如何工作的。
向量时钟是与数据项关联的*[服务器, 版本]*对。它可以用来检查一个版本是在其他版本之前,之后,还是与其他版本冲突。
假设向量时钟由D([S1, v1], [S2, v2], …, [Sn, vn])表示,其中D是一个数据项,v1是一个版本计数器,s1是一个服务器编号,等等。如果数据项D被写入到服务器Si,系统必须执行以下任务之一。
- 如果*[Si, vi]存在,增加vi*。
- 否则,创建一个新的条目*[Si, 1]*。
以上的抽象逻辑用图6-9中的具体例子来解释。
-
客户端将数据项D1写入系统,写入操作由服务器Sx处理,现在Sx有向量时钟D1[(Sx, 1)]。
-
另一个客户端读取最新的D1,更新它为D2,并将其写回。D2源于D1,所以它覆盖了D1。假设写操作由同一个服务器Sx处理,现在Sx有向量时钟D2([Sx, 2])。
-
另一个客户端读取最新的D2,更新它为D3,并将其写回。假设写操作由服务器Sy处理,现在Sy有向量时钟D3([Sx, 2], [Sy, 1]))。
-
另一个客户端读取最新的D2,更新它为D4,并将其写回。假设写操作由服务器Sz处理,现在Sz有D4([Sx, 2], [Sz, 1]))。
-
当另一个客户端读取D3和D4时,它发现了一个冲突,这个冲突是由于数据项D2被Sy和Sz同时修改导致的。冲突由客户端解决,更新的数据被发送到服务器。假设写操作由Sx处理,现在Sx有D5([Sx, 3], [Sy, 1], [Sz, 1])。我们将在稍后解释如何检测冲突。
使用向量时钟,如果版本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])。
尽管向量时钟可以解决冲突,但有两个显著的缺点。首先,向量时钟增加了客户端的复杂性,因为它需要实现冲突解决逻辑。
其次,向量时钟中的*[服务器:版本]*对可能会迅速增长。为了解决这个问题,我们为长度设定一个阈值,如果超过限制,最旧的对会被删除。这可能会导致在调解时效率低下,因为无法准确地确定后代关系。然而,根据Dynamo论文[4],亚马逊在生产环境中尚未遇到这个问题;因此,对大多数公司来说,这可能是一个可以接受的解决方案。
处理故障
与任何大规模系统一样,故障不仅不可避免,而且很常见。处理故障场景非常重要。在本节中,我们首先介绍检测故障的技术。然后,我们将讨论常见的故障解决策略。
故障检测
在分布式系统中,仅仅因为另一台服务器说某服务器已经宕机,就相信这台服务器已宕机是不够的。通常,至少需要两个独立的信息源才能标记服务器已宕机。
如图6-10所示,全向多播是一个简单直接的解决方案。然而,当系统中有很多服务器时,这是低效的。
更好的解决方案是使用像gossip协议这样的分布式故障检测方法。gossip协议的工作方式如下:
- 每个节点维护一个节点成员列表,其中包含成员ID和心跳计数器。
- 每个节点周期性地增加其心跳计数器。
- 每个节点周期性地向一组随机节点发送心跳,这些节点反过来再向另一组节点传播。
- 一旦节点接收到心跳,成员列表将更新为最新的信息。
- 如果心跳在预定的时间段内没有增加,那么这个成员就被认为是离线的。
如图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。
处理永久性故障
Hinted Handoff(暗示性移交)用于处理临时故障。那么如果副本永久性地不可用呢?为了处理这样的情况,我们实现了一种抗熵协议以保持副本同步。抗熵涉及比较副本上的每一部分数据并将每个副本更新到最新版本。我们使用一个叫做Merkle树的结构来检测不一致性,并最小化传输的数据量。
引用自维基百科[7]:“哈希树或Merkle树是一种树,其中每个非叶节点都被标记为其子节点的标签或值(在叶子节点的情况下)的哈希。哈希树允许对大型数据结构的内容进行高效和安全的验证。”
假设键空间是从1到12,以下步骤展示了如何构建一个Merkle树。高亮的框表示不一致性。
步骤1:将键空间划分为桶(在我们的例子中有4个)如图6-13所示。一个桶被用作根级节点,以维持树的深度限制。
步骤2:一旦创建了桶,就使用统一的哈希方法对桶中的每个键进行哈希(图6-14)。
步骤3:为每个桶创建一个哈希节点(图6-15)。
步骤4:通过计算子节点的哈希值,向上构建树,直到根节点(图6-16)。
要比较两个Merkle树,首先比较根哈希。如果根哈希匹配,那么两个服务器有相同的数据。如果根哈希不同,那么将比较左子节点哈希,然后是右子节点哈希。你可以遍历树来找出哪些桶没有同步,并只同步那些桶。
使用Merkle树,需要同步的数据量与两个副本之间的差异成正比,而不是它们包含的数据量。在真实世界的系统中,桶的大小相当大。例如,可能的配置是每十亿个键有一百万个桶,所以每个桶只包含1000个键。
处理数据中心故障
由于电力中断、网络中断、自然灾害等原因,可能会发生数据中心故障。为了构建一个能够处理数据中心故障的系统,将数据复制到多个数据中心是非常重要的。即使一个数据中心完全离线,用户仍然可以通过其他数据中心访问数据。
系统架构图
现在我们已经讨论了设计键值存储时的不同技术考虑因素,我们可以将注意力转移到架构图上,如图6-17所示。
架构的主要特点如下:
- 客户端通过简单的API与键值存储进行通信:get(key) 和 put(key,value)。
- 协调器是一个充当客户端和键值存储之间代理的节点。
- 节点使用一致性哈希在环上分布。
- 系统完全分散,添加和移动节点可以自动进行。
- 数据在多个节点上进行复制。
- 由于每个节点具有相同的责任集,因此没有单点故障。
由于设计是分散的,每个节点执行许多任务,如图6-18所示。
写入路径
图6-19解释了写入请求被定向到特定节点后会发生什么。请注意,写/读路径的建议设计主要基于Cassandra [8]的架构。
-
写入请求被持久化在提交日志文件中。
-
数据被保存在内存缓存中。
-
当内存缓存满了或达到预定义的阈值时,数据被刷新到磁盘上的SSTable [9]。注:排序字符串表(SSTable)是一个排序的对列表。对于希望更多了解SStable的读者,请参考参考资料[9]。
读取路径
在读取请求被定向到特定节点后,首先检查数据是否在内存缓存中。如果是,数据将被返回给客户端,如图6-20所示。
如果数据不在内存中,它将从磁盘中检索出来。我们需要一种有效的方式来找出哪个SSTable包含这个键。布隆过滤器 [10] 通常被用来解决这个问题。
图6-21显示了当数据不在内存中的读取路径。
- 系统首先检查数据是否在内存中。如果没有,执行第2步。
- 如果数据不在内存中,系统检查布隆过滤器。
- 布隆过滤器用于确定哪些SSTable可能包含该键。
- SSTable返回数据集的结果。
- 数据集的结果返回给客户端。
总结
本章介绍了许多概念和技术。为了帮助你回顾记忆,下表总结了分布式键值存储所使用的特性及对应的技术。
参考资料
[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)],这里会经常分享一些前沿资讯和编程知识,帮你积累弯道超车的资本。