作者:京东保险 王奕龙
1. 为什么需要复制我们可以考虑如下问题:
当数据量、读取或写入负载已经超过了当前服务器的处理能力,如何实现负载均衡?希望在单台服务器出现故障时仍能继续工作,这该如何实现?当服务的用户遍布全球,并希望他们访问服务时不会有较大的延迟,怎么才能统一用户的交互体验?这些问题其实都能通过 “复制” 来解决:复制,即在不同的节点上保存相同的副本,提供数据冗余。如果一些节点不可用,剩余的节点仍然可以提供数据服务,这些节点可能部署在不同的地理位置,以此来改善系统性能,针对以上三个问题的解决方案如下:
采用无共享架构(shared-nothing architecture),进行 横向扩展,将数据分散到多台服务器上,进行有效的 负载均衡,提高服务的 伸缩性部署多台服务器,在一台宕机时,其他服务器能随时接管,实现服务的 高可用在多地理位置上部署服务,使用户能就近访问,避免产生较大的延迟,统一用户体验2. 单主复制单主节点复制 是工作中最常见的复制解决方案。存储了数据库拷贝的每个节点被称为 副本(replica),每次向数据库的写入操作都需要传播到所有副本上,否则副本数据就会不一致,它的工作原理如下:
其中一个副本被指定为 领导者,也称为主库,当客户端要向数据库写入时,它必须将该请求发送给领导者其他副本被称为 追随者,也被称为 从库 或 只读副本,每当领导者将数据写入本地存储时,它会将数据变更以 复制日志 或 变更流 的形式推送给所有的追随者,并且追随者按照与领导者 相同的处理顺序 来进行写入2.1 节点间的数据同步数据的同步分 同步复制 和 异步复制,同步复制的好处是从库能够保证与主库有一致的数据,当主库失效时,这些数据能够在从库上找到,但是它的缺点也很明显:主库需要等待从库的数据同步结果,如果同步从库没有响应,主库就无法再处理新的写入操作,而是进入阻塞状态。
在 读多写少 的场景下,我们通常会增加从节点的数量来对读请求进行负载均衡,但是如果此时所有从库都是同步复制是不实际的且不可靠的,因为单个节点的故障或网络中断都会影响数据的写入。
事实上数据库启用同步复制时,通常表示有一个从库是同步复制,其他从库是异步复制,当同步从库失效时,异步复制的副本会改为同步复制,这保证了至少有两个节点拥有最新的数据副本,这种配置也被成为 半同步。
而通常情况下,基于领导者的复制都配置为 完全异步。如下图所示,用户1234修改picture_url 信息时,从主库同步到从库是存在延迟的。
这意味着如果此时主库失效而尚未复制给从库的数据会丢失,导致已经向客户端请求确认成功也不能保证写入是持久的,而且如果在主节点写入数据后,立即向 Follower 2 读取数据,则会读取到旧数据,给用户的感觉就像是刚才的写入丢失了一样,这对应了 读己之写一致性 问题,我们在后文会做具体解释。
但是实际生产情况下都基于异步复制,说明强一致性并不是必要的保证,而对保证系统 吞吐量 的需求更高。因为在这种机制下,即使从库已经远远落后,主库也不必等待从库写入完成就可以返回数据写入成功。之后从库会慢慢赶上并与主库一致,这种弱一致性的保证被称为 最终一致性。
2.2 复制延迟问题从上一小节中,我们知道了异步复制在写入主库到复制到从库存在延迟,因此会产生一系列的问题,在这里我们对这些存在的问题进行更具体的解释。
写入完成后主节点失效,但从节点未完成数据同步主节点失效,需要进行 故障转移,将一个从库提升为主库,主库的最佳人选通常是拥有最新数据副本的从库(zookeeper的事务ID比较过程遵从的这个原理),让新主库来继续为客户端服务,其他从库从新的主库节点进行数据同步。
如果此时新的主节点在旧的主节点失效前还未完成数据同步,那么通常的做法是将原主节点未完成复制的数据丢弃,此时就会发生 数据丢失 的问题。
而且在旧的主库恢复时,需要让它意识到新主库的存在,并使自己成为一个从库。如果当集群中出现多个节点认为自己是主节点时,即 "脑裂" 现象,是非常危险的:因为多个主节点都可以进行写操作,却没有冲突解决机制,数据就可能被破坏。
zookeeper出现脑裂时通过判断 epoch 的大小(故障转移完成新的一轮选举之后它的epoch会递增)来使从节点拒绝旧主节点的请求,保证数据不被破坏。
写后读一致性(读己之写一致性)如上图所示,如果用户在写入后马上请求查看数据,则新数据可能尚未到达只读从库,看起来好像刚提交的数据丢失了,这种情况可以通过以下方式来解决
对于用户 可能修改过 的内容,总是从主库读取,这需要有办法在不通过查询的方式来知道用户是否修改了某些数据。比如,社交网络的个人信息通常由个人来修改,因此可以定义总是从主库来读取自己的档案信息,读取其他人的信息则在从库获取如果应用中的大部分内容都能被用户修改,那么大部分查询都从主库读取的话,读伸缩性 就没有效果了。在这种情况下可以通过记录上次更新的时间,比如在更新后的一分钟内从主库查询,之后在从库读取,以此来保证读伸缩性客户端记录最近一次的写入时间戳,系统需要确保从库在处理该用户的读请求时,该时间戳的变更已经在本从库中记录了,如果查询的当前从库不存在该记录,那么需要再从其他从库读取,或者等待从库同步数据单调读如上图所示,用户1234写入了一条评论,用户2345在读取其他用户添加的评论时,第一次请求到了 Follower1,这时从库已经完成了数据同步,那么能读取到该评论。但是第二次请求到了 Follower2,而 Follower2 并没有完成数据同步,导致看不到之前读取到的评论,出现 "时间倒流" 现象。
避免这种现象需要保证 单调读,即当用户读取到较新的数据时,他不会再读取到更旧的数据。实现单调读的方式是使 同一个用户的读请求都请求到同一个副本节点,我们可以根据ID的散列来分配副本而不是随机分配。
2.3 新从库的数据同步通常为了增强系统的 读伸缩性,会添加新的从库。但新从库在与主库做数据同步时,简单地将数据文件复制到另一个节点通常是不够的,因为数据总是在不断的变化,当前的数据文件不能包含全量数据,所以一般情况下的流程如下:
获取某个时刻的主库一致性快照,并将该快照复制到新的从库节点从库连接到主库,并拉取数据快照之后发生的数据变更,这就要求快照与主库复制日志有精确的位置关联,Mysql是通过 binlog coordinates 二进制日志坐标来关联的从库处理完快照之后的数据变更,那么就说它赶上了主库,现在它就可以及时处理主库的数据变化了如果发生 从库失效,在从库重新启动后会执行以上 2,3 步骤,通过日志可以知道发生故障之前处理的最后一个事务,通过该记录请求从库断开期间的所有数据变更,慢慢地追赶主库。
3. 多主复制基于单主节点的复制,每个写请求都要经过主节点所在的数据中心,那么随着写入请求的增加,单主节点伸缩性差的局限性就会显现出来,而且在世界各地的用户都需要请求到该主节点才能进行写入,可能存在延时较长的问题。为了解决这些问题,在单主节点架构下进行延伸,自然是 多主节点复制,在这种情况下,每个主节点又是其他主节点的从库。
通常情况下,增加单主节点的伸缩性不会使用多主复制,而是通过数据分区来解决。因为前者导致的复杂性已经超过了它能带来的好处,不过在某些情况下,也是可以采用多主复制的。
多数据中心的多主复制架构如下图所示:
数据库的副本分散在多个数据中心,在每个数据中心都有主库,在每个数据中心内都是主从复制,每个数据中心的写请求都会在本地数据中心处理然后同步到其他数据中心的主节点,这样数据中心间的网络延迟对用户来说就变成了透明的,这 意味着性能可能会更好,对网络问题的容忍度更高;多数据中心部署在不同的地理位置上,对用户来说体验更好;如果本地数据中心发生故障,能够将请求转移到其他数据中心,等本地数据中心恢复并复制赶上进度后,能继续提供服务。
3.1 多主复制的应用场景断网后仍继续工作的应用程序如果你使用的手机和电脑是同一个生态的话,那么一般情况下,备忘录内容的修改能在设备之间进行同步。从架构的角度来看,每个设备都相当于是一个数据中心,每个数据中心都能进行写入,它符合多主复制模型。数据中心间的网络是极度不可靠的,当手机离线,在电脑端对备忘录进行修改后,那么当手机再接入互联网,需要完成设备间的数据同步,这就是异步多主复制的过程。
在线协同文档当有用户对文档进行编辑时,所做的更改将立即被异步复制到服务器和其他任何正在使用该文档的用户,每个用户操作的文档都相当于是一个数据中心,这种情况与我们上文所述的在离线设备上对备忘录进行修改有相似之处。不过,在这种情况下,为了加速协同和提高文档的使用体验,需要解决同时编辑产生的写入冲突问题。
3.2 解决写入冲突虽然我们在上文中提到了多主复制能带来诸多好处(多主带来的伸缩性、更好的容错机制和减少地理位置造成的延时),但是相伴的 配置复杂 和 写入冲突问题 也是需要我们直面的。
如下图所示,用户1修改标题为B,用户2修改标题为C,那么此时就会发生写入冲突,我们很难说得清楚将谁的结果指定为最终修改结果是合适的,但是我们还是不得不将多主数据库的值收敛至一致的状态。
最后写入胜利(LWW,last write wins) 是比较常用的方法,我们可以为每个请求增加时间戳或者唯一的ID,挑选其中较大的值作为最终结果,并将其他的值丢弃,不过这种情况容易造成数据丢失,比如在分布式服务中存在的 不可靠的时钟 问题,可能后写入的值反而携带的时间戳更靠前,那么这种情况下就会将我们预期被写入的结果丢弃。
另一种方法是可以为每个主库分配一个ID编号,具有更高的ID编号的主库具有更高的优先级,但是这也会产生数据丢失问题。
如果不想发生数据丢失,可以以某种组合的方式将这些值组合在一起。以上图中对标题的修改为例,可以将标题修改结果拼接成 B/C,不过这种情况需要用户对结果进行修正。和该方式类似的,还可以考虑将所有对数据修改的冲突都显示的记录下来,之后提示用户进行修改。
版本向量 也是一种解决冲突的方式。以缓存为例,我们为每个键维护一个版本号,每次写入时先进行读取,并且必须将之前读取的所有值合并在一起,其中删除的值会被标记(墓碑),这样就能够避免在合并完成后仍然出现曾删掉的值。在写入完成后版本号递增,将新版本号与写入的值一起存储。在多个副本并发接受写入时,每个副本也需要维护版本号,每个副本在处理写入时增加自己的版本号。所有副本的版本号集合称为 版本向量,版本向量会随着读取和写入在客户端和服务端之前来回传递,并且允许数据库区分覆盖写入和并发写入。版本向量能够 确保从一个副本读取并随后写回到另一个副本是安全的。
不过,虽然我们介绍了这么多解决冲突的方式,但是实际上 避免冲突 是最好的方式。比如我们可以确保特定记录的所有写入都通过同一个主库,那么就不会发生冲突了。
关于并发的理解:如果是在单体服务中,我们可以通过时间戳来判断两个事件同时发生;如果是在分布式系统中,因为分布式系统存在不可靠的时钟问题,所以在实际的系统中很难判断两个事件是否是同时发生,所以并发在 字面时间上的重叠并不重要。实际上,并发强调的是 两个事件是否能意识到对方的存在,如果都意识不到对方的存在,即两个事件都不在另一个之前发生,那么这两个事件是并发的,那么它们存在需要被解决的 并发写入 冲突。
5. 无主复制无主复制与单主、多主复制采用不同的复制机制:它没有主库和从库的职责差异,而是放弃了主库的概念,每一个数据库节点都可以处理写入请求,因此它适用于 高可用、低延时、且能够容忍偶尔读到陈旧值 的应用场景。
这种复制模式还有一个好处是不存在故障转移,当某个节点宕机时,应用会将该请求转发到其他正常工作的节点。等到宕机节点重新连接之后,该节点可以通过以下两种方式赶上错过的写入:
读修复:适用于读频繁的值,客户端并行获取多个节点时,如果它检测到陈旧的值,那么将读取到的新值把陈旧的值覆盖掉反熵:开启后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本无主复制的每个数据库节点都能处理读写请求,但是并不是在某单个节点写入完成后就被认定为写入成功或在单个节点读取就认为该值是读取结果。它的读写遵循 法定人数原则,与zookeeper处理写入请求使用的容错共识算法类似。
一般地说,如果有n个副本,每个写入必须由 w 个节点确认才能被认为是成功的,并且每个读取必须查询 r 个节点。只要 w + r > n,我们可以预期在读取时获得最新的值,因为在 r 个读取中至少有一个节点是最新的,遵循这些 r 值和 w 值的读写被称为法定人数读写。常见的配置是将n(节点数)配置成奇数,并设置 w = r = (n + 1) / 2 向上取整,这样保证了写入和读取的节点集合必然有重叠,所以读取的节点中必然至少有一个节点具有最新的值。
如下图所示,用户1234会将写入请求发送到所有的3个数据库副本,并且在其中两个副本返回成功时即认为写入成功,而忽略了宕机副本错过写入的事实;用户2345在读取数据时,也会将请求发送到所有副本,并将其中最新的值看作读取的结果。
每种复制的模式都有优点和缺点,单主复制是比较流行的,它容易理解而且无需处理冲突问题(写入只有主节点处理)。不过在节点故障或者网络出现较大的延时时,多主复制和无主复制可以更加健壮,但是它们只能提供较弱的一致性保证。
巨人的肩膀《数据密集型应用系统设计》:第五章 复制Replication(上):常见复制模型&分布式系统挑战