第五章:复制
可能出于各种各样的原因希望能复制数据
- 使得数据与用户在地理上接近,从而减少延迟
- 系统一部分出现故障也能继续工作,从而提高可用性
- 伸缩可以接收请求的机器数量,从而提高吞吐量
复制的困难之处在于处理复制数据的变更
三种流行的变更复制算法
- 单领导者(single leader,单主)
- 多领导者(multi leader, 多主)
- 无领导者(leaderless, 无主)
复制时需要进行很多权衡
-
使用同步复制还是异步复制
-
如何处理失败的副本
······
领导者和追随者
存储了数据库拷贝的每个节点称为副本(replica)
存在多个副本时,如何确保所有数据都落在所有副本上?
最常见的方案被称为主/从复制(master/slave) (也称为基于领导者的复制(leader-based replication) 、主动/被动复制(active/passive) ),其工作原理如下
- 其中一个副本被指定为 领导者(leader) ,也称为 主库(master|primary) 。当客户端要向数据库写入时,它必须将请求发送给该 领导者,其会将新数据写入其本地存储。
- 其他副本被称为 追随者(followers) ,亦称为 只读副本(read replicas) 、从库(slaves) 、备库( secondaries) 或 热备(hot-standby) 。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为 复制日志(replication log) 或 变更流(change stream) 。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照与领导者相同的处理顺序来进行所有写入。
- 当客户想要从数据库中读取数据时,它可以向领导者或任一追随者进行查询。但只有领导者才能接受写入操作(从客户端的角度来看从库都是只读的)。
同步复制和异步复制
复制系统的一个重要细节是:复制是同步(synchronously) 发生的还是异步(asynchronously) 发生的
(Follower1是同步,Follower2是异步)
同步复制
- 优点是,从库能保证有与主库一致的最新数据副本,如果主库突然失效,我们可以确信这些数据仍然能从库上找到
- 缺点是,如果同步从库没有相应(比如已经崩溃,或者出现网络故障或其它原因),主库就无法处理写入操作,主库必须阻止所有写入,并等待同步副本再次可用
将所有库都设置为同步是不切实际的,通常数据库上启用同步意味着一个从库是同步的,其它从库是异步的,当同步从库变得不可用或缓慢时将一个异步从库改为同步。这保证主库和同步从库上拥有最新的数据。这种配置有时也被称为半同步(semi-synchronous)
通常基于领导者的复制都配置为完全异步
- 缺点是:这种情况下几遍向客户端确认成功写入也不能保证是持久(Durable) 的,因为如果主库失效且不可恢复,尚未复制给从库的写入都会丢失。
- 优点是:及时所有的从库都落后了,主库也可以继续处理写入
链式复制(chain replication) 是同步复制的一种变体,为解决主库故障时的数据丢失而研究
设置新从库
如何保证新的从库拥有主库数据的精确副本?
简单的复制是没有意义的,因为数据库在不断地变化,复制内容中的不同部分可能包含不同时间点的内容,也可以锁定数据库来保持一致,但会违背高可用的目标
理想过程
- 在某个时刻获取主库的一致性快照(如果可能,不必锁定整个数据库)
- 将快照复制到新的从库节点
- 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置(例如MySQL 将其称为 二进制日志坐标(binlog coordinates) )精确关联
- 完成快照之后累计的数据变更,赶上(caught up)主库
处理节点宕机
如何节点都可能宕机,目标是即使个别节点失效,也能保持整个系统运行
从库失效:追赶恢复
从库可以从日志中知道,在发生故障之前处理的最后一个事务
从库可以重新连接到主库,并请求在从库断开期间发生的所有数据变更,并在完成所有这些变更之后赶上主库
主库失效:故障切换
处理起来相当棘手:其中一个库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其它从库需要开始拉取来自新主库的数据变更,这个过程被称为故障切换(failover)
故障切换可以手动进行或自动进行
手动进行:通知管理员主库挂了,采取必要步骤创建新的主库
自动故障切换通常有如下步骤:
- 确认主库失效。大多数系统只是简单的使用超时(timeout) :节点频繁相互传递消息,如果一个节点在一段时间内没有响应就认为它挂了
- 选择一个新的主库:可以通过选举来完成,或者由之前选定的控制器节点来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库。让所有节点同意一个新的领导者是一个共识问题
- 重新配置系统以启用新的主库。客户端需要将它们的写请求发送给新的主库。如果旧主库恢复,系统需要确保旧主库意识到新主库的存在,并成为一个从库
故障切换的过程中很多地方可能出错
- 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。如果旧主库重新加入集群,如何处理这写没有复制的写入。在此期间,新主库可能已经收到了与这部分写入相冲突的写入,最常见的办法是丢弃老主库未复制的写入
- 如果数据库需要和其它外部存储相协调,那么丢弃写入内容是极其危险的操作
- 发生某些故障时可能会出现两个节点都以为自己是主库的情况,称为脑裂(split brain) 。这种情况非常危险,如果两个主库都可以接受写操作,却没有冲突解决机制,那么数据就可能丢失或损坏
- 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长意味着恢复时间越长。但如果超时时间设置太短,又可能出现不必要的故障切换
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换
复制日志的实现
基于语句的复制
最简单的情况下,主库记录下它执行的每个写入请求语句,并将该语句日志发送给从库
问题
- 非确定性函数(nondeterministic) 的语句可能会产生不同的值,例如
NOW() - 如果使用了自增列,或者依赖于数据库中的现有数据,则必须在每个副本上按照完全相同的顺序执行它们,否则可能产生并的结果。当有多个并发执行的事务时,这可能会称称为一个限制
- 有副作用的语句(例如:触发器,用户定义函数,存储过程),可能产生不同的副作用
这些问题可以解决,但由于边缘情况太多,通常选择别的复制方法
传输预写式日志(WAL)
- 对于日志结构存储引擎,日志是主要的存储位置
- 对于覆写单个磁盘块的B树,每次修改都会先写入预写式日志(WAL),以便崩溃后索引可以恢复到一致状态
任何情况下,该日志都包含了所有数据库写入的仅追加字节序列,可以使用完全相同的日志在另一个节点上构建副本
缺点是日志记录的数据非常底层,WAL包含哪块磁盘块中的哪些字节发生了改变。这使复制与存储引擎紧密耦合。通常不可能在主库和从库上运行不同版本的数据库软件。这使得升级时可能需要停机
逻辑日志复制(基于行)
关系型数据库的逻辑日志通常以行的粒度来描述对数据库表的写入记录的序列:
- 对于插入行,日志包含所有列的新值
- 对于删除的行,日志包含足够的信息来唯一表示被删除的行
- 对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值
修改多行的事务会生成多条这样的日志记录,后面跟着一条指明事务已经提交的记录
由于逻辑日志与存储引擎解耦,系统可以更容易向后兼容,主库和从库能够运行不同版本的数据库,或者不同的存储引擎
对外部应用程序来说,逻辑日志格式更容易解析,更容易实现 数据变更捕获(change data capture)
基于触发器的复制
触发器允许你将数据更改发生时自动执行的自定义程序代码注册在数据库系统中。这有机会将更改记录到一个单独的表中去,使用外部程序读取这个表加上一些业务逻辑就可以将数据变更复制到另一个系统中去
优点是灵活性更高,缺点是开销更高,且更容易出错
复制延迟问题
应用程序从异步从库读取时,如果从库落后,可能会看到过时的信息。同时对主库和从库进行查询结果可能不一致。如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致,这种效应称为最终一致性(eventual consistency)
复制延迟(relication lag) ,即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒。但如果系统接近极限的情况,或网络中存在问题,延迟可以轻而易举地超过几秒,甚至达到几分钟
复制延迟时可能发生一些问题
读己之写
对于异步复制,如果在写入之后马上查看数据,则新数据可能尚未到达副本。对于用户而言,看起来好像是刚提交的数据丢失了
在这种情况下,我们需要写后读一致性(read-after-write consistency) ,也称为读己之写一致性(read-your-writers consistency) 。这是一个保证,保证用户重新加载页面,他们总会看到他们提交的任何更新。它不会对其他用户的写作出承诺,其他用户的更新可能仍要等待一段时间才能看到。它保证用户自己的输入已被正确保存
实现的技术
- 对于用户可能修改过的内容,总是从主库读取;这需要有办法不通过实际的查询就可以知道用户是否修改了东西。例如社交网络上用户只能修改自己的资料:总是从主库读取紫的档案,从从库读取其它用户的档案
- 如果大部分内容都必须从主库读取,可以更换其它标准,例如更新过后的一分钟内从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询
- 客户端可以记住最近一次写入的时间戳,系统需要确保从库处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中,如果不够新,旧换一个从库,或者等待从库赶上主库
- 如果你的副本分布在多个数据中心,还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心
另一种复杂性是可能需要提供跨设备的写后读一致性:如果用户在一个设备上输入了一些信息,然后在另一个设备上查看,则应该看到他们刚输入的信息
这会导致一些额外的问题
- 记住用户上次更新时间的方法变得困难,因为不知道另一个设备上发生了什么,需要对这些元数据进行中心化的存储
- 如果副本分布在不同的数据中心,很难保证来自不同设备的连接会路由到同一数据中心。如果使用的方法需要读主库,可能首先需要把来自该用户所有设备的请求都路由到同一个数据中心
单调读
......
处理写入冲突
多主复制的最大问题是可能发生写冲突
同步与异步冲突检测
- 单主数据库中,第二个写入将被阻塞并等待第一个写入完成,或者中止第二个写入事务并强制用户重试
- 多主配置中,两个写入都是成功的,在稍后的某个时间点才能异步地检测到冲突
冲突检测同步将失去多主复制允许每个副本独立地接受写入的优点
避免冲突
处理冲突最简单的策略就是避免冲突,如果应用程序可以确保特定记录的所有写入都通过同一个主库,那么冲突就不会发生
- 确保来自特定用户的请求始终路由到同一数据中心,使用该数据中心进行读写
- 不同用户可能有不同的“主”数据中心,但从单一用户的角度来看,本上就是单主配置
- 有时需要更改被指定的主库,这时需要处理不同主库同时写入的可能性
收敛至一致的状态
- 单主数据库按顺序进行写入:最后一个写操作决定字段的最终值
- 多主配置:没有明确的写入顺序,最终值应该是什么并不清楚
数据必须以一种收敛(convergent) 的方式解决冲突,所有副本必须在所有变更复制完成时收敛至一个相同的值
途径:
- 最后写入胜利(LWW, last write wins) :给每个写入一个唯一ID,挑选最高ID的写入作为胜者。虽然流行,但会造成数据丢失
- 每个副本分配一个唯一ID,ID编号更高副本的写入具有更高的优先级。也会有数据丢失
- 以某种方式将这些值合并在一起
- 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码
自定义冲突解决逻辑
应用程序代码编写解决冲突逻辑,代码可以在写入或读取时执行
- 写时执行: 只要数据库系统检测到复制更改日志存在冲突,就会调用冲突处理程序
- 读时执行: 当检测到冲突时,所有冲突写入被存储。下一次读取时,会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突,并将结果写会数据库
冲突解决通常适用于单行记录或单个文档的层面,而不是整个事务,如果有一个事务会原子性地进行几次不同的写入
一些研究来自动解决由于数据修改引起的冲突:
- 无冲突复制数据类型(Conflict-free replicated datatypes,CRDT) 是可以由多个用户同时编辑的集合、映射、有序列表、计数器等一系列数据结构,它们以合理的方式自动解决冲突。一些 CRDT 已经在 Riak 2.0 中实现。
- 可合并的持久数据结构(Mergeable persistent data structures) 显式跟踪历史记录,类似于 Git 版本控制系统,并使用三向合并功能(而 CRDT 使用双向合并)。
- 操作转换(operational transformation) 是 Etherpad 和 Google Docs 等协同编辑应用背后的冲突解决算法。它是专为有序列表的并发编辑而设计的,例如构成文本文档的字符列表。
什么是冲突
- 有些是显而易见的:两个操作并发修改同一个记录的同一个字段
- 有些是微妙难发现的
多主复制拓扑
复制拓扑(relication topology) 用来描述写入节点从一个点传播到另一个节点的通信路径
- Mysql仅支持Circulat topology
- 最常见的All-to-all topology
环形和星形中写入可能需要在到达所有副本之前通过多个节点,为防止无限复制循环,没戏写入都会使用其经过的所有节点的标识符进行标记,当收到自己标识过得数据更改时将忽略
环形和星形的问题是,如果有一个节点故障,可能会中断其它节点之间的复制。更密集连接的拓扑结构容错性更高
All-to-all topology也有问题,一些网络链接可能更快,结果是一些复制消息“超越”其它复制消息,写入可能会以错误的顺序到达某些副本
对于产生的因果关系问题,要正确排序这些时间,可以使用一种称为版本向量(version vectors)
无主复制
- 在一些无主复制的实现中,客户端直接将写入发送到几个副本中
- 另一个情况下,由一个协调者(coordinator) 节点代表客户端进行写入,协调者不执行特定写入顺序
当节点故障时写入数据库
如果有副本不可用
- 基于领导者的配置:可能需要执行故障切换
- 无主配置:不纯在故障转移 并行写入几个副本,如果接受副本数量足够,可以忽略不可用副本错过写入的事实 读取时将请求并行发送到多个节点,用于防止读取到没有故障后回复的副本中的旧值,版本号将确定哪个值是更新的
读修复和反熵
复制方案应确保最终将所有数据复制到每个副本。在Dynamo风格的数据存储中经常使用两种机制来保证不可用节点重连后赶上错过的写入
-
读修复(Read repair) 当客户端并行读取多个节点时,它可以检测到任何陈旧的相应,将旧值用新值替换
-
反熵过程(Anti-entropy process)
后台进程不断查找副本之间的差异,并将任何缺少的数据从一个副本复制到另一个副本。过程不会以任何特定的顺序复制写入,并且复制数据之前可能会有显著的延迟
如果没有反熵过程,很少读取的值可能会从某些副本中丢失
读写的法定人数
多少个副本完成才可以认为成功写入
如果有n个副本,每个写入必须由w个节点确认才能被认为是成功的,并且我们必须至少为每个读取查询r个节点。只要w+r>n,我们就可以预期在读取时能获得最新的值,因为r个节点中至少有一个是最新的。这些w和r值的读写称为法定人数(quorum) 的读和写
一个常见的选择是使n为奇数,并设置w=r=(n+1)/2(向上取整),根据需要更改数字,例如写入较少读取较多可以增加w减小r
集群中可能有多于n个节点,但任何给定的值只能存储在n个节点上(集群的机器数可能多余副本数目)
法定人数条件允许系统容忍不可用的节点
- 如果w<n,当节点不可用时,我们仍然可以处理写入
- 如果r<n,当节点不可用时,我们仍然可以处理读取
- 通常,读取和写入操作始终并行发送到所有n个副本。参数w和r决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功
法定人数一致性的局限性
通常r和w被选为多数(超过n/2)节点,因为确保了w+r>n的w和r,同时仍然容忍多达n/2个节点的故障
也可以设置为较小的数字,以使w+r \leq n,这种情况下操作只需要少量的成功响应、允许更低的延迟和更高的可用性。如果有许多副本编的无法访问,则有更大的机会可以继续处理读取和写入
w+r>n满足时也可能存在返回旧值的情况
- 如果使用宽松的法定人数,w个写入和r个读取可能落在完全不同的节点
- 两个写入同时发生,合并并发写入时由于时钟偏差而丢失写入
- 读写操作同时发生,写操作可能只在某些副本上
- 写操作部分失败,但成功的副本上没有回滚
- 携带新值的节点故障,从携带旧值的副本恢复,导致新值副本数小于w
- 不幸出现关于时序(timing)的边缘情况
监控陈旧值
即使应用可以容忍陈旧值也需要了解健康状况,如果发现显著落后可能需要调查原因
基于领导者的复制数据库通常会提供复制延迟的测量值。因为写入按照相同顺序作用于主库和从库,便于计算复制延迟的程度
无主复制的系统中,没有固定的写入顺序,使得监控更加困哪
宽松的法定人数与提示移交
在大型的集群中(节点数量明显多于n个),网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定的法定人数,在这种情况下,数据库设计人员需要权衡:
- 对于所有无法达到w或r个节点的法定人数的请求,是否返回错误是更好的
- 我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存储的n个节点上
后者被认为是一个宽松的法定人数(sloppy quorum) ,响应可能来自不在指定的n个“主”节点中的节点
网络恢复,一个节点代表另一个节点临时接受的任何写入都将被发送到适当的“主”节点。这就是所谓的提示移交(hinted handoff)
宽松的法定人数对写入可用性的提高特别有用:只要有任何w个节点可用,数据库就可以接受写入
宽松的法定人数实际上并不是法定人数,它只是一个持久性的保证,即数据已存储在某处的w个节点,但不保证r个节点的读取能看到它,除非移交已经完成
运维多个数据中心
多主复制也适用于多数据中心操作
Cassandra 和 Voldemort 在正常的无主模型中实现了他们的多数据中心支持:副本的数量 n 包括所有数据中心的节点,你可以在配置中指定每个数据中心所拥有的副本的数量。无论数据中心如何,每个来自客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步执行,尽管该配置仍有一定的灵活性
检测并发写入
Dynamo风格的数据库允许多个客户端同时写入相同的key,这意味着即使使用严格的法定人数也会发生冲突。在读修复或提示移交期间也可能会产生冲突
由于可变的网络延迟和部分节点的故障,事件可能以不同的顺序到达不同的节点
如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值,那么节点就会永久地不一致
为了最终达成一致,副本应该趋于相同的值
最后写入胜利(丢弃并发写入)
实现最终收敛的一种方法是声明每个副本只需要存储 “最近” 的值,并允许 “更旧” 的值被覆盖和抛弃。需要一种方式来确定 “最近” 的值,并且将它复制到每个副本
“最近”的想法是有误导性的,因为客户端互相不知道谁先发送请求,写入是并发的,它们的顺序是不确定的
即使写入没有自然排序,我们也可以强制进行排序,例如加入时间戳,挑选最大时间戳作为“最近”,这种冲突解决算法被称为最后写入胜利(LWW,last write wins)
LWW实现了最终收敛的目标,但以持久性为代价:如果一个键有多个并发写入并且都反馈成功,也只有一个写入将被保留
在数据库中使用LWW的唯一安全方法是确保一个键只写入一次,然后视为不可变,从而避免对同一个键进行并发更新
“此前发生”的关系和并发
如果操作B了解操作A,或者依赖于A,或者以某种方式构建于操作A之上,则操作A在操作B之前发生(happens before)。如果两个操作中的任何一个都不在另一个之前发生(两个操作都不知道对方),那么这两个操作是并发的
并发性、时间和相对性
对于并发,确切的时间并不重要,如果两个操作都意识不到对方的存在,就称这两个操作并发。人们有时把这个原理和物理学中的狭义相对论联系起来,该理论引入了信息不能比光速更快的思想。因此,如果两个事件发生的时间差小于光通过它们之间的距离所需要的时间,那么这两个事件不可能相互影响。
在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是 并发的。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,但仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。
捕获“此前发生”关系
算法工作原理
- 服务器为每个键维护一个版本号,每次写入该键时都递增版本号,并将新版本号与写入的值一起维护存储
- 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端写入前必须先读取
- 当客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起(针对写入请求的响应可以像读取请求一样,返回所有当前值,这使得我们可以像购物车示例那样将多个写入串联起来
- 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与正在进行的其它写入是并发的)
合并并发写入的值
这种算法可以确保没有数据被无声地丢弃,但客户端需要做一些额外的操作来合并并发写入的值
本质上是与多主复制中的冲突解决问题相同。简单的方法是根据版本号或时间戳来选择一个值,但这会丢失数据
一种合理的合并值方法是做并集,然而在移除东西时可能也会产生不正确的结果,被移除的项目可能会重新出现在两个客户端的并集结果中
要移除一个项目时不能简单的从数据库删除,系统必须留下一个具有适当版本号的标记来表示数据已经被移除,这种删除标记被称为墓碑(tombstone)
因为在应用程序代码中做兄弟合并是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,例如前文提到过的CRDT
版本向量
当多个副本并发接受写入时,单个版本号是不够的。除了对每个键,我们还需对每个副本使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其它副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及要保留哪些并发值或兄弟值
所有副本的版本号集合称为版本向量(version vector)
版本向量结构能确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能在其他副本上创建数据,但只要能正确合并就不会丢失数据
版本向量和向量时钟
版本向量有时也被称为向量时钟,即使它们不完全相同。其中的差别很微妙。简而言之,在比较副本的状态时,版本向量才是正确的数据结构。
本章小结
复制可以用于几个目的:
- 高可用性
- 断开连接的操作
- 降低延迟
- 可伸缩性
复制是一个非常棘手的问题,它需要仔细考虑并发和所有可能出错的事情并处理这些故障的后果
三种主要的复制方法
- 单主复制
- 多主复制
- 无主复制
有助于决定应用程序在复制延迟时的行为的一致性模型
- 写后读一致性
- 但调读
- 一致前缀读