英文原文 Elements of Scale: Composing and Scaling Data Platforms
作为软件工程师,不可避免地受到周围计算机工具的影响,语言、框架、甚至执行过程都会影响我们构建的软件。
数据库亦如此,基于一种特殊的方式,不可避免地影响到我们对应用程序中易变和共享状态的处理。
过去的十多年,我们采用不同的方式去探寻这个世界。采用不同理念的一小众开源项目,它们不断成长,你中有我,我中有你。平台集成了这些工具,每个控件通常都能提高某些基础硬件或者系统效能。结果是平台无法通过任何单一的工具解决某些问题,不是太过笨重,就是局限于某一特定部分。
因此当今数据平台多种多样,从简单的缓存层、多语言持久化层到整个集成数据管道,针对多种特定需求的多种解决方案。在某些方面,确实有不错的表现。
因此本对话的目的就是解释一些流行的方式方法如何发挥作用,为什么会有如此表现。我们先来考虑组成它们的基本元素,这样便于在后续的讨论中对这些认识通盘地考虑。
从某种抽象的角度看,当我们处理数据时,实际上就是对其进行局部性(locality)处理,局部性到CPU、局部性到我们需要的其它数据。有序地获取数据是其中很重要的部分,计算机很擅长序列化的操作,这些操作是可以预测的。
(译者注:局部性是计算机中一种预测行为,通过缓存、内存中预取指令、处理器管道分支预测等技术来提高性能;更多参见《操作系统精髓与设计原理》。)
若是有序地从硬盘中获取数据,数据会预获取存入硬盘缓存、页缓存、以及不同层级的CPU缓存中,这可以极大地提升性能。但这对随机数据寻址意义不大,这些数据存于主内存、硬盘或者网络中。实际上,预获取反倒会拉低随机负载能力:不论是各种缓存或是前端总线,充满了不太会被用到的数据。
硬盘通常被认为性能稍低,而主内存稍快些。这种认识不见得一直是对的,随机和有序主内存负载之间相差一两个数量级。用某种语言管理内存,事情往往会变得更加糟糕。
从硬盘有序获取的数据流性能确实好过随机寻址主内存,或许硬盘并不像我们想的那样跟乌龟似的,至少在有序获取的情况不会很慢。固态盘(SSD),特别是采用PCIe接口,正如它们显示不同的权衡,将事情复杂化。但采用这两种获取模式带来的缓存收益是不变的。
译者注:数据流就是大量连续到达的、潜在无限的有序数据序列,这些数据按顺序存取并被读取一次或有限次。
假设我们要创建一个简单的数据库,首先从基础部分文件开始。
保持有序读和写,文件在硬件上会表现地很好。我们可以将写入的数据放入文件的末尾,可以通过扫面整个文件进行数据读取。任何我们希望的处理过程可以随着数据流穿过CPU而成真,比如过滤,聚合、甚至做一些更复杂的操作,总之非常完美。
倘如数据发生诸如更新这样的变化会怎样?
我们有多个选择,在某个位置更新这个值。我们需要利用固定长度的字段,在我们浅显的思想实验中这是没有问题的。不过在某个位置更新数据意味着随机输入输出流(IO),这会影响性能。
替代的办法是将更新值放置在文件的末尾,在读取值时对过期的数据进行处理。
我们第一次做出权衡,将“日记”或者“日志”放在文件末尾,就能保证有序获取进而提高性能。另外倘若某处需要更新数据,可以实现每秒300次左右的读取,前提是更新数据刷入底层介质中。
实际上完整的读取文件是很慢的,获取十亿字节(GB)数据,最好的硬盘也需要花费数秒,这是一个数据库全表扫描所花费的时间。
我们时常只需要一些特定的数据,比如名为“bob”的客户,这时扫描整个文件就不妥当,我们需要一个索引。
我们可用许多不同类型的索引,最简单的一种是固定长度的有序数组,比如本例中的客户名,和对应的偏移量一起存放在一个堆文件中。有序数组可以进行二进制搜索查找。同样,我们可以用树结构、位图索引、哈希索引、字典索引等。这里是一个树的结构图。
索引就像是在数据中添加了一个总览结构,值是有序排放的,这样我们就能快速获取我们想要读取的数据。但总览结构有个问题,数据进来时需要随机写。因此理想的、写优化仅仅追加文件;考虑到写会打散文件系统,这会使一切变慢。
如果你将许多索引放入一个数据库表中,那你一定熟悉这个问题。假定我们使用的机械盘,用这种方式维护某个索引的硬盘完整性,速度大约慢1000倍。
幸运的是,这里有几种解决方案。这里我们讨论三种,它们都是一些极端地例子。在现实世界中,远没有这么复杂,但在考虑海量存储时这些概念会特别有用。
译者加:
- 第一种内存映射文件
- 第二种较小的索引集合,采用元索引或者布隆过滤算法(Bloom Filter)做一些优化
- 第三种简单匹配算法(brute force)又叫面向列(Column Oriented)
第一种方案是将索引放入主内存,随机写问题分隔到随机存储存储器(RAM),堆文件依旧在硬盘中。
这是一种简单但行之有效的方案,可以解决我们随机写的问题。这种方式在许多数据库中已得到应用,比如MongoDB、Cassandra、Riak、以及其他采用此优化类型的数据库,它们常常用到内存映射文件。
译者注:内存映射文件是虚拟内存单个分段,可以与文件或者类文件资源的某部分建立直接字节对字节的关联,即文件中的数据存放位置在内存中有对应的地址空间,这时对文件的读写可以直接用指针来做,而不需要read/write函数,处理大文件时可以显著提高输入输出流(IO)性能。
倘若数据量远超主内存,这种策略就失效了。特别是存在大量小的对象时,问题特别显眼;索引增长很大,最后存储越过了可用主内存的容量。多数情况下,这样做是没有问题的,但如果存在海量数据,这样做就会成为一种负担。
一种流行的方式抛开单个的“总览”索引,转而采用相对较小的索引集合。
这是一个简单的理念:数据进来,我们批量地将其写入主内存。一旦内存数据足够多,比如达到MB,我们就对它们进行排序,而后将它们作为单个小的索引写入硬盘中。最后得到的是一个小的、由不变索引文件组成的年表。
那么这样做的好处是?这些不变的文件集合被有序地流化处理,这样就能快速地写,最重要的是无需将整个索引加载入内存中。真棒!
当然它也有一个缺点,当读操作时需要询问非常多的小索引。我们将随机IO(RandomIO)写问题变为读问题。不过这确实一个很好的权衡策略,而且随机读比随机写更容易优化。
存储一个小的元索引(meta-index)在内存中或者采用布隆过滤算法(Bloom Filter),提供一种低内存方式,评估单个索引文件在读操作中是否需要被询问。即使保持快速地、有序化写操作,这种方式的读操作性能几乎可以和单个总览索引相媲美。
实际开发中,偶尔也需要清理孤子更新,但它有序读和写确实不错。
我们创建的这个结构称作日志结构合并树(Log Structured Merge Tree),这种存储方式在大数据工具中应用较大,如HBase、Cassandra、谷歌的BigTable等,它能用相对较小的内存开销平衡写、读性能。
将索引存储在内存中,或者利用诸如日志结构合并树(Log Structured Merge Tree)这样的写优化索引结构,绕开“随机写惩罚”(random-write penalty)。这是第三种方案为纯粹的简单匹配算法(Pure brute force)。
回到开始的文件例子,完整地读取它。如何处理文件中的数据,可以有许多选择。简单匹配算法(brute force)通过列而非行来存储数据,这种方法叫做面向列。
需要注意的是真实的列存储及其遵循的大表模式(Big Table pattern)之间存在一种不好的命名术语冲突。尽管它们有一些相似的地方,事实上它们是不同的,所以将它们视为不同的事情是一件明智的。
面向列是一种简单的理念,和行存储数据不同,通过列分割每一行,将数据追加到单个文件末尾。接着在每个单独的文件中存储每一列,一旦需要只需读取需要的列。
这样可以确保文件的含有相同的序列,即每个列文件的第N行含有相同的地址或者偏移量。这个很重要,在某一时刻读取多列,来服务一个单一的查询。意味着“连接(joining)”列速度飞快,倘若所有的列含有相同的序列,我们就能在一个紧凑的循环中这么做,此循环有很好缓存和CPU利用率。许多实现大量使用向量( vectorisation)进一步优化简单连接和过滤操作吞吐量。
写操作可以提高只在文件末尾追加( being append-only)性能。不利的地方是很多文件需要更新时,文件的每个列需要单独写入数据库。最常见的解决方案是采用类似日志结构合并(LSM)方式,进行批量化的写操作。许多列类型的数据库通过给表添加一个完整的序列来提升读的性能。
通过列分割数据可以极大地减少从硬盘中读取的数据量,只要查询操作在所有的列的子集中。
除此之外,单独列中的数据通常可以很好的压缩。可以利用列数据类型优势去压缩,特别是在我们熟悉列的数据类型时。这意味着我们能利用有效的、低成本的编码方式,比如行程长度编码、delta、位组合(bit-packed)等。对一些编码来说,谓词可以直接用来做压缩流。
一种简单匹配算法(brute force)特别适合大规模扫描操作,诸如平均值、最大值、最小值、分组等聚类函数就是这方面的典型。
这和先前提到的“堆文件和索引(‘heap file & index)”方式不同,很好的理解这一点可以问自己,诸如此类的列方式和每一个字段带有索引的“堆和索引”方式有什么不同?
问题的关键是索引文件序列:多路查找树(Btree)等会依据检索的字段排序,两次检索的数据连接一端涉及流操作,另一端第二个索引位置进行检索随机读取。平衡树总体上说效率低于包含两个相同序列索引列连接,我们再一次提高了序列化访问。
译者注:结论是平衡树连接性能不如两个相同序列索引列连接
我们都想将最好的技术作为数据平台控件,提升其中的某种核心功能,胜任一组特定的负载。
将索引存于内存而非堆文件为丛多非关系型数据库(NoSQL)所喜爱,比如Riak、Couchbase或者Mongodb,甚至一些关系型数据库,这种简单的模型效果不错。
设计用来处理海量数据集的工具乐意采用LSM方式,这样可以快速获取数据,得到基于硬盘结构 读一样好的性能。HBase、 Cassandra、RocksDB、 LevelDB 甚至Mongo现在也支持这种方式。
每个文件的列(Column-per-file)引擎常用于数据库大规模并行处理(MPP),比如Redshift或者Vertica,以及Hadoop stack中的Parquet。这些数据引擎最大的问题是需要大的遍历,聚合是这些工具最重要的特质。
诸如卡夫卡(Kafka)采用一个简单的、基于硬件的高效消息规范。消息可以简单地追加到文件的末尾,或者从预定的偏移量处读取。可以从某个偏移量读取消息,来来回回,你可以从上次结束的偏移量处读取。看得出是很不错的有序输入输出(IO)。
这和多数面向消息的中间件不同,JMS(Java消息服务)和AMQP(高级消息队列协议)说明文档需要额外的索引,来管理选择器和会话消息。这意味着它们结束某个行为的方式更像数据库,而非某个文件。著名的论述是1995年Jim Gray发表的队列就是数据库(Queue’s are Databases).
可见所有的方式都需要这样那样的权衡,作为一种分布式手段,使事情变得简单、硬件更加用户友好。
我们分析了存储引擎的一些核心方法,其实只是做了一些简要说明,现实世界这些是要复杂的多,不过概念确实是很有用的。分布式数据平台不仅仅是一个存储引擎,还需要考虑并行。
对于横跨多台计算机的分布式数据我们需要考虑两个核心点,分区(partition)和复制(replication)。分区有时指的是分库分表(sharding),在随机读取和简单匹配工作负载(brute force workloads)表现不俗。
如果是基于哈希的分区模型,借助哈希函数,数据就能均摊到一组机器上(译者注:理想的结果是这样的)。同哈希表工作方式相似,每个桶(bucket)盛放某个机器节点。
这样通过哈希函数,直接访问包含此数据的机器读取来数据。这是一种很经典的分布式模式,也是唯一一种随着客户端请求增加呈现线性分布的模式(译者注:简单点说就是均摊)。请求隔离到单台计算机上,由集群中的单台计算机为其服务。
利用分区提供并行批量计算,比如聚合函数或者诸如聚众或者机器学习的复杂算法。最大的不同是所有的计算机在同一时刻采用广播的方式,在很短的时间采用分治的策略解决大规模计算问题。
批量处理系统很好地处理大规模问题,但在执行过程中少有并发,容易耗尽集群资源。
两个极端且特别简单的方式:一端直接访问,另一端分治地进行广播。需要注意的是终端之间的中间地带,最好的例子就是非关系型数据库(NoSQL)中跨越多台计算机的二级索引。
二级索引有别于主键索引,这就意味着数据分区不再借助索引中的值。不再使用哈希函数直接分发,而是广播请求给所有的计算机。这会制约并发,任何一个节点与每一个请求都有关。
也是这个原因许多键值存储不愿采用二级索引,即使它的应用很广泛,Hbase和Voldemort就是如此。不过诸如MongoDb、Cassandra、Riak等数据库采用二级索引,不管咋说二级索引还是蛮有用的。但理解它们在整个系统并发的影响还是很重要的。
复制解决并发瓶颈,或许你熟悉备份,不论是异步到从服务器,还是复制到诸如Mongo或者Cassandra这样的NoSQL存储中。
实际上备份是不可见的(仅仅用于恢复)、只读(增加并发量)、或者读写(增加网络分区下的可用性),选择哪种方式需要从系统的一致性出发做出权衡。这是CAP(Consistency、Availability、Partition-Tolerance)理论的简单应用,当然CAP理论远非我们想象中的那么简单。
译者注:网络分区( network partitions)指某个网络设备出错导致网络分离,比如某个数据库挂掉。
权衡一致性给我们带来一个重要的问题,什么时候需要保证数据的一致性?
一致性的代价是昂贵,在数据库的世界里,原子性由线性化(linearisabilty)做保障,这样可以确保所有的操作有序排列.但代价也是昂贵的,实际上这完全是被禁止的,许多数据库并不将此作为一个独立(isolation)执行单元。鉴于此,很少将此设为默认值。
简而言之,你想分布式写的系统保持强一致性,系统会变慢。
注意一致性这个术语有两个应用场景,在原子性和CAP中,当然其意思是不同的。我通常采用CAP中的定义,对所有的节点而言数据在某一时刻是相同的。
解决一致性问题的方法其实很简单,就是避免它。如果无法避免,隔离它为其分配尽可能少的写操作和计算机资源。
避免一致性问题一般不难,特别是数据为不变的事实流时,网络日志集合就是一个很好的例子。无需关注一致性,因为这些日志作为事实是不会改变的。
需要一致性的用例,比如转账、使用优惠码这种非交换行为。
当然从传统的眼光看一些事情需要一致性,但实际上却也未必。比如一个行动从一个可变状态变成一个新的相关事实集合,就可以避免这种变化状态。通常是直接对新字段进行更新,考虑到标记一个事务存在潜在的欺诈,我们可以简单地利用某个事实流和原始的事务进行关联。
译者:好观点
在数据平台中移除所有一致性需求、或者隔离它都是很有用的。一种隔离方式是利用单个写原则,涉及几个方面,比如Datomic;另一种方式是拆分可变的和非可变的来隔离一致性需求。
诸如Bloom/CALM扩展了这些理念,支持默认状态下的无序概念,除非需要才做排序。因此我们有必要做一些基本的权衡,那我们如何利用这些特性去建立一个数据平台?
一个典型的应用架构或许应该是这样的:有一组处理将数据写入某个数据库,然后将其读出,对于许多简单的工作负载这是没有问题的,许多成功的应用都是基于此模式。但随着吞吐量的增加,此模式越来越难以适用;在应用领域这个问题或许可以通过消息传递、演员(actors)、负载均衡加以解决。
另外一个问题是这种方式将数据库作为一个黑盒,数据库是一个透明的软件。它们提供了海量的特征,但也提供了极少的原子拆分的机制。这样做有很多好处,默认状态下是安全的;但保护过度地扼杀我们的需求进而限制系统的分布式,这就很烦人。
命令查询职责分离(CQRS Command Query Responsibility Segregation)可以简单地解决此问题。
译注:
- 实现一Druid
- 实现二操作分析桥(Operational/Analytic Bridge)
- 实现三批量管道
- 实现四拉姆达框架(Lambda Architecture)
- 实现五卡帕(Kappa)框架又叫流数据平台
想法其实很简单,分离读写工作负载:最佳写入状态时写入,最切贴的例子比如某个简单日志文件;最佳读取状态时读取。有多种实现方式,比如用于关系型数据库的Goldengate工具、内部复制集成的诸如MongoDB的Replica Sets这样的产品。
许多数据库底层的行为就是这样,Druid是一个不错的例子,它是一个开源的、分布式、时序化、列式分析引擎。列式存储表现不俗,特别是大规模数据录入,数据必须分散到许多文件中。为了得到更好的写性能,Druid存储近期的新数据到某个最佳写入状态中,然后逐渐转移到最佳读取存储状态。
一旦查询Druid,请求就会同时派发到最佳写和最佳读控件中,对结果进行组合(移除冗余),返回给用户。Druid借助时间标记每条记录来进行排序。
诸如此类的组合方式提供了单个抽象下的CQRS好处。
另一种相似的方式是操作分析桥(Operational/Analytic Bridge),利用单个事件流拆分最佳读以及最佳写视图。流处在一种不断变化的状态,因此异步视图可以在随后的日子里被重写和增强。
前端提供了同步读和写,这么做即可以简单快速地读取已写入的数据,又可以支持复杂的原子事务。
后端采用异步、不变状态的优势来提高性能,比如借助复制、反范式化、甚至完全不同的存储引擎扩展线下处理。前后端之间的消息桥连方便应用通过平台去监听数据流。这种模型很适合中等规模的部署,可变视图至少存在一部分、不可避免的需求。
设计不变的状态,以便容易地去支持大规模数据集和更加复杂的分析。Hadoop栈中独一无二的实现——批量管道,就是一个典型的例子。
Hadoop栈最精彩的地方就是其丛多的工具,不管是快速读写访问、还是廉价地存储、抑或批量处理、高吞吐消息、或者提取、处理、分析数据,hadoop生态体系应有尽有。
批量管道从多种资源中获取数据,将其放入HDFS,接着对其进行处理,进而提供一个原始数据持续优化的版本。
数据可能得到富集、清理、反范式化、聚集、移到一个诸如Parquet的最佳读模式,或者加载进服务器层或者数据集市,处理之后的数据可以被检索和处理。
此框架适用于不变数据、以及对数据进行大规模获取和处理,比如100太字节(TBs)。此框架处理过程很缓慢,以小时为单位。
批量管道的问题是通常我们不想等几个小时去获取一个结果。常见的做法是添加一个流层,有时又叫拉姆达框架(Lambda Architecture)。
拉姆达框架保留了批量管线,不过增加了快速流层实现迂回,就像在忙乱的小镇架了一个支路,流层采用诸如Storm、Samza流处理工具。
拉姆达框架核心是我们最乐意快速粗略作答的,但我想在最后做一个精确的回答。
流层绕过了批量层,提供了最佳回答,它的核心就在流视图中。这些会写入一个服务器层。稍好批量管道计算出精确的数据并覆盖之前的值。
用响应来平衡精度是个不错的做法,两个分支在流和批量处理层都有编码,这种模式的一些实现是有问题的。解决办法,一是将此逻辑简单抽象到一个可复用的通用库中,比如处理都写入了诸如Python、R语言这样的外源库中。二是诸如Spark这样的系统同时提供了流和批量处理功能,当然spark中的流只是少量的批处理。
因此这种模式适合比如100TB的海量数据平台,将流和已存、富集的、批量分析函数结合起来。
另外一种解决慢数据管道的方式,称之为卡帕(Kappa)框架。起初我以为这个架构名称不对,现在我不太确定。不管它是什么,我叫它流数据平台,其实这个已经有人这么叫了。
流数据平台相对批量模式更有优势:与将数据存储在HDFS中划分给新的批量任务不同,数据分散存储在消息系统或者诸如kafka日志中。批处理就变成了记录系统,数据流经过实时处理生成三层结构:视图、索引、服务或者数据集市。
与拉姆达(lambda)框架的流层相似,不一样的是没有批处理层。显然这就要求消息层能够存储、供应海量数据,并且具有强大有效的流处理器来处理此过程。
天下没有免费的午餐,问题很棘手,流数据平台运行速度并没有同等批量处理系统快多少。但将默认的方法“存储和处理”切换为“流和处理”,可以极大地提高快速获取结果的可能性。
流数据平台方式还可以用来解决“应用集成”问题,应用集成这个棘手的问题困惑Informatica、Tibco和Oracle等大的供应商好多年了。对许多数据库而言是有益的,但不是一种变革性方案。应用集成至今停留在找寻切实可行方案的话题上。
流数据平台提供了一个潜在的解决方案:利用操作分析桥的丛多优势—多种异步存储格式以及重新创建视图的能力—但这会增加已有资源中一致性需求:
系统记录变为日志,易于增强数据的不变性。诸如Kafka等产品内部保留了足够的数据量和吞吐量,将其作为历史记录来用。这就意味着回复是一个重演、重新生成状态的处理过程,而非常态化地检验。
相似的方式很在就有应用,早于最新出现的数据湖或者Goldengate等工具,后者将数据放入企业级数据仓库。复制层缺乏吞吐量和管理复杂的schema变化使此方法大打折扣。看似最后第一个问题已经解决,但作为最后一个问题,还没有定论。
~
回到局部性,读和写按序寻址,是控件内部最需要权衡的部分。我们观看了如何拓展这些控件,提高了分库分表和复制最基本的性能。重新审视一致性将其作为一个问题,在构建平台时隔离它。
不过数据平台本身需要用单一、全局的方式来平衡这些控件达到最佳状态。不断重建,从最佳写状态迁移到最佳读状态,从一致性约束转移到流、异步、不变状态的开放地带。
需要记住几件事,一是schema,二是时间、分布式、异步系统风险。但这些问题都是可控的,前提是你认真对待。未来大数据领域可能会出现这样一些新的工具、革新,逐渐掺入到平台中,解决过去和现在更多的问题。
译者注:schema 指数据库完整性约束。