开源应用架构(第一卷)
NoSQL 生态系统

亚当·马库斯

与本书中的大多数其他项目不同,NoSQL 不是工具,而是一个由多个互补和竞争工具组成的生态系统。带有 NoSQL 品牌的工具为 SQL 基于关系数据库系统提供了存储数据的替代方案。要理解 NoSQL,我们必须理解可用工具的空间,并了解每个工具的设计如何探索数据存储可能性的空间。

如果您正在考虑使用 NoSQL 存储系统,您应该首先了解 NoSQL 系统跨越的广泛选项。NoSQL 系统放弃了关系数据库系统中的许多传统舒适性,并且通常封装在数据库系统边界内的操作现在留给应用程序设计人员。这要求您戴上系统架构师的帽子,这需要更深入地了解这些系统是如何构建的。

13.1. 名字的意义

在定义 NoSQL 空间时,让我们首先尝试定义这个名称。从字面上看,NoSQL 系统向用户提供了一个非 SQL 的查询接口。NoSQL 社区通常采取更包容的观点,建议 NoSQL 系统提供对传统关系数据库的替代方案,并允许开发人员设计使用不仅 SQL 接口的项目。在某些情况下,您可能会用 NoSQL 替代方案替换关系数据库,在其他情况下,您将采用混合搭配的方法来解决在应用程序开发中遇到的不同问题。

在深入了解 NoSQL 世界之前,让我们探索 SQL 和关系模型适合您的需求的情况,以及 NoSQL 系统可能更适合的其他情况。

13.1.1. SQL 和关系模型

SQL 是一种用于查询数据的声明性语言。声明性语言是指程序员指定他们希望系统做什么,而不是以过程方式定义系统如何执行的语言。一些示例包括:查找员工 39 的记录,仅从其完整记录中输出员工姓名和电话号码,将员工记录过滤为在会计部门工作的员工,计算每个部门的员工人数,或将员工表中的数据与经理表中的数据联接起来。

大体上说,SQL 允许您提出这些问题,而无需考虑数据在磁盘上的布局方式、使用哪些索引来访问数据,或使用哪些算法来处理数据。大多数关系数据库的一个重要架构组件是查询优化器,它决定执行众多逻辑等效查询计划中的哪个计划,以便最快速地回答查询。这些优化器通常比普通数据库用户更好,但有时它们没有足够的知识或对系统的模型过于简单,无法生成最有效的执行。

关系数据库是实践中最常见的数据库,它们遵循关系数据模型。在这个模型中,不同的现实世界实体存储在不同的表中。例如,所有员工都可能存储在员工表中,而所有部门都可能存储在部门表中。表的每一行都有存储在列中的各种属性。例如,员工可能具有员工 ID、薪资、出生日期以及姓名。这些属性中的每一个都将存储在员工表的列中。

关系模型与 SQL 紧密相连。简单的 SQL 查询,如过滤器,检索所有其字段与某个测试匹配的记录(例如,employeeid = 3 或 salary > $20000)。更复杂的构造会导致数据库做一些额外的工作,例如联接来自多个表的数据(例如,员工 3 所在部门的名称是什么?)。其他复杂构造,如聚合(例如,我所有员工的平均薪资是多少?),可能导致全表扫描。

关系数据模型定义了高度结构化的实体,它们之间存在严格的关系。用 SQL 查询这个模型允许复杂的跨数据遍历,而无需太多自定义开发。然而,这种建模和查询的复杂性也有其局限性。

随意丢弃多年的设计考虑通常是不明智的。当您考虑将数据存储在数据库中时,请考虑 SQL 和关系模型,它们经过数十年的研究和开发,提供了丰富的建模功能,并提供对复杂操作易于理解的保证。当您遇到特定的问题时,例如大量数据、庞大的工作负载或 SQL 和关系数据库可能没有针对其进行优化的困难数据建模决策,NoSQL 是一个不错的选择。

13.1.2. NoSQL 灵感

NoSQL 运动从研究界的大量论文中汲取了灵感。虽然许多论文是 NoSQL 系统设计决策的核心,但有两篇论文特别突出。

Google 的 BigTable [CDG+06] 展示了一个有趣的数据模型,它可以方便地对多列历史数据进行排序存储。数据使用基于范围的分层分区方案分布到多个服务器,并且数据更新使用严格一致性(我们将在13.5 节中定义的概念)。

亚马逊的 Dynamo [DHJ+07] 使用了不同的键导向分布式数据存储。Dynamo 的数据模型更简单,将键映射到应用程序特定的数据块。分区模型更能抵抗故障,但通过一种称为最终一致性的较松散的数据一致性方法来实现这一目标。

我们将更详细地探讨这些概念中的每一个,但重要的是要理解,它们中的许多可以混合搭配。一些 NoSQL 系统,如 HBase1,严格遵循 BigTable 的设计。另一个名为 Voldemort2 的 NoSQL 系统复制了 Dynamo 的许多功能。其他 NoSQL 项目,如 Cassandra3,从 BigTable(其数据模型)和 Dynamo(其分区和一致性方案)中借鉴了一些功能。

13.1.3. 特征和考虑因素

NoSQL 系统与庞大的 SQL 标准背道而驰,并为构建存储解决方案提供更简单但零散的解决方案。这些系统是在这样的信念下构建的:通过简化数据库在数据上的操作方式,架构师可以更好地预测查询的性能。在许多 NoSQL 系统中,复杂的查询逻辑留给应用程序处理,从而导致数据存储具有更可预测的查询性能,因为查询的缺乏变化性。

NoSQL 系统不仅与关系数据上的声明性查询背道而驰。事务语义、一致性和持久性是银行等组织对数据库的要求。事务在将多个可能复杂的运算组合成一个运算时,提供了一种全有或全无的保证,例如,从一个账户中扣款并将钱存入另一个账户。一致性确保当值更新时,后续查询将看到更新后的值。持久性保证一旦值更新,它将被写入稳定存储(如硬盘驱动器)中,并在数据库崩溃时可恢复。

