Kudu:在快速修改数据上的快速分析存储系统----论文摘要

目录

Kudu高层设计思想

Kudu事务模型

Kudu整体架构

集群角色

数据分区partitioning说明

数据复制

KuduMaster说明

Tablet存储

MemRowSet的实现

DiskRowSet的实现

数据插入路径(insert path)

数据读取路径(read path)

Delta数据compation

RowSet compaction

调度维护(scheduling maintenance)

与Hadoop的集成

性能评估

与parquet的比较

与phoenix的比较

随机访问的性能


Kudu高层设计思想

从一个用户的角度看来,kudu是一个对结构化数据表的存储系统。一个kudu集群可以包含任意数量的表,每个表都有一个定义良好的有明确列数的schema。每一列都有名字、类型及可选的是否可为null值。一些固定顺序的列被指定为primary key。主键强制了一个唯一约束,并作为可高效更新或删除的主索引。该模型比较类似于关系型数据库,而与很多其他分布式数据存储如Cassandre, MongoDB, BigTable等不同(关于有明确的列定义这点),而spanner与metastore可认为是半结构化表。

既然是关系型数据库,用户需要在创建表时定义schema。用户可以通过alter table命令来添加/删除列,但是primary key列不允许删除。我们决定使用明确的列定义而非Nosql类型的“所有一切都是bytes”的原因是:

  • 明确的类型定义允许我们使用类型相关的列编码(更好的列编码)
  • 明确的类型定义允许我们对外暴露SQL-like方式的元数据给传统的应用

不像很多关系型数据库,kudu现在还不提供次级索引或唯一约束(除PK之外的)。当前,kudu要求每一个table都有一个主键定义,虽然我们已经预计在未来某个版本提供自动生成的替代主键。

创建表之后,用户通过insert, update和delete apis来修改表。在所有操作场景下,用户都必须完全指定一个PK——基于谓词的删除或更新必须由一个更高层级的访问机制来实现。

当前而言,kudu并不支持任何多行事务的apis:虽然和其他的修改操作批量处理以提升性能,但是每一行的修改都视自己是一个单独的任务。kudu只保证在单个row内的修改,即使涉及不同的columns,也总是一个原子性的行为。

kudu对于从一个表中获取数据,只提供了scan操作。用户可以指定任何数量的谓词过滤来过滤results。当前,我们仅提供了两类的谓词:一个column和一个常量的比较;以及组合出来的主键范围(与relationDB的设计思路很相似)。另外,除了谓词过滤,用户也可以指定一个scan时的project(映射列)。由于kudu的磁盘上存储格式为列存,因此在分析场景中通过列剪裁能大大降低负载。

除了数据接口,kudu clients还提供了其他有用的接口。Hadoop生态从数据本地性调度上获益颇多。kudu也提供了使调用者用于决定数据范围到对应服务器的映射,用于帮助分布式执行框架如Spark, MapReduce或者Impala的任务调度。

Kudu事务模型

kudu提供给其client两种一致性模型:

  • 默认的一致性模型:快照一致性。一个扫描(scan)会保证创建一个不会造成任何违背因果律的快照出来
  • 由此,它同样保证了从一个client而言,read-your-writes(读你自己的写)一致性

kudu默认不支持外部一致性。也就是说,如果一个client执行了一个写操作。然后通过一个外部机制与另一个client沟通,通知其也执行一个写操作,这个因果关系的依赖在kudu中是无法捕捉到的。一个第三者reader,在某一时刻可能会看到一个快照版本,其中包含了第二个写,但不包含第一个写。基于我们支持类似于HBase等系统的经验(HBase并不是分布式时间戳,应该很容易避免这个问题),在很多场景下这种一致性是足够使用的。

但是对于要求更加强力保障的用户。kudu提供了在clients之间手动传播时间戳的方式:在执行了一个write之后,用户可以要求client库来获得一个timestamp token,该token可以通过外部渠道传播给另一个客户端,接着传递给其kudu API,这样一来,就在两个clients之间保留了因果关系。

如果说传播tokens太过于复杂,那么kudu可选的使用commit-wait,就像spanner一样。在执行了一个write之后(打开commit-wait功能时),client可能会被延迟一段时间以确保任何后续的写会被正确的按因果关系排序(发生在本commit动作之后的写操作,一定生效在本写操作之后)。没有spanner那样牛逼的硬件黑科技支持,这种延迟提交策略会引入巨大的延迟(10-100ms,在默认的NTP中)。可以预见,在几年之内,就会有云提供商提供可靠的truetime服务(面向分布式true time设计)

kudu目前分配操作时间戳是基于HLC算法的分布式分配算法(因此可以等待云提供商提供true time api,从而支持外部一致性)

虽然kudu内部使用timestamp时间戳来实现并发控制,但是kudu并不允许用户在写入的时候手动的设置timestamp时间戳。这与Cassandre或HBase有所不同,它们将时间戳视为数据模型的“一等公民”。在我们经验看来,非常高级的用户可以通过时间戳维度来有效改善性能表现,但绝大部分用户只会迷惑混乱,尤其是涉及到对过去数据的插入与删除等语义时。我们在read时允许用户指定时间戳,允许用户对过去数据的快照读,但是写入时不允许指定时间戳。

Kudu整体架构

集群角色

追随着BigTable和GFS(以及其开源实现HBase和HDFS),kudu基于一个单点的Master Server,其负责维护metadata元数据;以及一组数目可变的Tablet Server,其负责维护实际数据data。Master Server可以通过复制来容错,提供非常快速的失败转移。所有的角色都可运行于普通商用硬件中,Master节点不用额外的要求。

就像在绝大多数分布式数据库系统中一样,kudu中的tables是水平切分的。像BigTable一样,kudu称这些水平切分为tablets(子表)。一行数据可以通过其PK被映射到一个确切的tablet中,如此以确保像插入和更新这样的随机访问只会影响到一个子表。对于吞吐量性能要求很高的非常巨大的表,我们建设的规则是每台机器10-100个子表(tablets),每个子表可以有数十GB(例如32GB,每32MB一个DiskRowSet,则每个tablet会有1000个DiskRowSets,单个节点上维护10W级别的DiskRowSet数量)。

