DDIA读书笔记-第五章

143 阅读23分钟

概要

复制冗余是指多台机器保存相同数据的副本。好处

  • 降低访问延迟,地理位置上更接近用户
  • 提高可用性,部分组件故障,整体依旧可用
  • 提升吞吐量,水平扩展,多台机器同时提供服务

如果数据**只读,**那么复制很简单。但现实是数据是持续变更的,流行的复制数据变化的方法有:

  • 主从(单主)
  • 多主
  • 无主

其他考虑事项:

  • 同步复制还是异步复制
  • 如何处理失败副本

主从

每个保存完整数据集的节点称之为副本。当多副本时,如何保证多副本的数据一致性?

对于每笔写入,所有副本都要随之更新。最常见的方案是主从复制

工作原理

  1. 指定一个副本为主节点,客户端写入数据时,必须请求主节点,主节点将数据写入本地
  2. 其他副本为从节点,主节点写入数据后,将数据更改以日志或者流的形式发送给所有从节点,从节点应用到本地,并保持和主节点相同的写入顺序
  3. 客户端读取数据时,可以从主节点和从节点读取,但写入,只能是主节点

同步复制和异步复制

同步和异步复制的关键区别在于:客户端的请求何时返回

同步复制:等待副本写完成后

异步复制:不等待副本写完成

对比

  1. 同步复制牺牲了部分可用性和响应延迟,需要所有从节点写入成功,并且某些副本故障时,无法完成写入。优点是,保证了副本数据的一致性
  2. 异步复制牺牲了部分一致性,而换来了较低的写入延迟和较高的可用性

实践中,会对可用性和一致性做出取舍,一般有以下几种

  1. 全同步:所有的从副本都同步写入。如果副本数过多,可能性能较差,当然也可以做并行化、流水线化处理。
  2. 半同步:一些副本为同步,另一些副本为异步。
  3. 异步(全异步):所有从副本都异步写入。网络环境比较好的话,可以这么配置

新增副本

如果需要增加副本数提高可用性,或者替换失败的副本,如何继续保证一致性呢?

如果原副本是只读的,只需要简单拷贝即可。但是如果是可写副本,则问题要复杂很多。因此,比较简单的一种解决方法是:禁止写入,然后拷贝。这在某些情况下很有用,比如夜间没有写入流量(停机迁移?)。

不停机如何操作?

  1. 主节点生成本地一致性快照
  2. 快照拷贝到新的从节点
  3. 从节点拉取快照时间点之后的主节点的数据变更日志并应用,通过日志序列号
  4. 从节点追赶上主节点进度后,可以正常跟随

节点失效

系统中每个节点都可能宕机失效,如何应对这种失效?

从节点失效:追赶式恢复

类似于新增节点,根据日志拉取主节点快照+日志,或者缺失少,只拉取日志即可

主节点失效:节点切换(故障转移)

相对麻烦:需要选主,并且通知客户端新的主节点。可手动,可自动,自动的步骤一般如下

  • 确认主节点失效。一般是心跳探活,超过超时时间,认为失效
  • 选举新的主节点。可以通过共识或者外部指定
  • 重新配置系统使主节点失效。客户端写入请求后续发送给新的主节点,并且需要避免脑裂问题。

切换过程可能的问题:

  • 新老节点数据冲突:比如异步复制,新的主节点未同步完所有日志,旧的主节点恢复后,发现和新的主节点数据冲突,如何处理?常见的方案是,旧的主节点上未同步完的数据,丢弃,但违背了一定程度的持久性
  • 导致相关的其他系统冲突:书里举得例子github的mysql和redis数据不一致
  • 脑裂:多个节点认为自己是主节点。
  • 超时时间的阈值选取:如果选取的过小,在不稳定的网络环境中(或者主副本负载过高)可能会造成主副本频繁的切换;如果选取过大,则不能及时进行故障切换,且恢复时间也增长,从而造成服务长时间不可用

这些问题,没有银弹,手动和自动切换,有的运维,初期手动切换可能效率更高。

实际场景中,节点失效,网络分区,副本一致性,持久性,可用性各种细微的权衡,才是分布式系统的核心问题。

日志复制

主从复制的日志复制如何实现的呢?

基于语句

主节点记录操作语句(INSERT、UPDATE、DELETE)作为日志转发给从节点,从节点应用。

存在一些缺点:

  • 非确定性的函数会导致不一致,如NOW()获取当前时间,RAND()获取随机值
  • 自增值或依赖现有数据,必须按照相同的顺序执行,并发事务时,顺序可能无法保证,导致不一致
  • 副作用(触发器、存储过程、UDF)的语句,可能不同副本由于上下文不同,产生的副作用不一样。除非副作用是确定的输出

