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