Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

主从备份

每个存储一个数据库备份的节点称为replica,副本。有多个副本之后,一个问题无法避免:怎么保证数据都传送到了所有的副本上。

数据库的每次写入都需要传递到每个副本;否则副本之间数据就会不同。最常见的解决方法就是lead-based replication,主从复制,如图:5-1,工作方式如下:

1、一个副本设为主节点,当客户端写入数据库时,必须将请求发送至主节点,主节点会首先将数据写入到本地存储

2、其他节点称为从节点,当主节点写入新数据的时候,它需要将数据作为replication log,副本日志或者改变流,change stream,发送至所有从节点,所有从节点拿到日志后会按照主节点处理写入的顺序处理日志。

3、客户端想要读取的时候,可以查询任何节点。但是写入只能由主节点接收。

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

这个复制模式是很多关系型数据库内置的,例如Postgresql,MySQL等。它也用于很多非关系型数据库,包括MongoDB,RethinkDB,和Espresso。最后主从复制不局限于数据库:分布式消息代理如Kafka和RabitMQ高可用队列也用到了这些。一些网络文件系统和重复区块设备如DRBD也是类似的。

 

同步与异步备份

备份系统很重要的一点就是复制是同步发生的还是异步发生的。

想想图5-1中场景,一个用户更新头像,同时客户端将更新请求发送至主节点,主节点收到后,将数据更新发送至其他从节点,最后主节点提示用户更新成功。

图5-2展示了系统不同组件之间的通信,用户,一个主节点,两个从节点。时间轴从左至右,请求和回复用粗箭头表示。

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

如图,节点一的复制是同步的,主节点等到确定节点1收到了才会回复用户操作成功,节点2是异步的,主节点发送信息,但是不会等它的回复。

图表显示节点2处理信息会有短暂的延迟。一般而言,复制是很快的:绝大多数数据库系统在1秒内将修改应用到从节点。但是没人保证这会耗时多久。一些情况下,从节点会落后主节点几分钟甚至更久;比如从节点正在从故障中恢复,比如网络问题,比如负载接近最大值。

同步复制的好处是主节点保证数据的备份能维持在最新状态。如果主节点突然停掉,我们能保证从节点是可用的。缺点就是如果同步的节点没有相应,主节点必须停止所有的写入,等待同步复制过程完成。

因此,所有的从节点维持同步不实际,任何一个节点的停止都会导致整个系统的停摆。如果你在一个数据库上开启同步备份,意味着一个从节点是同步的,其他的节点是异步的。如果有一个同步节点不可用或者非常慢,那么一个异步节点会变成同步节点。这能保证你至少在两个节点上有最新的数据,这种方式称为半同步。

一般主从复制配置为完全异步,这种情况下,如果主节点不可用或者无法恢复,没有备份到从节点的写入就丢失了,即便写入已经返回确认给客户了。但是完全异步的情况,主节点可以一直处理写入,即便其他从节点全部落后。

减弱持续性听起来不好,但是异步同步广泛使用,特别是从节点很多,或者是距离很远的情况。

 

建立新的从节点

任何时候你都需要建立从节点,要么是增加备份,要么是替换无用的节点。你怎么确认新的的节点准确复制主节点的数据?简单的从一个节点复制文件至另一个节点是不够的,因为数据一直在变,复制的文件只是复制一个时间点的数据,没有意义。你可能想着将数据库锁住,停止写入,这就违背了可用性。幸运的是建立从节点不需要关掉服务。过程如下:

1、在主节点数据库上建立一个某个时间点的快照,尽量不锁住主数据库。

2、将快照拷贝到从节点

3、从节点向主节点请求子建立快照时间点后的所有数据修改,这要求快照关联主节点备份日志的确定时间点

4、然后从节点开始处理快照时间点之后的备份日志,直到追上主节点,这个过程称为catch up,追赶。

这个建立从节点的方法很实用,不少数据库可以自动建立。

 

应对节点断开

系统的任何节点都可能停掉,可能是故障,也可能是维护过程。能够独立重启节点而不需要停机利于操作和维护。因此我们的目标就是让系统在部分节点停掉的情况下正常运行,将一个节点断开的影响控制到最小。

那么主从备份如何获得高可用性?

 

从节点故障:恢复追赶过程

从节点在本地磁盘上都储存着从主节点那里接收到的数据变更日志。如果从节点故障或者重启了,可以很容易地使用日志恢复,从故障发生时的那个操作开始恢复,从节点可以向主节点请求自连接断开的时候的数据变更日志,然后应用这些数据变更,这样就能追赶上主节点。

 

主节点故障:故障切换