NoSQL 系统放宽了其中一些保证,对于许多非银行应用程序来说,这是一个可以接受的决策,它可以换取更高的性能,并提供可预测的行为。这些放宽以及数据模型和查询语言的更改,通常使得在数据超出单个机器的能力时,更容易地将数据库安全地划分为多个机器。

NoSQL 系统仍然处于起步阶段。本章描述的系统中的架构决策证明了各种用户的需求。总结多个开源项目的架构特征的最大挑战在于,每个项目都是一个不断变化的目标。请记住,单个系统的细节将发生变化。当您在 NoSQL 系统之间选择时,您可以使用本章来指导您的思考过程,而不是指导您进行功能对功能的产品选择。

当您考虑 NoSQL 系统时,这里提供了一个考虑因素路线图。

虽然我们将涉及所有这些考虑因素,但本章对最后三个考虑因素的关注度最低,尽管它们同样重要。

13.2. NoSQL 数据和查询模型

数据库的数据模型指定了数据的逻辑组织方式。其查询模型规定了如何检索和更新数据。常见的数据模型有关系模型、键值存储模型或各种图模型。您可能听说过的查询语言包括 SQL、键值查找和 MapReduce。NoSQL 系统结合了不同的数据和查询模型,导致了不同的架构考虑因素。

13.2.1. 基于键的 NoSQL 数据模型

NoSQL 系统通常会放弃关系模型和 SQL 的全部表达能力,而是将数据集上的查找限制在单个字段上。例如,即使员工有许多属性,您也可能只能通过她的 ID 来检索员工。因此,NoSQL 系统中的大多数查询都是基于键值查找的。程序员选择一个键来标识每个数据项,并且在大多数情况下,只能通过在数据库中执行其键的查找来检索项。

在基于键值查找的系统中,复杂的联接操作或同一数据的多个键检索可能需要对键名称进行创造性的使用。一个希望通过员工 ID 查找员工并查找部门中所有员工的程序员可能会创建两种键类型。例如,键employee:30将指向员工 ID 为 30 的员工记录,employee_departments:20可能包含部门 20 中所有员工的列表。联接操作被推送到应用程序逻辑中:要检索部门 20 中的员工,应用程序首先从键employee_departments:20中检索一个员工 ID 列表,然后循环遍历每个employee:ID的键值查找在员工列表中。

键值查找模型是有益的,因为它意味着数据库具有一致的查询模式——整个工作负载由键值查找组成,这些键值查找的性能相对一致且可预测。分析以找到应用程序的慢速部分变得更加简单,因为所有复杂的操作都驻留在应用程序代码中。另一方面,数据模型逻辑和业务逻辑现在更加紧密地交织在一起,这会使抽象变得模糊。

让我们快速了解与每个键关联的数据。各种 NoSQL 系统为此提供了不同的解决方案。

键值存储

NoSQL 存储最简单的形式是键值存储。每个键都映射到包含任意数据的某个值。NoSQL 存储不知道其有效负载的内容,只是将数据交付给应用程序。在我们的员工数据库示例中,可以将键employee:30映射到包含 JSON 或二进制格式(如 Protocol Buffers4、Thrift5或 Avro6)的 blob,以封装有关员工 30 的信息。

如果开发人员使用结构化格式来存储键的复杂数据,则她必须在应用程序空间中操作数据:键值数据存储通常不提供基于其值属性查询键的机制。键值存储在其查询模型的简单性方面很出色,通常由setgetdelete基元组成,但由于其值的模糊性,而放弃了添加简单的数据库内过滤功能的能力。Voldemort 基于亚马逊的 Dynamo,提供了一个分布式键值存储。BDB7提供了一个具有键值接口的持久性库。

键数据结构存储

由 Redis8普及的键数据结构存储为每个值分配一个类型。在 Redis 中,值可以采用的可用类型为整数、字符串、列表、集合和有序集合。除了set/get/delete之外,针对类型的命令(例如,整数的增量/减量,或列表的推入/弹出)将功能添加到查询模型中,而不会显着影响请求的性能特征。通过提供简单的针对类型功能,同时避免多键操作(如聚合或联接),Redis 在功能和性能之间取得了平衡。

键文档存储

键文档存储(如 CouchDB9、MongoDB10和 Riak11)将键映射到包含结构化信息的某个文档。这些系统以 JSON 或类似 JSON 的格式存储文档。它们存储列表和字典,这些列表和字典可以递归地嵌入彼此内部。

MongoDB 将键空间分成集合,因此例如员工和部门的键不会发生冲突。CouchDB 和 Riak 将类型跟踪留给开发人员。文档存储的自由和复杂性是一把双刃剑:应用程序开发人员在建模其文档时拥有很大的自由,但基于应用程序的查询逻辑可能会变得极其复杂。

BigTable 列族存储

HBase 和 Cassandra 的数据模型基于 Google 的 BigTable 使用的数据模型。在此模型中,一个键标识一行,该行包含存储在一个或多个列族 (CF) 中的数据。在一个 CF 内,每行可以包含多个列。每个列中的值都有时间戳,因此一个行-列映射的多个版本可以存在于一个 CF 中。

从概念上讲,可以将列族视为存储形式为 (行 ID,CF,列,时间戳) 的复杂键,映射到按其键排序的值。这种设计导致数据建模决策将许多功能推送到键空间中。它特别擅长建模具有时间戳的历史数据。该模型自然地支持稀疏列放置,因为没有特定列的行 ID 不需要为这些列显式 NULL 值。另一方面,具有很少或没有 NULL 值的列仍然必须将列标识符与每行一起存储,这会导致更大的空间消耗。

每个项目数据模型都以不同的方式不同于原始 BigTable 模型,但 Cassandra 的更改最为显着。Cassandra 引入了每个 CF 内的超列的概念,以允许另一个级别的映射、建模和索引。它还消除了局部性组的概念,这些组可以出于性能原因将多个列族物理地存储在一起。

13.2.2. 图存储