数据分区partitioning说明

不像BigTable,只提供了key-range的分区;也不像Cassandre,几乎仅仅按哈希分布,kudu支持一个复杂的分区schema组。当创建一个table时,用户可以指定一个分区的schema定义。该分区的schema表现的像一个将primary key映射为一个二进制的分区key的function函数。每一个tablet包含了这些partition keys的一个连续的范围(实际分布式按照partition keys做range顺序分布)。如此一来,一个client,当执行一个读或写,可以简单的决定哪个tablet应该hold这个给定的key并路由过去。

  • 一个hash分区规则包含了主键字段的一个子集以及一个分桶的数(bucket number)。例如,在我们自己的SQL方言中,
DISTRIBUTE BY HASH(hostname, ts) INTO 16 BUCKETS

这样的规则将元组转化为二进制的key,通过首先将这些指定的字段拼接起来,然后计算一个hash code,将其modulo一下指定的桶数。如此一来就将其分入不同的桶中了,该桶号会以大端字节序编码到32位的字节数组中,放入partition key中。

  • 一个range分区规则包含了主键列的一个有序的子集,该规则将元组映射为一个二进制的string,通过将指定的列的值以一种保留顺序的编码方式拼接起来。(保留顺序这一点很重要,意味着无论hash分区规则怎么划分,最后处于同一个tablet中的数据,都是按照range规则中指定的列的值有序排列)

通过利用这些分区规则,用户可以轻松的在并行查询和并发查询之间做一个权衡,基于它们的特定的负载。例如,考虑一个时间序的应用;其保存格式为(host, metric,time, value),插入几乎总是伴随着单调递增的时间戳。选择使用time作为hash分区最好的分布了插入的负载(在所有的servers之上);然而,查询一个指定host上的指定metric(在一个非常短的时间范围内),则需要scan所有的子表,降低了并发性(也就是多个查询语句并发执行的能力)。一个用户可以选择使用timestamp做range分区,同时添加额外的哈希分区规则,例如在hostname和metric上,这样会提供一个好的权衡,在并行写入和并发查询上。Hash分区信息放在前面,Range分区信息放在后面(其思路跟我们的hash之上加partition是相似的,在具体的shard分片内部再做partition或排序)(只不过kudu的hash分区与range分区都可指定为PK列的一部分)

数据复制

kudu将所有的table data在多台机器上面复制。当创建一个表时,用户指定一个复制因子,一般为3或5,视应用的可用性SLAs。kudu的master努力确保尽量在所有时间之中维持要求的副本数。kudu实现了Raft一致性协议来复制其tablets。尤其是,kudu使用Raft来一致同意逻辑的操作日志(insert/update/delete增量数据),对于每一个tablet。当一个client希望执行一个write,它首先确定leader副本所在,然后向其发送一个写的RPC。如果写的信息不新了,该副本不再是leader,它会拒绝掉该请求,导致client会失效并刷新其metadata cache,然后重新send发送该请求到leader上。如果副本仍是leader,它借助于一个本地的lock管理器来将并发的操作串行化(其实对应的是cockroach中的LatchManager,而在atomix中借助了ThreadContext),选择一个MVCC时间戳,并通过Raft协议将该操作广播给其follower。如果大多数副本接受了该写操作并将其记录进它们本地的WAL,该写操作就被认为是持久化复制成功并且可以提交了。注意,没有限制说leader必须将一个operation写到自己的local WAL中之后才能commit该operation:这提供了很好的延迟平滑特性,即使leader的disk性能不佳。

当大多数副本失败时,leader可以继续提议operation并提交操作。ps: atomix是leader先持久化,再发送给followers(发送时再从磁盘读出来),若加上leader自己有过半节点持久化成功,则commit并应用apply(在应用的时候再次从磁盘读出来一遍)。而kudu认为无需leader先持久化,因此若复制失败,则该operation就失败了,并不存在重试一说,因此下一条operation操作可以继续执行(kudu支持的只有单条事务),这是放弃了一部分持久性而对性能优化的权衡。

如果一个leader自己失效了,Raft算法会很快的选举一个新的leader出来。默认的,kudu使用一个500ms的心跳时间和1500ms的选举超时时间(均为atomix的2倍)。如此一来,当一个leader失效,新的leader会在几秒钟之内被选举出来。

Kudu对Raft算法做了一些小的改进,特别是:

  • 当选举失败时,使用了一个指数级回退算法,开启下一轮选举
  • 当一个新leader与一个其log日志跟自己分裂的follower沟通时,Raft建议每次一条操作日志的向后回退对比,直到发现它们在哪里分裂的。而kudu会直接跳回到上次知道的committedIndex,该committedIndex总是保证会在任何分裂的follower中存在。如此一来避免了很多不必要的operations操作。我们发现这样更易于实现,并且确保分裂的操作在1个round-trip中被丢弃了。(倾向于丢弃未提交的操作,而不是恢复提交它们)

Kudu并不复制on-disk的tablet存储文件,而是仅仅复制其operation log(这就是其tablet可以很大(几十G)的原因)。每个tablet的副本的物理存储是完全解耦的,这提供了若干优势:

  • 当一个副本正在执行物理层面的后台操作,例如flushes或compactions时,其他节点上的相同的tablet不太可能同时执行同样的操作。由于Raft可能在收到大多数副本的通知后执行commit,这降低了这种物理层面的操作对client端写所经历的尾部延迟(少数较慢的节点不会影响到响应时间)。未来,我们预期可以实现附件16提及的推测读请求,来进一步降低在并发的读/写负载中的读操作尾延迟。
  • 在开发时,我们发现有些罕见的场景发生在kudu tablet的物理存储层。由于各副本在物理层之间是解耦的,因此没有一种上述场景导致了不可恢复的数据丢失:在所有cases中,我们都能够检测出一个副本变坏了(或者偷偷摸摸的和大多数副本分离了),然后修复它。

