DDIA-数据复制

Posted by keys961 on January 31, 2019

1. 主从复制

工作原理

  1. 指定某个副本为主副本(主节点),写请求先发送给主副本,并写入本地存储
  2. 其它副本为从副本(从节点),主副本写入存储后,将数据更改发给所有从副本,从副本将其应用到本地,顺序与主副本严格一致
  3. 客户端可从主和从副本读,但只能向主副本写

1.1. 同步/异步复制

同步复制:一旦向用户确认,从节点保证完成了与主节点的更新同步;但从节点无法完成确认,写入不能成功,引起阻塞。(通常只开启一个节点的同步,其它是异步的)

异步复制:主节点奔溃则导致未复制的请求丢失,即使向用户确认了写操作(复制滞后)。但系统吞吐性更好。

1.2. 配置新的从节点

在不锁定数据库条件下(不违反高可用),可以:

  1. 某个时间点对主节点的副本生成一个一致性快照
  2. 将快照拷贝到新的从节点
  3. 从节点向主节点请求快照点之后所发生的数据更改日志
  4. 获取日志后,应用快照点后的变更(即追赶复制),之后可以继续处理主节点的新的变化

1.3. 处理节点失效

1.3.1. 从节点失效——追赶复制

根据副本日志,从节点直到故障前最后一个事务,然后请求主节点之后的变更,应用变更来追赶主节点。

1.3.2. 主节点失效——节点切换

从某个从节点中选择一个作为主节点,客户端也需要更新。

自动切换的流程通常为:

  1. 确认主节点失效,通常使用超时机制(心跳)
  2. 选举新的主节点,可通过选举达成共识或之前主节点指定的方式
  3. 重新配置使新的主节点生效,并更新客户端

但仍然充满变数:异步复制的数据丢失/不一致,脑裂,如何检测失效等

1.4. 复制日志的实现

1.4.1. 基于语句的复制

最简单的情况,主节点把所有请求的语句作为日志直接发给从节点。

但语句对系统有副作用的时候,并不合适。

1.4.2. 基于预写日志(WAL)传输

对于大多数事务数据库,会有预写日志,直接把日志发给从节点,根据日志可让从节点构建相同的副本。

缺点:WAL描述数据过于底层,导致复制方案和存储引擎紧耦合(升级的时候会有麻烦)。

1.4.3. 基于行的逻辑日志复制

关系日志的逻辑日志:包含一系列记录,描述数据表行级别的写请求

  • 行插入:日志包含所有相关列的值
  • 行删除:若有唯一标识(通常为主键),则使用唯一标识;否则记录所有列的旧值
  • 行更新:对应行的唯一标识以及所有列/已更新列的新值

它和存储引擎解耦,容易保持向后兼容,且易于解析。

1.4.4. 基于触发器的复制

将复制交给应用上层触发,更加有灵活性,但开销更大,且易出错。

2. 复制滞后

最终一致性:(异步)主从复制下,从节点会滞后于主节点,读请求在不同节点上的结果会暂时的不同,但最终会一致。

滞后时间过长,会出现很多的问题。

2.1. Read-After-Write/Read-Your-Write

Q:客户端写入数据后,在从节点上读不到数据。

A:实现Read-after-write/Read-your-write(两个是一样的)一致性,保证某个用户重复读,总能看到自己最近提交的更新,但对其它用户不保证。

实现:

  • 若用户访问可能修改的内容,从主节点读取;否则,可从从节点读取
  • 跟踪最新时间,在一段时间内只能从主节点读取;监控复制滞后程度,避免从滞后时间过长的从节点上读取
  • 客户端可记录最近的更新时间戳(逻辑或实际的),附带到请求中,系统可确保提供读服务的节点上至少包含该时间戳的更新
  • 若副本分布在多个数据中心,必须把请求路由到主节点所在的数据中心

2.2. Monotonic Reads

Q:客户端写入数据,先向从节点1读,读到数据,而再向从节点2读,读不到数据(由于滞后),似乎被回滚

A:实现Monotonic reads一致性,某个用户多次读取,永远读不到比之前读到的数据更旧的值

实现:确保每个用户总在一个固定的副本上读取(不同用户可在不同的副本上读取),如基于用户ID进行散列

Monotonic Write: 服务端(所有相关节点)和客户端的写顺序必须相同