一类 NoSQL 存储是图存储。并非所有数据都是天生平等的,关系和键值数据模型存储和查询数据的模型并非适合所有数据。图是计算机科学中一种基本的数据结构,诸如 HyperGraphDB12 和 Neo4J13 等系统是两个流行的 NoSQL 存储系统,用于存储图结构化数据。图存储在几乎所有方面都不同于我们迄今为止讨论过的其他存储:数据模型、数据遍历和查询模式、数据在磁盘上的物理布局、跨多个机器的分布以及查询的事务语义。由于空间限制,我们无法对这些明显的差异做出公平的阐述,但您应该意识到某些类别的数据可能更适合作为图进行存储和查询。

13.2.3. 复杂查询

在 NoSQL 系统中,仅基于键的查找有明显的例外。MongoDB 允许您根据任意数量的属性索引您的数据,并具有相对高级的语言来指定要检索的数据。基于 BigTable 的系统支持扫描程序来遍历列族并通过对列的过滤器选择特定项。CouchDB 允许您创建数据的不同视图,并运行跨表的 MapReduce 任务,以促进更复杂的查找和更新。大多数系统都绑定到 Hadoop 或其他 MapReduce 框架,以执行数据集规模的分析查询。

13.2.4. 事务

NoSQL 系统通常将性能置于事务语义之上。其他基于 SQL 的系统允许任何语句集——从简单的主键行检索到多个表的复杂联接,然后随后在多个字段上进行平均——放入事务中。

这些 SQL 数据库将为事务提供 ACID 保证。在事务中运行多个操作是原子性的 (ACID 中的 A),这意味着所有操作都会发生或都不发生。一致性 (C) 确保事务将数据库置于一致的、未损坏的状态。隔离 (I) 确保如果两个事务触及同一记录,它们将不会相互影响。持久性 (D,将在下一节中广泛介绍) 确保一旦事务提交,它就会存储在安全的地方。

符合 ACID 的事务通过使开发人员能够轻松地推断其数据的状态,从而保持开发人员的理智。想象多个事务,每个事务都有多个步骤(例如,首先检查银行账户的值,然后减去 60 美元,然后更新该值)。符合 ACID 的数据库在如何交织这些步骤方面往往受到限制,同时仍然为所有事务提供正确的结果。这种对正确性的推动会导致通常意想不到的性能特征,其中缓慢的事务可能会导致其他快速的事务排队等待。

大多数 NoSQL 系统将性能置于完整的 ACID 保证之上,但确实在键级别提供了保证:对同一键的两个操作将被序列化,避免对键值对造成严重损坏。对于许多应用程序来说,此决定不会造成明显的正确性问题,并且将允许快速操作更规律地执行。但是,它确实将更多关于应用程序设计和正确性的考虑因素留给了开发人员。

Redis 是无事务趋势中值得注意的例外。在一个服务器上,它提供了一个MULTI命令以原子地且一致地组合多个操作,以及一个WATCH命令以允许隔离。其他系统提供更低级的测试和设置功能,该功能提供了一些隔离保证。

13.2.5. 无模式存储

许多 NoSQL 系统的交叉属性是数据库中缺乏模式强制。即使在文档存储和面向列族的存储中,也不需要类似实体的属性相同。这具有支持结构较少的数据需求以及在动态修改模式时需要更少性能开销的优势。此决定将更多责任留给了应用程序开发人员,他们现在必须更加防御性地进行编程。例如,员工记录上缺少lastname属性是需要纠正的错误,还是目前正在通过系统传播的模式更新?在依赖松散模式 NoSQL 系统的项目经过几次迭代之后,数据和模式版本控制在应用程序级代码中很常见。

13.3. 数据持久性

理想情况下,存储系统上的所有数据修改都会立即安全地持久化并复制到多个位置,以避免数据丢失。但是,确保数据安全与性能之间存在矛盾,不同的 NoSQL 系统会做出不同的数据持久性保证以提高性能。故障场景多种多样,并非所有 NoSQL 系统都能保护您免受这些问题的困扰。

一个简单且常见的故障场景是服务器重启或电源故障。在这种情况下,数据持久性涉及将数据从内存移动到硬盘,硬盘不需要电源来存储数据。硬盘故障通过将数据复制到辅助设备来处理,这些设备可以是同一台机器中的其他硬盘(RAID 镜像)或网络上的其他机器。但是,数据中心可能无法承受导致相关故障的事件(例如龙卷风),一些组织甚至将数据复制到距离飓风宽度相隔数英里的数据中心的备份中。写入硬盘并将数据复制到多个服务器或数据中心成本很高,因此不同的 NoSQL 系统会以持久性保证为代价来换取性能。

13.3.1. 单服务器持久性

最简单的持久性形式是单服务器持久性,它确保任何数据修改都能在服务器重启或断电后存活。这通常意味着将更改后的数据写入磁盘,这通常会成为工作负载的瓶颈。即使您指示操作系统将数据写入磁盘文件,操作系统也可能会缓冲写入,避免立即修改磁盘,以便可以将多个写入组合到单个操作中。只有在发出fsync系统调用时,操作系统才会尽力确保将缓冲的更新持久保存到磁盘。

典型的硬盘驱动器每秒可以执行 100-200 次随机访问(查找),并且每次顺序写入的限制为 30-100 MB/秒。在两种情况下,内存的速度都可以快几个数量级。确保高效的单服务器持久性意味着限制系统产生的随机写入次数,并增加每个硬盘驱动器的顺序写入次数。理想情况下,您希望系统最大程度地减少fsync调用之间的写入次数,最大限度地增加这些写入中的顺序写入次数,同时在写入被fsync之前永远不要告诉用户他们的数据已成功写入磁盘。让我们来了解一些提高单服务器持久性保证性能的技术。

控制fsync频率

Memcached14是一个示例,它不提供任何磁盘上的持久性,以换取极快的内存中操作。当服务器重启时,该服务器上的数据会消失:这使其成为一个良好的缓存和一个较差的持久数据存储。

