数据密集型应用系统设计
前言
DDIA 在大一的时候就有前辈推荐,碍于时间一直没空阅读,恰逢最近考试复习太枯燥且不想内耗于活水的焦虑感中,正好把这本书好好读一下,也做一个阅读笔记方便未来重新思考。
2024.1.13
第一章:可靠性、可伸缩性和可维护性
关于数据系统的思考
数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性但它们有迥然不同的访问模式,这意味着迥异的性能特征和实现手段。
近些年来,出现了许多新的工具。它们针对不同应用场景进行优化,因此不再适合生硬地归入传统类别。类别之间的界限变得越来越模糊,例如:数据存储可以被当成消息队列用(Redis),消息队列则带有类似数据库的持久保证(Apache Kafka)。
越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。
可靠性
造成错误的原因叫做 故障(fault),能预料并应对故障的系统特性可称为 容错(fault-tolerant)。
注意 故障(fault) 不同于 失效(failure)。故障 通常定义为系统的一部分状态偏离其标准,而 失效 则是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零,因此最好设计容错机制以防因 故障 而导致 失效。
反直觉的是,在这类容错系统中,通过故意触发来 提高 故障率是有意义的,例如:在没有警告的情况下随机地杀死单个进程。许多高危漏洞实际上是由糟糕的错误处理导致的,因此我们可以通过故意引发故障来确保容错机制不断运行并接受考验,从而提高故障自然发生时系统能正确处理的信心。
硬件故障
硬盘的 平均无故障时间(MTTF, mean time to failure) 约为 10 到 50 年。因此从数学期望上讲,在拥有 10000 个磁盘的存储集群上,平均每天会有 1 个磁盘出故障。
为了减少系统的故障率,第一反应通常都是增加单个硬件的冗余度,例如:磁盘可以组建 RAID,服务器可能有双路电源和热插拔 CPU,数据中心可能有电池和柴油发电机作为后备电源,某个组件挂掉时冗余组件可以立刻接管。
如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器,单服务器系统就需要计划停机。而允许机器失效的系统则可以一次修复一个节点,无需整个系统停机。
软件错误
这类错误难以预料,而且因为是跨节点相关的,所以比起不相关的硬件故障往往可能造成更多的 系统失效。比如失控进程会用尽一些共享资源,包括 CPU 时间、内存、磁盘空间或网络带宽。
导致这类软件故障的 BUG 通常会潜伏很长时间,直到被异常情况触发为止。虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现 差异(discrepancy) 时报警。
人为错误
一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了 10-25% 的服务中断。
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
以最小化犯错机会的方式设计系统。例如,精心设计的抽象、API 和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
将人们最容易犯错的地方与可能导致失效的地方 解耦(decouple)。特别是提供一个功能齐全的非生产环境 沙箱(sandbox),使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。
在各个层次进行彻底的测试,从单元测试、全系统集成测试到手动测试。
允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。
配置详细和明确的监控,比如性能指标和错误率。 在其他工程学科中这指的是 遥测(telemetry)。监控可以向我们发出预警信号,并允许我们检查是否有任何地方违反了假设和约束。
良好的管理实践与充分的培训 —— 一个复杂而重要的方面,但超出了本书的范围。
可靠性的重要性
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失,而电商网站的中断则可能会导致收入和声誉的巨大损失。
在某些情况下,我们可能会选择牺牲可靠性来降低开发成本或运营成本,但我们偷工减料时,应该清楚意识到自己在做什么。
DDIA 预言了一切
可伸缩性
可伸缩性(Scalability) 是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴在系统上的一维标签:说 “X 可伸缩” 或 “Y 不可伸缩” 是没有任何意义的。相反,讨论可伸缩性意味着考虑诸如 “如果系统以特定方式增长,有什么选项可以应对增长?” 和 “如何增加计算资源来处理额外的负载?” 等问题。
描述负载
负载可以用一些称为 负载参数(load parameters) 的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向 Web 服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
Eg. 推特发布贴文 & 主页时间线
大体上讲,这一对操作有两种实现方式。
发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。
为每个用户的主页时间线维护一个缓存。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。
方法 1 系统很难跟上主页时间线查询的负载。方法 2 的效果更好,因为发推频率比查询主页时间线的频率几乎低了两个数量级,所以在这种情况下,最好在写入时做更多的工作,而在读取时做更少的工作。
然而方法 2 的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约 75 个关注者,所以每秒 4.6k 的发推写入,变成了对主页时间线缓存每秒 345k 的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过 3000 万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的 3000 万次写入!
推特轶事的最终转折:推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并。
描述性能
增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什么影响?
增加负载参数并希望保持性能不变时,需要增加多少系统资源?
对于 Hadoop 这样的批处理系统,通常关心的是 吞吐量(throughput),即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间。对于在线系统,通常更重要的是服务的 响应时间(response time),即客户端发送请求到接收响应之间的时间。
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值 分布(distribution),而不是单个数值。
如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第 50 百分位点,有时缩写为 p50。
响应时间的高百分位点(也称为 尾部延迟,即 tail latencies)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时是以 99.9 百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了。
百分位点通常用于 服务级别目标(SLO, service level objectives) 和 服务级别协议(SLA, service level agreements)。 SLA 可能会声明,如果服务响应时间的中位数小于 200 毫秒,且 99.9 百分位点低于 1 秒,则认为服务工作正常。这些指标为客户设定了期望值,并允许客户在 SLA 未达标的情况下要求退款。
排队延迟(queueing delay) 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务(如受其 CPU 核数的限制),所以只要有少量缓慢的请求就能阻碍后续请求的处理,这种效应有时被称为 头部阻塞(head-of-line blocking) 。
在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大)。
应对负载的方法
人们经常讨论 纵向伸缩(scaling up,转向更强大的机器)和 横向伸缩(scaling out,将负载分布到多台小机器上)之间的对立。跨多台机器分配负载也称为 “无共享(shared-nothing)” 架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
有些系统是 弹性(elastic) 的,而其他系统则是手动伸缩。如果负载 极难预测(highly unpredictable),则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少。
跨多台机器部署 无状态服务(stateless services) 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。
大规模的系统架构通常是应用特定的 —— 没有一招鲜吃遍天的通用可伸缩架构。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。举个例子,用于处理每秒十万个请求(每个大小为 1 kB)的系统与用于处理每分钟 3 个请求(每个大小为 2GB)的系统看上去会非常不一样,尽管两个系统有同样的数据吞吐量。
可维护性
我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则:
可操作性(Operability)
便于运维团队保持系统平稳运行。
简单性(Simplicity)
从系统中消除尽可能多的 复杂度(complexity),使新工程师也能轻松理解系统。
可演化性(evolvability)
使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为 可扩展性(extensibility)、可修改性(modifiability) 或 可塑性(plasticity)。
可操作性
良好的可操作性意味着更轻松的日常工作,进而运维团队能专注于高价值的事情。数据系统可以通过各种方式使日常任务更轻松:
通过良好的监控,提供对系统内部状态和运行时行为的 可见性(visibility)。
为自动化提供良好支持,将系统与标准化工具相集成。
避免依赖单台机器(在整个系统继续不间断运行的情况下允许机器停机维护)。
提供良好的文档和易于理解的操作模型(“如果做 X,会发生 Y”)。
提供良好的默认行为,但需要时也允许管理员自由覆盖默认值。
有条件时进行自我修复,但需要时也允许管理员手动控制系统状态。
行为可预测,最大限度减少意外。
简单性
小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。
复杂度(complexity) 有各种可能的症状,例如:模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的 Hack、需要绕开的特例等等。
用于消除 额外复杂度 的最好工具之一是 抽象(abstraction)。一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。
可演化性
系统的需求永远不变,基本是不可能的。更可能的情况是,它们处于常态的变化中,例如:你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能等。
修改数据系统并使其适应不断变化需求的容易程度,是与 简单性 和 抽象性 密切相关的:简单易懂的系统通常比复杂系统更容易修改。但由于这是一个非常重要的概念,我们将用一个不同的词来指代数据系统层面的敏捷性: 可演化性(evolvability)。
第二章:数据模型与查询语言
一个复杂的应用程序可能会有更多的中间层次,比如基于 API 的 API,不过基本思想仍然是一样的:每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作(例如数据库厂商的工程师和使用数据库的应用程序开发人员)。
关系模型与文档模型
现在最著名的数据模型可能是 SQL。它基于 Edgar Codd 在 1970 年提出的关系模型:数据被组织成 关系(SQL 中称作 表),其中每个关系是 元组(SQL 中称作 行) 的无序集合。
NoSQL 的诞生
“NoSQL” 这个名字让人遗憾,因为实际上它并没有涉及到任何特定的技术。最初它只是作为一个醒目的 Twitter 标签,用在 2009 年一个关于分布式,非关系数据库上的开源聚会上。好在 NoSQL 被追溯性地重新解释为 不仅是 SQL(Not Only SQL)。
采用 NoSQL 数据库的背后有几个驱动因素,其中包括:
需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
相比商业数据库产品,免费和开源软件更受偏爱
关系模型不能很好地支持一些特殊的查询操作
受挫于关系模型的限制性,渴望一种更具多动态性与表现力的数据模型
在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用,这种想法有时也被称为 混合持久化(polyglot persistence)。
对象关系不匹配
如果数据存储在关系表中,那么需要一个笨拙的转换层,处于应用程序代码中的对象和表,行,列的数据库模型之间。模型之间的不连贯有时被称为 **阻抗不匹配(impedance mismatch)。
对象关系映射(ORM object-relational mapping) 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。
Eg. Linkedln 简历
整个简介可以通过一个唯一的标识符 user_id
来标识。像 first_name
和 last_name
这样的字段每个用户只出现一次,所以可以在 User 表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对 User 表提供外键引用。
对于一个像简历这样自包含文档的数据结构而言,JSON 表示是非常合适的,可以使用面向文档的数据库(如 MongoDB)
有一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。JSON 表示比多表模式具有更好的 局部性(locality)。如果在关系型示例中获取简介,那需要执行多个查询(通过 user_id
查询每个表),或者在 User 表与其下属表之间混乱地执行多路连接。而在 JSON 表示中,所有相关信息都在同一个地方,一个查询就足够了。
这里看起来好像会引起一些新的话题,例如 MongoDB 的使用场景,文档数据库在这种时候确实是比关系型数据库更加适合,但是如何把握住这个点是一个值得思考的问题。
多对一和多对多的关系
简历中的会存 region_id
,它是以 ID,而不是纯字符串形式给出的。为什么?
如果用户界面用一个自由文本字段来输入区域和行业,那么将他们存储为纯文本字符串是合理的。另一方式是给出地理区域和行业的标准化的列表,并让用户从下拉列表或自动填充器中进行选择,其优势如下:
各个简介之间样式和拼写统一
避免歧义(例如,如果有几个同名的城市)
易于更新 —— 名称只存储在一个地方,如果需要更改(例如,由于政治事件而改变城市名称),很容易进行全面更新。
本地化支持 —— 当网站翻译成其他语言时,标准化的列表可以被本地化,使得地区和行业可以使用用户的语言来显示
更好的搜索 —— 例如,搜索华盛顿州的慈善家就会匹配这份简介,因为地区列表可以编码记录西雅图在华盛顿这一事实(从 “Greater Seattle Area” 这个字符串中看不出来)
使用 ID 的好处是,ID 对人类没有任何意义,因而永远不需要改变。任何对人类有意义的东西都可能需要在将来某个时候改变 —— 如果这些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,也存在不一致的风险(一些副本被更新了,还有些副本没有被更新)。去除此类重复是数据库 规范化(normalization) 的关键思想。
在关系数据库中,通过 ID 来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构没有必要用连接,对连接的支持通常很弱。
如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。
文档数据库是否在重蹈覆辙
IBM 的信息管理系统(IMS) 的设计中使用了一个相当简单的数据模型,称为 层次模型(hierarchical model),它与文档数据库使用的 JSON 模型有一些惊人的相似之处。
同文档数据库一样,IMS 能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似。
那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是 关系模型(relational model,它变成了 SQL,并统治了世界)和 网状模型(network model,最初很受关注,但最终变得冷门)。
网状模型
网状模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘上)。访问记录的唯一方法是跟随从根记录起沿这些链路所形成的路径。这被称为 访问路径(access path)。
最简单的情况下,访问路径类似遍历链表:从列表头开始,每次查看一条记录,直到找到所需的记录。但在多对多关系的情况中,数条不同的路径可以到达相同的记录,网状模型的程序员必须跟踪这些不同的访问路径。
查询和更新数据库的代码变得复杂不灵活。无论是分层还是网状模型,如果你没有所需数据的路径,就会陷入困境。你可以改变访问路径,但是必须浏览大量手写数据库查询代码,并重写来处理新的访问路径。更改应用程序的数据模型是很难的。
关系模型
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个 关系(表) 只是一个 元组(行) 的集合,仅此而已。
外键约束允许对修改进行限制,但对于关系模型这并不是必选项。即使有约束,外键连接在查询时执行,而在 CODASYL 中,连接在插入时高效完成。
在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。如果想按新的方式查询数据,你可以声明一个新的索引,查询会自动使用最合适的那些索引。
与文档数据库相比
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录(一对多关系,如 positions
,education
和 contact_info
),而不是在单独的表中。
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为 外键,在文档模型中称为 文档引用。该标识符在读取时通过连接或后续查询来解析。
关系型数据库与文档数据库在今日的对比
支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
哪种数据模型更有助于简化应用代码
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
但如果你的应用程序确实会用到多对多关系,那么文档模型就没有那么诱人了。尽管应用程序代码可以通过向数据库发出多个请求的方式来模拟连接,但这也将复杂性转移到应用程序中,而且通常也会比由数据库内的专用代码更慢。
文档模型中的模式灵活性
文档数据库有时称为 无模式(schemaless),但这具有误导性,因为读取数据的代码通常假定某种结构 —— 即存在隐式模式,但不由数据库强制执行。一个更精确的术语是 读时模式(即 schema-on-read,数据的结构是隐含的,只有在数据被读取时才被解释)。
在应用程序想要改变其数据格式的情况下,这些方法之间的区别尤其明显。例如,假设你把每个用户的全名存储在一个字段中,而现在想分别存储名字和姓氏。在文档数据库中,只需开始写入具有新字段的新文档,并在应用程序中使用代码来处理读取旧文档的情况。例如:
另一方面,在 “静态类型” 数据库模式中,通常会执行以下 迁移(migration) 操作:
模式变更的速度很慢,而且要求停运。大型表上运行 UPDATE
语句在任何数据库上都可能会很慢,因为每一行都需要重写。要是不可接受的话,应用程序可以将 first_name
设置为默认值 NULL
,并在读取时再填充,就像使用文档数据库一样。
当由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构时,读时模式更具优势。例如,如果:
存在许多不同类型的对象,将每种类型的对象放在自己的表中是不现实的。
数据的结构由外部系统决定。你无法控制外部系统且它随时可能变化。
查询的数据局部性
如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入。
文档与关系数据库的融合
随着时间的推移,关系数据库和文档数据库似乎变得越来越相似,这是一件好事:数据模型相互补充,如果一个数据库能够处理类似文档的数据,并能够对其执行关系查询,那么应用程序就可以使用最符合其需求的功能组合。
关系模型和文档模型的混合是未来数据库一条很好的路线。
数据查询语言
SQL 是一种 声明式 查询语言,而 IMS 和 CODASYL 使用 命令式 代码来查询数据库。
命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下,逐行地遍历代码,评估条件,更新变量,并决定是否再循环一遍。
声明式查询语言是迷人的,因为它通常比命令式 API 更加简洁和容易。但更重要的是,它还隐藏了数据库引擎的实现细节,这使得数据库系统可以在无需对查询做任何更改的情况下进行性能提升。
声明式语言往往适合并行执行。现在,CPU 的速度通过核心的增加变得更快,而不是以比以前更高的时钟速度运行。命令代码很难在多个核心和多个机器之间并行化,因为它指定了指令必须以特定顺序执行。声明式语言更具有并行执行的潜力,因为它们仅指定结果的模式,而不指定用于确定结果的算法。在适当情况下,数据库可以自由使用查询语言的并行实现。
Web 上的声明式查询
假设你有一个关于海洋动物的网站。用户当前正在查看鲨鱼页面,因此你将当前所选的导航项目 “鲨鱼” 标记为当前选中项目。
现在想让当前所选页面的标题具有一个蓝色的背景,以便在视觉上突出显示。使用 CSS 实现起来非常简单:
想象一下,必须使用命令式方法的情况会是如何。在 Javascript 中,使用 文档对象模型(DOM) API,其结果可能如下所示:
这段 JavaScript 代码命令式地将元素设置为蓝色背景,但是代码看起来很糟糕。不仅比 CSS 更长,更难理解,而且还有一些严重的问题:如果选定的类被移除(例如,因为用户点击了不同的页面),即使代码重新运行,蓝色背景也不会被移除 - 因此该项目将保持突出显示,直到整个页面被重新加载。使用 CSS,浏览器会自动检测 li.selected > p
规则何时不再适用,并在选定的类被移除后立即移除蓝色背景。
在 Web 浏览器中,使用声明式 CSS 样式比使用 JavaScript 命令式地操作样式要好得多。类似地,在数据库中,使用像 SQL 这样的声明式查询语言比使用命令式查询 API 要好得多。
MapReduce 查询
MapReduce 既不是一个声明式的查询语言,也不是一个完全命令式的查询 API,而是处于两者之间:查询的逻辑用代码片段来表示,这些代码片段会被处理框架重复性调用。
map 和 reduce 函数在功能上有所限制:它们必须是 纯 函数,这意味着它们只使用传递给它们的数据作为输入,它们不能执行额外的数据库查询,也不能有任何副作用。这些限制允许数据库以任何顺序运行任何功能,并在失败时重新运行它们。
MongoDB 2.2 添加了一种叫做 聚合管道 的声明式查询语言的支持。用这种语言表述鲨鱼计数查询如下所示:
聚合管道语言的表现力与 SQL 相当,但是它使用基于 JSON 的语法而不是 SQL。这个故事的寓意是:NoSQL 系统可能会意外发现自己只是重新发明了一套经过乔装改扮的 SQL。
图数据模型
一个图由两种对象组成:顶点(vertices,也称为 节点,即 nodes,或 实体,即 entities),和 边(edges,也称为 关系,即 relationships,或 弧,即 arcs)。多种数据可以被建模为一个图形。典型的例子包括:
社交图谱
顶点是人,边指示哪些人彼此认识。
公路或铁路网络
顶点是交叉路口,边线代表它们之间的道路或铁路线。
可以将那些众所周知的算法运用到这些图上:例如,汽车导航系统搜索道路网络中两点之间的最短路径。
图并不局限于同类数据:同样强大地是,图提供了一种一致的方式,用来在单个数据存储中存储完全不同类型的对象。例如,Facebook 维护一个包含许多不同类型的顶点和边的单个图:顶点表示人,地点,事件,签到;边缘表示哪些人是彼此的朋友,哪个签到发生在何处等等。
属性图
可以将图存储看作由两个关系表组成:一个存储顶点,另一个存储边,头部和尾部顶点用来存储每条边;如果你想要一组顶点的输入或输出边,你可以分别通过 head_vertex
或 tail_vertex
来查询 edges
表。
任何顶点都可以有一条边连接到任何其他顶点。没有模式限制哪种事物可不可以关联。
给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动。
通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息。
Cypher 查询语言
Cypher 是属性图的声明式查询语言,为 Neo4j 图形数据库而发明。
每个顶点都有一个像 USA
或 Idaho
这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:(Idaho) - [:WITHIN] ->(USA)
创建一条标记为 WITHIN
的边,Idaho
为尾节点,USA
为头节点。
Eg. 找到所有从美国移民到欧洲的人的名字
person
顶点拥有一条到某个顶点的BORN_IN
出边。从那个顶点开始,沿着一系列WITHIN
出边最终到达一个类型为Location
,name
属性为United States
的顶点。person
顶点还拥有一条LIVES_IN
出边。沿着这条边,可以通过一系列WITHIN
出边最终到达一个类型为Location
,name
属性为Europe
的顶点。
对于这样的 Person
顶点,返回其 name
属性。
SQL 中的图查询
一个人的 LIVES_IN
边可以指向任何类型的位置:街道、城市、地区、国家等。一个城市可以在(WITHIN)一个地区内,一个地区可以在(WITHIN)在一个州内,一个州可以在(WITHIN)一个国家内,等等。LIVES_IN
边可以直接指向正在查找的位置,或者一个在位置层次结构中隔了数层的位置。
在 Cypher 中,用 WITHIN*0..
非常简洁地表述了上述事实:“沿着 WITHIN
边,零次或多次”。它很像正则表达式中的 *
运算符。
自 SQL:1999,查询可变长度遍历路径的思想可以使用称为 递归公用表表达式(WITH RECURSIVE
语法)的东西来表示。但是,与 Cypher 相比,其语法非常笨拙。
同一个查询,用某一个查询语言可以写成 4 行,而用另一个查询语言需要 29 行,这恰恰说明了不同的数据模型是为不同的应用场景而设计的。选择适合应用程序的数据模型非常重要。
第三章:存储与检索
数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
驱动数据库的数据结构
日志(log) 这个词通常指应用日志:即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用 日志 这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,它可以使用二进制格式,并仅能由其他程序读取。
为了高效查找数据库中特定键的值,我们需要一个数据结构:索引(index)。索引背后的大致思想是通过保存一些额外的元数据作为路标来帮助你找到想要的数据。如果你想以几种不同的方式搜索同一份数据,那么你也许需要在数据的不同部分上建立多个索引。
这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容。你可以选择那些能为应用带来最大收益而且又不会引入超出必要开销的索引。
散列索引
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,寻找(seek) 该位置并读取该值即可。
这种简单的方法非常适合每个键的值经常更新的情况。例如,键可能是某个猫咪视频的网址(URL),而值可能是该视频被播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的 段(segment),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 压缩(compaction)。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
仅追加日志似乎很浪费:为什么不直接在文件里更新,用新值覆盖旧值?仅追加的设计之所以是个好的设计,有如下几个原因:
追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。
如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了。例如,当一个数据值被更新的时候发生崩溃,你不用担心文件里将会同时包含旧值和新值各自的一部分。
合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。
但是,散列表索引也有其局限性:
散列表必须能放进内存。如果你有非常多的键,那就难以处理。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O,而后者耗尽时想要再扩充是很昂贵的,并且需要很烦琐的逻辑去解决散列冲突。
范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
SSTables 和 LSM 树
我们可以对段文件的格式做一个简单的改变:要求键值对的序列按键排序。
我们把这个格式称为 排序字符串表(Sorted String Table),简称 SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable 有几个大的优势:
即使文件大于可用内存,合并段的操作仍然是简单而高效的。你并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。
为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完。
可以将这些记录分组为块(block),并在将其写入硬盘之前对其进行压缩。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对 I/O 带宽的使用。
构建和维护 SSTables
有新写入时,将其添加到内存中的平衡树数据结构(例如红黑树)。这个内存树有时被称为 内存表(memtable)。
当 内存表 大于某个阈值(通常为几兆字节)时,将其作为 SSTable 文件写入硬盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的 SSTable 文件将成为数据库中最新的段。当该 SSTable 被写入硬盘时,新的写入可以在一个新的内存表实例上继续进行。
收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的硬盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。
时不时地,在后台运行一个合并和压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉。
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入硬盘)将丢失。为了避免这个问题,我们可以在硬盘上保存一个单独的日志,每个写入都会立即被追加到这个日志上,就像在前面的章节中所描述的那样。这个日志没有按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到 SSTable 时,相应的日志都可以被丢弃。
用 SSTables 制作 LSM 树
这种索引结构最早由 Patrick O'Neil 等人发明,且被命名为日志结构合并树(或 LSM 树),它是基于更早之前的日志结构文件系统来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为 LSM 存储引擎。
性能优化
要让存储引擎在实践中表现良好涉及到大量设计细节。例如,当查找数据库中不存在的键时,LSM 树算法可能会很慢:你必须先检查内存表,然后查看从最近的到最旧的所有的段(可能还必须从硬盘读取每一个段文件),然后才能确定这个键不存在。为了优化这种访问,存储引擎通常使用额外的布隆过滤器。
还有一些不同的策略来确定 SSTables 被压缩和合并的顺序和时间。最常见的选择是 size-tiered 和 leveled compaction。对于 sized-tiered,较新和较小的 SSTables 相继被合并到较旧的和较大的 SSTable 中。对于 leveled compaction,key (按照分布范围)被拆分到较小的 SSTables,而较旧的数据被移动到单独的层级(level),这使得压缩(compaction)能够更加增量地进行,并且使用较少的硬盘空间。
即使有许多微妙的东西,LSM 树的基本思想 —— 保存一系列在后台合并的 SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,你可以高效地执行范围查询(扫描所有从某个最小值到某个最大值之间的所有键),并且因为硬盘写入是连续的,所以 LSM 树可以支持非常高的写入吞吐量。
B 树
在几乎所有的关系数据库中,B 树仍然是标准的索引实现,许多非关系数据库也会使用到 B 树。
像 SSTables 一样,B 树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这也就是仅有的相似之处了。B 树将数据库分解成固定大小的 块(block) 或 分页(page),传统上大小为 4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为硬盘空间也是按固定大小的块来组织的。每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树。
一个页面会被指定为 B 树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,根页面上每两个引用之间的键,表示相邻子页面管理的键的范围(边界)。
如果要更新 B 树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区。
大多数数据库可以放入一个三到四层的 B 树,所以你不需要追踪多个页面引用来找到你正在查找的页面(分支因子为 500 的 4KB 页面的四层树可以存储多达 256TB 的数据)。
让 B 树更可靠
B 树的基本底层写操作是用新数据覆写硬盘上的页面,并假定覆写不改变页面的位置。这与日志结构索引(如 LSM 树)形成鲜明对比,后者只追加到文件(并最终删除过时的文件)。
在固态硬盘上,由于 SSD 必须一次擦除和重写相当大的存储芯片块,所以会发生更复杂的事情,例如,如果因为插入导致页面过满而拆分页面,则需要写入新拆分的两个页面,并覆写其父页面以更新对两个子页面的引用。如果数据库在系列操作进行到一半时崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面没有被任何页面引用) 。
为了使数据库能处理异常崩溃的场景,B 树实现通常会带有一个额外的硬盘数据结构:预写式日志(WAL,即 write-ahead log,也称为 重做日志,即 redo log)。这是一个仅追加的文件,每个 B 树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时,这个日志将被用来使 B 树恢复到一致的状态。
另外还有一个更新页面的复杂情况是,如果多个线程要同时访问 B 树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常是通过使用 锁存器(latches,轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰新接收到的查询,并且能够时不时地将段文件切换为新的(该切换是原子操作)。
B 树的优化
不同于覆写页面并维护 WAL 以支持崩溃恢复,一些数据库(如 LMDB)使用写时复制方案。经过修改的页面被写入到不同的位置,并且还在树中创建了父页面的新版本,以指向新的位置。这种方法对于并发控制也很有用(MVCC)。
我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级(B+ 树)。
通常,页面可以放置在硬盘上的任何位置,如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次性重写一大段存储,所以它们更容易使顺序键在硬盘上连续存储。
额外的指针被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
比较 B 树和 LSM 树
尽管 B 树实现通常比 LSM 树实现更成熟,但 LSM 树由于性能特征也非常有趣。根据经验,通常 LSM 树的写入速度更快,而 B 树的读取速度更快。 LSM 树上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的 SSTables。
LSM 树的优点
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入硬盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入硬盘的次数越多,可用硬盘带宽内它能处理的每秒写入次数就越少。
进而,LSM 树通常能够比 B 树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎的配置和工作负载),部分是因为它们顺序地写入紧凑的 SSTable 文件而不是必须覆写树中的几个页面。这种差异在机械硬盘上尤其重要,其顺序写入比随机写入要快得多。
LSM 树可以被压缩得更好,因此通常能比 B 树在硬盘上产生更小的文件。B 树存储引擎会由于碎片化(fragmentation)而留下一些未使用的硬盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于 LSM 树不是面向页面的,并且会通过定期重写 SSTables 以去除碎片。
LSM 树的缺点
压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。
硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中,这些锁可以直接附加到树上。
其他索引结构
次级索引(secondary indexes)也很常见。在关系数据库中,你可以使用 CREATE INDEX
命令在同一个表上创建多个次级索引,而且这些索引通常对于有效地执行联接(join)而言至关重要。
将值存储在索引中
索引中的键是查询要搜索的内容,而其值可以是以下两种情况之一:它可以是实际的行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为 堆文件(heap file),并且存储的数据没有特定的顺序(它可以是仅追加的,或者它可以跟踪被删除的行以便后续可以用新的数据进行覆盖)。堆文件方法很常见,因为它避免了在存在多个次级索引时对数据的复制:每个索引只引用堆文件中的一个位置,实际的数据都保存在一个地方。
在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将被索引的行直接存储在索引中。这被称为聚集索引(clustered index)。在 聚集索引(在索引中存储所有的行数据)和 非聚集索引(仅在索引中存储对数据的引用)之间的折衷被称为 覆盖索引(covering index) 或 包含列的索引(index with included columns),其在索引内存储表的一部分列。这允许通过单独使用索引来处理一些查询(这种情况下,可以说索引 覆盖(cover) 了查询)。
多列索引
最常见的多列索引被称为 联合索引(concatenated index) ,它通过将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)。由于排序顺序,索引可以用来查找所有具有特定姓氏的人,或所有具有特定姓氏 - 名字组合的人。但如果你想找到所有具有特定名字的人,这个索引是没有用的。
全文搜索和模糊索引
到目前为止所讨论的所有索引都假定你有确切的数据,并允许你查询键的确切值或具有排序顺序的键的值范围。他们不允许你做的是搜索类似的键,如拼写错误的单词。这种模糊的查询需要不同的技术。
例如,全文搜索引擎通常允许搜索目标从一个单词扩展为包括该单词的同义词,忽略单词的语法变体,搜索在相同文档中的近义词,并且支持各种其他取决于文本的语言分析功能。为了处理文档或查询中的拼写错误,Lucene 能够在一定的编辑距离内搜索文本(编辑距离 1 意味着单词内发生了 1 个字母的添加、删除或替换)。
Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在 LevelDB 中,这个内存中的索引是一些键的稀疏集合,但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于 trie 。这个自动机可以转换成 Levenshtein 自动机,它支持在给定的编辑距离内有效地搜索单词。
其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。
在内存中存储一切
随着 RAM 变得更便宜,每 GB 成本的论据被侵蚀了。许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,包括可能分布在多个机器上。这导致了内存数据库的发展。
某些内存中的键值存储(如 Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的 RAM)来实现,也可以将更改日志写入硬盘,还可以将定时快照写入硬盘或者将内存中的状态复制到其他机器上。
除了性能,内存数据库的另一个有趣的地方是提供了难以用基于硬盘的索引实现的数据模型。例如,Redis 为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
事务处理还是分析
属性 | 事务处理系统 OLTP | 分析系统 OLAP |
---|---|---|
主要读取模式 | 查询少量记录,按键读取 | 在大批量记录上聚合 |
主要写入模式 | 随机访问,写入要求低延时 | 批量导入(ETL)或者事件流 |
主要用户 | 终端用户,通过 Web 应用 | 内部数据分析师,用于决策支持 |
处理的数据 | 数据的最新状态(当前时间点) | 随时间推移的历史事件 |
数据集尺寸 | GB ~ TB | TB ~ PB |
事务处理和分析查询使用了相同的数据库。 SQL 在这方面已证明是非常灵活的:对于 OLTP 类型的查询以及 OLAP 类型的查询来说效果都很好。尽管如此,在二十世纪八十年代末和九十年代初期,企业有停止使用 OLTP 系统进行分析的趋势,转而在单独的数据库上运行分析。这个单独的数据库被称为 数据仓库(data warehouse)。 |
数据仓库
数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响 OLTP 操作。数据仓库包含公司各种 OLTP 系统中所有的只读数据副本。从 OLTP 数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为 抽取 - 转换 - 加载(ETL)。
OLTP 数据库和数据仓库之间的分歧
一个数据仓库和一个关系型 OLTP 数据库看起来很相似,因为它们都有一个 SQL 查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都只是重点支持事务处理负载和分析工作负载这两者中的一个,而不是都支持。
星型和雪花型:分析的模式
在分析型业务中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式。
事实表的每一行代表在特定时间发生的事件。如果我们分析的是网站流量,则每行可能代表一个用户的页面浏览或点击。通常情况下,事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。
事实表中的一些列是属性,事实表中的其他列是对其他表(称为维度表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件发生的对象、内容、地点、时间、方式和原因。
这个模板的变体被称为雪花模式,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表格,并且表格中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 表格中。雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单。
列式存储
尽管事实表通常超过 100 列,但典型的数据仓库查询一次只会访问其中 4 个或 5 个列( “SELECT *
” 查询很少用于分析)。
在大多数 OLTP 数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。在查询时虽然有索引,但是也需要把所有的行加载到内存,这需要很长的时间。
列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。
列式存储布局依赖于每个列文件包含相同顺序的行。 因此,如果你需要重新组装完整的行,你可以从每个单独的列文件中获取第 23 项,并将它们放在一起形成表的第 23 行。
列压缩
通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为 1,否则为 0。
Cassandra 和 HBase 有一个列族(column families)的概念,他们从 Bigtable 继承。然而,把它们称为列式(column-oriented)是非常具有误导性的:在每个列族中,它们将一行中的所有列与行键一起存储,并且不使用列压缩。因此,Bigtable 模型仍然主要是面向行的。
内存带宽和矢量化处理
对于数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用内存到 CPU 缓存的带宽,避免 CPU 指令处理流水线中的分支预测错误和闲置等待,以及在现代 CPU 上使用单指令多数据(SIMD)指令来加速运算。
除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用 CPU 周期。例如,查询引擎可以将一整块压缩好的列数据放进 CPU 的 L1 缓存中,然后在紧密的循环(即没有函数调用)中遍历。相比于每条记录的处理都需要大量函数调用和条件判断的代码,CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被同时放进容量有限的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理(vectorized processing)。
列式存储中的排序顺序
对每列分别执行排序是没有意义的,因为那样就没法知道不同列中的哪些项属于同一行。我们只能在明确一列中的第 k 项与另一列中的第 k 项属于同一行的情况下,才能重建出完整的行。
相反,数据的排序需要对一整行统一操作,即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解,来选择表格中用来排序的列。例如,如果查询通常以日期范围为目标,例如“上个月”,则可以将 date_key
作为第一个排序键。这样查询优化器就可以只扫描近 1 个月范围的行了,这比扫描所有行要快得多。
按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,将会得到一个相同的值连续重复多次的序列。
几个不同的排序顺序
既然不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?反正数据都需要做备份,以防单点故障时丢失数据。因此你可以用不同排序方式来存储冗余数据,以便在处理查询时,调用最适合查询模式的版本。
写入列式存储
列式存储、压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。使用 B 树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。
幸运的是,本章前面已经看到了一个很好的解决方案:LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。
查询操作需要检查硬盘上的列数据和内存中的最近写入,并将两者的结果合并起来。但是,查询优化器对用户隐藏了这个细节。
物化视图
数据仓库的另一个值得一提的方面是物化聚合(materialized aggregates)。如前所述,数据仓库查询通常涉及一个聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果相同的聚合被许多不同的查询使用,可以提前缓存起来。
创建这种缓存的一种方式是物化视图(Materialized View)。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,会被写入硬盘,而虚拟视图只是编写查询的一个捷径。当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。
第四章:编码与演化
新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。系统想要继续顺利运行,就需要保持 双向兼容性:
向后兼容 (backward compatibility)
新的代码可以读取由旧的代码写入的数据。
向前兼容 (forward compatibility)
旧的代码可以读取由新的代码写入的数据。
编码数据的格式
如果要将数据写入文件,或通过网络发送,则必须将其 编码(encode) 为某种自包含的字节序列(例如,JSON 文档)。 由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同。
所以,需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为 编码(Encoding) (也称为 序列化(serialization) 或 编组(marshalling)),反过来称为 解码(Decoding)。
语言特定的格式
这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。
在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了前向后向兼容性带来的麻烦问题。
Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭著。
因此,除非临时使用,采用语言内置编码通常是一个坏主意。
JSON、XML 和二进制变体
JSON,XML 和 CSV 属于文本格式,因此具有人类可读性,但存在一些微妙的问题:
数字(numbers) 编码有很多模糊之处。在 XML 和 CSV 中,无法区分数字和碰巧由数字组成的字符串(除了引用外部模式)。 JSON 虽然区分字符串与数字,但并不区分整数和浮点数,并且不能指定精度。
无法处理大数字。例如大于 253253 的整数无法使用 IEEE 754 双精度浮点数精确表示,因此在使用浮点数(例如 JavaScript)的语言进行分析时,这些数字会变得不准确。 Twitter 有一个关于大于 253253 的数字的例子,它使用 64 位整数来标识每条推文。 Twitter API 返回的 JSON 包含了两个推特 ID,一个是 JSON 数字,另一个是十进制字符串,以解决 JavaScript 程序中无法正确解析数字的问题。
JSON 和 XML 对 Unicode 字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(即不带 字符编码 (character encoding) 的字节序列)。二进制串是很有用的功能,人们通过使用 Base64 将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为 Base64 编码的二进制数据。这种方案虽然管用,但比较 Hacky,并且会增加三分之一的数据大小。
二进制编码
JSON 比 XML 简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本 JSON(MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等) 和 XML(例如 WBXML 和 Fast Infoset)的出现。这些格式已经在各种各样的领域中采用,但是没有一个能像文本版 JSON 和 XML 那样被广泛采用。
所有的 JSON 的二进制编码在这方面是相似的。空间节省了一丁点(以及解析加速)是否能弥补可读性的损失,谁也说不准。
Thrift 与 Protocol Buffers
可以使用 Thrift 接口定义语言(IDL) 来描述模式,如下所示:
Protocol Buffers 的等效模式定义看起来非常相似:
Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类。
需要注意的一个细节:在前面所示的模式中,每个字段被标记为必需或可选,但是这对字段如何编码没有任何影响(二进制数据中没有任何字段指示某字段是否必须)。区别在于,如果字段设置为 required
,但未设置该字段,则所需的运行时检查将失败,这对于捕获错误非常有用。
字段标签和模式演变
字段标记对编码数据的含义至关重要。你可以更改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因为这会使所有现有的编码数据无效。
你可以添加新的字段到架构,只要你给每个字段一个新的标签号码。如果旧的代码试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了向前兼容性:旧代码可以读取由新代码编写的记录。
只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的字段,你不能设置为必需。如果你要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入你添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后 添加的每个字段必须是可选的或具有默认值。
删除一个字段就像添加一个字段,只是这回要考虑的是向前兼容性。这意味着你只能删除一个可选的字段(必需字段永远不能删除),而且你不能再次使用相同的标签号码(因为你可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。
数据类型和模式演变
假设你将一个 32 位的整数变成一个 64 位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用 32 位变量来保存该值。如果解码的 64 位值不适合 32 位,则它将被截断。
Avro
Avro 也使用模式来指定正在编码的数据的结构。 它有两种模式语言:一种(Avro IDL)用于人工编辑,一种(基于 JSON)更易于机器读取。
动态生成的模式
与 Protocol Buffers 和 Thrift 相比,Avro 方法的一个优点是架构不包含任何标签号码。Avro 对动态生成的模式更友善。例如,假如你有一个关系数据库,你想要把它的内容转储到一个文件中,并且你想使用二进制格式来避免前面提到的文本格式(JSON,CSV,SQL)的问题。如果你使用 Avro,你可以很容易地从关系模式生成一个 Avro 模式,并使用该模式对数据库内容进行编码,并将其全部转储到 Avro 对象容器文件中。
相比之下,如果你为此使用 Thrift 或 Protocol Buffers,则字段标签可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射。这种动态生成的模式根本不是 Thrift 或 Protocol Buffers 的设计目标,而是 Avro 的。
代码生成和动态类型的语言
Thrift 和 Protobuf 依赖于代码生成:在定义了模式之后,可以使用你选择的编程语言生成实现此模式的代码。
在动态类型编程语言(如 JavaScript、Ruby 或 Python)中,生成代码没有太多意义,因为没有编译时类型检查器来满足。
Avro 为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件,你可以简单地使用 Avro 库打开它,并以与查看 JSON 文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。
模式的优点
它们可以比各种 “二进制 JSON” 变体更紧凑,因为它们可以省略编码数据中的字段名称。
模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的。
维护一个模式的数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。
数据流的类型
通过数据库
通过服务调用
通过异步消息传递
数据库中的数据流
数据库中的一个值可能会被更新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,数据库也经常需要向前兼容。
假设你将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的字段不变,即使它不能被解释。
在不同的时间写入不同的值
对于五年前的数据来说,除非对其进行显式重写,否则它仍然会以原始编码形式存在。这种现象有时被概括为:数据的生命周期超出代码的生命周期。
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据。读取旧行时,对于磁盘上的编码数据缺少的任何列,数据库将填充空值。
因此,模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
归档存储
也许你不时为数据库创建一个快照,例如备份或加载到数据仓库。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。
由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet。
服务中的数据流:REST 与 RPC
在网络通信中,最常见的角色分为客户端和服务器。服务器通过公开 API,客户端则通过向 API 发出请求以实现连接,Web 就是利用这种方式运作的。
Web 浏览器并不是唯一的客户端类型,移动设备或桌面计算机上的应用程序也可以发出网络请求,客户端 JavaScript 程序也可以成为 HTTP 客户端。服务器返回的通常不仅仅是用于展示的 HTML,还可能包括客户端程序需要处理的编码数据,如 JSON 等。
服务器也可能是另一服务器的客户端,这种方式常用于分解大型应用程序,形成所谓的服务化架构(SOA)或微服务架构,这使得应用程序更易于更改和维护,也增强了系统的拓展性。
服务对客户端的处理也有一定的限制,这提供了一定的封装性和隔离性。我们需要考虑到服务器和客户端的旧版本和新版本需要兼容,在不同版本的服务 API 之间要保持数据编码的兼容性。
Web 服务
当服务使用 HTTP 作为底层通信协议时,可称之为 Web 服务。
有两种流行的 Web 服务方法:REST 和 SOAP。他们在哲学方面几乎是截然相反的,往往也是各自支持者之间的激烈辩论的主题。
REST 不是一个协议,而是一个基于 HTTP 原则的设计哲学。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制,身份验证和内容类型协商。与 SOAP 相比,REST 已经越来越受欢迎,至少在跨组织服务集成的背景下,并经常与微服务相关。根据 REST 原则设计的 API 称为 RESTful。
SOAP Web 服务的 API 使用称为 Web 服务描述语言(WSDL)的基于 XML 的语言来描述。 WSDL 支持代码生成,客户端可以使用本地类和方法调用(编码为 XML 消息并由框架再次解码)访问远程服务。
远程过程调用(RPC)的问题
网络请求是不可预测的:请求或响应可能由于网络问题会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在你的控制范围之内。
由于超时,返回时可能没有结果。在这种情况下,你根本不知道发生了什么。
如果你重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非你在协议中建立数据去重机制(幂等性,即 idempotence)。
每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的。
调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。
客户端和服务可以用不同的编程语言实现,所以 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会变得很丑陋,因为不是所有的语言都具有相同的类型。
RPC 的当前方向
尽管有这样那样的问题,RPC 不会消失。使用二进制编码格式的自定义 RPC 协议可以实现比通用的 JSON over REST 更好的性能。但是,RESTful API 还有其他一些显著的优点:方便实验和调试,能被所有主流的编程语言和平台所支持,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。
由于这些原因,REST 似乎是公共 API 的主要风格。 RPC 框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
数据编码和 RPC 的演化
对于可演化性,重要的是可以独立更改和部署 RPC 客户端和服务器。我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,你只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。
RPC 方案的前后向兼容性属性从它使用的编码方式中继承:
Thrift、gRPC(Protobuf)等可以根据相应编码格式的兼容性规则进行演变。
在 SOAP 中,请求和响应是使用 XML 模式指定的。这些可以演变,但有一些微妙的陷阱。
RESTful API 通常使用 JSON 用于响应,以及用于请求的 JSON 或 URI 编码 / 表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。
由于 RPC 经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务 API。
关于 API 版本化应该如何工作没有一致意见。对于 RESTful API,常用的方法是在 URL 或 HTTP Accept 头中使用版本号。对于使用 API 密钥来标识特定客户端的服务,另一种选择是将客户端请求的 API 版本存储在服务器上,并允许通过单独的管理界面更新该版本选项。
消息传递中的数据流
如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
避免发件人需要知道收件人的 IP 地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
它允许将一条消息发送给多个收件人。
将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者)。
然而,与 RPC 相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
消息代理
通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上,或者发送给原始消息的发送者使用的回复队列(允许请求 / 响应数据流,类似于 RPC)。
消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列,因此你可以使用任何编码格式。如果编码是向后和向前兼容的,你可以灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序进行部署。
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段。
第五章:复制
我们希望能复制数据,可能出于各种各样的原因:
使得数据与用户在地理上接近(从而减少延迟)
即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
伸缩可以接受读请求的机器数量(从而提高读取吞吐量)
复制的困难之处在于处理复制数据的 变更(change),有三种流行的变更复制算法:单领导者(single leader,单主),多领导者(multi leader,多主) 和 无领导者(leaderless,无主)。
领导者和追随者
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 基于领导者的复制(leader-based replication) (也称 主/从(master/slave) 复制)
其中一个副本被指定为 领导者(leader),也称为 主库(master|primary) 。当客户端要向数据库写入时,它必须将请求发送给该 领导者,其会将新数据写入其本地存储。
其他副本被称为 追随者(followers),亦称为 只读副本(read replicas)、从库(slaves)、备库( secondaries)。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为 复制日志(replication log)。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本。
当客户想要从数据库中读取数据时,它可以向领导者或任一追随者进行查询。但只有领导者才能接受写入操作(从客户端的角度来看从库都是只读的)。
这种复制模式是许多关系数据库的内置功能,基于领导者的复制并不仅限于数据库,像 Kafka 和 RabbitMQ 高可用队列这样的分布式消息代理也使用它。
同步复制与异步复制
同步复制的优点是,从库能保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。
因此,将所有从库都设置为同步的是不切实际的:实际上,如果在数据库上启用同步复制,通常意味着其中 一个 从库是同步的,而其他的从库则是异步的。如果该同步从库变得不可用或缓慢,则将一个异步从库改为同步运行。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为 半同步(semi-synchronous)。
通常情况下,基于领导者的复制都配置为完全异步。在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。这意味着即使已经向客户端确认成功,写入也不能保证是 持久(Durable) 的。然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
设置新从库
简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入数据,数据总是在不断地变化。
可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。我们可以使用如下过程:
在某个时刻获取主库的一致性快照。大多数数据库都具有这个功能,因为它是备份必需的。
将快照复制到新的从库节点。
从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。MySQL 将其称为 二进制日志坐标(binlog coordinates)。
当从库处理完快照之后积累的数据变更,我们就说它 赶上(caught up) 了主库。
处理节点宕机
我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。
从库失效:追赶恢复
从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开期间发生的所有数据变更。当应用完所有这些变更后,它就赶上了主库,并可以像以前一样继续接收数据变更流。
主库失效:故障切换
一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为 故障切换(failover)。
自动的故障切换过程通常由以下步骤组成:
确认主库失效。大多数系统只是简单使用 超时(Timeout) :节点频繁地相互来回传递消息,如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了。
选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 控制器节点(controller node) 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 共识 问题。
重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
故障切换的过程中有很多地方可能出错:
如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入。最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。一个过时的 MySQL 从库被提升为主库。数据库使用自增 ID 作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的 ID 作为主键。这些主键也在 Redis 中使用,主键重用使得 MySQL 和 Redis 中的数据产生不一致。
发生某些故障时可能会出现两个节点都以为自己是主库的情况。这种情况称为 脑裂 (split brain),非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制,那么数据就可能丢失或损坏。
超时配置问题,在主库失效的情况下,超时时间越长意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障切换。
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换。
复制日志的实现
实践中有好几种不同的复制方式。
基于语句的复制
主库记录下它执行的每个写入请求(语句,即 statement)并将该语句日志发送给从库,每个从库解析并执行该 SQL 语句,就像直接从客户端收到一样。
虽然听上去很合理,但有很多问题会搞砸这种复制方式:
任何调用 非确定性函数(nondeterministic) 的语句,可能会在每个副本上生成不同的值。例如,使用
NOW()
获取当前日期时间,或使用RAND()
获取一个随机数。如果语句使用了 自增列(auto increment),或者依赖于数据库中的现有数据(例如,
UPDATE ... WHERE <某些条件>
),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定性的。
有办法绕开这些问题 —— 例如,主库可以用固定的返回值替换掉任何不确定的函数调用,以便所有从库都能获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。
基于语句的复制在 5.1 版本前的 MySQL 中被使用到。因为它相当紧凑,现在有时候也还在用。但现在在默认情况下,如果语句中存在任何不确定性,MySQL 会切换到基于行的复制。
传输预写式日志(WAL)
对于覆写单个磁盘块的 B 树,每次修改都会先写入 预写式日志(Write Ahead Log, WAL),以便崩溃后索引可以恢复到一个一致的状态。
该日志是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给从库。通过使用这个日志,从库可以构建一个与主库一模一样的数据结构拷贝。
看上去这可能只是一个小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障切换,使升级后的节点之一成为新的主库,从而允许数据库软件的零停机升级。
逻辑日志复制(基于行)
复制和存储引擎使用不同的日志格式,这样可以将复制日志从存储引擎的内部实现中解耦出来。这种复制日志被称为逻辑日志(logical log),以将其与存储引擎的(物理)数据表示区分开来。
对于插入的行,日志包含所有列的新值。
对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
修改多行的事务会生成多条这样的日志记录,后面跟着一条指明事务已经提交的记录。 MySQL 的二进制日志(当配置为使用基于行的复制时)使用了这种方法。
由于逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。
基于触发器的复制
触发器允许你将数据更改(写入事务)发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上一些必要的业务逻辑,就可以将数据变更复制到另一个系统去。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。然而由于其灵活性,它仍然是很有用的。
复制延迟问题
当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 最终一致性。
在正常的操作中,复制延迟(replication lag),即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题时,延迟可以轻而易举地超过几秒,甚至达到几分钟。
读你所写
如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了。
在这种情况下,我们需要 写后读一致性(read-after-write consistency),也称为 读己之写一致性(read-your-writes consistency)。这是一个保证,如果用户重新加载页面,他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺:其他用户的更新可能稍等才会看到。
对于用户 可能修改过 的内容,总是从主库读取;这就要求得有办法不通过实际的查询就可以知道用户是否修改了某些东西。例如总是从主库读取用户自己的档案,如果要读取其他用户的档案就去从库。
如果应用中的大部分内容都可能被用户编辑,可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。
客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。
如果你的副本分布在多个数据中心(为了在地理上接近用户或者出于可用性目的),还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。
另一种复杂的情况发生在同一位用户从多个设备请求服务的时候,这种情况下可能就需要提供跨设备的写后读一致性。
在这种情况下,还有一些需要考虑的问题:
记住用户上次更新时间戳的方法变得更加困难,因为一个设备上运行的程序不知道另一个设备上发生了什么。需要对这些元数据进行中心化的存储。
如果副本分布在不同的数据中心,很难保证来自不同设备的连接会路由到同一数据中心。如果你的方法需要读主库,可能首先需要把来自该用户所有设备的请求都路由到同一个数据中心。
单调读
在从异步从库读取时可能发生的异常的第二个例子是用户可能会遇到 时光倒流(moving backward in time)。
单调读(monotonic reads) 可以保证这种异常不会发生。这是一个比 强一致性(strong consistency) 更弱,但比 最终一致性(eventual consistency) 更强的保证。当读取数据时,你可能会看到一个旧值;单调读仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间回退,也就是说,如果已经读取到较新的数据,后续的读取不会得到更旧的数据。
实现单调读的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本出现故障,用户的查询将需要重新路由到另一个副本。
一致前缀读
如果某些分区的复制速度慢于其他分区,那么观察者可能会在看到问题之前先看到答案。
要防止这种异常,需要另一种类型的保证:一致前缀读(consistent prefix reads)。这个保证的意思是说:如果一系列写入按某个顺序发生,那么任何人读取这些写入时,也会看见它们以同样的顺序出现。
这是 分区(partitioned) 或 分片(sharded) 数据库中的一个特殊问题。在许多分布式数据库中,不同的分区独立运行,因此不存在 全局的写入顺序:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些则处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区,但在一些应用中可能无法高效地完成这种操作。
复制延迟的解决方案
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果结果对于用户来说是不好的体验,那么设计系统来提供更强的保证(例如 写后读)是很重要的。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。
数据库通过事务提供强大的保证,所以应用程序可以更加简单。单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务,声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。
多主复制
基于领导者的复制模型的自然延伸是允许多个节点接受写入。复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据变更转发给所有其他节点。我们将其称之为 多领导者配置(multi-leader configuration,也称多主、多活复制,即 master-master replication 或 active/active replication)。在这种情况下,每个主库同时是其他主库的从库。
多主复制的应用场景
在单个数据中心内部使用多个主库的配置没有太大意义,因为其导致的复杂性已经超过了能带来的好处。
运维多个数据中心
多主配置中可以在每个数据中心都有主库。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
性能
在多主配置中,每个写操作都可以在本地数据中心进行处理,并与其他数据中心异步复制。因此,数据中心之间的网络延迟对用户来说是透明的,这意味着感觉到的性能可能会更好。
容忍数据中心停机
在单主配置中,如果主库所在的数据中心发生故障,故障切换必须使另一个数据中心里的从库成为主库。在多主配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。
容忍网络问题
数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对数据中心之间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的。
由于多主复制在许多数据库中都属于改装的功能,经常与其他数据库功能之间出现意外的反应。比如自增主键、触发器、完整性约束等都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免。
需要离线操作的客户端
考虑手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。
从架构的角度来看,这种设置实际上与数据中心之间的多主复制类似,每个设备都是一个 “数据中心”,而它们之间的网络连接是极度不可靠的。
协同编辑
我们通常不会将协作式编辑视为数据库复制问题,但它与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时,所做的更改将立即应用到其本地副本,并异步复制到服务器和编辑同一文档的任何其他用户。
如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
但是,为了加速协作,你可能希望将更改的单位设置得非常小(例如单次按键),并避免锁定。这种方法允许多个用户同时进行编辑,但同时也带来了多主复制的所有挑战,包括需要解决冲突。
处理写入冲突
多主复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
同步与异步冲突检测
在单主数据库中,第二个写入将被阻塞并等待第一个写入完成,或者中止第二个写入事务并强制用户重试。另一方面,在多主配置中,两个写入都是成功的,在稍后的某个时间点才能异步地检测到冲突。那时再来要求用户解决冲突可能为时已晚。
原则上,可以使冲突检测同步 - 即等待写入被复制到所有副本,然后再告诉用户写入成功。但是,通过这样做,你将失去多主复制的主要优点:允许每个副本独立地接受写入。如果你想要同步冲突检测,那么你可能不如直接使用单主复制。
避免冲突
由于许多的多主复制实现在处理冲突时处理得相当不好,避免冲突是一个经常被推荐的方法。
在一个用户可以编辑自己数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的主库进行读写。不同的用户可能有不同的 “主” 数据中心,但从任何一位用户的角度来看,本质上就是单主配置了。
但是,有时你可能需要更改被指定的主库,在这种情况下,冲突避免将失效,你必须处理不同主库同时写入的可能性。
收敛至一致的状态
给每个写入一个唯一的 ID(例如时间戳、长随机数、UUID 或者键和值的哈希),挑选最高 ID 的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为 最后写入胜利(LWW, last write wins)。虽然这种方法很流行,但是很容易造成数据丢失。
为每个副本分配一个唯一的 ID,ID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
以某种方式将这些值合并在一起。
用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
自定义冲突解决逻辑
写时执行
只要数据库系统检测到复制更改日志中存在冲突,就会调用冲突处理程序。例如,Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户 —— 它在后台进程中运行,并且必须快速执行。
读时执行
当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突,并将结果写回数据库。例如 CouchDB 就以这种方式工作。
请注意,冲突解决通常适用于单行记录或单个文档的层面,而不是整个事务。
什么是冲突
两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
其他类型的冲突可能更为微妙而难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用程序需要确保每个房间在任意时刻都只能被一组人进行预定(即不得有相同房间的重叠预订)。在这种情况下,如果为同一个房间同时创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前先检查会议室的可用性,如果两次预订是由两个不同的主库进行的,则仍然可能会有冲突。
多主复制拓扑
最常见的拓扑是全部到全部,其中每个主库都将其写入发送给其他所有的主库。默认情况下 MySQL 仅支持 环形拓扑(circular topology),其中每个节点都从一个节点接收写入,并将这些写入(加上自己的写入)转发给另一个节点。另一种流行的拓扑结构具有星形的形状:一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
环形和星形拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流。拓扑结构可以重新配置为跳过发生故障的节点,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,可以避免单点故障。
另一方面,全部到全部的拓扑也可能有问题。一些网络链接可能比其他网络链接更快(例如由于网络拥塞),结果是一些复制消息可能 “超越” 其他复制消息。
无主复制
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。
在一些无主复制的实现中,客户端直接将写入发送到几个副本中,而另一些情况下,由一个 协调者(coordinator) 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。
当节点故障时写入数据库
假设三个副本中的两个承认写入是足够的:在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭期间发生的任何写入都不在该节点上。因此,如果你从该节点读取数据,则可能会从响应中拿到陈旧的(过时的)值。
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅把它的请求发送到一个副本:读请求将被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应,即来自一个节点的最新值和来自另一个节点的陈旧值。版本号将被用于确定哪个值是更新的。
读修复和反熵
读修复(Read repair)
当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。客户端发现副本具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
反熵过程(Anti-entropy process)
此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
如果没有反熵过程,很少被读取的值可能会从某些副本中丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。
读写的法定人数
如果有 n 个副本,每个写入必须由 w 个节点确认才能被认为是成功的,并且我们必须至少为每个读取查询 r 个节点。只要 w + r > n,我们可以预期在读取时能获得最新的值,因为 r 个读取中至少有一个节点是最新的。遵循这些 r 值和 w 值的读写称为 法定人数(quorum)的读和写。你可以认为,r 和 w 是有效读写所需的最低票数。
在 Dynamo 风格的数据库中,参数 n、w 和 r 通常是可配置的。一个常见的选择是使 n 为奇数(通常为 3 或 5)并设置 w = r = (n + 1) / 2(向上取整)。但是你可以根据需要更改数字。例如,写入次数较少且读取次数较多的工作负载可以从设置 w = n 和 r = 1 中受益。这会使得读取速度更快,但缺点是只要有一个不可用的节点就会导致所有的数据库写入都失败。
法定人数一致性的局限性
可以将 w 和 r 设置为较小的数字,以使 w + r ≤ n(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到 n 个节点,但操作成功只需要少量的成功响应。
较小的 w 和 r 更有可能会读取到陈旧的数据,因为你的读取更有可能未包含具有最新值的节点。另一方面,这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则有更大的机会可以继续处理读取和写入。只有当可达副本的数量低于 w 或 r 时,数据库才变得不可写入或读取。
但是,即使在 w + r > n 的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
如果使用宽松的法定人数,w 个写入和 r 个读取有可能落在完全不同的节点上,因此 r 节点和 w 之间不再保证有重叠节点。
如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入。如果根据时间戳(最后写入胜利)挑选出一个胜者,则写入可能由于时钟偏差而丢失。
如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取返回的是旧值还是新值。
如果写操作在某些副本上成功,而在其他节点上失败,在小于 w 个副本上写入成功。所以整体判定写入失败,但整体写入失败并没有在写入成功的副本上回滚。后续的读取仍然可能会读取这次失败写入的值。
如果携带新值的节点发生故障,需要从其他带有旧值的副本进行恢复,则存储新值的副本数可能会低于 w,从而打破法定人数条件。
即使一切工作正常,有时也会不幸地出现关于 时序(timing) 的边缘情况。
Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。你可以通过参数 w 和 r 来调整读取到陈旧值的概率,但把它们当成绝对的保证是不明智的。
监控陈旧度
从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,你也需要了解复制的健康状况。
对于基于领导者的复制,你可以将其提供给监视系统。写入是按照相同的顺序应用于主库和从库,并且每个节点对应了复制日志中的一个位置(已经在本地应用的写入数量)。通过从主库的当前位置中减去从库的当前位置,你可以测量复制延迟的程度。
然而,在无主复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多陈旧其实是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
宽松的法定人数与提示移交
在一个大型的集群中(节点数量明显多于 n 个),网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定的法定人数。在这种情况下,数据库设计人员需要权衡一下:
对于所有无法达到 w 或 r 个节点法定人数的请求,是否返回错误是更好的?
或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存在的 n 个节点上?
后者被认为是一个 宽松的法定人数(sloppy quorum):写和读仍然需要 w 和 r 个成功的响应,但这些响应可能来自不在指定的 n 个 “主” 节点中的其它节点。就好比说,如果你把自己锁在房子外面了,你可能会去敲开邻居的门,问是否可以暂时呆在他们的沙发上。
一旦网络中断得到解决,一个节点代表另一个节点临时接受的任何写入都将被发送到适当的 “主” 节点。这就是所谓的 提示移交(hinted handoff)。
宽松的法定人数对写入可用性的提高特别有用:只要有任何 w 个节点可用,数据库就可以接受写入。
在传统意义上,宽松的法定人数实际上并不是法定人数。它只是一个持久性的保证,即数据已存储在某处的 w 个节点。但不能保证 r 个节点的读取能看到它,除非提示移交已经完成。
运维多个数据中心
无主复制也适用于多数据中心操作,既然它旨在容忍冲突的并发写入、网络中断和延迟尖峰。
副本的数量 n 包括所有数据中心的节点,你可以在配置中指定每个数据中心所拥有的副本的数量。客户端的写入都会发送到所有副本,但客户端通常只等待来自其本地数据中心内的法定节点的确认,从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步执行,尽管该配置仍有一定的灵活性。
Riak 将客户端和数据库节点之间的所有通信保持在一个本地的数据中心,因此 n 描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生,其风格类似于多主复制。
检测并发写入
如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值,那么节点就会永久地不一致,如图中的最终获取请求所示:节点 2 认为 X 的最终值是 B,而其他节点认为值是 A 。
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你需要知道很多有关数据库冲突处理的内部信息。
最后写入胜利(丢弃并发写入)
当客户端向数据库节点发送写入请求时,两个客户端都不知道另一个客户端,因此不清楚哪一个先发送请求。事实上,说这两种情况谁先发送请求是没有意义的:既然我们说写入是 并发(concurrent) 的,那么它们的顺序就是不确定的。
我们可以强制进行排序。例如,可以为每个写入附加一个时间戳,然后挑选最大的时间戳作为 “最近的”,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 最后写入胜利(LWW, last write wins),是 Cassandra 唯一支持的冲突解决方法。
LWW 实现了最终收敛的目标,但以 持久性 为代价:如果同一个键有多个并发写入,即使它们反馈给客户端的结果都是成功的(因为它们被写入 w 个副本),也只有一个写入将被保留,而其他写入将被默默地丢弃。
在类似缓存的一些情况下,写入丢失可能是可以接受的。但如果数据丢失不可接受,LWW 是解决冲突的一个很烂的选择。
在数据库中使用 LWW 的唯一安全方法是确保一个键只写入一次,然后视为不可变,从而避免对同一个键进行并发更新。例如,Cassandra 推荐使用的方法是使用 UUID 作为键,从而为每个写操作提供一个唯一的键。
“此前发生” 的关系和并发
如果操作 B 了解操作 A,或者依赖于 A,或者以某种方式构建于操作 A 之上,则操作 A 在操作 B 之前发生(happens before)。一个操作是否在另一个操作之前发生是定义并发含义的关键。事实上,我们可以简单地说,如果两个操作中的任何一个都不在另一个之前发生(即,两个操作都不知道对方),那么这两个操作是并发的。
只要有两个操作 A 和 B,就有三种可能性:A 在 B 之前发生,或者 B 在 A 之前发生,或者 A 和 B 并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前,则后面的操作应该覆盖前面的操作,但是如果这些操作是并发的,则存在需要解决的冲突。
捕获“此前发生”关系
箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。在这个例子中,客户端永远不会完全拿到服务器上的最新数据,因为总是有另一个操作同时进行。但是旧版本的值最终会被覆盖,并且不会丢失任何写入。
服务器可以只通过查看版本号来确定两个操作是否是并发的 —— 它不需要对值本身进行解释(因此该值可以是任何数据结构)。
服务器为每个键维护一个版本号,每次写入该键时都递增版本号,并将新版本号与写入的值一起存储。
当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须先读取。
当客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起。
当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与正在进行的其它写入是并发的)。
合并并发写入的值
合并并发值,本质上是与多主复制中的冲突解决问题相同。一个简单的方法是根据版本号或时间戳(最后写入胜利)来选择一个值,但这意味着丢失数据。
以购物车为例,一种合理的合并值的方法就是做并集。在上图中,最后的两个兄弟是 [牛奶,面粉,鸡蛋,熏肉] 和 [鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋虽然同时出现在两个并发值里,但他们每个只被写过一次。合并的值可以是 [牛奶,面粉,鸡蛋,培根,火腿],不再有重复了。
然而,如果你想让人们也可以从他们的购物车中 移除 东西,那么把并发值做并集可能不会产生正确的结果:如果你合并了两个客户端的购物车,并且只在其中一个客户端里面移除了一个项目,那么被移除的项目将会重新出现在这两个客户端的交集结果中。为了防止这个问题,要移除一个项目时不能简单地直接从数据库中删除;相反,系统必须留下一个具有适当版本号的标记,以在兄弟合并时表明该项目已被移除。这种删除标记被称为 墓碑(tombstone)。
版本向量
当多个副本并发接受写入时,只使用单个版本号是不够的。我们还需要对 每个副本 使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及要保留哪些并发值或兄弟值。
所有副本的版本号集合称为 版本向量(version vector)。当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。
应用程序可能需要合并并发值。版本向量结构能够确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能会在其他副本上面创建数据,但只要能正确合并就不会丢失数据。
第六章:分区
对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行 分区(partitions),也称为 分片(sharding)。
分区与复制
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
键值数据的分区
不均衡导致的高负载的分区被称为 热点(hot spot)。
避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有一个很大的缺点:当你试图读取一个特定的值时,你无法知道它在哪个节点上,所以你必须并行地查询所有的节点。
现在假设你有一个简单的键值数据模型,其中你总是通过其主键访问记录。例如,在一本老式的纸质百科全书中,你可以通过标题来查找一个条目;由于所有条目按字母顺序排序,因此你可以快速找到你要查找的条目。
根据键的范围分区
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。
键的范围不一定均匀分布,因为数据也很可能不均匀分布。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。
Key Range 分区的缺点是某些特定的访问模式会导致热点。 如果主键是时间戳,则分区对应于时间范围,例如,给每天分配一个分区,那么所有写入操作都会转到同一个分区(即今天的分区),这样分区可能会因写入而过载,而其他分区则处于空闲状态。
为了避免这个问题,需要使用除了时间戳以外的其他东西作为主键的第一个部分。 例如,可以在每个时间戳前添加测量名称,这样会首先按名称,然后按时间进行分区。 假设有多个测量同时运行,写入负载将最终均匀分布在不同分区上。 现在,当想要在一个时间范围内获取多个测量的值时,你需要为每个测量名称执行一个单独的范围查询。
根据键的散列分区
出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra 和 MongoDB 使用 MD5。一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。
通过使用键散列进行分区,我们失去了高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中,所以它们之间的顺序就丢失了。在 MongoDB 中,如果你使用了基于散列的分区模式,则任何范围查询都必须发送到所有分区。
Cassandra 采取了折衷的策略。 Cassandra 中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据,而其他列则被用作 Casssandra 的 SSTables 中排序数据的连接索引。
组合索引方法为一对多关系提供了一个优雅的数据模型。在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为 (user_id, update_timestamp)
,那么你可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。
负载偏斜与热点消除
哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。
在社交媒体网站上,一个拥有数百万粉丝的用户在做某事时可能会引发一场风暴。这个事件可能导致同一个键的大量写入(键可能是名人的用户 ID,或者人们正在评论的动作的 ID)。哈希策略不起作用,因为两个相同 ID 的哈希值仍然是相同的。
一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为 100 种不同的主键,从而存储在不同的分区中。
然而,将主键进行分割之后,任何读取都必须要做额外的工作,因为他们必须从所有 100 个主键分布中读取数据并将其合并。
分区与次级索引
如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。如果涉及次级索引,情况会变得更加复杂。次级索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式,例如查找所有颜色为红色的车辆。
次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法:基于文档的分区(document-based) 和 基于关键词(term-based)的分区。
基于文档的次级索引进行分区
每个列表都有一个唯一的 ID,称之为文档 ID,并且用文档 ID 对数据库进行分区(例如,分区 0 中的 ID 0 到 499,分区 1 中的 ID 500 到 999 等)。
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引。
在这种索引方法中,每个分区是完全独立的:每个分区维护自己的次级索引,只需处理包含你正在编写的文档 ID 的分区即可。出于这个原因,文档分区索引 也被称为 本地索引。
但是,从文档分区索引中读取需要注意:除非你对文档 ID 做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
基于关键词(Term)的次级索引进行分区
我们可以构建一个覆盖所有分区数据的 全局索引,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从 a
到 r
的颜色在分区 0 中,s
到 z
的在分区 1。
我们将这种索引称为 关键词分区(term-partitioned),因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:color:red
。
关键词分区的全局索引优于文档分区索引的地方点是不需要 分散 / 收集 所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区。
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务。
在实践中,对全局次级索引的更新通常是 异步 的(也就是说,如果在写入之后不久读取索引,刚才所做的更改可能尚未反映在索引中)。
分区再平衡
查询吞吐量增加,所以你想要添加更多的 CPU 来处理负载。
数据集大小增加,所以你想添加更多的磁盘和 RAM 来存储它。
机器出现故障,其他机器需要接管故障机器的责任。
所有这些更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点向另一个节点移动的过程称为 再平衡(rebalancing)。
再平衡策略
有几种不同的分区分配方法。
反面教材:hash mod N
如果节点数量 N 发生变化,大多数键将需要从一个节点移动到另一个节点。例如,假设 hash(key) = 123456。如果最初有 10 个节点,那么这个键一开始放在节点 6 上(因为 123456 mod 10 = 6)。当你增长到 11 个节点时,键需要移动到节点 3(123456 mod 11 = 3),当你增长到 12 个节点时,需要移动到节点 0(123456 mod 12 = 0)。这种频繁的举动使得再平衡的成本过高。
固定数量的分区
有一个相当简单的解决方案:创建比节点更多的分区,并为每个节点分配多个分区。例如,运行在 10 个节点的集群上的数据库可能会从一开始就被拆分为 1,000 个分区,因此大约有 100 个分区被分配给每个节点。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中 窃取 一些分区,直到分区再次公平分配。如果从集群中删除一个节点,则会发生相反的情况。
这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。
动态分区
对于使用键范围分区的数据库,具有固定边界的固定数量的分区将非常不便:如果出现边界错误,则可能会导致一个分区中的所有数据或者其他分区中的所有数据为空。
出于这个原因,按键的范围进行分区的数据库(如 HBase 和 RethinkDB)会动态创建分区。当分区增长到超过配置的大小时(在 HBase 上,默认值是 10GB),会被分成两个分区,每个分区约占一半的数据。与之相反,如果大量数据被删除并且分区缩小到某个阈值以下,则可以将其与相邻分区合并。此过程与 B 树顶层发生的过程类似。
动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据,少量的分区就足够了,所以开销很小;如果有大量的数据,每个分区的大小被限制在一个可配置的最大值。
一个空的数据库从一个分区开始,因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小,直到达到第一个分区的分割点,所有写入操作都必须由单个节点处理,而其他节点则处于空闲状态。为了解决这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为 预分割,即 pre-splitting)。在键范围分区的情况中,预分割需要提前知道键是如何进行分配的。
按节点比例分区
每个节点具有固定数量的分区。在这种情况下,每个分区的大小与数据集大小成比例地增长,而节点数量保持不变,但是当增加节点数时,分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储,因此这种方法也使每个分区的大小较为稳定。
运维:手动还是自动再平衡
全自动再平衡可以很方便,因为正常维护的操作工作较少。然而,它可能是不可预测的。再平衡是一个昂贵的操作,因为它需要重新路由请求并将大量数据从一个节点移动到另一个节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。
这种自动化与自动故障检测相结合可能十分危险。例如,假设一个节点过载,并且对请求的响应暂时很慢。其他节点得出结论:过载的节点已经死亡,并自动重新平衡集群,使负载离开它。这会对已经超负荷的节点,其他节点和网络造成额外的负载,从而使情况变得更糟,并可能导致级联失败。
出于这个原因,再平衡的过程中有人参与是一件好事。这比全自动的过程慢,但可以帮助防止运维意外。
请求路由
如果我想读或写键 “foo”,需要连接哪个 IP 地址和端口号?
这个问题可以概括为 服务发现(service discovery) ,它不仅限于数据库。任何可通过网络访问的软件都有这个问题,特别是如果它的目标是高可用性(在多台机器上运行冗余配置)。
概括来说,这个问题有几种不同的方案:
允许客户联系任何节点(例如,通过 循环策略的负载均衡,即 Round-Robin Load Balancer)。如果该节点恰巧拥有请求的分区,则它可以直接处理该请求;否则,它将请求转发到适当的节点,接收回复并传递给客户端。
首先将所有来自客户端的请求发送到路由层,它决定了应该处理请求的节点,并相应地转发。此路由层本身不处理任何请求;它仅负责分区的负载均衡。
要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节点,而不需要任何中介。
许多分布式数据系统都依赖于一个独立的协调服务,比如 ZooKeeper 来跟踪集群元数据,每个节点在 ZooKeeper 中注册自己,ZooKeeper 维护分区到节点的可靠映射。 其他参与者可以在 ZooKeeper 中订阅此信息。 只要分区分配发生了改变,或者集群中添加或删除了一个节点,ZooKeeper 就会通知路由层使路由信息保持最新状态。
执行并行查询
通常用于分析的 大规模并行处理(MPP, Massively parallel processing) 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接,过滤,分组和聚合操作。 MPP 查询优化器将这个复杂的查询分解成许多执行阶段和分区,其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
第七章:事务
事务(transaction) 一直是简化可靠性问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功 提交(commit),要么失败 中止(abort)或 回滚(rollback)。
事务的棘手概念
2000 年以后,非关系(NoSQL)数据库开始普及。它们的目标是在关系数据库的现状基础上,通过提供新的数据模型选择并默认包含复制和分区来进一步提升。事务是这次运动的主要牺牲品:这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,描述比以前所理解的更弱得多的一套保证。
随着这种新型分布式数据库的炒作,人们普遍认为事务是可伸缩性的对立面,任何大型系统都必须放弃事务以保持良好的性能和高可用性。另一方面,数据库厂商有时将事务保证作为 “重要应用” 和 “有价值数据” 的基本要求。这两种观点都是 纯粹的夸张。
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。
ACID 的含义
ACID 代表 原子性(Atomicity),一致性(Consistency),隔离性(Isolation) 和 持久性(Durability)。
今天,当一个系统声称自己 “符合 ACID” 时,实际上能期待的是什么保证并不清楚。不幸的是,ACID 现在几乎已经变成了一个营销术语。
原子性
在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的中间结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。
相比之下,ACID 的原子性并 不 是关于 并发(concurrent) 的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在缩写 I 中,即隔离性。
ACID 原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 或许 可中止性(abortability) 是更好的术语,但本书将继续使用原子性,因为这是惯用词。
一致性
ACID 一致性的概念是,对数据的一组特定约束必须始终成立。即 不变式(invariants)。例如,在会计系统中,所有账户整体上必须借贷相抵。
但是,一致性的这种概念取决于应用程序对不变式的理解,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变式的脏数据,数据库也无法阻止你(一些特定类型的不变式可以由数据库检查,例如外键约束或唯一约束)。
原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。
隔离性
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到 并发 问题(竞争条件,即 race conditions)。
ACID 意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为 可串行化(Serializability)。
在 Oracle 中有一个名为 “可串行的” 隔离级别,但实际上它实现了一种叫做 快照隔离(snapshot isolation) 的功能,这是一种比可串行化更弱的保证。
持久性
持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
它通常包括预写日志或类似的文件,以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
单对象和多对象操作
许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象 API(例如,某键值存储可能具有在一个操作中更新几个键的 multi-put 操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
单对象写入
假设你正在向数据库写入一个 20 KB 的 JSON 文档:
如果在发送第一个 10 KB 之后网络连接中断,数据库是否存储了不可解析的 10KB JSON 片段?
如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复,并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) 。
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要读取 - 修改 - 写入序列了。同样流行的是 CAS 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
多对象事务的需求
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。多对象事务使你确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
在具有次级索引的数据库中,每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
这些应用仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。
处理错误和中止
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID 数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。
错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像一些对象关系映射(ORM, object-relation Mapping)框架不会重试中断的事务 —— 这个错误通常会导致一个从堆栈向上传播的异常。
尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:
如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次 —— 除非你有一个额外的去重机制。
如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误。
仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,两阶段提交(2PC, two-phase commit) 可以提供帮助。
如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
弱隔离级别
如果两个事务不触及相同的数据,它们可以安全地 并行(parallel) 运行,因为两者都不依赖于另一个。
隔离并没有那么简单。可串行的隔离 会有性能损失,许多数据库不愿意支付这个代价。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。
读已提交
最基本的事务隔离级别是 读已提交(Read Committed),它提供了两个保证:
从数据库读时,只能看到已提交的数据(没有 脏读,即 dirty reads)。
写入数据库时,只会覆盖已提交的数据(没有 脏写,即 dirty writes)。
没有脏读
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做 脏读(dirty reads)。
为什么要防止脏读,有几个原因:
如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,用户看到新的未读电子邮件,但看不到更新的数量。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。
如果事务中止,则所有写入操作都需要回滚。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。
没有脏写
如果先前的写入是尚未提交事务的一部分,又会发生什么情况,后面的写入会覆盖一个尚未提交的值?这被称作 脏写(dirty write)。在 读已提交 的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
以一个二手车销售网站为例,Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。本来销售是属于 Bob 的(因为他成功更新了商品列表),但发票却寄送给了 Alice(因为她成功更新了发票表)。读已提交会防止这样的事故。
实现读已提交
最常见的情况是,数据库通过使用 行锁(row-level lock) 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的。
防止脏读的一种选择是使用相同的锁,并要求任何想要读取对象的事务获取该锁,然后在读取之后立即释放该锁。这将确保在对象具有脏的、未提交的值时不会发生读取(因为在此期间,锁将由进行写入的事务持有)。
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。
出于这个原因,大多数数据库对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
快照隔离和可重复读
Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她在事务处理的过程中查看其账户余额,她可能会在收到付款之前先看到一个账户的余额(收款账户,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户,新余额为 400 美元)。对 Alice 来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。
这种异常被称为 不可重复读(nonrepeatable read) 或 读取偏差(read skew):如果 Alice 在事务结束时再次读取账户 1 的余额,她将看到与她之前的查询中看到的不同的值(600 美元)。在读已提交的隔离条件下,不可重复读 被认为是可接受的:Alice 看到的帐户余额确实在阅读时已经提交了。
有些情况下,不能容忍这种暂时的不一致:
备份
进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
分析查询和完整性检查
有时,你可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见,也可能是定期完整性检查。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
快照隔离(snapshot isolation) 是这个问题最常见的解决方案。想法是,每个事务都从数据库的 一致快照(consistent snapshot) 中读取 —— 也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。
实现快照隔离
从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作,且两者间没有任何锁争用。
数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 多版本并发控制(MVCC, multi-version concurrency control)。
支持快照隔离的存储引擎通常也使用 MVCC 来实现 读已提交 隔离级别。一种典型的方法是 读已提交 为每个查询使用单独的快照,而 快照隔离 对整个事务使用相同的快照。
表中的每一行都有一个 created_by
字段,其中包含将该行插入到表中的的事务 ID。此外,每行都有一个 deleted_by
字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是进行标记删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。
观察一致性快照的可见性规则
在每次事务开始时,数据库列出当时所有其他(尚未提交或尚未中止)的事务清单,即使之后提交了,这些事务已执行的任何写入也都会被忽略。
被中止事务所执行的任何写入都将被忽略。
由具有较晚事务 ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
所有其他写入,对应用都是可见的。
换句话说,如果以下两个条件都成立,则可见一个对象:
读事务开始时,创建该对象的事务已经提交。
对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
索引和快照隔离
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
在 CouchDB、Datomic 和 LMDB 中使用的是一种 仅追加 / 写时拷贝(append-only/copy-on-write) 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变。
使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一棵新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
防止丢失更新
如果应用从数据库中读取一些值,修改它并写回修改的值(读取 - 修改 - 写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改
增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
将本地修改写入一个复杂值中:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
原子写
许多数据库提供了原子更新操作,从而消除了在应用程序代码中执行读取 - 修改 - 写入序列的需要。如果你的代码可以用这些操作来表达,那这通常是最好的解决方案。例如,下面的指令在大多数关系数据库中是并发安全的:
类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 提供了修改数据结构(如优先级队列)的原子操作。
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为 游标稳定性(cursor stability)。另一个选择是简单地强制所有的原子操作在单一线程上执行。
显式锁定
让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取 - 修改 - 写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个 读取 - 修改 - 写入序列 完成。
例如,考虑一个多人游戏,其中几个玩家可以同时移动相同的棋子。在这种情况下,一个原子操作可能是不够的,因为应用程序还需要确保玩家的移动符合游戏规则,这可能涉及到一些不能合理地用数据库查询实现的逻辑。但你可以使用锁来防止两名玩家同时移动相同的棋子。
FOR UPDATE
子句告诉数据库应该对该查询返回的所有行加锁。
自动检测丢失的更新
原子操作和锁是通过强制 读取 - 修改 - 写入序列 按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其 读取 - 修改 - 写入序列。
PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测 丢失更新。一些作者认为,数据库必须能防止丢失更新才称得上是提供了 快照隔离,所以在这个定义下,MySQL 下不提供快照隔离。
比较并设置(CAS)
只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列。
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑后页面内容未发生改变时,才会更新成功:
如果内容已经更改并且不再与 “旧内容” 相匹配,则此更新将不起作用,因此你需要检查更新是否生效,必要时重试。但是,如果数据库允许 WHERE
子句从旧快照中读取,则此语句可能无法防止丢失更新,因为即使发生了另一个并发写入,WHERE
条件也可能为真。在依赖数据库的 CAS 操作前要检查其是否安全。
冲突解决和复制
锁和 CAS 操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或 CAS 操作的技术不适用于这种情况。
这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。例如,递增计数器或向集合添加元素是可交换的操作。
写入偏差与幻读
医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
写入偏差的特征
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时序)。
对于写入偏差,我们的选择更受限制:
由于涉及多个对象,单对象的原子操作不起作用。
在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。
某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库。
如果无法使用可串行化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
和以前一样,
FOR UPDATE
告诉数据库锁定返回的所有行以用于更新。
写入偏差的更多例子
会议室预订系统
比如你想要规定不能在同一时间对同一个会议室进行多次的预订。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议。
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可串行化的隔离级别了。
多人游戏
我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。取决于你正在执行的规则类型,也许可以使用唯一约束(unique constraint),否则你很容易发生写入偏差。
抢注用户名
在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。
防止双重开支
允许用户花钱或使用积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值。在写入偏差场景下,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。
导致写入偏差的幻读
所有这些例子都遵循类似的模式:
一个
SELECT
查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋子占据;用户名还没有被抢注;账户里还有足够余额)按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
这个写入的效果改变了步骤 2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤 1 的 SELECT 查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是放弃还是提交。
在医生值班的例子中,在步骤 3 中修改的行,是步骤 1 中返回的行之一,所以我们可以通过锁定步骤 1 中的行(SELECT FOR UPDATE
)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否 不存在 某些满足条件的行,写入会 添加 一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,则 SELECT FOR UPDATE
锁不了任何东西。
这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为 幻读。快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻读会导致特别棘手的写入偏差情况。
物化冲突
如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象
例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如 15 分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。
现在,要创建预订的事务可以锁定(SELECT FOR UPDATE
)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息 —— 它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。
这种方法被称为 物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,并且让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。可串行化(Serializable) 的隔离级别是更可取的。
可串行化
可串行化(Serializability) 隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。
真的串行执行
数据库设计人员只是在 2007 年左右才决定,单线程循环执行事务是可行的。
两个进展引发了这个反思:
RAM 足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
数据库设计人员意识到 OLTP 事务通常很短,而且只进行少量的读写操作。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
串行执行事务的方法在 VoltDB/H-Store,Redis 和 Datomic 中实现。设计用于单线程执行的系统有时可以比支持并发的系统性能更好,因为它可以避免锁的协调开销。但是其吞吐量仅限于单个 CPU 核的吞吐量。为了充分利用单一线程,需要与传统形式的事务不同的结构。
在存储过程恭封装事务
交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘 I/O。
存储过程的优点和缺点
存储过程在关系型数据库中已经存在了一段时间了,自 1999 年以来它们一直是 SQL 标准(SQL/PSM)的一部分。出于各种原因,它们的名声有点不太好。
在数据库中运行的代码难以管理:与应用服务器相比,它更难调试,更难以保持版本控制和部署,更难测试,并且难以集成到指标收集系统来进行监控。
数据库通常比应用服务器对性能敏感的多,因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程(例如,占用大量内存或 CPU 时间)会比在应用服务器中相同的代码造成更多的麻烦。
现代的存储过程实现放弃了 PL/SQL,而是使用现有的通用编程语言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,而 Redis 使用 Lua。
存储过程与内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待 I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。
分区
对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。
为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区。如果你可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。
但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。由于跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多,并且不能通过增加更多的机器来增加吞吐量。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量的跨分区协调。
串行执行小结
在特定约束条件下,真的串行执行事务,已经成为一种实现可串行化隔离等级的可行办法。
每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。
写入吞吐量必须低到能在单个 CPU 核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
跨分区事务是可能的,但是它们能被使用的程度有很大的限制。
两阶段锁定
请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。
如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 独占访问(exclusive access) 权限:
如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续。
如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续。
在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 读不阻塞写,写也不阻塞读。
实现两阶段锁
2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 共享模式(shared mode) 或 独占模式(exclusive mode)。锁使用如下:
若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务 A 等待事务 B 释放它的锁,反之亦然。这种情况叫做 死锁(Deadlock)。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
两阶段锁定的性能
当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。
谓词锁
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订,则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
从概念上讲,我们需要一个 谓词锁(predicate lock)。它类似于前面描述的共享 / 排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
谓词锁限制访问,如下所示:
如果事务 A 想要读取匹配某些条件的对象,它必须获取查询条件上的 共享谓词锁(shared-mode predicate lock)。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁,那么 A 必须等到 B 释放它的锁之后才允许进行查询。
如果事务 A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁,那么 A 必须等到 B 已经提交或中止后才能继续。
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。 因此,大多数使用 2PL 的数据库实际上实现了索引范围锁(index-range locking,也称为 next-key locking),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁,则锁定 123 号房间的所有时间段是安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
在房间预订数据库中,你可能会在 room_id
列上有一个索引,并且 / 或者在 start_time
和 end_time
上有索引(否则前面的查询在大型数据库上的速度会非常慢):
假设你的索引位于
room_id
上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索 123 号房间用于预订。或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将 12:00~13:00 时间段标记为用于预定。
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入、更新或删除同一个房间和 / 或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确,但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格。
可串行化快照隔离
串行化的隔离级别和高性能是从根本上相互矛盾的吗?
也许不是:一个称为 可串行化快照隔离(SSI, serializable snapshot isolation) 的算法是非常有前途的。它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。
悲观与乐观的并发控制
两阶段锁是一种所谓的 悲观并发控制机制(pessimistic) :它是基于这样的原则:如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情。
相比之下,串行化快照隔离 是一种 乐观(optimistic) 的并发控制技术。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
乐观并发控制是一个古老的想法,其优点和缺点已经争论了很长时间。如果存在很多 争用(contention,即很多事务试图访问相同的对象),则表现不佳,因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量,来自重试事务的额外负载可能会使性能变差。
但是,如果有足够的空闲容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的性能要好。
顾名思义,SSI 基于快照隔离 —— 也就是说,事务中的所有读取都是来自数据库的一致性快照。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上,SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
基于过时前提的决策
在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
换句话说,事务基于一个 前提(premise) 采取行动。之后当事务要提交时,原始数据可能已经改变 —— 前提可能不再成立。
当应用程序进行查询时,数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之,事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
检测对旧 MVCC 对象版本的读取(读之前存在未提交的写入)
检测影响先前读取的写入(读之后发生写入)
检测旧 MVCC 读取
数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务?因为如果是只读事务,则不需要中止,因为没有写入偏差的风险。当事务进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务可能在其他事务被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留了快照隔离从一致快照中长时间读取的能力。
检测影响之前读取的写入
第二种情况要考虑的是另一个事务在读取数据之后修改数据。
事务 42 和 43 都在班次 1234 查找值班医生。如果在 shift_id
上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。(如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务直到其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。
可串行化快照隔离的性能
与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,波动更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
中止率显著影响 SSI 的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短(只读的长事务可能没问题)。对于慢事务,SSI 可能比两阶段锁定或串行执行更不敏感。
第八章:分布式系统的麻烦
使用分布式系统与在一台计算机上编写软件有着根本的区别,主要的区别在于,有许多新颖和刺激的方法可以使事情出错。
故障与部分失效
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为 部分失效(partial failure)。难点在于部分失效是 不确定性的(nondeterministic):如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的!
这种不确定性和部分失效的可能性,使得分布式系统难以工作。
云计算与超级计算机
在超级计算机中,如果一个节点出现故障,通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后,计算从上一个检查点重新开始。因此,超级计算机更像是一个单节点计算机而不是分布式系统。
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统。
不可靠的网络
我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径。
无共享 并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
互联网和数据中心(通常是以太网)中的大多数内部网络都是 异步分组网络(asynchronous packet networks)。在这种网络中,网络不能保证包什么时候到达,或者是否到达。如果你发送请求并期待响应,则很多事情可能会出错。
发送者甚至不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。
处理这个问题的通常方法是 超时(Timeout):在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
真实世界的网络故障
有人可能希望现在我们已经找出了使网络变得可靠的方法。但是现在似乎还没有成功。
如果网络故障的错误处理没有定义与测试,武断地讲,各种错误可能都会发生:例如,即使网络恢复,集群可能会发生 死锁,永久无法为请求提供服务,甚至可能会删除所有的数据。如果软件被置于意料之外的情况下,它可能会做出出乎意料的事情。
处理网络故障并不意味着容忍它们:如果你的网络通常是相当可靠的,一个有效的方法可能是当你的网络遇到问题时,简单地向用户显示一条错误信息。但是,你确实需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。
检测故障
许多系统需要自动检测故障节点。例如:
负载平衡器需要停止向已死亡的节点转发请求(从轮询列表移出,即 out of rotation)。
在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库。
不幸的是,网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下,你可能会收到一些反馈信息,明确告诉你某些事情没有成功:
如果你可以连接到运行节点的机器,但没有进程正在侦听目标端口(例如,因为进程崩溃),操作系统将通过发送 FIN 或 RST 来关闭并重用 TCP 连接。但是,如果节点在处理请求时发生崩溃,则无法知道远程节点实际处理了多少数据。
如果节点进程崩溃,但节点的操作系统仍在运行,则脚本可以通知其他节点有关该崩溃的信息,以便另一个节点可以快速接管,而无需等待超时到期。
如果路由器确认你尝试连接的 IP 地址不可用,则可能会使用 ICMP 目标不可达数据包回复你。
关于远程节点关闭的快速反馈很有用,但是你不能指望它。即使 TCP 确认已经传送了一个数据包,应用程序在处理之前可能已经崩溃。
相反,如果出了什么问题,你可能会在堆栈的某个层次上得到一个错误响应,但总的来说,你必须假设你可能根本就得不到任何回应。
超时与无穷的延迟
如果超时是检测故障的唯一可靠方法,那么超时应该等待多久?不幸的是没有简单的答案。长时间的超时意味着长时间等待,直到一个节点被宣告死亡。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了。
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致 级联失效(即 cascading failure,表示在极端情况下,所有节点都宣告对方死亡,所有节点都将停止工作)。
设想一个虚构的系统,其网络可以保证数据包的最大延迟 —— 每个数据包要么在一段时间内传送,要么丢失,如果你在此时间内没有收到响应,则知道网络或远程节点不工作。
不幸的是,我们所使用的大多数系统都没有这些保证:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求。
网络拥塞和排队
如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路。如果传入的数据太多,队列填满,数据包将被丢弃,因此需要重新发送数据包 —— 即使网络运行良好。
当数据包到达目标机器时,如果所有 CPU 内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。
在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用 CPU 内核。
TCP 执行 流量控制(flow control,也称为 拥塞避免)。这意味着甚至在数据进入网络之前,在发送者处就需要进行额外的排队。
在公共云和多租户数据中心中,资源被许多客户共享,在这种环境下,你只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定 故障检测延迟 与 过早超时风险 之间的适当折衷。
更好的一种做法是,系统不是使用配置的常量超时时间,而是连续测量响应时间及其变化(抖动),并根据观察到的响应时间分布自动调整超时时间。TCP 的超时重传机制也是以类似的方式工作。
同步网络与异步网络
当你通过电话网络拨打电话时,它会建立一个电路:在两个呼叫者之间的整个路线上为呼叫分配一个固定的,有保证的带宽量。这种网络是同步的:即使数据经过多个路由器,也不会受到排队的影响,因为呼叫的 16 位空间已经在网络的下一跳中保留了下来。
电话网络中的电路与 TCP 连接有很大不同:电路是固定数量的预留带宽,在电路建立时没有其他人可以使用,而 TCP 连接的数据包 机会性地 使用任何可用的网络带宽。你可以给 TCP 一个可变大小的数据块。
如果数据中心网络和互联网是电路交换网络,那么在建立电路时就可以建立一个受保证的最大往返时间。但是,它们并不能这样:以太网和 IP 是 分组交换协议,不得不忍受排队的折磨和因此导致的网络无限延迟。
为什么数据中心网络和互联网使用分组交换?答案是,它们针对 突发流量(bursty traffic) 进行了优化。一个电路适用于音频或视频通话,在通话期间需要每秒传送相当数量的比特。另一方面,请求网页,发送电子邮件或传输文件没有任何特定的带宽要求 —— 我们只是希望它尽快完成。
如果想通过电路传输文件,你得预测一个带宽分配。如果你猜的太低,传输速度会不必要的太慢,导致网络容量闲置。如果你猜的太高,电路就无法建立(因为如果无法保证其带宽分配,网络不能建立电路)。因此,将电路用于突发数据传输会浪费网络容量,并且使传输不必要地缓慢。相比之下,TCP 动态调整数据传输速率以适应可用的网络容量。
不可靠的时钟
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道晚了多少时间。
而且,网络上的每台机器都有自己的时钟,这是一个实际的硬件设备:通常是石英晶体振荡器。这些设备不是完全准确的,所以每台机器都有自己的时间概念,可能比其他机器稍快或更慢。可以在一定程度上同步时钟:最常用的机制是 网络时间协议(NTP),它允许根据一组服务器报告的时间来调整计算机时钟。服务器则从更精确的时间源(如 GPS 接收机)获取时间。
单调钟与日历时钟
日历时钟
日历时钟是你直观地了解时钟的依据:它根据某个日历(也称为 挂钟时间,即 wall-clock time)返回当前日期和时间。例如,Linux 上的 clock_gettime(CLOCK_REALTIME)
和 Java 中的 System.currentTimeMillis()
返回自 epoch(UTC 时间 1970 年 1 月 1 日午夜)以来的秒数(或毫秒),根据公历(Gregorian)日历,不包括闰秒。
单调钟
单调钟适用于测量持续时间(时间间隔),例如超时或服务的响应时间:Linux 上的 clock_gettime(CLOCK_MONOTONIC)
,和 Java 中的 System.nanoTime()
都是单调时钟。这个名字来源于他们保证总是往前走的事实(而日历时钟可以往回跳)。
你可以在某个时间点检查单调钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调钟的绝对值是毫无意义的。
时钟同步与准确性
单调钟不需要同步,但是日历时钟需要根据 NTP 服务器或其他外部时间源来设置才能有用。不幸的是,我们获取时钟的方法并不像你所希望的那样可靠或准确 —— 硬件时钟和 NTP 可能会变幻莫测。
计算机中的石英钟不够精确:它会 漂移(drifts,即运行速度快于或慢于预期)。时钟漂移取决于机器的温度。
如果计算机的时钟与 NTP 服务器的时钟差别太大,可能会拒绝同步,或者本地时钟将被强制重置。
一些 NTP 服务器是错误的或者配置错误的,报告的时间可能相差几个小时。
如果你足够在乎这件事并投入大量资源,就可以达到非常好的时钟精度。例如,针对金融机构的欧洲法规草案 MiFID II 要求所有高频率交易基金在 UTC 时间 100 微秒内同步时钟,以便调试 “闪崩” 等市场异常现象,并帮助检测市场操纵。
依赖同步时钟
时钟的问题在于,虽然它们看起来简单易用,但却具有令人惊讶的缺陷:一天可能不会有精确的 86,400 秒,日历时钟 可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
有序事件的时间戳
当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点 1 和节点 3 之间的偏差小于 3ms,这可能比你在实践中能预期的更好。
当节点 2 接收到这两个事件时,会错误地推断出 x = 1
是最近的值,而丢弃写入 x = 2
。效果上表现为,客户端 B 的增量操作会丢失。
这种冲突解决策略被称为 最后写入胜利(LWW),它在多主复制和无主数据库(如 Cassandra 和 Riak)中被广泛使用。
因此,尽管通过保留 “最近” 的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近” 的定义取决于本地的 日历时钟,这很可能是不正确的。即使用严格同步的 NTP 时钟,一个数据包也可能在时间戳 100 毫秒(根据发送者的时钟)时发送,并在时间戳 99 毫秒(根据接收者的时钟)处到达 —— 看起来好像数据包在发送之前已经到达,这是不可能的。
所谓的 逻辑时钟(logic clock) 是基于递增计数器而不是振荡石英晶体,对于排序事件来说是更安全的选择。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的 日历时钟 和 单调钟 也被称为 物理时钟(physical clock)。
时钟读数存在置信区间
你可能能够以微秒或甚至纳秒的精度读取机器的时钟。但即使可以得到如此细致的测量结果,这并不意味着这个值对于这样的精度实际上是准确的。实际上,大概率是不准确的。使用公共互联网上的 NTP 服务器,最好的准确度可能达到几十毫秒,而且当网络拥塞时,误差可能会超过 100 毫秒。
因此,将时钟读数视为一个时间点是没有意义的 —— 它更像是一段时间范围:例如,一个系统可能以 95% 的置信度认为当前时间处于本分钟内的第 10.3 秒和 10.5 秒之间,它可能没法比这更精确了。
全局快照的同步时钟
我们可以使用同步时钟的时间戳作为事务 ID 吗?如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
Spanner 以这种方式实现跨数据中心的快照隔离。它使用 TrueTime API 报告的时钟置信区间,并基于以下观察结果:如果你有两个置信区间,每个置信区间包含最早和最晚可能的时间戳,这两个区间不重叠,那么 B 肯定发生在 A 之后 —— 这是毫无疑问的。只有当区间重叠时,我们才不确定 A 和 B 发生的顺序。
为了确保事务时间戳反映因果关系,Spanner 在提交读写事务时,会故意等待置信区间长度的时间。通过这样,它可以确保任何可能读取数据的事务处于足够晚的时间,因此它们的置信区间不会重叠。为了保持尽可能短的等待时间,Spanner 需要保持尽可能小的时钟不确定性,为此,Google 在每个数据中心都部署了一个 GPS 接收器或原子钟,这允许时钟同步到大约 7 毫秒以内。
进程暂停
一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
一种选择是领导者从其他节点获得一个 租约(lease),类似一个带超时的锁。为了保持领导地位,节点必须周期性地在租约过期前续期。如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。
可以想象,请求处理循环看起来像这样:
这个代码有什么问题?首先,它依赖于同步时钟:租约到期时间由另一台机器设置(例如,当前时间加上 30 秒,计算到期时间),并将其与本地系统时钟进行比较。
其次,即使我们将协议更改为仅使用本地单调时钟,也存在另一个问题:代码假定在执行剩余时间检查 System.currentTimeMillis()
和实际执行请求 process(request)
中间的时间间隔非常短。通常情况下,这段代码运行得非常快,所以 10 秒的缓冲区已经足够确保 租约 在请求处理到一半时不会过期。
但是,如果程序执行中出现了意外的停顿呢?例如,想象一下,线程在 lease.isValid()
行周围停止 15 秒,然后才继续。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。
许多编程语言运行时(如 Java 虚拟机)都有一个垃圾收集器(GC),偶尔需要停止所有正在运行的线程。这些 “停止所有处理(stop-the-world)”GC 暂停有时会持续几分钟!甚至像 HotSpot JVM 的 CMS 这样的所谓的 “并行” 垃圾收集器也不能完全与应用程序代码并行运行,它需要不时地停止所有处理。
在虚拟化环境中,可以 挂起(suspend) 虚拟机(暂停执行所有进程并将内存内容保存到磁盘)并恢复(恢复内存内容并继续执行)。这个暂停可以在进程执行的任何时候发生,并且可以持续任意长的时间。
在最终用户的设备(如笔记本电脑)上,执行也可能被暂停并随意恢复,例如当用户关闭笔记本电脑的盖子时。
当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可能在代码中的任意点处暂停。在虚拟机的情况下,在其他虚拟机中花费的 CPU 时间被称为 窃取时间(steal time)。
如果应用程序执行同步磁盘访问,则线程可能暂停,等待缓慢的磁盘 I/O 操作完成。在许多语言中,即使代码没有包含文件访问,磁盘访问也可能出乎意料地发生 —— 例如,Java 类加载器在第一次使用时惰性加载类文件,这可能在程序执行过程中随时发生。I/O 暂停和 GC 暂停甚至可能合谋组合它们的延迟。
如果操作系统配置为允许交换到磁盘(页面交换),则简单的内存访问可能导致 页面错误(page fault),要求将磁盘中的页面装入内存。当这个缓慢的 I/O 操作发生时,线程暂停。如果内存压力很高,则可能需要将另一个页面换出到磁盘。
可以通过发送 SIGSTOP 信号来暂停 Unix 进程,例如通过在 shell 中按下 Ctrl-Z。这个信号立即阻止进程继续执行更多的 CPU 周期,直到 SIGCONT 恢复为止,此时它将继续运行。即使你的环境通常不使用 SIGSTOP,也可能由运维工程师意外发送。
所有这些事件都可以随时 抢占(preempt) 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。
当在一台机器上编写多线程代码时,我们有相当好的工具来实现线程安全:互斥量、信号量、原子计数器、无锁数据结构、阻塞队列等等。不幸的是,这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。
响应时间保证
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失,在这些系统中,软件必须有一个特定的 截止时间(deadline),如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的 硬实时(hard real-time) 系统。
例如,如果车载传感器检测到当前正在经历碰撞,你肯定不希望安全气囊释放系统因为 GC 暂停而延迟弹出。
在系统中提供 实时保证 需要各级软件栈的支持:一个实时操作系统(RTOS),允许在指定的时间间隔内保证 CPU 时间的分配。库函数必须申明最坏情况下的执行时间;动态内存分配可能受到限制或完全不允许(实时垃圾收集器存在,但是应用程序仍然必须确保它不会给 GC 太多的负担);必须进行大量的测试和测量,以确保达到保证。
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“实时” 与 “高性能” 不一样 —— 事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切。
对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
限制垃圾收集的影响
一个新兴的想法是将 GC 暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要 GC 暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行 GC。这样向客户端隐藏了 GC 暂停,并降低了响应时间的高百分比。一些对延迟敏感的金融交易系统使用这种方法。
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整 GC)之前重新启动进程。一次可以重新启动一个节点,在计划重新启动之前,流量可以从该节点移开。
知识、真相与谎言
我们已经探索了分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。
在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。
真相由多数所定义
设想一个具有不对称故障的网络:一个节点能够接收发送给它的所有消息,但是来自该节点的任何传出消息被丢弃或延迟。即使该节点运行良好,并且正在接收来自其他节点的请求,其他节点也无法听到其响应。经过一段时间后,其他节点宣布它已经死亡,因为他们没有听到节点的消息。
在一个稍微不那么梦魇的场景中,半断开的节点可能会注意到它发送的消息没有被其他节点确认,因此意识到网络中必定存在故障。尽管如此,节点被其他节点错误地宣告为死亡,而半连接的节点对此无能为力。
第三种情况,想象一个正在经历长时间 GC STW 的节点,节点的所有线程被 GC 抢占并暂停一分钟。其他节点等待,重试,不耐烦,并最终宣布节点死亡。最后,GC 完成,节点的线程继续,好像什么也没有发生。其他节点感到惊讶,因为所谓的死亡节点突然从棺材中抬起头来,身体健康,开始和旁观者高兴地聊天。GC 后的节点最初甚至没有意识到已经经过了整整一分钟,而且自己已被宣告死亡。从它自己的角度来看,从最后一次与其他节点交谈以来,几乎没有经过任何时间。
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票:决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
最常见的法定人数是超过一半的绝对多数。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障),系统仍然是安全的。
领导者和锁
通常情况下,一些东西在一个系统中只能有一个。例如:
数据库分区的领导者只能有一个节点,以避免 脑裂。
特定资源的锁或对象只允许一个事务 / 客户端持有,以防同时写入和损坏。
一个特定的用户名只能被一个用户所注册,因为用户名必须唯一标识一个用户。
在分布式系统中实现这一点需要注意:即使一个节点认为它是 “the choosen one”(分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序),但这并不一定意味着有法定人数的节点同意!一个节点可能以前是领导者,但是如果其他节点在此期间宣布它死亡(例如,由于网络中断或 GC 暂停),则它可能已被降级,且另一个领导者可能已经当选。
如果一个节点继续表现为 天选者,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
防护令牌
我们假设每次锁定服务器授予锁或租约时,它还会返回一个 防护令牌(fencing token),这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。
在图中,客户端 1 以 33 的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端 2 以 34 的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括 34 的令牌。稍后,客户端 1 恢复生机并将其写入存储服务,包括其令牌值 33。但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌 33 的请求。
拜占庭故障
防护令牌可以检测和阻止无意中发生错误的节点(例如,因为它尚未发现其租约已过期)。但是,如果节点有意破坏系统的保证,则可以通过使用假防护令牌发送消息来轻松完成此操作。
我们假设节点是不可靠但诚实的:它们可能很慢或者从不响应(由于故障),并且它们的状态可能已经过时(由于 GC 暂停或网络延迟),但是我们假设如果节点它做出了回应,它正在说出 “真相”:尽其所知,它正在按照协议的规则扮演其角色。
如果存在节点可能 “撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了 —— 例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为 拜占庭故障(Byzantine fault),在不信任的环境中达成共识的问题被称为拜占庭将军问题。
拜占庭将军问题是对所谓 “两将军问题” 的泛化,它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地,他们只能通过信使进行沟通,信使有时会被延迟或丢失(就像网络中的信息包一样)。
在这个问题的拜占庭版本里,有 n 位将军需要同意,他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的,因而发出了真实的信息,但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人(在试图保持未被发现的同时)。事先并不知道叛徒是谁。
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为 拜占庭容错(Byzantine fault-tolerant):
在航空航天环境中,计算机内存或 CPU 寄存器中的数据可能被辐射破坏,导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵(例如,飞机撞毁和炸死船上所有人员,或火箭与国际空间站相撞),飞行控制系统必须容忍拜占庭故障。
在多个参与组织的系统中,一些参与者可能会试图欺骗或诈骗他人。在这种情况下,节点仅仅信任另一个节点的消息是不安全的,因为它们可能是出于恶意的目的而被发送的。例如,像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式,而不依赖于中心机构(central authority)。
然而,之前讨论的那些系统中,我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里,所有的节点都是由你的组织控制的,辐射水平足够低,内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂,而容错嵌入式系统依赖于硬件层面的支持。在大多数服务器端数据系统中,部署拜占庭容错解决方案的成本使其变得不切实际。
Web 应用程序需要预防终端用户控制的客户端(如 Web 浏览器)的恶意行为。这就是为什么输入验证,数据清洗和输出转义如此重要:例如,防止 SQL 注入和跨站点脚本。然而,我们通常不在这里使用拜占庭容错协议,而只是让服务器有权决定是否允许客户端行为。
软件中的一个 bug 可能被认为是拜占庭式的错误,但是如果你将相同的软件部署到所有节点上,那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作(即,如果有四个节点,最多只能有一个故障)。要使用这种方法对付 bug,你必须有四个独立的相同软件的实现,并希望一个 bug 只出现在四个实现之一中。
同样,如果一个协议可以保护我们免受漏洞,安全渗透和恶意攻击,那么这将是有吸引力的。但这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能都运行着相同的软件。因此,传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
弱谎言形式
尽管我们假设节点通常是诚实的,但值得向软件中添加防止 “撒谎” 弱形式的机制 —— 例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
由于硬件问题或操作系统、驱动程序、路由器等中的错误,网络数据包有时会受到损坏。通常,损坏的数据包会被内建于 TCP 和 UDP 中的校验和所俘获,但有时它们也会逃脱检测。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。
NTP 客户端可以配置多个服务器地址。同步时,客户端联系所有的服务器,估计它们的误差,并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题,一个配置错误的 NTP 服务器报告的时间会被当成特异值从同步中排除。使用多个服务器使 NTP 更健壮(比起只用单个服务器来)。
系统模型与现实
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
关于时序假设,三种系统模型是常用的:
同步模型 同步模型(synchronous model) 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟;这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限。同步模型并不是大多数实际系统的现实模型,因为无限延迟和暂停确实会发生。
部分同步模型 部分同步(partial synchronous) 意味着一个系统在大多数情况下像一个同步系统一样运行,但有时候会超出网络延迟,进程暂停和时钟漂移的界限。这是很多系统的现实模型:大多数情况下,网络和进程表现良好,否则我们永远无法完成任何事情,但是我们必须承认,在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时,网络延迟、暂停和时钟错误可能会变得相当大。
异步模型 在这个模型中,一个算法不允许对时序做任何假设 —— 事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
进一步来说,除了时序问题,我们还要考虑 节点失效。三种最常见的节点系统模型是:
崩溃 - 停止故障 在 崩溃停止(crash-stop) 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失 —— 它永远不会回来。
崩溃 - 恢复故障 我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在 崩溃 - 恢复(crash-recovery) 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
拜占庭(任意)故障 节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。
对于真实系统的建模,具有 崩溃 - 恢复故障(crash-recovery) 的 部分同步模型(partial synchronous) 通常是最有用的模型。
算法的正确性
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌,我们可能要求算法具有以下属性:
唯一性(uniqueness) 没有两个防护令牌请求返回相同的值。
单调序列(monotonic sequence) 如果请求 x 返回了令牌 tx,并且请求 y 返回了令牌 ty,并且 x 在 y 开始之前已经完成,那么 tx < ty。
可用性(availability) 请求防护令牌并且不会崩溃的节点,最终会收到响应。
安全性和活性
为了澄清这种情况,有必要区分两种不同的属性:安全(safety)属性 和 活性(liveness)属性。在刚刚给出的例子中,唯一性 和 单调序列 是安全属性,而 可用性 是活性属性。
这两种性质有什么区别?一个试金石就是,活性属性通常在定义中通常包括 “最终” 一词(是的,你猜对了 —— 最终一致性是一个活性属性)。
安全通常被非正式地定义为:没有坏事发生,而活性通常就类似:最终好事发生。但其实安全和活性的实际定义是精确的和数学的:
如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌被返回的特定操作)。违反安全属性后,违规行为不能被撤销 —— 损失已经发生。
活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来能成立(即通过接受答复)。
区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求 始终 保持安全属性是常见的。也就是说,即使所有节点崩溃,或者整个网络出现故障,算法仍然必须确保它不会返回错误的结果(即保证安全属性得到满足)。
但是,对于活性属性,我们可以提出一些注意事项:例如,只有在大多数节点没有崩溃的情况下,只有当网络最终从中断中恢复时,我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态 —— 即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。
将系统模型映射到现实世界
安全属性和活性属性以及系统模型对于推理分布式算法的正确性非常有用。然而,在实践中实施算法时,很明显系统模型是对现实的简化抽象。
例如,在崩溃 - 恢复(crash-recovery)模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是,如果磁盘上的数据被破坏,或者由于硬件错误或错误配置导致数据被清除,会发生什么情况?如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器,即使驱动器已正确连接到服务器,那又会发生什么情况?
法定人数算法依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
算法的理论描述可以简单宣称一些事是不会发生的 —— 在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理 “假设上不可能” 情况的代码,即使代码可能就是 exit(666)
,实际上也就是留给运维来擦屁股。
这并不是说理论上抽象的系统模型是毫无价值的,我们可以证明算法是正确的,通过表明它们的属性在某个系统模型中总是成立的。
证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时序)因为不寻常的情况被打破。理论分析与经验测试同样重要。
第九章:一致性与共识
分布式系统中的许多事情可能会出错。处理这种故障的最简单方法是简单地让整个服务失效,并向用户显示错误消息。如果无法接受这个解决方案,我们就需要找到容错的方法 —— 即使某些内部组件出现故障,服务也能正常运行。
一致性保证
大多数复制的数据库至少提供了 最终一致性,这意味着如果你停止向数据库写入数据并等待一段不确定的时间,那么最终所有的读取请求都会返回相同的值。换句话说,不一致性是暂时的。最终一致性的一个更好的名字可能是 收敛(convergence),因为我们预计所有的副本最终会收敛到相同的值。
然而,这是一个非常弱的保证 —— 它并没有说什么时候副本会收敛。在收敛之前,读操作可能会返回任何东西或什么都没有。
具有较强保证的系统可能会比保证较差的系统具有更差的性能或更少的容错性。尽管如此,更强的保证能够吸引人,因为它们更容易用对。只有见过不同的一致性模型后,才能更好地决定哪一个最适合自己的需求。
线性一致性
在 最终一致 的数据库,如果你在同一时刻问两个不同副本相同的问题,可能会得到两个不同的答案。如果数据库可以提供只有一个副本的假象(即,只有一个数据副本),那么每个客户端都会有相同的数据视图,且不必担心复制滞后了。
这就是 线性一致性 背后的想法(也称为 原子一致性(atomic consistency),强一致性(strong consistency),立即一致性(immediate consistency) 或 外部一致性(external consistency ))。基本的想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本,应用也不需要担心它们。
在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。要维护数据的单个副本的假象,系统应保障读到的值是最近的、最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个 新鲜度保证(recency guarantee)。
上图展示了一个关于体育网站的非线性一致例子。Alice 和 Bob 正坐在同一个房间里,都盯着各自的手机,关注着世界杯决赛的结果。在最后得分公布后,Alice 刷新页面,看到宣布了获胜者,并兴奋地告诉 Bob。Bob 难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。
Bob 在听到 Alice 惊呼最后得分 之后,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。
什么使得系统线性一致
线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而实际上有更多要操心的地方。
每个横柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间 —— 只知道它发生在发送请求和接收响应之间的某个时刻。
x
的值最初为 0
,客户端 C 执行写请求将其设置为 1
。发生这种情况时,客户端 A 和 B 反复轮询数据库以读取最新值。
客户端 A 的第一个读操作,完成于写操作开始之前,因此必须返回旧值
0
。客户端 A 的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值
1
:如果在写入结束后开始读取,则读取处理一定发生在写入完成之后,因此它必须看到写入的新值。与写操作在时间上重叠的任何读操作,可能会返回
0
或1
,因为我们不知道读取时,写操作是否已经生效。这些操作是 并发(concurrent) 的。
这还不足以完全描述线性一致性:如果与写入同时发生的读取可以返回旧值或新值,那么可能会在写入期间看到数值在旧值和新值之间来回翻转。这个系统对 “单一数据副本” 的模拟还不是我们所期望的。
为了使系统线性一致,我们需要添加另一个约束 —— 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。
在一个线性一致的系统中,我们可以想象,在 x
的值从 0
自动翻转到 1
的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 1
,即使写操作尚未完成,所有后续读取也必须返回新值。
依赖线性一致性
对于少数领域,线性一致性是系统正确工作的一个重要条件。
锁定和领导选举
一个使用单主复制的系统,需要确保领导者真的只有一个,而不是几个(脑裂)。一种选择领导者的方法是使用锁:每个节点在启动时尝试获取锁,成功者成为领导者。不管这个锁是如何实现的,它必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致,否则就没用了。
诸如 Apache ZooKeeper 和 etcd 之类的协调服务通常用于实现分布式锁和领导者选举。它们使用一致性算法,以容错的方式实现线性一致的操作。
约束和唯一性保证
唯一性约束在数据库中很常见:例如,用户名或电子邮件地址必须唯一标识一个用户,而在文件存储服务中,不能有两个具有相同路径和文件名的文件。如果两个人试图同时创建一个具有相同名称的用户或文件,其中一个将返回一个错误,该要求需要线性一致性。
这种情况实际上类似于一个锁:当一个用户注册你的服务时,可以认为他们获得了所选用户名的 “锁”。该操作与 CAS 非常相似:将用户名赋予声明它的用户,前提是用户名尚未被使用。
如果想要确保银行账户余额永远不会为负数,或者不会出售比仓库里的库存更多的物品,或者两个人不会都预定了航班或剧院里同一时间的同一个位置。这些约束条件都要求所有节点都同意一个最新的值。
在实际应用中,宽松地处理这些限制有时是可以接受的(例如,如果航班超额预订,你可以将客户转移到不同的航班并为其提供补偿)。
跨信道的时序依赖
如果 Alice 没有惊呼得分,Bob 就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice 的声音传到了 Bob 的耳朵中),线性一致性的违背才被注意到。
计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。
如果它不是线性一致的,则存在竞争条件的风险:消息队列可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和缩略图就产生了永久性的不一致。
出现这个问题是因为 Web 服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。
线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果可以额外控制信道,不过会有额外的复杂度代价。
实现线性一致的系统
让我们思考一下,如何实现一个提供线性一致语义的系统。
由于线性一致性本质上意味着 “表现得好像只有一个数据副本,而且所有的操作都是原子的”,所以最简单的答案就是,真的只用一个数据副本。但是这种方法无法容错:如果持有该副本的节点失效,数据将会丢失。
使系统容错最常用的方法是使用复制。让我们回顾几个复制方法,并比较它们是否可以满足线性一致性:
单主复制(可能线性一致) 在具有单主复制功能的系统中,主库具有用于写入的数据的主副本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据,它们 可能 是线性一致性的 。然而实际上并不是每个单主数据库都是线性一致性的,例如使用快照的设计和并发错误。 从主库读取依赖一个假设,你确切地知道领导者是谁。一个节点很可能会认为它是领导者,而事实上并非如此 —— 如果具有错觉的领导者继续为请求提供服务,可能违反线性一致性。使用异步复制,故障切换时甚至可能会丢失已提交的写入,这同时违反了持久性和线性一致性。
共识算法(线性一致) 共识算法与单主复制类似。然而,共识协议包含防止脑裂和陈旧副本的措施。正是由于这些细节,共识算法可以安全地实现线性一致性存储。例如,Zookeeper 和 etcd 就是这样工作的。
多主复制(非线性一致) 具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将其异步复制到其他节点。因此,它们可能会产生需要被解决的写入冲突。这种冲突是因为缺少单一数据副本所导致的。
无主复制(也许不是线性一致的) 对于无主复制的系统,有时候人们会声称通过要求法定人数读写(w + r > n)可以获得 “强一致性”。这取决于法定人数的具体配置,以及强一致性如何定义(通常不完全正确)。 基于日历时钟的 “最后写入胜利” 冲突解决方法几乎可以确定是非线性一致的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。宽松的法定人数也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是可能的。
线性一致性和法定人数
直觉上严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件。
x 的初始值为 0,写入客户端通过向所有三个副本(n = 3, w = 3)发送写入将 x 更新为 1
。客户端 A 并发地从两个节点组成的法定人群(r = 2)中读取数据,并在其中一个节点上看到新值 1
。客户端 B 也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 0
。
法定人数条件满足(w + r > n),但是这个执行是非线性一致的:B 的请求在 A 的请求完成后开始,但是 B 返回旧值,而 A 返回新值。
通过牺牲性能,可以使 Dynamo 风格的法定人数线性化:读取者必须在将结果返回给应用之前,同步执行读修复,并且写入者必须在发送写入之前,读取法定数量节点的最新状态。
而且,这种方式只能实现线性一致的读写;不能实现线性一致的 CAS 操作,因为它需要一个共识算法。
线性一致性的代价
对多数据中心的复制而言,多主复制通常是理想的选择。我们假设每个数据中心内的网络正在工作,客户端可以访问数据中心,但数据中心之间彼此无法互相连接。
使用多主数据库,每个数据中心都可以继续正常运行:由于在一个数据中心写入的数据是异步复制到另一个数据中心的,所以在恢复网络连接时,写入操作只是简单地排队并交换。
另一方面,如果使用单主复制,则主库必须位于其中一个数据中心。任何写入和任何线性一致的读取请求都必须发送给该主库,因此对于连接到从库所在数据中心的客户端,这些读取和写入请求必须通过网络同步发送到主库所在的数据中心。
在单主配置的条件下,如果数据中心之间的网络被中断,则连接到从库数据中心的客户端无法联系到主库,因此它们无法对数据库执行任何写入,也不能执行任何线性一致的读取。它们仍能从从库读取,但结果可能是陈旧的(非线性一致)。如果应用需要线性一致的读写,却又位于与主库网络中断的数据中心,则网络中断将导致这些应用不可用。
网络中断迫使在线性一致性和可用性之间做出选择。
CAP 定理
如果应用需要线性一致性,且某些副本因为网络问题与其他副本断开连接,那么这些副本掉线时不能处理请求。请求必须等到网络问题解决,或直接返回错误。(无论哪种方式,服务都 不可用)。
如果应用不需要线性一致性,那么某个副本即使与其他副本断开连接,也可以独立处理请求(例如多主复制)。在这种情况下,应用可以在网络问题解决前保持可用,但其行为不是线性一致的。
因此,不需要线性一致性的应用对网络问题有更强的容错能力。这种见解通常被称为 CAP 定理。
CAP 有时以这种面目出现:一致性,可用性和分区容错性:三者只能择其二。这种说法很有误导性,因为网络分区是一种故障类型,所以它并不是一个选项:不管你喜不喜欢它都会发生。 在网络正常工作的时候,系统可以提供线性一致性和整体可用性。发生网络故障时,你必须在线性一致性和整体可用性之间做出选择。因此,CAP 更好的表述成:在分区时要么选择一致,要么选择可用。一个更可靠的网络需要减少这个选择,但是在某些时候选择是不可避免的。 在 CAP 的讨论中,术语可用性有几个相互矛盾的定义,形式化作为一个定理并不符合其通常的含义,所以最好避免使用 CAP。
线性一致性和网络延迟
虽然线性一致是一个很有用的保证,但线性一致的系统惊人的少。现代多核 CPU 上的内存甚至都不是线性一致的:如果一个 CPU 核上运行的线程写入某个内存地址,而另一个 CPU 核上运行的线程不久之后读取相同的地址,并不一定能读到第一个线程写入的值(除非使用了 内存屏障(memory barrier) 或 围栏(fence))。
这种行为的原因是每个 CPU 核都有自己的内存缓存和存储缓冲区。默认情况下,内存访问首先走缓存,任何变更会异步写入主存。因为缓存访问比主存要快得多,所以这个特性对于现代 CPU 的良好性能表现至关重要。对多核内存一致性模型而言,CAP 定理是没有意义的:在同一台计算机中,我们通常假定通信都是可靠的。并且我们并不指望一个 CPU 核能在脱离计算机其他部分的条件下继续正常工作。牺牲线性一致性的原因是 性能(performance),而不是容错。
许多分布式数据库也是如此:它们是 为了提高性能 而选择了牺牲线性一致性,而不是为了容错。线性一致的速度很慢 —— 这始终是事实。
如果你想要线性一致性,读写请求的响应时间至少与网络延迟的不确定性成正比。在像大多数计算机网络一样具有高度可变延迟的网络中,线性读写的响应时间不可避免地会很高。更快地线性一致算法不存在,但更弱的一致性模型可以快得多。
顺序保证
顺序(ordering) 反复出现,表明它是一个重要的基础性概念。回顾一下其它曾经出现过 顺序 的上下文:
领导者在单主复制中的主要目的就是,在复制日志中确定 写入顺序(order of write)。如果不存在一个领导者,则并发操作可能导致冲突。
可串行化 是关于事务表现的像按 某种先后顺序(some sequential order) 执行的保证。它可以字面意义上地以 串行顺序(serial order) 执行事务来实现,或者允许并行执行,但同时防止序列化冲突来实现(通过锁或中止事务)。
分布式系统中使用时间戳和时钟是另一种将顺序引入无序世界的尝试,例如,确定两个写入操作哪一个更晚发生。
顺序与因果顺序
顺序 反复出现有几个原因,其中一个原因是,它有助于保持 因果关系(causality)。因果关系对事件施加了一种 顺序:因在果之前;消息发送在消息收取之前。
如果一个系统服从因果关系所规定的顺序,我们说它是 因果一致(causally consistent) 的。例如,快照隔离提供了因果一致性:当你从数据库中读取到一些数据时,你一定还能够看到其因果前驱(假设在此期间这些数据还没有被删除)。
因果顺序不是全序的
全序(total order) 允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪个更大,哪个更小。例如,自然数集是全序的:给定两个自然数,比如说 5 和 13,那么你可以告诉我,13 大于 5。
然而数学集合并不完全是全序的:{a, b}
比 {b, c}
更大吗?好吧,你没法真正比较它们,因为二者都不是对方的子集。我们说它们是 无法比较(incomparable) 的,因此数学集合是 偏序的(partially ordered) :在某些情况下,可以说一个集合大于另一个(如果一个集合包含另一个集合的所有元素),但在其他情况下它们是无法比较的 。
全序和偏序之间的差异反映在不同的数据库一致性模型中:
线性一致性 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。
因果性 如果两个操作都没有在彼此 之前发生,那么这两个操作是并发的。换句话说,如果两个事件是因果相关的(一个发生在另一个事件之前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。这意味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则是无法比较的。
根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。
并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。
如果你熟悉像 Git 这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个 提交(Commit) 发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),合并(Merge) 会在这些并发创建的提交相融合时创建。
线性一致性强于因果一致性
任何线性一致的系统都能正确保持因果性。特别是,如果系统中有多个通信通道,线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。
线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。但使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。
线性一致性并不是保持因果性的唯一途径 —— 还有其他方法。一个系统可以是因果一致的,而无需承担线性一致带来的性能折损(尤其对于 CAP 定理不适用的情况)。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最强的一致性模型。而且在网络故障时仍能保持可用。
在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性可以更高效地实现。
捕获因果关系
为了维持因果性,你需要知道哪个操作发生在哪个其他操作之前(happened before)。并发操作可以以任意顺序进行这是一个偏序,但如果一个操作发生在另一个操作之前,那它们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面的操作必须等待,直到前面的操作被处理完毕。
如果节点在发出写入 Y 的请求时已经看到了 X 的值,则 X 和 Y 可能存在因果关系。这个分析使用了那些在欺诈指控刑事调查中常见的问题:CEO 在做出决定 Y 时是否 知道 X ?
无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题。
为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。在 SSI 的冲突检测中会出现类似的想法,如 “可串行化快照隔离” 中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。
序列号顺序
实际上跟踪所有的因果关系是不切实际的。在许多应用中,客户端在写入内容之前会先读取大量数据,我们无法弄清写入因果依赖于先前全部的读取内容,还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。
但还有一个更好的方法:我们可以使用 序列号(sequence number) 或 时间戳(timestamp) 来排序事件。时间戳不一定来自日历时钟(或物理时钟,它们存在许多问题)。它可以来自一个 逻辑时钟(logical clock),典型实现是使用一个每次操作自增的计数器。
它提供了一个全序关系:也就是说每个操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操作后发生)。
特别是,我们可以使用 与因果一致(consistent with causality) 的全序来生成序列号 :我们保证,如果操作 A 因果地发生在操作 B 前,那么在这个全序中 A 在 B 前( A 具有比 B 更小的序列号)。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息,但也施加了一个比因果性要求更为严格的顺序。
在单主复制的数据库中,复制日志定义了与因果一致的写操作。主库可以简单地为每个操作自增一个计数器,从而为复制日志中的每个操作分配一个单调递增的序列号。如果一个从库按照它们在复制日志中出现的顺序来应用写操作,那么从库的状态始终是因果一致的(即使它落后于领导者)。
非因果序列号生成器
如果主库不存在(可能因为使用了多主数据库或无主数据库,或者因为使用了分区的数据库),如何为操作生成序列号就没有那么明显了。在实践中有各种各样的方法:
每个节点都可以生成自己独立的一组序列号。例如有两个节点,一个节点只能生成奇数,而另一个节点只能生成偶数。通常,可以在序列号的二进制表示中预留一些位,用于唯一的节点标识符,这样可以确保两个不同的节点永远不会生成相同的序列号。
可以将日历时钟(物理时钟)的时间戳附加到每个操作上。这种时间戳并不连续,但是如果它具有足够高的分辨率,那也许足以提供一个操作的全序关系。这一事实应用于 最后写入胜利 的冲突解决方法中。
可以预先分配序列号区块。例如,节点 A 可能要求从序列号 1 到 1,000 区块的所有权,而节点 B 可能要求序列号 1,001 到 2,000 区块的所有权。然后每个节点可以独立分配所属区块中的序列号,并在序列号告急时请求分配一个新的区块。
这三个选项都比单一主库的自增计数器表现要好,并且更具可伸缩性。它们为每个操作生成一个唯一的,近似自增的序列号。然而它们都有同一个问题:生成的序列号与因果不一致。
因为这些序列号生成器不能正确地捕获跨节点的操作顺序,所以会出现因果关系的问题:
如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。
来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。因果上晚发生的操作,却被分配了一个更早的时间戳。
在分配区块的情况下,某个操作可能会被赋予一个范围在 1,001 到 2,000 内的序列号,然而一个因果上更晚的操作可能被赋予一个范围在 1 到 1,000 之间的数字。这里序列号与因果关系也是不一致的。
兰伯特时间戳
每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。兰伯特时间戳就是两者的简单组合:(计数器,节点 ID)。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点 ID,每个时间戳都是唯一的。
它提供了一个全序:如果你有两个时间戳,则 计数器 值大者是更大的时间戳。如果计数器值相同,则节点 ID 越大的,时间戳越大。
这个描述与奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大 计数器 值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。
客户端 A 从节点 2 接收计数器值 5
,然后将最大值 5
发送到节点 1 。此时,节点 1 的计数器仅为 1
,但是它立即前移至 5
,所以下一个操作的计数器的值为 6
。
只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。
兰伯特时间戳有时会与版本向量相混淆。虽然两者有一些相似之处,但它们有着不同的目的:版本向量可以区分两个操作是并发的,还是一个因果依赖另一个;而兰伯特时间戳总是施行一个全序。从兰伯特时间戳的全序中,你无法分辨两个操作是并发的还是因果依赖的。兰伯特时间戳优于版本向量的地方是,它更加紧凑。
光有时间戳排序还不够
虽然兰伯特时间戳定义了一个与因果一致的全序,但它还不足以解决分布式系统中的许多常见问题。
例如,考虑一个需要确保用户名能唯一标识用户帐户的系统。如果两个用户同时尝试使用相同的用户名创建帐户,则其中一个应该成功,另一个应该失败。
似乎操作的全序关系足以解决这一问题:如果创建了两个具有相同用户名的帐户,选择时间戳较小的那个作为胜者,并让带有更大时间戳者失败。由于时间戳上有全序关系,所以这个比较总是可行的。
这种方法适用于事后确定胜利者:当某个节点需要实时处理用户创建用户名的请求时,这样的方法就无法满足了。节点需要 马上(right now) 决定这个请求是成功还是失败。在那个时刻,节点并不知道是否存在其他节点正在并发执行创建同样用户名的操作。
为了确保没有其他节点正在使用相同的用户名和较小的时间戳并发创建同名账户,你必须检查其它每个节点,看看它在做什么。如果其中一个节点由于网络问题出现故障或不可达,则整个系统可能被拖至停机。
这里的问题是,只有在所有的操作都被收集之后,操作的全序才会出现。如果另一个节点已经产生了一些操作,但你还不知道那些操作是什么,那就无法构造所有操作最终的全序关系。
总之为了实现诸如用户名上的唯一约束这种东西,仅有操作的全序是不够的,你还需要知道这个全序何时会尘埃落定。如果你有一个创建用户名的操作,并且确定在全序中没有任何其他节点可以在你的操作之前插入对同一用户名的声称,那么你就可以安全地宣告操作执行成功。
全序广播
如果你的程序只运行在单个 CPU 核上,那么定义一个操作全序是很容易的:可以简单认为就是 CPU 执行这些操作的顺序。但是在分布式系统中可能相当棘手。如果按时间戳或序列号进行排序,它还不如单主复制给力(如果你使用时间戳排序来实现唯一性约束,就不能容忍任何错误,因为你必须要从每个节点都获取到最新的序列号)。
单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个 CPU 核上对所有操作进行排序。但如果吞吐量超出单个主库的处理能力,这种情况下如何扩展系统;以及,如果主库失效,如何处理故障切换。在分布式系统文献中,这个问题被称为 全序广播(total order broadcast) 或 原子广播(atomic broadcast)。
全序广播通常被描述为在节点间交换消息的协议。非正式地讲,它要满足两个安全属性:
可靠交付(reliable delivery) 没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。
全序交付(totally ordered delivery) 消息以相同的顺序传递给每个节点。
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能及时通过并送达(当然它们必须仍然按照正确的顺序传递)。
使用全序广播
全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原理被称为 状态机复制(state machine replication)。
与之类似,可以使用全序广播来实现可串行化的事务:如果每个消息都表示一个确定性事务,以存储过程的形式来执行,且每个节点都以相同的顺序处理这些消息,那么数据库的分区和副本就可以相互保持一致。
全序广播的一个重要表现是,顺序在消息送达时被固化:如果后续的消息已经送达,节点就不允许将消息插入顺序中的较早位置。这个事实使得全序广播比时间戳排序更强。
考量全序广播的另一种方式是,这是一种创建日志的方式:传递消息就像追加写入日志。由于所有节点必须以相同的顺序传递相同的消息,因此所有节点都可以读取日志,并看到相同的消息序列。
全序广播对于实现提供防护令牌的锁服务也很有用。每个获取锁的请求都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。序列号可以当成防护令牌用,因为它是单调递增的。在 ZooKeeper 中,这个序列号被称为 zxid
。
使用全序广播实现线性一致的存储
线性一致与全序广播两者之间有着密切的联系 。
全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息 何时 被送达(所以一个接收者可能落后于其他接收者)。相比之下,线性一致性是新鲜性的保证:读取一定能看见最新的写入值。
但如果有了全序广播,你就可以在此基础上构建线性一致的存储。例如,你可以确保用户名能唯一标识用户帐户。
设想对于每一个可能的用户名,你都可以有一个带有 CAS 原子操作的线性一致寄存器。每个寄存器最初的值为空值(表示未使用该用户名)。当用户想要创建一个用户名时,对该用户名的寄存器执行 CAS 操作,在先前寄存器值为空的条件,将其值设置为用户的账号 ID。如果多个用户试图同时获取相同的用户名,则只有一个 CAS 操作会成功,因为其他用户会看到非空的值(由于线性一致性)。
你可以通过将全序广播当成仅追加日志的方式来实现这种线性一致的 CAS 操作:
在日志中追加一条消息,试探性地指明你要声明的用户名。
读日志,并等待你刚才追加的消息被读回。
检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就是你自己的消息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息)并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。
由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可串行化的多对象事务。
尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的 —— 如果你从与日志异步更新的存储中读取数据,结果可能是陈旧的。为了使读取也线性一致,有几个选项:
你可以通过在日志中追加一条消息,然后读取日志,直到该消息被读回才执行实际的读取操作。消息在日志中的位置因此定义了读取发生的时间点(etcd 的法定人数读取有些类似这种情况)。
如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待该位置前的所有消息都传达到你,然后执行读取。(这是 Zookeeper
sync()
操作背后的思想)。你可以从同步更新的副本中进行读取,因此可以确保结果是最新的(这种技术用于链式复制(chain replication))。
使用线性一致性存储实现全序广播
我们可以反过来,假设我们有线性一致的存储,接下来会展示如何在此基础上构建全序广播。
最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子 自增并返回 操作。或者原子 CAS 操作也可以完成这项工作。
该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行 自增并返回 操作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点(重新发送任何丢失的消息),而收件人将按序列号依序传递消息。
通过自增线性一致性寄存器获得的数字形式上是一个没有间隙的序列。因此,如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息,则它知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同 —— 事实上,这是全序广播和时间戳排序间的关键区别。
实现一个带有原子性 自增并返回 操作的线性一致寄存器有多困难?如果事情从来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。问题在于处理当该节点的网络连接中断时的情况,并在该节点失效时能恢复这个值。一般来说,如果你对线性一致性的序列号生成器进行过足够深入的思考,你不可避免地会得出一个共识算法。
这并非巧合:可以证明,线性一致的 CAS(或自增并返回)寄存器与全序广播都等价于 共识 问题。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题的解决方案。
分布式事务与共识
节点能达成一致,在很多场景下都非常重要,例如:
领导选举 在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点无法与其他节点通信,则可能会对领导权的归属引起争议。错误的故障切换会导致两个节点都认为自己是领导者(脑裂)。
原子提交 在支持跨多节点或跨多分区事务的数据库中,一个事务可能在某些节点上失败,但在其他节点上成功。如果我们想要维护事务的原子性,我们必须让所有节点对事务的结果达成一致。这个共识的例子被称为 原子提交(atomic commit) 问题 。
原子提交与两阶段提交
事务原子性的目的是在多次写操作中途出错的情况下,提供一种简单的语义。事务的结果要么是成功提交,都被持久化;要么是中止,都被回滚(即撤消或丢弃)。
从单节点到分布式原子提交
对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端请求数据库节点提交事务时,数据库将使事务的写入持久化,然后将提交记录追加到磁盘中的日志里。
因此,在单个节点上,事务的提交主要取决于数据持久化落盘的 顺序:首先是数据,然后是提交记录。事务提交或终止的关键决定时刻是磁盘完成写入提交记录的时刻。
如果一个事务中涉及多个节点,很容易发生违反原子性的情况:提交在某些节点上成功,而在其他节点上失败:
某些节点可能会检测到违反约束或冲突,因此需要中止,而其他节点则可以成功进行提交。
某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。
在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。
如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。
事务提交必须是不可撤销的 —— 事务提交之后,无法追溯性地中止事务。一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。如果一个事务在提交后被允许中止,所有那些读取了 已提交却又被追溯声明不存在数据 的事务也必须回滚。
两阶段提交简介
两阶段提交(two-phase commit) 是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。2PC 在某些数据库内部使用,也以 XA 事务 的形式对应用可用(例如 Java Transaction API 支持)。
2PC 使用一个新组件:协调者(coordinator,也称为 事务管理器,即 transaction manager)。协调者通常在请求事务的相同应用进程中以库的形式实现(例如,嵌入在 Java EE 容器中),但也可以是单独的进程或服务。
正常情况下,2PC 事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为 参与者(participants)。当应用准备提交时,协调者开始阶段 1 :它发送一个 准备(prepare) 请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应:
如果所有参与者都回答 “是”,表示它们已经准备好提交,那么协调者在阶段 2 发出 提交(commit) 请求,然后提交真正发生。
如果任意一个参与者回复了 “否”,则协调者在阶段 2 中向所有节点发送 中止(abort) 请求。
系统承诺
在两阶段提交的情况下,准备请求和提交请求当然也可以轻易丢失。2PC 又有什么不同呢?
当应用想要启动一个分布式事务时,它向协调者请求一个事务 ID。此事务 ID 是全局唯一的。
应用在每个参与者上启动单节点事务,并带上这个全局事务 ID。所有的读写都是单节点事务各自完成的。如果在这个阶段出现任何问题则协调者或任何参与者都可以中止。
当应用准备提交时,协调者向所有参与者发送一个 准备 请求,并打上全局事务 ID 的标记。如果任意一个请求失败或超时,则协调者向所有参与者发送该事务的中止请求。
参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事务数据写入磁盘以及检查是否存在任何冲突或违反约束。换句话说,参与者放弃了中止事务的权利,但没有实际提交。
当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定。协调者必须把这个决定写到磁盘上的事务日志中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为 提交点(commit point)。
一旦协调者的决定落盘,提交或中止请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交 —— 由于参与者投了赞成,因此恢复后它不能拒绝提交。
因此,该协议包含两个关键的 “不归路” 点:当参与者投票 “是” 时,它承诺它稍后肯定能够提交(尽管协调者可能仍然选择放弃);以及一旦协调者做出决定,这一决定是不可撤销的。这些承诺保证了 2PC 的原子性(单节点原子提交将这两个事件合为了一体:将提交记录写入事务日志)。
协调者失效
如果协调者在发送 准备 请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了 “是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为 存疑(in doubt) 的或 不确定(uncertain) 的。
此时可以完成 2PC 的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中止请求之前,将其提交或中止决定写入磁盘上的事务日志:协调者恢复后,通过读取其事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。因此,2PC 的 提交点 归结为协调者上的常规单节点原子提交。
三阶段提交
两阶段提交被称为 阻塞(blocking)- 原子提交协议,因为存在 2PC 可能卡住并等待协调者恢复的情况。
作为 2PC 的替代方案,已经提出了一种称为 三阶段提交(3PC) 的算法。然而,3PC 假定网络延迟有界,节点响应时间有限;在大多数具有无限网络延迟和进程暂停的实际系统中,它并不能保证原子性。
通常,非阻塞原子提交需要一个 完美的故障检测器(perfect failure detector)—— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中,超时并不是一种可靠的故障检测机制,因为即使没有节点崩溃,请求也可能由于网络问题而超时。出于这个原因,2PC 仍然被使用,尽管大家都清楚存在协调者故障的问题。
实践中的分布式事务
数据库内部的分布式事务 一些分布式数据库(即在其标准配置中使用复制和分区的数据库)支持数据库节点之间的内部事务。在这种情况下,所有参与事务的节点都运行相同的数据库软件。
异构分布式事务 在 异构(heterogeneous) 事务中,参与者是由两种或两种以上的不同技术组成的:例如来自不同供应商的两个数据库,甚至是非数据库系统(如消息队列)。跨系统的分布式事务必须确保原子提交,尽管系统可能完全不同。
恰好一次的消息处理
异构的分布式事务处理能够以强大的方式集成不同的系统。例如:消息队列中的一条消息可以被确认为已处理,当且仅当用于处理消息的数据库事务成功提交。这是通过在同一个事务中原子提交 消息确认 和 数据库写入 两个操作来实现的。
如果消息传递或数据库事务任意一者失败,两者都会中止,因此消息代理可能会在稍后安全地重传消息。因此即使在成功之前需要几次重试,也可以确保消息被 有效地(effectively) 恰好处理一次。
然而,只有当所有受事务影响的系统都使用同样的 原子提交协议(atomic commit protocol) 时,这样的分布式事务才是可能的。例如,假设处理消息的副作用是发送一封邮件,而邮件服务器并不支持两阶段提交:如果消息处理失败并重试,则可能会发送两次或更多次的邮件。但如果处理消息的所有副作用都可以在事务中止时回滚,那么这样的处理流程就可以安全地重试,就好像什么都没有发生过一样。
XA 事务
XA(扩展架构(eXtended Architecture) 的缩写)是跨异构技术实现两阶段提交的标准。XA 不是一个网络协议 —— 它只是一个用来与事务协调者连接的 API。
XA 假定你的应用使用网络驱动或客户端库来与 参与者(数据库或消息服务)进行通信。如果驱动支持 XA,则意味着它会调用 XA API 以查明操作是否为分布式事务的一部分 —— 如果是,则将必要的信息发往数据库服务器。驱动还会向协调者暴露回调接口,协调者可以通过回调来要求参与者准备、提交或中止。
事务协调者需要实现 XA API。标准没有指明应该如何实现,但实际上协调者通常只是一个库,被加载到发起事务的应用的同一个进程中。它在事务中跟踪所有的参与者,并在要求它们 准备 之后收集参与者的响应(通过驱动回调),并使用本地磁盘上的日志记录每次事务的决定(提交 / 中止)。
如果应用进程崩溃,或者运行应用的机器报销了,任何带有 准备了 但未提交事务的参与者都会在疑虑中卡死。由于协调程序的日志位于应用服务器的本地磁盘上,因此必须重启该服务器,且协调程序库必须读取日志以恢复每个事务的提交 / 中止结果。只有这样,协调者才能使用数据库驱动的 XA 回调来要求参与者提交或中止。
从协调者故障中恢复
理论上,如果协调者崩溃并重新启动,它应该干净地从日志中恢复其状态,并解决任何存疑事务。然而在实践中,孤立(orphaned) 的存疑事务确实会出现,即无论出于何种理由,协调者无法确定事务的结果(例如事务日志已经由于软件错误丢失或损坏)。这些事务无法自动解决,所以它们永远待在数据库中,持有锁并阻塞其他事务。
即使重启数据库服务器也无法解决这个问题,因为在 2PC 的正确实现中,即使重启也必须保留存疑事务的锁(否则就会冒违反原子性保证的风险)。这是一种棘手的情况。
唯一的出路是让管理员手动决定提交还是回滚事务。管理员必须检查每个存疑事务的参与者,确定是否有任何参与者已经提交或中止,然后将相同的结果应用于其他参与者。解决这个问题潜在地需要大量的人力,并且可能发生在严重的生产中断期间(不然为什么协调者处于这种糟糕的状态),并很可能要在巨大精神压力和时间压力下完成。
许多 XA 的实现都有一个叫做 启发式决策(heuristic decisions) 的紧急逃生舱口:允许参与者单方面决定放弃或提交一个存疑事务,而无需协调者做出最终决定。要清楚的是,这里 启发式 是 可能破坏原子性(probably breaking atomicity) 的委婉说法,因为它违背了两阶段提交的系统承诺。因此,启发式决策只是为了逃出灾难性的情况而准备的,而不是为了日常使用的。
分布式事务的限制
XA 事务解决了保持多个参与者(数据系统)相互一致的现实的和重要的问题,但正如我们所看到的那样,它也引入了严重的运维问题。特别来讲,这里的核心认识是:事务协调者本身就是一种数据库(存储了事务的结果),因此需要像其他重要数据库一样小心地打交道:
如果协调者没有复制,而是只在单台机器上运行,那么它是整个系统的失效单点(因为它的失效会导致其他应用服务器阻塞在存疑事务持有的锁上)。令人惊讶的是,许多协调者实现默认情况下并不是高可用的,或者只有基本的复制支持。
许多服务器端应用都是使用无状态模式开发的(受 HTTP 的青睐),所有持久状态都存储在数据库中,因此具有应用服务器可随意按需添加删除的优点。但是,当协调者成为应用服务器的一部分时,它会改变部署的性质。突然间,协调者的日志成为持久系统状态的关键部分 —— 与数据库本身一样重要,因为协调者日志是为了在崩溃后恢复存疑事务所必需的。这样的应用服务器不再是无状态的了。
由于 XA 需要兼容各种数据系统,因此它必须是所有系统的最小公分母。例如,它不能检测不同系统间的死锁(因为这将需要一个标准协议来让系统交换每个事务正在等待的锁的信息),而且它无法与 SSI 协同工作,因为这需要一个跨系统定位冲突的协议。
对于数据库内部的分布式事务(不是 XA),限制没有这么大 —— 例如,分布式版本的 SSI 是可能的。然而仍然存在问题:2PC 成功提交一个事务需要所有参与者的响应。因此,如果系统的 任何 部分损坏,事务也会失败。因此,分布式事务又有 扩大失效(amplifying failures) 的趋势,这又与我们构建容错系统的目标背道而驰。
容错共识
非正式地,共识意味着让几个节点就某事达成一致。例如,如果有几个人 同时(concurrently) 尝试预订飞机上的最后一个座位,或剧院中的同一个座位,或者尝试使用相同的用户名注册一个帐户。共识算法可以用来确定这些 互不相容(mutually incompatible) 的操作中,哪一个才是赢家。
共识问题通常形式化如下:一个或多个节点可以 提议(propose) 某些值,而共识算法 决定(decides) 采用其中的某个值。在座位预订的例子中,当几个顾客同时试图订购最后一个座位时,处理顾客请求的每个节点可以 提议 将要服务的顾客的 ID,而 决定 指明了哪个顾客获得了座位。
在这种形式下,共识算法必须满足以下性质:
一致同意(Uniform agreement) 没有两个节点的决定不同。
完整性(Integrity) 没有节点决定两次。
有效性(Validity) 如果一个节点决定了值
v
,则v
由某个节点所提议。终止(Termination) 由所有未崩溃的节点来最终决定值。
一致同意 和 完整性 属性定义了共识的核心思想:所有人都决定了相同的结果,一旦决定了,你就不能改变主意。有效性 属性主要是为了排除平凡的解决方案:例如,无论提议了什么值,你都可以有一个始终决定值为 null
的算法,该算法满足 一致同意 和 完整性 属性,但不满足 有效性 属性。
如果你不关心容错,那么满足前三个属性很容易:你可以将一个节点硬编码为 “独裁者”,并让该节点做出所有的决定。但如果该节点失效,那么系统就无法再做出任何决定。事实上,这就是我们在两阶段提交的情况中所看到的:如果协调者失效,那么存疑的参与者就无法决定提交还是中止。
终止 属性形式化了容错的思想。它实质上说的是,一个共识算法不能简单地永远闲坐着等死 —— 换句话说,它必须取得进展。即使部分节点出现故障,其他节点也必须达成一项决定(终止 是一种 活性属性,而另外三种是 安全属性 —— 请参阅 “安全性和活性”)。
共识的系统模型假设,当一个节点 “崩溃” 时,它会突然消失而且永远不会回来。(不像软件崩溃,想象一下地震,包含你的节点的数据中心被山体滑坡所摧毁,你必须假设节点被埋在 30 英尺以下的泥土中,并且永远不会重新上线)在这个系统模型中,任何需要等待节点恢复的算法都不能满足 终止 属性。特别是,2PC 不符合终止属性的要求。
当然如果 所有 的节点都崩溃了,没有一个在运行,那么所有算法都不可能决定任何事情。算法可以容忍的失效数量是有限的:事实上可以证明,任何共识算法都需要至少占总体 多数(majority) 的节点正确工作,以确保终止属性。多数可以安全地组成法定人数。
因此 终止 属性取决于一个假设,不超过一半的节点崩溃或不可达。然而即使多数节点出现故障或存在严重的网络问题,绝大多数共识的实现都能始终确保安全属性得到满足 —— 一致同意,完整性和有效性。因此,大规模的中断可能会阻止系统处理请求,但是它不能通过使系统做出无效的决定来破坏共识系统。
大多数共识算法假设不存在 拜占庭式错误。也就是说,如果一个节点没有正确地遵循协议(例如,如果它向不同节点发送矛盾的消息),它就可能会破坏协议的安全属性。克服拜占庭故障,稳健地达成共识是可能的,只要少于三分之一的节点存在拜占庭故障。
共识算法和全序广播
最著名的容错共识算法是 视图戳复制(VSR, Viewstamped Replication),Paxos ,Raft 以及 Zab 。这些算法之间有不少相似之处,但它们并不相同。在本书中我们不会介绍各种算法的详细细节:了解一些它们共通的高级思想通常已经足够了,除非你准备自己实现一个共识系统。(可能并不明智,相当难)
大多数这些算法实际上并不直接使用这里描述的形式化模型(提议与决定单个值,并满足一致同意、完整性、有效性和终止属性)。取而代之的是,它们决定了值的 顺序(sequence),这使它们成为全序广播算法,正如本章前面所讨论的那样。
请记住,全序广播要求将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。如果仔细思考,这相当于进行了几轮共识:在每一轮中,节点提议下一条要发送的消息,然后决定在全序中下一条要发送的消息。
所以,全序广播相当于重复进行多轮共识(每次共识决定与一次消息传递相对应):
由于 一致同意 属性,所有节点决定以相同的顺序传递相同的消息。
由于 完整性 属性,消息不会重复。
由于 有效性 属性,消息不会被损坏,也不能凭空编造。
由于 终止 属性,消息不会丢失。
视图戳复制,Raft 和 Zab 直接实现了全序广播,因为这样做比重复 一次一值(one value a time) 的共识更高效。在 Paxos 的情况下,这种优化被称为 Multi-Paxos。
单主复制与共识
单主复制将所有的写入操作都交给主库,并以相同的顺序将它们应用到从库,从而使副本保持在最新状态。这实际上不就是一个全序广播吗?为什么我们在单主复制里一点都没担心过共识问题呢?
答案取决于如何选择领导者。如果主库是由运维人员手动选择和配置的,那么你实际上拥有一种 独裁类型 的 “共识算法”:只有一个节点被允许接受写入(即决定写入复制日志的顺序),如果该节点发生故障,则系统将无法写入,直到运维手动配置其他节点作为主库。这样的系统在实践中可以表现良好,但它无法满足共识的 终止 属性,因为它需要人为干预才能取得 进展。
一些数据库会自动执行领导者选举和故障切换,如果旧主库失效,会提拔一个从库为新主库。这使我们向容错的全序广播更进一步,从而达成共识。
但是还有一个问题。我们之前曾经讨论过脑裂的问题,并且说过所有的节点都需要同意是谁领导,否则两个不同的节点都会认为自己是领导者,从而导致数据库进入不一致的状态。因此,选出一位领导者需要共识。但如果这里描述的共识算法实际上是全序广播算法,并且全序广播就像单主复制,而单主复制需要一个领导者,那么...
这样看来,要选出一个领导者,我们首先需要一个领导者。要解决共识问题,我们首先需要解决共识问题。我们如何跳出这个先有鸡还是先有蛋的问题?
纪元编号和法定人数
迄今为止所讨论的所有共识协议,在内部都以某种形式使用一个领导者,但它们并不能保证领导者是独一无二的。相反,它们可以做出更弱的保证:协议定义了一个 纪元编号(epoch number,在 Paxos 中被称为 投票编号,即 ballot number,在视图戳复制中被称为 视图编号,即 view number,以及在 Raft 中被为 任期号码,即 term number),并确保在每个时代中,领导者都是唯一的。
每次当现任领导被认为挂掉的时候,节点间就会开始一场投票,以选出一个新领导。这次选举被赋予一个递增的纪元编号,因此纪元编号是全序且单调递增的。如果两个不同的时代的领导者之间出现冲突(也许是因为前任领导者实际上并未死亡),那么带有更高纪元编号的领导说了算。
在任何领导者被允许决定任何事情之前,必须先检查是否存在其他带有更高纪元编号的领导者,它们可能会做出相互冲突的决定。领导者如何知道自己没有被另一个节点赶下台?回想一下在 “真相由多数所定义” 中提到的:一个节点不一定能相信自己的判断 —— 因为只有节点自己认为自己是领导者,并不一定意味着其他节点接受它作为它们的领导者。
相反,它必须从 法定人数(quorum) 的节点中获取选票(请参阅 “读写的法定人数”)。对领导者想要做出的每一个决定,都必须将提议值发送给其他节点,并等待法定人数的节点响应并赞成提案。法定人数通常(但不总是)由多数节点组成【105】。只有在没有意识到任何带有更高纪元编号的领导者的情况下,一个节点才会投票赞成提议。
因此,我们有两轮投票:第一次是为了选出一位领导者,第二次是对领导者的提议进行表决。关键的洞察在于,这两次投票的 法定人群 必须相互 重叠(overlap):如果一个提案的表决通过,则至少得有一个参与投票的节点也必须参加过最近的领导者选举【105】。因此,如果在一个提案的表决过程中没有出现更高的纪元编号。那么现任领导者就可以得出这样的结论:没有发生过更高时代的领导选举,因此可以确定自己仍然在领导。然后它就可以安全地对提议值做出决定。
这一投票过程表面上看起来很像两阶段提交。最大的区别在于,2PC 中协调者不是由选举产生的,而且 2PC 则要求 所有 参与者都投赞成票,而容错共识算法只需要多数节点的投票。而且,共识算法还定义了一个恢复过程,节点可以在选举出新的领导者之后进入一个一致的状态,确保始终能满足安全属性。这些区别正是共识算法正确性和容错性的关键。
共识的局限性
共识算法对于分布式系统来说是一个巨大的突破:它为其他充满不确定性的系统带来了基础的安全属性(一致同意,完整性和有效性),然而它们还能保持容错(只要多数节点正常工作且可达,就能取得进展)。它们提供了全序广播,因此它们也可以以一种容错的方式实现线性一致的原子操作(请参阅 “使用全序广播实现线性一致的存储”)。
尽管如此,它们并不是在所有地方都用上了,因为好处总是有代价的。
节点在做出决定之前对提议进行投票的过程是一种同步复制。如 “同步复制与异步复制” 中所述,通常数据库会配置为异步复制模式。在这种配置中发生故障切换时,一些已经提交的数据可能会丢失 —— 但是为了获得更好的性能,许多人选择接受这种风险。
共识系统总是需要严格多数来运转。这意味着你至少需要三个节点才能容忍单节点故障(其余两个构成多数),或者至少有五个节点来容忍两个节点发生故障(其余三个构成多数)。如果网络故障切断了某些节点同其他节点的连接,则只有多数节点所在的网络可以继续工作,其余部分将被阻塞(请参阅 “线性一致性的代价”)。
大多数共识算法假定参与投票的节点是固定的集合,这意味着你不能简单的在集群中添加或删除节点。共识算法的 动态成员扩展(dynamic membership extension) 允许集群中的节点集随时间推移而变化,但是它们比静态成员算法要难理解得多。
共识系统通常依靠超时来检测失效的节点。在网络延迟高度变化的环境中,特别是在地理上散布的系统中,经常发生一个节点由于暂时的网络问题,错误地认为领导者已经失效。虽然这种错误不会损害安全属性,但频繁的领导者选举会导致糟糕的性能表现,因系统最后可能花在权力倾扎上的时间要比花在建设性工作的多得多。
有时共识算法对网络问题特别敏感。例如 Raft 已被证明存在让人不悦的极端情况【106】:如果整个网络工作正常,但只有一条特定的网络连接一直不可靠,Raft 可能会进入领导者在两个节点间频繁切换的局面,或者当前领导者不断被迫辞职以致系统实质上毫无进展。其他一致性算法也存在类似的问题,而设计能健壮应对不可靠网络的算法仍然是一个开放的研究问题。
成员与协调服务
像 ZooKeeper 或 etcd 这样的项目通常被描述为 “分布式键值存储” 或 “协调与配置服务”。这种服务的 API 看起来非常像数据库:你可以读写给定键的值,并遍历键。所以如果它们基本上算是数据库的话,为什么它们要把工夫全花在实现一个共识算法上呢?是什么使它们区别于其他任意类型的数据库?
为了理解这一点,简单了解如何使用 ZooKeeper 这类服务是很有帮助的。作为应用开发人员,你很少需要直接使用 ZooKeeper,因为它实际上不适合当成通用数据库来用。更有可能的是,你会通过其他项目间接依赖它,例如 HBase、Hadoop YARN、OpenStack Nova 和 Kafka 都依赖 ZooKeeper 在后台运行。这些项目从它那里得到了什么?
ZooKeeper 和 etcd 被设计为容纳少量完全可以放在内存中的数据(虽然它们仍然会写入磁盘以保证持久性),所以你不会想着把所有应用数据放到这里。这些少量数据会通过容错的全序广播算法复制到所有节点上。正如前面所讨论的那样,数据库复制需要的就是全序广播:如果每条消息代表对数据库的写入,则以相同的顺序应用相同的写入操作可以使副本之间保持一致。
ZooKeeper 模仿了 Google 的 Chubby 锁服务【14,98】,不仅实现了全序广播(因此也实现了共识),而且还构建了一组有趣的其他特性,这些特性在构建分布式系统时变得特别有用:
线性一致性的原子操作
使用原子 CAS 操作可以实现锁:如果多个节点同时尝试执行相同的操作,只有一个节点会成功。共识协议保证了操作的原子性和线性一致性,即使节点发生故障或网络在任意时刻中断。分布式锁通常以 租约(lease) 的形式实现,租约有一个到期时间,以便在客户端失效的情况下最终能被释放(请参阅 “进程暂停”)。
操作的全序排序
如 “领导者和锁” 中所述,当某个资源受到锁或租约的保护时,你需要一个防护令牌来防止客户端在进程暂停的情况下彼此冲突。防护令牌是每次锁被获取时单调增加的数字。ZooKeeper 通过全序化所有操作来提供这个功能,它为每个操作提供一个单调递增的事务 ID(
zxid
)和版本号(cversion
)【15】。失效检测
客户端在 ZooKeeper 服务器上维护一个长期会话,客户端和服务器周期性地交换心跳包来检查节点是否还活着。即使连接暂时中断,或者 ZooKeeper 节点失效,会话仍保持在活跃状态。但如果心跳停止的持续时间超出会话超时,ZooKeeper 会宣告该会话已死亡。当会话超时时(ZooKeeper 称这些节点为 临时节点,即 ephemeral nodes),会话持有的任何锁都可以配置为自动释放。
变更通知
客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。因此,客户端可以知道另一个客户端何时加入集群(基于新客户端写入 ZooKeeper 的值),或发生故障(因其会话超时,而其临时节点消失)。通过订阅通知,客户端不用再通过频繁轮询的方式来找出变更。
在这些功能中,只有线性一致的原子操作才真的需要共识。但正是这些功能的组合,使得像 ZooKeeper 这样的系统在分布式协调中非常有用。
将工作分配给节点
ZooKeeper/Chubby 模型运行良好的一个例子是,如果你有几个进程实例或服务,需要选择其中一个实例作为主库或首选服务。如果领导者失败,其他节点之一应该接管。这对单主数据库当然非常实用,但对作业调度程序和类似的有状态系统也很好用。
另一个例子是,当你有一些分区资源(数据库、消息流、文件存储、分布式 Actor 系统等),并需要决定将哪个分区分配给哪个节点时。当新节点加入集群时,需要将某些分区从现有节点移动到新节点,以便重新平衡负载(请参阅 “分区再平衡”)。当节点被移除或失效时,其他节点需要接管失效节点的工作。
这类任务可以通过在 ZooKeeper 中明智地使用原子操作,临时节点与通知来实现。如果设计得当,这种方法允许应用自动从故障中恢复而无需人工干预。不过这并不容易,尽管已经有不少在 ZooKeeper 客户端 API 基础之上提供更高层工具的库,例如 Apache Curator 【17】。但它仍然要比尝试从头实现必要的共识算法要好得多,这样的尝试鲜有成功记录【107】。
应用最初只能在单个节点上运行,但最终可能会增长到数千个节点。试图在如此之多的节点上进行多数投票将是非常低效的。相反,ZooKeeper 在固定数量的节点(通常是三到五个)上运行,并在这些节点之间执行其多数票,同时支持潜在的大量客户端。因此,ZooKeeper 提供了一种将协调节点(共识,操作排序和故障检测)的一些工作 “外包” 到外部服务的方式。
通常,由 ZooKeeper 管理的数据类型的变化十分缓慢:代表 “分区 7 中的节点运行在 10.1.1.23
上” 的信息可能会在几分钟或几小时的时间内发生变化。它不是用来存储应用的运行时状态的,后者每秒可能会改变数千甚至数百万次。如果应用状态需要从一个节点复制到另一个节点,则可以使用其他工具(如 Apache BookKeeper 【108】)。
服务发现
ZooKeeper、etcd 和 Consul 也经常用于服务发现 —— 也就是找出你需要连接到哪个 IP 地址才能到达特定的服务。在云数据中心环境中,虚拟机来来往往很常见,你通常不会事先知道服务的 IP 地址。相反,你可以配置你的服务,使其在启动时注册服务注册表中的网络端点,然后可以由其他服务找到它们。
但是,服务发现是否需要达成共识还不太清楚。DNS 是查找服务名称的 IP 地址的传统方式,它使用多层缓存来实现良好的性能和可用性。从 DNS 读取是绝对不线性一致性的,如果 DNS 查询的结果有点陈旧,通常不会有问题【109】。DNS 的可用性和对网络中断的鲁棒性更重要。
尽管服务发现并不需要共识,但领导者选举却是如此。因此,如果你的共识系统已经知道领导是谁,那么也可以使用这些信息来帮助其他服务发现领导是谁。为此,一些共识系统支持只读缓存副本。这些副本异步接收共识算法所有决策的日志,但不主动参与投票。因此,它们能够提供不需要线性一致性的读取请求。
成员资格服务
ZooKeeper 和它的小伙伴们可以看作是成员资格服务(membership services)研究的悠久历史的一部分,这个历史可以追溯到 20 世纪 80 年代,并且对建立高度可靠的系统(例如空中交通管制)非常重要【110】。
成员资格服务确定哪些节点当前处于活动状态并且是集群的活动成员。正如我们在 第八章 中看到的那样,由于无限的网络延迟,无法可靠地检测到另一个节点是否发生故障。但是,如果你通过共识来进行故障检测,那么节点可以就哪些节点应该被认为是存在或不存在达成一致。
即使它确实存在,仍然可能发生一个节点被共识错误地宣告死亡。但是对于一个系统来说,知道哪些节点构成了当前的成员关系是非常有用的。例如,选择领导者可能意味着简单地选择当前成员中编号最小的成员,但如果不同的节点对现有的成员都有谁有不同意见,则这种方法将不起作用。
Last updated