当然也有解决方案:

  • 非确定性函数替换为确定的结果

但边界条件太多,主流首选其他复制方案。

基于传输预写日志(WAL)

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

  1. 对于日志流派(LSM-Tree,如 LevelDB),每次修改先写入 log 文件,防止写入 MemTable 中的数据丢失。
  2. 对于原地更新流派(B+ Tree),每次修改先写入 WAL,以进行崩溃恢复。

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

  1. 追加写入
  2. 可重放

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

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

  1. 如果允许旧版本代码给新版本代码(应该会自然做到后向兼容)发送日志(前向兼容)。则在升级时可以先升级从库,再切换升级主库。
  2. 否则,只能进行停机升级软件版本。

基于逻辑日志(行)

为了与存储引擎解耦,可以采用基于逻辑日志(行)

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

  1. 对于插入行:日志需包含所有列值。
  2. 对于删除行:日志需要包含待删除行标识,可以是主键,也可以是其他任何可以唯一标识行的信息。
  3. 对于更新行:日志需要包含待更新行的标志,以及所有列值(至少是要更新的列值)

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

使用逻辑日志的好处有:

  1. 方便新旧版本的代码兼容,更好的进行滚动升级。
  2. 允许不同副本使用不同的存储引擎。
  3. 允许导出变动做各种变换。如导出到数据仓库进行离线分析、建立索引、增加缓存等等。

基于触发器

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

但有些情况下,可能需要用户决策,如何对数据进行复制,这就要在应用层处理:

  1. 对需要复制的数据进行过滤,只复制一个子集。
  2. 将数据从一种数据库复制到另外一种数据库。

有些数据库如 Oracle 会提供一些工具。但对于另外一些数据库,可以使用触发器和存储过程。即,将用户代码 hook 到数据库中去执行。

基于触发器的复制,性能较差且更易出错;但是给了用户更多的灵活性。

复制滞后

主从复制使得可以采用读写分离,使用更多的从节点只读来分摊更大的读流量,但是这往往意味着需要异步复制,如果同步复制,某些节点容易故障进而阻塞写入。

但是异步复制,就会产生不一致问题:某些节点的数据落后于主节点。

虽然这是一种暂时的状态,在停写后,一段时间后,数据最终会一致(最终一致性)。

实际中,网络通常较快,复制滞后在ms级别。但极端情况下,滞后可能达几秒甚至分钟

对于这种最终一致性的系统,要考虑到由于复制滞后带来的实际问题。

读自己的写

对于异步复制,可能发生,用户的写入(主节点)再次查询时(未同步的从节点)查询不到,用户感觉数据丢失了。对于这种情况,需要保证读写一致性

主从如何实现读写一致性?可行的方案:

  • 按内容分类:对于客户端可能修改的内容,只从主节点读取。如社交网络上的个人资料,读自己的资料时,从主节点读取;但读其他人资料时,可以向从节点读。
  • 按时间分类:如果大部分内容都是可修改的,那么内容分类无法奏效,也失去了读操作的扩展性。此时可以设定一个时间阈值(比如1分钟),阈值内的时间都从主节点读取,其他的从从节点读取。对于阈值,可以监控从副本一段时间内的最大延迟这个经验值,来设置。
  • 按时间戳:客户端记下本客户端上次改动时的时间戳,在读从节点时,利用此时间戳来看某个从节点是否已经同步了该时间戳之前的内容。可以在所有节点中找到一个已同步了的;或者阻塞等待某个节点同步到该时间戳后再读取。时间戳可以是逻辑时间戳,也可以是物理时间戳(此时多机时钟同步非常重要)。

实际场景的case更复杂:

  1. 数据分布在多个物理中心。所有需要发送给主副本的请求都要首先路由到主副本所在的数据中心。
  2. 一个逻辑用户有多个物理客户端,导致利用时间戳实现比较困难。比如一个用户通过电脑、手机多终端同时访问,此时就不能用设备 id,而需要使用用户 id,来保证用户角度的读写一致性。但不同设备有不同物理时间戳,不同设备访问时可能会路由到不同数据中心。因此,必须想办法将不同设备的请求,路由到同一个数据中心。

单调读

用户读取到复制较快的从节点看到了最新数据,再次读取时读取到复制较慢的从节点,导致最新数据像丢失了一样,时光倒流一般。

需要单调读一致性保证

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

如何实现单调读?

  1. 只从一个副本读数据。
  2. 前面提到的时间戳机制。

一致性前缀读