Kudu实现了Raft配置修改,遵循了附件24建议的one-by-one算法。在该方法中,Raft配置的选举人数可以在每次configuration change中改变一个。如果想要将一个3副本配置变为5副本,两个单独的配置修改(3-4, 4-5)必须被传播并提交。

Kudu实现了新server的添加,通过一个叫做remote bootstrap的过程。在我们的设计当中,为了添加一个新的副本,我们首先将其作为一个新成员添加到Raft Configuration中,甚至早于通知目标Server从当前leader上拉一个对应的tablet data的snapshot及log。当数据的transfer完成,新server便打开该tablet并遵循一个server重启后的相同过程处理。当tablet打开了tablet data然后回放了必要的WAL,它便完全的复制了leader的状态,然后可以开始作为一个正常工作的副本提供Raft RPC的响应了。

在我们当前的实现中,新server会直接作为一个voter副本(也就是直接作为有效投票成员加入的,此处确实有些不妥,应该先不属于一个active状态的对象)。这种实现有其不利的方面:从一个3副本配置变为一个4副本配置时,过半节点数就变成了3个,因此3个副本必须同时复制了operation才可以达到过半节点确认。而此时由于新的server正处于copying数据的过程中,它是不可能响应这些操作的。因此导致了在此期间,不能有任何一个节点崩溃,若有一个节点无响应,则该tablet就变得不可以写入了,直到remote bootstrap阶段完成为止。

为了解决该问题,我们计划实现一个PRE_VOTE副本状态(在atomix中已经实现了,就是promotable状态)。在该状态下,leader会将Raft updates发送并在该节点上触发remote Bootstrap过程,但是并不将该节点计算为一个投票节点。在监测到PRE_VOTE状态下的副本完全的追上了当前leader的logs,leader会自动的广播并提交另外一次配置修改,将该新加入的副本变为一个完全的VOTER副本。

当从一个tablet中删除副本时,我们遵循了相似的方法:当前的Raft leader广播一个操作以修改配置,发送给那些不包含被移除节点的节点。如果该operation被提交committed,那么剩余的节点将不再发送消息给被移除的节点了。当Configuration change被committed,剩余的节点会将该configuration change报告给Master,而Master有责任将成为孤儿的副本从元数据中清理掉(并通知实际节点清理相应的数据副本)。

上述的添加节点的Server和删除节点的Server,都是一个RaftServer,也就是针对某个复制组RaftGroup的一个Raft节点,而非实际的物理节点。

KuduMaster说明

KuduMaster是kudu的中心管理节点,负责如下几个核心职责:

  • 是一个catalog的管理者(数据库元数据管理者),追踪哪些表和子表仍然存在、同样维护它们的schemas,偏爱的复制级别,及其他元数据。当表被创建、修改,或删除,Master负责协调这些操作,在所有的tablets子表中,确保它们最终的完成。
  • 是一个集群协调者,保持追踪集群中的哪些Servers仍存活,并协调当某些Server失效之后的数据重分布。
  • 是一个tablet directory,其中保存了每一个tabletServer维护了哪些tablet的副本,或者反过来,每一个tablet的副本被哪些tableServer维护着。我们选择了一个中心化的,基于复制的Master设计,而非一个完全P2P的无中心化设计,主要是在实现上,调试上以及操作上更加简单。

Master自己维护了一个单tablet的表,该表不允许用户访问。Master内部将catalog的消息发送给该tablet,同时对其全量信息在内存中维护。鉴于现在的商业硬件的大内存,以及每个tablet所需要的小元数据,我们不认为这种设计会成为一个影响扩展的因素。假如真的出现了该问题,那么久不把所有数据放在内存,而是使用一个基于页paged的缓存便可以了。

Catalog table(也就是Master自己内部维护的表),里面维护了系统中每一个表的状态(数量较少)。特别地,它保存了table schema定义的当前版本,table的状态(creating, running, deleting, etc),并维护了每个表的tablets集合(该数据量较大)。当Master收到一个建表请求,其会首先往catalog table中写一条table记录并设置为creating状态。接着异步地,它选择tablet server来维护该表对应的tablet的副本,创建Master这边的tablet元数据信息,并异步地向tablet servers发送创建对应副本的请求。如果副本创建失败了或超时了,那么该tablet可以被安全的删除,一个新的tablet及对应的新的副本集合会被创建出来。如果master在该操作的中间部分失败,那么前面记下的table record会指示这个操作需要被继续执行,master可以在上次失败的地方重新开始。

其他的操作可以用相似的方法来处理,例如schema修改和删除,当master确认了该修改被广播给相关的table servers之后,将新状态写入自己的存储中。在所有的情况下,Master发送给table server的消息都被设计为幂等的,如此一来,在崩溃了及重启之后,可以安全的重新发送修改请求。

由于Catalog table自己也是持久化在一个kudu tablet中的,Master支持使用Raft来复制其持久化的状态到备用Master进程。当前,备Master仅仅作为Raft Followers,并不为client请求提供服务。当一个备Master通过Raft选举算法晋升为一个leader Master之后,其会扫描它自己的Catalog table,将其加载进自己的内存中,并开始作为一个active Master提供服务了,就像一个Master重启之后的行为一样。

每一个table servers在kudu cluster中都会静态地配置一组kudu Masters的host names。在启动时,该tablet server会将自己注册在Master上,并进一步将自己所维护的整个tablets的集合汇报给Master。第一个这样的table report报告包含了所有tablets的信息。后续的tablet report只包含增量信息而非全量信息,只包含从上次汇报之后最近创建的、删除的或修改的(例如执行了schema change或raft configuration change等)。

一个对于kudu而言至关重要的设计是,虽然master是catalog information的真正信息源(非缓存),但它仅仅是动态集群的观察者。Tablet Server自己负责tablet副本位置、tablet对应的Raft复制组的Raft configuration、当前的tablet version等等信息的维护。由于tablet副本在对于状态修改上通过Raft达成一致,每一个这样的修改可以被映射为一个特定的Raft操作index,表明该修改在这个index上被提交的。这就允许了Master确保所有tablets状态的修改是幂等的以及有弹性的(对于传输延迟而言):Master仅需要简单的对比一个tablet状态的raft操作的index,如果index大于Master的current view则修改它,否则抛弃它。(因此,某tablet状态修改的raft index成了该tablet的版本号的最佳扮演者,Master接受table server发送的tablet report,并维护其版本号信息)。

