DDIA(《数据密集型应用系统设计》)的阅读随笔:第5章 数据复制

DDIA(《数据密集型应用系统设计》)的阅读随笔:第5章 数据复制

DDIA这本书,其实我买的挺早,可能中文版刚出的时候我就买了,不过一直在书架上吃灰。可能屁股决定脑袋,当时刚博士毕业,从网络路由算法到AI,我更专注于去看看Machine Learning的经典充实自己的理论底蕴。然而,在工业界摸爬滚打了几年,越发感到一个好的产品不仅仅是一个团队几个精彩算法的事情,而是从需求到选型到规划到管理全方位的思考,立足于开发又不仅仅是开发。有一天,在B站看到DDIA这本书的读书会,想起在架子上的这本书,遂决定认真的去读一读这本分布式系统领域贴合工业界实践的好书。


DDIA这本书有三个部分组成,数据系统基础,分布式数据系统和派生数据。我在写这个系列随笔 时候,第一部分已经读完一段时间了,不想为写笔记而写笔记,所以决定从第5章 数据复制开始,这一部分也是本书开始涉及分布式系统设计的开始。其实第一部分的内容很精彩,无论是开宗明义的以一个twitter的设计切入介绍系统可靠性、可扩展性和可维护性的第一章还是接下来以数据库为核心的三章,对于数据模型、数据存储与索引以及数据编码的阐述,都有精彩之处。这里给自己插个旗,等到我下一次再次阅读这本书的时候,会补上第一部分的笔记。

文章目录

  • DDIA(《数据密集型应用系统设计》)的阅读随笔:第5章 数据复制
  • 前言
  • 一、单主节点复制
    • 1.主从复制
    • 2.同步复制和异步复制
    • 3.主从复制技术实现
    • 4.复制滞后的问题与解决方案讨论
  • 二、多主节点复制
  • 三、无主节点复制
  • 总结


前言

数据复制意味着在通过网络连接的多台机器上保留相同数据的副本。复制数据在系统设计上的出发点主要为:

  1. 降低延迟:使得数据与用户在地理上接近
  2. 提高可用性:即使系统的一部分出现故障,系统也能继续工作
  3. 保证吞吐量:伸缩可以接受读请求的机器数量

本章节有一个基本的假设: 将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。对于数据分片的问题会留在下一章进行讨论。

如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的 变更(change),这里讨论了三种流行的变更复制算法:单主节点、多主节点和无主节点。几乎所有分布式数据库都使用这三种方法之一。在此基础上,讨论了同步复制和异步复制,以及如何处理失败的副本等内容。


一、单主节点复制

1.主从复制

本章考虑的最根本问题: 当存在多个副本时,如何确保所有数据都落在了所有的副本上?

每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案为主从复制。它的工作原理如下:

  1. 多个副本中的其中一个副本被指定为 领导者(leader ),也称为 主库(master|primary)。当客户端要向数据库写入时,它必须将请求发送给该 领导者,其会将新数据写入其本地存储。
  2. 其他副本被称为 追随者(followers),亦称为 只读副本(read replicas)、从库(slaves)、备库( secondaries) 或 热备(hot-standby)。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为 复制日志(replication log) 或 变更流(change stream)。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照与领导者相同的处理顺序来进行所有写入。
  3. 当客户想要从数据库中读取数据时,它可以向领导者或任一追随者进行查询。但只有领导者才能接受写入操作(从客户端的角度来看从库都是只读的)。

现在主从复制,基本上在关系数据库,如mysql, postgre 以及nosql,如mong DB, redis,Espresso等都已经是基本的内置配置。但是深入到具体的实习机制,其实还是有一些差异。mysql 的binlog的方式可能是一种经典的主从复制的方案,值得去仔细体会。

2.同步复制和异步复制

复制系统的一个重要细节是:复制是 同步(synchronously) 发生的还是 异步(asynchronously) 发生的。这里结合书里的一个图去说明。
DDIA(《数据密集型应用系统设计》)的阅读随笔:第5章 数据复制_第1张图片
上图表达的场景是,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时间点,主库又会将数据变更转发给自己的从库。最终,主库通知客户更新成功。
从库 1 的复制是同步的:在向用户报告写入成功并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库 1 已经收到写入操作。而从库 2 的复制是异步的:主库发送消息,但不等待该从库的响应。

  • 同步复制的优点是,从库能保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。
    实际上,如果在数据库上启用同步复制,通常意味着其中 一个 从库是同步的,而其他的从库则是异步的。如果该同步从库变得不可用或缓慢,则将一个异步从库改为同步运行。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 半同步(semi-synchronous)。
  • 通常情况下,基于领导者的复制都配置为完全异步。在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。这意味着即使已经向客户端确认成功,写入也不能保证是 持久(Durable) 的。然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
    弱化的持久性可能听起来像是一个坏的折衷,但异步复制其实已经被广泛使用了,特别是在有很多从库的场景下,或者当从库在地理上分布很广的时候。