具有因果关系的多条写入,可能会写入不同的分区,不同分区的复制滞后不同,导致原有的写入顺序在读取时,无法保证,可能会出现果在前,因在后

引入了一种一致性:一致前缀读(consistent prefix reads)。奇怪的名字。

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

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

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

复制滞后的终极解决方案

事务!

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

  • 这是为什么?
  1. 更容易的实现。2. 更好的性能。3. 更好的可用性。

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

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

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

多主

主从(单主)模型最大的问题:主的单点故障。此时自然想到,多主是否可行?

多主复制:有多个可以接受写入的主节点,每个主节点在接收到写入之后,都要转给所有其他主节点。即一个系统,有多个写入点

适用场景

单个数据中心,多主模型意义不大:复杂度超过了收益。总体而言,由于一致性等问题,多主模型应用场景较少,但有一些场景,很适合多主:

  1. 数据库横跨多个数据中心
  2. 需要离线工作的客户端
  3. 协同编辑

处理写冲突

同步与异步冲突检测

对于主从模型,第二个写请求会被阻塞到第一个写入成功,或者被中止(用户必须重试)。对于多主,两个写请求都会成功,并且在稍后的时间点上才能检测到冲突。

理论上可以同步检测冲突,等待写请求完成所有节点的同步,但会失去多主的优势:允许每个主独立接受写入请求。

避免冲突

解决冲突的最好办法是避免冲突:应用层保证特定记录的写入总是通过同一个主节点。比如一个用户的请求总是路由到同一个主节点(地理上就近选择)。不同的用户对应不同的主节点。

但如果:

  1. 用户从一个地点迁移到了另一个地点
  2. 某个数据中心损坏,导致路由变化

就会对该设计提出一些挑战。

收敛冲突

单主模型中,所有事件比较容易进行定序,因此我们总可以用后一个写入覆盖前一个写入。

但在多主模型中,很多冲突无法定序:从每个主副本来看,事件顺序是不一致的,并且没有哪个更权威一些,那么就无法让所有副本最终收敛

此时,我们就需要一些规则,来让其收敛:

  1. 给每个写入一个序号,并且后者胜。本质上是使用外部系统对所有事件进行定序。但可能会产生数据丢失。举个例子,对于一个账户,原有 10 元,客户端 A - 8,客户端 B - 3,任何一个单独成功都有问题。
  2. 给每个副本一个序号,序号更高的副本有更高的优先级。这也会造成低序号副本的数据丢失。
  3. 提供一种自动的合并冲突的方式。如,假设结果是字符串,则可以将其排序后,使用连接符进行链接,如在之前 Wiki 的冲突中,合并后的标题为“B/C”
  4. 使用程序定制一种保留所有冲突值信息的冲突解决策略。也可以将这个定制权,交给用户。

自定义解决

只有用户知道数据本身的信息,最合适的方式还是将如何解决冲突转交给用户。

许用户编写回调代码,提供冲突解决逻。该回调可以在:

  1. 写时执行。在写入时发现冲突,调用回调代码,解决冲突后写入。这些代码通常在后台执行,并且不能阻塞,因此不能在调用时同步的通知用户。但打个日志之类的还是可以的。
  2. 读时执行。在写入冲突时,所有冲突都会被保留(如使用多版本)。下次读取时,系统会将所有数据本版本返回给用户,进行交互式的或者自动的解决冲突,并将结果写回系统。

什么是冲突

有些冲突显而易见:并发写同一个 Key。

有些冲突则更隐晦,考虑一个会议室预定系统。预定同一个会议室不一定会发生冲突,只有预定时间段有交叠,才会有冲突

拓扑结构

复制的拓扑结构描述了写请求从一个节点传播到其他节点的通信路径。

多主可能的拓扑结构

  1. 环形拓扑。通信跳数少,但是在转发时需要带上拓扑中前驱节点信息。如果一个节点故障,则可能中断复制链路。
  2. 星型拓扑。一个指定的中心节点负责接受并转发数据。如果中心节点故障,则会使得整个拓扑瘫痪。
  3. 全连接拓扑。每个主库都要把数据发给剩余主库。通信链路冗余度较高,能较好的容错。

环形拓扑和星型拓扑,为了防止广播风暴导致无限广播,需要对每个节点打上一个唯一标志(ID),在收到他人发来的自己的数据时,及时丢弃并终止传播。

全连接拓扑的问题是:复制链路的速度不一致时,可能带来日志覆盖。


两个有因果依赖的(先插入,后更新)的语句,在复制到 Leader 2 时,由于速度不同,导致其接收到的数据违反了因果一致性。