这个设计选择将更多的责任留给了tablet server自己。例如:不是由Master来监测tablet server的crash,kudu直接将该责任授权给了Raft leader副本,每一个在崩溃机器上的副本所对应的的Raft leader。Leader会持续追踪其成功与每一个follower沟通的最后时间,假如在一段较长时间之内无法与一个follower沟通,则它会将该follower声明为dead,并广播一个Raft configuration change来在本raft configuration中清理掉该follower(由raft leader自行发现、判断并清除(从选举节点中除名)某一个follower)。atomix中并不会出现这个场景,不会主动从选举节点中除名)。当该配置修改被成功committed之后,剩余的tablet servers会向Master汇报一个tablet report,告知Master该tablet对应的Raft leader作出的决定。Master会相应的更新自己维护的元数据。

为了维护想要的tablet的副本个数,Master就会选择一个tablet server来维护该tablet的一个新副本,基于Master自己对整个集群的全局视野。当选择了一个server后,Master会给该tablet的当前主副本发送一个建议,建议其在选定的tablet server上增加一个副本,然而Master自身无法改变tablet的configuration——它必须等待leader副本去传播并提交这个Raft复制组配置的修改操作,此时,Master才会被通知该配置的变化(通过tablet report)。如果Master的建议在执行时失败了(例如由于消息丢失等),它会周期性地顽固重试(选择一个tablet server并建议raft leader增加副本),直到成功为止。由于这些操作都标记了独一无二的index,因此它们是完全幂等及无冲突的,即使Master发起了若干冲突的建议。

对于tablet多出的副本数的处理是相似的:如果一个Master收到了tablet report报告,指出了一个副本被从一个tablet配置中删除了,它会持续并顽固的发送DeleteTable RPCs到对应的tablet server节点,直到该RPC执行成功。为了确保即使在Master崩溃后该清理工作同样会被执行,Master同样会在对tablet report的响应中将该RPC消息返回,指示出有一个tabletServer持有了不在最新提交的Raft configuration配置中的副本。

Tablet Directory,为了有效的读和写操作,而不用居中的网络跳转(从follower到leader),clients会直接向Master查询tablet位置信息。clients是“厚”客户端,并维护了本地的元数据缓存,包含了它们最近常用到的信息,包括它们访问的tablets,tablets的分区key范围及其Raft configuration配置。在任何时候,client的缓存都可能不新鲜,如果client尝试向一个不再是tablet主副本的server发送写操作,该server会拒绝这个请求。该client随后会联系master来获得该tablet当前最新leader的信息。当client收到一个网络通信错误,它同样假定它所认为的leader已不是leader了。

我们未来规划,在由非leader的副本返回的错误信息响应中附带当前raft配置信息,这样就可以避免一次额外的访问Master的消息往返开销。

由于Master直接在内存中维护了所有的tablet分区范围信息,因此它可以扩展到每秒很大数字的请求数(TPS),并在一个很低的响应延迟时间上(纯内存操作)。在一个270节点集群中,运行着一个数千子表的基准测试负载(kudu的子表可以大到数十GB),我们测量到99.99%延迟(提供tablet location)为3.2ms,95%延迟在374us,75%延迟在91us。如此一来,我们不觉得tablet directory lookup在当前的目标集群尺寸下会成为一个阻碍扩展的瓶颈。如果未来该功能真的成了一个瓶颈,我们发现提供不新鲜的位置信息其实是安全的,因此Master中这部分功能可以被简单的分区并复制到任意数量的机器上。

Tablet存储

在一个tablet server内部,每一个tablet副本运作的就像一个完全分离的实体。几乎完全的与前面介绍的分区与复制的体系无关。在kudu的开发过程中,我们发现将存储层开发成某种程度上与高层的分布式体系独立的,是很实用的。实际上很多我们的功能性和单元测试操作都是完全在tablet实现的内部范围中做的。

基于这种解耦,我们正在调研一个想法,就是提供一种针对每表/每子表甚至每子表副本选择不同的底层存储布局的能力——一种分布式的分裂镜的类似物。当然,我们当前只提供一种单个的存储布局。

tablet存储的实现达到了几个目标:

  • 快速的列式scan——为了提供可匹配像parquet和ORCFile这样的最佳的不可更改数据格式的分析性能表现,大部分的scans都需要被列式数据文件中有效编码的数据所服务,做到这点是非常重要的
  • 低延迟的随机更改——为了提供快速的访问来更改或读取随机的行,我们要求O(lgn)的查找时间复杂度
  • 一致性的性能表现——基于我们支持其他数据存储系统的经验,用户更愿意得到可预期的性能表现而非尖峰(但是波动较大)的性能表现

为了同时提供这些特征,kudu没有重用已经存在的存储引擎,而是实现了一个新的混合的列式存储结构。

子表tablets在kudu中又被分为了更小的单元,叫做RowSet。一些RowSet只存在于memory中,叫做MemRowSets,其他的则同时存在于内存和磁盘上,叫做DiskRowSets。任何给定的活跃的(未被删除的)行都只会存在于1个RowSet中;如此一来,RowSets格式分离了row集合。然而,需要注意的是不同RowSet的PK的区间是可以重叠的(也就是说,在一个tablet内部的RowSets之间,数据并非严格按PK排序的)

在任何一个时间点上,一个tablet都只有一个MemRowSet来接受最近插入的行。由于这些存储内容整个的处于内存中,一个后台线程会周期性的将MemRowSets刷新到disk上。后续会仔细详述如何调度这些刷盘任务。

当一个MemRowSet被选中要执行刷盘时,一个新的,空的MemRowSet就被切换来替代它。先前的MemRowSet被写进disk,变成了一个或多个DiskRowSets。该刷盘过程是完全并发的:读者可以继续访问老的MemRowSet,当它正在被刷入磁盘时,在老的MemRowSet中的记录上的更新和删除会被小心的记录下来,并在flush过程完成之后,执行roll toward应用。

