到目前为止,我们讨论的系统中,假设了所有的机器都会存储所有数据,在前面讨论复制的章节中,主节点在收到客户端的写入请求时,将全部数据分别保存在本地以及其它的从节点上,这样的存储方式存在以下的问题:
- 可扩展性:如果使用主备方式进行数据复制,所有的工作都落在主节点上,在负载很大的情况下,主节点很快会成为系统的瓶颈;
- 单机瓶颈:单台机器由于其物理硬件的能力(硬盘、内存、CPU等),总会遇到处理的上限。
- 故障隔离:如果一部分数据出现故障,会影响所有数据的访问,降低了系统的整体可用性。例如,如果能够将不同城市的数据保存在不同的节点上,当某个地区的服务出现异常时,就不会影响其它地区的数据。
在系统需要进行扩展时,通常有两种不同的扩展方案。如图6.1所示:
- 垂直扩展(Vertical Scaling):也称为"Scale Up",通过在单一服务器上增加更多资源(如更快的 CPU、更多的 RAM、更大的硬盘)来提升处理能力。
- 水平扩展(Horizontal Scaling):也称为"Scale Out",通过增加更多的服务器来分担负载,将它们组成一个集群共同工作。
垂直扩展方案只需要扩展单台机器的处理能力,有以下的优点:
- 简单:管理和维护一台机器远比管理一个集群简单。
- 数据一致性强:所有数据都在一台机器上,不需要处理分布式系统中的数据同步和一致性问题,事务处理简单。
- 低延迟:进程间通信(IPC)在单机内部非常快,没有网络开销。
- 对应用透明:应用代码通常不需要做大的改动来适应更强的硬件。
然而,任何机器的CPU、内存扩展都有其物理上限,无法无限制一直提升下去。同时由于存在单点问题,服务器一旦宕机,整个服务将完全中断,可用性低,也给维护单台机器带来很大挑战,通常需要停机维护。
对比而言,水平扩展通过增加机器扩展系统的处理能力,有以下的优点:
- 理论上无限扩展:当负载增加时,只需向集群中添加更多标准服务器即可,扩展性极佳。
- 高可用性与容错性:集群中的一台或多台服务器宕机,系统可以继续服务(可能会有性能下降),没有单点故障。
- 成本效益高:可以使用大量廉价的硬件来组成集群,总体拥有成本更低。
- 弹性伸缩:可以根据负载动态地增加或减少服务器数量,特别适合云环境。
- 在某些场景下,必须进行数据分区。比如服务可以在不同大洲、国家、区域被访问,需要在这些地区分别部署服务。
水平扩展方案中,由于数据分摊到集群上的不同节点上,面临以下的挑战:
- 架构复杂性:需要引入额外的组件和技术,如负载均衡器、服务发现、分布式数据存储、配置管理等。
- 数据一致性挑战:跨多台机器管理数据状态和保证一致性非常困难,这将是后续分布式事务章节讨论的重点。
- 网络延迟:节点间的通信依赖网络,相比单机内的 IPC,延迟更高且不可靠。
以上简述了垂直和水平两种扩展方案的优缺点,如果业务处于早期阶段,用户量和数据量增长可预测且在单机范围内,推荐优先选择垂直扩展方案;而如果预期用户量会爆炸式增长,需要应对不可预测的流量洪峰,则优先选择水平扩展。本章中主要讨论水平分区策略。
分布式系统中的水平扩展,引入了***“分区(Partition)”***的概念,将数据分摊到不同的机器,如图6.2所示,数据被分摊到两个不同的节点上,不再是所有节点保存所有数据的情况。
注意
这里的分区指的是不同节点之间划分数据的方式,而不是网络分区导致的通信中断。
“分区"在不同的系统中有不同的叫法,在有的系统中使用"shared"表示分区,有的系统使用"region”。
通常而言,分区和复制技术会结合在一起。对于一份数据,会保存在同一分区的多个节点上,这意味着一条记录会保存在特定的分区,而分区有多个节点保存副本数据,以提高系统的容错性,如图6.3所示。
在数据进行分区之后,有些操作会变得复杂:
- 如何访问数据:原先只需要访问主节点即可,现在需要考虑如何路由数据请求到对应的分区。这是一个请求路由问题,我们将在6.3.2节中深入讨论这个问题。
- 分区再平衡:划分数据分区的目的之一是将数据尽量均衡得分布在多个分区中,但是当不同分区数据分布不均时,要考虑数据分区的再平衡问题。
- 全排序问题:我们将看到,不同的分区策略,对全排序的支持不一样。如果全排序操作是系统需要考虑的常见操作,要尽量选择全排序友好的分区策略。
在本章中,我们将讨论以下和分区相关的话题:
- 有哪些常见的数据分区方式,它们的优缺点是什么;
- 如何路由数据请求到对应的分区,实现请求路由的常见策略。
- 在某些分区的访问量较大时,出现了分区热点问题,应该如何解决。
- 最后,做为复制和分区这两章的阶段性总结,我们将考察几个项目的实现方案。
6.1 分区策略 #
6.1.1 范围分区 #
范围分区是分布式数据存储中最直观、最符合直觉的分区策略。它的核心思想非常朴素:将数据主键看作一个连续的有序序列,将每一段切分出来的连续范围分配给不同的节点管理。
想象一本字典,按照词条的首字母划分了不同的分卷:
- 卷1:包含 A - B 开头的词条。
- 卷2:包含 C - E 开头的词条。
- …
这就是典型的范围分区,在这个类比中:词条单词就是分区键(Partition Key),每一卷书就是一个分区(Partition/Tablet/Region)。当你需要查找单词 “Apple” 时,你知道它一定在卷1;查找 “Cake” 时,它一定在卷2。
在分布式系统中,这种"边界"是明确定义的。系统会维护一张全局的路由表(Routing Table),记录如下映射关系,如表6.1所示:
表6.1 范围分区路由表
| 分区ID | 起始Key | 结束Key | 所在节点 |
|---|---|---|---|
| P1 | $-\infty$ | 1000 | 节点A |
| P2 | 1001 | 2000 | 节点B |
| P3 | 2001 | 3000 | 节点B |
在实际业务中,范围分区通常应用于具有自然顺序属性的数据:
- 时间维度的数据,如监控日志、交易流水、即时通讯消息等,选择时间做为分区主键。
- 地理位置的数据,如外卖订单、共享单车位置等,利用GeoHash算法将二维经纬度转化为字符串,相近的地点会有相似的前缀,从而被分到同一个节点,便于按照附近的地址派发外卖订单、查找附近的单车。
- 业务ID,如用户ID、电商平台订单号,这类ID通常都自带自增属性。图6.4就是一个以键值ID做为分区范围的例子。
范围分区的核心优势体现查询性能上:
高效的范围查询
这是范围分区最突出的优点。根据分区键查询如"WHERE user_id BETWEEN 500 AND 1500"非常高效。在进行范围查询时,系统可以快速确定哪些分片包含了目标数据范围,只需访问这些相关的分区数据,而不是全部数据,这被称为分区修剪(Partition Pruning)1。
数据物理局部性
由于相邻的键存储在同一个分片上,对连续数据的顺序扫描(如全表扫描某个时间段的数据)性能很好,可以充分利用磁盘的顺序读写特性。
易于管理和理解
对于管理员来说,基于范围的分区规则非常直观,易于进行手动管理和优化。
范围分区的主要挑战几乎都源于其优势的另一面。范围分区以序列的有序性来划分分区存储数据,如果数据的访问模式不是均匀的,就会导致严重的负载不均和热点(Hotspots)问题,这被称为数据倾斜(Data Skew):
序列写入热点
如果分区主键是自增 ID(Auto Increment ID)或者时间戳。所有新写入的数据,Key 都是递增的(如 10001, 10002, 10003…)。根据范围规则,这些最大的主键永远落在最后一个分区里。集群里虽然有多个节点,但只有存储当前最大主键的节点在承担写入压力。
在时间序列数据中,系统的最新数据(如最近一小时产生的日志、订单)通常被访问得最频繁。如果按天分区,那么"今天"这个分片就会承受巨大的写入和读取压力,而"去年"的分片几乎无人问津,形成热点分片。
业务访问热点
假设按照用户ID范围分区,如果某一个范围内恰好出现了"超级大V"(如社交媒体上的明星),或者该范围对应了一个超大型企业客户(SaaS 场景),这个特定的分区会因为请求量过大而过载,成为系统的短板。由于它是一个物理范围,很难把这个单独的热点 ID 拆分出去。
元数据管理复杂
范围分区需要一个管理元数据的组件(如 HDFS 的 NameNode,TiKV 的 PD)来维护全局路由表。客户端在读写数据前,通常要先查这个路由表,并缓存下来。如果路由表变更(比如发生了分裂),客户端的缓存会失效,导致访问错误,需要重试和更新元数据,我们将在6.3.2节继续讨论这个问题。
针对范围分区中的热点和负载不均问题,有以下的解决思路:
复合主键设计
以上可能出现负载不均的场景中,选择的都是单一的分区键,例如时间戳、ID等,应该尽量避免选择必然会产生热点的键。如果必须选择会出现热点的键做为分区键,可以考虑和其它字段组合成复合的分区键,这是一种在"有序性"和"均匀性"之间做妥协的技巧。例如,如果必须按时间分区,可以考虑将时间戳与另一个字段(如设备ID、用户ID后缀)组合成复合分区键。再比如,分区键定义为(user_id_hash, year, month)。这样确保了数据首先按年月粗粒度分区,然后在同一个月内,通过 user_id_hash 将数据打散到不同的分区中,从而避免单个分区过大或过热。
预分区
根据预期的数据分布,提前创建好多个分片,而不是从一个分片开始。这可以避免系统初期只有一个分片承担所有压力。例如TiDB支持*预分裂(pre split)*功能2。
动态分区分裂
系统监控每个节点的资源使用,当监测到某个分片的大小或吞吐量超过阈值时,自动将其分裂成更小的分片。
范围分区是一种强大而直观的策略,它将数据的物理存储与逻辑顺序对齐,从而在范围查询和顺序访问场景下提供了强大的性能。然而,其成功高度依赖于分区键的选择和访问模式。如果不能有效解决热点问题,它很容易成为系统的瓶颈。
负载再平衡
在讨论分区策略时,除了关注数据如何被访问,还应当关注不同分区策略的*负载再平衡(Load Rebalancing)*表现,这是维持系统稳定性、高可用性和高性能的核心机制。简单来说,它是指当系统节点发生变化(新增、故障、缩容)或现有负载分布不均时,将数据或计算任务从高负载节点迁移到低负载节点的过程。
在深入流程之前,我们需要了解什么情况会触发再平衡。通常分为被动触发和主动触发:
- 节点扩容:新节点加入集群,需要分担现有节点的流量和数据。
- 节点缩容/故障:节点下线或宕机,其上的任务或数据需要转移到剩余的健康节点上,以保证副本数和可用性。
- 数据倾斜:某些特定的分区变得非常大或访问非常大成为热点,导致所在节点负载过高,即使节点数量没有变化,也需要调整数据分布。
再平衡需要满足以下几个要求:
- 重新平衡后,负载(节点中存在的数据项、写入和读取请求)在集群中现有节点之间均匀分配。
- 在重新平衡数据时,仅应移动那些需要在节点间迁移的数据项,这样可以节省网络和磁盘I/O负载。
- 在重新平衡期间,系统必须保持可用,即它应该继续接受读取和写入操作。
再平衡的本质是重新建立数据与节点之间的映射关系,这依赖于底层的分片策略,我们来看各种分区策略在数据迁移时的影响。
注意
本节中将结合几种分区策略讨论不同分区策略的负载再平衡时的数据分布,将在6.2节中讨论数据迁移的流程。
如图6.5是范围分区时的再平衡流程,用三种颜色来区分原来分布于三个节点的数据。假如原先的数据是均匀分布在三个节点上的,当新增一个节点时,原先的每个节点都应该往范围中的下一个节点迁移数据:节点1往节点2迁移25%的数据,节点2往节点3迁移50%的数据,以此类推。可以看到,在范围分区再平衡时,每个节点都会受到影响,只是影响的数据比例不一样。
6.1.2 哈希分区 #
*哈希分区(Hash-based Partitioning)*是另一种常见的分区策略,是一种与数据无关的分片方法。哈希函数接受任意输入,并生成一个固定大小的随机但确定性的输出,通过对分区数量取模(hash(key) % N,这里N为分区的数量)的方式决定数据归属的分区。选择哈希函数时,要求数据均匀分布,常见的有MD5、CRC32、MurmurHash3、SHA-256等算法。
哈希分区的有如下优势:
- 负载均衡:这是采用哈希分区的首要目标。一个优秀的哈希函数可以将输入数据均匀地分布到输出空间,从而使得每个分片存储的数据量和接收的请求量都大致相当,极有效地避免了热点问题。
- ***点查询(Point Query)***性能:对于基于分区键的精确匹配查询(例如user_id = 7),系统可以直接计算出目标分片,实现O(1)时间复杂度的路由,只需访问一个节点。
由于哈希分区的数据无关性,因此这种分区方式牺牲了有序性来换取均衡性,将面临以下挑战:
范围查询效率低下
哈希函数破坏了分区键的原始顺序。查询如 WHERE user_id > 1000 变得毫无意义,因为哈希值的大小与原始值无关。系统必须将查询广播(Scatter/Gather)到所有分片,然后合并结果,效率极低,延迟很高。
因此,如果业务需要频繁使用范围查询,不应优先选择哈希分区策略。
另一种解决方案,可以设计一个同时包含哈希成分和范围成分的复合主键(Composite Primary Key)。例如,可以设计主键为PRIMARY KEY (country, user_id),其中country做为分区键,它先被哈希,决定数据去哪个分片;user_id 是聚类键(Clustering Key),它负责在分区内部排序。这样,就可以高效地查询"WHERE country = ‘CHINA’ AND user_id > 1000"。该查询只会命中存储中国用户数据的分片,并在该分片内部按 user_id 进行高效的范围扫描。
仍然存在热点问题
即使哈希分区的数据分布相对更均匀,也可能存在业务上的"超级热点",例如社交媒体上某些明星的发言,会引来更多的关注。这类问题属于*单热键(Hot Key)*问题,这类问题的一种方案可以考虑在原始分区键前附加一个随机的前缀(“盐(salt)"),将一条数据变成多条,分散到不同分区。
例如,一个超级明星的社交平台ID user:123是热键。在写入该明星的数据时,不再直接写 user:123,而是写入 user:123_salt0, user:123_salt1, … user:123_salt9(共10个不同的键),这些键通过哈希分布到10个不同分区。在读取时,要读取明星的所有数据,客户端必须并行查询所有10个键(user:123_salt*),然后在应用层合并结果。该方案的优点是可以有效地将单个热点的读写压力分散到多个分区,缺点是极大地增加了读取的复杂性和开销,因为写入时加了随机后缀,读取时你不知道数据具体存在哪个后缀的分区里。你必须查询所有可能的前缀,即执行一次分散-聚集 (Scatter-Gather) 查询,效率较低,是一种用读取代价换取写入均衡的权衡策略。
再平衡的代价高
简单的hash(key) % N算法有一个致命问题:当分片数量N发生变化时(增删节点),绝大多数数据的映射关系都会失效。对比图6.6和图6.7,节点的数量从4增加到5,因此尽管使用的是相同的哈希函数,但是由于节点的数量发生变化,哈希函数模节点数量的结果发生变化,导致相同的键值迁移到了不同的机器上。
对比范围分区的负载再平衡流程,因为要求数据有连续性,所以即使在进行再平衡影响所有分区数据时,也是可以大体预测平衡后的数据分布的。与此不同的是,哈希分区取模的方式不仅影响全部分区,新的数据分布也显得杂乱无章,难以预测新的数据分别情况,如图6.8所示。
注意
前文提到,哈希分区虽然解决了负载均衡,却牺牲了范围查询的能力。但在生产级的分布式存储系统(如 Apache Cassandra)中,设计者并不是在"哈希"与"有序"之间二选一,而是采用了一种巧妙的混合分区(Hybrid Partitioning)策略。
这种策略的核心在于引入复合主键(Composite Primary Key),将主键拆分为两个独立的维度:
- 分区键(Partition Key):负责"去哪台机器”。这是主键的第一部分。系统对分区键进行哈希计算,依据一致性哈希环将数据均匀分散到不同的物理节点上。这保证了集群的全局负载均衡,避免了数据倾斜。
- 聚类键(Clustering Key):负责"在机器内怎么排"。这是主键的第二部分。在同一个分区(即同一个物理节点)内部,数据并不是乱序堆放的,而是严格按照聚类键的值进行物理排序(通常存储在 SSTable 中)。这保证了数据的局部有序性。
假设我们要设计一个"用户订单表",查询需求通常是"查询某个用户最近半年的订单",将user_id设为分区键,将order_time设为聚类键,写入和查询流程如下:
- 写入时:数据根据Hash(user_id) 访问对应的节点,但在该节点内部,该用户的订单是严格按order_time 排列的。
- 查询时:会先通过哈希算法 O(1) 定位到节点,然后在该节点内进行极高效的顺序扫描。
这种"全局哈希定位,局部有序扫描"的设计,完美地结合了哈希分区的写入扩展性和范围分区的读取灵活性,是目前处理海量时序数据(Time-Series Data)和用户维度数据的最佳实践之一。
为了解决哈希分区由于分区数量变化导致的这一问题,引入了一致性哈希。
6.1.3 一致性哈希 #
一致性哈希在论文《Consistent Hashing》提出,它的核心思想是:不再将节点数量N作为模数,而是将一个巨大的、固定的哈希环作为映射空间。它通过哈希环(Hash Ring) 将数据和节点映射到同一个环形空间(通常为$2^{32}-1$),数据键按顺时针方向找到最近的节点存储。当读写数据时,对键值求对应的哈希值再对环形空间大小取模,顺时针查找到的第一个节点就是目标节点。
如图6.9所示,键值key-1映射到环形空间之后,顺时针找到的第一个节点是节点1,因此这条记录存储到节点1上;类似地,key-2的数据存储到节点3上。
由于环形空间大小是一开始就固定的,因此对应相同的键值,映射到环形空间的位置都是一样的,当节点数量发生变化时,也只会影响新增节点附近节点的数据分布。
如图6.10所示,新增节点4之后,原先写入到节点3的部分数据将迁移到节点4,而原先写入到节点1和节点2的数据则不需要进行数据迁移。
如图6.11所示,删除节点3之后,原先在节点3的请求将迁移到顺时针的第一个节点(即节点1)上,其它节点则不受影响。
可以看到,一致性哈希算法在增加或删除节点时,仅影响哈希环上的部分节点数据,其它数据则不受影响。
然而,标准的一致性哈希算法也有自己的缺点:
- 负载不均衡:一致性哈希算法并不能保证在哈希环上的数据均匀分布,这有可能导致数据的访问倾斜,大量的数据集中在少数节点上。如图6.12所示,由于数据的分布不均匀,导致节点1承担了大量的访问。
- 节点增删时负载失衡:当某个节点宕机下线后,本应由它负责的数据会全部转移到它的下一个节点上。这个新负责的节点突然要承担两个节点的数据量,容易引发雪崩效应(cascading failure)。同样,新增节点时,只会减轻它下一个节点的压力,其他节点不受影响,负载依然不均。
- 没有考虑节点的异构属性:某些节点的机器性能更好,就应该多承担一些请求工作,反之某些节点的机器性能不佳,应该少承担一些工作。但是目前的设计是不同性能的物理节点都对应一个哈希环上的节点。
为了解决这一问题,业界一般采用一致性哈希的变种算法《Chord》,如图6.13所示:每个物理节点不再映射到环上的一个点,而是多个点(即多个虚拟节点(virtual node)):
- 数据映射到哈希环时,不再映射到物理节点,而是映射到虚拟节点;
- 每个物理节点将管理多个虚拟节点。
- 考虑到不同物理节点的性能,不同的物理节点,管理的虚拟节点数量不一定一样。
如图6.14所示,系统中只有两个物理节点,其中物理节点1管理虚拟节点[A1,A2,A3],物理节点2管理虚拟节点[B1,B2,B3],虚拟节点的数量比物理节点大了三倍,数据分布也更加均匀。
引入虚拟节点后带来了如下的好处:
- 负载均衡:由于每个物理节点有大量的虚拟节点分散在环上,数据项在环上的分布会更均匀地由不同的虚拟节点处理,从而间接地由不同的物理节点承担。虚拟节点数量越多,负载就越均匀。通过增加虚拟节点的数量,可以平滑地优化负载分布。这些都极大改善了系统物理节点之间的负载均衡问题。
- 节点故障时实现平滑过渡:当一个物理节点宕机时,并不是一整段数据区间都转移给下一个节点,而是该节点的所有虚拟节点同时失效。这些虚拟节点原本负责的数据,会均匀地转移给环上接下来的其他虚拟节点,而这些虚拟节点又归属于多个不同的物理节点。这意味着,数据负载被均匀地重新分配给了所有剩余的物理节点,而不是全部压垮某一个节点,有效防止了雪崩效应。
- 方便地调整节点权重:如果某些物理节点性能更强(如 CPU、内存、磁盘更好),你可以为它分配更多数量的虚拟节点。性能强的节点拥有更多的虚拟节点,意味着它在环上占据了更多的区间,从而能够承担更大比例的数据量和流量,实现加权负载均衡。
标准一致性哈希算法和带虚拟节点的一致性哈希算法的对比如表6.2所示,目前几乎所有采用一致性哈希算法实现分区的生产系统(如Redis cluster、Dynamo等)都采用的是带虚拟节点的一致性哈希算法。
表6.2 对比标准一致性哈希算法和带虚拟节点的一致性哈希算法
| 特性 | 标准一致性哈希算法 | 带虚拟节点的一致性哈希算法 |
|---|---|---|
| 负载均衡 | 差,容易产生热点 | 优秀,虚拟节点数越多越均匀 |
| 节点故障影响 | 可能导致下游节点过载,引发雪崩效应 | 平滑,负载被所有存活节点分担 |
| 灵活性 | 低,所有节点权重相同 | 高,可通过虚拟节点数量调整权重 |
| 实现复杂度 | 简单 | 稍复杂,需要管理虚拟到物理的映射 |
哈希分区是一种以数据分布均衡性为核心追求的策略。它通过牺牲范围查询的能力,换来了无与伦比的负载均衡特性和高效的点查询性能。
其技术演进的核心难点和最高光点在于再平衡,而一致性哈希配以虚拟节点的实现,优雅地解决了这一问题,使其成为现代分布式系统(尤其是键值存储和缓存)事实上的标准分区方案。选择哈希分区,意味着你非常确定你的工作负载是大量的随机点查询,并且对负载均衡有极高的要求。
6.2 数据迁移流程 #
我们已经在分区策略中讨论了不同分区策略负载再平衡的流程,本节讨论数据迁移流程,它不仅发生在再平衡时,还发生在节点修复、副本补齐、跨机房同步等场景中。
在复制章节中,我们曾经讨论过数据复制流程,虽然两者在底层技术机制上高度重叠,但是在目的、生命周期、触发机制上有着本质区别。
- 数据复制是一个常态、持续的流程,目的是为了保持数据的冗余性,只要系统存在这个流程会一直进行下去,很多情况下,数据复制是一对多的流程(例如主从复制中,主节点要将数据复制到多个副本上)。
- 数据迁移则更多是一个临时的过程,只有在出现数据需要搬迁的情况下才开启,一般是一对一的流程。
由于这个区别,因此数据迁移时除了涉及到数据的移动,还涉及到何时能够将源节点的请求切换到新节点上,而数据复制不存在这个流程。
如图6.15,数据迁移的一般流程如下:
- 初始化:同步全量快照数据到新节点。
- 数据追赶:同步全量快照后的新增数据到新节点。
- 切换节点:在源节点和新节点的数据差距足够小时,完成集群路由表的切换,新节点正式接管所有的请求。这个切换流程,将影响系统的可用性。
在迁移流程中,最后的路由表切换是一个难点,它面临以下几个挑战:
- 在切换的间隙,可能会出现服务短暂不可用的情况。例如,先停掉了源节点的写入,等待新节点确认追平了数据才更新路由表。这个追平会花费一些时间,这些都会导致这个间隙写请求报错。
- 更新路由表后,元信息的传播需要花费一定的时间,这个过程中客户端可能仍然认为源节点还生效,继续往这个阶段写入数据。
- 除了这些挑战以外,可能还会残留一些和源节点保持长连接的客户端,即使路由表已经更新,仍然往源节点发送请求。
针对这些问题,我们给出以下几个方案。
阻塞式切换
这是最安全的方案,但有短暂时间系统不可用。适用于对一致性要求极高,容忍毫秒级抖动的场景,流程如下:
- 禁写源节点:源节点收到切换指令后,拒绝所有新的写请求,只处理读请求。
- 追赶最后的数据:新节点追赶最后新写入的数据。
- 配置变更:新节点追平数据之后,更新路由表执行切换。
基于版本号的请求围栏
可以采用故障转移章节中提到的Fence机制来进行保护:
- 每个节点维护一个单调递增的版本号,在进行切换时,源节点递增本地分区的版本号。
- 当旧节点继续收到客户端请求时,由于版本号不一致,旧节点可以转发到新的节点,也可以拒绝客户端的请求,提示更新路由重新进行尝试。
这个方案解决了"路由延迟"和"连接残留"问题。旧请求会被拦截,不会造成数据污染。
无缝双读双写
这个方案最复杂,但用户无感知,适用于数据库上层迁移或跨机房迁移。它的机制是引入一个中间状态,系统处于该状态时,一条日志需要在必须在旧配置的大多数和新配置的大多数节点都落盘,才算提交。
这个方案保证了即使源节点和新节点同时都想当Leader节点,也必须得到对方配置的认可,不可能同时成功。从而实现了平滑过渡,不需要停写。它的流程如下:
- 开启双写:当新旧节点数据足够接近时,客户端(或代理层)将写请求同时发送给源节点和新节点。
- 以源为准:此时读取依然走源节点,新节点的数据仅用于"热身"和校验。
- 无缝切换:一旦确认数据一致,只需在配置中心修改读路由指向新节点,然后关闭双写(只写新节点)。这种方式可以做到真正的"零停机",但对客户端实现的复杂度要求极高。
注意
我们将在联合共识算法章节中深入探讨Raft的联合成员变更算法,这个算法就是双读双写思想的一个实现。
以上几种方案,是在"可用性"和"一致性"之间做权衡。通过"追平数据 -> 短暂禁写 -> 变更元数据 -> 恢复写入"这一标准范式,我们可以用极小的可用性损耗(毫秒级阻塞),换取绝对的数据一致性保障。
6.3 请求路由 #
在复制和分区这两章中,都涉及到同一类问题,客户端的访问,如何到达对应的节点:
- 在主从复制模式中,需要将写请求发送到主节点。
- 在数据分区中,需要将数据发送到相应的节点。
这类问题在通常被称为请求路由,本节将总结最常见的几类请求路由实现方式,我们将看到,这几类实现方式的区别在于:谁负责感知路由信息。
由于这里讨论的请求路由问题,既包括了主从复制时由主节点处理客户端的写请求,也包括数据分区中数据由相对应的分区节点处理,为了简单起见,在以下的描述中,称一个节点可以处理某个客户端请求,意味着:
- 在主从复制中,该节点是主节点;
- 在数据分区中,该节点是数据所在的分区。
6.3.1 请求路由模式 #
有三种请求路由模式:节点转发、路由转发和客户端发现模式,这几种模式的区别在于:请求路由的责任方不同,我们依次进行讨论。
服务端代理模式
如图6.16所示,在这种实现方式中,客户端不需要知道任何分区信息,所有节点都是对等的,每个节点都维护一份路由映射关系表,因此客户端可以连接任意的节点发送请求:
- 如果该节点能够处理该请求,那么处理请求之后应答客户端;
- 否则,节点上识别数据转发给可以处理请求的节点,接收节点的应答再应答客户端。
这种模式的优点是,客户端不需要感知任何分区、主节点信息,可以将请求发送给集群内的任意节点,最终都能收到应答,因此客户端可以做到极简,采用通用的协议就能访问。缺点是由于可能产生转发请求,会多一次跳转。
客户端感知模式
以上的两种实现方式中,客户端除了知道如何访问节点或者路由(域名、IP地址、端口等信息)以外,对节点的其它信息一无所知(例如是否是主节点,可以处理哪些分区的数据等)。可以说,前两种方式中,节点的信息对客户端是"透明"的。除了这两种方式以外,还可以由客户端感知节点的信息,这样在一开始客户端就能知道访问哪个节点。
如图6.17所示,对比前两种方式,在客户端发现模式中,客户端的实现更"重",因为在这种实现方式中,除了请求应答的逻辑以外,客户度还要自己维护节点的信息,例如在节点发生变更时,客户端要实时更新这部分的元数据;当请求被拒绝时,需要自己实现重试逻辑。可以看到,在这种模式下,复杂性下沉到了客户端,因此这类模式的客户端也被称为"胖客户端"。
因此,在客户端感知节点信息的方式中,一般以SDK的方式提供给用户使用,这就涉及到可能需要实现多语言版本的SDK才能满足用户的需求,而在前两种方式中,如果请求协议是通用协议(例如HTTP协议),则可以使用任意的通用协议客户端。另外,客户端发现模式中,由于没有了网络跳转,由客户端直连节点,因此性能相对较高。Kafka、ZooKeeper等项目采用的都是胖客户端模式。
独立路由层模式
如图6.18所示,在这种方式中,客户端并不直接跟节点通信,而是首先连接一个路由层,路由层负责解析协议、维护路由表、转发请求,收到客户端请求时不做处理,而是转发请求给对应的节点处理。
路由转发模式的优点是将业务层和接入层解耦,业务层可以随意扩缩容,客户端无感知,路由规则的变化完全在接入层进行处理。在服务端代理模式中,如果收到客户端请求的节点不是相应的处理节点,将多一次跳转,但是路由层模式则是肯定会多一次跳转。
我们已经分析了几种模式实现的优缺点,在选择服务发现模式之前,需要考虑以下的问题:
- 延迟敏感度:如果业务非常在意延迟,多一个网络跳转都无法接受,那么客户端感知是唯一选择。
- 客户端的多样性:是否需要多语言(Java、Go、Python、Rust..)的客户端接入,如果客户端语言众多,每种语言维护一套客户端的成本极高,因为你要为每种语言重写复杂的路由逻辑,此时中间代理或服务端路由更合适。
- 连接管理模型:业务是长连接还是短连接?如果有海量短连接,那么客户端直接连后端存储节点可能会导致其文件句柄耗尽。此时引入路由层来做连接池化是很好的防御手段。
- 架构的解耦程度:后端扩缩容时,是否允许客户端重启或升级?如果不允许,那么客户端越"傻"越好,把复杂度藏在路由或节点内部。
表6.3中对这几种模式进行了对比。
表6.3 三种请求路由模式的对比
| 维度 | 客户端感知模式 | 服务端代理模式 | 独立路由层模式 |
|---|---|---|---|
| 网络跳数 | 1跳(直连) | 1跳或2跳 (可能需节点间转发) |
2跳(路由转发) |
| 客户端复杂度 | 高 (胖客户端,需实现路由逻辑) |
低 (瘦客户端,只需连任意节点) |
低 (瘦客户端,只需连路由) |
| 运维复杂度 | 低(无额外组件) | 中 | 高 (需维护额外的路由集群) |
| 耦合度 | 高 (客户端需感知拓扑变化) |
中 (客户端感知集群,不感知分片) |
低 (客户端完全无感知) |
| 连接数压力 | 存储节点直接承受所有客户端连接 | 存储节点直接承受连接 | 路由层承受连接 |
| 多语言支持 | 困难 (每种语言都要写复杂的 SDK) |
容易 | 容易 |
6.3.2 请求路由的实现 #
无论采用哪种路由模式(客户端感知、代理转发或路由层),系统的核心挑战在于如何维护和传播分区元数据(Partition Metadata)。
所谓分区元数据,是指记录"哪个数据分区(Partition)存储在哪个物理节点(Node)上"的全局映射关系。不同于无状态服务只需要知道"哪些节点存活",分布式存储系统必须精确知道"Key X归哪个节点管"。
在业界实践中,通常有以下两种主流的实现方案:
外部协调服务
这是最常见且架构最清晰的方案。系统引入一个高可用的外部协调组件(如 Zookeeper、Etcd)或专用的元数据管理节点(如 HDFS 的 NameNode、TiDB 的 PD)来维护全局的路由表。
如图6.19所示,其工作流程通常如下:
- 注册与汇报:当数据节点启动时,向元数据中心注册自己负责的分区范围;当发生数据迁移(Rebalance)时,节点也会汇报最新的分区持有情况。
- 订阅与通知:客户端(或路由层)启动时,从元数据中心拉取完整的分区映射表(Partition Map)并缓存在本地。
- 本地路由:客户端发起请求时,仅需在本地计算 Key 的哈希值或范围,查阅缓存的映射表即可定位目标节点,无需每次请求都查询协调组件,从而保证了高性能。
- 更新路由表:除了上面的流程以外,在路由表发生变化时,也需要通知客户端。客户端通常会通过Watch机制订阅变更,一旦元数据发生变化,客户端能实时感知并更新本地缓存。
这种方案的优点是单一信源(Single Source of Truth),元数据的一致性容易保证;缺点是引入了额外的外部依赖,若协调组件彻底宕机,虽然不影响已建立的数据读写,但会导致集群无法进行扩缩容或故障恢复。
注意
维护请求路由中的元数据信息,通常采用的是强一致模型,我们将在共识算法中讨论如何实现一个满足强一致模型的共识算法。
Gossip 协议与去中心化路由
为了避免中心化组件的单点风险,以 Cassandra、Redis Cluster 为代表的系统采用了去中心化(Decentralized)的方案。
在该方案中,集群内没有上帝视角的中心节点。每个数据节点都保存了一份自己视角下的集群路由表,节点之间通过 Gossip 协议(流言蜚语协议)周期性地交换状态信息。
Gossip协议的工作流程如下:
- 状态传播:节点随机选择另外一个节点,通知最新的路由信息,如图6.20所示。这种信息会像病毒一样迅速在集群内传播,最终所有节点都会收敛到一致的全局路由视图。
- 客户端请求:客户端可以向集群中的任意节点发送请求。
- 智能转发:
- 如果接收请求的节点正好持有该数据,则直接处理。
- 如果数据不在该节点,该节点会根据自己的路由表,告知客户端"该数据在节点 C,请重定向"(如 Redis 的 MOVED 响应);或者充当临时代理,转发请求并把结果返回给客户端(如 Cassandra)。
这种方案的优点是极强的扩展性和可用性,无单点瓶颈;缺点是当拓扑结构剧烈变化时,由于是逐个节点来传播新的路由信息,会出现网络风暴和收敛慢的问题,不同节点间的路由表可能存在短暂的不一致,导致客户端需要多跳几次才能找到正确节点。另外最重要的一点是,整个流程只满足最终一致性,并不知道何时路由的数据完全收敛。在节点剧烈变动时,Redis Cluster等系统可能会出现大量的MOVED或重定向,导致客户端性能抖动,这部分工程代价提及较少。
路由纠错机制
无论采用上述哪种方案,客户端缓存的路由表都可能因为网络延迟等原因变为*陈旧(Stale)*数据。因此,成熟的分布式系统都会设计一套纠错机制:当客户端依据旧路由表请求节点 A 时,如果节点 A 发现自己已经不再持有该数据(可能已经迁移到了节点 B),它不会盲目处理,而是返回一个特定的错误(如 NotMyKey或EpochMismatch)。客户端收到此类错误后,会强制触发一次元数据刷新,重新拉取最新的路由表并重试。这是保证系统在动态环境下准确性的最后一道防线。
6.4 本章小结 #
随着数据量和访问流量的爆发式增长,单机存储在存储容量、计算能力和网络带宽上终将触及物理极限。本章我们深入探讨了分布式系统扩展的核心技术——分区(Partitioning),它是实现数据水平扩展(Scale Out)、提升系统吞吐量和可用性的关键手段。
本章我们重点讨论了以下核心议题:
1、分区策略的选择与权衡
分区算法决定了数据在集群中的分布形态,没有一种"万能"的策略,只有最适合业务场景的选择:
- 范围分区:保留了键值的有序性,极其适合范围查询和时间序列数据,但面临着严峻的"顺序写入"和"访问热点"挑战,需要通过精细的预分区或动态分裂来缓解。
- 哈希分区:追求数据的极致均衡,通过牺牲范围查询能力换取了负载的均匀分布。
- 一致性哈希:引入哈希环和虚拟节点机制,优雅地解决了集群扩缩容时的数据剧烈抖动问题,成为现代分布式缓存和键值存储的事实标准。
2、应对数据倾斜与热点
理论上的均匀分布往往会被现实世界的"长尾效应"打破。我们分析了数据倾斜(Data Skew)和访问热点(Hotspots)产生的原因,并提出了复合主键设计以及热点Key加盐(Salting)等应用层解决方案,在写入均衡与读取复杂性之间寻找平衡点。
3、动态环境下的数据迁移
分布式系统不是静态的。我们剖析了在节点扩缩容、故障恢复或负载再平衡场景下的数据迁移流程。一个健壮的迁移机制必须在保证数据一致性的前提下,最小化对在线服务的影响。我们讨论了从全量快照到增量追赶,再到原子性路由切换的标准范式。
4、请求路由机制
分区的最后一公里是如何让客户端找到数据。我们对比了三种经典的路由模式:
- 客户端感知:性能最优,但客户端复杂且耦合度高。
- 服务端代理:客户端极简,但增加网络跳数。
- 独立路由层:架构解耦,适合大规模微服务体系。
最后,我们对比了通过外部协调服务或Gossip协议维护分区元数据一致性的不同实现方案。
分区将大数据化整为零,但这仅仅是开始。当数据被切分到不同节点后,如何跨越分区进行原子性的事务提交?如何在多个副本间保证数据的一致性?这些因分区而引入的新挑战,将是我们后续章节讨论"分布式事务"与"共识算法"的基础。通过本章的学习,我们已经为构建高可扩展的分布式系统打下了最坚固的基石。