数据复制
本书第一部分讲单机数据系统,现在进入的第二部分讲多机数据系统。
冗余(Replication)是指将同一份数据复制多份,放到通过网络互联的多个机器上去。其好处有:
- 降低访问延迟:可以在地理上同时接近不同地区的用户。
- 提高可用性:当系统部分故障时仍然能够正常提供服务。
- 提高读吞吐:扩展至多台机器以同时提供数据访问服务。
本章假设我们的数据系统中所有数据能够存放到一台机器中,则本章只需考虑多机冗余的问题,而不考虑分片的问题。
如果数据是只读的,则冗余很好做,直接复制到多机即可。我们有时可以利用这个特性,使用分治策略,将数据分为只读部分和读写部分,则只读部分的冗余就会容易处理的多,甚至可以用 EC 方式做冗余,减小存储放大的同时,还提高了可用性。
- 想想 EC 牺牲了什么?以计算换存储。
但难点就在于,数据允许数据变更时,如何维护多机冗余且一致。常用的冗余控制算法有:
- single leader,主从复制
- multi-leader,多主节点复制
- leaderless,无主节点复制
这需要在多方面做取舍:
- 使用同步复制还是异步复制
- 如何处理失败的副本
数据库冗余问题在学术界不是一个新问题了,但在工业界,大部分人都是新手——分布式数据库是近些年才大规模的在工业界落地的。
# 1. 单主节点与从节点
冗余存储的每份数据称为副本(replica)。多副本所带来的最主要的一个问题是:如何保证所有数据被同步到了所有副本上?
leader-based replication(主从复制)的同步算法,是最常用解决办法。
- 其中一个 replica 称为 leader,也称为主副本(primary、master)。主副本作为写入的协调者,所有写入都要发给主副本。
- 其他 replica 称为 follower,也称为只读副本(read replicas)、从副本(slaves)、次副本(secondaries)、热备(hot-standby)。主副本将改动写到本地后,将其发送给各个从副本,从副本收变动到后应用到自己状态机,这个过程称为日志同步(replication log)、变更流(change steam)。
- 对于读取,客户端可以从主副本和从副本中读取;但写入,客户端只能将请求发到主副本。
leader 也被称为主节点,follower 也被称为从节点,因此这种算法也被称为主从复制。不论名称叫什么,关键是理解其中的思想。根据习惯,下面通称主副本和从副本。
有很多数据系统都用了此模式:
- 关系型数据库:PostgreSQL(9.0+)、MySQL 和 Oracle Data Guard 和 SQL Server 的 AlwaysOn
- 非关系型数据库:MongoDB、RethinkDB 和 Espresso
- 消息队列:Kafka 和 RabbitMQ。
# 1.1 同步复制与异步复制
同步(synchronously)复制和异步(asynchronously)复制和关键区别在于:请求何时返回给客户端。
- 如果等待某副本写完成后,则该副本为同步复制。
- 如果不等待某副本写完成,则该副本为异步复制。
两者的对比:
- 同步复制牺牲了响应延迟和部分可用性(在某些副本有问题时不能完成写入操作),换取了所有副本的一致性(但并不能严格保证)
- 异步复制放松了一致性,而换来了较低的写入延迟和较高的可用性。
在实践中,会根据对一致性和可用性的要求,进行取舍。针对所有从副本来说,可以有以下选择:
- 全同步:所有的从副本都同步写入。如果副本数过多,可能性能较差,当然也可以做并行化、流水线化处理。
- 半同步:(semi-synchronous),有一些副本为同步,另一些副本为异步。
- 全异步:所有的从副本都异步写入。网络环境比较好的话,可以这么配置。
异步复制可能会造成副本丢失等严重问题,为了能兼顾一致性和性能,学术界也在不断研究新的复制方法。如,链式复制(chain-replication)。
多副本的一致性和共识性有诸多联系,本书后面章节会讨论。
# 1.2 配置新的从节点
其实,“节点”往往是在说提供 service 的机器,而“副本”是在指逻辑上的数据集。但在文中讲解时,两者会混用。
如果原副本是只读(read-only)的,只需要简单拷贝即可。但是如果是可写副本,则问题要复杂很多。因此,比较简单的一种解决方法是:禁止写入,然后拷贝。这在某些情况下很有用,比如夜间没有写入流量,同时一晚上肯定能复制完。但这会违反高可用的设计目标。
但如果要求不停机,则需要另一种解决方法:
- 主副本在本地做一致性快照。何谓一致性?
- 将快照复制到从副本节点。
- 从主副本拉取快照之后的操作日志,应用到从副本,这个过程叫做追赶。如何知道快照与其后日志的对应关系?序列号。
- 当从副本赶上主副本进度后,就可以正常跟随主副本了。
这个过程一般是自动化的,比如 Raft,具体操作步骤也因数据库系统而异。也可以手动化,比如写一些脚本。
# 1.3 宕机处理
系统中任何节点都可能在计划内或者计划外宕机(节点失效)。那么如何应对这些宕机情况,保持整个系统的可用性呢?我们的目标是,尽管个别节点会出现中断,但要保持系统总体的持续运行,并尽可能减小节点中断带来的影响。
# 1.3.1 从节点宕机:追赶恢复
类似于新增从副本。如果落后的多,可以直接向主副本拉取快照+日志;如果落后的少,可以仅拉取缺失日志。
# 1.3.2 主节点宕机:故障转移
处理相对麻烦,首先要选出新的主副本,然后要通知所有从副本变更。具体来说,包含下面步骤:
- 确认主副本故障。要防止由于网络抖动造成的误判。一般会用心跳探活,并设置合理超时(timeout)阈值,超过阈值后没有收到该节点心跳,则认为该节点故障。
- 选择新的主副本。新的主副本可以通过选举(共识问题)或者指定(外部控制程序)来产生。选主时,要保证备选节点数据尽可能的新,以最小化数据损失。
- 让系统感知新主副本。系统其他参与方,包括从副本、客户端和旧主副本。前两者不多说,旧主副本在恢复时,需要通过某种手段,让其知道已经失去领导权,避免脑裂。
主副本切换时,会遇到很多问题:
- 新老主副本数据冲突。新主副本在上位前没有同步完所有日志,旧主副本恢复后,可能会发现和新主副本数据冲突。
- 相关外部系统冲突。即新主副本,和使用该副本数据的外部系统冲突。书中举了 GitHub 数据库 MySQL 和缓存系统 redis 冲突的例子。
- 新老主副本角色冲突。即新老主副本都以为自己才是主副本,称为脑裂(split brain)。如果他们两个都能接受写入,且没有冲突解决机制,数据会丢失或者损坏。有的系统会在检测到脑裂后,关闭其中一个副本,但设计的不好可能将两个主副本都关闭调。反正需要让那些不是 leader 但还自认为是 leader 的节点意识到自己不是 leader 了。
- 超时阈值选取。如果超时阈值选取的过小,在不稳定的网络环境中(或者主副本负载过高)可能会造成主副本频繁的切换;如果选取过大,则不能及时进行故障切换,且恢复时间也增长,从而造成服务长时间不可用。
所有上述问题,在不同需求、不同环境、不同时间点,都可能会有不同的解决方案。因此在系统上线初期,不少运维团队更愿意手动进行切换;等积累一定经验后,再进行逐步自动化。
节点故障、不可靠网络、副本一致性、持久化、可用性和延迟之间各种细微的权衡,都是设计分布式系统时,所面临的分布式系统核心的基本问题。根据实际情况,对这些问题进行艺术化的取舍,便是分布式系统之美。
# 1.4 日志复制
在数据库中,基于领导者的多副本是如何实现的?在不同层次有多种方法,包括:
- 语句层面的复制
- 预写日志的复制
- 逻辑日志的复制
- 触发器的复制
对于一个系统来说,多副本同步的是什么?增量修改。
从另一个角度来看本节所讲述的四个方法的脉络:
具体到一个由数据库构成的数据系统,通常由数据库外部的应用层、数据库内部查询层和存储层组成。修改在查询层表现为:语句;在存储层表现为:存储引擎相关的预写日志、存储引擎无关的逻辑日志;修改完成后,在应用层表现为:触发器逻辑。
# 1.4.1 基于语句的复制
主副本记录下所有更新语句:INSERT
、UPDATE
或 DELETE
然后发给从库。主副本在这里类似于充当其他从副本的伪客户端。
但这种方法有一些问题:
- 非确定性函数(nondeterministic) 的语句可能会在不同副本造成不同改动。如 NOW()、RAND()
- 使用自增列,或依赖于现有数据。则不同用户的语句需要完全按相同顺序执行,当有并发事务时,可能会造成不同的执行顺序,进而导致副本不一致。
- 有副作用(触发器、存储过程、UDF)的语句,可能不同副本由于上下文不同(如环境变量),产生的副作用不一样。除非副作用是确定的输出。
当然也有解决办法:
- 识别所有产生非确定性结果的语句。
- 对于这些语句同步值而非语句。
但是 Corner Case 实在太多,需要考虑的情况太多,因此效果不好。
# 1.4.2 基于预写日志(WAL)传输
我们发现主流的存储引擎都有预写日志(WAL,为了宕机恢复):
- 对于日志流派(LSM-Tree,如 LevelDB),每次修改先写入 log 文件,防止写入 MemTable 中的数据丢失。
- 对于原地更新流派(B+ Tree),每次修改先写入 WAL,以进行崩溃恢复。
不管哪种情况,所有对数据库写入的字节序列都会被记入日志,也就是说,存储引擎通常都会维护一个具有如下特点的数据结构:
- 追加写入
- 可重放
这种结构,天然适合备份同步。本质是因为磁盘的读写特点和网络类似:磁盘是顺序写比较高效,网络是只支持流式写。具体来说,主副本在写入 WAL 时,会同时通过网络发送对应的日志给所有从副本。从节点收到日志后进行处理,建立和主节点内容完全相同的数据副本。主要缺点是日志描述的数据结果非常底层
书中提到一个数据库版本升级的问题:
- 如果允许旧版本代码给新版本代码(应该会自然做到后向兼容)发送日志(前向兼容)。则在升级时可以先升级从库,再切换升级主库。
- 否则(也就是要求多个节点的版本必须一致),只能进行停机升级软件版本。
# 1.4.3 基于行的逻辑日志复制
这种在工业中用的比较多。
为了和具体的存储引擎物理格式解耦,在做数据同步时,可以使用不同的日志格式:逻辑日志。
对于关系型数据库来说,行是一个合适的粒度:
- 对于插入行:日志需包含所有列值。
- 对于删除行:日志需要包含待删除行标识,可以是主键,也可以是其他任何可以唯一标识行的信息。
- 对于更新行:日志需要包含待更新行的标志,以及所有列值(至少是要更新的列值)
对于多行修改来说,比如事务,可以在修改之后增加一条事务提交的记录。 MySQL 的 binlog 就是这么干的。
使用逻辑日志的好处有:
- 方便新旧版本的代码兼容,更好的进行滚动升级。
- 允许不同副本使用不同的存储引擎。
- 允许导出变动做各种变换。如导出到数据仓库进行离线分析、建立索引、增加缓存等等。
之前分析过一种基于逻辑日志,统一各种数据系统的文章 (opens new window),很有意思。
# 1.4.4 基于触发器的复制
前面所说方法,都是在数据库内部对数据进行多副本同步。
但有些情况下,可能需要用户决策,如何对数据进行复制:
- 对需要复制的数据进行过滤,只复制一个子集。
- 将数据从一种数据库复制到另外一种数据库。
有些数据库如 Oracle 会提供一些工具。但对于另外一些数据库,可以使用触发器和存储过程。触发器支持注册自己的应用层代码,使得当数据库系统发生数据更改时自动执行上述自定义代码。
基于触发器的复制,开销更高、更容易出错;但是给了用户更多的灵活性。
# 2. 复制滞后问题
容忍节点故障只是采用数据复制的其中一个原因,如前所述,使用多副本的好处有:
- 可用性:容忍部分节点故障
- 可伸缩性:增加读副本处理更多读请求
- 低延迟:让用户选择一个就近的副本访问
对于读多写少的场景,可以通过增加从副本的数量来均摊流量。但这种扩展方法有个条件:数据复制必须是异步复制。否则,同步复制情况下一个节点崩就全崩,节点越多,发生故障的概率就越高。
但若是异步复制,就会引入不一致问题:某些副本进度可能落后于主副本。如果应用恰好从这个落后的副本中读取数据,那应用可能会读到过期的信息。
如果此时不再有写入,经过一段时间后,多副本最终会达到一致:最终一致性。
这里的“最终”有点含糊不清,只说了最终,但没说什么时候算最终。实际中,网络通常比较快,副本滞后(replication lag)通常不会太久,但对于分布式系统,谁都不干打包票,在极端情况下,这个“最终”可能非常久。
这个一致性的要求其实非常低,只有对一致性没啥要求的系统才敢用最终一致性,对于这种最终一致的系统,在工程中,要考虑到由于副本滞后所带来的一致性问题。下面讲的就是复制滞后带来的魔幻问题:
# 2.1 读你所写
上图问题在于,在一个异步复制的分布式数据库里,同一个客户端,写入主副本后返回;稍后再去读一个落后的从副本,就会发现:读不到自己刚写的内容!对用户来讲,看起来似乎是刚刚提交的数据丢失了,显然用户不会高兴。
为了避免这种反直觉的事情发生,我们引入一种新的一致性:读写一致性(read-after-write consistency),或者读你所写一致性(read-your-writes consistency)。该机制保证,对于单个客户端来说,就一定能够读到其所写变动,但对其他用户则没有保证。也即,这种一致性是从单个客户端角度来看的一种因果一致性。
那么如何实现这种一致性呢?列举几种方案:
- 【按内容分类】对于客户端可能修改的内容集,只从主副本读取。如社交网络上的个人资料,读自己的资料时,从主副本读取;但读其他人资料时,可以向从副本读。
- 【按时间分类】如果每个客户端都能访问基本所有数据,则方案一就会退化成所有数据都要从主副本读取,这显然不可接受。此时,可以按时间分情况讨论,近期内有过改动的数据,从主副本读,其他的,向从副本读。像那种过去了一天的数据,应该可以假定已经同步完了,因此可以大胆地去从节点中拿。那这个区分是否最近的时间阈值(比如一分钟)如何选取呢?可以监控从副本一段时间内的最大延迟这个经验值,来设置。
- 【利用时间戳】客户端记下本客户端上次改动时的时间戳,在读从副本时,利用此时间戳来看某个从副本是否已经同步了改时间戳之前内容。可以在所有副本中找到一个已同步了的;或者阻塞等待某个副本同步到改时间戳后再读取。时间戳可以是逻辑时间戳(例如序列号),也可以是物理时间戳(此时多机时钟同步非常重要)。
会有一些实际的复杂 case:
- 数据分布在多个物理中心。所有需要发送给主副本的请求都要首先路由到主副本所在的数据中心。
- 一个逻辑用户有多个物理客户端。此时要提供跨设备的写后读一致性。比如一个用户通过电脑、手机多终端同时访问,此时就不能用设备 id,而需要使用用户 id,来保证用户角度的读写一致性。但不同设备有不同物理时间戳,不同设备访问时可能会路由到不同数据中心。
# 2.2 单调读
异步复制可能带来的另外一个问题:对于一个客户端来说,系统可能会发生时光倒流(moving backward in time)。
于是,我们再引入一种一致性保证:单调读一致性(Monotonic reads)。单调读保证,如果某个用户依次进行多次读取,则他绝不会看到回滚现象,即在读取较新值之后又发生读旧值的情况。
- 读写一致性和单调读有什么区别? 读你所写保证的是写后读顺序,单调读保证的是多次读之间的顺序。
如何实现单调读?
- 确保每个用户总是从固定的同一副本执行读取。
- 前面提到的时间戳机制。
# 2.3 前缀一致读
异步复制所带来的第三个问题:有时候会违反因果关系。
本质在于:如果数据库由多个分区(Partition)组成,而分区间的事件顺序无法保证。此时,如果有因果关系的两个事件落在了不同分区,则有可能会出现果在前,因在后。
在一个 partition 内,往往是可以保证数据写入的时间逻辑顺序,但在多个 partition 中却一般不太能保证这种顺序,比如 kafka 的不同 partition 之间就很难保证消息交付顺序,从而出现一些魔幻的现象。
如上图的例子,在 Observer 看来,是 Cake 夫人先回答了问题,Poon 先生才提出了问题,产生了奇怪的逻辑混乱。
为了防止这种问题,我们又引入了一种一致性:前缀一致读(consistent prefix reads)。额… 这个名字比较奇怪。
实现这种一致性保证的方法:
- 不分区。
- 让所有有因果关系的事件路由到一个分区。但如何追踪因果关系是个难题。
# 2.4 副本滞后的终极解决方案
事务!!
多副本异步复制所带来的一致性问题,都可以通过事务(transaction)来解决。单机事务已经存在了很长时间,但在数据库走向分布式时代,一开始很多 NoSQL 系统抛弃了事务,因为这更容易实现、有更好的性能、更好的可用性,于是复杂度被转移到了应用层。
所以 NoSQL 刚出现的时候,只是解决了大数据量的问题,并没有解决好用的问题,这其实是数据库刚刚进入大规模分布式(多副本、多分区)的一种妥协,在经验积累的够多之后,事务必然会被引回。
于是近年来越来越多的分布式数据库开始支持事务,是为分布式事务。
# 3. 多主模型
单主模型一个最大问题:所有写入都要经过它,如果由于任何原因,客户端无法连接到主副本,就无法向数据库写入。
于是自然产生一种想法:多主行不行?
多主复制(multi-leader replication):有多个可以接受写入的主副本,每个主副本在接收到写入之后,都要转给所有其他副本。即一个系统,有多个写入点。
# 3.1 多主模型适用场景
单个数据中心,多主模型意义不大:复杂度超过了收益。总体而言,由于一致性等问题,多主模型应用场景较少,工业中使用的也不多,但有一些场景,很适合多主:
- 数据库横跨多个数据中心
- 需要离线工作的客户端
- 协同编辑
# 场景 1:多个数据中心
假设一个数据库的副本,横跨多个数据中心,如果使用单主模型,在写入时的延迟会很大。那么每个数据中心能不能各配一个主副本?
单主和多主,在多数据中心场景下的对比:
对比项 | 单主模型 | 多主模型 |
---|---|---|
性能 | 所有写入都要路由到一个数据中心 | 写入可以就近 |
可用性 | 主副本所在数据中心故障,需要有个切主的过程 | 每个数据中心可以独立工作,发生故障的数据中心在恢复之后再更新到最新状态 |
网络 | 跨数据中心,写入对网络抖动更敏感 | 数据中心间异步复制,对公网容错性更高 |
但是多主模型在一致性方面有很大缺陷:如果两个数据中心同时修改同样的数据,必须合理解决写冲突,但目前还没有通用的很好的解决冲突的方案。另外,对于数据库来说,多主很难保证一些自增主键、触发器和完整性约束的一致性。因此在工程实践中,多主用的相对较少。
# 场景 2:离线工作的客户端
离线工作的一个应用的多个设备上的客户端,如果也允许继续写入数据。如:日历应用。在电脑上和手机上离线时如果也支持添加日程。则在各个设备联网时,需要互相同步数据。
则离线后还继续工作的多个副本,本质上就是一个多主模型:每个主都可以独立的写入数据,然后在网络连通后解决冲突。但,如何支持离线后正常地工作,联网后优雅的解决冲突,是一个难题。
Apache CouchDB 的一个特点便是支持多主模型。
# 场景 3:协同编辑
Google Docs 等类似 SaaS 模式的在线协同应用越来越流行。
这种应用允许多人在线同时编辑文档或者电子表格,其背后的原理,与上一节离线工作的客户端很像。
为了实现协同,并解决冲突,可以:
- 悲观方式。加锁以避免冲突,但粒度需要尽可能小,否则无法允许多人同时编辑一个文档。
- 乐观方式。允许每个用户无脑写入,然后如果有冲突,交由用户解决。
其实大部分解决冲突都是这两个大思路。
git 也是一个类似的协议,同时 git 设定了人工解决冲突的方式。
# 3.2 处理写入冲突
多主模型最大的问题是:如何解决冲突。
如上例,考虑 wiki 一个页面标题的修改:
- 用户 1 将该页面标题从 A 修改到 B
- 用户 2 将该页面标题从 A 修改到 C
两个操作在本地都修改成功,然后异步同步时,会出现冲突。
# 3.2.1 冲突检测
有同步或者异步的方式进行冲突检测。
对于单主模型,当检测到冲突时,由于只有一个主副本,可以同步的检测冲突,从而解决冲突:
- 让第二个写入阻塞,直到第一个写完成。
- 让第二个写入失败,进行重试。
但对于多主模型,两个写入可能会在不同主副本立即成功。然后异步同步时,发现冲突,但为时已晚(没有办法简单决定如何解决冲突),这就有点像先污染后治理了。
虽然,可以在多主间使用同步方式写入所有副本后,再返回请求给客户端。但这会失掉多主模型的主要优点:允许多个主副本独立接受写入。此时,这样的模型已经退化成单主模型了。
# 3.2.2 冲突避免
解决冲突最好的方式是在设计上避免冲突。由于多主模型在冲突发生后再去解决会有很大的复杂度,因此常使用冲突避免的设计。
假设你的数据集可以分成多个分区,让不同分区的主副本放在不同数据中心中,那么从任何一个分区的角度来看,变成了单主模型。举个栗子:对于服务全球用户的应用,每个用户就近固定路由到附近的数据中心。则,每个用户信息都有唯一的主副本。但如果:
- 用户从一个地点迁移到了另一个地点
- 某个数据中心损坏,导致路由变化
就会对该设计提出一些挑战。
# 3.2.3 冲突收敛
在单主模型中,所有事件比较容易进行定序,因此我们总可以用后一个写入覆盖前一个写入。
但在多主模型中,很多冲突无法定序:从每个主副本来看,事件顺序是不一致的,并且没有哪个更权威一些,那么就无法让所有副本最终收敛(convergent)。因此,数据库必须以一种收敛趋同的方式来解决冲突,这也意味着当所有的更改最终被复制、同步之后,所有副本的最终值是相同的。
实现收敛的冲突解决有以下可能的方式:
- 给每个写入一个序号,并且后者胜。本质上是使用外部系统对所有事件进行定序。但可能会产生数据丢失。举个例子,对于一个账户,原有 10 元,客户端 A - 8,客户端 B - 3,任何一个单独成功都有问题。
- 给每个副本一个序号,序号更高的副本有更高的优先级。这也会造成低序号副本的数据丢失。
- 提供一种自动的合并冲突的方式。如,假设结果是字符串,则可以将其排序后,使用连接符进行链接,如在之前 Wiki 的冲突中,合并后的标题为 “B/C”
- 使用程序定制一种保留所有冲突值信息的冲突解决策略。也可以将这个定制权,交给用户。
# 3.2.4 自定义解决
由于只有用户知道数据本身的信息,因此较好的方式是,将如何解决冲突交给用户。即,允许用户编写回调代码,提供冲突解决逻辑。该回调可以在:
- 写时执行。在写入时发现冲突,调用回调代码,解决冲突后写入。这些代码通常在后台执行,并且不能阻塞,因此不能在调用时同步的通知用户。但打个日志之类的还是可以的。
- 读时执行。在写入冲突时,所有冲突都会被保留(如使用多版本)。下次读取时,系统会将所有数据本版本返回给用户,进行交互式的或者自动的解决冲突,并将结果写回系统。
上述冲突解决只限于单个记录、行、文档层面,而不是整个事务。因此,如果有一个原子事务包含多个不同的写请求,每个写请求仍然是分开考虑来解决冲突。
# 3.2.5 界定冲突
有些冲突显而易见:并发写同一个 Key。
有些冲突则更隐晦,难以发现,考虑一个会议室预定系统。预定同一个会议室不一定会发生冲突,只有预定时间段有交叠,才会有冲突。
# 3.3 多主复制的拓展结构
复制的拓扑结构(replication topology)描述了数据写入从一个节点到另一个节点的传播路径。
在只有两个主副本时,拓扑是确定的,Leader1 和 Leader 都得把数据发给对方。但随着副本数的增多,数据复制拓扑就会有多种选择,如下图:
上图表示了 ≥ 4 个主副本时,常见的复制拓扑:
- 环形拓扑。通信跳数少,但是在转发时需要带上拓扑中前驱节点信息。如果一个节点故障,则可能中断复制链路。
- 星型拓扑。中心节点负责接受并转发数据。如果中心节点故障,则会使得整个拓扑瘫痪。
- 全连接拓扑。每个主库都要把数据发给剩余主库。通信链路冗余度较高,能较好的容错。(最常见)
对于环形拓扑和星型拓扑,为了防止广播风暴,需要对每个节点打上一个唯一标志(ID),在收到他人发来的自己的数据时,及时丢弃并终止传播。
全连接拓扑也有自己问题:尤其是所有复制链路速度不一致时。考虑下面一个例子:
两个有因果依赖的(先插入,后更新)的语句,在复制到 Leader 2 时,由于速度不同,导致其接收到的数据违反了因果一致性。
要想对这些写入事件进行全局排序,仅用每个 Leader 的物理时钟是不够的,因为物理时钟:
- 可能不能够充分同步
- 同步时可能会发生回退
可以用一种叫做版本向量(version vectors) 的策略,对多个副本的事件进行排序,解决因果一致性问题。下一节会详细讨论。
最后忠告:如果你要使用基于多主模型的系统,一定要知晓上面提到的问题,多做测试,确保其提供的保证符合你的使用场景。反正就是,多主模型要慎用!
# 4. 无主模型
有主模型中,由主副本决定写入顺序,从副本在写入上不直接和客户端打交道,只是重放其对应的主副本的写入顺序。而无主模型,则允许任何副本接受写入。
在关系数据库时代,无主模型已经快被忘却。从 Amazon 的 Dynamo 论文开始,无主模型又重新大放异彩,Riak,Cassandra 和 Voldemort 都受其启发,可以统称为 Dynamo 流(Dynamo-style)。
奇特的是,Amazon 的一款数据库产品 DynamoDB,和 Dynamo 并不是一个东西。
通常来说,在无主模型的写入有两种不同的实现:
- 由客户端直接写入副本。
- 由协调者(coordinator)接收写入,转发给多副本。但与主副本不同,协调者并不负责定序。
协调者与数据库节点还有一个重要区别就是,协调者不保留数据,或顶多有一个 buffer,收到数据后立刻就转发出去了,因此协调者通常是无状态的。
# 4.1 有节点故障时的写入
基于 leader-based 的模型,在有副本故障时,需要进行故障切换。但在无主模型中,客户完全可以忽略其中一个副本无法写入的情况。
上图是“多数派写入,多数派读取,以及读时修复”的示意图。
出现故障的节点在恢复后会存在过期数据,为了解决这个问题,当客户端从数据库中读取数据时,它不是想一个副本发送请求,而是并行地发送到多个副本。
所以无主模型其实是牺牲了部分读取性能来换取写入性能,所以模型的选择存在读写性能的 trade-off。
# 4.1.1 读修复与反熵
无主模型也需要维持多个副本数据的一致性。在某些节点宕机重启后,如何让其弥补错过的数据?
Dynamo 流派的存储中通常有两种机制:
- 读时修复(read repair):本质上是一种捎带修复,在读取时发现旧的就顺手修了。
- 反熵过程(Anti-entropy process):本质上是一种兜底修复,读时修复不可能覆盖所有过期数据,因此需要一些后台进程,持续进行扫描,寻找陈旧数据,然后更新。这个博文 (opens new window)对该词有展开描述。
# 4.1.2 Quorum 读写
如果副本总数为 n,写入 w 个副本才认定写入成功,并且在查询时最少需要读取 r 个节点。只要满足 w + r > n,读取的节点中就一定包含最新值。此时 r 和 w 的值称为 quorum 读写。即这个约束是保证数据有效所需的最低(法定)票数。
这个思路本质上就是借助鸽巢原理来保证写入集合与读取集合一定存在交集,从而保证有最新值。
在 Dynamo 流派的存储中,n、r 和 w 通常是可以配置的:
- n 越大冗余度就越高,也就越可靠。
- r 和 w 都常都选择超过半数,如
(n+1)/2
- w = n 时,可以让 r = 1。此时是牺牲写入性能换来读取性能。
仲裁条件 w + r > n 决定了系统可容忍的失效节点数:
- 如果 w < n,则有节点不可用时,仍然能正常写入。
- 如果 r < n,则有节点不可用时,仍然能正常读取。
特化一下:
- 如果 n = 3,r = w = 2,则系统可以容忍最多一个节点宕机。
- 如果 n = 5,r = w = 3,则系统可以容忍最多两个节点宕机。
通常来说,我们会将读或者写并行的发到全部 n 个副本,但是只要等到法定个副本的结果,就可以返回。
如果由于某种原因,可用节点数少于 r 或者 w,则读取或者写入就会出错。
# 4.2 Quorum 一致性的局限
由于 w + r > n 时,总会至少有一个节点(读写子集至少有一个节点的交集)保存了最新的数据,因此总是期望能读到最新的。当 w + r ≤ n 时,则很可能会读到过期的数据。
但在 w + r > n 时,有一些边角情况(corner case),也会导致客户端读不到最新数据:
- 使用宽松的 Quorum 时(n 台机器范围可以发生变化),写操作的 w 台节点和读取的 r 台节点可能完全不同,因此 w 和 r 可能并没有交集。
- 对于写入并发,如果处理冲突不当时。比如使用 last-win 策略,根据本地时间戳挑选时,可能由于时钟偏差造成数据丢失。
- 对于读写并发,写操作仅在部分节点成功就被读取,此时不能确定应当返回新值还是旧值。
- 如果写入节点数 < w 导致写入失败,但并没有对数据进行回滚时,客户端读取时,仍然会读到旧的数据。
- 虽然写入时,成功节点数 > w,但中间有故障造成了一些副本宕机,导致成功副本数 < w,则在读取时可能会出现问题。
- 即使都正常工作,也有可能出现一些关于时序(timing)的边角情况。
因此,虽然 Quorum 读写看起来能够保证返回最新值,但在工程实践中,有很多细节需要处理。我们建议,最好不要把参数 w 和 r 视为绝对的保证,而是一种灵活可调的读取新值的概率。
如果数据库不遵守之前副本滞后小节引入的几个一致性保障,前面提到的异常仍然可能会发生。
# 4.2.1 一致性监控
由于分布式场景下会出现各种奇怪问题,因此对系统进行监控是个很常见的做法。
对副本数据陈旧性监控,能够让你了解副本的健康情况,当其落后太多时,可以及时调查原因。
基于领导者的多副本模型,由于每个副本复制顺序一致,则可以方便的给出每个副本的落后(lag)进度。
但对于无主模型,由于没有固定写入顺序,副本的落后进度变得难以界定。如果系统只使用读时修复策略,则对于一个副本的落后程度是没有限制的。读取频率很低数据可能版本很老。
最终一致性是一种很模糊的保证,但通过监控能够量化“最终”(比如到一个阈值),也是很棒的。
# 4.3 宽松的 Quorum 和提示转交
正常的 Quorum 能够容忍一些副本节点的宕机。但在大型集群(总节点数目 > n)中,可能最初选中的 n 台机器,由于种种原因(宕机、网络问题)部分机器不可用了,导致无法达到法定读写数目,则此时有两种选择:
- 对于所有无法达到 r 或 w 个法定数目的读写,直接报错。
- 仍然接受写入,并且将新的写入暂时交给一些正常节点。
后者被认为是一种宽松的 Quorum(sloppy quorum):写和读仍然需要 w 和 r 个成功返回,但是其所在节点集合可以发生变化。
一旦问题得到解决,数据将会根据线索移回其应该在的节点(D—> B),我们称之为提示移交(hinted handoff)。这个移交过程是由反熵 anti-entropy 后台进程完成的。
这是一种典型的牺牲部分一致性,换取更高可用性的做法。在常见的 Dynamo 实现中,放松的法定人数是可选的。在 Riak 中,它们默认是启用的,而在 Cassandra 和 Voldemort 中它们默认是禁用的。
# 4.3.1 多数据中心
无主模型也适用于系统多数据中心部署。
为了同时兼顾多数据中心和写入的低延迟,有一些不同的基于无主模型的多数据中心的策略:
- 其中 Cassandra 和 Voldemort 将 n 配置到所有数据中心,但写入时只等待本数据中心副本完成就可以返回。
- Riak 将 n 限制在一个数据中心内,因此所有客户端到存储节点的通信可以限制到单个数据中心内,而数据复制在后台异步进行。
# 4.4 并发写入检测
由于 Dynamo 允许多个客户端并发写入相同 Key,则即使使用严格的 Quorum 读写,也会产生冲突:对于时间间隔很短(并发)的相同 key 两个写入,不同副本上收到的顺序可能不一致。
此外,读时修复和提示移交时,也可能产生冲突。
如上图,如果每个节点不去检查顺序,而是简单的接受写入请求,就落到本地,不同副本间可能就会出现永久不一致:上图 Node1 和 Node3 上副本X 的值是 A,Node2 上副本 X 的值是 B。
为了使所有副本最终一致,需要有一种手段来解决并发冲突。
# 4.4.1 后者胜(Last-Write-Win)
后者胜(LWW,last write wins)的策略是,通过某种手段确定一种全局唯一的顺序,然后让后面的修改覆盖之前的修改。
如,为所有写入附加一个全局时间戳,如果对于某个 key 的写入有冲突,可以挑选具有最大时间戳的数据保留,并丢弃较早时间戳的写入。
LWW 有一个问题,就是多个并发写入的客户端,可能都认为自己成功了,但是最终只有一个值被保留了,其他都在后台被丢弃了。即,其迅速再读,会发现不是自己写入的数据。
使用 LWW 唯一安全的方法是:key 是一次可写,后变为只读。如 Cassandra 建议使用一个 UUID 作为主键,则每个写操作都只会有一个唯一的键。
# 4.4.2 Happens-before 关系和并发
考虑之前的两个图:
- 在 5-9 中,由于 client B 的更新依赖于 client A 的插入,因此他们是因果关系。
- 在 5-12 中,set X = A 和 set X = B 是并发的,因为他们都互相不知道对方存在,也不存在因果关系。
所以可以简单地说,如果两者都不知道对方,那么这两个操作就是并发的。
系统中任意的两个写入 A 和 B,只可能存在三种关系:
- A happens before B
- B happens before A
- A B 并发
如果两个操作可以定序,则 last write win;如果两个操作并发,则需要进行冲突解决。
并发、时间和相对性
Lamport 时钟相关论文中有详细推导相关概念关系。为了定义并发,事件发生的绝对时间先后并不重要,只要两个事件都意识不到对方的存在,则称两个操作 “并发”。 从狭义相对论上来说,只要两个事件发生的时间差,小于光在两者距离传播所用时间,则两个事件不可能互相影响。推广到计算机网络中,只要由于网络问题导致,在事件发生时间差内,两者不能互相意识到,则称其是并发的。
# 4.4.3 确定 Happens-before 关系
我们可以用某种算法来确定系统中任意两个事件,是否存在 happens-before 关系,还是并发关系。
这里说的 happens-before 关系,是说如果 A happens before B,也就是如果说 B 依赖于 A,其实是说 B 在发起请求时是意识到有 A,或者说 B 基于 A。
以下面这个例子为例:两个 client 并发添加购物车:
这里具体的过程参考 DDIA 原书第五章。
需要注意:
- client 不会主动读取,只有主动写入,通过写入的返回值读取数据库当前状态。
- 客户端下一次写入,依赖于(因果关系)本客户端上一次写入后获取的返回值。
- 对于并发,数据库不会覆盖,而是保留多个并发值(每个 client 一个)。
上图中的数据流,如下图所示。箭头表示 happens-before 关系。本例中,客户端永远没办法完全获知服务器数据,因为总有另外的客户端进行并发操作。但是旧版本的值会被覆盖,并且不会丢失写入。
总结下,该算法如下:
- 服务器为每个键分配一个版本号 V ,每次该键有写入时,将 V + 1,并将版本号与写入的值一块保存。
- 当客户端读取该键时,服务器将返回所有未被覆盖的值以及最新的版本号。
- 客户端在进行下次写入时,必须包含之前读到的版本号 Vx(说明基于哪个版本进行新的写入),并将读取的值合并到一块。
- 当服务器收到特定版本号 Vx 的写入时,可以用其值覆盖所有 V ≤ Vx 的值。
如果又来一个新的写入,不基于任何版本号,则该写入不会覆盖任何内容。
# 4.4.4 合并并发值
该算法可以保证所有数据都不会被无声的丢弃。但,需要客户端在随后写入时合并之前的值来清理多个值。如果简单基于时间戳进行 LWW,则有些数据又会被丢掉。
因此需要根据实际情况,选择一些策略来解决冲突,合并数据。
- 对于上述购物车中只增加物品的例子,可以使用“并集”来合并冲突数据。
- 如果购物车汇总还有删除操作,就不能简单并了,但是可以将删除变为增加(写一个 tombstone 标记)。
# 4.4.5 版本向量
上面例子只有单个副本。将该算法扩展到无主多副本模型时,该怎么做呢?
上面的例子使用单个版本号来捕获操作之间的依赖关系,担当多个副本同时接受写入时,这是不够的。因为我们需要为每个副本和每个主键均定义一个版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本看到的版本号,通过这些信息来指示要覆盖哪些值,该保留哪些并发值。
对于同一个键来说,不同副本的版本号会构成版本向量(version vector)。
key1
A Va
B Vb
C Vc
key1: [Va, Vb, Vc]
[Va-x, Vb-y, Vc-z] <= [Va-x1, Vb-y1, Vc-z1] <==>
x <= x1 && y <= y1 && z <= z1
2
3
4
5
6
7
8
9
每个副本在遇到写入时,会增加对应键的版本号,同时跟踪从其他副本中看到的版本号,通过比较版本号大小,来决定哪些值要覆盖哪些值要保留。
TIP
其实,多主复制模型和无主复制模型相对用得较少,主要问题就是冲突的解决,在方案的选择上有平台侧解决和用户侧解决。