要想对这些写入事件进行全局排序,仅用每个 Leader 的物理时钟是不够的,因为物理时钟:

  1. 可能不能够充分同步
  2. 同步时可能会发生回退

可以用一种叫做版本向量(version vectors) 的策略,对多个副本的事件进行排序,解决因果一致性问题

如果使用多主模型,需要详细阅读文档,充分测试,确保这些可能的问题不会影响期望得到的功能。

无主

主从和多主都是基于这种思想,客户端将写请求发送到某个主节点,然后主节点同步到从节点。由主节点决定写入顺序,从节点按相同顺序应用日志。

无主就是放弃主节点,任何节点都可以接受客户端的写请求。

通常来说,在无主模型中,写入时可以:

  1. 由客户端直接写入节点。
  2. 协调者(coordinator) 接收写入,转发给其余节点。但与主节点不同,协调者并不负责维护写入顺序

节点失效时的写入

基于主节点的模型,在主节点故障时,需要进行故障转移。

无主模型中,写入时忽略故障节点即可。但是因此,读取时,需要读取多个节点,确定最新版本的值。

总结就是,多数派写入读修复

读修复与反熵

节点宕机时,可能错过了一些写入请求,如何保证宕机节点重新上线时,赶上中间错过的写请求呢?

两种机制处理:

  • 读修复

  • 本质上是一种捎带修复,在读取时发现旧的就顺手修了。

  • 反熵

  • 后台进程,持续进行扫描,寻找陈旧数据,然后更新。本质上是兜底修复

读写quorum

如果节点总数为 n,写入 w 个节点才认定写入成功,并且在查询时最少需要读取 r 个节点。只要满足 w + r > n,我们就能读到最新的数据(鸽巢原理)。此时 r 和 w 的值称为 quorum 读写。即这个约束是保证数据有效所需的最低有效票数。

n、r 和 w 通常是可以配置的:

  1. n 越大冗余度就越高,也就越可靠。
  2. r 和 w 都常都选择超过半数,如 (n+1)/2
  3. w = n 时,可以让 r = 1。此时是牺牲写入性能换来读取性能。

考量满足 w+r > n 系统对节点故障的容忍性:

  1. 如果 w < n,则有节点不可用时,仍然能正常写入。
  2. 如果 r < n,则有节点不可用时,仍然能正常读取。

特化一下:

  1. 如果 n = 3,r = w = 2,则系统可以容忍最多一个节点宕机。
  2. 如果 n = 5,r = w = 3,则系统可以容忍最多两个节点宕机。

通常来说,我们会将读或者写并行的发到全部 n 个节点,但是只要等到法定个节点的结果,就可以返回。

如果由于某种原因,可用节点数少于 r 或者 w,则读取或者写入就会出错。

quorum一致性的局限

由于 w + r > n 时,总会至少有一个节点(读写子集至少有一个节点的交集)保存了最新的数据,因此总是期望能读到最新的。

当 w + r ≤ n 时,则很可能会读到过期的数据。

但在 w + r > n 时,有一些边角情况(corner case),也会导致客户端读不到最新数据:

  1. 使用宽松的 Quorum 时(n 台机器范围可以发生变化),w 和 r 可能并没有交集。
  2. 对于写入并发,如果处理冲突不当时。比如使用 last-win 策略,根据本地时间戳挑选时,可能由于时钟偏差造成数据丢失。
  3. 对于读写并发,写操作仅在部分节点成功就被读取,此时不能确定应当返回新值还是旧值。
  4. 如果写入节点数 < w 导致写入失败,但并没有对数据进行回滚时,客户端读取时,仍然会读到旧的数据。
  5. 虽然写入时,成功节点数 > w,但中间有故障造成了一些副本宕机,导致成功副本数 < w,则在读取时可能会出现问题。
  6. 即使都正常工作,也有可能出现一些关于时序(timing)的边角情况。

因此,虽然 Quorum 读写看起来能够保证返回最新值,但在工程实践中,有很多细节需要处理。

之前复制滞后小节引入的几个一致性保障(写后读,单调读,一致性前缀读)仅用quorum无法得到保障,需要引入事务和共识。

一致性监控

即便应用可以容忍旧值,但是一致性的监控仍有必要,可以及时了解健康状态,排查原因(网络问题或者其他)

有主节点的模型来说,监控好做,因为主从都遵循同样的写入顺序,并且有日志偏移量可以对比。

但是无主模型,没有固定写入顺序,节点的落后进度难以界定,监控困难。但是可以根据w,r,n来预测读到旧值的期望百分比,毕竟这是最终一致性,实际工程上,可以量化何为“最终”,就可以根据不同的应用,带来不同的选择了,所以依旧很有实际意义。