MemRowSet的实现

MemRowSet被实现为一个内存中的乐观锁控制的并发B-Tree,大体上基于MassTree的设计,并有如下的修改:

  • 我们不支持从该树中删除元素。相反,我们使用MVCC记录来代替删除操作。MemRowSets最终会被flush到其他存储中,因此我们可以将这些records的删除延迟到其他部分做。
  • 同样的,我们不支持随意的in-place的更改tree中的记录。相反,我们只允许不修改value的size的修改:这允许了通过原子性的CAS操作来给一个每条记录都持有一个的linked list添加修改信息。
  • 我们将叶子节点通过一个next指针连起来,就像在B+树中做的。这改善了我们顺序scan时的性能,这是很重要的一个操作。
  • 我们并没有实现一个完全的“trie of trees”,而仅仅是一个tree,由于我们并不特别关注极端高的随机访问吞吐量。

为了优化随机访问时的scan性能表现,我们使用了稍微大一点的内部及叶子节点,其尺寸为4倍的cache-lines(256B,cache-line大小为64B)。

不像kudu中的绝大部分数据,MemRowSet以一种按行的布局来保存行数据。这仍能提供可接受的性能表现,由于数据总是在memory中。即使选择了行存储,但是为了最大化吞吐量性能,我们利用了SSE2(单指令多数据流技术)内存预取指令来预取一个叶子节点,在我们的scanner之前;以及使用LLVM来实现的JIT-Compile记录投影(projection)。这些优化相较于原生的实现提供了巨大的性能增长。

为了构建要插入B-Tree中的key值,我们将每一行的pk值按照一个前面介绍过的保留顺序的方式进行编码。这允许了有效的遍历树,仅仅使用memcmp操作来比较,MemRowSet排过序的特性允许有效的对主键范围的scans(配合next指针)或单独的查找的支持。

DiskRowSet的实现

当MemRowSets刷到disk中,它们就变成了DiskRowSets。当在flush一个MemRowSet时,我们在每写32MB之后卷曲(roll up)该DiskRowSet。这保证了没有一个DiskRowSet会非常巨大,这样一来就允许了有效的增量压缩(incremental compaction)。由于一个MemRowSet整体是处于排序状态的,因此被刷到磁盘上的多个DistRowSets之间及自身也是处于有序状态的,单次flush中的每一个卷曲的segment会有一个不相交的间隔(在PK上)[单个DiskRowSet内部是有序的,其中由相同的MemRowSet分隔的多个DiskRowSets是有序分段的,但是不同MemRowSet刷入的多个DiskRowSets之间PK区间是相互重叠的]。

一个DiskRowSet是由两种主要的组件构成的:基线数据和delta数据。基线数据是一个队DiskRowSet中存在的行数据的按列组织的描述。每列被单独写到磁盘上一块连续的区域中。列数据自己又会被分为更小的页(pages,HBase中同样会分为块block),用来支持更细粒度的随机读,以及一个内置的B-tree index以允许有效的查询每一页(页/块级索引),基于其在一个rowset中的序号偏移。列页(column pages)可使用多种编码(列页是压缩与编码的基本单位),例如字段编码,bitshuffle,或front coding,并且可选的使用通用的二进制压缩算法如lz4, gzip或bzip2等来执行压缩。这些编码和压缩选项可以由用户明确的在一个每列(per column basis)指定,例如指定一个巨大的不经常使用的text列要通过gzip压缩,同时一个保存小的integer的列应该被按位填充。一些kudu支持的页格式都在parquet中有支持,我们的实现也和Impala的parquet库共享了很多代码。

除了将每一个用户指定的列刷到磁盘上,我们同时还会写入一个主键索引列,该索引列保存了每一行的编码的主键值。我们同样将一个用来基于编码的primary key来检查该行是否可能存在的Bloom filter刷到磁盘中(如此一来,Bloom filter的粒度为DiskRowSet,一般涉及的数据大小为32MB左右)。

由于列式编码很难in place的就地进行修改(update),基线数据一旦输入盘中,就被认为是不可变的。另外,更新和删除通过称为delta存储的数据结构来追踪记录。Delta存储要么是内存中的DeltaMemStores,要么是磁盘上的DeltaFiles。一个DeltaMemStore是一个并发的B-Tree,该并发B-Tree和上面介绍的MemRowSet结构一样。一个DeltaFile是一个二进制的列块。在两种格式下,delta存储都维护了一个(row_offset, timestamp)元组到RowChangeList记录的一个映射。row offset是一个row在RowSet中的简单的序号————例如,在RowSet中的最小的primary key的序号为0。时间戳timestamp是操作最初被写入时被分配的一个MVCC时间戳。RowChangeList是一个二进制编码的对某一行修改的一个列表,例如,指示SET column id 3 = 'foo'  or DELETE

当要服务一个对于DiskRowSet的数据更新,我们首先咨询主键索引列。通过使用其内置的B-tree索引,我们可以快速的找到包含该目标行的page页。然后使用page-level的元数据描述,我们可以确定本page页中第一个cell在整个RowSet中的偏移。通过在该page中执行查找(例如通过内存中的二分查找),我们可以计算出目标行在整个DiskRowSet中的偏移。一旦确定了该offset偏移,我们插入一个delta记录到该RowSet的DeltaMemStore中(记下(offset, timetamp)对应的RowChangeList)[spanner会将timetamp编码到value中而非附加到key中]

Delta数据的刷盘:由于DeltaMemStore是一个内存中的store,它有有限的容量。调度MemRowSets刷盘的后台进程也同样用于调度DeltaMemStores刷盘。当刷盘一个DeltaMemStore时,一个新的空的store就被切换过来,此时老的DeltaMemStore就写入disk中成为一个DeltaFile。一个DeltaFile是一个简单的二进制列文件,包含了先前存在于内存中的数据的不可更改的拷贝。

数据插入路径(insert path)

如前所述,每一个tablet都有一个单个的MemRowSet用来保存最近插入的数据;然而,仅仅将inserts直接的插入到当前的MemRowSet中是不够的,既然kudu强制要求了主键唯一约束。换句话说,不像很多NoSQL存储一样,kudu将insert和update/delete分开处理。(在kudu中sstable基线数据文件中只包含唯一的PK值记录,其多版本MVCC是通过delta文件来支持的)

