数据密集型应用(data-intensive applications
)正在通过使用这些技术进步来推动可能性的 边界。一个应用被称为数据密集型的,如果数据是其主要挑战(数据量,数据复杂度或数据变化速度)—— 与之相对的是计算密集型,即处理器速度是其瓶颈。
数据系统的基石
可靠性、可扩展性、可维护性
现今很多应用程序都是数据密集型(data-intensive)的,而非计算密集型(compute-intensive)的。因此CPU很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
可靠性(Reliability)
系统在困境(adversity)(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功 能,并能达到期望的性能水准)。
人们对于一个东西是否可靠,都有一个直观的想法。人们对可靠软件的典型期望包括:
- 应用程序表现出用户所期望的功能。
- 允许用户犯错,允许用户以出乎意料的方式使用软件。
- 在预期的负载和数据量下,性能满足要求。
- 系统能防止未经授权的访问和滥用。
造成错误的原因叫做 故障(fault) ,能预料并应对故障的系统特性可称为 容错(fault- tolerant) 或韧性(resilient) 。“容错”一词可能会产生误导,因为它暗示着系统可以容忍所 有可能的错误,但在实际中这是不可能的。比方说,如果整个地球(及其上的所有服务器) 都被黑洞吞噬了,想要容忍这种错误,需要把网络托管到太空中——这种预算能不能批准就 祝你好运了。所以在讨论容错时,只有谈论特定类型的错误才有意义。
注意 故障(fault) 不同于 失效(failure)【2】。故障通常定义为系统的一部分状态偏离其 标准,而失效则是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零,因此 最好设计容错机制以防因故障而导致失效。本书中我们将介绍几种用不可靠的部件构建可靠系统的技术。
硬件故障
当想到系统失效的原因时, 硬件故障(hardware faults) 总会第一个进入脑海。硬盘崩溃、 内存出错、机房断电、有人拔错网线……任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机器,这些事情总会发生!
据报道称, 硬盘的平均无故障时间(MTTF, mean time to failure) 约为10到50年【5】 【6】。因此从数学期望上讲,在拥有10000个磁盘的存储集群上,平均每天会有1个磁盘出故障。
为了减少系统的故障率,第一反应通常都是增加单个硬件的冗余度,例如:磁盘可以组建 RAID,服务器可能有双路电源和热插拔CPU,数据中心可能有电池和柴油发电机作为后备电 源,某个组件挂掉时冗余组件可以立刻接管。这种方法虽然不能完全防止由硬件问题导致的 系统失效,但它简单易懂,通常也足以让机器不间断运行很多年。
直到最近,硬件冗余对于大多数应用来说已经足够了,它使单台机器完全失效变得相当罕 见。只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难 性的。只有少量高可用性至关重要的应用才会要求有多套硬件冗余。
但是随着数据量和应用计算需求的增加,越来越多的应用开始大量使用机器,这会相应地增 加硬件故障率。此外在一些云平台(如亚马逊网络服务(AWS, Amazon Web Services)) 中,虚拟机实例不可用却没有任何警告也是很常见的【7】,因为云平台的设计就是优先考虑灵活性(flexibility)和弹性(elasticity),而不是单机可靠性。
如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障 的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器(例如应 用操作系统安全补丁),单服务器系统就需要计划停机。而允许机器失效的系统则可以一次 修复一个节点,无需整个系统停机。
软件故障
我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器 的磁盘也会失效。大量硬件组件不可能同时发生故障,除非它们存在比较弱的相关性(同样 的原因导致关联性错误,例如服务器机架的温度)。
另一类错误是内部的系统性错误(systematic error)【7】。这类错误难以预料,而且因为 是跨节点相关的,所以比起不相关的硬件故障往往可能造成更多的系统失效【5】。
虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的 假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中 的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量 相等),那么系统就可以在运行时不断自检,并在出现差异(discrepancy)时报警、
人为错误
设计并构建了软件系统的工程师是人类,维持系统运行的运维也是人类。即使他们怀有最大 的善意,人类也是不可靠的。举个例子,一项关于大型互联网服务的研究发现,运维配置错 误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了10-25%的服务中断
可扩展性(Scalability)
有合理的办法应对系统的增长(数据量、流量、复杂性)(参阅“可扩展性”)
系统今天能可靠运行,并不意味未来也能可靠运行。服务降级(degradation)的一个常见 原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一 百万增长到一千万。也许现在处理的数据量级要比过去大得多。
可扩展性(Scalability)是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴 在系统上的一维标签:说“X可扩展”或“Y不可扩展”是没有任何意义的。相反,讨论可扩展性意 味着考虑诸如“如果系统以特定方式增长,有什么选项可以应对增长?”和“如何增加计算资源 来处理额外的负载?”等问题。
描述负载
首先要能简要描述系统的当前负载。负 载可以用一些称为负载参数(load parameters)的数字来描述。参数的最佳选择取决于系统 架构,它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的 用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是 少数极端场景。
扇出(Fan Out):从电子工程学中借用的术语,它描述了输入连接到另一个门输出的逻辑门数量。 输出需要提供足够的电流来驱动所有连接的输入。在事务处理系统中,我们使用它来描述为了服务一个传入请求而需要执行其他服务的请求数量。
推特实现方案一(读扩散)
好处:写入简单,坏处:查询复杂
发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如图1-2所示的关系型数据库中,可以编写这样的查询:
推特实现方案二(写扩散)
为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱(图1-3)。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。
推特实现方案方案三(推拉结合)
推特轶事的最终转折:现在已经稳健地实现了方法2,推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户(即名流)会被排除在外。当用户读取主页时间线时,分别地获取出该用户所关注的每位名流的推文,再与用户的主页时间线缓存合并,如方法1所示。这种混合方法能始终如一地提供良好性能。
描述性能
一旦系统的负载被描述好,就可以研究当负载增加会发生什么。我们可以从两种角度来看:
- 增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什 么影响?
- 增加负载参数并希望保持性能不变时,需要增加多少系统资源?
对于Hadoop这样的批处理系统,通常关心的是吞吐量(throughput),即每秒可以处理的 记录数量,或者在特定规模数据集上运行作业的总时间iii。对于在线系统,通常更重要的是服务的响应时间(response time),即客户端发送请求到接收响应之间的时间。
理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。 在实践中由于数据倾斜(数据不是均匀分布在每个工作进程中),需要等待最慢的任务完成,所以运行时间往往更长。
延迟和响应时间
延迟(latency)和响应时间(response time)经常用作同义词,但实际上它们并不一 样。响应时间是客户所看到的,除了实际处理请求的时间(服务时间(service time))之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的持续时长,在此期间它 处于休眠(latent)状态,并等待服务【17】。
应对负载的方法
人们经常讨论纵向扩展(scaling up)(垂直扩展(vertical scaling),转向更强大的机器)和横向扩展(scaling out)(水平扩展(horizontal scaling),将负载分布到多台小机 器上)之间的对立。跨多台机器分配负载也称为“无共享(shared-nothing)”架构。可以在单 台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避 免地需要横向扩展。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
有些系统是弹性(elastic)的,这意味着可以在检测到负载增加时自动增加计算资源,而其 他系统则是手动扩展(人工分析容量并决定向系统添加更多的机器)。如果负载极难预测(highly unpredictable),则弹性系统可能很有用,但手动扩展系统更简单,并且意外操作 可能会更少(参阅“重新平衡分区”)。
大规模的系统架构通常是应用特定的—— 没有一招鲜吃遍天的通用可扩展架构(不正式的叫法:万金油(magic scaling sauce) )。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。
一个良好适配应用的可扩展架构,是围绕着假设(assumption)建立的:哪些操作是常见 的?哪些操作是罕见的?这就是所谓负载参数。如果假设最终是错误的,那么为扩展所做的工程投入就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品 快速迭代的能力,要比可扩展至未来的假想负载要重要的多。
可维护性(Maintainability)
许多不同的人(工程师、运维)在不同的生命周期,都能在高效地在系统上工作(使系统保 持现有行为,并适应新的应用场景)。(参阅”可维护性“)
众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添 加新的功能等等。
不幸的是,许多从事软件系统行业的人不喜欢维护所谓的遗留(legacy)系统,——也许因 为涉及修复其他人的错误、和过时的平台打交道,或者系统被迫使用于一些份外工作。每一 个遗留系统都以自己的方式让人不爽,所以很难给出一个通用的建议来和它们打交道。
但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护 期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则:
- 可操作性(Operability):便于运维团队保持系统平稳运行。
- 简单性(Simplicity):从系统中消除尽可能多的复杂度(complexity),使新工程师也能轻松理解系统。(注意这 和用户接口的简单性不一样。)
- 可演化性(evolability):使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为可扩 展性(extensibility),可修改性(modifiability)或可塑性(plasticity)
复杂度(complexity)有各种可能的症状,例如:状态空间激增、模块间紧密耦合、纠结的 依赖关系、不一致的命名和术语、解决性能问题的Hack、需要绕开的特例等等,现在已经有 很多关于这个话题的讨论【31,32,33】。
简化系统并不一定意味着减少功能;它也可以意味着消除额外的(accidental)的复杂度。 Moseley和Marks【32】把额外复杂度定义为:由具体实现中涌现,而非(从用户视角看,系 统所解决的)问题本身固有的复杂度。
用于消除额外复杂度的最好工具之一是抽象(abstraction)。一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。 比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的 质量改进将使所有使用它的应用受益。
例如,高级编程语言是一种抽象,隐藏了机器码、CPU寄存器和系统调用。 SQL也是一种抽象,隐藏了复杂的磁盘/内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语言编程时,我们仍然用到了机器码;只不过没有直接(directly)使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。
抽象可以帮助我们将系统的复杂度控制在可管理的水平,不过,找到好的抽象是非常困难 的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样抽象。
在组织流程方面,敏捷(agile)工作模式为适应变化提供了一个框架。敏捷社区还开发了对 在频繁变化的环境中开发软件很有帮助的技术工具和模式,如测试驱动开发(TDD, test- driven development)和重构(refactoring)。
数据模型与查询语言
关系模型与文档模
关系模型曾是一个理论性的提议,当时很多人都怀疑是否能够有效实现它。然而到了20世纪80年代中期,关系数据库管理系统(RDBMSes)和SQL已成为大多数人们存储和查询某些常规结构的数据的首选工具。关系数据库已经持续称霸了大约25~30年——这对计算机史来说是极其漫长的时间。
NoSQL的诞生
现在 - 2010年代,NoSQL开始了最新一轮尝试,试图推翻关系模型的统治地位。“NoSQL”这 个名字让人遗憾,因为实际上它并没有涉及到任何特定的技术。最初它只是作为一个醒目的 Twitter标签,用在2009年一个关于分布式,非关系数据库上的开源聚会上。无论如何,这个 术语触动了某些神经,并迅速在网络创业社区内外传播开来。好些有趣的数据库系统现在都 与#NoSQL#标签相关联,并且NoSQL被追溯性地重新解释为不仅是SQL(Not Only SQL) 【4】。
采用NoSQL数据库的背后有几个驱动因素,其中包括:
- 需要比关系数据库更好的可扩展性,包括非常大的数据集或非常高的写入吞吐量
- 相比商业数据库产品,免费和开源软件更受偏爱。
- 关系模型不能很好地支持一些特殊的查询操作
- 受挫于关系模型的限制性,渴望一种更具多动态性与表现力的数据模型【5】
不同的应用程序有不同的需求,一个用例的最佳技术选择可能不同于另一个用例的最佳技术选择。因此,在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用-这种想法有时也被称为混合持久化(polyglot persistence)
对象关系不匹配
目前大多数应用程序开发都使用面向对象的编程语言来开发,这导致了对SQL数据模型的普 遍批评:如果数据存储在关系表中,那么需要一个笨拙的转换层,处于应用程序代码中的对 象和表,行,列的数据库模型之间。模型之间的不连贯有时被称为阻抗不匹配(impedance mismatch)i。
像ActiveRecord和Hibernate这样的对象关系映射(object-relational mapping, ORM)框架 可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。
多对一和多对多的关系
使用ID的好处是,ID对人类没有任何意义,因而永远不需要改变:ID可以保持不变,即使它 标识的信息发生变化。任何对人类有意义的东西都可能需要在将来某个时候改变——如果这 些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,也存在不一致的风险(一些副本被更新了,还有些副本没有被更新)。去除此类重复是数据库规范化 (normalization)的关键思想。
文档数据库是否在重蹈覆辙?
在多对多的关系和连接已常规用在关系数据库时,文档数据库和NoSQL重启了辩论:如何最好地在数据库中表示多对多关系。那场辩论可比NoSQL古老得多,事实上,最早可以追溯到 计算机化数据库系统。
20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了阿 波罗太空计划的库存管理而开发的,并于1968年有了首次商业发布【13】。目前它仍在使用 和维护,运行在IBM大型机的OS/390上【14】。
IMS的设计中使用了一个相当简单的数据模型,称为层次模型(hierarchical model),它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像图2-2的JSON结构。
同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的 引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似 【15】。
那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是关系模型(relational model)(它变成了SQL,统治了世界)和网络模型(network model)(最初很受关注,但最终变得冷门)。这两个阵营之间的“大辩论”在70年代持续了很久时间 【2】。
网络模型
网络模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘 上)。访问记录的唯一方法是跟随从根记录起沿这些链路所形成的路径。这被称为访问路径 (access path)。
最简单的情况下,访问路径类似遍历链表:从列表头开始,每次查看一条记录,直到找到所 需的记录。但在多对多关系的情况中,数条不同的路径可以到达相同的记录,网络模型的程 序员必须跟踪这些不同的访问路径。
尽管手动选择访问路径够能最有效地利用20世纪70年代非常有限的硬件功能(如磁带驱动器,其搜索速度非常慢),但这使得查询和更新数据库的代码变得复杂不灵活。无论是分层还是网络模型,如果你没有所需数据的路径,就会陷入困境。你可以改变访问路径,但是必须浏览大量手写数据库查询代码,并重写来处理新的访问路径。更改应用程序的数据模型是很难的。
关系模型
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个关系(表)只是一个元 组(行)的集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的 访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些 列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系iv。
关系数据库的查询优化器是复杂的,已耗费了多年的研究和开发精力【18】。关系模型的一 个关键洞察是:只需构建一次查询优化器,随后使用该数据库的所有应用程序都可以从中受 益。如果你没有查询优化器的话,那么为特定查询手动编写访问路径比编写通用优化器更容 易——不过从长期看通用解决方案更好。
文档模型中的架构灵活性
大多数文档数据库以及关系数据库中的JSON支持都不会强制文档中的数据采用何种模式。关 系数据库的XML支持通常带有可选的模式验证。没有模式意味着可以将任意的键和值添加到 文档中,并且当读取时,客户端对无法保证文档可能包含的字段。
文档数据库有时称为无模式(schemaless),但这具有误导性,因为读取数据的代码通常假 定某种结构——即存在隐式模式,但不由数据库强制执行【20】。一个更精确的术语是读时 模式(schema-on-read)(数据的结构是隐含的,只有在数据被读取时才被解释),相应的 是写时模式(schema-on-write)(传统的关系数据库方法中,模式明确,且数据库确保所 有的数据都符合其模式)【21】。
查询的数据局部性
文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如 MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那 么存储局部性会带来性能优势。如果将数据分割到多个表中(如图2-1所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
文档和关系数据库的融合
自2000年代中期以来,大多数关系数据库系统(MySQL除外)都已支持XML。这包括对XML 文档进行本地修改的功能,以及在XML文档中进行索引和查询的功能。这允许应用程序使用 那种与文档数据库应当使用的非常类似的数据模型。
从9.3版本开始的PostgreSQL 【8】,从5.7版本开始的MySQL以及从版本10.5开始的IBM DB2 [30]也对JSON文档提供了类似的支持级别。鉴于用在Web APIs的JSON流行趋势,其他关系数据库很可能会跟随他们的脚步并添加JSON支持。
图数据模型
一个图由两种对象组成:顶点(vertices)(也称为节点(nodes) 或实体(entities)), 和边(edges)( 也称为关系(relationships)或弧 (arcs) )。多种数据可以被建模为 一个图形。典型的例子包括:社交图谱
存储与检索
一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。
驱动数据库的数据结构
为了高效查找数据库中特定键的值,我们需要一个数据结构:索引(index)。本章将介绍一系列的索引结构,并它们进行对比。索引背后的大致思想是,保存一些额外的元数据作为路标,帮助你找到想要的数据。如果您想在同一份数据中以几种不同的方式进行搜索,那么你也许需要不同的索引,建在数据的不同部分上。
索引是从主数据衍生的附加(additional)结构。许多数据库允许添加与删除索引,这不会影响数据的内容,它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通 常都会减慢写入速度,因为每次写入数据时都需要更新索引。
这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容,而需要你(程序员或 DBA)通过对应用查询模式的了解来手动选择索引。你可以选择能为应用带来最大收益,同时又不会引入超出必要开销的索引。
哈希索引
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策 略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量, 指明了可以找到对应值的位置,如图3-1所示。当你将新的键值对追加写入文件中时,还要更 新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当 你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,寻找(seek)该位置并读取 该值。
听上去简单,但这是一个可行的方法。现实中,Bitcask实际上就是这么做的(Riak中默认的 存储引擎)【3】。 Bitcask提供高性能的读取和写入操作,但所有键必须能放入可用内存 中,因为哈希映射完全保留在内存中。这些值可以使用比可用内存更多的空间,因为可以从 磁盘上通过一次 seek 加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取 根本不需要任何磁盘I/O
像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存 在内存中是可行的。
直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入 一个新的段文件。然后,我们就可以对这些段进行压缩(compaction),如图3-2所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近段的哈希映射;如果键不存在,我们检查第二个最近的段,依此类推。合并过程保 持细分的数量,所以查找不需要检查许多哈希映射。 大量的细节进入实践这个简单的想法工作。简而言之,一些真正实施中重要的问题是:
- 文件格式,CSV不是日志的最佳格式。使用二进制格式更快,更简单,首先以字节为单位对字符串的长度进行编码,然后使用原始字符串(不需要转义)。
- 删除记录,如果要删除一个键及其关联的值,则必须在数据文件(有时称为逻辑删除)中附加一个特殊的删除记录。当日志段被合并时,逻辑删除告诉合并过程放弃删除键的任何以前的值。
- 崩溃恢复,如果数据库重新启动,则内存散列映射将丢失。原则上,您可以通过从头到尾读取整个段文件并在每次按键时注意每个键的最近值的偏移量来恢复每个段的哈希映射。但是,如果段文件很大,这可能需要很长时间,这将使服务器重新启动痛苦。 Bitcask通过存储加速恢复磁盘上每个段的哈希映射的快照,可以更快地加载到内存中。
- 部分写入记录,数据库可能随时崩溃,包括将记录附加到日志中途。 Bitcask文件包含校验和,允许检测和忽略日志的这些损坏部分。
- 并发控制,由于写操作是以严格顺序的顺序附加到日志中的,所以常见的实现选择是只有一个写入器线程。数据文件段是附加的,否则是不可变的,所以它们可以被多个线程同时读取。
乍一看,只有追加日志看起来很浪费:为什么不更新文件,用新值覆盖旧值?但是只能追加设计的原因有几个:
- 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。 在某种程度上,顺序写入在基于闪存的固态硬盘(SSD)上也是优选的【4】。我们将在 第83页的“比较B-树和LSM-树”中进一步讨论这个问题。
- 如果段文件是附加的或不可变的,并发和崩溃恢复就简单多了。例如,您不必担心在覆盖值时发生崩溃的情况,而将包含旧值和新值的一部分的文件保留在一起。
- 合并旧段可以避免数据文件随着时间的推移而分散的问题。
但是,哈希表索引也有局限性:
- 散列表必须能放进内存,如果你有非常多的键,那真是倒霉。原则上可以在磁盘上保留一个哈希映射,不幸的是 磁盘哈希映射很难表现优秀。它需要大量的随机访问I/O,当它变满时增长是很昂贵的, 并且散列冲突需要很多的逻辑【5】。
- 范围查询效率不高。例如,您无法轻松扫描kitty00000和kitty99999之间的所有键——您 必须在散列映射中单独查找每个键。
SSTables和LSM树
现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一 看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。
我们把这个格式称为排序字符串表(Sorted String Table),简称SSTable。我们还要求每个 键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相 比,SSTable有几个很大的优势:
- 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的 方法一样,如图3-4所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制 最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。
- 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以图3-5为例: 假设你正在内存中寻找键 handiwork ,但是你不知道段文件中该关键字的确切偏移量。 然而,你知道 handbag 和 handsome 的偏移,而且由于排序特性,你知道 handiwork 必须出现在这两者之间。这意味着您可以跳到 handbag 的偏移位置并从那里扫描,直到 您找到 handiwork (或没找到,如果该文件中没有该键)。
您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的 段文件就有一个键就足够了,因为几千字节可以很快被扫描 。 - 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分 组到块中,并在将其写入磁盘之前对其进行压缩(如图3-5中的阴影区域所示) 。稀疏内 存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少 IO带宽的使用。
构建和维护SSTables
到目前为止,但是如何让你的数据首先被按键排序呢?我们的传入写入可以以任何顺序发生。
在磁盘上维护有序结构是可能的(参阅“B树”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或AVL树【2】。使用这些数据结构,您可以按任 何顺序插入键,并按排序顺序读取它们。
现在我们可以使我们的存储引擎工作如下:
- 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为内存表(memtable)。
- 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新 部分。当SSTable被写入磁盘时,写入可以继续到一个新的内存表实例。
- 为了提供读取请求,首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在下一个较旧的段中找到该关键字。
- 有时会在后台运行合并和压缩过程以组合段文件并丢弃覆盖或删除的值。
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中, 但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每 个写入都会立即被附加到磁盘上,就像在前一节中一样。该日志不是按排序顺序,但这并不 重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时,相应的日志 都可以被丢弃。
用SSTables制作LSM树
这里描述的算法本质上是LevelDB 【6】和RocksDB 【7】中使用的关键值存储引擎库,被设 计嵌入到其他应用程序中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在 Cassandra和HBase中使用了类似的存储引擎【8】,这两种引擎都受到了Google的Bigtable 文档【9】(引入了SSTable和memtable)的启发。
最初这种索引结构是由Patrick O’Neil等人描述的。在日志结构合并树(或LSM树)【10】的 基础上,建立在以前的工作上日志结构的文件系统【11】。基于这种合并和压缩排序文件原 理的存储引擎通常被称为LSM存储引擎。
性能优化
与往常一样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的 键时,LSM树算法可能会很慢:您必须检查内存表,然后将这些段一直回到最老的(可能必 须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额 外的Bloom过滤器【15】。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以 告诉您数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。
还有不同的策略来确定SSTables如何被压缩和合并的顺序和时间。最常见的选择是大小分层 压实。 LevelDB和RocksDB使用平坦压缩(LevelDB因此得名),HBase使用大小分层, Cassandra同时支持【16】。在规模级别的调整中,更新和更小的SSTables先后被合并到更 老的和更大的SSTable中。在水平压实中,关键范围被拆分成更小的SSTables,而较旧的数 据被移动到单独的“水平”,这使得压缩能够更加递增地进行,并且使用更少的磁盘空间。
即使有许多微妙的东西,LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简 单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存 储,因此可以高效地执行范围查询(扫描所有高于某些最小值和最高值的所有键),并且因 为磁盘写入是连续的,所以LSM树可以支持非常高的写入吞吐量。
B树
刚才讨论的日志结构索引正处在逐渐被接受的阶段,但它们并不是最常见的索引类型。使用 最广泛的索引结构在1970年被引入【17】,不到10年后变得“无处不在”【18】,B树经受了时 间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也 使用它们。
像SSTables一样,B树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这就是 相似之处的结尾:B树有着非常不同的设计理念。
我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大 小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大 小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件, 因为磁盘也被安排在固定大小的块中。
让B树更可靠
为了使数据库对崩溃具有韧性,B树实现通常会带有一个额外的磁盘数据结构:预写式日志 (WAL, write-ahead-log)(也称为重做日志(redo log))。这是一个仅追加的文件,每 个B树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来使B树 恢复到一致的状态【5,20】。
更新页面的一个额外的复杂情况是,如果多个线程要同时访问B树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常通过使用锁存器(latches)(轻量级 锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所 有的合并,而不会干扰传入的查询,并且不时地将旧的分段原子交换为新的分段。
B树优化
由于B树已经存在了这么久,许多优化已经发展了多年,这并不奇怪。仅举几例:
- 一些数据库(如LMDB)使用写时复制方案【21】,而不是覆盖页面并维护WAL进行崩 溃恢复。修改的页面被写入到不同的位置,并且树中的父页面的新版本被创建,指向新 的位置。这种方法对于并发控制也很有用,我们将在“快照隔离和可重复读”中看到。
- 我们可以通过不存储整个键来节省页面空间,但可以缩小它的大小。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允 许树具有更高的分支因子,因此更少的层次
- 通常,页面可以放置在磁盘上的任何位置;没有什么要求附近的键范围页面附近的磁盘上。如果查询需要按照排序顺序扫描大部分关键字范围,那么每个页面的布局可能会非 常不方便,因为每个读取的页面都可能需要磁盘查找。因此,许多B树实现尝试布局树, 使得叶子页面按顺序出现在磁盘上。但是,随着树的增长,维持这个顺序是很困难的。 相比之下,由于LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易 使顺序键在磁盘上彼此靠近。
- 额外的指针已添加到树中。例如,每个叶子页面可以在左边和右边具有对其兄弟页面的 引用,这允许不跳回父页面就能顺序扫描。
- B树的变体如分形树【22】借用一些日志结构的思想来减少磁盘寻道(而且它们与分形无关)。
比较B树和LSM树
尽管B树实现通常比LSM树实现更成熟,但LSM树由于其性能特点也非常有趣。根据经验,通常LSM树的写入速度更快,而B树的读取速度更快【23】。LSM树上的读取通常比较慢,因为它们必须在压缩的不同阶段检查几个不同的数据结构和SSTables。
然而,基准通常对工作量的细节不确定和敏感。 您需要测试具有特定工作负载的系统,以便 进行有效的比较。 在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
LSM树的优点
B树索引必须至少两次写入每一段数据:一次写入预先写入日志,一次写入树页面本身(也许 再次分页)。即使在该页面中只有几个字节发生了变化,也需要一次编写整个页面的开销。 有些存储引擎甚至会覆盖同一个页面两次,以免在电源故障的情况下导致页面部分更新。
由于反复压缩和合并SSTables,日志结构索引也会重写数据。这种影响 —— 在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为写放大(write amplification)。需要特别关注的是固态硬盘,固态硬盘在磨损之前只能覆写一段时间。
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入磁盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入磁盘的次数越多,可用磁盘带宽内的每秒写入次数越少。
而且,LSM树通常能够比B树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大 (尽管这取决于存储引擎配置和工作负载),部分是因为它们顺序地写入紧凑的SSTable文件 而不是必须覆盖树中的几个页面【26】。这种差异在磁性硬盘驱动器上尤其重要,顺序写入 比随机写入快得多。
LSM树可以被压缩得更好,因此经常比B树在磁盘上产生更小的文件。 B树存储引擎会由于分 割而留下一些未使用的磁盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些 空间仍未被使用。由于LSM树不是面向页面的,并且定期重写SSTables以去除碎片,所以它 们具有较低的存储开销,特别是当使用平坦压缩时【27】。
在许多固态硬盘上,固件内部使用日志结构化算法,将随机写入转变为顺序写入底层存储芯 片,因此存储引擎写入模式的影响不太明显【19】。但是,较低的写入放大率和减少的碎片 对SSD仍然有利:更紧凑地表示数据可在可用的I/O带宽内提供更多的读取和写入请求。
LSM树的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试逐步执 行压缩而不影响并发访问,但是磁盘资源有限,所以很容易发生请求需要等待而磁盘完成昂 贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是在更高百分比的情况下(参 阅“描述性能”),对日志结构化存储引擎的查询响应时间有时会相当长,而B树的行为则相对 更具可预测性【28】。
压缩的另一个问题出现在高写入吞吐量:磁盘的有限写入带宽需要在初始写入(记录和刷新 内存表到磁盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全磁盘带宽 进行初始写入,但数据库越大,压缩所需的磁盘带宽就越多。
如果写入吞吐量很高,并且压缩没有仔细配置,压缩跟不上写入速率。在这种情况下,磁盘 上未合并段的数量不断增加,直到磁盘空间用完,读取速度也会减慢,因为它们需要检查更 多段文件。通常情况下,即使压缩无法跟上,基于SSTable的存储引擎也不会限制传入写入的 速率,所以您需要进行明确的监控来检测这种情况【29,30】。
B树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的 段中有相同键的多个副本。这个方面使得B树在想要提供强大的事务语义的数据库中很有吸引 力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在B树索引中,这些 锁可以直接连接到树【5】。在第7章中,我们将更详细地讨论这一点。
B树在数据库体系结构中是非常根深蒂固的,为许多工作负载提供始终如一的良好性能,所以 它们不可能很快就会消失。在新的数据存储中,日志结构化索引变得越来越流行。没有快速 和容易的规则来确定哪种类型的存储引擎对你的场景更好,所以值得进行一些经验上的测试
其他索引结构
到目前为止,我们只讨论了关键值索引,它们就像关系模型中的主键(primary key)索引。 主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数 据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。
有二级索引也很常见。在关系数据库中,您可以使用 CREATE INDEX 命令在同一个表上创建多 个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在第2章中的图2-1中,很可能在 user_id 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。
一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许 多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个 值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标 识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。
全文搜索和模糊索引
到目前为止所讨论的所有索引都假定您有确切的数据,并允许您查询键的确切值或具有排序 顺序的键的值范围。他们不允许你做的是搜索类似的键,如拼写错误的单词。这种模糊的查 询需要不同的技术。
例如,全文搜索引擎通常允许搜索一个单词以扩展为包括该单词的同义词,忽略单词的语法 变体,并且搜索在相同文档中彼此靠近的单词的出现,并且支持各种其他功能取决于文本的 语言分析。为了处理文档或查询中的拼写错误,Lucene能够在一定的编辑距离内搜索文本 (编辑距离1意味着添加,删除或替换了一个字母)【37】。
正如“在SSTables中创建LSM树”中所提到的,Lucene为其词典使用了一个类似于SSTable的 结构。这个结构需要一个小的内存索引,告诉查询在排序文件中哪个偏移量需要查找关键 字。在LevelDB中,这个内存中的索引是一些键的稀疏集合,但在Lucene中,内存中的索引 是键中字符的有限状态自动机,类似于trie 【38】。这个自动机可以转换成Levenshtein自动 机,它支持在给定的编辑距离内有效地搜索单词【39】。
其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。有关更多详细信息,请参阅信 息检索教科书,例如【40】。
在内存中存储一切
本章到目前为止讨论的数据结构都是对磁盘限制的回答。与主内存相比,磁盘处理起来很尴 尬。对于磁盘和SSD,如果要在读取和写入时获得良好性能,则需要仔细地布置磁盘上的数 据。但是,我们容忍这种尴尬,因为磁盘有两个显着的优点:它们是耐用的(它们的内容在 电源关闭时不会丢失),并且每GB的成本比RAM低。
随着RAM变得更便宜,每GB的成本价格被侵蚀了。许多数据集不是那么大,所以将它们全部 保存在内存中是非常可行的,可能分布在多个机器上。这导致了内存数据库的发展。
某些内存中的键值存储(如Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可 以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的 RAM),将更改日志写入磁盘,将定时快照写入磁盘或通过复制内存来实现,记忆状态到其 他机器。
内存数据库重新启动时,需要从磁盘或通过网络从副本重新加载其状态(除非使用特殊的硬 件)。尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅用作耐久性附加日志,读取完 全由内存提供。写入磁盘也具有操作优势:磁盘上的文件可以很容易地由外部实用程序进行 备份,检查和分析。
诸如VoltDB,MemSQL和Oracle TimesTen等产品是具有关系模型的内存数据库,供应商声 称,通过消除与管理磁盘上的数据结构相关的所有开销,他们可以提供巨大的性能改进 【41,42】。 RAM Cloud是一个开源的内存键值存储器,具有持久性(对存储器中的数据以及 磁盘上的数据使用日志结构化方法)【43】。 Redis和Couchbase通过异步写入磁盘提供了 较弱的持久性。
事务处理还是分析?
在业务数据处理的早期,对数据库的写入通常对应于正在进行的商业交易:进行销售,向供 应商下订单,支付员工工资等等。随着数据库扩展到那些没有不涉及钱易手,术语交易仍然 卡住,指的是形成一个逻辑单元的一组读写。 事务不一定具有ACID(原子性,一致性,隔离 性和持久性)属性。事务处理只是意味着允许客户端进行低延迟读取和写入 —— 而不是批量 处理作业,而这些作业只能定期运行(例如每天一次)。我们在第7章中讨论ACID属性,在 第10章中讨论批处理。
即使数据库开始被用于许多不同类型的博客文章,游戏中的动作,地址簿中的联系人等等, 基本访问模式仍然类似于处理业务事务。应用程序通常使用索引通过某个键查找少量记录。 根据用户的输入插入或更新记录。由于这些应用程序是交互式的,因此访问模式被称为在线 事务处理(OLTP, OnLine Transaction Processing)。
但是,数据库也开始越来越多地用于数据分析,这些数据分析具有非常不同的访问模式。通 常,分析查询需要扫描大量记录,每个记录只读取几列,并计算汇总统计信息(如计数,总 和或平均值),而不是将原始数据返回给用户。
数据仓库
一个企业可能有几十个不同的交易处理系统:系统为面向客户的网站提供动力,控制实体商 店的销售点(checkout)系统,跟踪仓库中的库存,规划车辆路线,管理供应商,管理员工 等。这些系统中的每一个都是复杂的,需要一个人员去维护,所以系统最终都是自动运行的。
这些OLTP系统通常具有高度的可用性,并以低延迟处理事务,因为这些系统往往对业务运作 至关重要。因此数据库管理员密切关注他们的OLTP数据库他们通常不愿意让业务分析人员在 OLTP数据库上运行临时分析查询,因为这些查询通常很昂贵,扫描大部分数据集,这会损害 同时执行的事务的性能。
OLTP数据库和数据仓库之间的分歧
数据仓库的数据模型通常是关系型的,因为SQL通常很适合分析查询。有许多图形数据分析 工具可以生成SQL查询,可视化结果,并允许分析人员探索数据(通过下钻,切片和切块等 操作)。
表面上,一个数据仓库和一个关系OLTP数据库看起来很相似,因为它们都有一个SQL查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都将重点放在支持事务处理或分析工作负载上,而不是两者都支持。
列存储
面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列 的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中 使用的那些列,这可以节省大量的工作。
列压缩
除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量 的需求。幸运的是,面向列的存储通常很适合压缩。
看看图3-10中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列 中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如图3- 11所示。
内存带宽和向量处理
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从磁盘获取数据到内存的带 宽。但是,这不是唯一的瓶颈。分析数据库的开发人员也担心有效利用主存储器带宽到CPU 缓存中的带宽,避免CPU指令处理流水线中的分支错误预测和泡沫,以及在现代中使用单指 令多数据(SIMD)指令CPU 【59,60】。
除了减少需要从磁盘加载的数据量以外,面向列的存储布局也可以有效利用CPU周期。例 如,查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环中循环(即 没有函数调用)。一个CPU可以执行这样一个循环比代码要快得多,这个代码需要处理每个 记录的大量函数调用和条件。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的 按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量 化处理【58,49】。
列存储中的排序顺序
在列存储中,存储行的顺序并不一定很重要。按插入顺序存储它们是最简单的,因为插入一 个新行就意味着附加到每个列文件。但是,我们可以选择强制执行一个命令,就像我们之前 对SSTables所做的那样,并将其用作索引机制。
注意,每列独自排序是没有意义的,因为那样我们就不会知道列中的哪些项属于同一行。我 们只能重建一行,因为我们知道一列中的第k项与另一列中的第k项属于同一行。
相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常 见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上 个月,则可以将 date_key 作为第一个排序键。然后,查询优化器只能扫描上个月的行,这 比扫描所有行要快得多。
第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 date_key 是图3-10 中的第一个排序关键字,那么 product_sk 可能是第二个排序关键字,因此同一天的同一产 品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进 行分组或过滤的查询。
排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排 序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就 像我们用于图3-11中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重 复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列 排序仍然是一个整体。
写入列存储
这些优化在数据仓库中是有意义的,因为大多数负载由分析人员运行的大型只读查询组成。 面向列的存储,压缩和排序都有助于更快地读取这些查询。然而,他们有写更加困难的缺点。
使用B树的更新就地方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须始终更新所有列。
幸运的是,本章前面已经看到了一个很好的解决方案:LSM树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入磁盘。内存中的存储是面向行还是列的,这并不重要。当已经积累了足够的写入数据时,它们将与磁盘上的列文 件合并,并批量写入新文件。这基本上是Vertica所做的【62】
查询需要检查磁盘上的列数据和最近在内存中的写入,并将两者结合起来。但是,查询优化器隐藏了用户的这个区别。从分析师的角度来看,通过插入,更新或删除操作进行修改的数 据会立即反映在后续查询中。
编码与演化
编码数据的格式
程序通常(至少)使用两种形式的数据:
- 在内存中,数据保存在对象,结构体,列表,数组,哈希表,树等中。 这些数据结构针 对CPU的高效访问和操作进行了优化(通常使用指针)。
- 如果要将数据写入文件,或通过网络发送,则必须将其编码(encode)为某种自包含的 字节序列(例如,JSON文档)。 由于每个进程都有自己独立的地址空间,一个进程中 的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数 据结构完全不同i。
所以,需要在两种表示之间进行某种类型的翻译。 从内存中表示到字节序列的转换称为编码 (Encoding)(也称为序列化(serialization)或编组(marshalling)),反过来称为解码 (Decoding) (解析(Parsing),反序列化(deserialization),反编组() unmarshalling)) 。
语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持。例如,Java 有 java.io.Serializable 【1】,Ruby有 Marshal 【2】,Python有 pickle 【3】等等。许多 第三方库也存在,例如 Kryo for Java 【4】。
这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一 些深层次的问题:
- 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编 码存储或传输数据,那你就和这门语言绑死在一起了。并且很难将系统与其他组织的系 统(可能用的是不同的语言)进行集成。
- 为了恢复相同对象类型的数据,解码过程需要实例化任意类的能力,这通常是安全问题 的一个来源【5】:如果攻击者可以让应用程序解码任意的字节序列,他们就能实例化任 意的类,这会允许他们做可怕的事情,如远程执行任意代码【6,7】。
- 在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行 编码,所以往往忽略了前向后向兼容性带来的麻烦问题。
- 效率(编码或解码所花费的CPU时间,以及编码结构的大小)往往也是事后才考虑的。 例如,Java的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭着【8】。
JSON,XML和二进制变体
JSON,XML和CSV是文本格式,因此具有人类可读性(尽管语法是一个热门辩题)。除了表 面的语法问题之外,它们也有一些微妙的问题:
- 数字的编码多有歧义之处。XML和CSV不能区分数字和字符串(除非引用外部模式)。 JSON虽然区分字符串和数字,但不区分整数和浮点数,而且不能指定精度。
- 当处理大量数据时,这个问题更严重了。例如,大于$2^{53}$的整数不能在IEEE 754双 精度浮点数中精确表示,因此在使用浮点数(例如JavaScript)的语言进行分析时,这些 数字会变得不准确。 Twitter上有一个大于$2^{53}$的数字的例子,它使用一个64位的数 字来标识每条推文。 Twitter API返回的JSON包含了两种推特ID,一个JSON数字,另一 个是十进制字符串,以此避免JavaScript程序无法正确解析数字的问题【10】。
- JSON和XML对Unicode字符串(即人类可读的文本)有很好的支持,但是它们不支持二 进制数据(不带字符编码(character encoding)的字节序列)。二进制串是很实用的功 能,所以人们通过使用Base64将二进制数据编码为文本来绕开这个限制。模式然后用于 表示该值应该被解释为Base64编码。这个工作,但它有点hacky,并增加了33%的数据 大小。 XML 【11】和JSON 【12】都有可选的模式支持。这些模式语言相当强大,所以 学习和实现起来相当复杂。 XML模式的使用相当普遍,但许多基于JSON的工具嫌麻烦 才不会使用模式。由于数据的正确解释(例如数字和二进制字符串)取决于模式中的信 息,因此不使用XML/JSON模式的应用程序可能需要对相应的编码/解码逻辑进行硬编码。
- CSV没有任何模式,因此应用程序需要定义每行和每列的含义。如果应用程序更改添加 新的行或列,则必须手动处理该变更。 CSV也是一个相当模糊的格式(如果一个值包含 逗号或换行符,会发生什么?)。尽管其转义规则已经被正式指定【13】,但并不是所 有的解析器都正确的实现了标准。
尽管存在这些缺陷,但JSON,XML和CSV已经足够用于很多目的。特别是作为数据交换格式 (即将数据从一个组织发送到另一个组织),它们很可能仍然很受欢迎。这种情况下,只要 人们对格式是什么意见一致,格式多么美观或者高效就没有关系。让不同的组织达成一致的 难度超过了其他大多数问题。
二进制编码
对于仅在组织内部使用的数据,使用最小公分母编码格式的压力较小。例如,可以选择更紧 凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计,但一旦达到TB级别,数据 格式的选择就会产生巨大的影响。
MessagePack
Thrift
Protobuf
字段标签和模式演变
我们之前说过,模式不可避免地需要随着时间而改变。我们称之为模式演变。 Thrift和 Protocol Buffers如何处理模式更改,同时保持向后兼容性?
从示例中可以看出,编码的记录就是其编码字段的拼接。每个字段由其标签号码(样本模式 中的数字1,2,3)标识,并用数据类型(例如字符串或整数)注释。如果没有设置字段值,则 简单地从编码记录中省略。从中可以看到,字段标记对编码数据的含义至关重要。您可以更 改架构中字段的名称,因为编码的数据永远不会引用字段名称,但不能更改字段的标记,因 为这会使所有现有的编码数据无效。
您可以添加新的字段到架构,只要您给每个字段一个新的标签号码。如果旧的代码(不知道 你添加的新的标签号码)试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了前向兼容性:旧代码可以读取由新代码编写的记录。
向后兼容性呢?只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据, 因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的领域,你不能要 求。如果您要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入您添加的新字段。因此,为了保持向后兼容性,在模式 的初始部署之后添加的每个字段必须是可选的或具有默认值。
删除一个字段就像添加一个字段,倒退和向前兼容性问题相反。这意味着您只能删除一个可 选的字段(必填字段永远不能删除),而且您不能再次使用相同的标签号码(因为您可能仍 然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。
数据类型和模式演变
如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将 失去精度或被扼杀。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松 读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由 新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位, 则它将被截断。
Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这 是第三个选项旁边必要和可选)。如图4-4所示,重复字段的编码正如它所说的那样:同一个 字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重 复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字 段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许 Protocol Buffers所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。
Avro
Apache Avro 【20】是另一种二进制编码格式,与Protocol Buffers和Thrift有趣的不同。 它是 作为Hadoop的一个子项目在2009年开始的,因为Thrift不适合Hadoop的用例【21】。
Avro也使用模式来指定正在编码的数据的结构。 它有两种模式语言:一种(Avro IDL)用于 人工编辑,一种(基于JSON),更易于机器读取。
record Person {
string userName;
union { null, long }
favoriteNumber = null;
array<string> interests;
}
动态生成的模式
与Protocol Buffers和Thrift相比,Avro方法的一个优点是架构不包含任何标签号码。但为什么 这很重要?在模式中保留一些数字有什么问题?
不同之处在于Avro对动态生成的模式更友善。例如,假如你有一个关系数据库,你想要把它 的内容转储到一个文件中,并且你想使用二进制格式来避免前面提到的文本格式(JSON, CSV,SQL)的问题。如果你使用Avro,你可以很容易地从关系模式生成一个Avro模式(在 我们之前看到的JSON表示中),并使用该模式对数据库内容进行编码,并将其全部转储到 Avro对象容器文件【25】中。您为每个数据库表生成一个记录模式,每个列成为该记录中的 一个字段。数据库中的列名称映射到Avro中的字段名称。
现在,如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更 新的数据库模式生成新的Avro模式,并在新的Avro模式中导出数据。数据导出过程不需要注 意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记 录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的作者的模式仍然可以与 旧的读者模式匹配。
相比之下,如果您为此使用Thrift或Protocol Buffers,则字段标记可能必须手动分配:每次数 据库模式更改时,管理员都必须手动更新从数据库列名到字段标签。 (这可能会自动化,但 模式生成器必须非常小心,不要分配以前使用的字段标记。)这种动态生成的模式根本不是 Thrift或Protocol Buffers的设计目标,而是为Avro。
代码生成和动态类型的语言
Thrift和Protobuf依赖于代码生成:在定义了模式之后,可以使用您选择的编程语言生成实现 此模式的代码。这在Java,C ++或C#等静态类型语言中很有用,因为它允许将高效的内存 中结构用于解码的数据,并且在编写访问数据结构的程序时允许在IDE中进行类型检查和自动 完成。
在动态类型编程语言(如JavaScript,Ruby或Python)中,生成代码没有太多意义,因为没 有编译时类型检查器来满足。代码生成在这些语言中经常被忽视,因为它们避免了明确的编 译步骤。而且,对于动态生成的模式(例如从数据库表生成的Avro模式),代码生成对获取 数据是一个不必要的障碍。
Avro为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情 况下使用。如果你有一个对象容器文件(它嵌入了作者的模式),你可以简单地使用Avro库 打开它,并以与查看JSON文件相同的方式查看数据。该文件是自描述的,因为它包含所有必 要的元数据。
这个属性特别适用于动态类型的数据处理语言如Apache Pig 【26】。在Pig中,您可以打开 一些Avro文件,开始分析它们,并编写派生数据集以Avro格式输出文件,而无需考虑模式。
模式的优点
正如我们所看到的,Protocol Buffers,Thrift和Avro都使用模式来描述二进制编码格式。他们 的模式语言比XML模式或者JSON模式简单得多,它支持更详细的验证规则(例如,“该字段 的字符串值必须与该正则表达式匹配”或“该字段的整数值必须在0和100之间“)。由于Protocol Buffers,Thrift和Avro实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广 泛的编程语言。
这些编码所基于的想法绝不是新的。例如,它们与ASN.1有很多相似之处,它是1984年首次 被标准化的模式定义语言【27】。它被用来定义各种网络协议,其二进制编码(DER)仍然 被用于编码SSL证书(X.509),例如【28】。 ASN.1支持使用标签号码的模式演进,类似于 Protocol Buf-fers和Thrift 【29】。然而,这也是非常复杂和严重的文件记录,所以ASN.1可 能不是新应用程序的好选择。
许多数据系统也为其数据实现某种专有的二进制编码。例如,大多数关系数据库都有一个网 络协议,您可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据 库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序 (例如使用ODBC或JDBC API)。
所以,我们可以看到,尽管JSON,XML和CSV等文本数据格式非常普遍,但基于模式的二进 制编码也是一个可行的选择。他们有一些很好的属性:
- 它们可以比各种“二进制JSON”变体更紧凑,因为它们可以省略编码数据中的字段名称。
- 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的 (而手动维护的文档可能很容易偏离现实)。
- 保留模式数据库允许您在部署任何内容之前检查模式更改的向前和向后兼容性。
- 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编 译时进行类型检查。
数据流的类型
在本章的开始部分,我们曾经说过,无论何时您想要将某些数据发送到不共享内存的另一个 进程,例如,只要您想通过网络发送数据或将其写入文件,就需要将它编码为一个字节序 列。然后我们讨论了做这个的各种不同的编码。 我们讨论了向前和向后的兼容性,这对于可演化性来说非常重要(通过允许您独立升级系统的不同部分,而不必一次改变所有内容,可 以轻松地进行更改)。兼容性是编码数据的一个进程和解码它的另一个进程之间的一种关系。
这是一个相当抽象的概念 - 数据可以通过多种方式从一个流程流向另一个流程。谁编码数据, 谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:
- 通过数据库(参阅“通过数据库的数据流”)
- 通过服务调用(参阅“通过服务传输数据流:REST和RPC”)
- 通过异步消息传递(参阅“消息传递数据流”)MQ
分布式数据
复制
扩展至更高的载荷
如果你需要的只是扩展至更高的载荷(load),最简单的方法就是购买更强大的机器(有时 称为垂直扩展(vertical scaling)或向上扩展(scale up))。许多处理器,内存和磁盘可 以在同一个操作系统下相互连接,快速的相互连接允许任意处理器访问内存或磁盘的任意部 分。在这种共享内存架构(shared-memory architecture)中,所有的组件都可以看作一台单独的机器。
共享内存方法的问题在于,成本增长速度快于线性增长:一台有着双倍处理器数量,双倍内存大小,双倍磁盘容量的机器,通常成本会远远超过原来的两倍。而且可能因为存在瓶颈, 并不足以处理双倍的载荷。
共享内存架构可以提供有限的容错能力,高端机器可以使用热插拔的组件(不关机更换磁 盘,内存模块,甚至处理器)——但它必然囿于单个地理位置的桎梏。
另一种方法是共享磁盘架构(shared-disk architecture),它使用多台具有独立处理器和内 存的机器,但将数据存储在机器之间共享的磁盘阵列上,这些磁盘通过快速网络连接ii。这种 架构用于某些数据仓库,但竞争和锁定的开销限制了共享磁盘方法的可扩展性【2】。
无共享架构
相比之下,无共享架构(shared-nothing architecture)(有时称为水平扩展(horizontal scale) 或向外扩展(scale out))已经相当普及。在这种架构中,运行数据库软件的每台 机器/虚拟机都称为节点(node)。每个节点只使用各自的处理器,内存和磁盘。节点之间的 任何协调,都是在软件层面使用传统网络实现的。
无共享系统不需要使用特殊的硬件,所以你可以用任意机器——比如性价比最好的机器。你 也许可以跨多个地理区域分布数据从而减少用户延迟,或者在损失一整个数据中心的情况下 幸免于难。随着云端虚拟机部署的出现,即使是小公司,现在无需Google级别的运维,也可 以实现异地分布式架构。
在这一部分里,我们将重点放在无共享架构上。它不见得是所有场景的最佳选择,但它是最 需要你谨慎从事的架构。如果你的数据分布在多个节点上,你需要意识到这样一个分布式系 统中约束和权衡 ——数据库并不能魔术般地把这些东西隐藏起来。
复制 vs 分区
数据分布在多个节点上有两种常见的方式:
复制(Replication)
在几个不同的节点上保存数据的相同副本,可能放在不同的位置。 复制提供了冗余:如果一 些节点不可用,剩余的节点仍然可以提供数据服务。 复制也有助于改善性能。 第五章将讨论 复制。
分区 (Partitioning)
将一个大型数据库拆分成较小的子集(称为分区(partitions)),从而不同的分区可以指派 给不同的节点(node)(亦称分片(shard))。 第六章将讨论分区。
理解了这些概念,就可以开始讨论在分布式系统中需要做出的困难抉择。第七章将讨论事务 (Transaction),这对于了解数据系统中可能出现的各种问题,以及我们可以做些什么很有帮助。第八章和第九章将讨论分布式系统的根本局限性。
领导者与追随者
存储数据库副本的每个节点称为副本(replica)。当存在多个副本时,会不可避免的出现一 个问题:如何确保所有数据都落在了所有的副本上?
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最 常见的解决方案被称为基于领导者的复制(leader-based replication)(也称主动/被动 (active/passive) 或 主/从(master/slave)复制),如图5-1所示。它的工作原理如下:
- 副本之一被指定为领导者(leader),也称为 主库(master) ,首要(primary)。当 客户端要向数据库写入时,它必须将请求发送给领导者,领导者会将新数据写入其本地 存储。
- 其他副本被称为追随者(followers),亦称为只读副本(read replicas),从库 (slaves),次要( sencondaries),热备(hot-standby)i。每当领导者将新数据写 入本地存储时,它也会将数据变更发送给所有的追随者,称之为复制日志(replication log)记录或变更流(change stream)。每个跟随者从领导者拉取日志,并相应更新其 本地数据库副本,方法是按照领导者处理的相同顺序应用所有写入。
- 当客户想要从数据库中读取数据时,它可以向领导者或追随者查询。 但只有领导者才能 接受写操作(从客户端的角度来看从库都是只读的)。
不同的人对热(hot),温(warn),冷(cold) 备份服务器有不同的定义。 例如在 PostgreSQL中,热备(hot standby)指的是能接受客户端读请求的副本。而温备 (warm standby)只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些 差异并不重要。
同步复制与异步复制
复制系统的一个重要细节是:复制是同步(synchronously)发生还是异步 (asynchronously)发生。 (在关系型数据库中这通常是一个配置项,其他系统通常硬编 码为其中一个)。
同步复制的优点是,从库保证有与主库一致的最新数据副本。如果主库突然失效,我们可以 确信这些数据仍然能在从库上上找到。缺点是,如果同步从库没有响应(比如它已经崩溃, 或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写 入,并等待同步副本再次可用。
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停 滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中一个跟随者是同步的,而 其他的则是异步的。如果同步从库变得不可用或缓慢,则使一个异步从库同步。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。 这种配置有时也被称为半同步 (semi-synchronous)
通常情况下,基于领导者的复制都配置为完全异步。 在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。 这意味着即使已经向客户端确认成功,写入也不能保证持久(Durable)。 然而,一个完全异步的配置也有优点:即使所有的从库都落后 了,主库也可以继续处理写入。
设置新从库
有时候需要设置一个新的从库:也许是为了增加副本的数量,或替换失败的节点。如何确保 新的从库拥有主库数据的精确副本?
简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入数 据,数据总是在不断变化,标准的数据副本会在不同的时间点总是不一样。复制的结果可能 没有任何意义。
可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可 用的目标。幸运的是,拉起新的从库通常并不需要停机。从概念上讲,过程如下所示:
- 在某个时刻获取主库的一致性快照(如果可能),而不必锁定整个数据库。大多数数据 库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如 MySQL的innobackupex。
- 将快照复制到新的从库节点。
- 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中 的位置精确关联。该位置有不同的名称:例如,PostgreSQL将其称为日志序列号(log sequence number, LSN),MySQL将其称为二进制日志坐标(binlog coordinates)。
- 当从库处理完快照之后积压的数据变更,我们说它赶上(caught up)了主库。现在它可 以继续处理主库产生的数据变化了。
建立从库的实际步骤因数据库而异。在某些系统中,这个过程是完全自动化的,而在另外一 些系统中,它可能是一个需要由管理员手动执行的,有点神秘的多步骤工作流。
处理节点宕机
系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护(例如,重 启机器以安装内核安全补丁)。对运维而言,能在系统不中断服务的情况下重启单个节点好 处多多。我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停 机带来的影响。
从库失效:追赶恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者, 如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故 障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开连接时发生的 所有数据变更。当应用完所有这些变化后,它就赶上了主库,并可以像以前一样继续接收数 据变更流。
主库失效:故障转移
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端, 以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过 程被称为故障转移(failover)。
故障转移可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动 进行。自动故障转移过程通常由以下步骤组成:
- 确认主库失效。有很多事情可能会出错:崩溃,停电,网络问题等等。没有万无一失的 方法来检测出现了什么问题,所以大多数系统只是简单使用超时(Timeout):节点频繁 地相互来回传递消息,并且如果一个节点在一段时间内(例如30秒)没有响应,就认为 它挂了(因为计划内维护而故意关闭主库不算)。
- 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成, 或者可以由之前选定的控制器节点(controller node)来指定新的主库。主库的最佳人 选通常是拥有旧主库最新数据副本的从库(最小化数据损失)。让所有的节点同意一个 新的领导者,是一个共识问题,将在第9章详细讨论。
- 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在“请 求路由”中讨论这个问题)。如果老领导回来,可能仍然认为自己是主库,没有意识到其 他副本已经让它下台了。系统需要确保老领导认可新领导,成为一个从库。
故障转移会出现很多大麻烦:
- 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主 库后,如果老主库重新加入集群,新主库在此期间可能会收到冲突的写入,那这些写入 该如何处理?最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对 于数据持久性的期望。
- 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在 GitHub 【13】的一场事故中,一个过时的MySQL从库被提升为主库。数据库使用自增ID 作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已 经被老主库分配掉的ID作为主键。这些主键也在Redis中使用,主键重用使得MySQL和 Redis中数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中。
- 发生某些故障时(见第8章)可能会出现两个节点都以为自己是主库的情况。这种情况称 为脑裂(split brain),非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制 (参见“多领导者复制”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施: 当检测到两个主库节点同时存在时会关闭其中一个节点ii,但设计粗糙的机制可能最后会 导致两个节点都被关闭【14】。
- 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长, 意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障转移。例 如,临时负载峰值可能导致节点的响应时间超时,或网络故障可能导致数据包延迟。如 果系统已经处于高负载或网络问题的困扰之中,那么不必要的故障切换可能会让情况变 得更糟糕。
复制日志的实现
基于语句的复制
在最简单的情况下,主库记录下它执行的每个写入请求(语句(statement))并将该语句 日志发送给其从库。对于关系数据库来说,这意味着每个 INSERT , UPDATE 或 DELETE 语句都 被转发给每个从库,每个从库解析并执行该SQL语句,就像从客户端收到一样。
- 任何调用非确定性函数(nondeterministic)的语句,可能会在每个副本上生成不同的 值。例如,使用 NOW() 获取当前日期时间,或使用 RAND() 获取一个随机数。
- 如果语句使用了自增列(auto increment),或者依赖于数据库中的现有数据(例 如, UPDATE … WHERE <某些条件> ),则必须在每个副本上按照完全相同的顺序执行它 们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
- 有副作用的语句(例如,触发器,存储过程,用户定义的函数)可能会在每个副本上产 生不同的副作用,除非副作用是绝对确定的。
的确有办法绕开这些问题 ——例如,当语句被记录时,主库可以用固定的返回值替换任何不 确定的函数调用,以便从库获得相同的值。但是由于边缘情况实在太多了,现在通常会选择 其他的复制方法。
基于语句的复制在5.1版本前的MySQL中使用。因为它相当紧凑,现在有时候也还在用。但 现在在默认情况下,如果语句中存在任何不确定性,MySQL会切换到基于行的复制(稍后讨 论)。 VoltDB使用了基于语句的复制,但要求事务必须是确定性的,以此来保证安全
传输预写式日志(WAL)
PostgreSQL和Oracle等使用这种复制方法【16】。主要缺点是日志记录的数据非常底层: WAL包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库 将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数 据库软件。
看上去这可能只是一个微小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允 许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障转移,使升级后的节 点之一成为新的主库,从而执行数据库软件的零停机升级。如果复制协议不允许版本不匹配 (传输WAL经常出现这种情况),则此类升级需要停机。
逻辑日志复制(基于行)
另一种方法是,复制和存储引擎使用不同的日志格式,这样可以使复制日志从存储引擎内部 分离出来。这种复制日志被称为逻辑日志,以将其与存储引擎的(物理)数据表示区分开来。
关系数据库的逻辑日志通常是以行的粒度描述对数据库表的写入的记录序列:
- 对于插入的行,日志包含所有列的新值。
- 对于删除的行,日志包含足够的信息来唯一标识已删除的行。通常是主键,但是如果表 上没有主键,则需要记录所有列的旧值。
- 对于更新的行,日志包含足够的信息来唯一标识更新的行,以及所有列的新值(或至少 所有已更改的列的新值)。
修改多行的事务会生成多个这样的日志记录,后面跟着一条记录,指出事务已经提交。 MySQL的二进制日志(当配置为使用基于行的复制时)使用这种方法【17】
由于逻辑日志与存储引擎内部分离,因此可以更容易地保持向后兼容,从而使领导者和跟随 者能够运行不同版本的数据库软件甚至不同的存储引擎。
基于触发器的复制
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况 下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果您只想复制数据的一 个子集,或者想从一种数据库复制到另一种数据库,或者如果您需要冲突解决逻辑(参阅“处 理写入冲突”),则可能需要将复制移动到应用程序层。
一些工具,如Oracle Golden Gate 【19】,可以通过读取数据库日志,使得其他应用程序可 以使用数据。另一种方法是使用许多关系数据库自带的功能:触发器和存储过程。
触发器允许您注册在数据库系统中发生数据更改(写入事务)时自动执行的自定义应用程序 代码。触发器有机会将更改记录到一个单独的表中,使用外部程序读取这个表,再加上任何 业务逻辑处理,会后将数据变更复制到另一个系统去。例如,Databus for Oracle 【20】和 Bucardo for Postgres 【21】就是这样工作的。
复制延迟问题
容忍节点故障只是需要复制的一个原因。正如在第二部分的介绍中提到的,另一个原因是可 扩展性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
基于主库的复制要求所有写入都由单个节点处理,但只读查询可以由任何副本处理。所以对 于读多写少的场景(Web上的常见模式),一个有吸引力的选择是创建很多从库,并将读请 求分散到所有的从库上去。这样能减小主库的负载,并允许向最近的副本发送读请求。
在这种扩展体系结构中,只需添加更多的追随者,就可以提高只读请求的服务容量。但是, 这种方法实际上只适用于异步复制——如果尝试同步复制到所有追随者,则单个节点故障或 网络中断将使整个系统无法写入。而且越多的节点越有可能会被关闭,所以完全同步的配置 是非常不可靠的。
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会 导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结 果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态——如果停止写 入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被 称为最终一致性(eventually consistency)iii【22,23】
“最终”一词故意含糊不清:总的来说,副本落后的程度是没有限制的。在正常的操作中,复 制延迟(replication lag),即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒, 在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题,延迟可以轻 而易举地超过几秒,甚至几分钟。
复制延迟的解决方案
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的 行为。如果答案是“没问题”,那很好。但如果结果对于用户来说是不好体验,那么设计系统来 提供更强的保证是很重要的,例如写后读。明明是异步复制却假设复制是同步的,这是很多 麻烦的根源。
如前所述,应用程序可以提供比底层数据库更强有力的保证,例如通过主库进行某种读取。 但在应用程序代码中处理这些问题是复杂的,容易出错。
如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库“做了正确的事 情”,那该多好呀。这就是事务(transaction)存在的原因:数据库通过事务提供强大的保 证,所以应用程序可以更假简单。
单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放 弃了事务。声称事务在性能和可用性上的代价太高,并断言在可扩展系统中最终一致性是不 可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。第 七章和第九章将回到事务的话题,并讨论一些替代机制。
单主复制
客户端将所有写入操作发送到单个节点(领导者),该节点将数据更改事件流发送到其他副 本(追随者)。读取可以在任何副本上执行,但从追随者读取可能是陈旧的。
多主复制
客户端发送每个写入到几个领导节点之一,其中任何一个都可以接受写入。领导者将数据更 改事件流发送给彼此以及任何跟随者节点。
本章到目前为止,我们只考虑使用单个领导者的复制架构。 虽然这是一种常见的方法,但也 有一些有趣的选择。
基于领导者的复制有一个主要的缺点:只有一个主库,而所有的写入都必须通过它。如果出 于任何原因(例如和主库之间的网络连接中断)无法连接到主库, 就无法向数据库写入。
基于领导者的复制模型的自然延伸是允许多个节点接受写入。 复制仍然以同样的方式发生: 处理写入的每个节点都必须将该数据更改转发给所有其他节点。 称之为多领导者配置(也称 多主、多活复制)。 在这种情况下,每个领导者同时扮演其他领导者的追随者。
无主复制
我们在本章到目前为止所讨论的复制方法 ——单主复制、多主复制——都是这样的想法:客 户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺 序,而从库按相同顺序应用主库的写入。
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端 的写入。最早的一些的复制数据系统是无领导的(leaderless)【1,44】,但是在关系数据库 主导的时代,这个想法几乎已被忘却。在亚马逊将其用于其内部的Dynamo系统vi之后,它再 一次成为数据库的一种时尚架构【37】。(Dynamo不适用于Amazon以外的用户。 令人困惑 的是,AWS提供了一个名为DynamoDB的托管数据库产品,它使用了完全不同的体系结构: 它基于单主程序复制。) Riak,Cassandra和Voldemort是由Dynamo启发的无领导复制模型 的开源数据存储,所以这类数据库也被称为Dynamo风格。
读写的法定人数
更一般地说,如果有n个副本,每个写入必须由w节点确认才能被认为是成功的,并且我们必 须至少为每个读取查询r个节点。 (在我们的例子中,$n = 3,w = 2,r = 2$)。只要$w + r> n$,我们期望在读取时获得最新的值,因为r个读取中至少有一个节点是最新的。遵循这些r值,w值的读写称为法定人数(quorum)vii的读和写。【44】 ,你可以认为,r和w是有效读 写所需的最低票数。
在Dynamo风格的数据库中,参数n,w和r通常是可配置的。一个常见的选择是使n为奇数 (通常为3或5)并设置 $w = r =(n + 1)/ 2$(向上取整)。但是可以根据需要更改数字。例 如,设置$w = n$和$r = 1$的写入很少且读取次数较多的工作负载可能会受益。这使得读取速 度更快,但具有只有一个失败节点导致所有数据库写入失败的缺点。
因此,尽管法定人数似乎保证读取返回最新的写入值,但在实践中并不那么简单。 Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。允许通过参数w和r来调整读取 陈旧值的概率,但把它们当成绝对的保证是不明智的。
尤其是,通常没有得到“与延迟有关的问题”(读取您的写入,单调读取或一致的前缀读取) 中讨论的保证,因此前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要事 务或共识。我们将在第七章和第九章回到这些话题。
最后写入为准(丢弃并发写入)
实现最终融合的一种方法是声明每个副本只需要存储最“最近”的值,并允许“更旧”的值被覆 盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都 被复制到每个副本,那么复制最终会收敛到相同的值。
正如“最近”的引号所表明的,这个想法其实颇具误导性。在图5-12的例子中,当客户端向数 据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事 实上,说“发生”是没有意义的:我们说写入是并发(concurrent)的,所以它们的顺序是不确 定的。
即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间 戳,挑选最“最近”的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被 称为最后写入为准(LWW, last write wins),是Cassandra 【53】唯一支持的冲突解决方 法,也是Riak 【35】中的一个可选特征。
LWW实现了最终收敛的目标,但以持久性为代价:如果同一个Key有多个并发写入,即使它 们都被报告为客户端成功(因为它们被写入 w 个副本),其中一个写道会生存下来,其他的 将被无声丢弃。此外,LWW甚至可能会删除不是并发的写入,我们将在的“有序事件的时间 戳”中讨论。
版本向量
图5-13使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是 不够的。相反,除了对每个键使用版本号之外,还需要在每个副本中版本号。每个副本在处 理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。
所有副本的版本号集合称为版本向量(version vector)【56】。这个想法的一些变体正在 使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的分散版本矢量(dotted version vector)【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常 相似。
与图5-13中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后 写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为因果上下文 (causal context))。版本向量允许数据库区分覆盖写入和并发写入。
另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副 本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正 确,就不会丢失数据。
分区
分区与复制
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记 录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如图6-1所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些 分区的领导者,同时是其他分区的追随者。 我们在第5章讨论的关于数据库复制的所有内容同 样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单 起见,本章中将忽略复制。
根据键的范围分区
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书 的卷(图6-2)。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还 知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书 架上选取正确的书籍)。
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在图6-2中,第1卷包含以A 和B开头的单词,但第12卷则包含以T,U,V,X,Y和Z开头的单词。只是简单的规定每个卷 包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。
根据键的散列分区
由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。
一个好的散列函数可以将将偏斜的数据均匀分布。假设你有一个32位散列函数,无论何时给定 一个新的字符串输入,它将返回一个0到$2^{32}$ -1之间的”随机”数。即使输入的字符串非常 相似,它们的散列也会均匀分布在这个数字范围内。
出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用 MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用 于哈希表),但是它们可能不适合分区:例如,在Java的 Object.hashCode() 和Ruby 的 Object#hash ,同一个键可能在不同的进程中有不同的哈希值【6】。
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范 围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如图6-3所示。
负载倾斜与消除热点
如今,大多数数据系统无法自动补偿这种高度偏斜的负载,因此应用程序有责任减少偏斜。 例如,如果一个主键被认为是非常火爆的,一个简单的方法是在主键的开始或结尾添加一个 随机数。只要一个两位数的十进制随机数就可以将主键分散为100钟不同的主键,从而存储在 不同的分区中。
然而,将主键进行分割之后,任何读取都必须要做额外的工作,因为他们必须从所有100个 主键分布中读取数据并将其合并。此技术还需要额外的记录:只需要对少量热点附加随机数; 对于写入吞吐量低的绝大多数主键来是不必要的开销。因此,您还需要一些方法来跟踪哪些 键需要被分割。
也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,您需要自己来权 衡。
分片与次级索引
次级索引的问题是它们不能整齐地映射到分区。有两种用二级索引对数据库进行分区的方 法:基于文档的分区(document-based)和基于关键词(term-based)的分区。
分区再平衡
随着时间的推移,数据库会有各种变化。
- 查询吞吐量增加,所以您想要添加更多的CPU来处理负载。
- 数据集大小增加,所以您想添加更多的磁盘和RAM来存储它。
- 机器出现故障,其他机器需要接管故障机器的责任。
所有这些更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点 向另一个节点移动的过程称为再平衡(reblancing)。
- 再平衡之后,负载(数据存储,读取和写入请求)应该在集群中的节点之间公平地共 享。
- 再平衡发生时,数据库应该继续接受读取和写入。
- 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘I/O负载。
反面教材:hash mod N
也许你想知道为什么我们不使用mod(许多编程语言中的%运算符)。例如, hash(key) mod 10 会返回一个介于0和9之间的数字(如果我们将散列写为十进制数,散列模10将是最后一个 数字)。如果我们有10个节点,编号为0到9,这似乎是将每个键分配给一个节点的简单方 法。
模$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个分区被分配给每个节点。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中窃取一些分区,直到分 区再次公平分配。这个过程如图6-6所示。如果从集群中删除一个节点,则会发生相反的情况。
只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变 的是分区所在的节点。这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。
动态分区
动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据,少量的分区就足够 了,所以开销很小;如果有大量的数据,每个分区的大小被限制在一个可配置的最大值
需要注意的是,一个空的数据库从一个分区开始,因为没有关于在哪里绘制分区边界的先验 信息。数据集开始时很小,直到达到第一个分区的分割点,所有写入操作都必须由单个节点 处理,而其他节点则处于空闲状态。为了解决这个问题,HBase和MongoDB允许在一个空的 数据库上配置一组初始分区(这被称为预分割(pre-splitting))。在键范围分区的情况中, 预分割需要提前知道键是如何进行分配的【4,26】。
动态分区不仅适用于数据的范围分区,而且也适用于散列分区。从版本2.4开始,MongoDB 同时支持范围和哈希分区,并且都是进行动态分割分区。
按节点比例分区
Cassandra和Ketama使用的第三种方法是使分区数与节点数成正比——换句话说,每个节点 具有固定数量的分区【23,27,28】。在这种情况下,每个分区的大小与数据集大小成比例地增 长,而节点数量保持不变,但是当增加节点数时,分区将再次变小。由于较大的数据量通常 需要较大数量的节点进行存储,因此这种方法也使每个分区的大小较为稳定。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行拆分,然后占有这些拆分分 区中每个分区的一半,同时将每个分区的另一半留在原地。随机化可能会产生不公平的分 割,但是平均在更大数量的分区上时(在Cassandra中,默认情况下,每个节点有256个分 区),新节点最终从现有节点获得公平的负载份额。 Cassandra 3.0引入了另一种再分配的算 法来避免不公平的分割【29】。
随机选择分区边界要求使用基于散列的分区(可以从散列函数产生的数字范围中挑选边 界)。实际上,这种方法最符合一致性哈希的原始定义【7】(参阅“一致性哈希”)。最新的 哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。
运维:手动还是自动平衡
在全自动重新平衡(系统自动决定何时将分区从一个节点移动到另一个节点,无须人工干 预)和完全手动(分区指派给节点由管理员明确配置,仅在管理员明确重新配置时才会更 改)之间有一个权衡。例如,Couchbase,Riak和Voldemort会自动生成建议的分区分配,但 需要管理员提交才能生效。
全自动重新平衡可以很方便,因为正常维护的操作工作较少。但是,这可能是不可预测的。 再平衡是一个昂贵的操作,因为它需要重新路由请求并将大量数据从一个节点移动到另一个 节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。
这种自动化与自动故障检测相结合可能十分危险。例如,假设一个节点过载,并且对请求的 响应暂时很慢。其他节点得出结论:过载的节点已经死亡,并自动重新平衡集群,使负载离 开它。这会对已经超负荷的节点,其他节点和网络造成额外的负载,从而使情况变得更糟, 并可能导致级联失败。
出于这个原因,再平衡的过程中有人参与是一件好事。这比完全自动的过程慢,但可以帮助 防止运维意外。
请求路由
现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的 问题:当客户想要发出请求时,如何知道要连接哪个节点?随着分区重新平衡,分区对节点 的分配也发生变化。为了回答这个问题,需要有人知晓这些变化:如果我想读或写键“foo”, 需要连接哪个IP地址和端口号?
这个问题可以概括为 服务发现(service discovery) ,它不仅限于数据库。任何可通过网络访 问的软件都有这个问题,特别是如果它的目标是高可用性(在多台机器上运行冗余配置)。 许多公司已经编写了自己的内部服务发现工具,其中许多已经作为开源发布【30】。
概括来说,这个问题有几种不同的方案(如图6-7所示):
- 允许客户联系任何节点(例如,通过循环策略的负载均衡(Round-Robin Load Balancer))。如果该节点恰巧拥有请求的分区,则它可以直接处理该请求;否则,它将 请求转发到适当的节点,接收回复并传递给客户端。
- 首先将所有来自客户端的请求发送到路由层,它决定了应该处理请求的节点,并相应地 转发。此路由层本身不处理任何请求;它仅负责分区的负载均衡。
- 要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节 点,而不需要任何中介。
许多分布式数据系统都依赖于一个独立的协调服务,比如ZooKeeper来跟踪集群元数据,如 图6-8所示。 每个节点在ZooKeeper中注册自己,ZooKeeper维护分区到节点的可靠映射。 其 他参与者(如路由层或分区感知客户端)可以在ZooKeeper中订阅此信息。 只要分区分配发 生的改变,或者集群中添加或删除了一个节点,ZooKeeper就会通知路由层使路由信息保持 最新状态。
事务
数十年来,事务(transaction) 一直是简化这些问题的首选机制。事务是应用程序将多个 读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个 操作来执行:整个事务要么成功(提交(commit))要么失败(中止(abort),回滚 (rollback))。如果失败,应用程序可以安全地重试。对于事务来说,应用程序的错误处理 变得简单多了,因为它不用再担心部分失败的情况了,即某些操作成功,某些失败(无论出 于何种原因)。
和事务打交道时间长了,你可能会觉得它显而易见。但我们不应将其视为理所当然。事务不 自然法;它们是为了简化应用编程模型而创建的。通过使用事务,应用程序可以自由地忽略 某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(我们称之为安全保证 (safety guarantees))。
ACID的含义
事务所提供的安全保证,通常由众所周知的首字母缩略词ACID来描述,ACID代表原子性 (Atomicity),一致性(Consistency),隔离性(Isolation)和持久性(Durability)。 它由TheoHärder和Andreas Reuter于1983年创建,旨在为数据库中的容错机制建立精确的术 语。
但实际上,不同数据库的ACID实现并不相同。例如,我们将会看到,围绕着隔离性 (Isolation) 的含义有许多含糊不清【8】。高层次上的想法是合理的,但魔鬼隐藏在细节 里。今天,当一个系统声称自己“符合ACID”时,实际上能期待的是什么保证并不清楚。不幸 的是,ACID现在几乎已经变成了一个营销术语。
(不符合ACID标准的系统有时被称为BASE,它代表基本可用性(Basically Available), 软状态(Soft State)和最终一致性(Eventual consistency)【9】,这比ACID的定义更加 模糊,似乎BASE的唯一合理的定义是“不是ACID”,即它几乎可以代表任何你想要的东西。)
让我们深入了解原子性,一致性,隔离性和持久性的定义,这可以让我们提炼出事务的思想。
两阶段锁定(2PL)
大约30年来,在数据库中只有一种广泛使用的序列化算法:两阶段锁定(2PL,two-phase locking)
请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全 不同的东西。我们将在第9章讨论2PC。
之前我们看到锁通常用于防止脏写(参阅“没有脏写”一节):如果两个事务同时尝试写入同一 个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
两阶段锁定定类似,但使锁的要求更强。只要没有写入,就允许多个事务同时读取同一个对 象。但对象只要有写入(修改或删除),就需要独占访问(exclusive access) 权限:
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才 能继续。 (这确保B不能在A底下意外地改变对象。)
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能 继续。 (像图7-1那样读取旧版本的对象在2PL下是不可接受的。)
在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得读不阻塞写,写也不阻塞读(参阅“实现快照隔离”),这是2PL和快照隔离之间的关键区别。另一方面,因为 2PL提供了可序列化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
实现两阶段锁
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔 离级别【23,36】。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)或独占模式(exclusive mode)。锁使用如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果 另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有 锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直 接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名 字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务A等待事务B释放它的锁,反之亦然。这种 情况叫做死锁(Deadlock)。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
两阶段锁定的性
两阶段锁定的巨大缺点,以及70年代以来没有被所有人使用的原因,是其性能问题。两阶段 锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计 的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的 事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可 能需要等待几个其他事务才能完成。
因此,运行2PL的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能 高百分位点处的响应会非常的慢(参阅“描述性能”)。可能只需要一个缓慢的事务,或者一个 访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需 要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读已提交隔离级别可能发生死锁,但在基于2PL实现的可序列化隔离级别中,它 们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由 于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨 大的浪费。
分布式系统的麻烦
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。 我们将研究网络的问题 (“无法访问的网络”); 时钟和时序问题(“不可靠时钟”); 我们将讨论他们可以避免的程度。 所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如 何推理发生的事情(“知识,真相和谎言”)。
从不可靠的组件构建可靠的系统
您可能想知道这是否有意义——直观地看来,系统只能像其最不可靠的组件(最薄弱的 环节)一样可靠。事实并非如此:事实上,从不太可靠的潜在基础构建更可靠的系统是 计算机领域的一个古老思想【11】。例如:
- 纠错码允许数字数据在通信信道上准确传输,偶尔会出现一些错误,例如由于无线 网络上的无线电干扰【12】。
- 互联网协议(Internet Protocol, IP)不可靠:可能丢弃,延迟,复制或重排数据包。 传输控制协议(Transmission Control Protocol, TCP)在互联网协议(IP)之 上提供了更可靠的传输层:它确保丢失的数据包被重新传输,消除重复,并且数据 包被重新组装成它们被发送的顺序。
虽然这个系统可以比它的底层部分更可靠,但它的可靠性总是有限的。例如,纠错码可 以处理少量的单比特错误,但是如果你的信号被干扰所淹没,那么通过信道可以得到多 少数据,是有根本性的限制的【13】。 TCP可以隐藏数据包的丢失,重复和重新排序, 但是它不能神奇地消除网络中的延迟。
小结
在本章中,我们讨论了分布式系统中可能发生的各种问题,包括:
- 当您尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢 失或延迟,所以如果你没有得到答复,你不知道消息是否通过。
- 节点的时钟可能会与其他节点显着不同步(尽管您尽最大努力设置NTP),它可能会突 然跳转或跳回,依靠它是很危险的,因为您很可能没有好的测量你的时钟的错误间隔。
- 一个进程可能会在其执行的任何时候暂停一段相当长的时间(可能是因为世界上的垃圾 收集器),被其他节点宣告死亡,然后再次复活,却没有意识到它被暂停了。
这类部分失效可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节 点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分 布式系统中,我们试图在软件中建立部分失效的容错机制,这样整个系统即使在某些组成部 分被破坏的情况下,也可以继续运行。
为了容忍错误,第一步是检测它们,但即使这样也很难。大多数系统没有检测节点是否发生 故障的准确机制,所以大多数分布式算法依靠超时来确定远程节点是否仍然可用。但是,超 时无法区分网络失效和节点失效,并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外,有时一个节点可能处于降级状态:例如,由于驱动程序错误【94】,千兆网卡可 能突然下降到1 Kb/s的吞吐量。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点 更难处理。
一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知 识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不 用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信 息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协 议,并争取达到法定人数以达成一致。
如果你习惯于在理想化的数学完美(同一个操作总能确定地返回相同的结果)的单机环境中 编写软件,那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反,如果能够在 单台计算机上解决一个问题,那么分布式系统工程师通常会认为这个问题是平凡的【5】,现 在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子,把东西放在 一台机器上,那么通常是值得的。
但是,正如在第二部分的介绍中所讨论的那样,可扩展性并不是使用分布式系统的唯一原 因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不 能用单个节点实现。
在本章中,我们也转换了几次话题,探讨了网络,时钟和进程的不可靠性是否是不可避免的 自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这 样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择便宜而不可 靠,而不是昂贵和可靠。
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重 新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维 护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变 更被应用到所有的节点,仍然会使分布式系统瘫痪)。
一致性与共识
现在我们将继续沿着同样的路线前进,寻求可以让应用忽略分布式系统部分问题的抽象概 念。例如,分布式系统最重要的抽象之一就是共识(consensus):就是让所有的节点对某 件事达成一致。正如我们在本章中将会看到的那样,尽管存在网络故障和流程故障,可靠地 达成共识是一个令人惊讶的棘手问题。
一旦达成共识,应用可以将其用于各种目的。例如,假设你有一个单主复制的数据库。如果 主库挂点,并且需要故障转移到另一个节点,剩余的数据库节点可以使用共识来选举新的领 导者。正如在“处理节点宕机”中所讨论的那样,重要的是只有一个领导者,且所有的节点都认 同其领导。如果两个节点都认为自己是领导者,这种情况被称为脑裂(split brain),且经常 导致数据丢失。正确实现共识有助于避免这种问题。
一致性保证
大多数复制的数据库至少提供了最终一致性,这意味着如果你停止向数据库写入数据并等待 一段不确定的时间,那么最终所有的读取请求都会返回相同的值【1】。换句话说,不一致性 是暂时的,最终会自行解决(假设网络中的任何故障最终都会被修复)。最终一致性的一个 更好的名字可能是收敛(convergence),因为我们预计所有的复本最终会收敛到相同的值 【2】。
然而,这是一个非常弱的保证 —— 它并没有说什么什么时候副本会收敛。在收敛之前,读操 作可能会返回任何东西或什么都没有【1】。例如,如果你写入了一个值,然后立即再次读 取,这并不能保证你能看到刚跟写入的值,因为读请求可能会被路由到另外的副本上。(参 阅“读己之写” )。
线性一致性
这就是线性一致性(linearizability)背后的想法【6】(也称为原子一致性(atomic consistency)【7】,强一致性(strong consistency),立即一致性(immediate consistency)或外部一致性(external consistency )【8】)。线性一致性的精确定义相 当微妙,我们将在本节的剩余部分探讨它。但是基本的想法是让一个系统看起来好像只有一 个数据副本,而且所有的操作都是原子性的。有了这个保证,即使实际中可能有多个副本, 应用也不需要担心它们。
锁定和领导选举
一个使用单主复制的系统,需要确保领导真的只有一个,而不是几个(脑裂)。一种选择领 导者的方法是使用锁:每个节点在启动时尝试获取锁,成功者成为领导者【14】。不管这个 锁是如何实现的,它必须是线性一致的:所有节点必须就哪个节点拥有锁达成一致,否则就 没用了。
实现线性一致的系统
单主复制(可能线性一致)
在具有单主复制功能的系统中(参见“领导者与追随者”),主库具有用于写入的数据的主副 本,而追随者在其他节点上保留数据的备份副本。如果从主库或同步更新的从库读取数据, 它们可能(protential)是线性一致性的iv。然而,并不是每个单主数据库都是实际线性一致 性的,无论是通过设计(例如,因为使用快照隔离)还是并发错误【10】。
从主库读取依赖一个假设,你确定领导是谁。正如在“真理在多数人手中”中所讨论的那样, 一个节点很可能会认为它是领导者,而事实上并非如此——如果具有错觉的领导者继续为请 求提供服务,可能违反线性一致性【20】。使用异步复制,故障转移时甚至可能会丢失已提 交的写入(参阅“处理节点宕机”),这同时违反了持久性和线性一致性。
共识算法(线性一致)
一些在本章后面讨论的共识算法,与单领导者复制类似。然而,共识协议包含防止脑裂和陈 旧副本的措施。由于这些细节,共识算法可以安全地实现线性一致性存储。例如,Zookeeper 【21】和etcd 【22】就是这样工作的。
多主复制(非线性一致)
具有多主程序复制的系统通常不是线性一致的,因为它们同时在多个节点上处理写入,并将 其异步复制到其他节点。因此,它们可能会产生冲突的写入,需要解析(参阅“处理写入冲 突”)。这种冲突是因为缺少单一数据副本人为产生的。
无主复制(也许不是线性一致的)
对于无领导者复制的系统(Dynamo风格;参阅“无主复制”),有时候人们会声称通过要求法 定人数读写( $w + r> n$ )可以获得“强一致性”。这取决于法定人数的具体配置,以及强一 致性如何定义(通常不完全正确)。
基于时钟
基于时钟(例如,在Cassandra中;参见“依赖同步时钟”)的“最后写入胜利”冲突解决方法几 乎可以确定是非线性的,由于时钟偏差,不能保证时钟的时间戳与实际事件顺序一致。松散 的法定人数也破坏了线性一致的可能性。即使使用严格的法定人数,非线性一致的行为也是 可能的,如下节所示。
线性一致性和法定人数
直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有 可变的网络延迟时,就可能存在竞争条件,如图9-6所示。
总而言之,最安全的做法是:假设采用Dynamo风格无主复制的系统不能提供线性一致性。
CAP定理
CAP有时以这种面目出现:一致性,可用性和分区容忍:三者只能择其二。不幸的是这 种说法很有误导性【32】,因为网络分区是一种错误,所以它并不是一个选项:不管你 喜不喜欢它都会发生【38】
在网络正常工作的时候,系统可以提供一致性(线性一致性)和整体可用性。发生网络 故障时,你必须在线性一致性和整体可用性之间做出选择。因此,一个更好的表达CAP 的方法可以是一致的,或者在分区时可用【39】。一个更可靠的网络需要减少这个选 择,但是在某些时候选择是不可避免的。
在CAP的讨论中,术语可用性有几个相互矛盾的定义,形式化作为一个定理【30】并不 符合其通常的含义【40】。许多所谓的“高可用”(容错)系统实际上不符合CAP对可用 性的特殊定义。总而言之,围绕着CAP有很多误解和困惑,并不能帮助我们更好地理解 系统,所以最好避免使用CAP。
线性一致性和网络延迟
虽然线性一致是一个很有用的保证,但实际上,线性一致的系统惊人的少。例如,现代多核 CPU上的内存甚至都不是线性一致的【43】:如果一个CPU核上运行的线程写入某个内存地 址,而另一个CPU核上运行的线程不久之后读取相同的地址,并没有保证一定能一定读到第 一个线程写入的值(除非使用了内存屏障(memory barrier)或围栏(fence)
因果顺序不是全序的
全序(total order)允许任意两个元素进行比较,所以如果有两个元素,你总是可以说出哪 个更大,哪个更小。例如,自然数集是全序的:给定两个自然数,比如说5和13,那么你可以 告诉我,13大于5。
然而数学集合并不完全是全序的: {a, b} 比 {b, c} 更大吗?好吧,你没法真正比较它 们,因为二者都不是对方的子集。我们说它们是无法比较(incomparable)的,因此数学集 合是偏序(partially order)的:在某些情况下,可以说一个集合大于另一个(如果一个集合 包含另一个集合的所有元素),但在其他情况下它们是无法比较的 。
全序和偏序之间的差异反映在不同的数据库一致性模型中
线性一致性:
在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有 操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序 图9-4中以时间线表示。
因果性:
我们说过,如果两个操作都没有在彼此之前发生,那么这两个操作是并发的(参阅“此前发 生”的关系和并发)。换句话说,如果两个事件是因果相关的(一个发生在另一个事件之 前),则它们之间是有序的,但如果它们是并发的,则它们之间的顺序是无法比较的。这意 味着因果关系定义了一个偏序,而不是一个全序:一些操作相互之间是有顺序的,但有些则 是无法比较的。
因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时 间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但 是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并 发。
并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即 并发操作)。在第五章中我们看到了这种现象:例如,图5-14 并不是一条直线的全序关系, 而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序
线性一致性强于因果一致性
那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性隐含着(implies)因果关 系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道 (如图9-5 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何 特殊操作(如在不同组件间传递时间戳)
线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“线性 一致性的代价”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有 严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数 据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。
好消息是存在折衷的可能性。线性一致性并不是保持因果性的唯一途径 —— 还有其他方法。 一个系统可以是因果一致的,而无需承担线性一致带来的性能折损(尤其对于CAP定理不适 用的情况)。实际上在所有的不会被网络延迟拖慢的一致性模型中,因果一致性是可行的最 强的一致性模型。而且在网络故障时仍能保持可用【2,42】。
在许多情况下,看上去需要线性一致性的系统,实际上需要的只是因果一致性,因果一致性 可以更高效地实现。基于这种观察结果,研究人员正在探索新型的数据库,既能保证因果一 致性,且性能与可用性与最终一致的系统类似【49,50,51】。
捕获因果关系
为了维持因果性,你需要知道哪个操作发生在哪个其他操作之前(happened before)。这 是一个偏序:并发操作可以以任意顺序进行,但如果一个操作发生在另一个操作之前,那它 们必须在所有副本上以那个顺序被处理。因此,当一个副本处理一个操作时,它必须确保所 有因果前驱的操作(之前发生的所有操作)已经被处理;如果前面的某个操作丢失了,后面 的操作必须等待,直到前面的操作被处理完毕。
为了确定因果依赖,我们需要一些方法来描述系统中节点的“知识”。如果节点在发出写入Y 的 请求时已经看到了 X的值,则 X 和 Y 可能存在因果关系。这个分析使用了那些在欺诈指控刑 事调查中常见的问题:CEO在做出决定 Y 时是否知道 X ?
用于确定哪些操作发生在其他操作之前 的技术,与我们在“检测并发写入”中所讨论的内容类 似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同 一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅 仅是一个键。可以推广版本向量以解决此类问题【54】。
为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 图5-13 中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似 的想法,如“可序列化的快照隔离(SSI)”中所述:当事务要提交时,数据库将检查它所读取 的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。
序列号顺序
虽然因果是一个重要的理论概念,但实际上跟踪所有的因果关系是不切实际的。在许多应用 中,客户端在写入内容之前会先读取大量数据,我们无法弄清写入因果依赖于先前全部的读 取内容,还是仅包括其中一部分。显式跟踪所有已读数据意味着巨大的额外开销。
但还有一个更好的方法:我们可以使用序列号(sequence nunber)或时间戳 (timestamp)来排序事件。时间戳不一定来自时钟(或物理时钟,存在许多问题,如 “不可 靠时钟” 中所述)。它可以来自一个逻辑时钟(logical clock),这是一个用来生成标识操作 的数字序列的算法,典型实现是使用一个每次操作自增的计数器。
这样的序列号或时间戳是紧凑的(只有几个字节大小),它提供了一个全序关系:也就是说 每操作都有一个唯一的序列号,而且总是可以比较两个序列号,确定哪一个更大(即哪些操 作后发生)。
特别是,我们可以使用与因果一致(consistent with causality)的全序来生成序列号vii: 我们保证,如果操作 A 因果后继于操作 B,那么在这个全序中 A 在 B 前( A 具有比 B 更小的 序列号)。并行操作之间可以任意排序。这样一个全序关系捕获了所有关于因果的信息,但 也施加了一个比因果性要求更为严格的顺序。
全序广播
如果你的程序只运行在单个CPU核上,那么定义一个操作全序是很容易的:可以简单地就是 CPU执行这些操作的顺序。但是在分布式系统中,让所有节点对同一个全局操作顺序达成一 致可能相当棘手。在上一节中,我们讨论了按时间戳或序列号进行排序,但发现它还不如单 主复制给力(如果你使用时间戳排序来实现唯一性约束,而且不能容忍任何错误)。
如前所述,单主复制通过选择一个节点作为主库来确定操作的全序,并在主库的单个CPU核 上对所有操作进行排序。接下来的挑战是,如果吞吐量超出单个主库的处理能力,这种情况 下如何扩展系统;以及,如果主库失效(“处理节点宕机”),如何处理故障转移。在分布式系统文献中,这个问题被称为全序广播(total order broadcast)或原子广播(atomic broadcast)ix【25,57,58】。
“原子广播”是一个传统的术语,非常混乱,而且与“原子”一词的其他用法不一致:它与 ACID事务中的原子性没有任何关系,只是与原子操作(在多线程编程的意义上 )或原子 寄存器(线性一致存储)有间接的联系。全序广播是另一个同义词。
全序广播通常被描述为在节点间交换消息的协议。 非正式地讲,它要满足两个安全属性
可靠交付(reliable delivery)
没有消息丢失:如果消息被传递到一个节点,它将被传递到所有节点。
全序交付(totally ordered delivery)
消息以相同的顺序传递给每个节点。
正确的全序广播算法必须始终保证可靠性和有序性,即使节点或网络出现故障。当然在网络 中断的时候,消息是传不出去的,但是算法可以不断重试,以便在网络最终修复时,消息能 及时通过并送达(当然它们必须仍然按照正确的顺序传递)。
使用全序广播
像ZooKeeper和etcd这样的共识服务实际上实现了全序广播。这一事实暗示了全序广播与共 识之间有着紧密联系,我们将在本章稍后进行探讨。
全序广播正是数据库复制所需的:如果每个消息都代表一次数据库的写入,且每个副本都按 相同的顺序处理相同的写入,那么副本间将相互保持一致(除了临时的复制延迟)。这个原 理被称为状态机复制(state machine replication)【60】,我们将在第11章中重新回到这个概念。
与之类似,可以使用全序广播来实现可序列化的事务:如“真的串行执行”中所述,如果每个 消息都表示一个确定性事务,以存储过程的形式来执行,且每个节点都以相同的顺序处理这 些消息,那么数据库的分区和副本就可以相互保持一致【61】。
全序广播的一个重要表现是,顺序在消息送达时被固化:如果后续的消息已经送达,节点就 不允许追溯地将(先前)消息插入顺序中的较早位置。这个事实使得全序广播比时间戳命令 更强。
考量全序广播的另一种方式是,这是一种创建日志的方式(如在复制日志,事务日志或预写 式日志中):传递消息就像附加写入日志。由于所有节点必须以相同的顺序传递相同的消 息,因此所有节点都可以读取日志,并看到相同的消息序列。
全序广播对于实现提供防护令牌的锁服务也很有用(参见“防护令牌”)。每个获取锁的请求 都作为一条消息追加到日志末尾,并且所有的消息都按它们在日志中出现的顺序依次编号。 序列号可以当成防护令牌用,因为它是单调递增的。在ZooKeeper中,这个序列号被称 为 zxid 【15】。
使用全序广播实现线性一致的存储
全序广播是异步的:消息被保证以固定的顺序可靠地传送,但是不能保证消息何时被送达 (所以一个接收者可能落后于其他接收者)。相比之下,线性一致性是新鲜性的保证:读取 一定能看见最新的写入值。
但如果有了全序广播,你就可以在此基础上构建线性一致的存储。例如,你可以确保用户名 能唯一标识用户帐户。
设想对于每一个可能的用户名,你都可以有一个带有CAS原子操作的线性一致寄存器。每个 寄存器最初的值为空值(表示不使用用户名)。当用户想要创建一个用户名时,对该用户名 的寄存器执行CAS操作,在先前寄存器值为空的条件,将其值设置为用户的账号ID。如果多 个用户试图同时获取相同的用户名,则只有一个CAS操作会成功,因为其他用户会看到非空 的值(由于线性一致性)。
你可以通过将全序广播当成仅追加日志【62,63】的方式来实现这种线性一致的CAS操作:
- 在日志中追加一条消息,试探性地指明你要声明的用户名。
- 读日志,并等待你所附加的信息被回送。
- 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就你自己的消 息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息) 并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。
由于日志项是以相同顺序送达至所有节点,因此如果有多个并发写入,则所有节点会对最先 到达者达成一致。选择冲突写入中的第一个作为胜利者,并中止后来者,以此确定所有节点 对某个写入是提交还是中止达成一致。类似的方法可以在一个日志的基础上实现可序列化的 多对象事务【62】。
尽管这一过程保证写入是线性一致的,但它并不保证读取也是线性一致的 —— 如果你从与日 志异步更新的存储中读取数据,结果可能是陈旧的。 (精确地说,这里描述的过程提供了顺 序一致性(sequential consistency)【47,64】,有时也称为时间线一致性(timeline consistency)【65,66】,比线性一致性稍微弱一些的保证)。为了使读取也线性一致,有 几个选项:
- 你可以通过追加一条消息,当消息回送时读取日志,执行实际的读取。消息在日志中的 位置因此定义了读取发生的时间点。 (etcd的法定人数读取有些类似这种情况 【16】。)
- 如果日志允许以线性一致的方式获取最新日志消息的位置,则可以查询该位置,等待直 到该位置前的所有消息都传达到你,然后执行读取。 (这是Zookeeper sync() 操作背 后的思想【15】)。
- 你可以从同步更新的副本中进行读取,因此可以确保结果是最新的。 (这种技术用于链 式复制【63】;参阅“复制研究”。)
使用线性一致性存储实现全序广播
上一节介绍了如何从全序广播构建一个线性一致的CAS操作。我们也可以把它反过来,假设 我们有线性一致的存储,接下来会展示如何在此基础上构建全序广播。
最简单的方法是假设你有一个线性一致的寄存器来存储一个整数,并且有一个原子自增并返 回操作【28】。或者原子CAS操作也可以完成这项工作。
该算法很简单:每个要通过全序广播发送的消息首先对线性一致寄存器执行自增并返回操 作。然后将从寄存器获得的值作为序列号附加到消息中。然后你可以将消息发送到所有节点 (重新发送任何丢失的消息),而收件人将按序列号连续发送消息。
请注意,与兰伯特时间戳不同,通过自增线性一致性寄存器获得的数字形式上是一个没有间 隙的序列。因此,如果一个节点已经发送了消息 4 并且接收到序列号为 6 的传入消息,则它 知道它在传递消息 6 之前必须等待消息 5 。兰伯特时间戳则与之不同 —— 事实上,这是全序 广播和时间戳排序间的关键区别。
实现一个带有原子性自增并返回操作的线性一致寄存器有多困难?像往常一样,如果事情从 来不出差错,那很容易:你可以简单地把它保存在单个节点内的变量中。问题在于处理当该 节点的网络连接中断时的情况,并在该节点失效时能恢复这个值【59】。一般来说,如果你 对线性一致性的序列号生成器进行深入过足够深入的思考,你不可避免地会得出一个共识算 法。
这并非巧合:可以证明,线性一致的CAS(或自增并返回)寄存器与全序广播都都等价于共 识问题【28,67】。也就是说,如果你能解决其中的一个问题,你可以把它转化成为其他问题 的解决方案。这是相当深刻和令人惊讶的洞察!
分布式事务与共识
共识是分布式计算中最重要也是最基本的问题之一。从表面上看似乎很简单:非正式地讲, 目标只是让几个节点达成一致(get serveral nodes to agree on something)。你也许会认 为这不会太难。不幸的是,许多出故障的系统都是因为错误地轻信这个问题很容易解决。
尽管共识非常重要,但关于它的内容出现在本书的后半部分,因为这个主题非常微妙,欣赏 细微之处需要一些必要的知识。即使在学术界,对共识的理解也是在几十年的过程中逐渐沉 淀而来,一路上也有着许多误解。现在我们已经讨论了复制(第5章),事务(第7章),系 统模型(第8章),线性一致以及全序(本章),我们终于准备好解决共识问题了。
节点能达成一致,在很多场景下都非常重要,例如:
领导选举
在单主复制的数据库中,所有节点需要就哪个节点是领导者达成一致。如果一些节点由于网 络故障而无法与其他节点通信,则可能会对领导权的归属引起争议。在这种情况下,共识对 于避免错误的故障切换非常重要。错误的故障切换会导致两个节点都认为自己是领导者(脑 裂,参阅“处理节点宕机”)。如果有两个领导者,它们都会接受写入,它们的数据会发生分 歧,从而导致不一致和数据丢失。
原子提交
在支持跨多节点或跨多分区事务的数据库中,一个事务可能在某些节点上失败,但在其他节点上成功。如果我们想要维护事务的原子性(就ACID而言,请参“原子性”),我们必须让所有节点对事务的结果达成一致:要么全部中止/回滚(如果出现任何错误),要么它们全部提交(如果没有出错)。这个共识的例子被称为原子提交(atomic commit)问题。
两阶段提交简介
两阶段提交(two-phase commit)是一种用于实现跨多个节点的原子事务提交的算法,即 确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在 某些数据库内部使用,也以XA事务的形式对应用可用【76,77】(例如Java Transaction API 支持)或以SOAP Web服务的 WS-AtomicTransaction 形式提供给应用【78,79】。
为了理解它的工作原理,我们必须更详细地分解这个过程:
- 当应用想要启动一个分布式事务时,它向协调者请求一个事务ID。此事务ID是全局唯一的。
- 应用在每个参与者上启动单节点事务,并在单节点事务上捎带上这个全局事务ID。所有 的读写都是在这些单节点事务中各自完成的。如果在这个阶段出现任何问题(例如,节 点崩溃或请求超时),则协调者或任何参与者都可以中止。
- 当应用准备提交时,协调者向所有参与者发送一个准备请求,并打上全局事务ID的标 记。如果任意一个请求失败或超时,则协调者向所有参与者发送针对该事务ID的中止请 求。
- 参与者收到准备请求时,需要确保在任意情况下都的确可以提交事务。这包括将所有事 务数据写入磁盘(出现故障,电源故障,或硬盘空间不足都不能是稍后拒绝提交的理 由)以及检查是否存在任何冲突或违反约束。通过向协调者回答“是”,节点承诺,只要请 求,这个事务一定可以不出差错地提交。换句话说,参与者放弃了中止事务的权利,但 没有实际提交。
- 当协调者收到所有准备请求的答复时,会就提交或中止事务作出明确的决定(只有在所 有参与者投赞成票的情况下才会提交)。协调者必须把这个决定写到磁盘上的事务日志 中,如果它随后就崩溃,恢复后也能知道自己所做的决定。这被称为提交点(commit point)。
- 一旦协调者的决定落盘,提交或放弃请求会发送给所有参与者。如果这个请求失败或超 时,协调者必须永远保持重试,直到成功为止。没有回头路:如果已经做出决定,不管 需要多少次重试它都必须被执行。如果参与者在此期间崩溃,事务将在其恢复后提交 ——由于参与者投了赞成,因此恢复后它不能拒绝提交。
因此,该协议包含两个关键的“不归路”点:当参与者投票“是”时,它承诺它稍后肯定能够提交 (尽管协调者可能仍然选择放弃)。一旦协调者做出决定,这一决定是不可撤销的。这些承 诺保证了2PC的原子性。 (单节点原子提交将这两个事件混为一谈:将提交记录写入事务日 志。)
协调者失效
我们已经讨论了在2PC期间,如果参与者之一或网络发生故障时会发生什么情况:如果任何 一个准备请求失败或者超时,协调者就会中止事务。如果任何提交或中止请求失败,协调者 将无条件重试。但是如果协调者崩溃,会发生什么情况就不太清楚了。
如果协调者在发送准备请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到 了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或 中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种 事务状态称为存疑(in doubt)的或不确定(uncertain)的。
情况如图9-10 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请 求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交 或中止。即使超时在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提 交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。
没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每 个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。
可以完成2PC的唯一方法是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交 或中止请求之前,将其提交或中止决定写入磁盘上的事务日志:协调者恢复后,通过读取其 事务日志来确定所有存疑事务的状态。任何在协调者日志中没有提交记录的事务都会中止。 因此,2PC的提交点归结为协调者上的常规单节点原子提交。
三阶段提交
两阶段提交被称为阻塞(blocking)原子提交协议,因为存在2PC可能卡住并等待协调者恢 复的情况。理论上,可以使一个原子提交协议变为非阻塞(nonblocking)的,以便在节点 失败时不会卡住。但是让这个协议能在实践中工作并没有那么简单。
作为2PC的替代方案,已经提出了一种称为三阶段提交(3PC)的算法【13,80】。然而, 3PC假定网络延迟有界,节点响应时间有限;在大多数具有无限网络延迟和进程暂停的实际 系统中(见第8章),它并不能保证原子性。
通常,非阻塞原子提交需要一个完美的故障检测器(perfect failure detector)【67,71】 —— 即一个可靠的机制来判断一个节点是否已经崩溃。在具有无限延迟的网络中,超时并不是一种可靠的故障检测机制,因为即使没有节点崩溃,请求也可能由于网络问题而超时。出于这个原因,2PC仍然被使用,尽管大家都清楚可能存在协调者故障的问题。
XA事务
X/Open XA(扩展架构(eXtended Architecture)的缩写)是跨异构技术实现两阶段提交 的标准【76,77】。它于1991年推出并得到了广泛的实现:许多传统关系数据库(包括 PostgreSQL,MySQL,DB2,SQL Server和Oracle)和消息代理(包括ActiveMQ, HornetQ,MSMQ和IBM MQ) 都支持XA。
XA不是一个网络协议——它只是一个用来与事务协调者连接的C API。其他语言也有这种API 的绑定;例如在Java EE应用的世界中,XA事务是使用Java事务API(JTA, Java Transaction API)实现的,而许多使用Java数据库连接(JDBC, Java Database Connectivity)的数据库驱动,以及许多使用Java消息服务(JMS)API的消息代理都支持 Java事务API(JTA)。
XA假定你的应用使用网络驱动或客户端库来与参与者进行通信(数据库或消息服务)。如果 驱动支持XA,则意味着它会调用XA API 以查明操作是否为分布式事务的一部分 —— 如果 是,则将必要的信息发往数据库服务器。驱动还会向协调者暴露回调接口,协调者可以通过 回调来要求参与者准备,提交或中止。
事务协调者需要实现XA API。标准没有指明应该如何实现,但实际上协调者通常只是一个 库,被加载到发起事务的应用的同一个进程中(而不是单独的服务)。它在事务中个跟踪所 有的参与者,并在要求它们准备之后收集参与者的响应(通过驱动回调),并使用本地磁盘 上的日志记录每次事务的决定(提交/中止)。
如果应用进程崩溃,或者运行应用的机器报销了,协调者也随之往生极乐。然后任何带有准 备了但未提交事务的参与者都会在疑虑中卡死。由于协调程序的日志位于应用服务器的本地 磁盘上,因此必须重启该服务器,且协调程序库必须读取日志以恢复每个事务的提交/中止结 果。只有这样,协调者才能使用数据库驱动的XA回调来要求参与者提交或中止。数据库服务 器不能直接联系协调者,因为所有通信都必须通过客户端库。
共识算法和全序广播
最著名的容错共识算法是视图戳复制(VSR, viewstamped replication)【94,95】,Paxos 【96,97,98,99】,Raft 【22,100,101】以及 Zab 【15,21,102】 。这些算法之间有不少相似 之处,但它们并不相同【103】。在本书中我们不会介绍各种算法的详细细节:了解一些它们 共通的高级思想通常已经足够了,除非你准备自己实现一个共识系统。(可能并不明智,相 当难【98,104】)
大多数这些算法实际上并不直接使用这里描述的形式化模型(提议与决定单个值,一致同 意,完整性,有效性和终止属性)。取而代之的是,它们决定了值的顺序(sequence),这 使它们成为全序广播算法,正如本章前面所讨论的那样(参阅“全序广播”
请记住,全序广播要求将消息按照相同的顺序,恰好传递一次,准确传送到所有节点。如果 仔细思考,这相当于进行了几轮共识:在每一轮中,节点提议下一条要发送的消息,然后决 定在全序中下一条要发送的消息【67】。
所以,全序广播相当于重复进行多轮共识(每次共识决定与一次消息传递相对应):
- 由于一致同意属性,所有节点决定以相同的顺序传递相同的消息。
- 由于完整性属性,消息不会重复。
- 由于有效性属性,消息不会被损坏,也不能凭空编造。
- 由于终止属性,消息不会丢失。
视图戳复制,Raft和Zab直接实现了全序广播,因为这样做比重复一次一值(one value a time)的共识更高效。在Paxos的情况下,这种优化被称为Multi-Paxos。
共识的局限性
共识算法对于分布式系统来说是一个巨大的突破:它为其他充满不确定性的系统带来了基础 的安全属性(一致同意,完整性和有效性),然而它们还能保持容错(只要多数节点正常工 作且可达,就能取得进展)。它们提供了全序广播,因此也可以它们也可以以一种容错的方式实现线性一致的原子操作(参见“使用全序广播实现线性一致性存储”)。
尽管如此,它们并不是在所有地方都用上了,因为好处总是有代价的。
节点在做出决定之前对提议进行投票的过程是一种同步复制。如“同步与异步复制”中所述, 通常数据库会配置为异步复制模式。在这种配置中发生故障切换时,一些已经提交的数据可 能会丢失 —— 但是为了获得更好的性能,许多人选择接受这种风险
共识系统总是需要严格多数来运转。这意味着你至少需要三个节点才能容忍单节点故障(其 余两个构成多数),或者至少有五个节点来容忍两个节点发生故障(其余三个构成多数)。 如果网络故障切断了某些节点同其他节点的连接,则只有多数节点所在的网络可以继续工 作,其余部分将被阻塞(参阅“线性一致性的代价”)。
大多数共识算法假定参与投票的节点是固定的集合,这意味着你不能简单的在集群中添加或 删除节点。共识算法的动态成员扩展(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调用这些临时节点)时,会话持有的任何锁都可以配置为自动释放 (ZooKeeper称之为临时节点(ephemeral nodes))。
- 变更通知,客户端不仅可以读取其他客户端创建的锁和值,还可以监听它们的变更。因此,客户端可以 知道另一个客户端何时加入集群(基于新客户端写入ZooKeeper的值),或发生故障(因其 会话超时,而其临时节点消失)。通过订阅通知,客户端不用再通过频繁轮询的方式来找出 变更。