宽松的quorum和数据回传

正常的 Quorum 能够容忍一些副本节点的宕机。但在大型集群(总节点数目 > n)中,可能最初选中的 n 台机器,由于种种原因(宕机、网络问题),导致无法达到法定读写数目,则此时有两种选择:

  1. 直接拒绝:对于所有无法达到 r 或 w 个法定数目的读写,直接报错。
  2. 宽松的仲裁:仍然接受写入,并且将新的写入暂时交给一些在n之外的正常节点。

一旦问题得到解决,临时的节点需要将接受的写入请求发送到原始的节点上,即**数据回传。**通常有反熵进程(后台进程)完成。

书中的例子是,你找不到家里的钥匙了,在邻居家沙发暂住,找到钥匙后,邻居后会让你离开他家,回自己家。

总结,只要任意w个节点可用,均可以接受写入。但即便w+r>n,也未必保证一定读到最新值。

这是什么?牺牲一致性,换区更高的可用性。这就是trade off的艺术!

多数据中心

无主模型也适用于系统多数据中心部署。

为了同时兼顾多数据中心写入的低延迟,有一些不同的基于无主模型的多数据中心的策略:

  1. 其中 Cassandra 和 Voldemort 将 n 配置到所有数据中心,但写入时只等待本数据中心副本完成就可以返回。
  2. Riak 将 n 限制在一个数据中心内,因此所有客户端到存储节点的通信可以限制到单个数据中心内,而数据复制在后台异步进行。

并发写入检测

并发写入时,写入和读取均难以保证顺序。

为达成最终一致,需要手段处理并发冲突。

最后写入者胜

每个节点保存最新值,但是可能覆盖并丢弃旧值

后者胜(LWW,last write wins)的策略是,通过某种手段确定一种全局唯一的顺序,然后让后面的修改覆盖之前的修改。

如,为所有写入附加一个全局时间戳,如果对于某个 key 的写入有冲突,可以挑选具有最大时间戳的数据保留,并丢弃较早时间戳的写入。

LWW 有一个问题,就是多个并发写入的客户端,可能都认为自己成功了,但是最终只有一个值被保留了,其他都在后台被丢弃了。即,其迅速再读,会发现不是自己写入的数据。

使用 LWW 唯一安全的方法是:key 是一次可写,后变为只读。如 Cassandra 建议使用一个 UUID 作为主键,则每个写操作都只会有一个唯一的键。

happens-before和并发

系统中任意的两个写入 A 和 B,只可能存在三种关系:

  1. A happens before B
  2. B happens before A
  3. A B 并发

如果两个操作可以定序,则 last write win;如果两个操作并发,则需要进行冲突解决。

确定happens-before关系

书中以购物车为例

注意:

  1. 不会主动读取,只有主动写入,通过写入的返回值读取数据库当前状态。
  2. 客户端下一次写入,依赖于(因果关系)本客户端上一次写入后获取的返回值。
  3. 对于并发,数据库不会覆盖,而是保留多个并发值(每个 client 一个)。

数据流如下:

总结下,该算法如下:

  1. 服务器为每个键分配一个版本号 V,每次该键有写入时,将 V + 1,并将版本号与写入的值一块保存。
  2. 当客户端读取该键时,服务器将返回所有未被覆盖的值以及最新的版本号。
  3. 客户端在进行下次写入时,必须包含之前读到的版本号 Vx(说明基于哪个版本进行新的写入),并将读取的值合并到一块。
  4. 当服务器收到特定版本号 Vx 的写入时,可以用其值覆盖所有 V ≤ Vx 的值。

如果又来一个新的写入,不基于任何版本号,则该写入不会覆盖任何内容。

合并并发值

该算法可以保证所有数据都不会被无声的丢弃。但,需要客户端在随后写入时合并之前的值来清理多个值。如果简单基于时间戳进行 LWW,则有些数据又会被丢掉。

因此需要根据实际情况,选择一些策略来解决冲突,合并数据。

  1. 对于上述购物车中只增加物品的例子,可以使用“并集”来合并冲突数据。
  2. 如果购物车汇总还有删除操作,就不能简单并了,但是可以将删除变为增加(写一个墓碑标记)。
版本矢量

上述算法是但副本情况,如果是多副本且无主的呢?

这时需要给每个副本的键都引入版本号,对于同一个键来说,不同副本的版本号集合称为版本向量(version vector)

每个副本在遇到写入时,会增加对应键的版本号,同时跟踪从其他副本中看到的版本号,通过比较版本号大小,来决定哪些值要覆盖哪些值要保留。