为了强制主键唯一约束,kudu必须咨询所有已存在的DistRowSets,然后才可以插入一行数据。由于每个tablet可能有成千上万的DistRowSets(一个tablet可以大到数十GB,而一个DiskRowSet大约在32MB时卷曲生成,因此可以包含上千级别),因此该咨询可以被高效的完成就变得很重要,同时通过减少需要咨询的DiskRowSets,以及对于一个DiskRowSet的查询更加高效来达到目的。

为了减少insert时要咨询的DiskRowSets集合,每一个DiskRowSet都保存了一个Bloom filter。由于新的keys永远也不会插入到一个已存在的DiskRowSet中,该Bloom filter会是一个静态的数据。我们将Bloom filter切分成大小为4KB的页(pages),每一页对应了一个小范围的keys,使用一个不可变的B-tree来将这些pages索引起来。这些pages连同它们的索引index会通过Server级别的LRU页缓存来维护,确保大部分的Bloom filter访问不需要物理磁盘的访问。

另外,对于每一个DiskRowSet,我们保存了最小和最大的primary key,并使用这些key范围区间来在一个间隔树(interval tree)中索引DiskRowSets(该索引是在tablet中维护的)。这更加的减少了查询给定的key时要咨询的DiskRowSets数量。在后面要讲述的后台压缩进程中,会重组DiskRowSets来改善基于key间隔树的DiskRowSets的过滤效率。(使用间隔树维护一个tablet中的多个区间可能相互重叠的RowSets)

对于任何一个不被过滤掉的DiskRowSets,我们必须在其中查找是否存在要被插入的key值。这是通过内置的PK列的B-Tree索引完成的,其确保了在最坏情况下的一个(logn)次的磁盘读取(每次读取一个page, 4K-64K)。再一次,该数据访问是通过页缓存(操作系统的)来执行的,确保对于key space的热点区域,不需要物理磁盘的查找。

bloom过滤器,在错误率不大于E的情况下,m至少要等于n*log2(1/E)才能表示任意n个元素的集合。但m还应该更大些,要保证数组里至少一半为0,则m至少要等于n*log2(1/E)*log2(e) = n*log2(1/E)*1.44。因此,假如错误率不大于0.1%,则计算值m=15*n;假如错误率不大于1%,则计算值m=10*n。而k(哈希函数的个数)取值为ln2*(m/n)时错误率最小,因此在6-7个。

数据读取路径(read path)

kudu的read path总是以批量多行的形式操作,为了摊还方法调用成本,并提供更好的机会来做循环展开及单指令多数据流等优化。kudu的内存中批量格式包含了一个顶级的(top-level)结构,其中包含指向要读取的每一列的小块(blocks)的指针。如此一来,批量结果自己就是在内存中的一个列式结构,这避免了在将结果从列式存储的磁盘store拷贝到结果batch中的偏移计算。(类似presto的Page和block的内存中列式数据结构)

当从一个DiskRowSet中读取数据时,kudu首先确定是否该scan上的一个谓词可以被用来减少本DiskRowSet中的行的范围。例如,如果该scan设置了一个PK的最低边界,我们在PK列执行一个查找以决定row偏移的最低边界(每个DiskRowSet内部行是按照PK有序的);同样对于最高边界的key值也做同样的事。这将key范围的谓词转换成了一个行偏移范围的谓词,该谓词只需要执行并不昂贵的string比较,因此更容易完成。

接着,kudu每次一列的执行该scan操作。首先,它在目标列中找到正确的row偏移(offset)(如果没有谓词提供的话就是0;或者如果先前提供了一个最低边界,就设置为start row)。接着,它将从源列数据中过滤出的数据拷贝到我们的row batch(结果)中,使用页编码指定的decoder。最终,它基于当前scan的MVCC快照咨询delta存储来看是否有任何后续的更新使用了新的version来代替对应的列值,如有需要则将这些修改应用到我们的内存batch结果中。由于deltas数据基于数字的行offset偏移,而非primary key,因此该delta应用过程就会额外的高效:它不需要任何的逐行分支判断(branching)或昂贵的string比较。

在投影阶段对每行数据执行了上述操作流程之后,它返回batch results结果,该结果很有可能被拷贝到一个RPC响应中并返回给client端。Tablet Server维护了有状态的iterators(在Server端针对每一个Scanner维护的),因此执行成功的请求不需要重新查找(re-seek),而是可以从每一个列文件中先前的点继续执行。

如果谓词被指定给scanner了,那么我们会执行对于列数据的延迟物化。特别地,我们选择先读取与range谓词相关联的列,然后才是其他的列。在读完每一个这样的与range谓词相关的列之后,我们评估其关联的谓词。当谓词过滤掉了该batch中的所有行,那么我们就直接忽略读取其他列。这就提供了一个巨大的速度提升(当使用有选择性的谓词时),因为其他列的绝大部分数据不会从disk中读入。

Delta数据compation

由于deltas数据不是使用一个列式格式进行存储的,对于一个tablet的扫描速度会随着越多的deltas数据被附加应用到基线数据上而越慢。因此,kudu的后台维护manager会周期性的扫描DiskRowSets来查找是否有积累了大量deltas数据的情况(通过基线数据行数与delta数据行数的比率来标记),若有的话则调度一个delta压缩操作,将deltas数据merge合并入基线数据中。(这其中的选择,就涉及到一个性价比的问题,如何达到平衡可控的开销与性能,有点类似于GC中的G1)

RowSet compaction