Redis 为开发人员提供了多种选择来选择何时调用fsync。开发人员可以在每次更新后强制执行fsync调用,这是缓慢但安全的做法。为了获得更好的性能,Redis 可以每 N 秒fsync一次写入。在最坏的情况下,您将丢失最近 N 秒的操作,对于某些用途来说,这可能是可以接受的。最后,对于持久性不重要的用例(维护粗粒度统计数据或将 Redis用作缓存),开发人员可以完全关闭fsync调用:操作系统最终会将数据刷新到磁盘,但无法保证何时会发生。

通过日志记录增加顺序写入

一些数据结构,例如 B+树,帮助 NoSQL 系统快速从磁盘检索数据。对这些结构的更新会导致对数据结构文件的随机位置进行更新,如果您在每次更新后都执行fsync,则每次更新都会导致多次随机写入。为了减少随机写入,Cassandra、HBase、Redis 和 Riak 等系统将更新操作追加到一个顺序写入的文件中,称为日志。虽然系统使用的其他数据结构仅定期fsync,但日志会频繁fsync。通过将日志视为数据库在崩溃后的真实状态,这些存储引擎能够将随机更新转换为顺序更新。

虽然 MongoDB 等 NoSQL 系统在其数据结构中执行就地写入,但其他系统则将日志记录做得更进一步。Cassandra 和 HBase 使用了从 BigTable 借来的技术,将它们的日志和查找数据结构组合成一个日志结构化合并树。Riak 使用日志结构化哈希表提供类似的功能。CouchDB 已经修改了传统的 B+树,以便对数据结构的所有更改都附加到物理存储上的结构中。这些技术可以提高写入吞吐量,但需要定期进行日志压缩,以防止日志无限增长。

通过对写入进行分组来提高吞吐量

Cassandra 将在短时间窗口内发生的多个并发更新分组到单个fsync调用中。这种称为组提交的设计会导致每次更新的延迟更高,因为用户必须等待多个并发更新才能确认自己的更新。延迟增加会带来吞吐量增加,因为多个日志追加可以在单个fsync中发生。截至撰写本文时,每个 HBase 更新都会持久保存到由 Hadoop 分布式文件系统 (HDFS)15提供的底层存储中,该存储最近已经看到了支持尊重fsync和组提交的追加操作的补丁。

13.3.2. 多服务器持久性

由于硬盘驱动器和机器经常会发生不可修复的故障,因此跨机器复制重要数据是必要的。许多 NoSQL 系统为数据提供多服务器持久性。

Redis 采用传统的“主从”方法来复制数据。对主服务器执行的所有操作都以类似日志的方式传达给从服务器,这些服务器在自己的硬件上复制操作。如果主服务器出现故障,从服务器可以接管并从从主服务器接收的操作日志状态提供数据。这种配置可能会导致一些数据丢失,因为主服务器不会确认从服务器在其日志中持久保存操作,然后再向用户确认操作。CouchDB 促进了类似的定向复制形式,其中可以配置服务器来将对其他存储上的文档的更改进行复制。

MongoDB 提供了副本集的概念,其中一定数量的服务器负责存储每个文档。MongoDB 为开发人员提供了确保所有副本都已收到更新,或者在不确保副本拥有最新数据的情况下继续操作的选择。许多其他分布式 NoSQL 存储系统支持数据的多服务器复制。HBase 构建在 HDFS 之上,通过 HDFS 接收多服务器持久性。所有写入都会在返回控制权给用户之前复制到两个或多个 HDFS 节点,从而确保多服务器持久性。

Riak、Cassandra 和 Voldemort 支持更可配置的复制形式。这三个系统都以微妙的不同方式允许用户指定N(最终应该拥有数据副本的机器数量)和W<N(在返回控制权给用户之前应该确认数据已写入的机器数量)。

为了处理整个数据中心无法访问的情况,需要跨数据中心进行多服务器复制。Cassandra、HBase 和 Voldemort 具有机架感知配置,这些配置指定了各种机器所在的机架或数据中心。通常,在远程服务器确认更新之前阻止用户的请求会造成过多的延迟。当跨广域网执行更新以备份数据中心时,更新会以流式传输方式进行,而无需确认。

13.4. 扩展性能

我们刚刚谈论了如何处理故障,现在让我们设想一个更美好的情况:成功!如果您构建的系统取得了成功,您的数据存储将成为在负载下承受压力的组件之一。解决此类问题的廉价且肮脏的解决方案是对现有机器进行纵向扩展:投资更多 RAM 和磁盘以在一台机器上处理工作负载。随着越来越多的成功,将资金投入更昂贵的硬件将变得不可行。此时,您必须复制数据并将请求分散到多台机器上,以分发负载。这种方法称为横向扩展,并通过系统的横向可扩展性进行衡量。

理想的横向可扩展性目标是线性可扩展性,在这种情况下,将存储系统中的机器数量增加一倍会使系统的查询能力增加一倍。实现这种可扩展性的关键在于如何在机器之间分配数据。分片是指将读写工作负载拆分到多台机器上,以横向扩展存储系统。分片是许多系统设计的核心,包括 Cassandra、HBase、Voldemort 和 Riak,以及最近的 MongoDB 和 Redis。一些项目,如 CouchDB,专注于单服务器性能,没有提供分片的系统内解决方案,但辅助项目提供了协调器来将工作负载划分为多台机器上的独立安装。

让我们了解一些您可能遇到的可互换术语。我们将分片分区这两个术语互换使用。机器服务器节点这些术语指的是存储部分分区数据的物理计算机。最后,集群指的是参与您的存储系统的机器集。

分片意味着没有一台机器必须处理整个数据集的写入工作负载,但也没有一台机器可以回答有关整个数据集的查询。大多数 NoSQL 系统在数据和查询模型中都是以键为导向的,而且很少有查询会触及整个数据集。由于这些系统中数据的首要访问方法是以键为基础的,因此分片通常也是以键为基础的:键的某个函数决定了存储键值对的机器。我们将介绍两种定义键机器映射的方法:哈希分区和范围分区。

