备份意味着把你的数据的多个副本放置在不同的机器上,这些机器通过网络连接。如第二章综述所讲,备份的好处有以下几点
- 允许你的数据离你的用户在地理位置上更近,从而减小延迟
- 允许你的系统在单个节点挂掉之后依旧能够工作
- 通过提高机器的数量从而提高读的吞吐量。
这章我们先假设一台机器能够放下所有的数据,在第6章,我们在把这个假设放宽,通过分区技术解决当单机没办法放下所有数据时候的问题。这章后面讨论备份系统可能出现的问题以及如何解决他们。
如果你的数据从不变化,那你只需要把数据的复制到每个节点就搞定了。所有的问题都是在你备份的数据发生变化时出现的。当备份系统的数据发生变化时,有3种流行算法使得多个节点也能备份这些变化。single leader, multi-leader,leaderless. 近乎所有的分布式系统都是用这3种的其中一种。三种算法都有自己的优点和缺点。
在备份的过程中有很多的权衡需要考虑,举个例子,使用同步备份还是异步备份,如何处理失败的备份流程。这些一般都在数据库当中有配置,虽说不同数据库在实现上有众多差异,但是思想上还是一致的。
Leaders 和 Followers
我们管每台存储着数据库备份的节点叫做备份节点(replica), 如果你是多备份系统,就有一个问题需要处理,你怎么能知道所有的数据在所有的备份上都是正常的呢?
数据库要求每次写操作都要把数据发送给每个备份节点,否则备份节点就没办法都有相同的数据了。最常用的解决方案是leader-based replication, 也叫active/passive 或者 master–slave replication,工作原理如Figure 5-1所示
- 某一个备份节点被选作Leader, 当客户端要写数据库的时候,他们必须把他的请求发给Leader, Leader先把请求内容写入到本地的存储中。
- 其他的备份节点称作Follower, 当Leader把写请求写到本地存储的同时,也把更新以备份日志或更新流的形式发送给其他的Follower。 每个Follower接收从Leader传来的更新日志,按照日志更新的顺序,更新自己的本地存储。
- 当客户端需要读数据的时候,他们可以从Leader或者Follower读取数据。但是写请求就只能通过Leader了,Follower对于客户端而言是只读的。
这种备份模式在关系数据库中有很多应用,例如PostgreSQL, MySQL, 在某些非关系数据库中也有应用,例如MongoDB, RethinkDB, Espresso。不仅如此,leader-based replication不光用于数据当中,分布式的消息队列,比如Kafka和RabbitMQ同样运用了这套方案。某些网络文件系统,例如DRBD, 在备份文件的时候也是一样的。
同步备份 vs 异步备份
备份有一个很重要的细节,是备份和写请求时同步的还是异步的。在关系数据库中,这是通过配置实现的,其他系统一般在两者中选取其一,不可修改。
想想Figure 5-1的情况,用户通过网页更新自己的档案照片,客户端把这个更新发送给Leader, Leader收到请求之后,在之后的某个时间把更新发送给Follower, 最后Leader通知客户端更新完成。如Figure 5-2所示
Figure 5-2中, Follower1的备份是同步的,Leader在Follower1返回ok之前一直等待。在收到Follower更新成功之后,才向客户端返回更新成功。而在这段等待的时间中,数据库是不接受其他客户端的写请求的。Follower 2的备份是异步的,Leader将消息发送出去,并没有等待Follower返回成功。
图中显示Follower 2在处理消息之前有很大的延迟,正常情况下,备份是很快的,大多数数据库更新到备份节点需要少于一秒的时间。但是对于这个时间其实并不是100%承诺的。有时候会出现Follower的数据比Leader晚几分钟甚至更多的情况出现,比如当Follower刚刚从异常中恢复,或者系统负载接近满负荷,或者节点间的网络出现异常。
同步备份的好处是,Follower的数据100%跟Leader的内容一致,一旦Leader在某个时间挂了,我们可以直接拿Follower来替代它并且保证数据没有问题。缺点在于如果Follower因为某些原因没有响应,那这次写操作就无法处理了。Leader必须在Follower恢复之前禁止任何的写更新操作。
基于这个原因,让所有的Follower都是同步更新显然是不现实的,任何一个节点异常都会导致整个系统挂起。在实际情况下,如果你在数据库中开启了同步备份,这往往代表其中的一个备份节点是同步的,其他是异步的。如果那个同步节点出现异常,或者延迟很大,那另外一个异步节点会替代他成为同步节点。这样就保证你至少有2个节点有最新的数据,Leader和一个同步节点,这种配置被称作半同步(semi-synchronous)
很多时候,leader-based replication通常被配置成全异步的。这种情况下,一旦Leader挂了,那所有没有同步给Follower的更新就全都丢掉了。这就意味着写操作是不能100%保证持久化的,即使客户端已经收到了成功的返回。但是这样的好处是即使所有的Follower都挂掉了,Leader依然可以正常的处理写请求。可能会丢数据听上去是一个很差的代价,但是异步备份仍然是用的十分广泛,尤其是当你有很多个Follower或者他们分布在世界不同地方的时候。
异步备份丢数据听上去依旧是一个很可怕的事情,所以人们开始研究那种不会丢数据但依旧有高性能和高可用性的备份方式。chain replication 是一种同步备份的变种,他已经用到了Azure存储中。
添加新的Follower
随着时间的推移,你可能需要添加新的Follower,可能是要增加备份数,可能是要替换掉老的Follower,但是问题是你怎么能够知道新的Follower已经拥有最新的数据了呢?
简单从一个节点把数据库文件复制过来是不可行的,因为client一直还在更新删除数据。一个简单的复制只能得到数据库某个时间点的镜像,但是不是最新的。你也可以简单的在复制的过程中禁止写请求,就能避免这个问题,但是这就和我们的高性能,高可用性违背了。所以实际过程如下
- 从Leader的数据库中取出一个时间点的镜像,这个在大多数数据库中都是很轻松的事情,因为数据库本身也需要定期备份。某些时候,我们需要些第三方库,比如在处理MYSQL时需要innobackupex
- 把这个镜像内容复制到新的Follower中
- Follower向Leader请求这个镜像时间点之后的所有更新。这就需要镜像文件能够和Leader的备份日志中某一个具体的位置关联起来。这个位置有着不同的名字,PostgreSQL叫他log sequence number, MYSQL叫他binlog coordinates
- 当Follower把更新都完成后,我们认为他已经追上了最新的数据(caught up),这个时候他就可以像其他Follower一样正常跟Leader通信做备份了。
理论是上面这样,但是实际操作中每个数据库具体的方法差距很大,有的能做到全自动,有的就需要管理员手动执行一些东西。
处理节点失效
系统中任何节点都可能会失效,有些是意外,比如错误,掉电,有些就是计划中的,比如停机检修,重启。重启单个机器但不影响服务对于操作和维护而言很重要。所以我们的目标是即使单机挂掉,整个系统依旧能够正常运转,并且要把单机挂掉带来的影响降到尽可能的小。
那在leader-based replication的架构下如何实现高可用性呢?
Follower失效: 追赶恢复(Catch-up recovery)
在每个Follower本地磁盘中,有一份他从Leader收到的更新数据的日志。如果某个Follower突然挂掉或者他与Leader的网络中断,他能够基于他的日志很容易的恢复。首先他能根据知道发生异常之前他更新到的位置,Follower可以向Leader请求这个位置之后的所有更新数据。当他处理完这些数据后,可以认为已经跟上了Leader的进度,这时就可以正常的持续接收Leader发送的更新数据了。
Leader失效:故障切换(failover)
处理Leader失效就要麻烦很多了,要从众多Follower中选取一个作为新Leader,客户端需要重新将请求发送给新Leader。剩下的Follower要切换到新Leader接收更新数据。我们管这个叫故障切换(failover)
故障切换可能需要手动操作,管理员收到Leader挂的通知,然后人工操作选个新Leader,当然也可以自动来。自动操作就是以下几步
- 确定Leader挂掉了, 因为失效的原因很多,掉电,异常崩溃,断网。其实没有一个100%确定的方法能够判断Leader真的挂掉了,所以一般来说都是用一个很简单的方法。超时判断,节点定时向对方发送心跳消息,如果一个节点超过一段时间,比如30s没有响应心跳消息,就认为这个节点已经死了。(如果Leader是计划中的维护导致不可用,则不适用于这个判断方法)
- 选新Leader, 这个一般是由一个选举过程实现的,剩余的Follower中选Leader,也可以预先选好一个控制节点(controller node), Leader失效后将控制节点指定为Leader。候选的Leader最好是更新的数据最新的节点,这样可以减少数据丢失。让所有节点一致同意某一个新的Leader是一个一致性问题,这个第九章讲。
- 重新配置系统使用新Leader, 客户端需要将写请求发送给新Leader。另外当新的Leader活过来的时候,他可能依旧认为自己是Leader,系统必须保证他能够意识到自己已经变成了Follower并且能够正确的后新的Leader进行通信。
故障切换看似简单,有很多地方都可能会出错。
- 如果使用了异步备份,那新的Leader可能没有收到老Leader的所有的更新数据。当老的Leader恢复重新加入系统中后,那些没有同步的写请求应该如何处理就很尴尬。因为新的Leader可能会接收到互相冲突的写请求,比如老的说这个字段是1,但是新Leader后面又收到了他是2,他就不知道到底是几了。绝大多数的处理方法是把这部分数据丢掉,但是这就让客户端不好处理了,因为他的写请求可能返回成功,但是却丢了。
- 丢掉这些写请求时危险的,尤其是当还有其他外部系统使用了你的数据的时候。在一次GitHub的故障中,一个没有收到所有的更新的Follower被选成了Leader,对于每条新数据,数据库用了一个自增的数字来做主键。但是因为新的Leader的更新是落后于老Leader的,所以他把之前老Leader已经使用过的一些主键又给了当时的新数据,导致主键复用的情况。问题在于这些主键在另一个redis当中有存储用到,这一下导致redis的数据和MYSQL不一致,导致一些私人数据暴露给了错误的用户。
- 在特定的异常情况下,可能出现有两个节点都认为自己是Leader,这种情况称作split brain, 这种情况很危险,因为两个节点都会接受写请求,写请求之间的冲突就没办法处理了,数据很有可能会发生丢失或损坏。出于安全考虑,有些系统会有对应的安全机制,如果选出来两个Leader,直接干掉一个,但是你又没办法保证你的安全机制是正常的,有可能出问题把两个都干掉了。
- 判断Leader超时应该设多长?如果设的很长,那一旦Leader挂掉就需要很多时间才能恢复,但是如果设的很短,那可能会增添没必要的故障切换。举个例子,某个时刻的负载高峰或者某个时段网络抖动都会造成返回延迟。这个时候说明整个系统负载已经很大了,如果还启用了故障切换,带来更多额外的流量和操作,那就会把事情搞得更糟。
由于没有简单的解决这些问题的办法,所有有些运维团队宁愿选择手动的故障切换。
备份日志的实现
之前讲的都是虚的,leader-based replication具体是如何工作的呢?实际情况中,有几种不同的备份方式。
基于指令的备份(Statement-based replication)
这是一种最简单的方式,Leader记录每次写请求指令,将这个指令日志发送给Follower。在关系数据库中,这就意味着每个INSERT, UPDATE, DELETE请求的具体SQL被发送个Follower。每个Follower解析收到的日志,执行对应的SQL命令,就好像是从客户端收到的一样。
这种方法虽然听上去挺靠谱的,但是有几个问题会导致备份出错。
- 一旦指令中包含非确定性的函数,比如NOW(), RAND(), 备份中生成的数据就不一样了。
- 如果指令中涉及自增的数据,或者这个指令依赖于数据库中的其他数据,例如 update ... where <some condition>, 那每个副本的执行顺序必须一致,否则效果就不一样了。一旦有多个事务同时执行,这个可能就有问题了。
- 每个指令可能会有额外效果(触发器,存储过程, 用户自定义函数), 这些内容除非影响面是确定的,否则在不同副本间可能会有不同的结果。
虽然这个方案有些问题,但是改一改还是可以用的,比如Leader在记日志的时候把所有非确定函数转成一个指定值,这样所有Follower结果就一致了。但是图啥呢,明明还有其他的方法嘛。
基于指令的备份在 MYSQL 5.1以前都在使用。现在有时候也会使,因为他十分简单。如果有非确定函数的时候,会切换到基于行的备份,这个马上讲。VoltDB也是用这种方法,他要求每个事务结果都是确定的,所以没有之前的问题。
借用Write-ahead log (WAL) 的方法
第3章我们讨论了,存储引擎是如何把数据写入磁盘的,通常就是把所有写请求追加到一个日志中。
- 在SSTables中,这个日志其实就是整个存储引擎的核心,读写都是基于这个日志。日志段在后台进行合并。
- 在B树中,数据本身是存在另外的地方,日志不负责读数据,每次写请求都是先写入write-ahead log, 保证程序崩溃后,可以根据日志恢复。
无论哪种情况,其实日志都是一个只可以追加的字节流,这个字节流中包含了所有的写请求。我们可以用这个日志在其他节点上构建一个完整的备份出来。Leader不仅要把写请求记到本地WAL日志中,还要把它发给Follower,Follower基于这些日志构建一个完整的备份。
这种方法用于PostgreSQL和Oracle等等。但是这种方法有一个缺点,就是他是在一个很低的层级上去描述数据。因为WAL绘描述磁盘上某个字节发生了变化。这就使得副本和Leader紧紧耦合在一起。那Leader和Follower就必须运行相同的版本。如果数据库更新版本导致数据格式发生变化,那兼容性就没办法处理了,因为你不可能一瞬间把Leader和Follower同时更新。这就意味着一旦更新版本,必须暂停服务。
逻辑日志备份/基于行备份
由于WAL有这些问题,所以有一个解决方法是用另一套日志格式来做备份。这样备份日志就可以和存储引擎内部解耦了。这种类型的备份日志称作逻辑日志(logical log), 用以和存储引擎物理层面的数据表达格式区分。
关系数据库的备份日志通常是一系列记录,用以准确描述数据库写入的数据。
- 针对插入数据,日志包含了这条数据所有列的值
- 针对删除数据,日志包含足够的信息以准确找到需要删除的数据。典型的信息就是他的主键,如果没有主键,那就需要被删除列的所有字段。
- 针对更新数据,日志包含信息能够准确找到需要更新的行,以及需要更新的列和更新的值。
如果一个事务同时更新了多条数据,则系统会产生了多条这样的日志,日志后面有一条记录标识这个事务被成功提交了。MYSQL的binlog就是用这种方法。
因为逻辑日志和存储引擎内部解耦,所以他可以很容易的具备向后兼容性,允许Leader和Follower运行不同的版本的数据库,甚至是不同的数据库。同时逻辑日志还能够容易的被外部应用解析,这个特性在你需要把数据库内容发送给外部应用的时候十分有用。比如从数据库把数据导给数据仓库用以离线分析,或者构建缓存和索引。这个技术叫做change data capture, 第11章讲
基于触发器的备份
前面讲过的所有备份都是数据库系统实现的,不需要任何应用方写代码。绝大多数时候你希望的就是这样,但是有时候你需要更为灵活的配置。比如你只希望备份一部分数据,或者将一个数据库内容备份到另一个不同的数据库中。这种时候你就需要将备份功能上移到应用层来实现。
一些工具可以让应用触及数据库的数据变化,比如Oracle GoldenGate, 他是通过读取数据库的日志实现的。另外一种实现的方法是用触发器(triggers) 和存储过程(stored procedures)
触发器可以允许你注册一些自定义函数,在数据变化时 ,这些函数会自动执行。这样触发器可以把数据变化记录到另外一个单独的表中,这个表可以被外部程序访问。这样外部程序就可以用任意自定义的逻辑来处理或者备份数据。Oracle的数据总线(data bus)和Postgres的Bucardo就是这样工作的。
很显然,基于触发器的备份要比其他数据库自带的备份方案代价更大,而且也更容易带来bug和一些限制因素。但是由于他的灵活性,他还是很有用的。