logo
企业版

技术分享

DDIA精读|分布式数据之多副本的读写流程

以下文章来源于木鸟杂记 ,作者穆尼奥

分布式数据之多副本的读写流程

随着上周《如何设计性能良好的编码?你该知道这几种数据流模型》的发布,DDIA 精读的单机数据部分已全部结束,下面开始进入到分布式数据系统部分。

在分布式数据系统部分,先同大家见面的是——Replication,冗余。本文着重讲 Replication 的数据读写,下一篇侧重 Replication 的模型讲解。

冗余(Replication) 是指将同一份数据复制多份,放到通过网络互联的多个机器上去。其好处有:

  1. 降低延迟:可以在地理上同时接近不同地区的用户。

  2. 提高可用性:当系统部分故障时仍然能够正常提供服务。

  3. 提高读吞吐:平滑扩展可用于查询的机器。

本章假设我们的数据系统中所有数据能够存放到一台机器中,则本章只需考虑多机冗余的问题。如果数据超过单机尺度该怎么办?那是下一章要解决的事情。

如果数据是只读的,则冗余很好做,直接复制到多机即可。我们有时可以利用这个特性,使用分治策略,将数据分为只读部分和读写部分,则只读部分的冗余就会容易处理的多,甚至可以用 EC(纠错码)方式做冗余,减小存储放大的同时,还提高了可用性。

  • 想想 EC 牺牲了什么?以计算换存储。

但难点就在于,数据允许数据变更时,如何维护多机冗余且一致。常用的冗余控制算法有:

  1. 单领导者(single leader)

  2. 多领导者(multi-leader)

  3. 无领导者(leaderless)

这需要在多方面做取舍:

  1. 使用同步复制还是异步复制

  2. 如何处理失败的副本

数据库冗余问题在学术界不是一个新问题了,但在工业界,大部分人都是新手——分布式数据库是近些年才大规模的在工业界落地的。

领导者与跟随者

冗余存储的每份数据称为副本(replica)。多副本所带来的最主要的一个问题是:如何保证所有数据被同步到了所有副本上?

基于领导者(leader-based) 的同步算法,是最常用解决办法。

  1. 其中一个副本称为领导者(leader),别称主副本(primary、master)。主副本作为写入的协调者,所有写入都要发给主副本。

  2. 其他副本称为跟随者(follower),也称为只读副本(read replicas)、从副本(slaves)、次副本(secondaries)、热备(hot-standby)。主副本将改动写到本地后,将其发送给各个从副本,从副本收变动到后应用到自己状态机,这个过程称为日志同步(replication log)、变更流(change steam)。

  3. 对于读取,客户端可以从主副本和从副本中读取;但写入,客户端只能将请求发到主副本。

如何保证所有数据被同步到了所有副本上

根据我的习惯,下面通称主副本和从副本。

有很多数据系统都用了此模式:

  • 关系型数据库:PostgreSQL(9.0+)、MySQL 和 Oracle Data Guard 和 SQL Server 的 AlwaysOn

  • 非关系型数据库:MonogoDB、RethinkDB 和 Espresso

  • 消息队列:Kafka 和 RabbitMQ

同步复制和异步复制

同步(synchronously)复制和异步(asynchronously)复制和关键区别在于:请求何时返回给客户端。

  1. 如果等待某副本写完成后,则该副本为同步复制。

  2. 如果不等待某副本写完成,则该副本为异步复制。

同步复制和异步复制

两者的对比如下:

  1. 同步复制牺牲了响应延迟和部分可用性(在某些副本有问题时不能完成写入操作),换取了所有副本的一致性(但并不能严格保证)。

  2. 异步复制放松了一致性,而换来了较低的写入延迟和较高的可用性。

在实践中,会根据对一致性和可用性的要求,进行取舍。针对所有从副本来说,可以有以下选择:

  1. 全同步:所有的从副本都同步写入。如果副本数过多,可能性能较差,当然也可以做并行化、流水线化处理。

  2. 半同步:(semi-synchronous),有一些副本为同步,另一些副本为异步。

  3. 全异步:所有的从副本都异步写入。网络环境比较好的话,可以这么配置。