13.4.1. 在必须之前不要分片

分片会增加系统复杂性,在可能的情况下,应避免分片。让我们了解两种无需分片即可进行扩展的方法:只读副本和缓存。

只读副本

许多存储系统看到的读请求多于写请求。在这种情况下,一个简单的解决方案是在多台机器上创建数据的副本。所有写请求仍然发送到主节点。读请求发送到复制数据的机器,并且通常比写主节点上的数据略微陈旧。

如果您已经在主从配置中复制了数据以实现多服务器持久性(如 Redis、CouchDB 或 MongoDB 中常见的那样),则只读从服务器可以减轻写主节点的部分负载。一些查询(例如数据集的聚合摘要)可能很昂贵并且通常不需要实时新鲜度,这些查询可以在从服务器副本上执行。通常,对内容新鲜度的要求越低,您就可以越依赖只读从服务器来提高只读查询性能。

缓存

将系统中最受欢迎的内容缓存起来通常效果出奇地好。Memcached 在多台服务器上的内存块中分配数据,以缓存来自数据存储的数据。Memcached 客户端利用多种横向可扩展性技巧来分发跨不同服务器上的 Memcached 安装的负载。要向缓存池添加内存,只需添加另一个 Memcached 主机。

由于 Memcached 是为缓存而设计的,因此它不像用于扩展工作负载的持久解决方案那样具有那么多的体系结构复杂性。在考虑更复杂的解决方案之前,请考虑缓存是否可以解决您的可扩展性问题。缓存不仅仅是一种临时权宜之计:Facebook 的 Memcached 安装规模高达数万亿字节的内存!

只读副本和缓存允许您扩展读密集型工作负载。但是,当您开始增加写入和更新数据的频率时,您也会增加包含所有最新数据的主服务器的负载。在本节的剩余部分,我们将介绍将写工作负载分散到多台服务器上的分片技术。

13.4.2. 通过协调器进行分片

CouchDB 项目专注于单服务器体验。Lounge 和 BigCouch 两个项目通过外部代理(充当独立 CouchDB 实例的前端)来促进 CouchDB 工作负载的分片。在此设计中,独立安装彼此之间并不了解。协调器根据请求的文档的键将请求分发到各个 CouchDB 实例。

Twitter 已将分片和复制的概念构建到一个名为 Gizzard16的协调框架中。Gizzard 采用任何类型的独立数据存储(您可以为 SQL 或 NoSQL 存储系统构建包装器),并将它们排列成任何深度的树,以根据键范围对键进行分区。为了实现容错性,可以配置 Gizzard 将数据复制到同一键范围内的多台物理机器上。

13.4.3. 一致哈希环

好的哈希函数以均匀的方式分布一组键。这使得它们成为在多个服务器之间分配键值对的有力工具。关于一种称为一致性哈希的技术的学术文献非常丰富,并且该技术在数据存储中的第一个应用是在称为分布式哈希表DHT)的系统中。围绕亚马逊 Dynamo 原则构建的 NoSQL 系统采用了这种分布技术,并且它出现在 Cassandra、Voldemort 和 Riak 中。

哈希环示例

[A Distributed Hash Table Ring]

图 13.1:分布式哈希表环

一致性哈希环的工作原理如下。假设我们有一个哈希函数 H,它将键映射到均匀分布的大整数值。我们可以通过对某个相对较大的整数 L 取 H(key) mod L,形成一个在 [1, L] 范围内并围绕自身循环的数字环。这将把每个键映射到 [1,L] 范围内。一致性哈希服务器环是通过获取每个服务器的唯一标识符(例如其 IP 地址)并对其应用 H 来形成的。您可以通过查看由五个服务器 (A-E) 在 图 13.1 中形成的哈希环来直观地了解其工作原理。

在那里,我们选择了 L = 1000。假设 H(A) mod L = 7H(B) mod L = 234H(C) mod L = 447H(D) mod L = 660H(E) mod L = 875。我们现在可以判断哪个服务器应该存储一个键。为此,我们通过查看一个键的哈希值是否落在该服务器与环中的下一个服务器之间的范围内,将所有键映射到一个服务器。例如,A 负责哈希值落在 [7,233] 范围内的键,E 负责哈希值落在 [875, 6] 范围内的键(此范围在 1000 处围绕自身循环)。因此,如果 H('employee30') mod L = 899,它将存储在服务器 E 上,如果 H('employee31') mod L = 234,它将存储在服务器 B 上。

复制数据

通过将一个服务器分配范围内的键和值传递给环中后面的服务器,可以实现多服务器持久性的复制。例如,在复制因子为 3 的情况下,映射到 [7,233] 范围的键将存储在服务器 ABC 上。如果 A 出现故障,其邻居 BC 将接管其工作负载。在某些设计中,E 将复制并暂时接管 A 的工作负载,因为其范围将扩展到包含 A

实现更好的分布

虽然哈希在统计上可以有效地均匀地分布键空间,但通常需要许多服务器才能使其均匀分布。不幸的是,我们经常从少量服务器开始,这些服务器在哈希函数的作用下彼此之间没有完美间隔。在我们的示例中,A 的键范围的长度为 227,而 E 的范围的长度为 132。这会导致不同服务器上的负载不均。它还使服务器在故障时难以互相接管,因为邻居突然必须控制故障服务器的整个范围。

为了解决不均匀的大键范围问题,包括 Riak 在内的许多 DHT 为每台物理机创建了几个“虚拟”节点。例如,在有 4 个虚拟节点的情况下,服务器 A 将充当服务器 A_1A_2A_3A_4。每个虚拟节点都映射到不同的值,使其有更多机会管理分配到键空间不同部分的键。Voldemort 采取了类似的方法,其中分区数量是手动配置的,通常大于服务器数量,导致每个服务器接收数量较少的小分区。

Cassandra 不会为每个服务器分配多个小分区,这会导致有时不均匀的键范围分布。为了实现负载均衡,Cassandra 拥有一个异步进程,该进程根据服务器的历史负载调整服务器在环上的位置。

