003 Kudu | Tablet设计思想

翻译来自https://github.com/cloudera/kudu/blob/master/docs/design-docs/tablet.md和网友的翻译以及个人的理解。

基础概念

Tablet是kudu表的水平分区,类似于google Bigtable的tablet,或者HBase的region。每个tablet存储着一定连续range的数据(key),且tablet两两间的range不会重叠。一张表的所有tablet包含了这张表的所有key空间。

Tablet由RowSet组成,RowSet由一组rows组成(n条数据、n行数据)。RowSet是不相交的,即不同的RowSet间的row不会交叉,因此一条给定的数据,只会存在于一个RowSet中。虽然Rowset是不相交的,但是两两间的key空间是可以相交的(key的range)。有点绕,意思是,物理上,每一条数据只会放置到一个rowset中,不会同时放置到多个rowset中,但是逻辑上,一条数据的业务字段组成的key是可以在多个rowset之间相互重叠

Rowset概念

存在于内存中,称之为memrowset,存在于磁盘中,称之为diskrowset。一个tablet中只有一个MemRowSet。MemRowSet是一个in-memory的B-Tree树,且按照表的主键排序。所有的insert直接写入进MemRowSet。受益于MVCC(Multi-Version Concurrency Control 多版本并发控制,下文中会讲述),一旦数据写入到MemRowSet,后续的reader能立马查询到。