2.3. Consistent Prefix Reads

Q:不同用户的写入(按顺序)$W_1, W_2$分布到不同分区,由于复制滞后,观察者从不同分区的从节点读取内容,观察到的顺序可能相反,即先读到$W_2$,然后才是$W_1$(在有因果顺序的时候,往往是错的)

A:实现Consistent prefix reads一致性,即保证:对于一系列按照某个顺序发生的写请求,读取这些内容的时候也要按照写入的顺序

实现:

  • 有因果或其它顺序要求的写入,都交给一个分区(效率低)
  • 利用算法追踪因果关系(后面叙述)

2.4. 复制滞后的解决方案

在最终一致性系统中,若滞后来带严重的问题时,可考虑:

  • 使用更强的一致性,如Read-after-write
  • 使用分布式事务

3. 多主复制

主从复制的缺点:写只能经过一个主节点,主节点挂掉会对写入造成巨大影响

多主复制:多个主节点可以接收写操作,后面的操作类似(可能还需要冲突解决的模块),而主节点可作为其它节点的从节点

3.1. 适用场景

3.1.1. 多数据中心

多主复制和主从复制的区别:

  多主复制 主从复制
性能 写请求可路由到最近的数据中心,延迟小 写请求必须路由到主节点的数据中心,延迟大
容忍数据中心失效 每个数据中心独立运行,故障的数据中心恢复后即可更新到最新状态 必须切换数据中心,将一个从节点提升为主节点,然后恢复
容忍网络问题 异步复制采用的多,依赖网络小 同步复制采用的多,更依赖网络

而缺点有:

  • 多主产生写入冲突(最大的问题)
  • 与其它数据库功能交互时会有副作用

3.1.2. 离线客户端操作

当终端与网络断开时,也得可用。这时终端充当主节点,然后在所有终端上异步同步这些多个主节点的副本。协作编辑也可以是一个类似的例子。

它们都会遇到写入冲突的问题。

3.2. 处理写入冲突

3.2.1. 避免冲突

最简单的策略,即应用层让特定请求总是通过一个主节点。(最极端的情况即主从复制)

3.2.2. 收敛于一致状态

数据库在多主节点上必须以一种收敛于趋同的方式解决冲突,即更改被复制、同步后,所有副本最终值一样。方式可以有:

  • 给请求赋上ID(如时间戳),挑选ID最大的写入,其它丢弃。(时间戳下,为最后写入者获胜。数据易丢失,缓存系统中适用)
  • 每个副本赋上唯一ID,制定规则规定写入顺序(如ID高先写,但数据易丢失)
  • 以某种方式将这些值合并
  • 记录和保留冲突信息,依靠上层逻辑解决冲突

3.2.3. 自定义冲突解决逻辑(依赖上层)

最好的方式。大多数多主复制模型都提供这种方式,可在:

  • 写入时执行:写入时,根据日志和规则解决冲突,通常在后台进行,不能提醒用户
  • 读取时执行:读取时,所有的冲突值(前提要保留所有的冲突值)返回给应用层,让应用层决定,最后将结果返回到数据库

3.2.4. 自动冲突解决

如通过:

  • 无冲突的复制数据类型(CRDT),可以由多个用户同时编辑,并内置合理的方式解决冲突
  • 可合并的持久数据结构。类似于git,跟踪变更历史,提出三向合并(CRDT是双向合并)
  • 操作转换,一种冲突解决算法

3.3. 拓扑结构

复制的拓扑结构描述了一个写请求传播到其它节点的路径,包括:环形星型全部-全部型

前两种,当路径上某个节点失效,会影响其它节点的复制转发。因此,需要有多条路径可选。

最后一种,有网络开销大以及复制日志的覆盖(顺序问题,覆盖了前面的更改)问题。

4. 无主复制

允许任何副本直接接收客户端的写入。

实现方式:

  • 客户端直接将写入(并行)发送到多副本
  • 有一个协调者代表客户端进行写入,但不维护写入顺序

4.1. 节点失效后的写入

写入:并行向所有副本写入,成功比例达到一定值后即可认为成功

读取:并行向所有副本发送读请求,采用最新版本号的数据

这样较少节点失效时,也能读写新数据

4.1.1 读修复 & 反熵 —— 失效节点再上线后的追赶

