系统设计面试:卷1 第六章: 如何设计一个键值存储

47 阅读21分钟

声明:本文仅作学习使用。 键值存储(也称为键值数据库)是一种非关系数据库。每个唯一标识符存储为带有其关联值的键。这种数据配对称为“键-值”对。

在键-值对中,键必须是唯一的,并且与该键相关联的值可以通过该键访问。键可以是纯文本或散列值。出于性能考虑,短键效果更好。keys看起来像什么?

  • 纯文本键:" last_logged_in_at "
  • 散列键:253DDEC4 键值对中的值可以是字符串、列表、对象等。该值通常在键值存储中被视为不透明对象,例如Amazon dynamo , Memcached , Redis等。

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

image.png

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

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

了解需求并且确定设计的范围

没有完美的设计。每种设计在读、写和内存使用方面都达到了特定的平衡。必须在一致性和可用性之间进行另一个权衡。在本章中,我们设计了一个包含以下特征的键值存储:

  • 键值对的大小很小:小于10kb。
  • 具备大数据存储能力。
  • 高可用性:即使在故障期间,系统也能快速响应。
  • 高可扩展性:系统可扩展以支持大型数据集。
  • 自动扩展:服务器的添加/删除应该基于流量自动。
  • 可调一致性。
  • 低延迟。

单服务器键值对存储

开发驻留在单个服务器中的键值存储很容易。一种直观的方法是将键值对存储在散列表中,它将所有内容保存在内存中。尽管内存访问速度很快,但由于空间限制,在内存中拟合所有内容可能是不可能的。为了在一台服务器上容纳更多的数据,可以进行两种优化:

  • 数据压缩
  • 仅将频繁使用的数据存储在内存中,其余数据存储在磁盘上。

但即使使用这些优化,一台服务器也可以很快达到其容量。如果要支持大数据的话还需要分布式键值存储。

分布式键值存储

分布式键值存储也称为分布式哈希表,它将键值对分布在许多服务器上。在设计分布式系统时,理解CAP(一致性、可用性、分区容忍度)定理是很重要的。

CAP定理

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

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

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

分区容忍:一个分区表示两个节点之间的通信中断。

分区容忍意味着系统在网络分区的情况下继续运行。

CAP定理表明,为了支持图6-1所示的3个属性中的2个,必须牺牲三个属性中的一个。

image.png

现在,键值存储根据它们支持的两个CAP特征进行分类: CP(一致性和分区容忍度)系统:CP键值存储在牺牲可用性的同时支持一致性和分区容忍度。

AP(可用性和分区容忍度)系统:AP键值存储在牺牲一致性的同时支持可用性和分区容忍度。

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

你在上面读到的大部分是定义部分。为了更容易理解,让我们看一些具体的例子。在分布式系统中,数据通常被复制多次。假设在三个复制节点n1、n2和n3上复制数据,如图6-2所示。

理想情况

在理想情况下,永远不会发生网络分区。写入n1的数据会自动复制到n2和n3。一致性和可用性都得到了实现。

image.png

真实的分布式系统

在分布式系统中,分区是不可避免的,当分区发生时,我们必须在一致性和可用性之间做出选择。在图6-3中,n3 宕机,无法与n1和n2通信。如果客户端将数据写入n1或n2,则无法将数据传播到n3。如果数据已经被写入n3,但尚未传播到n1和n2,则n1和n2具有的数据是过时的数据。

image.png

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

但是,如果我们选择可用性而不是一致性(AP系统),系统将继续接受读取,即使它可能返回过时的数据。对于写操作,n1和n2将继续接受写操作,当网络分区被解析后,数据将同步到n3。

选择合适的CAP保证适合您的用例是构建分布式键值存储的重要步骤。你可以和面试官讨论这个问题,并据此设计面试系统。

系统组件

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

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

下面的内容主要基于三个流行的键值存储系统:Dynamo、Cassandra和BigTable。

数据分区

对于大型应用程序,在单个服务器中容纳完整的数据集是不可行的。实现这一目标的最简单方法是将数据分割成更小的分区,并将它们存储在多个服务器中。在对数据进行分区时存在两个挑战:

  • 将数据均匀地分布在多个服务器上。

  • 在增加或删除节点时尽量减少数据移动。

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

  • 首先,服务器被放置在哈希环上。在图6-4中,8个服务器,用s0,s1,…,s7表示,放在哈希环上。

  • 接下来,将密钥散列到相同的环上,并将其存储在顺时针方向移动时遇到的第一个服务器上。例如,key0使用这种逻辑存储在s1中。

