副本

副本的作用:

  • 地理位置上距离使用者更近
  • 当系统的某些节点挂掉后可以继续正常运行
  • 减小读操作的负载

备份这件事情最麻烦的地方在于:处理备份数据的变化

有三种算法来处理节点间的数据变化:

  • 单 leader
  • 多 leader
  • 无 leader

处理副本时还需要做一些权衡:同步备份还是异步备份如何处理失败的备份

Leaders and Followers

每个存储了数据库一份拷贝的节点都叫做一个副本(replica)
每一个对数据库的写都需要被每个副本正确处理,否则的话,副本里的数据就会不一样。为了解决这个问题,最常见的解决办法叫做 leader-based 备份(又叫主动/被动主从备份)。如下图所示:

其作用模式是:

  1. 某一个节点被设计为 leader(master),当有写操作时,只能发送请求到这个节点,主节点先将这个写操作的数据存在自己的存储空间里;
  2. 其他几点称作 followers(slave),当主节点写数据到自己的存储空间时,它同时通过备份日志将数据改变发送给所有的从节点。每个从节点通过顺序地应用所有的 日志改动 来改变数据。
  3. 读操作可以从任意节点读,写操作只能从主节点写

同步 vs 异步

直接上图:

follower 1 是同步的:主节点等到从节点返回更改成功的信号才进一步操作;
follower 2 是 异步的:主节点不等待从节点返回。

同步的好处是:数据可以被确保被存在了从节点上;坏处是:等!如果从节点挂了,主节点会一直等!
所以,不可能所有的从节点都是同步的

半同步:如果有一个同步从节点挂了或者很慢,异步从节点中的一个就变成了同步的。

通常情况下,主从备份机制都被配置为完全异步的,缺点是,如果在备份的过程中主节点挂了,还没有被备份到从节点的数据就永远丢失了。好处是,主节点可以一直处理写操作。

当从节点很多或者从节点分布在不同的地理位置时,完全异步方式是被广泛采用的。

配置新从节点

步骤:

  1. 在某个时间点得到一个主节点的快照;
  2. 把快照拷贝到新节点上;
  3. 新节点连接到主节点并请求自从快照发生后的新数据;要求数据库有一个标志可以记录这个节点:log sequence number 之类的;
  4. 追加成功之后,开始正常处理数据

处理节点挂掉

节点除了意外 down 掉外,也有可能主动 down 掉,比如升级系统,升级数据库等等。

从节点down掉,追加恢复

每个从节点在本地磁盘上保留一份从主节点接收的数据更改的日志。当从节点从崩溃中恢复后,只需要读这个日志然后向主节点请求crash之后的数据即可。等追加成功后,正常备份就好。

主节点挂掉: 故障转移(failover)

当主节点挂掉后,某个从节点被顶上来当主节点。

步骤:

  1. 定义主节点挂掉:timeout。
  2. 选新的主节点
    选举机制:大多数从节点选一个新的从节点成为主节点。
    controller 节点: 事前选一个controller node
  3. 重新配置系统
    客户端发送写请求到新的主节点。其他从节点要知道新的主节点。老的主节点恢复后要知道自己已经是从节点了。

故障转移可能会因为这些事情出错:

  • 如果使用的是异步备份,有一种情况:新的主节点没有收到老的主节点的全部数据,而这时老的主节点又变成了新的从节点,那这时候老的主节点上的没有备份的数据怎么办呢?现实中大部分数据库的做法是:丢弃这些数据。
  • 丢弃数据有时候很可怕:当数据和其他系统有交互时。
    真实事件发生在 GitHub。一台数据不完整的 MySQL 从节点被选举为了主节点,而同时公司使用了自增 Primary Key,而这些 Primary Key 同时存在了 Redis 中。这就导致了新的主节点上位后用了已经存在Redis中的 PK 作为新的 PK,从而导致了混乱。
  • 有一种神奇的场景:两个节点都觉得自己是主节点。。。数据会错乱。
  • Timeout 时间长短的设定要考虑清楚

