DDIA这本书,其实我买的挺早,可能中文版刚出的时候我就买了,不过一直在书架上吃灰。可能屁股决定脑袋,当时刚博士毕业,从网络路由算法到AI,我更专注于去看看Machine Learning的经典充实自己的理论底蕴。然而,在工业界摸爬滚打了几年,越发感到一个好的产品不仅仅是一个团队几个精彩算法的事情,而是从需求到选型到规划到管理全方位的思考,立足于开发又不仅仅是开发。有一天,在B站看到DDIA这本书的读书会,想起在架子上的这本书,遂决定认真的去读一读这本分布式系统领域贴合工业界实践的好书。
DDIA这本书有三个部分组成,数据系统基础,分布式数据系统和派生数据。我在写这个系列随笔 时候,第一部分已经读完一段时间了,不想为写笔记而写笔记,所以决定从第5章 数据复制开始,这一部分也是本书开始涉及分布式系统设计的开始。其实第一部分的内容很精彩,无论是开宗明义的以一个twitter的设计切入介绍系统可靠性、可扩展性和可维护性的第一章还是接下来以数据库为核心的三章,对于数据模型、数据存储与索引以及数据编码的阐述,都有精彩之处。这里给自己插个旗,等到我下一次再次阅读这本书的时候,会补上第一部分的笔记。
数据复制意味着在通过网络连接的多台机器上保留相同数据的副本。复制数据在系统设计上的出发点主要为:
本章节有一个基本的假设: 将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。对于数据分片的问题会留在下一章进行讨论。
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的 变更(change),这里讨论了三种流行的变更复制算法:单主节点、多主节点和无主节点。几乎所有分布式数据库都使用这三种方法之一。在此基础上,讨论了同步复制和异步复制,以及如何处理失败的副本等内容。
本章考虑的最根本问题: 当存在多个副本时,如何确保所有数据都落在了所有的副本上?
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案为主从复制。它的工作原理如下:
现在主从复制,基本上在关系数据库,如mysql, postgre 以及nosql,如mong DB, redis,Espresso等都已经是基本的内置配置。但是深入到具体的实习机制,其实还是有一些差异。mysql 的binlog的方式可能是一种经典的主从复制的方案,值得去仔细体会。
复制系统的一个重要细节是:复制是 同步(synchronously) 发生的还是 异步(asynchronously) 发生的。这里结合书里的一个图去说明。
上图表达的场景是,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时间点,主库又会将数据变更转发给自己的从库。最终,主库通知客户更新成功。
从库 1 的复制是同步的:在向用户报告写入成功并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库 1 已经收到写入操作。而从库 2 的复制是异步的:主库发送消息,但不等待该从库的响应。
分布式系统的设计,很多时候就是一个折中的过程,同步复制和异步复制的讨论也是如此。异步的可用性虽然会损失一定的一致性,但在目前大部分应用追求高可用的前提下,依然是首选,毕竟同步失效带来的主库锁死写入的代价,通常是不可接受的。但是,对于这个问题,如果迁移到别的领域,比如安全性要求极高的嵌入式系统,则选择可能完全不同。
基于语句的复制
主库记录下它执行的每个写入请求(语句,即 statement)并将该语句日志发送给从库。对于关系数据库来说,这意味着每个INSERT、UPDATE 或 DELETE 语句都被转发给每个从库,每个从库解析并执行该 SQL 语句,就像直接从客户端收到一样。
这个方案,似乎最为简单直接。但是书中提到了三个限制:
1)任何调用 非确定性函数(nondeterministic) 的语句,可能会在每个副本上生成不同的值。例如,使用 NOW() 获取当前日期时间,或使用 RAND() 获取一个随机数。
2)如果语句使用了 自增列(auto increment),或者依赖于数据库中的现有数据(例如,UPDATE … WHERE <某些条件>),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
3)有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定性的。
基于预写日志(WAL)传输
存储引擎,对于覆写单个磁盘块的 B 树,每次修改都会先写入 预写式日志(Write Ahead Log, WAL),以便崩溃后索引可以恢复到一个一致的状态。该日志都是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给从库。通过使用这个日志,从库可以构建一个与主库一模一样的数据结构拷贝。 这种复制方法在 PostgreSQL 和 Oracle 等一些产品中被使用到。
其主要缺点是日志记录的数据非常底层:WAL 包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。 这开起来似乎问题不大,但对升级运维确实一个巨大的挑战,意味者数据库版本无法热更新,升级需要停机。
基于行的逻辑日志复制
对复制和存储引擎使用不同的日志格式,这样可以将复制日志从存储引擎的内部实现中解耦出来。这种复制日志被称为逻辑日志(logical log),以将其与存储引擎的(物理)数据表示区分开来。关系数据库的逻辑日志通常是以行的粒度来描述对数据库表的写入记录的序列:
1)对于插入的行,日志包含所有列的新值。
2)对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
3)对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
MySQL 的binlog就是使用了这种方法。 由于逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。
基于触发器的复制
这个思路是将复制操作上移到应用程序层。
触发器允许你将数据更改(写入事务)发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上一些必要的业务逻辑,就可以将数据变更复制到另一个系统去。例如,Databus for Oracle 和 Bucardo for Postgres就是这样工作的。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。但是这种方法赢一手最佳的灵活性,所以也是很多场景值得思考的方式。
当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 最终一致性(eventual consistency)。
在正常的操作中,复制延迟(replication lag),即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,但如果系统在接近极限的情况下运行,或网络中存在问题时,延迟可以轻而易举地超过几秒,甚至达到几分钟。因为滞后时间太长引入的不一致性,不仅仅是一个理论问题,更是应用设计中会遇到的真实问题。
目前主要的解决方法如下:
多主节点复制有意义的语境是在多数据中心下的。假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于但主节点的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 多主节点配置中可以在每个数据中心都有主库。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
当然这里,作者给出了两个近似的场景,也可以理解为多主节点:一种是离线客户端,即应用程序在断网之后仍然需要继续工作,另一种是多人协作编写文档,如Etherpad和Google Docs。
这个场景下最主要考虑的就是写冲突的处理。 本质上写冲突其实目前并没有特别优的解决方法,书中给出的原则:处理冲突的最简单的策略就是避免它们:如果应用程序可以确保特定记录的所有写入都通过同一个主节点,那么冲突就不会发生。由于多主节点复制处理的许多实现冲突相当不好,避免冲突是一个经常推荐的方法。 对于冲突合并,最实用的方案还是最后写入胜利(LWW, last write wins)。当然判定最终写入,可以依据一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),通常挑选最高ID的写入作为胜利者,并丢弃其他写入。
无主节点架构,最有影响力的是亚马逊的Dynamo系统,遵循该风格的数据库还有Riak,Cassandra和Voldemort等。
无主节点架构最主要的特点是允许任何副本直接接受来自客户端的写入。 在一些无主节点的实现中,客户端直接将写入发送到到几个副本中,而另一些情况下,一个协调者(coordinator)节点代表客户端进行写入。但与主节点不同,协调者不执行特定的写入顺序。
一个典型的无主复制的例子如下图所示,无主节复制场景和前两两类场景最大的差异是,客户端的写和读的请求都会发送给所有允许的副本。图示示例可以看出由于副本3宕机,它没有响应User1234的写入,当User 2345读取的时候,则会出现两个版本的数据。
在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是目前主要的冲突合并策略。