在分布式数据系统中,复制是一种重要的能力。简单来说,复制就是将数据的副本存储在多个位置,通常是在不同的服务器或节点上。这样做有几个关键的优点:
- 使得数据与用户在地理上接近(从而减少延迟),例如,在一个全球分布式的系统中,如果用户在美国请求数据,而数据只存储在欧洲的服务器上,那么这个请求可能需要跨越大洋,导致延迟。但是,如果数据的复制品也存储在美国的服务器上,那么这个请求就可以更快地得到响应。
- 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
- 扩展可以接受读请求的机器数量(从而提高读取吞吐量)
你可以将这个过程想象成是一家连锁餐厅。每家分店(分布式系统的节点)都有一份菜单(数据)。这样,无论客户(用户)去哪家分店,都能得到相同的菜单。即使某家分店因为一些原因(例如装修)暂时关门,客户仍然可以去其他分店。同时,如果菜单有更新,每家分店都会收到新的菜单,以保证信息的同步。
复制的困难之处在于处理复制数据的变更。三种流行的变更复制算法:单领导者(single leader),多领导者(multi leader)和无领导者(leaderless)
我们在复制时要考虑很多问题,使用同步复制还是异步复制?如何处理失败的副本?
当存在多个副本时,如果多个副本包含不一样的数据,就会导致读取的时候产生数据不一致情况,如何确保所有数据都落在了所有的副本上?
单领导者
最常见的解决方案被称为基于领导者的复制,也称为主从复制
- 副本之一被指定为领导者(leader),也称为 主库(master) ,首要(primary)。当客户端要向数据库写入时,它必须将请求发送给领导者,领导者会将新数据写入其本地存储。
- 其他副本被称为追随者(followers),亦称为只读副本(read replicas),从库slaves),次要( sencondaries),热备(hot-standby) 。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为复制日志(replication log)记录或变更流(change stream)。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
- 当客户想要从数据库中读取数据时,它可以向领导者或追随者查询。 但只有领导者才能接受写操作(从客户端的角度来看从库都是只读的)。
如果对于数据一致性有比较高的要求,领导者需要等所有的追随者返回成功状态,当然,这种操作显而易见,会导致性能的降低
同步复制与异步复制
同步复制的优点是,从库保证有与主库一致的最新数据副本。
缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。
实际上还有一种半同步配置(其中一个跟随者是同步的,而其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步),通常情况下,基于领导者的复制都配置为完全异步。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。 这意味着即使已经向客户端确认成功,写入也 不能保证持久。 然而,一个完全异步的配置也有优点:即使所有的从库都落后 了,主库也可以继续处理写入。
我们如何处理新增的从节点呢
设置新从库
有时候需要设置一个新的从库:也许是为了增加副本的数量,或替换失败的节点。核心在于要保证新从节点有主库数据的精确副本,简单的全量主从同步是不满足的,还要考虑如何进行增量数据同步。我们可以简单想一下,通过锁定主库,保证数据静态化,完成同步,但这样会造成集群不可用,显然与实际生产目标违背。
如何确保新的从库拥有主库数据的精确副本?
1、锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。
2、不停机更新
- 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如MySQL的innobackupex。
- 将快照复制到新的从库节点。
- 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称:例如,PostgreSQL将其称为日志序列号(log sequence number, LSN),MySQL将其称为二进制日志坐标(binlog coordinates)。
- 当从库处理完快照之后积压的数据变更,我们说它赶上了主库,就可以继续处理主库产生的数据变化了。
有些系统中,这种过程是完全自动化的,但有些系统可能还需要管理员手动执行
处理节点宕机
分布式系统中,任何节点都可能宕机,我们肯定是期望,及时偶尔有机器宕机,整个集群仍然能够对外提供稳定服务。那么要如何保证呢
从库失效:追赶恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发生的所有数据变更。当应用完所有这些变化后,它就赶上了主库
主库失效:故障转移
主库失效就麻烦很多,需要先将一个从库变为主库,然后告诉客户端换老大了,其他从库需要开始拉取来自新主库的数据变更
- 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用超时(Timeout):节点频繁地相互来回传递消息,并且如果一个节点在一段时间内(例如30秒)没有响应,就认为它挂了。
- 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的控制器节点(controller node)来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个新的领导者,是一个共识问题,关注我,后面内容也会讲解。
- 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(。如果老领导回来,可能仍然认为自己是主库,没有意识到其他副本已经让它下台了。系统需要确保老领导认可新领导,成为一个从库。
故障转移带来的问题
- 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入,那这些写入该如何处理?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
- 脑裂(split brain):如果两个主库都可以接受写操作,却没有冲突解决机制,那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 ,但设计粗糙的机制可能最后会导致两个节点都被关闭
- 正确设置超时时间,太长数据丢失多,太短容易误判
复制日志的实现
复制日志是支持数据同步的基础,你可能想的很简单,只需要告诉从库,主库执行了什么不就行了。但是思考一下这几个场景
-
执行了一个Now()函数,目的是为了获取当前时间,但是主从机器时间不一致
-
执行update … where <条件>,依赖于数据库中的现有数据,但数据库在主从出现了使用不同索引情况,导致更新内容不一致
当然,对于上面的问题,我们可以用另一种方法,主库可以用固定的返回值替换任何不确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择 其他的复制方法。
基于语句的复制
主库记录下它执行的每个写入请求(语句(statement))并将该语句日志发送给其从库。对于关系数据库来说,这意味着每个 INSERT , UPDATE 或 DELETE 语句都被转发给每个从库,每个从库解析并执行该SQL语句,就像从客户端收到一样。
问题:
- 执行了一个Now()函数,目的是为了获取当前时间,但是主从机器时间不一致
- 执行update … where <条件>,依赖于数据库中的现有数据,但数据库在主从出现了使用不同索引情况,导致更新内容不一致
- 有副作用的语句(例如,触发器,存储过程,用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定的。
当然,对于上面的问题,我们可以用另一种方法,主库可以用固定的返回值替换任何不确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择 其他的复制方法。
传输预写式日志(WAL)
- 对于日志结构存储引擎(“SSTables和LSM树”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
- 对于覆写单个磁盘块的B树,每次修改都会先写入预写式日志(Write Ahead Log,WAL),以便崩溃后索引可以恢复到一个一致的状态。
这种主要缺点是日志记录的数据非常底层:WAL包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。这对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障转移,使升级后的节点之一成为新的主库,从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配(传输WAL经常出现这种情况),则此类升级需要停机。
逻辑日志复制(基于行)
复制和存储引擎使用不同的日志格式,这样可以使复制日志从存储引擎内部分离出来。这种复制日志被称为逻辑日志,以将其与存储引擎的(物理)数据表示区分开来。
由于逻辑日志与存储引擎内部分离,因此可以更容易地保持向后兼容,从而使领导者和跟随者能够运行不同版本的数据库软件甚至不同的存储引擎。
对于外部应用程序来说,逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统(如数据仓库),这一点很有用,例如复制到数据仓库进行离线分析,或建立自定义索引和缓存。 这种技术被称为捕获数据变更
基于触发器的复制
在某些情况下需要更多的灵活性。例如,如果只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果您需要冲突解决逻辑(“处理写入冲突”),则可能需要将复制移动到应用程序层。一些工具,如Oracle Golden Gate ,可以通过读取数据库日志,使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库的内置复制更容易出错,也有很多限制,然而由于其灵活性,仍然是很有用的。
复制延迟问题
当应用程序从异步从库读取时,如果从库落后,比如可能由于网络延迟、从库压力大更新数据缓慢等原因,它可能会看到过时的信息。
读己之写
这种方式的思想一个是记录写操作的用户,用户访问自己修改的数据,就读主库,一个场景就是修改个人主页的信息时,用户修改后,再进行查看自己的个人主页时,采用读主库,这样就不会有读到旧数据的问题
但是假如我们修改的是共享文档,那这种方式就没用了,所以另外一种方式是记录修改时间戳,在写入后比如1s的时间范围内,读主库。但这样也不是很精确,万一从库1s还没有完成同步呢,并且这种方式只对单台机器有效,现在服务都是多台机器,那需要将时间戳存储到共享的元数据中心(比如redis),但这无疑又增加了复杂度
单调读
还有一种问题,因为从库机器同步的效率可能会有差异,所以就可能导致读用户可能已经修改过的内容。先读新从库再读就从库,会以为数据突然“消失”
实现单调读取的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户ID的散列来选择副本,而不是随机选择副本。但是,如果该副本失败,用户的查询将需要重新路由到另一个副本
多主复制
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
多主复制的优势:
1、提升写性能,避免异地多活写延迟
2、容忍数据中心停机
3、容忍网络问题
多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的。需要更大的代价去维护数据一致性
我们下面就要讨论如何尽量去解决写冲突问题
处理写入冲突
如上图,用户1与用户2分别期望将A改为B,C,此时出现了冲突
同步与异步冲突检测
在多活配置中,两个写入都是成功的,并且在稍后的时间点仅仅异步地检测到冲突。那时要求用户解决冲突可能为时已晚。理论上,可以使冲突检测同步, 即等待写入被复制到所有副本,然后再告诉用户写入成功。但是,将失去多主复制的主要优点:允许每个副本独立接受写入。++如果想要同步冲突检测,那么可以使用单主程序复制。++
避免冲突
处理冲突的最简单的策略就是避免它们:如果应用程序可以++确保特定记录的所有写入都通过同一个领导者,那么冲突就不会发生++。比如在用户可以编辑自己的数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心(最简单的方式比如用户id经过hash计算后取模),并使用该数据中心的领导者进行读写。(局限性也很大,有些情况数据更新并不是针对用户级别,比如统计数据)
保证最终一致
多主库执行语句顺序不一致,导致最终结果状态不同。我们可以寻找一个途径使得所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
现冲突合并解决有多种途径:
- 给每个写入一个唯一的ID(例如一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),挑选最高ID的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为最后写入胜利(LWW, last write wins)。虽然这种方法很流行,但是很容易造成数据丢失
- 为每个副本分配一个唯一的ID,ID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
- 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(合并的标题可能类似于“B/C”)。
- 在保留所有信息的显式数据结构中记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
总结下,无非就是选取最新值、记录操作顺序、提醒用户手动处理
多主复制拓扑结构
数据循环问题:在圆形和星形拓扑中,写入可能需要在到达所有副本之前通过多个节点。因此,节点需要转发从其他节点收到的数据更改。为了防止无限复制循环,每个节点被赋予一个唯一的标识符,并且在复制日志中,每个写入都被标记了所有已经通过的节点的标识符。当一个节点收到用自己的标识符标记的数据更改时,该数据更改将被忽略,因为节点知道它已经被处理。
循环和星型拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。
全能拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息(顺序不一致带来问题)
要正确排序这些事件,可以使用一种称为**版本向量(version vectors)**的技术
无主复制
在一些无领导者的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个协调者(coordinator)节点代表客户端进行写入。但与主库数据库不同,协调员不执行特定的写入顺序。无主复制,我觉得其核心在于保证所配置的“多数节点”返回成功状态响应
当节点故障时写入数据库
当一个客户端从数据库中读取数据时,它不仅仅发送它的请求到一个副本:读请求也被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应。即来自一个节点的最新值和来自另一个节点的陈旧值。
那么思考一下,如何保证读取的新值呢?
读修复和反熵
读修复:当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。客户端发现副本3具有陈旧值,并将新值写回复制品。这种方法适用于频繁阅读的值。
反熵:一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显着的延迟。就好比业务上,我们常常会遇见一些比如统计数出现错误的问题,常见的做法就是通过一个定时器定期修复嘛,维护最终一致性
Quorum NWR算法
上面的读修复和反熵保证了底层数据的最终一致性,那么我们读的时候如何保证一定读到新值呢?
如果有n个副本,每个写入必须由w节点确认才能被认为是成功的,并且我们必须至少为每个读取查询r个节点。 (在我们的例子中,$n = 3,w = 2,r = 2$
)。只要$w + r> n$
,我们期望在读取时获得最新的值,因为r个读取中至少有一个节点是最新的。遵循这些r值,w值的读写称为法定人数(quorum) 的读和写。