异步复制可能会造成副本丢失等严重问题,为了能兼顾一致性和性能,学术界也在不断研究新的复制方法。如,链式复制(chain-replication)。

多副本的一致性和共识性有诸多联系,本书后面章节会讨论。

新增副本

在很多情况下,需要给现有系统新增副本。

如果原副本是只读(read-only)的,只需要简单拷贝即可。但是如果是可写副本,则问题要复杂很多。因此,比较简单的一种解决方法是:禁止写入,然后拷贝。这在某些情况下很有用,比如夜间没有写入流量,同时一晚上肯定能复制完。

如果要不停机,可以:

  1. 主副本在本地做一致性快照。
  2. 将快照复制到从副本节点。
  3. 从主副本拉取快照之后的操作日志,应用到从副本。如何知道快照与其后日志的对应关系?序列号。
  4. 当从副本赶上主副本进度后,就可以正常跟随主副本了。

宕机处理

系统中任何节点都可能在计划内或者计划外宕机。那么如何应对这些宕机情况,保持整个系统的可用性呢?

从副本宕机:追赶恢复

类似于新增从副本。如果落后的多,可以直接向主副本拉取快照 + 日志;如果落后的少,可以仅拉取缺失日志。

主副本宕机:故障转移。

处理相对麻烦,首先要选出新的主副本,然后要通知所有客户端主副本变更。具体来说,包含下面步骤:

  1. 确认主副本故障。要防止由于网络抖动造成的误判。一般会用心跳探活,并设置合理超时(timeout)阈值,超过阈值后没有收到该节点心跳,则认为该节点故障。

  2. 选择新的主副本。新的主副本可以通过选举(共识问题)或者指定(外部控制程序)来产生。选主时,要保证备选节点数据尽可能的新,以最小化数据损失。

  3. 让系统感知新主副本。系统其他参与方,包括从副本、客户端和旧主副本。前两者不多说,旧主副本在恢复时,需要通过某种手段,让其知道已经失去领导权,避免脑裂。

主副本切换时,会遇到很多问题:

  1. 新老主副本数据冲突。新主副本在上位前没有同步完所有日志,旧主副本恢复后,可能会发现和新主副本数据冲突。

  2. 相关外部系统冲突。即新主副本,和使用该副本数据的外部系统冲突。书中举了 GitHub 数据库 MySQL 和缓存系统 Redis 冲突的例子。

  3. 新老主副本角色冲突。即新老主副本都以为自己才是主副本,称为脑裂(split brain)。如果它们两个都能接受写入,且没有冲突解决机制,数据会丢失或者损坏。有的系统会在检测到脑裂后,关闭其中一个副本,但设计的不好可能将两个主副本都关闭调。

  4. 超时阈值选取。如果超时阈值选取的过小,在不稳定的网络环境中(或者主副本负载过高)可能会造成主副本频繁的切换;如果选取过大,则不能及时进行故障切换,且恢复时间也增长,从而造成服务长时间不可用。

所有上述问题,在不同需求、不同环境、不同时间点,都可能会有不同的解决方案。因此在系统上线初期,不少运维团队更愿意手动进行切换;等积累一定经验后,再进行逐步自动化。

节点故障;不可靠网络;在一致性、持久化、可用性和延迟间的取舍;等等问题,都是设计分布式系统时,所面临的的基本问题。根据实际情况,对这些问题进行艺术化的取舍,便是分布式系统之美。

日志复制

在数据库中,基于领导者的多副本是如何实现的?在不同层次有多种方法,包括:

  • 语句层面的复制
  • 预写日志的复制
  • 逻辑日志的复制
  • 触发器的复制

对于一个系统来说,多副本同步的是什么?增量修改

具体到一个由数据库构成的数据系统,通常由数据库外部的应用层、数据库内部查询层和存储层组成。修改在查询层表现为:语句;在存储层表现为:存储引擎相关的预写日志、存储引擎无关的逻辑日志;修改完成后,在应用层表现为:触发器逻辑。

基于语句的复制

主副本记录下所有更新语句:INSERTUPDATEDELETE 然后发给从库。主副本在这里类似于充当其他从副本的伪客户端。