这些都是 分布式系统 的基本问题

备份日志的实现

基于声明的副本

最简单的情况是:主节点把每个写操作都发送给从节点。对于关系型数据库而言,就是把 INSERT, UPDATE, DELETE 语句都发送给从节点,每个从节点都要解析并执行这些 SQL 语句就像这些语句是直接从客户端来的一样。

听起来很有道理,但这种方法有不少问题:

  • 只要语句中有 非确定性的函数,比如 NOW(), RAND()等,就会造成每个 副本 出来的数据不一致;
  • 顺序必须一致!当有大量并发实务操作时会有问题;
  • 有副作用的语句(比如 触发器,存储过程,UDF 等)通常也是非确定性的。

解决办法就是:主节点在发送之前把非决定性的语句转换为决定性的

Write-ahead log(WAL)

几乎所有的存储引擎都使用了这种先写数据后记录日志的方式。

我们可以使用同样的日志文件在每个节点构建出一个副本。

这种方式有一个大问题:WAL 包含了太多实现细节,导致版本更新或者更换存储引擎变得几乎不可能。

逻辑(基于行的)日志副本

另一种存储方式是:日志格式和存储引擎解耦。这种日志称为逻辑日志(以区别存储引擎中真是的物理日志)。

逻辑日志通常是一系列记录,用于描述以行为粒度对行的写入:

  • 对于一行的写入操作,日志会包含这一行所有列的值;
  • 对于一行的删除操作,日志会包含足够的信息来说明这一行被删除了,通常来说是主键;如果这个表没有主键,日志需要记录这行所有的数据;
  • 对于一行的更新操作,日志会包含足够的信息来说明这一行被更新了,以及被更新后的所有列的值。

如果有一个事务操作更改了很多行,就会记录行日志,然后会有一行记录表明这个事务被提交(committed)了。

这种方式使数据和日志解耦了,所以兼容性更好得多。

这种兼容性不仅体现在同一个数据库的不同版本,也体现在同一份数据可以被不同数据库使用。

触发器副本

如果你想在应用层面手动控制副本(只复制某些数据),需要用到触发器存储过程

副本延迟的问题

单主多从结构只适用异步复制。但异步复制的问题是:在某些时候,某些从节点的数据和主节点不一致(你懂的)。等一段时间后,这些从节点上的数据最后会追加回来和主节点保持一致。这个问题被称为数据的最终一致性

从节点最终追加回来的时间被称为复制延迟。复制延迟可能会带来下面这些问题。

只读自己写的数据

当被写的数据需要立马被读的时候,需要写后读一致性,也称为读你自己写的数据一致性。有几种方式可以实现这种一致性:

  • 这种场景的数据从主节点读,其他数据从从节点读。前提是需要对业务了如指掌。
  • 当大部分数据都需要满足写后读一致性时,不能全部从主节点读,需要更复杂的机制保证从从节点读数据而不遗漏;
  • 由客户端记住数据最近一次被写的时间戳,根据这个时间戳来决定从库里的数据是否可用。问题是时间戳的同步问题。
  • 如果是多数据中心架构,如果一个查询需要主节点参与,那么这个查询必须要路由到包含主节点的数据中心,这会带来额外的复杂度。

单调读

哈哈哈,有时会出现时光倒流的效果:第一次读到一个延时比较小的副本,第二次读到一个延时比较长的副本:时光倒流了

单调读(monotonic reads)就是为了保证这种情况不会发生而存在的。

一种实现单调读的办法是:保证同一个用户只读同一个副本。

一致性前缀读

如果一连串的写操作以某个固定的顺序发生,那么读这些顺序化的数据必须以同样的顺序出现。

分 Shard 的数据库架构中,要保证这个非常困难。

一种解决办法是在分区的时候,手动控制和这个需求相关的数据都放在同一个分区。

解决副本延迟的方法

  1. 看需求。看是否有这方面的需求。
  2. 事务。分布式系统中的事务是个复杂的话题;以后会讲。

(TO BE CONTINUED…)