分布式系统的设计,很多时候就是一个折中的过程,同步复制和异步复制的讨论也是如此。异步的可用性虽然会损失一定的一致性,但在目前大部分应用追求高可用的前提下,依然是首选,毕竟同步失效带来的主库锁死写入的代价,通常是不可接受的。但是,对于这个问题,如果迁移到别的领域,比如安全性要求极高的嵌入式系统,则选择可能完全不同。

3.主从复制技术实现

  1. 基于语句的复制
    主库记录下它执行的每个写入请求(语句,即 statement)并将该语句日志发送给从库。对于关系数据库来说,这意味着每个INSERT、UPDATE 或 DELETE 语句都被转发给每个从库,每个从库解析并执行该 SQL 语句,就像直接从客户端收到一样。
    这个方案,似乎最为简单直接。但是书中提到了三个限制:
    1)任何调用 非确定性函数(nondeterministic) 的语句,可能会在每个副本上生成不同的值。例如,使用 NOW() 获取当前日期时间,或使用 RAND() 获取一个随机数。
    2)如果语句使用了 自增列(auto increment),或者依赖于数据库中的现有数据(例如,UPDATE … WHERE <某些条件>),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
    3)有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定性的。

  2. 基于预写日志(WAL)传输
    存储引擎,对于覆写单个磁盘块的 B 树,每次修改都会先写入 预写式日志(Write Ahead Log, WAL),以便崩溃后索引可以恢复到一个一致的状态。该日志都是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给从库。通过使用这个日志,从库可以构建一个与主库一模一样的数据结构拷贝。 这种复制方法在 PostgreSQL 和 Oracle 等一些产品中被使用到。
    其主要缺点是日志记录的数据非常底层:WAL 包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。 这开起来似乎问题不大,但对升级运维确实一个巨大的挑战,意味者数据库版本无法热更新,升级需要停机。

  3. 基于行的逻辑日志复制
    对复制和存储引擎使用不同的日志格式,这样可以将复制日志从存储引擎的内部实现中解耦出来。这种复制日志被称为逻辑日志(logical log),以将其与存储引擎的(物理)数据表示区分开来。关系数据库的逻辑日志通常是以行的粒度来描述对数据库表的写入记录的序列:
    1)对于插入的行,日志包含所有列的新值。
    2)对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
    3)对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
    MySQL 的binlog就是使用了这种方法。 由于逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。

  4. 基于触发器的复制
    这个思路是将复制操作上移到应用程序层。
    触发器允许你将数据更改(写入事务)发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上一些必要的业务逻辑,就可以将数据变更复制到另一个系统去。例如,Databus for Oracle 和 Bucardo for Postgres就是这样工作的。
    基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。但是这种方法赢一手最佳的灵活性,所以也是很多场景值得思考的方式。

4.复制滞后的问题与解决方案讨论

当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 最终一致性(eventual consistency)。

在正常的操作中,复制延迟(replication lag),即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,但如果系统在接近极限的情况下运行,或网络中存在问题时,延迟可以轻而易举地超过几秒,甚至达到几分钟。因为滞后时间太长引入的不一致性,不仅仅是一个理论问题,更是应用设计中会遇到的真实问题。

目前主要的解决方法如下:

  • 读自己的写
    1)用户控制:对于用户 可能修改过 的内容,总是从主库读取;这就要求得有办法不通过实际的查询就可以知道用户是否修改了某些东西。举个例子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则就是:总是从主库读取用户自己的档案,如果要读取其他用户的档案就去从库。
    2)时间控制:如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从主库读取(读伸缩就没效果了)。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。
    3)时间戳比较:客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟(在这种情况下,时钟同步变得至关重要,请参阅 “不可靠的时钟”)。
    4)路由控制:如果你的副本分布在多个数据中心(为了在地理上接近用户或者出于可用性目的),还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。
    这里其实用户控制的实现,相对可操作性强,虽然有比较强的业务耦合性。基于时间来控制,不管是时间段还是时间戳,都会陷入另一个难题,即分布式系统的时间可靠性问题。
  • 单调读
    实现单调读的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本出现故障,用户的查询将需要重新路由到另一个副本。
    这主要保证的是多次查询的一致性问题,不出现时光倒流类似的情况,但不能保证当前的查询返回的是最新的结果。不过这已经是设计上一个很好的折中考虑了。
  • 前缀一致读
    这个考虑的问题和单调读类似,是希望如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。这是一个因果性的考虑。它的实现主要是确保任何因果相关的写入都写入相同的分区,但在一些应用中可能无法高效地完成这种操作,这就需要依赖一些显式跟踪因果依赖关系的算法。

