#分布式 #数据库 #数据结构
1. 数据复制
1.1. 数据复制方式
1.1.1. 同步复制
1.1.1.1. 基础同步复制
主节点将数据发送给从节点,待从节点 ack 以后主节点再返回客户端 ack 信号。对于复制给从节点的数据,可以等到全部从节点 ack,亦可以部分从节点 ack 即可,但是至少需要一个从节点 ack 才能保证数据已被复制。
同步复制可以保证至少有一个副本备份了数据,缺点是从节点的延迟会阻塞主节点的处理直到从节点 ack。
1.1.1.2. 链式复制
1.1.2. 异步复制
主节点将数据发送给从节点后不等待从节点 ack 即向客户端 ack,这样不能百分百保证数据被备份,但在某些要求高吞吐量的场景下此种方式被广泛使用,特别是那些从节点数量巨大或者分布于广域地理环境。
1.2. 节点失效与切换
1.2.1. 从节点失效
对于从节点失效,通常只需要等待节点重启后,从最后一次同步记录开始继续同步主节点数据即可。
1.2.2. 主节点失效
主节点失效后,通常需要处理以下两个问题:主从切换,选择一个从节点将其提升为主节点;主节点切换,客户端需要往新主节点写入数据,其他从节点也要从新主节点同步数据。
主从切换可以手动切换或自动切换,自动切换步骤如下:
-
确认主节点失效
类似于系统崩溃,停电、网络问题等都会导致主节点失效,没有万无一失的方法可以检测问题如何产生,所以大多数系统都采用了基于超时的机制:节点间频繁地互相发生发送心跳存活消息,如果发现某一个节点在一段比较长时间内 (例如 30s) 没有响应,即认为该节点发生失效。
对于超时时间的设定,也不是非常容易确定的,主节点失效后,超时时间设置得越长也意味着总体恢复时间就越长。但如果超时设置太短,可能会导致很多不必要的切换。一种选择的方式是平时记录系统的平均响应时间,根据其设定超时时间。
-
选举新的主节点
可以通过选举的方式 (超过多数的节点达成共识) 来选举新的主节点,或者由之前选定的某控制节点来指定新的主节点。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险。让所有节点同意新的主节点是个典型的共识问题。
-
重新配置系统使新主节点生效
客户端现在需要将写请求发送给新的主节点。如果原主节点之后重新上线,可能仍然自认为是主节点,而没有意识到其他节点已经达成共识迫使其下台。这时系统要确保原主节点降级为从节点,并认可新的主节点。
1.2.3. 节点切换风险
1.2.3.1. 数据丢失问题
对于采用异步复制的方式,若主节点尚未完成复制就宕机,则此时从节点不会收到这部分数据。进行主从切换之后,对于未复制的数据,有以下两种处理:
-
原主节点恢复后,将未复制的数据发送给新主节点。
新主节点收到的复制数据可能与当前已有数据产生冲突,如采用数据自增 ID 生成了数据,原主节点又发来同样 ID 的数据。
-
原主节点直接丢弃未复制的数据。
对于这种处理,数据会直接丢失。
无论采用哪种方式,都会存在一定的问题,这是采用异步复制方式无法避免的。
1.2.3.2. 脑裂问题
在某些故障情况下,可能会发生两个节点同时都自认为是主节点的情况,这种情况被称为脑裂。它非常危险: 两个主节点都可能接受写请求,并且没有很好解决冲突的办法 , 最后数据可能会丢失或者破坏。作为一种安全应急方案,有些系统会采取措施来强制关闭其中一个节点。然而,如果设计或者实现考虑不周,可能会出现两个节点都被关闭的情况。
1.3. 数据复制的实现
1.3.1. 基于语句复制
最简单的情况,主节点记录所执行的每个写请求(操作语旬)并将该操作语旬作为日志发送给从节点。对于关系数据库,这意味着每个 INSERT、UPDATE 或 DELETE 语旬都会转发给从节点,并且每个从节点都会分析并执行这些 SQL 语旬,如同它们是来自客户端那样。
语句级复制看起来是最简单的一种实现,逻辑紧凑,然而其有许多限制:
-
任何调用非确定性函数的语旬,如 NOW () 获取当前时间,或 RAND () 获取一个随机数等,可能会在不同的副本上产生不同的值。
-
如果语句中使用了自增列,或者依赖于数据库的现有数据(例如,
UPDATE ... WHERE CONDITION),则所有副本必须按照完全相同的顺序执行,否则可能会带来不同的结果。进而,如果有多个同时并发执行的事务时,会有很大的限制。 -
有副作用的语句(例如,触发器、存储过程、用户定义的函数等),可能会在每个副本上产生不同的副作用。
对于语句级复制的限制,可以采取一定措施避免,如主节点可以在记录操作语句时将非确定性函数替换为执行之后的确定的结果,这样所有节点直接使用相同的结果值。但是,这里面存在太多边界条件需要考虑,因此目前通常首选的是其他复制实现方案。
1.3.2. 基于预写日志 (WAL) 实现
可以采用 WAL 日志进行复制日志实现,如 Mysql 的 redolog 日志就是一种预写日志。其主要缺点是日志描述的数据结果非常底层:一个 WAL 包含了哪些磁盘块的哪些字节发生改变,诸如此类的细节。这使得复制方案和存储引敛紧密耦合。如果数据库的存储格式从一个版本改为另一个版本,那么系统通常无法支持主从节点上运行不同版本的软件。
看起来这似乎只是个有关实现方面的小细节,但可能对运营产生巨大的影响:
-
如果复制协议允许从节点的软件版本比主节点更新,则可以实现数据库软件的不停机升级: 首先升级从节点,然后执行主节点切换,使升级后的从节点成为新的主节点。
-
相反,复制协议如果要求版本必须严格一致(例如 WAL 传输),那么就势必以停机为代价。
1.3.3. 基于行的逻辑日志实现
另一种方法是复制和存储引擎采用不同的日志格式,这样复制与存储逻辑剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。关系数据库的逻辑日志通常是指一系列记录来描述数据表行级别的写请求:
-
对于行插入,日志包含所有相关列的新值。
-
对于行删除,日志里有足够的信息来唯一标识已删除的行,通常是靠主键,但如果表上没有定义主键,就需要记录所有列的旧值。
-
对于行更新,日志包含足够的信息来唯一标识更新的行,以及所有列的新值(或至少包含所有已更新列的新值)。
如果一条事务涉及多行的修改,则会产生多个这样的日志记录,并在后面跟着一条记录,指出该事务已经提交。 MySQL 的二进制日志 binlog (当配置为基于行的复制时) 使用该方式。
由于逻辑日志与存储引敛逻辑解耦,因此可以更容易地保持向后兼容,从而使主从节点能够运行不同版本的软件甚至是不同的存储引擎。
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统(如用于离线分析的数据仓库),或构建自定义索引和缓存等,基于逻辑日志的复制更有优势。
1.3.4. 基于触发器实现
到目前为止所描述的复制方法都是由数据库系统来实现的,不涉及任何应用程序代码。通常这是大家所渴望的,不过,在某些情况下,我们可能需要更高的灵活性。例如,只想复制数据的一部分,或者想从一种数据库复制到另一种数据库,或者需要订制、管理冲突解决逻辑,则需要将复制控制交给应用程序层。
触发器和存储过程:触发器支持注册自己的应用层代码,使得当数据库系统发生数据更改(写事务)时自动执行上述自定义代码。通过触发器技术,可以将数据更改记录到一个单独的表中,然后外部处理逻辑访问该表,实施必要的自定义应用层逻辑,例如将数据更改复制到另一个系统。 Oracle 的 Databus 和 Postgres 的 Bucardo 就是这种技术的典型代表。
基于触发器的复制通常比其他复制方式开销更高,也比数据库内置复制更容易出错,或者暴露一些限制。然而,其高度灵活性仍有用武之地。
1.4. 复制滞后问题
主从复制要求所有写请求都经由主节点,而任何副本只能接受只读查询。对于读操作密集的负载(如 Web) , 这是一个不错的选择: 创建多个从副本,将读请求分发给这些从副本,从而减轻主节点负载并允许读取请求就近满足。
在这种扩展体系下,只需添加更多的从副本,就可以提高读请求的服务吞吐最。但是,这种方法实际上只能用于异步复制,如果试图同步复制所有的从副本,则单个节点故障或网络中断将使整个系统无法写入。而且节点越多,发生故障的概率越高,所以完全同步的配置现实中反而非常不可靠。
不幸的是,如果一个应用正好从一个异步的从节点读取数据,而该副本落后于主节点,则应用可能会读到过期的信息。这会导致数据库中出现明显的不一致:由于并非所有的写入都反映在从副本上,如果同时对主节点和从节点发起相同的查询,可能会得到不同的结果。这种不一致只是一个暂时的状态,如果停止写数据库,经过一段时间之后,从节点最终会赶上并与主节点保持一致。这种效应也被称为最终一致性。
1.4.1. 写后读 (read-after-write) 一致性
对于异步复制存在这样一个问题,如图所示,用户在写入不久即查看数据,则新数据可能尚未到达从节点。对用户来讲,看起来似乎是刚刚提交的数据丢失了:
基于主从复制的系统实现写后读一致性有许多方案,如:
-
如果用户访问可能会被修改的内容,从主节点读取;否则,在从节点读取。
这种判断方式就要求有一些方法在实际执行查询之前,就已经知道内容是否可能会被修改。
-
跟踪最近更新的时间,如果更新后一段时间内,则在主节点读取。
如果应用的大部分内容都可能被所有用户修改,那么根据是否更新过从主从读取将不太有效,它会导致大部分内容都必须经由主节点,这就丧失了读操作的扩展性。
此时需要其他方案来判断是否从主节点读取。例如,跟踪最近更新的时间,如果更新后一分钟之内,则总是在主节点读取;并监控从节点的复制滞后程度,避免从那些滞后时间超过一分钟的从节点读取。
-
记录更新时间戳保证提供的读服务至少包含了该时间戳的更新。
客户端还可以记住最近更新时的时间戳,并附带在读请求中,据此信息,系统可以确保对该用户提供读服务时都应该至少包含了该时间戳的更新。如果不够新,要么交由另一个副本来处理,要么等待直到副本接收到了最近的更新。时间戳可以是逻辑时间戳(例如用来指示写入顺序的日志序列号)或实际系统时钟 (在这种情况下,时钟同步又称为一个关键点)。
记住用户上次更新时间戳的方法实现起来可能会比较困难,因为在一台设备上运行的代码完全无法知道在其他设备上发生了什么。此时,元数据必须做到全局共享。
除了以上方式外,实现写后读一致还需要考虑其它的问题。若实现写后读一致性需要将请求都路由到主节点读取的话,这就要求在不同环境不同网络下数据都要路由到同一节点去读取。
1.4.2. 单调读
单调读是一个比强一致性弱,但比最终一致性强的保证。当读取数据时,单调读保证,如果某个用户依次进行多次读取,则他绝不会看到回滚现象,即在读取较新值之后又发生读旧值的情况:
实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读取(而不同的用户可以从不同的副本读取)。例如,基于用户 ID 的啥希的方法而不是随机选择副本。但如果该副本发生失效,则用户的查询必须重新路由到另一个副本。
1.4.3. 前缀一致读
前缀一致读保证对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。
这是分区(分片)数据库中出现的一个特殊问题。如果数据库总是以相同的顺序写入,则读取总是看到一致的序列,不会发生这种反常。然而,在许多分布式数据库中,不同的分区独立运行,因此不存在全局写入顺序。这就导致当用户从数据库中读数据时,可能会看到数据库的某部分旧值和另一部分新值。
一个解决方案是确保任何具有因果顺序关系的写入都交给一个分区来完成,但该方案真实实现效率会大打折扣。现在有一些新的算法来显式地追踪事件因果关系,在本章稍后的 "Happened-before 关系与并发” 会继续该问题的探讨。
1.5. 多主节点复制
主从复制存在一个明显的缺点: 系统只有一个主节点,而所有写入都必须经由主节点。如果由于某种原因,例如与主节点之间的网络中断而导致主节点无法连接,主从复制方案就会影响所有的写入操作。
对主从复制模型进行自然的扩展,则可以配置多个主节点,每个主节点都可以接受写操作,后面复制的流程类似: 处理写的每个主节点都必须将该数据更改转发到所有其他节点。这就是多主节点(也称为主主,或主动/主动)复制。此时,每个主节点还同时扮演其他主节点的从节点。
1.5.1. 多主节点适用场景
1.5.1.1. 多数据中心
在多数据中心场景下,有了多主节点复制模型,则可以在每个数据中心都配置主节点。在每个数据中心内,采用常规的主从复制方案。而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。
多主节点对比于单主节点对比如下:
-
性能对比
对于主从复制,每个写请求都必须经由广域网传送至主节点所在的数据中心。这会大大增加写入延迟,并基本偏离了采用多数据中心的初衷(即就近访问)。而在多主节点模型中,每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心。因此,对上层应用有效屏蔽了数据中心之间的网络延迟,使得终端用户所体验到的性能更好。
-
可用性对比
-
数据中心容错性
对于主从复制,如果主节点所在的数据中心发生故陈,必须切换至另一个数据中心,将其中的一个从节点被提升为主节点。
在多主节点模型中,每个数据中心则可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态。
-
网络容错性
数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对数据中心之间的网络性能和稳定性等更加依赖。
多主节点模型则通常采用异步复制,可以更好地容忍此类问题,例如临时网络闪断不会妨碍写请求最终成功
-
-
易用性对比
对于多主节点,不同的数据中心可能会同时修改相同的数据,因而必须解决潜在的写冲突。
1.5.1.2. 离线客户端操作
另一种多主复制比较适合的场景是,应用在与网络断开后还需要继续工作。比如手机,笔记本电脑和其他设备上的日历应用程序。无论设备当前是否联网,都需要能够随时查看当前的会议安排(对应于读请求)或者添加新的会议(对应于写请求)。在离线状态下进行的任何更改,会在下次设备上线时,与服务器以及其他设备同步。
这种情况下,每个设备都有一个充当主节点的本地数据库(用来接受写请求),然后在所有设备之间采用异步方式同步这些多主节点上的副本,同步滞后可能是几小时或者数天,具体时间取决于设备何时可以再次联网。
从架构层面来看,上述设置基本上等同于数据中心之间的多主复制,只不过是个极端情况,即一个设备就是数据中心,而且它们之间的网络连接非常不可靠。多个设备同步日历的例子表明,多主节点可以得到想要的结果,但中间过程依然有很多的未知数。有一些工具可以使多主配置更为容易,如 CouchDB 就是为这种操作模式而设计的。
1.5.1.3. 在线协作
我们通常不会将协作编辑完全等价于数据库复制问题,但二者确实有很多相似之处。当一个用户编辑文档时,所做的更改会立即应用到本地副本 (Web 浏览器或客户端应用程序),然后异步复制到服务器以及编辑同一文档的其他用户。
如果要确保不会发生编辑冲突,则应用程序必须先将文档锁定,然后才能对其进行编辑。如果另一个用户想要编辑同一个文档,首先必须等到第一个用户提交修改并释放锁。这种协作模式相当于主从复制模型下在主节点上执行事务操作。为了加快协作编辑的效率,可编辑的粒度需要非常小。例如,单个按键甚至是全程无锁。然而另一方面,也会面临所有多主复制都存在的挑战,即如何解决冲突。
1.5.2. 多主节点下写冲突处理
多主复制的最大问题是可能发生写冲突,这意味着必须有方案来解决冲突。
例如,两个用户同时编辑 Wiki 页面,如图所示。用户 1 将页面的标题从 A 更改为 B, 与此同时用户 2 却将标题从 A 改为 C。每个用户的更改都顺利地提交到本地主节点。但是,当更改被异步复制到对方时,却发现存在冲突。注意,正常情况下的主从复制则不会出现这种情况。
1.5.2.1. 同步/异步冲突检测
如果是主从复制数据库,第二个写请求要么会被阻塞直到第一个写完成,要么被中止 (用户必须重试)。然而在多主节点的复制模型下,这两个写请求都是成功的,并且只能在稍后的时间点上才能异步检测到冲突,那时再要求用户层来解决冲突为时已晚。
理论上,也可以做到同步冲突检测,即等待写请求完成对所有副本的同步,然后再通知用户写入成功。但是,这样做将会失去多主节点的主要优势: 允许每个主节点独立接受写请求。如果确实想要同步方式冲突检测,或许应该考虑采用单主节点的主从复制模型。
1.5.2.2. 冲突避免
处理冲突最理想的策略是避免发生冲突,即如果应用层可以保证对特定记录的写请求总是通过同一个主节点,这样就不会发生写冲突。现实中,由于不少多主节点复制模型所实现的冲突解决方案存在瑕疵,因此,避免冲突反而成为大家普遍推荐的首选方案。
例如,一个应用系统中,用户需要更新自己的数据,那么我们确保特定用户的更新请求总是路由到特定的数据中心,并在该数据中心的主节点上进行读/写。不同的用户则可能对应不同的主数据中心(例如根据用户的地理位置来选择)。从用户的角度来看,这基本等价于主从复制模型。但是,有时可能需要改变事先指定的主节点,例如由于该数据中心发生故障,不得不将流量重新路由到其他数据中心,或者是因为用户已经漫游到另一个位置,因而更靠近新数据中心。此时,冲突避免方式不再有效,必须有措施来处理同时写入冲突的可能性。
1.5.2.3. 冲突解决
所有的复制模型至少应该确保数据在所有副本中最终状态一定是一致的。因此,数据库必须以一种收敛趋同的方式来解决冲突,这也意味着当所有更改最终被复制、同步之后,所有副本的最终值是相同的。
1.5.2.3.1. 固定冲突解决方案
-
给每个写入分配唯一的 ID, 例如,一个时间戳,一个足够长的随机数,一个 UUID 或者一个基于键-值的哈希,挑选最高 ID 的写入作为胜利者,并将其他写入丢弃。如果基于时间戳,这种技术被称为最后写入者获胜。虽然这种方法很流行,但是很容易造成数据丢失。
-
为每个副本分配一个唯一的 ID, 并制定规则,例如序号高的副本写入始终优先于序号低的副本。这种方法也可能会导致数据丢失。
-
以某种方式将这些值合并在一起。例如,按字母顺序排序,然后拼接在一起。
-
利用预定义好的格式来记录和保留冲突相关的所有信息,然后依靠应用层的逻辑,事后解决冲突(可能会提示用户)。
1.5.2.3.2. 自定义冲突解决方案
解决冲突最合适的方式可能还是依靠应用层,所以大多数多主节点复制模型都有工具来让用户编写应用代码来解决冲突。可以在写入时或在读取时执行这些代码逻辑:
-
在写入时执行
只要数据库系统在复制变更日志时检测到冲突,就会调用应用层的冲突处理程序。例如, Bucardo 支持编写一段 Perl 代码。这个处理程序通常不能在线提示用户,而只能在后台运行,这样速度更快。
-
在读取时执行
当检测到冲突时,所有冲突写入值都会暂时保存下来。下一次读取数据时,会将数据的多个版本读返回给应用层。应用层可能会提示用户或自动解决冲突,并将最后的结果返回到数据库。 CouchDB 采用了这样的处理方式。
注意,冲突解决通常用于单个行或文档,而不是整个事务。因此,如果有一个原子事务包含多个不同写请求,每个写请求仍然是分开考虑来解决冲突。
1.5.2.3.3. 自动冲突解决方案
有一些有意思的研究尝试自动解决并发修改所引起的冲突。下面这些方法值得一看:
-
元冲突的复制数据类型 (Conflict-free Replicated Datatypes, CRDT) 。 CRDT 是可以由多个用户同时编辑的数据结构,包括 map 、ordered list、计数器等,并且以内置的合理方式自动地解决冲突。一些 CRDT 已经在 Riak 2.0 中得以具体实现。
-
可合并的持久数据结构 (Mergeable persistent data)。它跟踪变更历史,类似于 Git 版本控制系统,并提出三向合并功能 (three-way merge function, CRDT 采用双向合并)。
-
操作转换 (Operational transformation)。它是 Etherpad 和 Google Docs 等协作编辑应用背后的冲突解决算法。专为可同时编辑的有序列表而设计,如文本文档的字符列表。
1.5.3. 多主节点部署结构
复制的拓扑结构描述了写请求从一个节点的传播到其他节点的通信路径。如果有两个主节点,则只存在一个合理的拓扑结构: 主节点 1 必须把所有的写同步到主节点 2, 反之亦然。
但如果存在两个以上的主节点,则会有多个可能的同步拓扑结构:
最常见的拓扑结构是全链接拓扑,如图 (c) , 每个主节点将其写入同步到其他所有主节点。而其他一些拓扑结构也有普遍使用,例如,默认情况下 MySQL 只支持环形拓扑结构, 其中的每个节点接收来自前序节点的写入,并将这些写入(加上自己的写入)转发给后序节点。另一种流行的拓扑是星形结构:一个指定的根节点将写入转发给所有其他节点。星形拓扑还可以推广到树状结构。
在环形和星形拓扑中,写请求需要通过多个节点才能到达所有的副本,即中间节点需要转发从其他节点收到的数据变更。为防止无限循环,每个节点需要赋予一个唯一的标识符,在复制日志中的每个写请求都标记了已通过的节点标识符。如果某个节点收到了包含自身标识符的数据更改,表明该请求巳经被处理过,因此会忽略此变更请求,避免重复转发。
环形和星形拓扑的问题是,如果某一个节点发生了故障,在修复之前,会影响其他节点之间复制日志的转发。可以采用重新配置拓扑结构的方法暂时排除掉故障节点。在大多数部署中,这种重新配置必须手动完成。而对于链接更密集的拓扑(如全部到全部),消息可以沿着不同的路径传播,避免了单点故障,因而有更好的容错性。
全连接拓扑主要是存在某些网络链路比其他链路更快的情况(例如由于不同网络拥塞),从而导致复制日志之间的覆盖:
这里涉及到一个因果关系问题,类似于在[[数据密集系统设计/数据密集型系统设计二:复制与分区#前缀一致读|前缀一致读]]所看到的:更新操作一定是依赖于先前完成的插入,因此我们要确保所有节点上一定先接收插入日志,然后再处理更新。在每笔写日志里简单地添加时间戳还不够,主要因为无法确保时钟完全同步,因而无法在主节点 2 上正确地排序所收到日志。为了使得日志消息正确有序,可以使用一种称为版本向量的技术。
1.6. 无主节点复制
一些数据存储系统则采用了不同的设计思路:选择放弃主节点,允许任何副本直接接受来自客户端的写请求。
当亚马逊内部采用了 Dynamo 系统之后,无主复制又再次成为一种时髦的数据库架构。Riak、Cassandra 和 Voldemort 都是受 Dynamo 启发而设计的无主节点开源数据库系统,这类数据库也被称为 Dynamo 风格数据库。
1.6.1. 节点失效时写入数据库
无主节点架构允许存在节点失效的场景,当某个节点失效时可以不向该节点写入。客户端向所有节点发出写请求,当收到大多数节点确认 (大多数节点又是多少?) 后就可以认为写入成功,对于其它的节点情况,并不关心。
只确认一部分节点,这就意味着节点间数据可能不一致甚至丢失,为了解决这个问题,当一个客户端从数据库中读取数据时,它不是向一个副本发送请求,而是并行地发送到多个副本。客户端可能会得到不同节点的不同响应,包括某些节点的新值和某些节点的旧值。可以采用版本号技术确定哪个值更新。
1.6.1.1. 读修复与反熵
对于节点不一致的数据,Dynamo 风格的数据存储系统经常使用以下两种机制:
-
读修复 (Read repair)
当客户端并行读取多个副本时,可以检测到过期的返回值。例如,在节点失效场景示例图中,用户 2345 从副本 3 获得的是版本 6, 而从副本 1 和 2 得到的是版本 7。客户端可以判断副本 3 一个过期值,然后将新值写入到该副本。这种方法主要适合那些被频繁读取的场景。
-
反熵过程 (Anti-entropy process)
此外,一些数据存储有后台进程不断查找副本之间数据的差异,将任何缺少的数据从一个副本复制到另一个副本。与基于主节点复制的复制日志不同,此反墒过程并不保证以特定的顺序复制写入,并且会引入明显的同步滞后。
1.6.2. 读写 quorum
1.6.2.1. 严格的读写 quorum
在上面的例子中,三个副本中如果有两个以上完成处理,写入即可认为成功。如果三个副本中只有一个完成了写请求,会怎样呢?依次类推,究竟多少个副本完成才可以认为写成功?
我们知道,成功的写操作要求三个副本中至少两个完成,这意味着至多有一个副本可能包含旧值。因此,在读取时需要至少向两个副本发起读请求,通过版本号可以确定一定至少有一个包含新值。如果第三个副本出现停机或响应缓慢,则读取仍可以继续并返回最新值。
把上述道理推广到一般情况,如果有 n 个副本,写入需要 w 个节点确认,读取必须至少查询 r 个节点,则只要 w+ r > n, 读取的节点中一定会包含最新值 (w+ r > n 意味着读写数据有交集,则一定会包含最新值)。例如在前面的例子中, n=3, w=2, r=2。满足上述这些 r、 w 值的读/写操作称之为法定票数读(或仲裁读)或法定票数写(或仲裁写)。也可以认为 r 和 w 是用于判定读、写是否有效的最低票数。
参数 n 、 w 和 r 通常是可配置的。一个常见的选择是设置 n 为某奇数(通常为 3 或 5), w=r= (n+l) /2 (向上舍入)。也可以根据自己的需求灵活调整这些配置。例如,对于读多写少的负载,设置 w=n 和 r= 1 比较合适,这样读取速度更快,但是一个失效的节点就会使得数据库所有写入因无法完成 quorum 而失败。
仲裁条件 w+r>n 定义了系统可容忍的失效节点数,如下所示:
-
当 w < n, 如果一个节点不可用,仍然可以处理写入。
-
当 r < n, 如果一个节点不可用,仍然可以处理读取。
-
假定 n=3, w=2, r=2, 则可以容忍一个不可用的节点。
至少保证有一个节点可用,2 - 1 = 1;
-
假定 n=S, w=3, r=3, 则可以容忍两个不可用的节点。
至少保证有一个节点可用,3 - 1 = 2;
通常,读取和写入操作总是并行发送到所有的 n 个副本。参数 w 和参数 r 只是决定要等待的节点数。即有多少个节点需要返回结果,我们才能判断出结果的正确性。如果可用节点数小于所需的 w 或 r 则写入或读取就会返回错误。
1.6.2.2. 宽松的读写 quorum 与数据回传
配置适当 quorum 的数据库系统可以容忍某些节点故障,也不需要执行故障切换。它们还可以容忍某些节点变慢,这是因为请求并不需要等待所有 n 个节点的响应,只需 w 或 r 节点响应即可。对于需要高可用和低延迟的场景来说,还可以容忍偶尔读取旧值,所有这些特性使之具有很高的吸引力。
但是,quorum 并不总如期待的那样提供高容错能力。一个网络中断可以很容易切断一个客户端到多数数据库节点的链接。尽管这些集群节点是活着的,而且其他客户端也确实可以正常链接,但是对于断掉链接的客户端来讲,情况无疑等价于集群整体失效。这种情况下,很可能无法满足最低的 w 和 r 所要求的节点数,因此导致客户端无法满足 quorum 要求。
在一个大规模集群中(节点数远大于 n 个),客户可能在网络中断期间还能连接到某些数据库节点,但这些节点又不是能够满足数据仲裁的那些节点。此时,数据库设计者就面临着一个选择:
-
如果无法达到 w 或 r 所要求 quorum, 将错误明确地返回给客户端?
-
或者,我们是否应该接受该写请求,只是将它们暂时写入一些可访问的节点中? 注意,这些节点并不在 n 个节点集合中。
后一种方案称之为放松的仲裁: 写入和读取仍然需要 w 和 r 个成功的响应,但包含了那些并不在先前指定的 n 个节点。一旦网络问题得到解决,临时节点需要把接收到的写入全部发送到原始主节点上。这就是所谓的数据回传(或暗示移交)。
可以看出,sloppy quorum 对于提高写入可用性特别有用: 只要有任何 w 个节点可用,数据库就可以接受新的写入。然而这意味着,即使满足 w + r > n, 也不能保证在读取某个键时,一定能读到最新值,因为新值可能被临时写入 n 之外的某些节点且尚未回传过来。因此,sloppy quorum 并非传统意义上 quorum。而更像是为了数据持久性而设计的一个保证措施,除非回传结束,否则它无法保证客户端一定能从 r 个节点读到新值。
1.6.2.3. 读写 quorum 保证一致性的局限性
如果有 n 个副本,并且配置 w 和 r, 使得 w + r> n, 可以预期可以读取到一个最新值。之所以这样,是因为成功写入的节点集合和读取的节点集合必然有重合,这样读取的节点中至少有一个具有最新值。
通常,设定 r 和 w 为简单多数(多于 n / 2) 节点,即可确保 w + r > n, 且同时容忍多达 n / 2 个节点故障。但是, quorum 不一定非得是多数,读和写的节点集中有一个重叠的节点才是最关键的。设定其他的 quorum 分配数也是可行的。
由于 w 和 r 配置的节点数较小,读取请求当中可能恰好没有包含新值的节点,因此最终可能会返回一个过期的旧值。好的一方面是,这种配置可以获得更低的延迟和更高的可用性,例如网络中断,许多副本变得无法访问,相比而言有更高的概率继续处理读取和写入。只有当可用的副本数已经低于 w 或 r 时,数据库才会变得无法读/写,即处于不可用状态。
对于 w 和 r 配置的核心就是到底需要达到何种程度的一致性?对于一致性要求高的场景,那么配置 w + r > n 使得一致性更能得到满足,但同时会降低性能。对于一致性要求低的场景,那么只要保证数据写入即可,后续数据读取是否是最新的也不关心。
即使在 w + r> n 的情况下,也可能存在返回旧值的边界条件。这主要取决于具体实现,可能的情况包括:
-
如果采用了 sloppy quorum,写操作的 w 节点和读取的 r 节点可能完全不同,因此无法保证读写请求一定存在重叠的节点。
-
如果两个写操作同时发生,则无法明确先后顺序。这种情况下,唯一安全的解决方案是合并并发写入进行[[数据密集系统设计/数据密集型系统设计二:复制与分区#多主节点下写冲突处理|写冲突解决]]。如果根据时间戳(最后写入获胜)挑选胜者,则由于时钟偏差问题, 某些写入可能会被错误地抛弃。
-
如果写操作与读操作同时发生,写操作可能仅在一部分副本上完成。此时,读取时返回旧值还是新值存在不确定性。
-
如果某些副本上已经写入成功,而其他一些副本发生写入失败(例如磁盘已满),且总的成功副本数少于 w, 那些已成功的副本上不会做回滚。这意味着尽管这样的写操作被视为失败,后续的读操作仍可能返回新值。
-
如果具有新值的节点后来发生失效,但恢复数据来自某个旧值,则总的新值副本数会低于 w, 这就打破了之前的判定条件。
-
即使一切工作正常,也会出现一些边界情况,如第 9 章所介绍的可线性化与 “quorum” 。
1.6.3. 并发写检测
Dynamo 风格的数据库允许多个客户端对相同的主键同时发起写操作,即使采用严格的 quorum 机制也可能会发生写冲突。这与多主节复制类似,此外,由于读时修复或者数据回传也会导致并发写冲突。
一个核心问题是,由于网络延迟不稳定或者局部失效,请求在不同的节点上可能会呈现不同的顺序。如下图所示,对于包含三个节点的数据系统,客户端 A 和 B 同时向主键 X 发起写请求:
-
节点 1 收到来自客户端 A 的写请求,但由于节点失效,没有收到客户端 B 的写请求。
-
节点 2 首先收到 A 的写请求,然后是 B 的写请求。
-
与节点 2 相反,节点 3 首先收到 B 的写请求,然后是 A 的写请求。
如果节点每当收到新的写请求时就简单地覆盖原有的主键,那么这些节点将永久无法达成一致,节点 2 认为 X 的最终值是 B, 而其他节点认为值是 A。
1.6.3.1. 最后写入者获胜
一种实现最终收敛的方法是,每个副本总是保存最新值,允许覆盖并丢弃旧值。那么,假定每个写请求都最终同步到所有副本,只要我们有一个明确的方法来确定哪一个写入是最新的,则副本可以最终收敛到相同的值。
这个想法其实有些争议,关键点在于前面所提到关于如何定义最新。在上述例子中,当客户端向数据库节点发送写请求时,一个客户端无法意识到另一个客户端,也不清楚哪一个先发生。其实,争辩哪个先发生没有太大意义,当我们说支持写入并发,也就意味着它们的顺序是不确定的。
即使无法确定写请求的自然顺序,我们可以强制对其排序。例如,为每个写请求附加一个时间戳,然后选择最新即最大的时间戳,丢弃较早时间戳的写入。这个冲突解决算法被称为最后写入者获胜 (last write wins, LWW) 。
LWW 可以实现最终收敛的目标,但是以牺牲数据持久性为代价。如果同一个主键有多个并发写,即使这些并发写都向客户端报告成功(因为完成了写入 w 个副本),但最后只有一个写入值会存活下来,其他的将被系统默默丢弃。此外, LWW 甚至可能会[[数据密集系统设计/数据密集型系统设计四:分布式的挑战#时间戳与事件顺序|删除那些非并发写]]。在一些场景如缓存系统,覆盖写是可以接受的。如果覆盖、丢失数据不可接受,则 LWW 并不是解决冲突很好的选择。
要确保 LWW 安全无副作用的唯一方法是,只写入一次然后写入值视为不可变,这样就避免了对同一个主键的并发(覆盖)写。例如, Cassandra 的一个推荐使用方法就是采用 UUID 作为主键,这样每个写操作都针对的不同的、系统唯一的主键。
1.6.3.2. Happens-before 关系和并发
如何判断两个操作是否是并发呢?对于两个操作 A 和 B,如果 B 知道 A, 或者依赖于 A, 或者以某种方式在 A 基础上构建,则称操作 A 在操作 B 之前发生。这是定义何为并发的关键。事实上,我们也可以简单地说,如果两个操作都不在另一个之前发生,那么操作是并发的(或者两者都不知道对方)。为史好地定义并发性,我们并不依赖确切的发生时间,即不管物理的时机如何,如果两个操作并不需要意识到对方,我们即可声称它们是并发操作。
因此,对于两个操作 A 和 B, 一共存在三种可能性: A 在 B 之前发生,或者 B 在 A 之前发生,或者 A 和 B 并发。我们需要的是一个算法来判定两个操作是否并发。如果一个操作发生在另一个操作之前,则后面的操作可以覆盖较早的操作。如果属于并发,就需要解决潜在的冲突问题。
1.6.3.2.1. 先后关系确认算法
简单起见,我们先从只有一个副本的数据库开始,在阐明其原理之后,将其推广到有多个副本的无主节点数据库。接下来以两个客户端同时向购物篮车加商品为例说明并发与依赖操作。初始时购物车为空。然后两个客户端向数据库共发出五次写入操作:
-
客户端 1 首先将牛奶加入购物车。这是第一次写入该主键的值,服务器保存成功然后分配版本 1,服务器将值与版本号一起返回给该客户端 1。
-
客户端 2 将鸡蛋加入购物车,此时它并不知道客户端 1 己添加了牛奶,而是认为鸡蛋是购物车中的唯一物品。服务器为此写入并分配版本 2, 然后将鸡蛋和牛奶存储为两个单独的值,最好将这两个值与版本号 2 返回给客户端 2。
-
同理,客户端 1 也并不知道上述步骤 2, 想要将面粉加入购物车,且以为购物车的内容应该是[牛奶,面粉],将此值与版本号 1 一起发送到服务器。服务器可以从版本号中知道[牛奶,面粉]的新值要取代先前值[牛奶],但值[鸡蛋]则是新的并发操作。因此,服务器将版本 3 分配给[牛奶,面粉]并覆盖版本 1 的[牛奶],同时保留版本 2 的值[鸡蛋],将二者同时返回给客户端 1。
-
同时,客户端 2 想要加入火腿,也不知道客户端 l 刚刚加了面粉。其在最后一个响应中从服务器收到的两个值是[牛奶]和[蛋],现在合并这些值,并添加火腿形成一个新的值[鸡蛋,牛奶,火腿]。它将该值与前一个版本号 2 一起发送到服务器。服务器检测到版本 2 会覆盖[鸡蛋],但与[牛奶,面粉]是同时发生,所以设置为版本 4 并将所有这些值发送给客户端 2。
-
最后,客户端 1 想要加培根。它以前在版本 3 中从服务器接收[牛奶,面粉]和[鸡蛋],所以合并这些值,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号 3 来覆盖[牛奶,面粉],但与[鸡蛋,牛奶,火腿]并发,所以服务器会保留这些并发值。
上述操作之间的数据流可以通过下图形象展示。箭头表示某个操作发生在另一个操作之前,即后面的操作知道或是依赖于前面的操作。在这个例子中,因为总有另一个操作同时进行,所以每个客户端都没有时时刻刻和服务器上的数据保持同步。但是,新版本值最终会覆盖旧值,且不会发生已写入值的丢失。
需要注意的是,服务器判断操作是否并发的依据主要依靠对比版本号,而并不需要解释新旧值本身(值可以是任何数据结构)。针对上面的过程,进行算法描述如下:
-
服务端版本号维护
服务器为每个主键维护一个版本号,每当主键新值写入时递增版本号,并将新版本号与写入的值一起保存。
当多个副本同时接受写入时,只设定单个版本号是不够的。因此我们需要为每个副本和每个主键均定义一个版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本看到的版本号。通过这些信息来指示要覆盖哪些值、该保留哪些并发值。
所有副本的版本号集合称为版本向量,这种思路还有一些变体,但最有趣的可能是在 Riak 2.0 中使用的虚线版本向量。Riak 将版本向量编码为一个称之为因果上下文的字符串。版本矢量技术使数据库可以区分究竟应该覆盖写还是保留并发值。
另外,就像单副本的例子一样,应用程序仍然需要执行合并操作。版本向量可以保证从某一个副本读取值然后写入到另一个副本,而这些值可能会导致在其他副本上衍生出来新的“ 兄弟值,但至少不会发生数据丢失且可以正确合并所有并发值。
-
客户端版本号读取
当客户端读取主键时,服务器将返回所有(未被覆盖的)当前值以及最新的版本号。且要求写之前,客户必须先发送读请求。
-
客户端合并与写入
客户端写主键,写请求必须包含之前读到的版本号、读到的值和新值合并后的集合。写请求的响应可以像读操作一样,会返回所有当前值,这样就可以像购物车例子那样一步步链接起多个写入的值。
采用合并算法可以保证不会发生数据丢弃,但不幸的是,客户端需要做一些额外的工作: 即如果多个操作并发发生,则客户端必须通过合并并发写入的值来继承旧值。 Riak 称这些并发值为兄弟关系。
然而,设想一下人们也可以在购物车中删除商品,此时把并发值都合并起来可能会导致错误的结果: 如果合并了两个客户端的值,且其中有一个商品被某客户端删除掉,则被删除的项目会再次出现在合并的终值中。为了防止该问题,项目在删除时不能简单地从数据库中删除,系统必须保留一个对应的版本号以恰当的标记该项目需要在合并时被剔除。这种删除标记被称为墓碑。
考虑到在应用代码中合并非常复杂且容易出错,因此可以设计一些专门的数据结构来自动执行合并,例如, Riak 支持称为 CRDT 一系列数据结构以合理的方式高效自动合并,包括支持删除标记。
-
服务器数据更新
当服务器收到带有特定版本号的写入时,覆盖该版本号或更低版本的所有值(因为知道这些值已经被合并到新传入的值集合中),但必须保存更高版本号的所有值(因为这些值与当前的写操作属于并发)。
当写请求包含了前一次读取的版本号时,意味着修改的是基于以前的状态。如果一个写请求没有包含版本号,它将与所有其他写入同时进行,不会覆盖任何已有值,其传入的值将包含在后续读请求的返回值列表当中。
2. 数据分区
分区通常是这样定义的,即每一条数据(或者每条记录,每行或每个文档)只属于某个特定分区。采用数据分区的主要目的是提高可扩展性。不同的分区可以放在一个无共享集群的不同节点上。这样一个大数据集可以分散在更多的磁盘上,查询负载也随之分布到更多的处理器上。
对单个分区进行查询时,每个节点对自己所在分区可以独立执行查询操作,因此添加更多的节点可以提高查询吞吐量。超大而复杂的查询尽管比较困难,但也可能做到跨节点的并行处理。
2.1. 键值数据的分区
分区的主要目标是将数据和查询负载均匀分布在所有节点上。如果节点平均分担负载,那么理论上 10 个节点应该能够处理 10 倍的数据量和 10 倍于单个节点的读写吞吐量 (忽略复制)。
而如果分区不均匀,则会出现某些分区节点比其他分区承担更多的数据量或查询负载,称之为倾斜。倾斜会导致分区效率严重下降,在极端情况下,所有的负载可能会集中在一个分区节点上,这就意味着 10 个节点 9 个空闲,系统的瓶颈在最繁忙的那个节点上。这种负载严重不成比例的分区即成为系统热点。
避免热点最简单的方法是将记录随机分配给所有节点上。这种方法可以比较均匀地分布数据,但是有一个很大的缺点: 当试图读取特定的数据时,没有办法知道数据保存在哪个节点上,所以不得不并行查询所有节点。可以改进上述方法。如果数据是简单的键-值数据模型,这意味着总是可以通过关键字来访问记录。
2.1.1. 基于关键字分区
一种分区方式是为每个分区分配一段连续的关键字或者关键字区间范围(以最小值和最大值来指示)。如果知道关键字区间的上下限,就可以轻松确定哪个分区包含这些关键字。如果还知道哪个分区分配在哪个节点,就可以直接向该节点发出请求。
关键字的区间段不一定非要均匀分布,这主要是因为数据本身可能就不均匀。为了更均匀地分布数据,分区边界理应适配数据本身的分布特征。
每个分区内可以按照关键字排序保存。这样可以轻松支持区间查询,即将关键字作为一个拼接起来的索引项从而一次查询得到多个相关记录。
然而,基于关键字的区间分区的缺点是某些访问模式会导致热点。如果关键字是时间戳,则分区对应于一个时间范围,例如每天一个分区。然而,当数据写入数据库时,所有的写入操作都集中在同一个分区(即当天的分区),这会导致该分区在写入时负载过高,而其他分区始终处于空闲状态。为了避免上述问题,需要使用时间戳以外的其他内容作为关键字的第一项。例如,可以在时间戳前面加上其它属性字段作为前缀,这样首先由该字段,然后按时间进行分区。假设同时有许多数据处于活动状态,则写入负载最终会比较均匀地分布在多个节点上。接下来,当需要获取一个时间范围内、多个类型的数据肘,可以根据属性,各自执行区间查询。
2.1.2. 基于关键字哈希值分区
对于关键字分区的数据倾斜与热点问题,许多分布式系统采用了基于关键字哈希函数的方式来分区。
一个好的哈希函数可以处理数据倾斜并使其均匀分布。例如一个处理字符串的 32 位哈希函数,当输入某个字符串,它会返回一个 0 和 2^32 -1 之间近似随机分布的数值。即使输入的字符串非常相似,返回的哈希值也会在上述数字范围内均匀分布。用于数据分区目的的啥希函数不需要在加密方面很强: 例如, Cassandra 和 MongoDB 使用 MD5, Voldemort 使用 Fowler-Noll-Vo 函数。
这种方法可以很好地将关键字均匀地分配到多个分区中。分区边界可以是均匀间隔,也可以是伪随机选择(在这种情况下,该技术有时被称为一致性啥希)。然而,通过关键字哈希进行分区,我们丧失了良好的区间查询特性。即使关键字相邻,但经过哈希之后会分散在不同的分区中,区间查询就失去了原有的有序相邻的特性。在 MongoDB 中,如果启用了基于哈希的分片模式,则区间查询会发送到所有的分区上而 Riak 、 Couchbase 和 Voldemort 干脆就不支持关键字上的区间查询。
2.1.3. 关键字分区与哈希值分区的结合
可以同时采用关键字分区和哈希值分区的策略,如 Cassandra 在两种分区策略之间做了一个折中。 Cassandra 中的表可以声明为由多个列组成的复合主键。复合主键只有第一部分可用于哈希分区,而其他列则用作组合索引来对 Cassandra SSTable 中的数据进行排序。因此,它不支持在第一列上进行区间查询,但如果为第一列指定好了固定值,可以对其他列执行高效的区间查询。
组合索引为一对多的关系提供了一个优雅的数据模型。例如,在社交网站上,一个用户可能会发布很多消息更新。如果更新的关键字设置为 (user_id, update_ timestamp) 的组合,那么可以有效地检索由某用户在一段时间内所做的所有更新,且按时间戳排序。不同的用户可以存储在不同的分区上,但是对于某一用户,消息按时间戳顺序存储在一个分区上。
2.1.4. 负载倾斜与热点
如前所述,基于哈希的分区方法可以减轻热点,但无法做到完全避免。一个极端情况是,所有的读/写操作都是针对同一个关键字,则最终所有请求都将被路由到同一个分区。
这种负载或许并不普遍,但也并非不可能: 例如,社交媒体网站上,一些名人用户有数百万的粉丝,当其发布一些热点事件时可能会引发一场访问风暴出现大量的对相同关键字的写操作(其中关键字可能是名人的用户 ID, 或者人们正在评论的事件 ID)。此时,哈希起不到任何帮助作用,因为两个相同 ID 的哈希值仍然相同。
大多数的系统今天仍然无法自动消除这种高度倾斜的负载,而只能通过应用层来减轻倾斜程度。例如,如果某个关键字被确认为热点,一个简单的技术就是在关键字的开头或结尾处添加一个随机数。只需一个两位数的十进制随机数就可以将关键字的写操作分布到 100 个不同的关键字上,从而分配到不同的分区上。
但是,随之而来的问题是,之后的任何读取都需要些额外的工作,必须从所有 100 个关键字中读取数据然后进行合并。因此通常只对少量的热点关键字附加随机数才有意义,而对于写入吞吐量低的绝大多数关键字,这些都意味着不必要的开销。此外,还需要额外的元数据来标记哪些关键字进行了特殊处理。
也许将来某一天,数据系统能够自动检测负载倾斜情况,然后自动处理这些倾斜的负载。但截至目前,仍然需要开发者自己结合应用来综合权衡。
2.2. 分区与二级索引
我们之前所讨论的分区方案都依赖于键-值数据模型。键-值模型相对简单,即都是通过关键字来访问记录,自然可以根据关键字来确定分区,并将读写请求路由到负责该关键字的分区上。
但是,如果涉及二级索引,情况会变得复杂。二级索引通常不能唯一标识一条记录,而是用来加速特定值的查询,例如查找用户 123 的所有操作,找到所有含有 hogwash 的文章,查找所有颜色为红色的汽车等。
二级索引是关系数据库的必备特性,在文档数据库中应用也非常普遍。但考虑到其复杂性,许多键-值存储(如 HBase 和 Voldemort) 并不支持二级索引。但其他一些如 Riak 则开始增加对二级索引的支持。此外,二级索引技术也是 Solr 和 Elasticsearch 等全文索引服务器存在之根本。
二级索引带来的主要挑战是它们不能规整的地映射到分区中。有两种主要的方法来支持对二级索引进行分区: 基千文档的分区和基于词条的分区。
2.2.1. 基于文档分区的二级索引
基于文档分区的索引方法,每个分区完全独立,各自维护自己的二级索引,且只负责自己分区内的文档而不关心其他分区中数据。每当需要写数据库时,包括添加,删除或更新文档等,只需要处理包含目标文档 ID 的那一个分区。因此文档分区索引也被称为本地索引,而不是全局索引。
例如对于多个数据库,每个数据库发生数据增删改查操作时,数据操作都是针对当前操作库而言,索引的使用也是使用的当前库,不会涉及到其它分区的索引。
使用文档分区的索引方法可以保持分区数据独立,互不干扰。但这样存在的问题是对于每个分区的索引所索引的数据并不是完整的数据,当客户端需要获取某个索引下的数据时该数据可能存在多个分区下。因此,客户端就需要将查询发送到所有的分区,然后合并所有返回的结果。
这种查询分区数据库的方法有时也称为分散/聚集,显然这种二级索引的查询代价高昂。即使采用了并行查询,也容易导致读延迟显著放大。尽管如此,它还是广泛用于实践:MongoDB、 Riak ISJ、 Cassandra 161、Elasticsearch 、 SolrCloud 和 VoltDB 都支持基于文档分区二级索引。大多数数据库供应商都建议用户自己来构建合适的分区方案,尽量由单个分区满足二级索引查询,但现实往往难以如愿,尤其是当查询中可能引用多个二级索引时。
2.2.2. 基于词条的二级索引分区
另一种方法,我们可以对所有的数据构建全局索引,而不是每个分区维护自己的本地索引。而且,为避免成为瓶颈,不能将全局索引存储在一个节点上,否则就破坏了设计分区均衡的目标。所以,全局索引也必须进行分区,且可以与数据关键字采用不同的分区策略。
我们将这种索引方案称为词条分区,它以待查找的关键字本身作为索引。和前面讨论的方法一样,可以直接通过关键词来全局划分索引,或者对其取哈希值。直接分区的好处是可以支持高效的区间查询(例如,查询汽车报价在某个值以上) 而采用哈希的方式则可以更均匀的划分分区。
词条分区的优劣势:
-
优势
这种全局的词条分区相比千文档分区索引的主要优点是,它的读取更为高效,即它不需要采用 scatter/gather 对所有的分区都执行一遍查询,相反,客户端只需要向包含词条的那一个分区发出读请求。
-
劣势
然而全局索引的不利之处在于,写入速度较慢且非常复杂,主要因为单个文档的更新时,里面可能会涉及多个二级索引,而二级索引的分区又可能完全不同甚至在不同的节点上,由此势必引入显著的写放大。
理想情况下,索引应该时刻保持最新,即写入的数据要立即反映在最新的索引上。但是,对于词条分区来讲,这需要一个跨多个相关分区的分布式事务支持,写入速度会受到极大的影响,所以现有的数据库都不支持同步更新二级索引。
实践中,对全局二级索引的更新往往都是异步的(也就意味着,如果在写入之后马上去读索引,那么刚刚发生的更新可能还没有反映在索引中)。例如, Amazon DynamoDB 的二级索引通常可以在 1 秒之内完成更新,但当底层设施出现故障时,也有可能需要等待很长的时间。其他使用全局索引的系统还包括 Riak 的搜索功能和 Oracle 数据仓库,后者允许用户来选择是使用本地还是全局索引。在第 12 章我们会重新讨论如何实现全局二级索引。
2.3. 分区与再平衡
随着时间的推移,数据库可能总会出现某些变化:
-
查询压力增加,因此需要更多的 CPU 来处理负载。
-
数据规模增加,因此需要更多的磁盘和内存来存储数据。
-
节点可能出现故障,因此需要其他机器来接管失效的节点。
所有这些变化都要求数据和请求可以从一个节点转移到另一个节点。这样一个迁移负载的过程称为再平衡(或者动态平衡)。无论对于哪种分区方案,分区再平衡通常至少要满足:
-
平衡之后,负载、数据存储、读写请求等应该在集群范围更均匀地分布。
-
再平衡执行过程中,数据库应该可以继续正常提供读写服务。
-
避免不必要的负载迁移,以加快动态再平衡,并尽显减少网络和磁盘 I/O 影响。
2.3.1. 动态再平衡策略
将分区对应到节点上存在多种不同的分配策略。我们在前面提到最好将哈希值划分为不同的区间范围,然后将每个区间分配给一个分区。也许你会问为什么不直接使用 mod (许多编程语言里的取模运算符%)。例如, hash (key) mod 10 会返回一个介于 0 和 9 之间的数字,如果有 10 个节点,则依次对应节点 0 到 9, 这似乎是将每个关键字分配到节点的最简单方法。
对节点数取模方法的问题是,如果节点数 N 发生了变化,会导致很多关键字需要从现有的节点迁移到另一个节点。这种频繁的迁移操作大大增加了再平衡的成本,因此我们需要一种减少迁移数据的方法。
2.3.1.1. 固定数量分区
幸运的是,有一个相当简单的解决方案: 首先,创建远超实际节点数的分区数,然后为每个节点分配多个分区。例如,对于一个 10 节点的集群,数据库可以从一开始就逻辑划分为 1000 个分区,这样大约每个节点承担 100 个分区。
接下来,如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡。如果从集群中删除节点,则采取相反的均衡措施。
选中的整个分区会在节点之间迁移,但分区的总数量仍维持不变,也不会改变关键字到分区的映射关系。这里唯一要调整的是分区与节点的对应关系。考虑到节点间通过网络传输数据总是需要些时间,这样调整可以逐步完成,在此期间,旧的分区仍然可以接收读写请求。
原则上,也可以将集群中的不同的硬件配置因素考虑进来,即性能更强大的节点将分配更多的分区,从而分担更多的负载。
使用该策略时,分区的数量往往在数据库创建时就确定好,之后不会改变。原则上也可以拆分和合并分区,但固定数量的分区使得相关操作非常简单,因此许多采用固定分区策略的数据库决定不支持分区拆分功能。所以,在初始化时,已经充分考虑将来扩容增长的需求(未来可能拥有的最大节点数),设置一个足够大的分区数。而每个分区也有些额外的管理开销,选择过高的数字可能会有副作用。
如果数据集的总规模高度不确定或可变(例如,开始非常小,但随着时间的推移可能会变得异常庞大),此时如何选择合适的分区数就有些困难。每个分区包含的数据批的上限是固定的,实际大小应该与集群中的数据总扯成正比。如果分区里的数据量非常大,则每次再平衡和节点故障恢复的代价就很大。但是如果一个分区太小,就会产生太多的开销。分区大小应该恰到好处,不要太大,也不能过小,如果分区数量固定了但总数据量却高度不确定,就难以达到一个最佳取舍点。
2.3.1.2. 动态分区
对于采用关键字区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其他分区基本为空,那么设定固定边界、固定数最的分区将非常不便:而手动去重新配置分区边界又非常繁琐。
因此,一些数据库如 HBase 和 RethinkDB 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阈值 (HBase 上默认值是 10GB) , 它就拆分为两个分区,每个承担一半的数据量。相反,如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合并。该过程类似于 B 树的分裂操作。
动态分区的一个优点是分区数量可以自动适配数据总量。如果只有少量的数据,少量的分区就足够了,这样系统开销很小。如果有大显的数据,每个分区的大小则被限制在一个可配的最大值。
但是,需要注意的是,对于一个空的数据库,因为没有任何先验知识可以帮助确定分区的边界,所以会从一个分区开始。可能数据集很小,但直到达到第一个分裂点之前,所有的写入操作都必须由单个节点来处理,而其他节点则处于空闲状态。为了缓解这个问题, HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为预分裂)。对于关键字区间分区,预分裂要求已经知道一些关键字的分布情况。动态分区不仅适用于关键字区间分区,也适用于基于哈希的分区策略。 MongoDB 从版本 2.4 开始,同时支持二者,并且都可以动态分裂分区。
2.3.1.3. 按节点比例分区
采用动态分区策略,拆分和合并操作使每个分区的大小维持在设定的最小值和最大值之间,因此分区的数量与数据集的大小成正比关系。另一方面,对千固定数量的分区方式,其每个分区的大小也与数据集的大小成正比。两种情况,分区的数量都与节点数无关。
另一种方式时将节点数量作为分区的一个维度,Cassandra 和 Ketama 采用了该种方式,使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时,当节点数不变时,每个分区的大小与数据集大小保持正比的增长关系;当节点数增加时,分区则会调整变得更小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小保持稳定。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据晕,将另一半数据留在原节点。随机选择可能会带来不太公平的分区分裂,但是当平均分区数量较大时,新节点最终会从现有节点中拿走相当数量的负载。 Cassandra 在 3.0 时推出了改进算法,可以避免上述不公平的分裂。
随机选择分区边界的前提要求采用基于哈希分区(可以从哈希函数产生的数字范围里设置边界)。这种方法也最符合本章开头所定义一致性哈希。一些新设计的哈希函数也可以以较低的元数据开销达到类似的效果。
2.3.2. 自动与手动再平衡策略
动态平衡另一个重要问题我们还没有考虑: 它是自动执行还是手动方式执行?
全自动式的再平衡(即由系统自动决定何时将分区从一个节点迁移到另一个节点,不需要任何管理员的介入)与纯手动方式(即分区到节点的映射由管理员来显式配置)可能还有一个过渡阶段。例如, Couchbase, Riak 和 Voldemort 会自动生成一个分区分配的建议方案,但需要管理员的确认才能生效。
全自动式再平衡会更加方便,它在正常维护之外所增加的操作很少。但是,也有可能出现结果难以预测的情况。再平衡总体讲是个比较昂贵的操作,它需要重新路由请求并将大量数据从一个节点迁移到另一个节点。万一执行过程中间出现异常,会使网络或节点的负载过重,并影响其他请求的性能。
将自动平衡与自动故障检测相结合也可能存在一些风险。例如,假设某个节点负载过重,对请求的响应暂时受到影响,而其他节点可能会得到结论: 该节点已经失效;接下来激活自动平衡来转移其负载。客观上这会加重该节点、其他节点以及网络的负荷,可能会使总体情况变得更糟,甚至导致级联式的失效扩散。
出于这样的考虑,让管理员介入到再平衡可能是个更好的选择。它的确比全自动过程响应慢一些,但它可以有效防止意外发生。
2.4. 请求路由
现在我们已经将数据集分布到多个节点上,但是仍然有一个悬而未决的问题: 当客户端需要发送请求时,如何知道应该连接哪个节点?如果发生了分区再平衡,分区与节点的对应关系随之还会变化。为了回答该问题,我们需要一段处理逻辑来感知这些变化,并负责处理客户端的连接。
这其实属于一类典型的服务发现问题,服务发现并不限于数据库,任何通过网络访问的系统都有这样的需求,尤其是当服务目标支持高可用时(在多台机器上有冗余配置)。
概括来讲,这个问题有以下几种不同的处理策略:
-
节点转发或重定向
允许客户端链接任意的节点(例如,采用循环式的负载均衡器)。如果某节点恰好拥有所请求的分区,则直接处理该请求。
否则,将请求转发到下一个合适的节点,接收答复,并将答复返回给客户端。
另一种选择是当前节点直接返回目标节点,然后由客户端向目标节点发送请求。
[[数据库/Redis大纲#5 1 2 Redis Cluster 集群的使用|Redis 的 Cluster 模式]]中使用了该方式。
-
提供路由层代理
将所有客户端的请求都发送到一个路由层,由后者负责将请求转发到对应的分区节点上。路由层本身不处理任何请求,它仅充一个分区感知的负载均衡器。
-
客户端缓存节点关系
客户端感知分区和节点分配关系。此时,客户端可以直接连接到目标节点,而不需要任何中介。
不管哪种方法,核心问题是:作出路由决策的组件如何知道分区与节点的对应关系以及其变化情况?该组件可能是某个节点,路由层或客户端。
这其实是一个很有挑战性的问题,所有参与者都要达成共识这一点很重要。否则请求可能被发送到错误的节点,而没有得到正确处理。分布式系统中有专门的共识协议算法,但通常难以正确实现。
许多分布式数据系统依靠独立的协调服务(如 ZooKeeper) 跟踪集群范围内的元数据。每个节点都向 ZooKeeper 中注册自己, ZooKeeper 维护了分区到节点的最终映射关系。其他参与者(如路由层或分区感知的客户端)可以向 ZooKeeper 订阅此信息。一且分区发生了改变,或者添加、删除节点, ZooKeeper 就会主动通知路由层,这样使路由信息保持最新状态。
Cassandra 和 Riak 则采用了不同的方法,它们在节点之间使用 gossip 协议来同步群集状态的变化。请求可以发送到任何节点,由该节点负责将其转发到目标分区节点。这种方式增加了数据库节点的复杂性,但是避免了对 ZooKeeper 之类的外部协调服务的依赖。
2.5. 并行查询
到目前为止,我们只关注了读取或写入单个关键字这样简单的查询(对于文档分区的二级索引,里面要求分散/聚集查询)。这基本上也是大多数 NoSQL 分布式数据存储所支持的访问类型。
然而对于大规模并行处理 (massively parallel processing, MPP) 这一类主要用于数据分析的关系数据库,在查询类型方面要复杂得多。典型的数据仓库查询包含多个联合、过滤、分组和聚合操作。 MPP 查询优化器会将复杂的查询分解成许多执行阶段和分区,以便在集群的不同节点上并行执行。尤其是涉及全表扫描这样的查询操作,可以通过并行执行获益颇多。