但这种方法有一些问题:

  • 非确定性函数(nondeterministic) 的语句可能会在不同副本造成不同改动。如 NOW()RAND()

  • 使用自增列,或依赖于现有数据。则不同用户的语句需要完全按相同顺序执行,当有并发事务时,可能会造成不同的执行顺序,进而导致副本不一致。

  • 有副作用(触发器、存储过程、UDF)的语句,可能不同副本由于上下文不同,产生的副作用不一样。除非副作用是确定的输出。

当然也有解决办法:

  1. 识别所有产生非确定性结果的语句。

  2. 对于这些语句同步值而非语句。

但是 Corner Case 实在太多,步骤 1 需要考虑的情况太多。

传输预写日志(WAL)

我们发现主流的存储引擎都有预写日志(WAL,为了宕机恢复):

  • 对于日志流派(LSM-Tree,如 LevelDB),每次修改先写入 log 文件,防止写入 MemTable 中的数据丢失。

  • 对于原地更新流派(B+ Tree),每次修改先写入 WAL,以进行崩溃恢复。

所有用户层面的改动,最终都要作为状态落到存储引擎里,而存储引擎通常会维护一个:

  • 追加写入

  • 可重放

这种结构,天然适合备份同步。本质是因为磁盘的读写特点和网络类似:磁盘是顺序写比较高效,网络是只支持流式写。具体来说,主副本在写入 WAL 时,会同时通过网络发送对应的日志给所有从副本。

书中提到一个数据库版本升级的问题:

  • 如果允许旧版本代码给新版本代码(应该会自然做到后向兼容)发送日志(前向兼容)。则在升级时可以先升级从库,再切换升级主库。

  • 否则,只能进行停机升级软件版本。

逻辑日志复制(基于行)

为了和具体的存储引擎物理格式解耦,在做数据同步时,可以使用不同的日志格式:逻辑日志。

对于关系型数据库来说,行是一个合适的粒度:

  • 对于插入行:日志需包含所有列值。

  • 对于删除行:日志需要包含待删除行标识,可以是主键,也可以是其他任何可以唯一标识行的信息。

  • 对于更新行:日志需要包含待更新行的标志,以及所有列值(至少是要更新的列值)

对于多行修改来说,比如事务,可以在修改之后增加一条事务提交的记录。MySQL 的 binlog 就是这么干的。

使用逻辑日志的好处有:

  • 方便新旧版本的代码兼容,更好的进行滚动升级。

  • 允许不同副本使用不同的存储引擎。

  • 允许导出变动做各种变换。如导出到数据仓库进行离线分析、建立索引、增加缓存等等。

之前分析过一种基于日志,统一各种数据系统的文章,很有意思。

基于触发器的复制

前面所说方法,都是在数据库内部对数据进行多副本同步。

但有些情况下,可能需要用户决策,如何对数据进行复制:

  • 对需要复制的数据进行过滤,只复制一个子集。

  • 将数据从一种数据库复制到另外一种数据库。

有些数据库如 Oracle 会提供一些工具。但对于另外一些数据库,可以使用触发器和存储过程。即,将用户代码 Hook 到数据库中去执行。 基于触发器的复制,性能较差且更易出错;但是给了用户更多的灵活性。

复制滞后的问题

如前所述,使用多副本的好处有:

  1. 可用性:容忍部分节点故障
  2. 可伸缩性:增加读副本处理更多读请求
  3. 低延迟:让用户选择一个就近的副本访问

引出

对于读多写少的场景,想象中,可以通过使劲增加读副本来均摊流量。但有个隐含的条件是,多副本间的同步得做成异步的,否则,读副本一多,某些副本就很容易出故障,进而阻塞写入。

但若是异步复制,就会引入不一致问题:某些副本进度落后于主副本。

如果此时不再有写入,经过一段时间后,多副本最终会达到一致:最终一致性。

在实际中,网络通常比较快,副本滞后(replication lag)不太久,也即这个“最终”通常不会太久,比如 ms 级别,最多 s 级别。但是,对于分布式系统,谁都不敢打包票,由于网络分区、机器高负载等等软硬件问题,在极端情况下,这个最终可能会非常久。

总之,最终是一个非常不精确的限定词。