除了将delta数据compact到基线数据,kudu同样会周期性的将不同的DiskRowSets compact到一起,在一个叫做RowSet compaction的过程中。该过程执行一个基于key的对两个或多个DiskRowSet的合并,生成一个排好序的输出行的数据流。该输出会写出到一个新的DiskRowSets中,同样会在每32MB时执行rolling卷曲,以确保没有DiskRowSet会变得很大。RowSet的压缩由两个目标:

  • 有机会清理掉已经被删除的行数据
  • 该过程减少了在key range上有交叠的DiskRowSets的个数。通过减少有交叠的RowSets的数量,我们在执行一个随机选择的key的查询时就可以减少我们期望的可能会包含该key的RowSets的数量。当服务于一个在该tablet中的写操作时,该数量是执行bloom filter查找的上限值,因此也是预期disk查找的上限值。(首先在tablet维护的区间树上过滤,在过滤出来的RowSets上执行bloom filter,最后符合的才会执行加载及折半查找)

调度维护(scheduling maintenance)

如上所述,kudu有几个不同的后台维护操作,这些操作用来降低内存使用以及改善磁盘布局。这些操作通过一个维护线程的线程池来执行,这些线程运行在Table Server进程中。为了性能一致性(稳定性)的设计目标,这些线程是常驻的,一直在后台运行而非被某些特定的事件或条件触发。当维护操作完成之后,一个调度的过程会评估磁盘存储的状态,然后基于一组探索式的意图平衡内存使用、WAL保留,以及为未来的读和写操作做平衡的目的而选择下一步的操作。

为了选择DiskRowSets来压缩,维护调度者解决了一个优化问题:给定一个IO预选(一般为128MB),选择一组DiskRowSets,通过compact它们来减少期望的查找数。该优化算法原来就是广为人知的数字背包的问题,并且可以在几ms之内高效的解决掉。(如何选择最符合条件的DiskRowSet来压缩)

由于维护线程总是不断的运行小的工作,这些操作可以很快的起作用以更改工作负载的行为。例如,当插入的工作负载增加,调度器会很快的起反应并将in-memory的存储刷到磁盘上。当插入的负载降低了,Server会在后台执行压缩Compaction以增加未来写的性能(由于写时需要查询PK重复的行)。这提供了性能的平滑过渡,使得开发者和操作者可以更加简单的执行容量的规划,并估算他们的工作负载延迟的概况。

与Hadoop的集成

kudu是在Hadoop生态的上下文中构建的,我们优先处理了几个与其他Hadoop组件的核心功能集成。特别地,我们提供了对MR任务的构建,这些任务同时针对往kudu tables里输入或输出数据。这些构建可以被轻松的使用在Spark中。一个小的胶水层将kudu tables绑定到更高层次的Spark概念中,例如DataFrames和Spark SQL tables(其实就是其中的一个Relation或TableScan,通过SparkDataSource集成)。

这些绑定提供了对几个核心特征的原生支持:

* 本地化(locality)————内部地,输入格式查询kudu master进程来确定每一个tablet的当前位置,允许数据本地化管理。

* 列式的投影(projection)————输入格式提供一个简单的API,允许用户选择他们需要的列,如此一来可以最小化IO消耗。

* 谓词下推————输入格式提供了一个简单的API来指定要被Server端计算的谓词,计算之后才把结果rows返回给job。这种谓词的下推增加了性能表现,并可以被简单的通过高层接口例如SparkSQL来访问(SparkSQL通过SparkDataSource来支持谓词下推)

kudu同样深度的与Cloudera Impala进行集成。实际上,kudu没有提供自己的shell或SQL解析器:唯一的对SQL操作的支持就是通过它与Impala的集成。与Impala的集成包含了几个核心特征:

  • 本地化(locality)————Impala的planner使用kudu的Java API来检查tablet的位置信息,并将后端查询执行任务分发到保存该数据的相同节点上去。在典型的查询中,没有数据会通过网络由kudu传递到Impala(都是基于本机的进程间传输(kudu进程到Impala进程))。我们现在正在调研基于共享内存传输的进一步优化,让数据传输更有效率。
  • 谓词下推支持————Impala的planner已经修改的可以指定能够被下推到kudu的谓词了。在很多场景下,下推一个谓词允许IO的巨大减少,由于kudu的列延迟物化只有在指定了下推的谓词后才会启动。
  • DDL扩展————Impala的DDL语句例如create table等已经被扩展以支持指定kudu的分区模板、复制因子,以及主键定义等。
  • DML扩展————由于kudu是在Hadoop生态中第一个可修改的存储,且非常适合快速分析,Impala当前并不支持update和delete这样的可变语句,这些语句会专门为了kudu的tables而实现。
  • Impala的模块化结构允许一个查询透明的从不同的存储组件中join数据。例如,hdfs上的一个text格式的log文件可以被join到一个巨大的保存在kudu中的维表。(联邦查询,presto主打的特性)

性能评估

与parquet的比较

为了评估kudu在分析型工作负载上的性能,我们在一个75节点的集群上以换算系数为100来加载工业标准的TPC-H数据,每个节点有64GB内存,12块旋转磁盘,以及2*6核的志强(xeon)E5-2630L处理器,工作频率为2GHz。由于总内存远大过要查询的数据大小,因此所有查询操作统统基于缓存数据;然而,所有数据都被完全的持久化到DiskRowSet的列式存储中,而不是在memory stores中(并且完成了compact,也就是不存在delta datas,否则会被parquet爆出渣)。

我们使用Impala2.2来执行整个的TCP-H的22个查询,基于存储在parquet上与kudu上的相同的数据集。对于kudu表,我们将每个表根据其primary key哈希到256个bucket桶中。除了很小的国家及地区维度表,这些表每一个都存在一个子表tablet中。所有数据都使用create table as select语句进行加载。

在没有进行进一步深入的包括并发工作负载等的基准测试之前,我们对比了两个系统的每一个TPC-H查询的耗时。kudu平均比parquet快31%。我们相信kudu的性能优势基于如下两点:

  • 延迟物化————一些TPC-H的查询包含了一个在像lineitem这样的大表上的约束性的谓词。kudu支持延迟物化,避免了在谓词不匹配时消耗在其他列上的IO和CPU的开销。因为当前在Impala中的parquet并不支持该特性(Spark对于parquet做了大量优化,支持一定级别的谓词下推)
  • CPU效率————Impala中的Parquet读取器并未完全充分的优化,当前会调用很多的每行方法调用,这些分支判断限制了其CPU的效率。