image.png

使用一致hash对数据进行分区有以下优点:

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

异构:服务器的虚拟节点数量与服务器容量成正比。

例如,容量越大的服务器被分配的虚拟节点越多。

数据复制

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

image.png

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

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

一致性

由于数据是在多个节点上复制的,因此必须跨多个副本同步数据。 Quorum consensus算法可以保证读写操作的一致性。让我们先确定几个定义。

N = 副本数 W = 大小为W的写Quorum。要认为一个写操作成功,必须有W个副本确认该写操作。

R = 大小为R的读Quorum。要认为一个读操作成功,读操作必须等待至少R个副本的响应。

考虑如下图6-6中N = 3的例子。

image.png

W = 1并不意味着数据仅被写入到一台服务器上。以图6-6中的配置为例,在s0、s1、s2处复制数据。W = 1表示coordinator在认为写操作成功之前必须至少收到一次确认。例如,如果我们收到来自s1的确认,我们不再需要等待来自50和s2的确认。coordinator充当客户机和节点之间的代理。

W、R和N的配置是延迟和一致性之间的典型权衡。如果W = 1或R = 1,则快速返回操作,因为coordinator只需要等待来自任何副本的响应。当W或R > 1时,系统具有较好的一致性;但是,查询将会变慢,因为coordinator必须等待最慢副本的响应。 当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”)操作获得相同的值。

image.png

接下来,服务器1将名称更改为“johnSanFrancisco”,服务器2将名称更改为“johnNewYork”,如图6-8所示。这两个更改是同时执行的。

现在,我们有冲突的值,称为版本v1和版本v2。

image.png

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

向量时钟是与数据项相关联的[服务器,版本]对。它可以用来检查一个版本是否先于其他版本、是否成功或是否与其他版本冲突。

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

  • 如果[Si, vi]存在,则增加vi。

  • 否则,创建新的[Si, 1]。

  • 上面的抽象逻辑用一个具体的例子来说明,如图6-9所示

image.png

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

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

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

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

  5. 当另一个客户端读取D3和D4时,它会发现冲突,这是由于数据项D2被Sy和Sz同时修改造成的。冲突由客户端解决,更新后的数据被发送到服务器。假设写操作由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([50,2], [s1, 1])。

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

其次,向量时钟中的[server: version]对可能会快速增长。为了解决这个问题,我们为长度设置了一个阈值,如果它超过了限制,那么最老的对将被删除。这可能导致协调效率低下,因为后代关系无法准确确定。然而,基于Dynamo论文,亚马逊在生产中尚未遇到此问题;因此,它可能是大多数公司可以接受的解决方案。

解决失败问题

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

故障检测

在分布式系统中,仅仅因为另一个服务器说服务器已经关闭,就认为服务器已经关闭是不够的。通常,至少需要两个独立的信息源来标记服务器。

如图6-10所示,all-to-all组播是一种简单的解决方案。但是,当系统中有许多服务器时,这是低效的。

image.png

更好的解决方案是使用分散的故障检测方法,如Gossip协议。

Gossip协议的工作原理如下:

  • 每个节点维护一个节点成员列表,该列表包含成员id和心跳计数器。

  • 各节点定时增加心跳计数器。

  • 每个节点周期性地将心跳发送到一组随机节点,这些随机节点依次传播到另一组节点。

  • 一旦节点接收到心跳,成员列表将更新为最新信息。

  • 如果心跳在预定义的时间内没有增加,则认为该成员离线。

image.png

  • 节点s0维护左侧显示的节点成员列表。

  • 节点s0注意到节点s2(成员ID = 2)的心跳计数器很长时间没有增加。

  • 节点s0将包含s2信息的心跳发送给一组随机节点。一旦其他节点确认s2的心跳计数器很长时间没有更新,节点s2就会被标记下来,并将此信息传播给其他节点。

解决临时故障

通过Gossip协议检测到故障后,系统需要部署一定的机制来保证可用性。在严格的Qurom方法中,读取和写入操作可能被阻塞,如Qurom共识部分所示。