13.4.4. 范围分区

在范围分区方法中,系统中的某些机器会保存有关哪些服务器包含哪些键范围的元数据。查询此元数据以将键和范围查找路由到适当的服务器。与一致性哈希环方法一样,这种范围分区将键空间划分为范围,每个键范围由一台机器管理,并可能复制到其他机器。与一致性哈希方法不同的是,键排序中彼此相邻的两个键很可能出现在同一个分区中。这减少了路由元数据的规模,因为大的范围被压缩为 [开始,结束] 标记。

通过添加对范围到服务器映射的主动记录保存,范围分区方法允许更细粒度地控制负载从负载过重的服务器中分离。如果特定键范围的流量高于其他范围,负载管理器可以缩减该服务器上范围的大小,或者减少该服务器服务的碎片数量。主动管理负载的额外自由是以需要额外的体系结构组件为代价的,这些组件会监控和路由碎片。

BigTable 的方法

Google 的 BigTable 论文描述了一种将数据分片成数据块的范围分区分层技术。数据块存储一个列族内的一系列行键和值。它维护所有必要的日志和数据结构以回答有关其分配范围内键的查询。数据块服务器根据每个数据块所经历的负载来服务多个数据块。

每个数据块的大小保持在 100-200 MB。随着数据块大小的变化,两个具有相邻键范围的小数据块可能会合并,或者一个大数据块可能会拆分成两个。主服务器分析数据块大小、负载和数据块服务器可用性。主服务器随时调整哪个数据块服务器服务哪些数据块。

[BigTable-based Range Partitioning]

图 13.2:基于 BigTable 的范围分区

主服务器在元数据表中维护数据块分配。由于此元数据可能变得很大,因此元数据表也被分片成数据块,这些数据块将键范围映射到数据块和负责这些范围的数据块服务器。这导致客户端为找到其托管数据块服务器上的键而执行三层层次结构遍历,如 图 13.2 所示。

让我们来看一个例子。搜索键 900 的客户端将查询服务器 A,该服务器存储元数据级别 0 的数据块。此数据块识别服务器 6 上包含键范围 500-1500 的元数据级别 1 数据块。客户端使用此键向服务器 B 发送请求,该服务器响应包含键 850-950 的数据块在服务器 C 上的一个数据块上找到。最后,客户端将键请求发送到服务器 C,并获得其查询的行数据。客户端可能会缓存级别 0 和 1 的元数据数据块,从而避免由于重复查询而对它们的数据块服务器施加不必要的负载。BigTable 论文解释说,这种 3 层层次结构可以使用 128 MB 数据块容纳 261 字节的存储空间。

处理故障

主服务器是 BigTable 设计中的单点故障,但可以暂时停机而不会影响对数据块服务器的请求。如果数据块服务器在服务数据块请求时发生故障,则由主服务器识别并重新分配其数据块,而请求会暂时失败。

为了识别和处理机器故障,BigTable 论文描述了使用 Chubby,这是一种分布式锁定系统,用于管理服务器成员资格和活动状态。ZooKeeper17 是 Chubby 的开源实现,几个基于 Hadoop 的项目使用它来管理辅助主服务器和数据块服务器重新分配。

基于范围分区的 NoSQL 项目

HBase 采用 BigTable 的层次化方法进行范围分区。底层数据块数据存储在 Hadoop 的分布式文件系统 (HDFS) 中。HDFS 处理副本之间的数据复制和一致性,将数据块服务器留给处理请求、更新存储结构以及启动数据块拆分和压缩。

MongoDB 以类似于 BigTable 的方式处理范围分区。几个配置节点存储和管理路由表,这些路由表指定哪个存储节点负责哪些键范围。这些配置节点通过称为两阶段提交的协议保持同步,并且充当 BigTable 的主服务器的混合体,用于指定范围和 Chubby,用于高可用性配置管理。单独的路由进程(无状态)跟踪最新的路由配置并将键请求路由到适当的存储节点。存储节点按副本集排列以处理复制。

Cassandra 提供了一个保持顺序的分区器,如果你希望允许对你的数据进行快速范围扫描。Cassandra 节点仍然使用一致性哈希排列在环中,但不是将键值对哈希到环中以确定应将其分配到的服务器,而是简单地将键映射到控制该键自然适合的范围的服务器上。例如,键 20 和 21 都将映射到我们 图 13.1 中的一致性哈希环中的服务器 A,而不是被哈希并随机分布在环中。

Twitter 的 Gizzard 框架用于跨多个后端管理已分区和复制的数据,使用范围分区对数据进行分片。路由服务器形成任何深度的层次结构,将一系列键分配给层次结构中低于它们的服务器。这些服务器要么存储其分配范围内键的数据,要么路由到另一层路由服务器。在这种模型中,复制是通过向多个机器发送键范围的更新来实现的。Gizzard 路由节点以不同于其他 NoSQL 系统的方式管理失败的写入。Gizzard 要求系统设计人员使所有更新幂等(它们可以运行两次)。当存储节点发生故障时,路由节点会缓存并重复将更新发送到节点,直到确认更新为止。

13.4.5. 使用哪种分区方案

考虑到基于哈希和范围的分片方法,哪种方法更可取?这取决于。当你经常对数据的键进行范围扫描时,范围分区是明显的选择。当按键顺序读取值时,你不会跳转到网络中的随机节点,这会导致严重的网络开销。但是,如果你不需要范围扫描,应该使用哪种分片方案?

哈希分区可以合理地将数据分布到各个节点,并且可以使用虚拟节点减少随机偏差。哈希分区方案中的路由很简单:在大多数情况下,客户端可以执行哈希函数以找到合适的服务器。使用更复杂重新平衡方案,找到键的正确节点变得更加困难。

范围分区需要维护路由和配置节点的预先成本,这些节点可能会看到繁重的负载,并且在没有相对复杂的容错方案的情况下成为中心故障点。但是,如果做得好的话,范围分区数据可以在小块中进行负载均衡,这些小块可以在高负载情况下重新分配。如果一台服务器宕机,其分配的范围可以分布到许多服务器,而不是在停机期间将服务器的直接邻居加载到服务器上。

