Storm作者Nathan Marz的大作《Big Data: Principles and Best Practices of Scalable Realtime Data Systems》原版授权翻译,未经允许不得转载!
在最高的层次上,传统的体系结构如图1.3所示。这些结构的特征是读/写数据库的使用和随着新数据的可用,增量地维护这些数据库中的状态。例如,一个计算页面浏览量的增量方法,将通过对URL计数器加1来处理新的页面浏览量。这种架构的特征比关系型与非关系型更基础——事实上,绝大多数的关系型和非关系型数据库是用全增量架构来部署的。几十年来一直如此。
图1.3 全增量架构
值得强调的是,全增量架构非常普遍,以至于许多人没有意识到可以使用另外一种不同的架构来避免它们的问题。这些都是熟悉的复杂性的很好的示例——复杂性如此根深蒂固,你甚至认为找不到一种方法来避免它。
全增量架构的问题是意义重大的。通过查看任何全增量架构带来的一般复杂性,我们将开始这个主题的探索。然后,我们将看看针对相同问题的两种截然不同的解决方案:一个使用最好可能的全增量方案,一个使用Lambda 架构。你会发现全增量的版本在每个方面都明显更加糟糕。
在全增量架构中有许多的内在复杂性,给操作生产基础架构造成了困难。这里我们将关注一个方面:需要读/写数据库来执行在线合并,还有你为保证这项工作平稳运行必须做的操作。
在读/写数据库中,随着磁盘索引逐步增加和修改,使得部分索引从未使用过。这些未使用的部分索引占用空间,最终需要被回收以防止磁盘被填满。如果当索引一旦变成未使用的,回收空间就立马回收,这样付出的代价太高了,所以空间在一个被称为合并的进程中不定期地被批量回收。
合并是一类集中操作。在合并过程中,服务器对CPU和磁盘有更高的需求,这大大降低了该时期机器的性能。数据库,如HBase和Cassandra,众所周知都需要仔细地配置和管理,以避免在合并时出现问题或服务器锁定。合并时性能的损失是一类甚至会导致级连故障的复杂性——如果太多机器同时在执行合并操作,它们所支撑的负载将必须由集群中的其他机器处理。这可能使剩余的集群过载,导致彻底的失败。我们已经看到这种失败模式发生过很多次了。
为了正确地管理合并,你必须在每个节点上都安排合并,这样同一时间不会有太多节点受到影响。你必须知道一个合并操作花费多少时间——以及时间的方差——以避免有比你预期更多的节点在执行合并。你必须确保节点上有足够的磁盘容量,以保证在合并期间它们能够维持正常的操作。此外,你必须确保集群有足够的容量,这样在合并时资源的丢失就不会造成过载。
所有这些可以由一个合格的操作人员来管理,但我们的观点是,处理任何一种复杂度的最好方式是完全摆脱这种复杂度。系统失败模式越少,就越不可能遇到意外的故障时间。处理在线合并是全增量架构中固有的复杂度,但在Lambda 架构中,主数据库不需要任何在线合并。
当试图让系统高可用时,会导致增量架构一个另外的复杂度。高可用系统甚至允许在机器或部分网络发生故障时进行查询和更新。
事实证明,实现高可用将与另一个称为一致性的重要属性直接竞争。具备一致性的系统返回结果时,会考虑到以前所有的写操作。CAP原理表明,在网络分区情况下,不可能在同一系统中同时实现高可用和一致性。所以在一个网络分区中,高可用系统有时会返回陈旧的结果。
我们将在第12章深入讨论CAP原理——现在我们总是希望专注于不能实现的完全一致性和高可用性上,那会影响构建系统的能力。事实证明,如果你的业务需求中对高可用性的需要超过完全一致性,那么你必须处理大量的复杂性。
一旦网络分区结束后,为了高可用性系统能回到一致性状态(即最终一致性),你的应用程序需要许多帮助。例如,在数据库中,维护一个计数的基本用例。最显而易见的方法是在数据库中存储一个数值,当接收到需要加和的事件时,就做增量。你也许会很惊讶,如果你采取这种方法,在网络分区时,你会遇到大规模的数据丢失。
这样做的原因是由于分布式数据库通过保存所有被存储信息的多个副本,来实现高可用性。当你保存了相同信息的多份副本时,即使机器出现故障或网络分区,这些信息仍然可用,如图1.4所示。在网络分区时,选择成为高可用性的系统,只要副本是可获得的,就会有客户端的更新。这将导致副本产生分歧并接收不同的更新。只有当分区消失,副本才可以合并成一个共同的值。
图1.4 使用副本增加可用性
当网络分区开始时,假设有两个副本的计数为10。假设第一个副本得到两个增量,第二个副本得到一个增量。当这些副本合并在一起时,值分别为12和11,合并后的值应该是多少?虽然正确的答案是13,但是没有办法通过查看数值12和11得到该值。这两个数可能在11的时候产生分歧(在这种情况下,答案会是12),或者它们可能会在0的时候产生分歧(在这种情况下,答案是23)。
为了做高可用性的正确地计算,只存储一个计数是不够的。当值出现分歧时,你需要一个负责合并的数据结构,并且需要实现一段用于一旦分区结束对值进行修复的代码。为了维护一个简单的计数,你必须处理令人难以置信的复杂性。
一般来说,在增量、高可用性系统中处理最终一致性,是不直观的且容易出错的。在高可用、全增量系统中,这种复杂性是内置的。稍后你将看到Lambda架构本身是如何以一种不同的方式,极大地减少实现高可用性、最终一致性系统的负担。
我们希望指出的全增量架构的最后一个问题是,它们天生缺乏容忍人为错误的特性。增量系统不断修改保存在数据库中的状态,这意味着一个错误也可以修改数据库中的状态。因为错误是不可避免的,所以全增量架构的数据库肯定会受到破坏。
重要的是要注意,全增量架构中,不用重新思考架构就可以解决的少数复杂性之一。考虑如图1.5所示的两种架构:同步架构,应用程序直接更新数据库;异步架构,在后台更新数据库之前事件先进到一个队列中。在这两种情况下,每个事件都被永久地记录到事件的数据存储。通过保存每个事件,如果是人为错误导致数据库被破坏,那么你可以返回到事件存储,为数据库重建正确的状态。因为事件存储是不可变且不断增长的,充分校验,如权限,可以放入事件存储,使得不可能因出现某个错误而影响事件存储。这种技术也是Lambda 架构的核心,我们将在第2章和第3章深入讨论。
图1.5 为全增量架构添加记录
尽管附带日志记录的全增量架构可以克服无日志记录的完全增量架构中对人为错误缺乏容忍的缺陷,但是日志记录对于前面讨论的其他复杂性于事无补。在下一节中你将看到,纯粹基于完全增量计算的各种架构,包括那些附带日志记录的架构,需要努力解决很多问题。
贯穿整本书而实现的示例查询之一,适合作为全增量和Lambda架构的一个很好的对比。关于这个查询没有任何矫揉造作的地方——事实上,它是基于我们职业生涯中多次面临的现实生活中的问题的。该查询是处理网页浏览分析,并且完成对传入的两类数据的查询:
■ 页面访问数据,包括用户ID,URL和时间戳,
■ 等价数据,其中包含两个用户ID。一份等价数据表明两个用户ID是指同一个人。例如,你可能在电子邮箱sally@gmail.com和用户名sally之间有一份等价数据。如果sally@gmail.com也注册了用户名sally2,那么你在sally@gmail.com和sally2之间有一份等价数据。通过传递性,你会知道用户名sally和sally2指的是同一个人。
查询的目的是计算一段时间内对一个URL来说独立访客的数量。查询应该将所有这段时间的数据加起来,并以最小的延迟来响应(少于100毫秒)。这是查询的接口:
long uniquesOverTime(String url, int startHour, int endHour)
使得这个查询实现起来有些棘手的就是那些等价数据。如果在一个时间范围内,一个人用两个用户ID访问了相同的URL,通过等价数据的连接(甚至传递),这应该只算一次访问。一个新的等价数据的传入,可以改变任何URL在任何时间范围内任何查询的结果。
在这一点上,我们将不去展示解决方案的细节,因为必须提及非常多的概念才能理解它们:索引,分布式数据库,批处理,HyperLogLog以及其他更多的概念。此时让你淹没在这些概念中往往会适得其反。相反,我们将专注于解决方案的特征和它们之间的显著差异。最佳完全增量解决方案将在第10章详细讨论,Lambda 架构解决方案在第8,9,14和15章中逐渐讨论。
两种解决方案可以在三个轴上进行对比:准确性,延迟和吞吐量。Lambda 架构解决方案在各方面都表现得更好。这两种方案都必须实现近似值,但全增量版本被迫使用具有3-5倍甚至更糟糕的错误率的劣质近似技术。在全增量版本中执行查询更昂贵,并且会影响延迟和吞吐量。但这两种方案之间最显著的区别是,全增量版本需要使用特殊的硬件来实现接近合理的吞吐量。因为全增量版本必须做许多随机访问查找来解决查询问题,它实际上需要使用固态硬盘,以防止磁盘寻道时间成为瓶颈。
Lambda架构可以生成在每个方面都具有更高性能的解决方案,同时也避免了困扰全增量架构的复杂性,展示了正在发生的非常根本性的事情。关键是挣脱全增量计算的束缚,采用不同的技术。现在让我们看看如何做到这一点。