处理主节点的故障有些麻烦,需要在从节点中选出新的主节点,然后从节点注册新的主节点,再从新的主节点接收数据,这个过程称为故障转移。

故障转移可以自动完成或者手动完成,一般有以下步骤:

1、确定主节点已经故障。主节点会因为很多原因故障:硬件问题,网络问题,电源故障等,没有一个完全可靠的方法确定发生了什么故障,大多数系统用的方法就是超时判定:一个节点在指定时间内没有回复,判断节点故障了。

2、选择一个新节点,可以通过控制节点来选择一个新的主节点,最好是用有最新备份数据的节点作为新的主节点。

3、系统注册新的主节点,客户端需要将数据写入新的主节点,如果旧主节点回来了,而其他从节点还是将其作为主节点,会导致其他副本的数据回退至旧版本,因此系统需要确定旧主节点变成从节点,并注册主节点。

 

故障转移会发生很多问题

1、如果使用异步备份,有可能从节点没有收到,在主节点故障之前的全部写入;在发生故障后,如果主节点又加进集群,那么新的主节点会同时收到冲突的写入,最常见的方法就是丢弃掉旧节点未备份的写入,但是这样就违背了持久化性。

2、如果存储系统需要与其他数据系统协作,那么丢弃写入是十分危险的操作。比如GitHub上一个事故,一个过时的MySQL节点被选为主节点。数据库使用自增主键,因为新的主节点的计数器落后于旧的主节点,所以新的主节点使用了旧主节点已经使用过的主键,并且这些主键也被Redis使用了,所以MySQ和Redis就产生了冲突,导致一些私人数据分配给了错误的用户。

3、一些故障场景中,可能两个从节点都认为自己是主节点,这种情况称为“脑裂”,非常危险,如果两个主节点都接受数据写入,并且没有机制防止冲突,可能导致数据丢失或者毁坏。一些系统会有一些安全措施在发生脑裂的情况下关掉一个,但是如果没有设计好,可能两个都关掉了。

4、如何设置正确的超时限制?如果时间过长,意味着需要比较长的时间才能从主节点故障中恢复,如果设置过短,可能造成不必要的故障转移。比如,负载暂时增长,或者网络延迟都会造成节点的响应时间超过超时限制。如果系统面临高负载或者网络问题,故障转移会让情况更糟。

没有简单方法解决这些问题,所以即便是软件能自动进行故障转移,一些操作人员情愿手动进行。

节点故障,网络问题,以及围绕着分布一致性,持久性,可用性和延迟的权衡,就是分布式系统的基础问题,以后会讨论到。

 

实现备份日志

主从备份背后是怎么工作的?接下来就是一些使用中的不同备份方法。

语句备份

最简单的情况下,主节点记录它所执行的每个执行语句,然后发送到从节点。在关系型数据库中,这意味着每个INSERT,UPDATE,DELETE语句都会发送至从节点,每个从节点都会都会执行这些语句,就好像是直接从客户端拿到一样。

这听起来很合理,但是有些问题:

1、一些语句包含一些不确定的函数,比如 NOW()获取当前时间,或者RAND()获取随机值,这很明显会在不同节点上取得不同的值。

2、如果语句使用自增的列,或者它们依赖现有的数据,那么必须保证每个节点上的语句都按相同顺序执行,否则会有副作用。并行执行事务的时候有限制。

3、语句会有其他副作用,可能导致不同节点上有不同副作用,比如触发器,存储过程,或者用户自定义函数;除非完全使用确定的函数。

有方法应对这些问题,比如主节点将非确定的函数在记录时替换成确定的返回值,这样所有从节点都会收到相同的值,但是因为有太多的边界条件而不受欢迎。

MySQL 5.1之前使用语句备份,现在还在使用,但是很有限,现在如果有非确定性函数,会使用行备份。VoltDB使用语句备份,通过要求确定的事务来保证安全。

 

传输优先写入日志

第三章我们讨论了存储引擎如何表示在磁盘上的数据,我们发现每次写入都会追加到日志文件

1、使用日志架构存储引擎的情况,日志是数据主要存储的位置。日志块文件会被压缩,后台会进行回收工作。

2、使用B-tree的情况,数据写入会先写入优先写入日志,以便在宕机后恢复索引。

两种情况下,日志文件都是包含了数据库数据的只增文件。我么可以使用相同的日志文件在从节点上建立备份:主节点将日志写进磁盘的同时,将其发送给从节点,从节点就可以根据日志建立相同的数据结构。

PostGresql和Oracle使用了这种方法。它的缺点是日志在很低的维度上表现数据:WAL表示了磁盘上的那个字节发生了变化。这让备份程序非常依赖存储引擎。如果数据库的存储格式改变了版本,一般就不可能在主从节点上运行数据库的不同版本。