13.5. 一致性

在谈到将数据复制到多台机器以实现持久性和负载分发的好处后,现在该告诉你一个秘密了:保持多台机器上的数据副本彼此一致非常困难。实际上,副本会崩溃并不同步,副本会崩溃并永远无法恢复,网络会将两组副本分区,机器之间的消息会延迟或丢失。在 NoSQL 生态系统中,数据一致性主要有两种方法。第一种是强一致性,所有副本保持同步。第二种是最终一致性,允许副本不同步,但最终会互相追赶。让我们首先了解分布式计算的一个基本属性,从而了解为什么第二个选项是一个适当的考虑因素。之后,我们将深入探讨每种方法的细节。

13.5.1. 关于 CAP 的一些内容

为什么我们不考虑对数据进行强一致性保证?这归结于现代网络设备架构的分布式系统的属性。这个想法最初是由埃里克·布鲁尔提出的,称为 *CAP 定理*,后来由吉尔伯特和林奇证明 [GL02]。该定理首先介绍了分布式系统的三个属性,它们构成了 CAP 的首字母缩写

该定理接着说,在一个运行在多台计算机上的存储系统中,只能实现其中两个属性,而必须牺牲第三个属性。此外,我们被迫实现分区容忍系统。在使用当前消息协议的当前网络硬件上,数据包可能会丢失,交换机可能会发生故障,并且无法确定网络是否已关闭或您尝试向其发送消息的服务器是否不可用。所有 NoSQL 系统都应该是分区容忍的。剩下的选择是在一致性和可用性之间做出选择。没有 NoSQL 系统可以同时提供两者。

选择一致性意味着您的复制数据在所有副本之间不会出现不一致。实现一致性的一个简单方法是要求所有副本确认更新。如果一个副本出现故障,并且您无法确认该副本上的数据更新,那么您将降低其键的可用性。这意味着,在所有副本恢复并响应之前,用户无法收到其更新操作的成功确认。因此,选择一致性意味着选择缺乏对每个数据项的昼夜不间断的可用性。

选择可用性意味着,当用户发出操作时,副本应根据其拥有的数据采取行动,无论其他副本的状态如何。这可能会导致副本之间数据一致性的差异,因为它们不需要确认所有更新,并且某些副本可能没有记录所有更新。

CAP 定理的含义导致了构建 NoSQL 数据存储的强一致性和最终一致性方法。还存在其他方法,例如雅虎的 PNUTS [CRS+08] 系统中提出的松散一致性和松散可用性方法。我们讨论的开源 NoSQL 系统中还没有一个采用这种技术,因此我们将不再赘述。

13.5.2. 强一致性

促进强一致性的系统确保数据项的副本始终能够就键的值达成共识。一些副本可能彼此不同步,但当用户请求 `employee30:salary` 的值时,机器有一种方法可以一致地就用户看到的价值达成一致。如何实现这一点最好用数字来解释。

假设我们在 N 台机器上复制一个键。其中一台机器,可能是 N 台机器中的一台,充当每个用户请求的协调器。协调器确保 N 台机器中的特定数量的机器已接收并确认每个请求。当对某个键进行写入或更新操作时,协调器只有在 W 台副本确认已收到更新后,才向用户确认写入操作已完成。当用户想要读取某个键的值时,协调器只有在至少有 R 台副本响应了相同的值时,才会进行响应。如果 R+W>N,我们说该系统体现了强一致性。

让我们用一些数字来解释这个概念,假设我们在 N=3 台机器上复制每个键(分别称为 ABC)。假设键 `employee30:salary` 最初设置为 $20,000,但我们想给 `employee30` 加薪到 $30,000。假设对于某个键,我们要求至少 W=2ABC 机器确认每个写入请求。当 AB 确认对 `(employee30:salary, $30,000)` 的写入请求时,协调器会让用户知道 `employee30:salary` 已成功更新。假设机器 C 从未收到对 `employee30:salary` 的写入请求,因此它仍然保留着 $20,000 的值。当协调器收到对键 `employee30:salary` 的读取请求时,它会将该请求发送到所有 3 台机器

因此,为了在这个情况下实现强一致性,我们需要设置 R=2},以便 R+W3}。

W 台副本未响应写入请求,或者 R 台副本未以一致的响应响应读取请求时会发生什么?协调器最终可能会超时并向用户发送错误,或者等待情况自行解决。无论哪种方式,系统在至少一段时间内都被认为对该请求不可用。

您选择的 RW 会影响在您的系统对某个键的不同操作变得不可用之前,有多少台机器可以表现异常。例如,如果您强制所有副本确认写入操作,那么 W=N,任何副本故障都会导致写入操作挂起或失败。常见的选择是 R + W = N + 1,这是强一致性所需的最小值,同时仍然允许副本之间存在暂时性差异。许多强一致性系统选择 W=NR=1,因为这样它们就不必为节点不同步而进行设计。

HBase 将其复制存储基于 HDFS,这是一个分布式存储层。HDFS 提供强一致性保证。在 HDFS 中,写入操作只有在复制到所有 N 台(通常为 2 或 3 台)副本后才能成功,因此 W = N。读取操作将由单个副本满足,因此 R = 1。为了避免拖慢写入密集型工作负载,数据会从用户异步并行传输到副本。一旦所有副本确认已收到数据副本,则在所有副本上原子且一致地执行将新数据交换到系统中的最后一步。

13.5.3. 最终一致性

基于 Dynamo 的系统,包括 Voldemort、Cassandra 和 Riak,允许用户根据需要指定 NRW,即使 R + W <= N 也是如此。这意味着用户可以实现强一致性或最终一致性。当用户选择最终一致性时,即使程序员选择强一致性但 W 小于 N 时,也会出现副本可能不一致的时期。为了在副本之间提供最终一致性,这些系统采用各种工具将过时的副本更新到最新状态。让我们首先了解各种系统如何确定数据已不同步,然后讨论它们如何同步副本,最后介绍一些受 Dynamo 启发的加速同步过程的方法。

