# 第六章:分区 ![](img/ch6.png) > 我们必须跳出电脑指令序列的窠臼。 叙述定义、描述元数据、梳理关系,而不是编写过程。 > > —— Grace Murray Hopper,未来的计算机及其管理(1962) > ------------- [TOC] ​ 在[第5章](ch5.md)中,我们讨论了复制——即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行**分区(partitions)**,也称为**分片(sharding)**[^i]。 [^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区(net splits)** 无关,这是节点之间网络中的一种故障类型。我们将在[第8章](ch8.md)讨论这些错误。 > ##### 术语澄清 > > ​ 上文中的**分区(partition)**,在MongoDB,Elasticsearch和Solr Cloud中被称为**分片(shard)**,在HBase中称之为**区域(Region)**,Bigtable中则是 **表块(tablet)**,Cassandra和Riak中是**虚节点(vnode)**,Couchbase中叫做**虚桶(vBucket)**。但是**分区(partition)** 是约定俗成的叫法。 > ​ 通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。 ​ 分区主要是为了**可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(参阅[第二部分](part-ii.md)关于[无共享架构](part-ii.md#无共享架构)的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。 ​ 对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。 ​ 分区数据库在20世纪80年代由Teradata和NonStop SQL【1】等产品率先推出,最近因为NoSQL数据库和基于Hadoop的数据仓库重新被关注。有些系统是为事务性工作设计的,有些系统则用于分析(参阅“[事务处理或分析]”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。 ​ 在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论[重新平衡分区](#重新平衡分区),如果想要添加或删除群集中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。 ## 分区与复制 ​ 分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。 ​ 一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1]()所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。 我们在[第5章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。 ![](img/fig6-1.png) **图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。** ## 键值数据的分区 ​ 假设你有大量数据并且想要分区,如何决定在哪些节点上存储哪些记录呢? ​ 分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载,那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量(暂时忽略复制)。 ​ 如果分区是不公平的,一些分区比其他分区有更多的数据或查询,我们称之为**偏斜(skew)**。数据偏斜的存在使分区效率下降很多。在极端的情况下,所有的负载可能压在一个分区上,其余9个节点空闲的,瓶颈落在这一个繁忙的节点上。不均衡导致的高负载的分区被称为**热点(hot spot)**。 ​ 避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有一个很大的缺点:当你试图读取一个特定的值时,你无法知道它在哪个节点上,所以你必须并行地查询所有的节点。 ​ 我们可以做得更好。现在假设您有一个简单的键值数据模型,其中您总是通过其主键访问记录。例如,在一本老式的纸质百科全书中,你可以通过标题来查找一个条目;由于所有条目按字母顺序排序,因此您可以快速找到您要查找的条目。 ### 根据键的范围分区 ​ 一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。 ![](img/fig6-2.png) **图6-2 印刷版百科全书按照关键字范围进行分区** ​ 键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在[图6-2]()中,第1卷包含以A和B开头的单词,但第12卷则包含以T,U,V,X,Y和Z开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。 ​ 分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在“[重新平衡分区]()”中更详细地讨论分区边界的选择)。 Bigtable使用了这种分区策略,以及其开源等价物HBase 【2, 3】,RethinkDB和2.4版本之前的MongoDB 【4】。 ​ 在每个分区中,我们可以按照一定的顺序保存键(参见“[SSTables和LSM-树]()”)。好处是进行范围扫描非常简单,您可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(参阅“[多列索引](#ch2.md#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。 ​ 然而,Key Range分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区。 不幸的是,由于我们在测量发生时将数据从传感器写入数据库,因此所有写入操作都会转到同一个分区(即今天的分区),这样分区可能会因写入而过载,而其他分区则处于空闲状态【5】。 ​ 为了避免传感器数据库中的这个问题,需要使用除了时间戳以外的其他东西作为主键的第一个部分。 例如,可以在每个时间戳前添加传感器名称,这样会首先按传感器名称,然后按时间进行分区。 假设有多个传感器同时运行,写入负载将最终均匀分布在不同分区上。 现在,当想要在一个时间范围内获取多个传感器的值时,您需要为每个传感器名称执行一个单独的范围查询。 ### 根据键的散列分区 ​ 由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。 ​ 一个好的散列函数可以将将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定一个新的字符串输入,它将返回一个0到$2^{32}$ -1之间的“随机”数。即使输入的字符串非常相似,它们的散列也会均匀分布在这个数字范围内。 ​ 出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用于哈希表),但是它们可能不适合分区:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。 ​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。 ![](img/fig6-3.png) **图6-3 按哈希键分区** ​ 这种技术擅长在分区之间分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为**一致性哈希(consistent hashing)**)。 > #### 一致性哈希 > > ​ 一致性哈希由Karger等人定义。【7】 用于跨互联网级别的缓存系统,例如CDN中,是一种能均匀分配负载的方法。它使用随机选择的 **分区边界(partition boundaries)** 来避免中央控制或分布式一致性的需要。 请注意,这里的一致性与复制一致性(请参阅第5章)或ACID一致性(参阅[第7章](ch7.md))无关,而是描述了重新平衡的特定方法。 > > ​ 正如我们将在“[重新平衡分区](#重新平衡分区)”中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然指的是一致性哈希,但是它往往是不准确的)。 因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为**散列分区(hash partitioning)**。 ​ 不幸的是,通过使用Key散列进行分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的密钥现在分散在所有分区中,所以它们之间的顺序就丢失了。在MongoDB中,如果您使用了基于散列的分区模式,则任何范围查询都必须发送到所有分区【4】。Riak 【9】,Couchbase 【10】或Voldemort不支持主键上的范围查询。 ​ Cassandra采取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据,而其他列则被用作Casssandra的SSTables中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表,但如果第一列已经指定了固定值,则可以对该键的其他列执行有效的范围扫描。 ​ 组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为`(user_id, update_timestamp)`,那么您可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。 ### 负载倾斜与消除热点 ​ 如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。 ​ 这种场景也许并不常见,但并非闻所未闻:例如,在社交媒体网站上,一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致大量写入同一个键(键可能是名人的用户ID,或者人们正在评论的动作的ID)。哈希策略不起作用,因为两个相同ID的哈希值仍然是相同的。 ​ 如今,大多数数据系统无法自动补偿这种高度偏斜的负载,因此应用程序有责任减少偏斜。例如,如果一个主键被认为是非常火爆的,一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从而存储在不同的分区中。 ​ 然而,将主键进行分割之后,任何读取都必须要做额外的工作,因为他们必须从所有100个主键分布中读取数据并将其合并。此技术还需要额外的记录:只需要对少量热点附加随机数;对于写入吞吐量低的绝大多数主键来说是不必要的开销。因此,您还需要一些方法来跟踪哪些键需要被分割。 ​ 也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,您需要自己来权衡。 ## 分片与次级索引 ​ 到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。 ​ 如果涉及次级索引,情况会变得更加复杂(参考“[其他索引结构]()”)。辅助索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式:查找用户123的所有操作,查找包含词语`hogwash`的所有文章,查找所有颜色为红色的车辆等等。 ​ 次级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如HBase和Volde-mort)为了减少实现的复杂度而放弃了次级索引,但是一些(如Riak)已经开始添加它们,因为它们对于数据模型实在是太有用了。并且次级索引也是Solr和Elasticsearch等搜索服务器的基石。 ​ 次级索引的问题是它们不能整齐地映射到分区。有两种用二级索引对数据库进行分区的方法:**基于文档的分区(document-based)**和**基于关键词(term-based)的分区**。 ### 基于文档的二级索引进行分区 ​ 假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 ​ 你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段(field)**,关系数据库中这些是**列(column)** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。 [^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求]()”。 ![](img/fig6-4.png) **图6-4 基于文档的二级索引进行分区** ​ 在这种索引方法中,每个分区是完全独立的:每个分区维护自己的二级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库(添加,删除或更新文档),只需处理包含您正在编写的文档ID的分区即可。出于这个原因,**文档分区索引**也被称为**本地索引(local index)**(而不是将在下一节中描述的**全局索引(global index)**)。 ​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 ​ 这种查询分区数据库的方法有时被称为**分散/聚集(scatter/gather)**,并且可能会使二级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文档分区二级索引。大多数数据库供应商建议您构建一个能从单个分区提供二级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个二级索引时(例如同时需要按颜色和制造商查询)。 ### 基于关键词(Term)的二级索引进行分区 ​ 我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。 ​ [图6-5](img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 ![](img/fig6-5.png) **图6-5 基于关键词对二级索引进行分区** ​ 我们将这种索引称为**关键词分区(term-partitioned)**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`颜色:红色`。**关键词(Term)** 来源于来自全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。 ​ 和之前一样,我们可以通过**关键词**本身或者它的散列进行索引分区。根据它本身分区对于范围扫描非常有用(例如对于数字,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。 ​ 关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要**分散/收集**所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。 ​ 理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅[第7章](ch7.md)和[第9章](ch9.md))。 ​ 在实践中,对全局二级索引的更新通常是**异步**的(也就是说,如果在写入之后不久读取索引,刚才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB声称在正常情况下,其全局次级索引会在不到一秒的时间内更新,但在基础架构出现故障的情况下可能会有延迟【20】。 ​ 全局关键词分区索引的其他用途包括Riak的搜索功能【21】和Oracle数据仓库,它允许您在本地和全局索引之间进行选择【22】。我们将在[第12章](ch12.md)中涉及实现关键字二级索引的话题。 ## 分区再平衡 随着时间的推移,数据库会有各种变化。 * 查询吞吐量增加,所以您想要添加更多的CPU来处理负载。 * 数据集大小增加,所以您想添加更多的磁盘和RAM来存储它。 * 机器出现故障,其他机器需要接管故障机器的责任。 所有这些更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点向另一个节点移动的过程称为**再平衡(rebalancing)**。 无论使用哪种分区方案,再平衡通常都要满足一些最低要求: * 再平衡之后,负载(数据存储,读取和写入请求)应该在集群中的节点之间公平地共享。 * 再平衡发生时,数据库应该继续接受读取和写入。 * 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘I/O负载。 ### 平衡策略 有几种不同的分区分配方法【23】,让我们依次简要讨论一下。 #### 反面教材:hash mod N ​ 我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key)