二、多主节点复制

多主节点复制有意义的语境是在多数据中心下的。假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于但主节点的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 多主节点配置中可以在每个数据中心都有主库。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。

当然这里,作者给出了两个近似的场景,也可以理解为多主节点:一种是离线客户端,即应用程序在断网之后仍然需要继续工作,另一种是多人协作编写文档,如Etherpad和Google Docs。

这个场景下最主要考虑的就是写冲突的处理。 本质上写冲突其实目前并没有特别优的解决方法,书中给出的原则:处理冲突的最简单的策略就是避免它们:如果应用程序可以确保特定记录的所有写入都通过同一个主节点,那么冲突就不会发生。由于多主节点复制处理的许多实现冲突相当不好,避免冲突是一个经常推荐的方法。 对于冲突合并,最实用的方案还是最后写入胜利(LWW, last write wins)。当然判定最终写入,可以依据一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),通常挑选最高ID的写入作为胜利者,并丢弃其他写入。

三、无主节点复制

无主节点架构,最有影响力的是亚马逊的Dynamo系统,遵循该风格的数据库还有Riak,Cassandra和Voldemort等。
无主节点架构最主要的特点是允许任何副本直接接受来自客户端的写入。 在一些无主节点的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个协调者(coordinator)节点代表客户端进行写入。但与主节点不同,协调者不执行特定的写入顺序。

一个典型的无主复制的例子如下图所示,无主节复制场景和前两两类场景最大的差异是,客户端的写和读的请求都会发送给所有允许的副本。图示示例可以看出由于副本3宕机,它没有响应User1234的写入,当User 2345读取的时候,则会出现两个版本的数据。
DDIA(《数据密集型应用系统设计》)的阅读随笔:第5章 数据复制_第2张图片

在Dynamo风格的数据存储中经常使用两种机制:

1)读修复(Read repair)

​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在上图中,用户2345获得了来自Replica 3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回复制品。这种方法适用于频繁阅读的值。

2)反熵过程(Anti-entropy process)

​ 一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于主节点的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。

最后讨论以下,在无主节点风格中,系统可用的读写副本数的约束(仲裁一致性)。一般地说,如果有n个副本,每个写入必须由w节点确认才能被认为是成功的,并且我们必须至少为每个读取查询r个节点。则需要满足 w + r > n w+r > n w+r>n
在Dynamo风格的数据库中,参数n,w和r通常是可配置的。一个常见的选择是使n为奇数(通常为3或5)并设置 w = r = ( n + 1 ) / 2 w = r =(n + 1)/ 2 w=r=n+1/2(向上取整)。但是可以根据需要更改数字。例如,设置 w = n w = n w=n r = 1 r = 1 r=1的写入很少且读取次数较多的工作负载可能会受益。这使得读取速度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。

当然本节的后面还有一些比较精彩的讨论,比如上述上述仲裁一致性的局限、松散仲裁原则带来的好处以及版本向量和版本时钟的讨论。这里的写冲入检测相关的讨论其实和多主节点复制的场景类似。

总结

复制可以用于几个目的:

1)高可用性:即使在一台机器(或多台机器,或整个数据中心)停机的情况下也能保持系统正常运行

2)断开连接的操作:允许应用程序在网络中断时继续工作

3)延迟:将数据放置在距离用户较近的地方,以便用户能够更快地与其交互

4)可扩展性:能够处理比单个机器更高的读取量可以通过对副本进行读取来处理

书中讨论了复制的三种主要方法:单主节点复制,多主节点复制和无主节点复制。

​ 复制可以是同步的,也可以是异步的,在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是在复制滞后增加和服务器故障时要弄清楚会发生什么,这一点很重要。

针对应用程序在复制滞后时的行为的一致性模型:

1)写后读:用户应该总是看到自己提交的数据。

2)单调读:用户在一个时间点看到数据后,他们不应该在某个早期时间点看到数据。

3)一致前缀读: 用户应该将数据视为具有因果意义的状态:例如,按照正确的顺序查看问题及其答复。

​ 多主节点和无主节点复制方法固有的并发问题:写冲突。检测冲突和避免是推荐的,LWW是目前主要的冲突合并策略。

你可能感兴趣的:(系统设计,分布式系统,数据库架构)