任何一条数据都以entry的形式精确的存在于一个MemRowSet中,entry由一个特殊的header和实际的row data内容组成。由于MemRowSet只存于内存中,最终会被写满,然后Flush到磁盘里(一个或者多个DiskRowSet中,一方面由于内存flush到磁盘需要根据设定的块大小生成一个或者多个块,另一方面MemRowSet对于一个tablet只有一个,多个列公用一个MRS,但是flush到磁盘的diskrowset是按照列分开的,所以这里也会产生多个

MVCC 多版本并发控制

  • Snapshot scanner:快照查询,当创建了一个查询,系统会操作tablet指定时间的快照(point-in-time)。在这个查询过程中的任何针对这个tablet的update都会被忽略。另外,指定时间的快照(point-in-time)可以被存储并在其他的查询中重复使用,例如,一个应用对一组连续的数据进行多次交互执行分析查询。
  • Time-travel scanners:历史快照查询,与上边的快照查询一样。用户可以指定历史的一个时间点创建一个查询,MVCC可以保证历史快照的一致性。这个功能可以被用来在某个时间点上的一致性备份。
  • Change-history queries:历史变更查询,给定两个MVCC快照,用户可以查询这两个快照间任务数据。这个功能可以用来做增量备份,跨集群同步,或者离线审计分析。
  • Multi-row atomic updates within a tablet:tablet内多行数据的原子更新,在一个tablet里,一个操作(mutation)可以修改多行数据,而且在一条数据里的原子操作里是可见的。(应该是针对column的原子操作)

为了提供MVCC功能,每个操作(mutation)会带有一个时间戳(timestamp)。Timestamp是由TS-wide Clock实例提供的,tablet的MvccManager能保证在这个tablet中timestamp是唯一的不重复的。MvccManager决定了数据提交的timestamp,从而这个时间点后的查询都可以获取到刚刚提交的数据。查询在被创建的时候,scanner提取了一个MvccManager时间状态的快照,所有对于这个scanner可见的数据都会跟这个MvccSnapshot比较,从而决定到底是哪个insertion、update或者detete操作后的数据可见

每个tablet的Timestamp都是单调递增的。我们使用HybridTime技术来创建时间戳,它能保证节点之间的时间戳一致。

为了支持快照和历史快照功能,多个版本的数据必须被存储。为了防止空间无限扩展,用户可以配置一个保留时间,并将这个时间之前的记录GC(这个功能可以防止每次查询都从最原始版本开始读取)

MVCC Mutations in MemRowSet

为了在MemRowSet中支持MVCC功能,每行插入的数据都会带着时间戳。而且,row会有一个指针,它指向紧随其后的mutations列表,每个mutation都有带有timestamp:

mutatitions in memrowset.png

这里第一个语句是insert,实际情况,也许是update或者delete语句,个人觉得,如果将一个数据的一串操作关联在一起,可以通过业务主键,虽然主键不一定完全提供在where语句中,但是相关的属性可以使用通配来代替。通过生成的主键可以将一些列的操作关联在一起,如上图。

任何reader需要访问MemRowSet的row中的mutations(每一个row都对应这么一长串的mutations,当然第一个是insert,后续的mutation如果修改或者删除,则会增加),才能得到正确的快照。逻辑如下

  1. 如果这行数据插入时的timestamp,不在scanner 的MVCC snapshot里(即scanner快照指定的timestamp小于数据插入的时间戳,数据还没创建),忽略该行。
  2. 如上如果不满足,将这行数据放入output缓存里。
  3. 循环list里的mutation:
  • 如果mutation的timestamp在MVCC snapshot里,在内存的缓存中执行这个更新。如果不在,则跳过此mutation。
  • 如果mutation是一个DELETE操作,则在buffer中标记为已经被删除了,并清空之前加载缓存里的数据。

注意,mutation可以是如下的任何一种:

  • UPDATE:更新value,一行数据里的一列或者多列
  • DELETE: 删除一行数据
  • REINSERT:重新插入一行数据(这种情况只在之前有一个DELETE mutation且数据在MemRowSet里时发生。)

举个真实例子,表结构(key STRING, val UINT32),经过如下操作:
INSERT INTO t VALUES (“row”, 1); [timestamp 1]
UPDATE t SET val = 2 WHERE key = “row”; [timestamp 2]
DELETE FROM t WHERE key = “row”; [timestamp 3]
INSERT INTO t VALUES (“row”, 3); [timestamp 4]
在MemRowSet中,会有如下结构:


mutation结构.png

注意,当更新过于频繁时,会有如下的影响:

  • readers需要追踪linked list指针,导致生成很多CPU cache任务
  • 更新需要追加到linked list的末尾,导致每次更新的时间复杂度是O(n)。

考虑到如上低效率的操作,我们给出如下假设:

  1. Kudu适用于相对低频率更新的场景,即假设数据不会过于频繁的更新。
  2. 整个数据中,只有一小部分存于MemRowSet中:一旦MemRowSet达到一定阈值,它会被flush到disk。因此即使MemRowSet的mutation会导致性能低,也只是占用整体查询时间的一小部分。

MemRowSet Flushes

当MemRowSet满了,会触发Flush操作,它会持续将数据写入disk。


memrowset flush.png

数据flush到disk成了CFiles文件(参加我的另一个翻译博客)。数据里的每行都通过一个有序的rowid标识了,而且这个rowid在DiskRowSet中是密集的、不可变的、唯一的。举个例子,如果一个给定的DiskRowSet包含有5行数据,那么它们会以key上升的顺序被分配为rowid0~4。不同的DiskRowSet,会有不同的行(rows),但却可能有相同rowid。

读取时,系统会使用一个索引结构,把用户可见的主键key和系统内部的rowid映射起来。上述例子中的主键是一个简单的key,它的结构嵌入在主键列的cfile里。另外,一个独立的index cfile保存了编码后的组合key

注意:rowid不是精确的跟每行数据的data存在一起,而是在这个cfile里根据数据有序的index的一个隐式识别。在一部分源码中,将rowid定义为 “row indexes” 或者 “ordinal indexes”。
注意:其他系统,例如C-Store把MemRowSet称为”write optimized store” (WOS),把DiskRowSet称为”read-optimized store” (ROS)。

这里需要了解rowkey和业务key的区别,rowkey是kudu内部每个DiskRowSet中所有数据全局唯一有序不可变的offset。而业务key是数据字段符合而成,每个数据都有业务key,也有自己的rowkey,二者对应关系存放在cfile中,通过映射以及索引加快检索

Historical MVCC in DiskRowSets

为了让on-disk data具备MVCC功能,每个on-disk的Rowset不仅仅包含当前版本row的data,还包含UNDO的记录,如此,可以获取这行数据的历史版本。

undo records.png

当用户想读取flush后最新版本的数据时,只需要获取base data。因为base data是列式存储的,这种查询性能是非常高的。如果不是读取最新数据,而是time-travel查询,就得回滚到指定历史时间的一个版本,此时就需要借助UNDO record数据

当一个查询拿到一条数据,它处理MVCC信息的流程是需要查询历史数据时候

  1. 读取base data
  2. 循环每条UNDO record:如果相关的操作timestamp还未提交,则执行回滚操作。即查询指定的快照timestamp小于mutation的timestamp,mutation还未发生。

举个例子,回顾一下之前MVCC Mutations in MemRowSet章节例子的一系列操作

10.png

当这条数据flush进磁盘,它将会被存成如下形式:
10.png

每条UNDO record是执行处理的反面。例如在UNDO record里,第一条INSERT事务会被转化成DELETE。UNDO recod旨在保留插入或者更新数据的时间戳:查询的MVCC快照指定的时间早于Tx1时,Tx1还未提交,此时将会执行DELETE操作,那么这时这条数据是不存在的。

最常见的场景是查询最新的数据。此时,我们需要优化查询策略,避免处理所有的UNDO records。为了达到这个目标,我们引入文件级别的元数据,指向UNDO record的数据范围。如果查询的MVCC快照符合的所有事务都已经提交了(查询最新的数据),这组deltas就会短路(不处理UNDO record),这时查询将没有MVCC开销。

Handling mutations against on-disk files

更新或者删除已经flush到disk的数据,不会操作MemRowSet。它的处理过程是这样的:为了确定update/delete的key在哪个RowSet里,系统将巡视所有RowSet。这个处理首先使用一个区间tree,去定位一组可能含有这key的RowSet。然后,使用boom filter判断所有候选RowSet是否含有此key。如果某一些RowSet同时通过了如上两个check,系统将在这些RowSet里寻找主键对应的rowid(查找过程,如果事先能够区分tablet效果岂不是更好)。

一旦确定了数据所在的RowSet,mutation将拿到主键对应的rowid,然后mutation会被写入到一个称为DeltaMemStore的内存结构中。

一个DiskRowSet里就一个DeltaMemStore,DeltaMemStore是一个并行BTree,BTree的key是使用rowid和mutation的timestamp混合成的。查询时,符合条件的mutation被执行后得到快照timestamp对应数据,执行方式与新数据插入后的mutation类似(MemRowSet)。

当DeltaMemStore存入的数据很大后,同样也会执行flush到disk,落地为DeltaFile文件

10.png

DeltaFile的信息类型与DeltaMemStore是一致的,只是被压实和序列化在密集型的磁盘里。为了把数据从base data更新成最新的数据,查询时需要执行这些DeltaFile里的mutation事务,这些DeltaFile集合称作REDO文件,而file里这些mutation称作REDO record。与存于MemRowSet里的mutation类似,当读取比base data更新版本的数据时,它们需要被一次应用(执行)。

一条数据的delta信息可能包含在多个DeltaFile文件,这种情况下,DeltaFile是有序的,后边的变更会优先于前边的变更。

注意,mutation存储结构没必要包含整行的数据。如果在一行中,仅仅只有一列数据被更新,那么mutation结构只会包含这一列的更新信息。不读取或者重写无关的列,这样更新数据操作就快而有效率

Summary of delta file processing

总结一下,每个DiskRowSet逻辑上分三部分:


10.png

Base data:MemRowSet flush到DiskRowSet时的最新数据,数据是列式存储的。

  • UNDO records:历史数据,用来回滚到Base data之前一些历史版本数据。
  • REDO records:Base data之后的一些更新数据,可以用来得到最新版本的数据。
  • UNDO record 和REDO record存储格式是一样的,都称为DeltaFile。

其中undo record是内存数据持久化到磁盘的时候,将mutation写成undo file,同时base file记录最新的数据。而,redo record,是后续对持久化文件中的数据进行修改的操作,写成了redo file,可以理解成内存数据对应mutations list,也就是说,针对mutations list,在内存数据持久化到磁盘的时候,如果已经存在,则需要写成正向redo文件,如果不存在,则写成undo文件

Types of Delta Compaction

delta campaction分minor和major两种:

  1. Minor delta compactoin
    Minor compaction是多个delta file的compaction,不会包含base data,compact生成的也是delta file。
  2. Major delta compaction
    Major compaction是对base data和任意多个delta file的compact

Major compaction比minor compaction更耗性能,因为它需要读取和重写base data,并且base data比delta data大很多(因为base data存了一行数据,而delta data是对某一些column的mutation,需要注意的base data是列式存储的,delta data不是)。

Major compaction可以对DiskRowSet里的任意多个或者一个column进行compact。如果只有一列数据进行了多次重要的更新,那么compact可以只针对这一列进行读取和重写。在企业级应用中会经常遇到这种情况,例如更新订单的状态、更新用户的访问量。

两种类型的compaction都维护RowSet里的rowid。因为它们完全在后台执行,且不会带锁。compact的结果文件会采用原子swapping的方式被引入进RowSet。Swap操作结束后,compact前的那些老文件将会被删除。

Merging compactions

随着越来越多的数据写入tablet,DiskRowSet数量也会累积的越来越多。如此这般将会降低kudu性能:

  1. 随机访问(通过主键获取或者更新一条数据),这种情况下,每个RowSet只要它的key范围包含了这个主键,将各自去定位主键的位置。Boom filter可以缓解一定数量的物理寻址,但是特大的bloom filter访问会影响到CPU,并且同样会增加内存消耗。
  2. 查询一定key范围数据(例如查询主键在A与B之间的数据),此时,每个RowSet,只要它的key范围与提供的范围重叠,将各自去寻址,不使用bloom filter。专门的索引结构可能会有帮助,但是同样会消耗内存。
  3. 排序查询,如果用户要求查询的结果与主键有相同顺序,那么查询结果集必须经过一个merge过程。Merge的消耗通常与输入的数据量成对数级增长,即随着数据量的增大,merge将越耗性能。
    如上所述,我们应该merge RowSet以减少RowSet的数量:
    10.png

    与如上提到的Delta Compaction不同,请注意,merging Compaction不会保持rowid一样

你可能感兴趣的:(003 Kudu | Tablet设计思想)