读修复:读取频繁场景下,客户端读取到多个副本,取最新,并将新值写入到旧副本上

反熵:后台进程不断查找不同副本之间的差异,将缺少的数据从一个副本复制到另一个副本,但有明显的同步滞后

4.1.2. 读写quorum(严格)

假定总共有$n$个副本,写入至少需要$w$个节点(从那$n$个节点中)的确认,读至少查询$r$个节点(从那$n$个节点中)并得到结果,则只需要$w + r > n$,就能保证读取一定包含最新值。

这里读写操作必须作用到设定好的$n$个副本/节点上

以上为(严格)法定票数读写,若不满足票数条件,客户端可直接返回错误,提示集群不可用。

通常$n$为奇数,$w = r = \frac {(n + 1)}{2}$

4.2. Quorum一致性和局限性

$w + r > n$下也可能返回旧值,情况可能为(通常在最终一致性情况下):

  • 若采用sloppy quorum,写操作$w$与读操作$r$可能不重叠,造成读取旧值
  • 两个写同时发送,无法明确顺序,唯一安全的方法是合并(机器时间戳不行)
  • 写和读同时发生,写可能只在一部分副本完成,读取的内容不确定(实际的$w’ < w$并和$r$不重合)
  • 和上条类似,若一部分写入成功,一部分写入失败,总数小于$w$,成功副本上不回滚,则读可能是新值也可能是旧值
  • 若具有新值节点失效,恢复时来自旧值(这是可能的,如通过读恢复),则总的新值副本会小于$w$,打破了判定条件
  • 一切正常情况下也会有边界情况

4.2.1. 监控旧值

主从复制:数据库导出复制滞后的指标(可通过日志执行偏移量),集成到监控模块

无主复制:由于没有固定的写入顺序(写入到不同副本的),监控更加困难,若只支持读时修复,则旧值的落后程度没有上限。因此没有很普及。

4.2.2. 宽松quorum和数据回传

宽松的读写仲裁:读写仍然满足$w + r > n$条件,但是读写操作包含了并不在之前指定的$n$个节点(即将请求暂存到另一个不在$n$个指定节点集合的节点)。

它大大提高写可用性(只要有$w$个节点可用即可),但在$w + r > n$下不能保证读到新值(新值可能在$n$个节点之外的某个节点),因此需要数据回传,将新值传回$n$个指定节点(回传未结束,就不保证读到新值)。

4.2.3. 多数据中心

无主复制也可以应用到多个数据中心的场景。

4.3. 检测并发写

4.3.1. Happens-Before关系和并发

并发:两个操作都不在(或不知道在)另一个操作前发生

若$A$操作与$B$操作有先后关系,则使用覆盖;否则$A$与$B$为并发,需要解决冲突,见3.2节

4.3.2. 确定先后关系

主要依赖版本号,算法工作流程为:

  1. 服务器为每个主键维护一个版本号$v$,每次写入新值,$v = v + 1$, 并将其保存

  2. 客户端读取时,服务器返回所有当前值以及最新版本号$v$,且要求客户端写之前,必须先读

  3. 客户端写入时,请求包含之前的版本号$v$,读到的值和新值的合并值$V_r \cup V_{new}$。响应可像读操作一样,返回所有当前值

    若无版本号,则认为写请求与其它写请求并发。传入值会在后续读请求的返回值中,用于合并并发写入

  4. 服务器受到特定版本号的写入时,覆盖该版本号以及更低版本的所有值,但必须保留更高版本号的所有值

上述算法可保证数据丢失,但是并发操作时,客户端必须合并并发写入,以继承旧值(因此可设计专门的数据结构执行合并,如CRDT)

对于删除操作,删除操作需要添加一个对应的版本号,作为标记,表明这个删除是否在合并时被应用上去(即墓碑)

4.3.3. 版本矢量

无主复制的多副本场景下,每个副本都要创建一个版本矢量(类似于矢量时钟,之前的整理中详细说明过),写入时增加自己的版本号,并跟踪其它副本的版本号。通过它决定并发操作时,哪些需要覆盖,哪些需要保留。

和之前单副本一样,读时返回版本矢量,写入时将版本信息稍带到请求中给数据库,但依旧需要执行合并操作,不过不会丢失数据