版本控制和冲突

因为两个副本可能会看到某个键的两个不同版本的价值,所以数据版本控制和冲突检测很重要。基于 Dynamo 的系统使用一种称为 *向量时钟* 的版本控制。向量时钟是分配给每个键的向量,其中包含每个副本的计数器。例如,如果服务器 ABC 是某个键的三个副本,那么向量时钟将有三个条目 (N_A, N_B, N_C),初始化为 (0,0,0)

每次副本修改键时,它都会增加向量中的计数器。如果 B 修改了以前版本为 (39, 1, 5) 的键,它会将向量时钟更改为 (39, 2, 5)。当另一个副本(例如 C)从 B 接收有关键数据的更新时,它会将来自 B 的向量时钟与其自己的向量时钟进行比较。只要它自己的向量时钟计数器都小于从 B 传递的计数器,那么它就拥有过时的版本,并且可以将其自己的副本覆盖为 B 的副本。如果 B 和 C 的时钟中一些计数器都大于其他时钟中的计数器,例如 (39, 2, 5)(39, 1, 6),那么服务器会识别到它们在一段时间内收到了不同的、可能无法调和的更新,并识别到冲突。

冲突解决

冲突解决在不同的系统中有所不同。Dynamo 论文将冲突解决留给使用存储系统的应用程序。购物车可以合并两个版本,而不会造成重大数据丢失,但协作编辑的文档的两个版本可能需要人工审核员来解决冲突。Voldemort 遵循此模型,在发生冲突时,将键的多个副本返回给请求的客户端应用程序。

Cassandra 在每个键上存储时间戳,当两个版本发生冲突时,它会使用时间戳最新的键版本。这样无需向客户端进行往返,并且简化了 API。这种设计难以处理以下情况:冲突数据可以像我们的购物车示例中那样智能合并,或者实现分布式计数器。Riak 允许 Voldemort 和 Cassandra 提供的两种方法。CouchDB 提供了一种混合方法:它会识别冲突,并允许用户查询冲突的键以便手动修复,但会确定性地选择一个版本返回给用户,直到冲突修复为止。

读取修复

如果 R 台副本将不冲突的数据返回给协调器,那么协调器可以安全地将不冲突的数据返回给应用程序。协调器可能仍然注意到一些副本不同步。Dynamo 论文建议,Cassandra、Riak 和 Voldemort 都实现了称为 *读取修复* 的技术来处理这种情况。当协调器在读取操作中识别出冲突时,即使已向用户返回一致的值,协调器也会在冲突的副本之间启动冲突解决协议。这会主动修复冲突,而无需额外的工作。副本已将其数据版本发送给协调器,并且更快的冲突解决将导致系统之间的差异更小。

隐式转移

Cassandra、Riak 和 Voldemort 都采用了一种称为 *隐式转移* 的技术,以提高节点暂时不可用时的写入性能。如果某个键的某个副本未响应写入请求,则会选择另一个节点来暂时接管其写入工作负载。不可用节点的写入会单独保留,当备份节点注意到以前不可用的节点变为可用时,它会将所有写入转发到新可用的副本。Dynamo 论文利用了“松散配额”方法,并允许通过隐式转移完成的写入计入所需的 W 写确认。Cassandra 和 Voldemort 不会将隐式转移计入 W,并且会失败没有 W 台原始分配的副本确认的写入。隐式转移在这些系统中仍然有用,因为它在不可用节点返回时可以加速恢复。

反熵

当副本关闭的时间过长,或者存储不可用副本的隐式转移的机器也关闭时,副本必须彼此同步。在这种情况下,Cassandra 和 Riak 实现了一种受 Dynamo 启发的过程,称为 *反熵*。在反熵中,副本交换 *Merkle 树* 以识别其复制的键范围中不同步的部分。Merkle 树是一种分层哈希验证:如果整个键空间的哈希在两个副本之间不同,它们将交换复制的键空间越来越小的部分的哈希,直到识别出不同步的键。这种方法减少了包含大部分相同数据的副本之间不必要的数据传输。

八卦

最后,随着分布式系统的增长,跟踪系统中每个节点的运行状态变得越来越困难。三个基于 Dynamo 的系统使用一种古老的高中技术——“八卦”来跟踪其他节点。定期(每秒或更短时间),一个节点会随机选择一个曾经与之通信过的节点来交换其他节点健康状况的信息。通过这种交换,节点可以了解哪些节点已关闭,并知道将客户端路由到哪个节点以查找键。

13.6. 总结

NoSQL 生态系统仍处于起步阶段,我们讨论过的许多系统都将改变架构、设计和接口。本章的重点不在于每个 NoSQL 系统当前的功能,而在于导致这些系统组合功能的设计决策。NoSQL 将大量设计工作留给了应用程序设计人员。理解这些系统的架构组件不仅可以帮助您构建下一个伟大的 NoSQL 聚合,还可以让您负责任地使用当前版本。

13.7. 致谢

感谢 Jackie Carter、Mihir Kedia 和匿名审稿人对本章的评论和建议。如果没有 NoSQL 社区多年来的辛勤工作,本章也不可能完成。继续构建!

脚注

  1. http://hbase.apache.org/
  2. http://project-voldemort.com/
  3. https://cassandra.apache.ac.cn/
  4. http://code.google.com/p/protobuf/
  5. http://thrift.apache.org/
  6. http://avro.apache.org/
  7. http://www.oracle.com/technetwork/database/berkeleydb/overview/index.html
  8. https://redis.ac.cn/
  9. https://couchdb.cn/
  10. http://www.mongodb.org/
  11. http://www.basho.com/products_riak_overview.php
  12. http://www.hypergraphdb.org/index
  13. http://neo4j.org/
  14. https://memcached.org.cn/
  15. https://hadoop.apache.ac.cn/hdfs/
  16. http://github.com/twitter/gizzard
  17. https://hadoop.apache.ac.cn/zookeeper/