实现上的小差异会对操作产生大影响。如果备份协议运行从节点运行比主节点更新的版本,那么就可以先更新从节点,然后进行故障转移,将从节点设为新的主节点,这样就不用停机也能进行升级。如果备份协议不允许版本不匹配,那么升级需要停机。

 

逻辑日志(列数据)备份

一个选择就是使用区别于存储引擎日志格式的,备份程序自己使用的日志格式。这个日志成为逻辑日志,独立于存储引擎。

关系型数据库的逻辑日志通常是以行为单位的描述数据记录的序列:

1、插入一行,记录就包含所有列的新值

2、删除一行,记录包含确定一行的所有信息,通常是主键,如果表格没有主键,需要记录待删记录的所有列的值。

3、更新一行,日志包含足够确定一行的信息,也包含了所有新值。

一个会改变多行的事务会产生多个日志记录,并跟着一个记录表明事务已经提交。MySQL的binLog用的就是这种方法。

因为逻辑日志独立于存储引擎,可以很容易保持后向兼容性,所以主节点和从节点可以运行不同版本的数据库软件,甚至是不同的存储引擎。

外部程序可以很容易地解析逻辑日志格式。如果你想将数据库内容发送至外部应用,这个特性很重要,比如发送至数据仓库,或者是建立自定义的索引和缓存。这个技术称为change data capture,改变数据归属。

 

触发器备份

目前描述的备份方法都是数据库系统实现的,没有涉及到应用代码。但是一些情况下,我们需要一些灵活性,比如备份部分数据,备份至一种数据库,或者处理逻辑冲突。那么就需要将备份转移到应用层。

可以用一些工具改变数据库日志,从而改变数据。一个选择就是使用关系型数据库的一些特性:触发器和存储过程。

触发器能让你注册一段代码,在数据发生变化的时候自动执行,触发器能够将修改记录到其他独立的表格,其他进程可以读取。外部进程因此能够应用任何必要的数据,将其备份到其他系统。

触发器备份比起其他的备份方式消耗更多资源,而且容易出现bug,有不少限制,但是因为其灵活性,有诸多用处。

 

备份延迟的问题

备份不仅是为了容灾,还是为了扩展性和延迟(物理上更接近用户)。

主从备份,需要所有的写入都送到主节点处理,对于读取频繁但是写入较少的系统,可以增加从节点,让从节点处理读取请求,这能减少主节点的负载,并就近处理读取请求。

在读取扩展架构中,可以通过增加从节点来提高性能。但是这个方法对于同步备份不适用,因为如果你试图同步备份所有从节点,那么如果有一个节点出错,那么整个系统都没法写入,随着节点的增多,出错的概率会越来越大。所以同步备份是不可靠的。

但是,如果应用从异步备份系统的从节点读取数据,可能读取到过时的数据。这会导致数据库出现不一致性。如果你同时在主节点和从节点上运行相同的查询,那么可能得到不一样的结果,因为写入可能还没有反映在从节点上;如果停止写入,等一段时间,那么从节点就能赶上主节点,与主节点保持一致;这种情况称为:最终一致。

“最终”这个词很含糊:没法保证备份间的延迟有多长,可能主从节点间的写入延迟在1秒内,运行时感觉不出来;如果系统繁忙,负载接近阈值,延迟可能有几秒钟或者几分钟。

当延迟很大,主从节点间的不一致性就是大问题,接下来我们会看到延迟带来的三个问题,以及它们的解决方法。

 

读取自己的写入

许多应用允许用户在提交数据后立刻查看提交的数据,比如用户资料、评论等。当发送数据时,必须首先发送至主节点,但是读取时可能是从从节点读取,这样可能就看不到即时写入的数据。

图5-3展示了如下过程,一个用户查看刚刚写入的过程,新数据可能还没传到备份。在用户看来,他们提交的数据丢失了,他们会很不高兴。

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

这种情况下,我们需要 read-after-write consistency,写后读取的一致,也称为 read-your-writes consistency,读取自己的写入的一致。这能保证用户在刷新页面后,能看到自己提交的更新。这并不保证用户能即时看到其他用户的更新,但是这能向用户保证自己的输入已经正确存储了。

我们如何在主从备份的框架下实现写后读取的一致性,下面是一些方法:

1、如果用户可能修改过某些信息,就从主节点读取。这需要你在不查询的情况下,确定是否某些信息修改过。例如,用户私人信息只可能被用户自己看见,所以读取用户私人信息时,都从主节点读取。