我们预期到kudu相对于parquet的优势会随着Impala针对parquet实现的不断优化而削弱。另外,我们预期到parquet会在基于磁盘数据的工作负载上表现的更好,因为它使用了很大的8MB的IO访问,而kudu提供的是page级别(4KB-64KB)的IO访问。

由于kudu的性能表现相对于列存格式值的更进一步的调研,非常清楚地,kudu有能力达到与不可变存储相当的scan速度,同时提供可变的特征。

与phoenix的比较

另一个在Hadoop生态之上实现SQL的是Apache的phoenix。phoenix在HBase之上提供了一个SQL查询层。虽然phoenix主要目标不是分析型工作负载,我们还是执行少量的对比以展示kudu和HBase在扫描较重的分析型工作负载下的数量级上的性能差异。

为了减少扩展性导致的效果,并比较原始的扫描性能,我们在一个更小的集群上执行这些对比,包括了9个worker节点和1个master节点,每一个包括48GB的内存,3个数据磁盘,以及2*4核的志强L5630处理器,2.13GHz。我们使用phoenix4.3和HBase1.0。

在本次基准测试中,我们同样加载TPC-H的lineitem表(CSV格式下为62GB),使用提供的CsvBulkLoadTool MapReduce任务加载进phoenix中。我们使用100个哈希分区来配置phoenix表,并在kudu中创建相同数量的tablets。在kudu和phoenix中,我们都对非integer的数值列使用DOUBLE类型,由于kudu现在尚不支持DECIMAL类型。我们配置HBase使用默认的块缓存设置,导致了在每个Server上有9.6GB的堆上缓存。kudu只配置了1G的进程内的块缓存,而是依赖于操作系统的缓存来避免磁盘IO。我们使用默认的HBase table属性(由phoenix提供的):FAST_DIFF编码、没有压缩、每个Cell一个历史版本。在Impala中,我们使用一个每个query独立的选项来使得对于那些没有得到好处的queries的运行时的代码生成失效,以消除一个与存储引擎无关的常量的开销来源。

在加载数据完成后,我们执行明确的major压缩来确保100%的HDFS块本地化,并确保hbase table的regions(类似kudu的tablets)在9个workers节点上分布均匀。62GB的数据集在HBase中会扩展到570GB(在经过了复制之后),而在kudu中复制之后为227GB大小。

HBase的region servers和kudu的tablet servers都被分配了24GB的内存,我们在各自的集群中分别运行了两个service(为了这次基准测试)。我们确保在各自的workloads中没有产生磁盘的读取,以确保专注于测试CPU的效率,虽然我们预计在一个涉及到磁盘的工作负载中,kudu基于其列式布局以及更好的存储效率,会有更好的表现。

为了专注于scan的速度而非join的性能,我们只关注于TPC-H的Q1,该查询只读lineitem表。我们同时也运行几个其他的简单的queries。为了量化在Impala-Kudu系统与phoenix-HBase系统之间的性能差别(相同硬件下),我们对每个query执行10次并报告中间的执行时间。在所有的分析型queries之中,Impala-kudu比phoenix-HBase要快上16倍到187倍。对于主键范围很短的scan(小查询),Impala-kudu和phoenix-HBase都在亚秒级返回,phoenix胜出,因为其由更小的查询planning的常量开销。

随机访问的性能

虽然kudu并不是设计用来作为一个OLTP的存储,但其一个核心的设计目标是适用于轻量的随机访问的工作负载。为了评估kudu的随机访问性能,我们使用Yahoo CloudServing Benchmark,在与上面所述相同的集群上。我们从master分支上构建出YCSB,并添加了一个与kudu对比的绑定。对于这些基准测试,我们对kudu和HBase都配置使用24GB的内存。HBase自动的分配9.6GB的块缓存,剩余的堆内存用于内存存储(Memtable)。对于kudu,我们仅仅配置1GB的块缓存,选择依赖于Linux的buffer缓存,不再做其他调优。对于kudu和HBase,我们都将其分为100个子表或regions,并确保其平均的分布在集群节点中。

我们配置YCSB加载一个1亿行的数据集,每行有10个列,每列100字节(总100GB大小)。由于kudu并没有特殊的row key列,因此我们在kudu的schema中添加了一个明确的key值列。在这个基准测试中,数据集会整个的放置于RAM中;未来我们希望会做更深入的关于常驻闪存的或常驻磁盘的工作负载的基准测试,但是我们认为,由于不昂贵的RAM内存的容量不断增加,绝大部分延迟敏感的在线工作负载都会主要由内存来满足。

我们顺序的执行工作负载如下,首先将数据load进table中,接着执行从A到D的工作负载,每个任务之间没有停顿。每种工作负载执行1000万次操作。对于加载数据,我们使用16个客户端线程,并启动client端的buffering来发送大的批量数据到后端的存储引擎(如此才可能充分利用网络IO带宽,并考验存储引擎的吞吐量)。对于所有其他的workloads,我们使用64个客户端线程并关闭客户端的buffering。(增加并发度,但是不使批量请求,此时考验的是存储引擎的tps)

我们针对每个存储引擎执行该全部的序列两次,两次中间会将表数据删除并重新加载。在第二次执行期间,我们对于工作负载A-C使用了一个均匀分布(uniform accessdistribution)来代替默认的zipfian(幂等分布,如二八原则)。工作负载D使用的是一个特殊的分布,其中行会被随机插入,并随机读取那些最近被插入的行。

我们没有执行工作负载E,其执行的是小范围的scans,由于kudu client当前缺少指定返回结果的limit的能力。我们也没有执行工作负载F,由于它基于一个原子性的CAS基元,kudu尚未支持。当这些特征添加到kudu之后,我们计划同样执行这些工作负载的基准测试。

在随机访问的场景中,几乎在所有的工作负载中HBase都优过kudu。尤其是在幂等分布的更新负载中,CPU时间会花在应用长长的delta数据到基线数据之上。而HBase,一直以来就把目标确定在在线工作场景上,其在两种分布场景下性能都相当不错。由于准备时间不充分,并没有长时间执行工作负载的数据,也没有包括延迟百分比的信息。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值