对于这种最终一致的系统,在工程中,要考虑到由于副本滞后所带来的一致性问题。

读你所写

读写一致性

上图问题在于,在一个异步复制的分布式数据库里,同一个客户端,写入主副本后返回;稍后再去读一个落后的从副本,就会发现:读不到自己刚写的内容!

为了避免这种反直觉的事情发生,我们引入一种新的一致性:读写一致性(read-after-write consistency),或者读你所写一致性(read-your-writes consistency)。

若数据库提供这种一致性保证,对于单个客户端来说,就一定能够读到其所写变动。也即,这种一致性是从单个客户端角度来看的一种因果一致性。

那么如何提供这种保证,或者说,实现这种一致性呢?列举几种方案:

  1. 按内容分类。对于客户端可能修改的内容集,只从主副本读取。如社交网络上的个人资料,读自己的资料时,从主副本读取;但读其他人资料时,可以向从副本读。

  2. 按时间分类。如果每个客户端都能访问基本所有数据,则方案一就会退化成所有数据都要从主副本读取,这显然不可接受。此时,可以按时间分情况讨论,近期内有过改动的数据,从主副本读,其他的,向从副本读。那这个区分是否最近的时间阈值(比如一分钟)如何选取呢?可以监控从副本一段时间内的最大延迟这个经验值,来设置。

  3. 利用时间戳。客户端记下本客户端上次改动时的时间戳,在读从副本时,利用此时间戳来看某个从副本是否已经同步了改时间戳之前内容。可以在所有副本中找到一个已同步了的;或者阻塞等待某个副本同步到改时间戳后再读取。时间戳可以是逻辑时间戳,也可以是物理时间戳(此时多机时钟同步非常重要)。

会有一些实际的复杂 case:

  • 数据分布在多个物理中心。所有需要发送给主副本的请求都要首先路由到主副本所在的数据中心。

  • 一个逻辑用户有多个物理客户端。比如一个用户通过电脑、手机多终端同时访问,此时就不能用设备 ID,而需要使用用户 ID,来保证用户角度的读写一致性。但不同设备有不同物理时间戳,不同设备访问时可能会路由到不同数据中心。

单调读

异步复制可能带来的另外一个问题:对于一个客户端来说,系统可能会发生时光倒流(moving backward in time)。

异步复制

于是,我们再引入一种一致性保证:单调读(Monotonic reads)。

  • 读写一致性和单调读有什么区别?写后读保证的是写后读顺序,单调读保证的是多次读之间的顺序。

如何实现单调读?

  1. 只从一个副本读数据。

  2. 前面提到的时间戳机制。

一致前缀读

一致前缀读

异步复制所带来的第三个问题:有时候会违反因果关系。

本质在于:如果数据库由多个分区(Partition)组成,而分区间的事件顺序无法保证。此时,如果有因果关系的两个事件落在了不同分区,则有可能会出现果在前,因在后。

为了防止这种问题,我们又引入了一种一致性:一致前缀读(consistent prefix reads)。

实现这种一致性保证的方法:

  1. 不分区。

  2. 让所有有因果关系的事件路由到一个分区。

但如何追踪因果关系是个难题。

副本滞后的终极解决方案

事务!

多副本异步复制所带来的一致性问题,都可以通过事务(transaction) 来解决。单机事务已经存在了很长时间,但在数据库走向分布式时代,一开始很多 NoSQL 系统抛弃了事务。

这是为什么?

  1. 更容易的实现;2. 更好的性能;3. 更好的可用性。

于是复杂度被转移到了应用层。

这是数据库系统刚大规模步入分布式(多副本、多分区)时代的一种妥协,在经验积累的够多之后,事务必然会被引回。

于是近年来越来越多的分布式数据库开始支持事务,是为分布式事务。

以上,为数据读写流程相关的内容。在下一篇文章中将会同大家讲讲主从模型。


谢谢你读完本文(///▽///)

如果你想尝鲜图数据库 NebulaGraph,记得去 GitHub 下载、使用、(^з^)-☆ star 它 -> GitHub;如果你有更高的性能、易用性、运维实施等方面的需求,你也可以随时 联系我们,获取进一步的帮助哦~