一种叫做“草率Qurom”的技术被用来提高可用性。系统没有强制执行Qurom要求,而是选择前W个正常运行的服务器进行写操作,选择前R个正常运行的服务器进行读操作。离线服务器将被忽略。

如果一个服务器由于网络或服务器故障而不可用,另一个服务器将临时处理请求。当停机服务器启动时,更改将被推回到启动的服务器,以实现数据一致性。这个过程被称为提示切换。由于s2在图6-12中不可用,读取和写入将暂时由s3处理。当s2重新联机时,s3将把数据交还给s2。

image.png

处理永久故障

提示切换用于处理临时故障。如果副本永久不可用怎么办?为了处理这种情况,我们实现了一个 anti-entropy协议来保持副本同步。anti-entropy包括比较副本上的每个数据块,并将每个副本更新为最新版本。Merkle树用于不一致检测和最小化传输的数据量。

引用自维基百科:“哈希树或Merkle树是一棵树,其中每个非叶子节点都被标记为其子节点的标签或值的哈希值(在叶子的情况下)。

哈希树允许对大型数据结构的内容进行有效和安全的验证”。

假设键空间从1到12,下面的步骤展示了如何构建Merkle树。

高亮显示的框表示不一致。

步骤1:将key空间划分为多个桶(本例中为4个),如图6-13所示。桶用作根级节点,以保持树的有限深度.

image.png

步骤2:创建桶后,使用统一的哈希方法对桶中的每个键进行哈希(图6-14)。获得每个键的hash值。

image.png

步骤3:为每个桶创建单个哈希节点(图6-15)。获取每一个hash桶的hash值。

image.png

步骤4:通过计算子节点的哈希值向上构建树直到根(图6-16)。从下往上,每次使用两个孩子节点进行hash,得到的hash值再往上构建,直到构建完一整棵树。

image.png

要比较两个Merkle树,首先比较根哈希。如果根散列匹配,则两台服务器具有相同的数据。如果根哈希不一致,则比较左子哈希,然后比较右子哈希。您可以遍历树以查找未同步的桶,并仅同步这些桶。

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

处理数据中心的中断

由于停电、网络中断、自然灾害等原因,可能导致数据中心中断。

要构建能够处理数据中心中断的系统,跨多个数据中心复制数据非常重要。即使一个数据中心完全脱机,用户仍然可以通过其他数据中心访问数据。

系统架构图

既然我们已经讨论了设计键-值存储时的不同技术考虑,我们可以将重点转移到架构图上,如图6-17所示.

image.png

该架构的主要特点如下:

  • 客户端通过简单的api与键值存储通信:get(key)和put(key, value)。

  • 协调器是一个节点,充当客户端和键值存储之间的代理。

  • 节点使用一致哈希分布在一个环上。

  • 系统是完全分散的,因此可以自动添加和移动节点。

  • 数据在多个节点进行复制。

  • 没有单点故障,因为每个节点都有相同的责任集。

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

image.png

写路径

将写请求定向到指定节点后的处理如图6-19所示。请注意,所提出的写/读路径设计主要基于Cassandra的架构。

image.png

  1. 写请求保存在提交日志文件中。

  2. 数据保存在内存缓存中。当内存缓存已满或达到预定义阈值时,数据将刷新到磁盘上的SSTable。注意:排序字符串表(SSTable)是<键,值>对的排序列表。对于有兴趣了解更多SStable的读者,请参阅参考资料

读路径

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

image.png

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

当数据不在内存中时,读路径如图6-21所示。

image.png

  1. 系统首先检查数据是否在内存中。否= >步骤2。

  2. 如果数据不在内存中,系统检查bloom过滤器。布隆过滤器用于找出哪些sstable可能包含密钥。

  3. sstable返回数据集的结果。

  4. 数据集的结果返回给客户端。

总结

本章涵盖了许多概念和技术。为了刷新您的记忆,下表总结了用于分布式键值存储的特性和相应技术。

目的/存在问题技术
存储大数据的能力使用一致性hash在服务器之间分散负载
高可用性读取数据复制,多数据中心设置
高可用写使用矢量时钟进行版本控制和冲突解决
数据分区一致性hash
增量可扩展性一致性hash
异质性一致性hash
数据一致性算法Quorum算法
故障检测Gossip协议
处理临时故障sloppy Quorum算法和提示切换
处理永久故障使用的hash树也叫Markle树
处理数据中心中断跨数据中心复制与故障转移
表6-2