2、如果应用中的绝大多数信息都是用户编辑的,那么上述方法很低效,因为大部分信息都会从主节点读取。这种情况下需要添加其他的条件判定是否从主节点查询。比如,可以记录上次更新的时间,更新后一分钟的读取都由主节点处理,你也可以监视从节点上的备份延迟,防止查询请求发送到延迟超过一分钟的从节点上。

3、客户端可以记录最近的更新的时间戳,然后系统可以确保客户端的请求会发送到同步时间晚于客户端更新时间的节点上;如果节点未及时同步,请求会发送至其他节点或者等待节点更新。时间戳可以是逻辑时间戳(表示写入顺序,比如日志序列),或者是真实的系统时间(需要时钟严格同步)。

4、如果你的备份系统包括多个数据中心,那么更复杂。任何需要主节点处理的请求都要转发至包含主节点的数据中心。

还有种情况是:如果你使用不同的设备访问系统,比如浏览器和手机,那么情况更复杂了。这种情况下想提供写后读取一致性应该是,一个设备写入后,所有设备上看到的信息应该一致。

这种情况下,有额外的事情需要考虑:

1、需要记录用户最近更新的时间戳的方法变得非常困难,因为一个设备上的代码不知道其他设备上发生了什么,因此需要集中收集元数据。

2、如果备份系统跨越多个数据中心,那么不同设备可能没法连接至相同的数据中心。(比如,如果用户的电脑可能通过宽带连接,手机使用移动网络,设备的网络路径完全不同),如果你需要从主节点读取数据,你需要确保用户的所有设备的请求转发至相同的数据中心。

 

单调性读取

另一个从异步备份的集群读取的反常现象就是 moving backward in time,时间回退。

这在用户从不同副本做多次读取时会遇到。如图5-4所示,用户2345做了两次相同的查询,一次从低延迟节点,一次是从高延迟节点。第一次查询能查到用户1234添加的评论,第二次看不到新添加的评论。类似于第二次查询观察到了早于第一次查询时的系统的状态。如果第一次查询没有显示新的评论,这没什么,因为用户2345不知道用户1234新添加了评论。但是如果是第二次没查到,但是第一次查到了,这就让人疑惑了。

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

单调性读取保证不会发生上述异常情况。它的保障性弱于强一致性,但是强于最终一致性。单调性读取意味着用户做多次顺序读取,不会在看到新值后,再看到旧值。

实现单调性读取的方法就是保证每个用户的查询都发送至相同的副本上(不同用户可以使用不同的副本)。可以根据用户ID来选择副本,如果副本故障,那么用户请求要转发到其他副本。

 

前缀一致性读取

备份延迟导致的第三个异常现象就是逻辑混乱。假设Mr.Poons 和 Mr.Cake间有如下对话

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

一问一答,前后有逻辑关联。现在想想第三个人从其他备份上听到这个对话,Mr.Cake延迟短,Mr.Poons延迟长,那么第三者可能看到的就是:

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

回答在提问前面,让人困惑。

Design Data-Intensive Applications 读书笔记十三 第五章:主从备份和备份延迟产生的问题

防止这种情况需要另一种保障: consistent prefix reads,前缀一致性读取。说的是如果一系列写入遵循特定顺序,那么读取它们后,也需要呈现相同的顺序。

这是分布式数据库特有的问题,如果数据库按照一定顺序写入,读取时会看到前缀一致性,那么这个异常不会发生。但是很多分布式数据库的不同分区是独立工作的,没有全局的写入顺序,用户从不同分区读取时,看到的状态可能不同。

解决方法就是,确保任意逻辑相关的写入,都写入到相同分区;一些应用可能没法有效达成。后面我们会看到一些追踪逻辑独立的算法。

 

备份延迟的解决方法

当使用最终一致的系统时,需要考虑如果备份延迟高达几分钟甚至几小时时,应用如何工作。如果答案是“没问题”,那么恭喜。如果答案是会给用户很差的体验,那么需要重新设计系统来提供更强的保障。将一个异步系统伪装成同步,是应对潜在问题的方法。

之前讨论过,应用可以比数据库自身提供更强的保障。比如在主节点上做特定读取。但是处理这些问题,应用代码会变得复杂并且容易出错。

更好的情况是,开发者不需要考虑潜在的备份问题,相信数据库“能做正确的事情”。这就是为什么 transactions事务会存在:他们提供更强的保障,因此代码可以更加简单。

单节点事务已经存在了很长的时间,但是迁移到分布式数据库,很多系统抛弃了它,宣称考虑到性能和可用,支持事务过于昂贵,并且断定最终一致性不可避免。这个表述某种程度上是对的,但是过于简单,我们后续会更细腻地观察这些问题。