kylin KV+cube方案分析

前言

  在使用Kylin的时候,最重要的一步就是创建cube的模型定义,即指定度量和维度以及一些附加信息,然后对cube进行build,当然我们也可以根据原始表中的某一个string字段(这个字段的格式必须是日期格式,表示日期的含义)设定分区字段,这样一个cube就可以进行多次build,每一次的build会生成一个segment,每一个segment对应着一个时间区间的cube,这些segment的时间区间是连续并且不重合的,对于拥有多个segment的cube可以执行merge,相当于将一个时间区间内部的segment合并成一个。下面从源码开始分析cube的build和merge过程。本文基于Kylin-1.0-incubating版本,对于Kylin的介绍可以参见:http://blog.csdn.net/yu616568/article/details/48103415

入口介绍

  在kylin的web页面上创建完成一个cube之后可以点击action下拉框执行build或者merge操作,这两个操作都会调用cube的rebuild接口,调用的参数包括:1、cube名,用于唯一标识一个cube,在当前的kylin版本中cube名是全局唯一的,而不是每一个project下唯一的;2、本次构建的startTime和endTime,这两个时间区间标识本次构建的segment的数据源只选择这个时间范围内的数据;对于BUILD操作而言,startTime是不需要的,因为它总是会选择最后一个segment的结束时间作为当前segment的起始时间。3、buildType标识着操作的类型,可以是”BUILD”、”MERGE”和”REFRESH”。 
  这些操作的统一入口就是JobService.submitJob函数,该函数首先取出该cube所有关联的构建cube的job,并且判断这些job是否有处于READY、RUNNING、ERROR状态,如果处于该状态意味着这个job正在执行或者可以之后被resume执行,做这种限制的原因不得而知(可能是构建的区间是基于时间吧,需要对一个cube并行的构建多个segment(时间区间的数据)的需求并不明显)。所以如果希望build或者merge cube,必须将未完成的cube的操作执行discard操作。然后根据操作类型执行具体的操作: 
1. 如果是BUILD,如果这个cube中包含distinct count聚合方式的度量并且这个cube中已经存在其他segment,则执行appendAndMergeSegments函数,否则执行buildJob函数。 
2. 如果是MERGE操作则执行mergeSegments函数。 
3. 如果是REFRESH,则同样执行buildJob函数。为这个时间区间的segment重新构建。 
  buildJob函数构建一个新的segment,mergeSegments函数合并一个时间区间内的所有segments,appendAndMergeSegments函数则首先根据最后一个segment的时间区间的end值build一个新的segment然后再将所有的时间区间的segments进行合并(为什么包含distinct count的聚合函数的cube的构建一定要进行合并呢?这应该是有distinct-count使用的hyperloglog算法决定的,下次可以专门分析一下这个算法)。

BUILD操作

  Build操作是构建一个cube指定时间区间的数据,由于kylin基于预计算的方式提供数据查询,构建操作是指将原始数据(存储在Hadoop中,通过Hive获取)转换成目标数据(存储在Hbase中)的过程。主要的步骤可以按照顺序分为四个阶段:1、根据用户的cube信息计算出多个cuboid文件,2、根据cuboid文件生成htable,3、更新cube信息,4、回收临时文件。每一个阶段操作的输入都需要依赖于上一步的输出,所以这些操作全是顺序执行的。

1. 计算cuboid文件

  在kylin的CUBE模型中,每一个cube是由多个cuboid组成的,理论上有N个普通维度的cube可以是由2的N次方个cuboid组成的,那么我们可以计算出最底层的cuboid,也就是包含全部维度的cuboid(相当于执行一个group by全部维度列的查询),然后在根据最底层的cuboid一层一层的向上计算,直到计算出最顶层的cuboid(相当于执行了一个不带group by的查询),其实这个阶段kylin的执行原理就是这个样子的,不过它需要将这些抽象成mapreduce模型,提交mapreduce作业执行。

1.1 生成原始数据(Create Intermediate Flat Hive Table)

  这一步的操作是根据cube的定义生成原始数据,这里会新创建一个hive外部表,然后再根据cube中定义的星状模型,查询出维度(对于DERIVED类型的维度使用的是外键列)和度量的值插入到新创建的表中,这个表是一个外部表,表的数据文件(存储在HDFS)作为下一个子任务的输入,它首先根据维度中的列和度量中作为参数的列得到需要出现在该表中的列,然后执行三步hive操作,这三步hive操作是通过hive -e的方式执行的shell命令。 
  1. drop TABLE IF EXISTS xxx. 
  2. CREATE EXTERNAL TABLE IF NOT EXISTS xxx() ROW FORMAT DELIMITED FIELDS TERMINATED BY ‘\177’ STORED AS SEQUENCEFILE LOCATION xxxx,其中表名是根据当前的cube名和segment的uuid生成的,location是当前job的临时文件,只有当insert插入数据的时候才会创建,注意这里每一行的分隔符指定的是’\177’(目前是写死的,十进制为127). 
  3. 插入数据,在执行之前需要首先设置一些配置项,这些配置项通过hive的SET命令设置,是根据这个cube的job的配置文件(一般是在kylin的conf目录下)设置的,最后执行的是INSERT OVERWRITE TABLE xxx SELECT xxxx语句,SELECT子句中选出cube星状模型中事实表与维度表按照设置的方式join之后的出现在维度或者度量参数中的列(特殊处理derived列),然后再加上用户设置的where条件和partition的时间条件(根据输入build的参数). 
  需要注意的是这里无论用户设置了多少维度和度量,每次join都会使用事实表和所有的维度表进行join,这可能造成不必要的性能损失(多一个join会影响hive性能,毕竟要多读一些文件)。这一步执行完成之后location指定的目录下就有了原始数据的文件,为接下来的任务提供了输入。

1.2 创建事实表distinct column文件(Extract Fact Table Distinct Columns)

  在这一步是根据上一步生成的hive表计算出还表中的每一个出现在事实表中的度量的distinct值,并写入到文件中,它是启动一个MR任务完成的,MR任务的输入是HCatInputFormat,它关联的表就是上一步创建的临时表,这个MR任务的map阶段首先在setup函数中得到所有度量中出现在事实表的度量在临时表的index,根据每一个index得到该列在临时表中在每一行的值value,然后将<index, value>作为mapper的输出,该任务还启动了一个combiner,它所做的只是对同一个key的值进行去重(同一个mapper的结果),reducer所做的事情也是进行去重(所有mapper的结果),然后将每一个index对应的值一行行的写入到以列名命名的文件中。如果某一个维度列的distinct值比较大,那么可能导致MR任务执行过程中的OOM。 
  对于这一步我有一个疑问就是既然所有的原始数据都已经通过第一步存入到临时hive表中了,我觉得接下来就不用再区分维度表和事实表了,所有的任务都基于这个临时表,那么这一步就可以根据临时表计算出所有的维度列的distinct column值,但是这里仅仅针对出现在事实表上的维度,不知道这样做的原因是什么?难道是因为在下一步会单独计算维度表的dictionary以及snapshot?

1.3 创建维度词典(Build Dimension Dictionary)

  这一步是根据上一步生成的distinct column文件和维度表计算出所有维度的词典信息,词典是为了节约存储而设计的,用于将一个成员值编码成一个整数类型并且可以通过整数值获取到原始成员值,每一个cuboid的成员是一个key-value形式存储在hbase中,key是维度成员的组合,但是一般情况下维度是一些字符串之类的值(例如商品名),所以可以通过将每一个维度值转换成唯一整数而减少内存占用,在从hbase查找出对应的key之后再根据词典获取真正的成员值。 
   这一步是在kylin进程内的一个线程中执行的,它会创建所有维度的dictionary,如果是事实表上的维度则可以从上一步生成的文件中读取该列的distinct成员值(FileTable),否则则需要从原始的hive表中读取每一列的信息(HiveTable),根据不同的源(文件或者hive表)获取所有的列去重之后的成员列表,然后根据这个列表生成dictionary,kylin中针对不同类型的列使用不同的实现方式,对于time之类的(date、time、dtaetime和timestamp)使用DateStrDictionary,这里目前还存在着一定的问题,因为这种编码方式会首先将时间转换成‘yyyy-MM-dd’的格式,会导致timestamp之类的精确时间失去天以后的精度。针对数值型的使用NumberDictionary,其余的都使用一般的TrieDictionary(字典树)。这些dictionary会作为cube的元数据存储的kylin元数据库里面,执行query的时候进行转换。 
   之后还需要计算维度表的snapshotTable,每一个snapshot是和一个hive维度表对应的,生成的过程是:首先从原始的hive维度表中顺序得读取每一行每一列的值,然后使用TrieDictionary方式对这些所有的值进行编码,这样每一行每一列的之都能够得到一个编码之后的id(相同的值id也相同),然后再次读取原始表中每一行的值,将每一列的值使用编码之后的id进行替换,得到了一个只有id的新表,这样同时保存这个新表和dictionary对象(id和值得映射关系)就能够保存整个维度表了,同样,kylin也会将这个数据存储元数据库中。 
   针对这一步需要注意的问题:首先,这一步的两个步骤都是在kylin进程的一个线程中执行的,第一步会加载某一个维度的所有distinct成员到内存,如果某一个维度的cardinality比较大 ,可能会导致内存出现OOM,然后在创建snapshotTable的时候会限制原始表的大小不能超过配置的一个上限值,如果超过则会执行失败。但是应该强调的是这里加载全部的原始维度表更可能出现OOM。另外,比较疑惑的是:1、为什么不在上一步的MR任务中直接根据临时表中的数据生成每一个distinct column值,而是从原始维度表中读取?2、计算全表的dictionary是为了做什么?我目前只了解对于drived维度是必要保存主键和列之间的映射,但是需要保存整个维度表?!

1.4 计算生成BaseCuboid文件(Build Base Cuboid Data)

   何谓Base cuboid呢?假设一个cube包含了四个维度:A/B/C/D,那么这四个维度成员间的所有可能的组合就是base cuboid,这就类似在查询的时候指定了select count(1) from xxx group by A,B,C,D;这个查询结果的个数就是base cuboid集合的成员数。这一步也是通过一个MR任务完成的,输入是临时表的路径和分隔符,map对于每一行首先进行split,然后获取每一个维度列的值组合作为rowKey,但是rowKey并不是简单的这些维度成员的内容组合,而是首先将这些内容从dictionary中查找出对应的id,然后组合这些id得到rowKey,这样可以大大缩短hbase的存储空间,提升查找性能。然后在查找该行中的度量列,根据cube定义中度量的函数返回对该列计算之后的值。这个MR任务还会执行combiner过程,执行逻辑和reducer相同,在reducer中的key是一个rowKey,value是相同的rowKey的measure组合的数组,reducer回分解出每一个measure的值,然后再根据定义该度量使用的聚合函数计算得到这个rowKey的结果,其实这已经类似于hbase存储的格式了。

1.5 计算第N层cuboid文件(Build N-Dimension Cuboid Data)

  这一个流程是由多个步骤的,它是根据维度组合的cuboid的总数决定的,上一层cuboid执行MR任务的输入是下一层cuboid计算的输出,由于最底层的cuboid(base)已经计算完成,所以这几步不需要依赖于任何的hive信息,它的reducer和base cuboid的reducer过程基本一样的(相同rowkey的measure执行聚合运算),mapper的过程只需要根据这一行输入的key(例如A、B、C、D中某四个成员的组合)获取可能的下一层的的组合(例如只有A、B、C和B、C、D),那么只需要将这些可能的组合提取出来作为新的key,value不变进行输出就可以了。 
举个例子,假设一共四个维度A/B/C/D,他们的成员分别是(A1、A2、A3),(B1、B2)、(C1)、(D1),有一个measure(对于这列V,计算sum(V)),这里忽略dictionary编码。原始表如下:

ABCDV
A1B1C1D12
A1B2C1D13
A2B1C1D15
A3B1C1D16
A3B2C1D18

那么base cuboid最终的输出如下 
<A1、B1、C1、D1>、2) 
<A1、B2、C1、D1>, 3) 
<A2、B1、C1、D1>, 5) 
<A3、B1、C1、D1>, 6) 
<A3、B2、C1、D1>, 8) 
那么它作为下面一个cuboid的输入,对于第一行输入 
<A1、B1、C1、D1>, 2),mapper执行完成之后会输出 
<A1、B1、C1>, 2)、 
<A1、B1、D1>, 2)、 
<A1、C1、D1>, 2)、 
<B1、C1、D1>, 2)这四项,同样对于其他的内一行也会输出四行,最终他们经过reducer的聚合运算,得到如下的结果: 
<A1、B1、C1>, 2) 
<A1、B1、D1>, 2) 
<A1、C1、D1>, 2 + 3) 
<B1、C1、D1>,2 + 5 +6) 
... 
这样一次将下一层的结果作为输入计算上一层的cuboid成员,直到最顶层的cuboid,这一个层cuboid只包含一个成员,不按照任何维度进行group by。 
  上面的这些步骤用于生成cuboid,假设有N个维度(对于特殊类型的),那么就需要有N +1层cuboid,每一层cuboid可能是由多个维度的组合,但是它包含的维度个数相同。

2 准备输出

  在上面几步中,我们已经将每一层的cuboid计算完成,每一层的cuboid文件都是一些cuboid的集合,每一层的cuboid的key包含相同的维度个数,下面一步就是将这些cuboid文件导入到hbase中。

2.1 计算分组

  这一步的输入是之前计算的全部的cuboid文件,按照cuboid文件的顺序(层次的顺序)一次读取每一个key-value,再按照key-value的形式统计每一个key和value占用的空间大小,然后以GB为单位,mapper阶段的输出是每当统计到1GB的数据,将当前的这个key和当前数据量总和输出,在reducer阶段根据用户创建cube时指定的cube大小(SMALL,MEDIUM和LARGE)和总的大小计算出实际需要划分为多少分区,这时还需要参考最多分区数和最少分区数进行计算,再根据实际数据量大小和分区数计算出每一个分区的边界key,将这个key和对应的分区编号输出到最终文件中,为下一步创建htable做准备。

2.2 创建HTable

  这一步非常简单,根据上一步计算出的rowKey分布情况(split数组)创建HTable,创建一个HTable的时候还需要考虑一下几个事情:1、列组的设置,2、每一个列组的压缩方式,3、部署coprocessor,4、HTable中每一个region的大小。在这一步中,列组的设置是根据用户创建cube时候设置的,在hbase中存储的数据key是维度成员的组合,value是对应聚合函数的结果,列组针对的是value的,一般情况下在创建cube的时候只会设置一个列组,该列包含所有的聚合函数的结果;在创建HTable时默认使用LZO压缩,如果不支持LZO则不进行压缩,在后面kylin的版本中支持更多的压缩方式;kylin强依赖于hbase的coprocessor,所以需要在创建HTable为该表部署coprocessor,这个文件会首先上传到HBase所在的HDFS上,然后在表的元信息中关联,这一步很容易出现错误,例如coprocessor找不到了就会导致整个regionServer无法启动,所以需要特别小心;region的划分已经在上一步确定了,所以这里不存在动态扩展的情况,所以kylin创建HTable使用的接口如下: 
public void createTable( final HTableDescriptor desc , byte [][] splitKeys)

2.3 构建hfile文件

  创建完了HTable之后一般会通过插入接口将数据插入到表中,但是由于cuboid中的数据量巨大,频繁的插入会对Hbase的性能有非常大的影响,所以kylin采取了首先将cuboid文件转换成HTable格式的Hfile文件,然后在通过bulkLoad的方式将文件和HTable进行关联,这样可以大大降低Hbase的负载,这个过程通过一个MR任务完成。 
  这个任务的输入是所有的cuboid文件,在mapper阶段根据每一个cuboid成员的key-value输出,如果cube定义时指定了多个列组,那么同一个key要按照不同列组中的值分别输出,例如在cuboid文件中存在一行cuboid=1,key=1,value=sum(cost),count(1)的数据,而cube中将这两个度量划分到两个列组中,这时候对于这一行数据,mapper的输出为<1, sum(cost)>和<1,count(1)>。reducer使用的是org.apache.hadoop.hbase.mapreduce.KeyValueSortReducer,它会按照行排序输出,如果一行中包含多个值,那么会将这些值进行排序再输出。输出的格式则是根据HTable的文件格式定义的。

2.4 BulkLoad文件

  这一步将HFile文件load到HTable中,因为load操作会将原始的文件删除(相当于remove),在操作之前首先将所有列组的Hfile的权限都设置为777,然后再启动LoadIncrementalHFiles任务执行load操作,它的输入为文件的路径和HTable名,这一步完全依赖于HBase的工具。这一步完成之后,数据已经存储到HBase中了,key的格式由cuboid编号+每一个成员在字典树的id组成,value可能保存在多个列组里,包含在原始数据中按照这几个成员进行GROUP BY计算出的度量的值。

3 收尾工作

  执行完上一步就已经完成了从输入到输出的计算过程,接下来要做的就是一些kylin内部的工作,分别是更新元数据,更新cube状态,垃圾数据回收。

3.1 更新状态

  这一步主要是更新cube的状态,其中需要更新的包括cube是否可用、以及本次构建的数据统计,包括构建完成的时间,输入的record数目,输入数据的大小,保存到Hbase中数据的大小等,并将这些信息持久到元数据库中。

3.2 垃圾文件回收

  这一步是否成功对正确性不会有任何影响,因为经过上一步之后这个segment就可以在这个cube中被查找到了,但是在整个执行过程中产生了很多的垃圾文件,其中包括:1、临时的hive表,2、因为hive表是一个外部表,存储该表的文件也需要额外删除,3、fact distinct 这一步将数据写入到HDFS上为建立词典做准备,这时候也可以删除了,4、rowKey统计的时候会生成一个文件,此时可以删除。5、生成HFile时文件存储的路径和hbase真正存储的路径不同,虽然load是一个remove操作,但是上层的目录还是存在的,也需要删除。这一步kylin做的比较简单,并没有完全删除所有的临时文件,其实在整个计算过程中,真正还需要保留的数据只有多个cuboid文件(需要增量build的cube),这个因为在不同segment进行merge的时候是基于cuboid文件的,而不是根据HTable的。

  在Kylin-1.x版本中,整个cube的一个build的过程大概就是这样,这样的一个build只不过是生成一虐segment,而当一个cube中存在多个segment时可能需要将它们进行merge,merge的过程和build的流程大致是相同的,不过它不需要从头开始,只需要对字典进行merge,然后在对cuboid文件进行merge,最后生成一个新的HTable。 
但是在Kylin-2.x版本中,整个家沟发生了很大的变化,build的引擎也分成了多套,分别是原始的MR引擎,基于Fast Cubing的MR引擎和Spark引擎,这使得build进行的更迅速,大大降低等待时间,后面会持续的再对新的引擎进行分析。

Kylin Cube Build的接口说明
每一个Cube需要设置数据源、计算引擎和存储引擎,工厂类负责创建数据源对象、计算引擎对象和存储引擎对象
三者之间通过适配器进行串联
数据源接口(ISource)
public interface ISource extends Closeable {

    // 同步数据源中表的元数据信息
    ISourceMetadataExplorer getSourceMetadataExplorer();

    // 适配制定的构建引擎接口
    <I> I adaptToBuildEngine(Class<I> engineInterface);

    // 顺序读取表
    IReadableTable createReadableTable(TableDesc tableDesc);

    // 构建之前丰富数据源的Partition
    SourcePartition enrichSourcePartitionBeforeBuild(IBuildable buildable, SourcePartition srcPartition);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
存储引擎接口(IStorage)

public interface IStorage {

    // 创建一个查询指定Cube的对象
    public IStorageQuery createQuery(IRealization realization);

    public <I> I adaptToBuildEngine(Class<I> engineInterface);
}
1
2
3
4
5
6
7
8
计算引擎接口(IBatchCubingEngine)
public interface IBatchCubingEngine {

    public IJoinedFlatTableDesc getJoinedFlatTableDesc(CubeSegment newSegment);

    // 返回一个工作流计划, 用以构建指定的CubeSegment
    public DefaultChainedExecutable createBatchCubingJob(CubeSegment newSegment, String submitter);

   // 返回一个工作流计划, 用以合并指定的CubeSegment
    public DefaultChainedExecutable createBatchMergeJob(CubeSegment mergeSegment, String submitter);

   // 返回一个工作流计划, 用以优化指定的CubeSegment
    public DefaultChainedExecutable createBatchOptimizeJob(CubeSegment optimizeSegment, String submitter);

    public Class<?> getSourceInterface();

    public Class<?> getStorageInterface();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
离线Cube Build 调用链
Rest API请求/{cubeName}/rebuild, 调用CubeController.rebuild()方法 -> jobService.submitJob()
Project级别的权限校验: aclEvaluate.checkProjectOperationPermission(cube);
ISource source = SourceManager.getSource(cube)根据CubeInstance的方法getSourceType()的返回值决定ISource的对象类型

public int getSourceType() {
    return getModel().getRootFactTable().getTableDesc().getSourceType();
}
1
2
3
分配新的segment: newSeg = getCubeManager().appendSegment(cube, src);

EngineFactory根据Cube定义的engine type, 创建对应的IBatchCubingEngine对象 -> 调用createBatchCubingJob()方法创建作业链,MRBatchCubingEngine2新建的是BatchCubingJobBuilder2

public BatchCubingJobBuilder2(CubeSegment newSegment, String submitter) {
    super(newSegment, submitter);
    this.inputSide = MRUtil.getBatchCubingInputSide(seg);
    this.outputSide = MRUtil.getBatchCubingOutputSide2(seg);
}    
1
2
3
4
5
适配输入数据源到构建引擎

SourceManager.createEngineAdapter(seg, IMRInput.class).getBatchCubingInputSide(flatDesc);

public static <T> T createEngineAdapter(ISourceAware table, Class<T> engineInterface) {
    return getSource(table).adaptToBuildEngine(engineInterface);
}

// HiveSource返回的是HiveMRInput
public <I> I adaptToBuildEngine(Class<I> engineInterface) {
    if (engineInterface == IMRInput.class) {
        return (I) new HiveMRInput();
    } else {
        throw new RuntimeException("Cannot adapt to " + engineInterface);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
适配存储引擎到构建引擎

StorageFactory.createEngineAdapter(seg, IMROutput2.class).getBatchCubingOutputSide(seg);

public static <T> T createEngineAdapter(IStorageAware aware, Class<T> engineInterface) {
    return storage(aware).adaptToBuildEngine(engineInterface);
}

// HBaseStorage返回的是HBaseMROutput2Transition
public <I> I adaptToBuildEngine(Class<I> engineInterface) {
    if (engineInterface == IMROutput2.class) {
        return (I) new HBaseMROutput2Transition();
    } else {
        throw new RuntimeException("Cannot adapt to " + engineInterface);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
适配成功后, new BatchCubingJobBuilder2(newSegment, submitter).build()该方法创建具体的执行步骤, 形成工作流
将工作流添加到执行管理器,等待调度执行: getExecutableManager().addJob(job);
--------------------- 

本文主要介绍了Apache Kylin是如何将Hive表中的数据转化为HBase的KV结构,并简单介绍了Kylin的SQL查询是如何转化为HBase的Scan操作。

Apache Kylin 是什么
Apache Kylin是一个开源的、基于Hadoop生态系统的OLAP引擎(OLAP查询引擎、OLAP多维分析引擎),能够通过SQL接口对十亿、甚至百亿行的超大数据集实现秒级的多维分析查询。

Apache Kylin 核心:Kylin OLAP引擎基础框架,包括元数据引擎,查询引擎,Job(Build)引擎及存储引擎等,同时包括REST服务器以响应客户端请求。

OLAP 是什么
即联机分析处理:以复杂的分析型查询为主,需要扫描,聚合大量数据。

Kylin如何实现超大数据集的秒级多维分析查询
预计算

对于超大数据集的复杂查询,既然现场计算需要花费较长时间,那么根据空间换时间的原理,我们就可以提前将所有可能的计算结果计算并存储下来,从而实现超大数据集的秒级多维分析查询。

Kylin的预计算是如何实现的
将数据源Hive表中的数据按照指定的维度和指标 由计算引擎MapReduce离线计算出所有可能的查询结果(即Cube)存储到HBase中。

Cube 和 Cuboid是什么
简单地说,一个cube就是一个Hive表的数据按照指定维度与指标计算出的所有组合结果。

其中每一种维度组合称为cuboid,一个cuboid包含一种具体维度组合下所有指标的值。

如下图,整个立方体称为1个cube,立方体中每个网格点称为1个cuboid,图中(A,B,C,D)和(A,D)都是cuboid,特别的,(A,B,C,D)称为Base cuboid。cube的计算过程是逐层计算的,首先计算Base cuboid,然后计算维度数依次减少,逐层向下计算每层的cuboid。

图1

Build引擎Cube构建流程

BatchCubingJobBuilder2.build方法逻辑如下:

  public CubingJob build() {
        logger.info("MR_V2 new job to BUILD segment " + seg);       
        final CubingJob result = CubingJob.createBuildJob(seg, submitter, config);
        final String jobId = result.getId();
        final String cuboidRootPath = getCuboidRootPath(jobId);
      
        // Phase 1: Create Flat Table & Materialize Hive View in Lookup Tables
        // 根据事实表和维表抽取需要的维度和度量,创建一张宽表或平表,并且进行文件再分配(执行Hive命令行来完成操作)
        inputSide.addStepPhase1_CreateFlatTable(result);       
 
        // Phase 2: Build Dictionary
        // 创建字典由三个子任务完成,由MR引擎完成,分别是抽取维度值(包含抽样统计)、创建维度字典和保存统计信息
        result.addTask(createFactDistinctColumnsStep(jobId));
        result.addTask(createBuildDictionaryStep(jobId));
        result.addTask(createSaveStatisticsStep(jobId));
        // add materialize lookup tables if needed
        LookupMaterializeContext lookupMaterializeContext = addMaterializeLookupTableSteps(result);
 
        // 创建HTable
        outputSide.addStepPhase2_BuildDictionary(result);
      
        // Phase 3: Build Cube
        // 构建Cube,包含两种Cube构建算法,分别是逐层算法和快速算法,在执行时会根据源数据的统计信息自动选择一种算法(各个Mapper的小Cube的行数之和 / reduce后的Cube行数 > 7,重复度高就选逐层算法,重复度低就选快速算法)
        addLayerCubingSteps(result, jobId, cuboidRootPath); // layer cubing, only selected algorithm will execute
        addInMemCubingSteps(result, jobId, cuboidRootPath); // inmem cubing, only selected algorithm will execute
        // 构建HFile文件及把HFile文件BulkLoad到HBase
        outputSide.addStepPhase3_BuildCube(result);
       
        // Phase 4: Update Metadata & Cleanup
        // 更新Cube元数据,其中需要更新的包括cube是否可用、以及本次构建的数据统计,包括构建完成的时间,输入的record数目,输入数据的大小,保存到Hbase中数据的大小等,并将这些信息持久到元数据库中
 
        // 以及清理临时数据,是在整个执行过程中产生了很多的垃圾文件,其中包括:1、临时的hive表,2、因为hive表是一个外部表,存储该表的文件也需要额外删除,3、fact distinct 这一步将数据写入到HDFS上为建立词典做准备,这时候也可以删除了,4、rowKey统计的时候会生成一个文件,此时可以删除。
 
        result.addTask(createUpdateCubeInfoAfterBuildStep(jobId, lookupMaterializeContext));
        inputSide.addStepPhase4_Cleanup(result);
        outputSide.addStepPhase4_Cleanup(result);        
 
        return result;
    }
一、 根据事实表和维表抽取需要的维度和度量,创建一张宽表或平表,并且进行文件再分配

1.1 生成Hive宽表或平表(Create Intermediate Flat Hive Table)(执行Hive命令行)

这一步的操作是根据cube的定义生成原始数据,这里会新创建一个hive外部表,然后再根据cube中定义的星状模型,查询出维度(对于DERIVED类型的维度使用的是外键列)和度量的值插入到新创建的表中,这个表是一个外部表,表的数据文件(存储在HDFS)作为下一个子任务的输入,它首先根据维度中的列和度量中作为参数的列得到需要出现在该表中的列,然后执行三步hive操作,这三步hive操作是通过hive -e的方式执行的shell命令。

  1. drop TABLE IF EXISTS xxx

  2. CREATE EXTERNAL TABLE IF NOT EXISTS xxx() ROW FORMAT DELIMITED FIELDS TERMINATED BY '\177' STORED AS SEQUENCEFILE LOCATION xxxx,其中表名是根据当前的cube名和segment的uuid生成的,location是当前job的临时文件,只有当insert插入数据的时候才会创建,注意这里每一行的分隔符指定的是'\177'(目前是写死的,十进制为127)。

  3. 插入数据,在执行之前需要首先设置一些配置项,这些配置项通过hive的SET命令设置,是根据这个cube的job的配置文件(一般是在kylin的conf目录下)设置的,最后执行的是INSERT OVERWRITE TABLE xxx SELECT xxxx语句,SELECT子句中选出cube星状模型中事实表与维度表按照设置的方式join之后的出现在维度或者度量参数中的列(特殊处理derived列),然后再加上用户设置的where条件和partition的时间条件(根据输入build的参数)。

  需要注意的是这里无论用户设置了多少维度和度量,每次join都会使用事实表和所有的维度表进行join,这可能造成不必要的性能损失(多一个join会影响hive性能,毕竟要多读一些文件)。这一步执行完成之后location指定的目录下就有了原始数据的文件,为接下来的任务提供了输入。

JoinedFlatTable.generateDropTableStatement(flatDesc);

JoinedFlatTable.generateCreateTableStatement(flatDesc, jobWorkingDir);

JoinedFlatTable.generateInsertDataStatement(flatDesc);

二、 提取纬度值、创建维度字典和保存统计信息

2.1 提取事实表维度去重值(Extract Fact Table Distinct Columns)(执行一个MapReduce任务,包含抽取纬度值及统计各Mapper间的重复度两种任务)

    在这一步是根据上一步生成的hive表计算出还表中的每一个出现在事实表中的维度的distinct值,并写入到文件中,它是启动一个MR任务完成的,MR任务的输入是HCatInputFormat,它关联的表就是上一步创建的临时表,这个MR任务的map阶段首先在setup函数中得到所有维度中出现在事实表的维度列在临时表的index,根据每一个index得到该列在临时表中在每一行的值value,然后将<index+value,EMPTY_TEXT>作为mapper的输出,通过index决定由哪个Reduce处理(而Reduce启动的时候根据ReduceTaskID如0000,0001来初始化决定处理哪个index对应的维度列),该任务还启动了一个combiner,它所做的只是对同一个key(维度值)进行去重(同一个mapper的结果),reducer所做的事情也是进行key(维度值)去重(所有mapper的结果),然后在Reduce中将该维度列去重后的维度值一行行的写入到以列名命名的文件中(注意kylin实现的方式,聚合的key是纬度值,而不是index)。

提取事实表维度列的唯一值是通过FactDistinctColumnsJob这个MapReduce来完成,核心思想是每个Reduce处理一个维度列,然后每个维度列Reduce单独输出该维度列对应的去重后的数据文件(output written to baseDir/colName/-r-00000,baseDir/colName2/-r-00001 or 直接输出字典 output written to baseDir/colName/colName.rldict-r-00000)。另外会输出各Mapper间重复度统计文件(output written to baseDir/statistics/statistics-r-00000,baseDir/statistics/statistics-r-00001)

FactDistinctColumnsJob

FactDistinctColumnsMapper

FactDistinctColumnPartitioner

FactDistinctColumnsCombiner

FactDistinctColumnsReducer

org.apache.kylin.engine.mr.steps.FactDistinctColumnsMapper
org.apache.kylin.engine.mr.steps.FactDistinctColumnsReducer
 
在FactDistinctColumnsMapper中输出维度值或通过HHL近似算法统计每个Mapper中各个CuboID的去重行数
    public void doMap(KEYIN key, Object record, Context context) throws IOException, InterruptedException {
        Collection<String[]> rowCollection = flatTableInputFormat.parseMapperInput(record);
        for (String[] row : rowCollection) {
            context.getCounter(RawDataCounter.BYTES).increment(countSizeInBytes(row));
            for (int i = 0; i < allCols.size(); i++) {
                String fieldValue = row[columnIndex[i]];
                if (fieldValue == null)
                    continue;
                final DataType type = allCols.get(i).getType();
                if (dictColDeduper.isDictCol(i)) {
                    if (dictColDeduper.add(i, fieldValue)) {
                        // 输出维度值,KEY=COLUMN_INDEX+COLUME_VALUE,VALUE=EMPTY_TEXT
                        writeFieldValue(context, type, i, fieldValue);
                    }
                } else {
                    DimensionRangeInfo old = dimensionRangeInfoMap.get(i);
                    if (old == null) {
                        old = new DimensionRangeInfo(fieldValue, fieldValue);
                        dimensionRangeInfoMap.put(i, old);
                    } else {
                        old.setMax(type.getOrder().max(old.getMax(), fieldValue));
                        old.setMin(type.getOrder().min(old.getMin(), fieldValue));
                    }
                }
            }
            // 抽样统计,KEY=CUBOID,VALUE=HLLCount
            if (rowCount % 100 < samplingPercentage) {
                putRowKeyToHLL(row);
            }
            
            if (rowCount % 100 == 0) {
                dictColDeduper.resetIfShortOfMem();
            }
            rowCount++;
        }
    }
    protected void doCleanup(Context context) throws IOException, InterruptedException {
        ByteBuffer hllBuf = ByteBuffer.allocate(BufferedMeasureCodec.DEFAULT_BUFFER_SIZE);
        // output each cuboid's hll to reducer, key is 0 - cuboidId
        for (CuboidStatCalculator cuboidStatCalculator : cuboidStatCalculators) {
            cuboidStatCalculator.waitForCompletion();
        }
        for (CuboidStatCalculator cuboidStatCalculator : cuboidStatCalculators) {
            Long[] cuboidIds = cuboidStatCalculator.getCuboidIds();
            HLLCounter[] cuboidsHLL = cuboidStatCalculator.getHLLCounters();
            HLLCounter hll;
            // 输出各个CuboID的去重行数HLLCount
            for (int i = 0; i < cuboidIds.length; i++) {
                hll = cuboidsHLL[i];
                tmpbuf.clear();
                tmpbuf.put((byte) FactDistinctColumnsReducerMapping.MARK_FOR_HLL_COUNTER); // one byte
                tmpbuf.putLong(cuboidIds[i]);
                outputKey.set(tmpbuf.array(), 0, tmpbuf.position());
                hllBuf.clear();
                hll.writeRegisters(hllBuf);
                outputValue.set(hllBuf.array(), 0, hllBuf.position());
                sortableKey.init(outputKey, (byte) 0);
                context.write(sortableKey, outputValue);
            }
        }
        for (Integer colIndex : dimensionRangeInfoMap.keySet()) {
            DimensionRangeInfo rangeInfo = dimensionRangeInfoMap.get(colIndex);
            DataType dataType = allCols.get(colIndex).getType();
            writeFieldValue(context, dataType, colIndex, rangeInfo.getMin());
            writeFieldValue(context, dataType, colIndex, rangeInfo.getMax());
        }
    }
 
在FactDistinctColumnPartitioner中根据SelfDefineSortableKey(COLUMN_INDEX)选择分区
    public int getPartition(SelfDefineSortableKey skey, Text value, int numReduceTasks) {
        Text key = skey.getText();
        // 统计任务
        if (key.getBytes()[0] == FactDistinctColumnsReducerMapping.MARK_FOR_HLL_COUNTER) {
            Long cuboidId = Bytes.toLong(key.getBytes(), 1, Bytes.SIZEOF_LONG);
            return reducerMapping.getReducerIdForCuboidRowCount(cuboidId);
        } else {
            // 抽取纬度值任务,直接根据COLUMN_INDEX指定分区
            return BytesUtil.readUnsigned(key.getBytes(), 0, 1);
        }
    }
 
在FactDistinctColumnsReducer中输出去重后的维度值或输出通过HLL近似算法统计CuboID去重后的行数
    public void doReduce(SelfDefineSortableKey skey, Iterable<Text> values, Context context) throws IOException, InterruptedException {
        Text key = skey.getText();
        
        // 统计逻辑
        if (isStatistics) {
            // for hll
            long cuboidId = Bytes.toLong(key.getBytes(), 1, Bytes.SIZEOF_LONG);
            for (Text value : values) {
                HLLCounter hll = new HLLCounter(cubeConfig.getCubeStatsHLLPrecision());
                ByteBuffer bf = ByteBuffer.wrap(value.getBytes(), 0, value.getLength());
                hll.readRegisters(bf);
                // 累计Mapper输出的各个CuboID未去重的行数(每个Reduce处理部分CuboIDs)
                totalRowsBeforeMerge += hll.getCountEstimate();
                if (cuboidId == baseCuboidId) {
                    baseCuboidRowCountInMappers.add(hll.getCountEstimate());
                }
                // 合并CuboID
                if (cuboidHLLMap.get(cuboidId) != null) {
                    cuboidHLLMap.get(cuboidId).merge(hll);
                } else {
                    cuboidHLLMap.put(cuboidId, hll);
                }
            }
        } else {
            String value = Bytes.toString(key.getBytes(), 1, key.getLength() - 1);
            logAFewRows(value);
            // if dimension col, compute max/min value
            if (cubeDesc.listDimensionColumnsExcludingDerived(true).contains(col)) {
                if (minValue == null || col.getType().compare(minValue, value) > 0) {
                    minValue = value;
                }
                if (maxValue == null || col.getType().compare(maxValue, value) < 0) {
                    maxValue = value;
                }
            }
            //if dict column
            if (cubeDesc.getAllColumnsNeedDictionaryBuilt().contains(col)) {
                if (buildDictInReducer) {
                    // 如果需要在Reduce阶段构建词典,则在doCleanup后构建完输出词典文件
                    // output written to baseDir/colName/colName.rldict-r-00000 (etc)
                    builder.addValue(value);
                } else {
                    // 直接输出去重后的维度值
                    byte[] keyBytes = Bytes.copy(key.getBytes(), 1, key.getLength() - 1);
                    // output written to baseDir/colName/-r-00000 (etc)
                    String fileName = col.getIdentity() + "/";
                    mos.write(BatchConstants.CFG_OUTPUT_COLUMN, NullWritable.get(), new Text(keyBytes), fileName);
                }
            }
        }
        rowCount++;
    }
 
    protected void doCleanup(Context context) throws IOException, InterruptedException {
        if (isStatistics) {
            //output the hll info;
            List<Long> allCuboids = Lists.newArrayList();
            allCuboids.addAll(cuboidHLLMap.keySet());
            Collections.sort(allCuboids);
            logMapperAndCuboidStatistics(allCuboids); // for human check
            输出通过HLL近似算法统计CuboID去重后的行数
            outputStatistics(allCuboids);
        } else {
            //dimension col
            if (cubeDesc.listDimensionColumnsExcludingDerived(true).contains(col)) {
                outputDimRangeInfo();
            }
            // dic col
            if (buildDictInReducer) {
                Dictionary<String> dict = builder.build();
                outputDict(col, dict);
            }
        }
        mos.close();
    }
 
    private void outputStatistics(List<Long> allCuboids) throws IOException, InterruptedException {
        // output written to baseDir/statistics/statistics-r-00000 (etc)
        String statisticsFileName = BatchConstants.CFG_OUTPUT_STATISTICS + "/" + BatchConstants.CFG_OUTPUT_STATISTICS;
        ByteBuffer valueBuf = ByteBuffer.allocate(BufferedMeasureCodec.DEFAULT_BUFFER_SIZE);
        // 获取进入这个Reduce各个CuboID去重后的最终统计行数
        // mapper overlap ratio at key -1
        long grandTotal = 0;
        for (HLLCounter hll : cuboidHLLMap.values()) {
            // 累计各个CuboID去重后的最终统计行数
            grandTotal += hll.getCountEstimate();
        }
        
        // 输出进入这个Reduce中的各Mapper间的重复度,totalRowsBeforeMerge / grandTotal
        double mapperOverlapRatio = grandTotal == 0 ? 0 : (double) totalRowsBeforeMerge / grandTotal;
        mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(-1), new BytesWritable(Bytes.toBytes(mapperOverlapRatio)), statisticsFileName);
        //  Mapper数量
        // mapper number at key -2
        mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(-2), new BytesWritable(Bytes.toBytes(baseCuboidRowCountInMappers.size())), statisticsFileName);
        // 抽样百分比
        // sampling percentage at key 0
        mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(0L), new BytesWritable(Bytes.toBytes(samplingPercentage)), statisticsFileName);
        // 输出进入这个Reduce的各个cuboId的最终统计结果
        for (long i : allCuboids) {
            valueBuf.clear();
            cuboidHLLMap.get(i).writeRegisters(valueBuf);
            valueBuf.flip();
            mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(i), new BytesWritable(valueBuf.array(), valueBuf.limit()), statisticsFileName);
        }
    }
2.2 基于维度去重值构建维度字典(Build Dimension Dictionary)(在kylin进程内的一个线程中去创建所有维度的dictionary)

  这一步是根据上一步生成的distinct column文件和维度表计算出所有维度的词典信息,词典是为了节约存储而设计的,用于将一个成员值编码成一个整数类型并且可以通过整数值获取到原始成员值,每一个cuboid的成员是一个key-value形式存储在hbase中,key是维度成员的组合,但是一般情况下维度是一些字符串之类的值(例如商品名),所以可以通过将每一个维度值转换成唯一整数而减少内存占用,在从hbase查找出对应的key之后再根据词典获取真正的成员值。使用字典的好处是有很好的数据压缩率,可降低存储空间,同时也提升存储读取的速度。缺点是构建字典需要较多的内存资源,创建维度基数超过千万的容易造成内存溢出。

   这一步是在kylin进程内的一个线程中执行的,它会创建所有维度的dictionary,如果是事实表上的维度则可以从上一步生成的文件中读取该列的distinct成员值(FileTable),否则则需要从原始的hive表中读取每一列的信息(HiveTable),根据不同的源(文件或者hive表)获取所有的列去重之后的成员列表,然后根据这个列表生成dictionary,kylin中针对不同类型的列使用不同的实现方式,对于time之类的(date、time、dtaetime和timestamp)使用DateStrDictionary,这里目前还存在着一定的问题,因为这种编码方式会首先将时间转换成‘yyyy-MM-dd’的格式,会导致timestamp之类的精确时间失去天以后的精度。针对数值型的使用NumberDictionary,其余的都使用一般的TrieDictionary(字典树)。这些dictionary会作为cube的元数据存储的kylin元数据库里面,执行query的时候进行转换。

   针对这一步需要注意的问题:首先,这一步的两个步骤都是在kylin进程的一个线程中执行的,第一步会加载某一个维度的所有distinct成员到内存,如果某一个维度的基数比较大 ,可能会导致内存出现OOM,然后在创建snapshotTable的时候会限制原始表的大小不能超过配置的一个上限值,如果超过则会执行失败。但是应该强调的是这里加载全部的原始维度表更可能出现OOM。

CreateDictionaryJob

2.3 保存统计信息(合并保存统计信息及基于上一个HyperLogLog模拟去重统计信息选择Cube构建算法等)

   针对上一个MR的HyperLogLog模拟去重统计结果文件baseDir/statistics/statistics-r-00000,baseDir/statistics/statistics-r-00001,合并相关统计信息,根据最终重复度选择Cube构建算法

在FactDistinctColumnsReducer中输出进入这个Reduce的各个CuboID的统计信息   

private void outputStatistics(List<Long> allCuboids) throws IOException, InterruptedException {
        // output written to baseDir/statistics/statistics-r-00000 (etc)
        String statisticsFileName = BatchConstants.CFG_OUTPUT_STATISTICS + "/" + BatchConstants.CFG_OUTPUT_STATISTICS;
        ByteBuffer valueBuf = ByteBuffer.allocate(BufferedMeasureCodec.DEFAULT_BUFFER_SIZE);
        // 获取进入这个Reduce各个CuboID去重后的最终统计行数
        // mapper overlap ratio at key -1
        long grandTotal = 0;
        for (HLLCounter hll : cuboidHLLMap.values()) {
            // 累计各个CuboID去重后的最终统计行数
            grandTotal += hll.getCountEstimate();
        }
        // 输出进入这个Reduce中的各Mapper间的重复度,totalRowsBeforeMerge / grandTotal
        double mapperOverlapRatio = grandTotal == 0 ? 0 : (double) totalRowsBeforeMerge / grandTotal;
        mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(-1), new BytesWritable(Bytes.toBytes(mapperOverlapRatio)), statisticsFileName);
        //  Mapper数量
        // mapper number at key -2
        mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(-2), new BytesWritable(Bytes.toBytes(baseCuboidRowCountInMappers.size())), statisticsFileName);
        // 抽样百分比
        // sampling percentage at key 0
        mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(0L), new BytesWritable(Bytes.toBytes(samplingPercentage)), statisticsFileName);
        // 输出进入这个Reduce的各个cuboId的最终统计结果
        for (long i : allCuboids) {
            valueBuf.clear();
            cuboidHLLMap.get(i).writeRegisters(valueBuf);
            valueBuf.flip();
            mos.write(BatchConstants.CFG_OUTPUT_STATISTICS, new LongWritable(i), new BytesWritable(valueBuf.array(), valueBuf.limit()), statisticsFileName);
        }
}
在SaveStatisticsStep保存统计信息任务阶段会去读取上一步任务产出的cuboID统计结果文件,产出最终统计信息保存到元数据引擎中并且根据各个Mapper重复度选择Cube构建算法。

 Map<Long, HLLCounter> cuboidHLLMap = Maps.newHashMap();
 long totalRowsBeforeMerge = 0;
 long grantTotal = 0;
 int samplingPercentage = -1;
 int mapperNumber = -1;
 for (Path item : statisticsFiles) {
 // 读取解析统计文件
CubeStatsReader.CubeStatsResult cubeStatsResult = new CubeStatsReader.CubeStatsResult(item,
                        kylinConf.getCubeStatsHLLPrecision());            
                // 获取各个CuboID的计数器
                cuboidHLLMap.putAll(cubeStatsResult.getCounterMap());
                long pGrantTotal = 0L;
                for (HLLCounter hll : cubeStatsResult.getCounterMap().values()) {
                    pGrantTotal += hll.getCountEstimate();
                }                
                // 累计所有Mapper输出的cuboID行数
                totalRowsBeforeMerge += pGrantTotal * cubeStatsResult.getMapperOverlapRatio();
                // 累计去重后的cuboID统计行数
                grantTotal += pGrantTotal;
            double mapperOverlapRatio = grantTotal == 0 ? 0 : (double) totalRowsBeforeMerge / grantTotal;
            CubingJob cubingJob = (CubingJob) getManager()
                    .getJob(CubingExecutableUtil.getCubingJobId(this.getParams()));
            // fact源数据行数
            long sourceRecordCount = cubingJob.findSourceRecordCount();
           
            // 保存CuboID最终统计信息到最终统计文件cuboid_statistics.seq中
            // cuboidHLLMap CuboID的统计信息
            // samplingPercentage 抽样百分比
            // mapperNumber Mapper数
            // mapperOverlapRatio 各个Mapper间的重复度
            // sourceRecordCount fact源数据行数
            CubeStatsWriter.writeCuboidStatistics(hadoopConf, statisticsDir, cuboidHLLMap, samplingPercentage,mapperNumber, mapperOverlapRatio, sourceRecordCount);
            Path statisticsFile = new Path(statisticsDir, BatchConstants.CFG_STATISTICS_CUBOID_ESTIMATION_FILENAME);
            logger.info(newSegment + " stats saved to hdfs " + statisticsFile);
            FSDataInputStream is = fs.open(statisticsFile);
            try {
 
                // put the statistics to metadata store
                // 把统计信息存储到kylin的元数据引擎中
                String resPath = newSegment.getStatisticsResourcePath();
                rs.putResource(resPath, is, System.currentTimeMillis());
                logger.info(newSegment + " stats saved to resource " + resPath);
                // 根据抽样数据计算重复度,选择Cube构建算法,如mapperOverlapRatio > 7 选逐层算法,否则选快速算法
                StatisticsDecisionUtil.decideCubingAlgorithm(cubingJob, newSegment);
                StatisticsDecisionUtil.optimizeCubingPlan(newSegment);
            } finally {
                IOUtils.closeStream(is);
}
用户该如何选择算法呢?无需担心,Kylin会自动选择合适的算法。Kylin在计算Cube之前对数据进行采样,在“fact distinct”步,利用HyperLogLog模拟去重,估算每种组合有多少不同的key,从而计算出每个Mapper输出的数据大小,以及所有Mapper之间数据的重合度,据此来决定采用哪种算法更优。在对上百个Cube任务的时间做统计分析后,Kylin选择了7做为默认的算法选择阀值(参数kylin.cube.algorithm.layer-or-inmem-threshold):如果各个Mapper的小Cube的行数之和,大于reduce后的Cube行数的7倍,采用Layered Cubing, 反之采用Fast Cubing。如果用户在使用过程中,更倾向于使用Fast Cubing,可以适当调大此参数值,反之调小。

org.apache.kylin.engine.mr.steps.SaveStatisticsStep               

 int mapperNumLimit = kylinConf.getCubeAlgorithmAutoMapperLimit();
                double overlapThreshold = kylinConf.getCubeAlgorithmAutoThreshold(); // 默认7
                logger.info("mapperNumber for " + seg + " is " + mapperNumber + " and threshold is " + mapperNumLimit);
                logger.info("mapperOverlapRatio for " + seg + " is " + mapperOverlapRatio + " and threshold is "+ overlapThreshold);
                // in-mem cubing is good when
                // 1) the cluster has enough mapper slots to run in parallel
                // 2) the mapper overlap ratio is small, meaning the shuffle of in-mem MR has advantage
                alg = (mapperNumber <= mapperNumLimit && mapperOverlapRatio <= overlapThreshold)//
                        ? CubingJob.AlgorithmEnum.INMEM     // 快速算法
                        : CubingJob.AlgorithmEnum.LAYER;    // 逐层算法
三、 构建Cube

3.1 计算BaseCuboid文件(Build Base Cuboid Data)(执行一个MapReduce任务)

   何谓Base cuboid呢?假设一个cube包含了四个维度:A/B/C/D,那么这四个维度成员间的所有可能的组合就是base cuboid,这就类似在查询的时候指定了select count(1) from xxx group by A,B,C,D;这个查询结果的个数就是base cuboid集合的成员数。这一步也是通过一个MR任务完成的,输入是临时表的路径和分隔符,map对于每一行首先进行split,然后获取每一个维度列的值组合作为rowKey,但是rowKey并不是简单的这些维度成员的内容组合,而是首先将这些内容从dictionary中查找出对应的id,然后组合这些id得到rowKey,这样可以大大缩短hbase的存储空间,提升查找性能。然后在查找该行中的度量列。这个MR任务还会执行combiner过程,执行逻辑和reducer相同,在reducer中的key是一个rowKey,value是相同的rowKey的measure组合的数组,reducer会分解出每一个measure的值,然后再根据定义该度量使用的聚合函数计算得到这个rowKey的结果,其实这已经类似于hbase存储的格式了。

org.apache.kylin.engine.mr.steps.BaseCuboidJob

org.apache.kylin.engine.mr.steps.HiveToBaseCuboidMapper

org.apache.kylin.engine.mr.steps.CuboidReducer

3.2 计算第N层cuboid文件(Build N-Dimension Cuboid Data)(执行N个MapReduce任务)

  这一个流程是由多个步骤的,它是根据维度组合的cuboid的总数决定的,上一层cuboid执行MR任务的输入是下一层cuboid计算的输出,由于最底层的cuboid(base)已经计算完成,所以这几步不需要依赖于任何的hive信息,它的reducer和base cuboid的reducer过程基本一样的(相同rowkey的measure执行聚合运算),mapper的过程只需要根据这一行输入的key(例如A、B、C、D中某四个成员的组合)获取可能的下一层的的组合(例如只有A、B、C和B、C、D),那么只需要将这些可能的组合提取出来作为新的key,value不变进行输出就可以了。

举个例子,假设一共四个维度A/B/C/D,他们的成员分别是(A1、A2、A3),(B1、B2)、(C1)、(D1),有一个measure(对于这列V,计算sum(V)),这里忽略dictionary编码。原始表如下:

A

B

C

D

V

A1

B1

C1

D1

2

A1

B2

C1

D1

3

A2

B1

C1

D1

5

A3

B1

C1

D1

6

A3

B2

C1

D1

8

那么base cuboid最终的输出如下

(<A1、B1、C1、D1>、2)

(<A1、B2、C1、D1>, 3)

(<A2、B1、C1、D1>, 5)

(<A3、B1、C1、D1>, 6)

(<A3、B2、C1、D1>, 8)

那么它作为下面一个cuboid的输入,对于第一行输入

(<A1、B1、C1、D1>, 2),mapper执行完成之后会输出

(<A1、B1、C1>, 2)、

(<A1、B1、D1>, 2)、

(<A1、C1、D1>, 2)、

(<B1、C1、D1>, 2)这四项,

同样对于其他的内一行也会输出四行,最终他们经过reducer的聚合运算,得到如下的结果:

(<A1、B1、C1>, 2)

(<A1、B1、D1>, 2)

(<A1、C1、D1>, 2 + 3)

(<B1、C1、D1>,2 + 5 +6)

   ...

这样一次将下一层的结果作为输入计算上一层的cuboid成员,直到最顶层的cuboid,这一个层cuboid只包含一个成员,不按照任何维度进行group by。

     上面的这些步骤用于生成cuboid,假设有N个维度(对于特殊类型的),那么就需要有N +1层cuboid,每一层cuboid可能是由多个维度的组合,但是它包含的维度个数相同。

org.apache.kylin.engine.mr.steps.NDCuboidJob

org.apache.kylin.engine.mr.steps.NDCuboidMapper

org.apache.kylin.engine.mr.steps.CuboidReducer

3.3 创建HTable

  在上面几步中,我们已经将每一层的cuboid计算完成,每一层的cuboid文件都是一些cuboid的集合,每一层的cuboid的key包含相同的维度个数,下面一步就是将这些cuboid文件导入到hbase中,根据上一步计算出的rowKey分布情况(split数组)创建HTable,创建一个HTable的时候还需要考虑一下几个事情:1、列组的设置,2、每一个列组的压缩方式,3、部署coprocessor,4、HTable中每一个region的大小。在这一步中,列组的设置是根据用户创建cube时候设置的,在hbase中存储的数据key是维度成员的组合,value是对应聚合函数的结果,列组针对的是value的,一般情况下在创建cube的时候只会设置一个列组,该列包含所有的聚合函数的结果;在创建HTable时默认使用LZO压缩,如果不支持LZO则不进行压缩,在后面kylin的版本中支持更多的压缩方式;kylin强依赖于hbase的coprocessor,所以需要在创建HTable为该表部署coprocessor,这个文件会首先上传到HBase所在的HDFS上,然后在表的元信息中关联,这一步很容易出现错误,例如coprocessor找不到了就会导致整个regionServer无法启动,所以需要特别小心;region的划分已经在上一步确定了,所以这里不存在动态扩展的情况,所以kylin创建HTable使用的接口如下:

public void createTable( final HTableDescriptor desc , byte [][] splitKeys)。

CreateHTableJob

3.4 转换HFile文件

  创建完了HTable之后一般会通过插入接口将数据插入到表中,但是由于cuboid中的数据量巨大,频繁的插入会对Hbase的性能有非常大的影响,所以kylin采取了首先将cuboid文件转换成HTable格式的HFile文件,然后在通过bulkLoad的方式将文件和HTable进行关联,这样可以大大降低Hbase的负载,这个过程通过一个MR任务完成。

  这个任务的输入是所有的cuboid文件,在mapper阶段根据每一个cuboid成员的key-value输出,如果cube定义时指定了多个列组,那么同一个key要按照不同列组中的值分别输出,例如在cuboid文件中存在一行cuboid=1,key=1,value=sum(cost),count(1)的数据,而cube中将这两个度量划分到两个列组中,这时候对于这一行数据,mapper的输出为<1, sum(cost)>和<1,count(1)>。reducer使用的是org.apache.hadoop.hbase.mapreduce.KeyValueSortReducer,它会按照行排序输出,如果一行中包含多个值,那么会将这些值进行排序再输出。输出的格式则是根据HTable的文件格式定义的。

CubeHFileJob

3.5 BulkLoad文件

  这一步将HFile文件load到HTable中,因为load操作会将原始的文件删除(相当于remove),在操作之前首先将所有列组的Hfile的权限都设置为777,然后再启动LoadIncrementalHFiles任务执行load操作,它的输入为文件的路径和HTable名,这一步完全依赖于HBase的工具。这一步完成之后,数据已经存储到HBase中了,key的格式由cuboid编号+每一个成员在字典树的id组成,value可能保存在多个列组里,包含在原始数据中按照这几个成员进行GROUP BY计算出的度量的值。

BulkLoadJob

四、 收尾工作

  执行完上一步就已经完成了从输入到输出的计算过程,接下来要做的就是一些kylin内部的工作,分别是更新Cube元数据,更新cube状态,临时数据清理。

4.1 更新Cube元数据信息

  这一步主要是更新cube的状态,其中需要更新的包括cube是否可用、以及本次构建的数据统计,包括构建完成的时间,输入的record数目,输入数据的大小,保存到Hbase中数据的大小等,并将这些信息持久到元数据库中。

UpdateCubeInfoAfterBuildStep

4.2 清理临时数据

  这一步是否成功对正确性不会有任何影响,因为经过上一步之后这个segment就可以在这个cube中被查找到了,但是在整个执行过程中产生了很多的垃圾文件,其中包括:1、临时的hive表,2、因为hive表是一个外部表,存储该表的文件也需要额外删除,3、fact distinct 这一步将数据写入到HDFS上为建立词典做准备,这时候也可以删除了,4、rowKey统计的时候会生成一个文件,此时可以删除。5、生成HFile时文件存储的路径和hbase真正存储的路径不同,虽然load是一个remove操作,但是上层的目录还是存在的,也需要删除。这一步kylin做的比较简单,并没有完全删除所有的临时文件,其实在整个计算过程中,真正还需要保留的数据只有多个cuboid文件(需要增量build的cube),这个因为在不同segment进行merge的时候是基于cuboid文件的,而不是根据HTable的。

GarbageCollectionStep

Cuboid 的维度和指标如何转换为HBase的KV结构

简单的说Cuboid的维度会映射为HBase的Rowkey,Cuboid的指标会映射为HBase的Value。如下图所示: 图2

如上图原始表所示:Hive表有两个维度列year和city,有一个指标列price。

如上图预聚合表所示:我们具体要计算的是year和city这两个维度所有维度组合(即4个cuboid)下的sum(priece)指标,这个指标的具体计算过程就是由MapReduce完成的。

如上图字典编码所示:为了节省存储资源,Kylin对维度值进行了字典编码。图中将beijing和shanghai依次编码为0和1。

如上图HBase KV存储所示:在计算cuboid过程中,会将Hive表的数据转化为HBase的KV形式。Rowkey的具体格式是cuboid id + 具体的维度值(最新的Rowkey中为了并发查询还加入了ShardKey),以预聚合表内容的第2行为例,其维度组合是(year,city),所以cuboid id就是00000011,cuboid是8位,具体维度值是1994和shanghai,所以编码后的维度值对应上图的字典编码也是11,所以HBase的Rowkey就是0000001111,对应的HBase Value就是sum(priece)的具体值。

所有的cuboid计算完成后,会将cuboid转化为HBase的KeyValue格式生成HBase的HFile,最后将HFile load进cube对应的HBase表中。

Cube 构建过程重要源码分析
1 从Hive表生成Base Cuboid

在实际的cube构建过程中,会首先根据cube的Hive事实表和维表生成一张大宽表,然后计算大宽表列的基数,建立维度字典,估算cuboid的大小,建立cube对应的HBase表,再计算base cuboid。

计算base cuboid就是一个MapReduce作业,其输入是上面提到的Hive大宽表,输出的是key是各种维度组合,value是Hive大宽表中指标的值。

org.apache.kylin.engine.mr.steps.BaseCuboidJob

org.apache.kylin.engine.mr.steps.HiveToBaseCuboidMapper

org.apache.kylin.engine.mr.steps.CuboidReducer

map阶段生成key-value的代码如下:   

public void doMap(KEYIN key, Object value, Context context) throws IOException, InterruptedException {
        Collection<String[]> rowCollection = flatTableInputFormat.parseMapperInput(value);
        for (String[] row: rowCollection) {
            try {
                outputKV(row, context);
            } catch (Exception ex) {
                handleErrorRecord(row, ex);
            }
        }
 
    }
2 从Base Cuboid 逐层计算 Cuboid(Cube构建算法-逐层算法)

从base cuboid 逐层计算每层的cuboid,也是MapReduce作业,map阶段每层维度数依次减少。

org.apache.kylin.engine.mr.steps.NDCuboidJob
org.apache.kylin.engine.mr.steps.NDCuboidMapper
org.apache.kylin.engine.mr.steps.CuboidReducer
        public void doMap(Text key, Text value, Context context) throws Exception {
            long cuboidId = rowKeySplitter.split(key.getBytes());
            Cuboid parentCuboid = Cuboid.findForMandatory(cubeDesc, cuboidId);
            /**
             * Build N-Dimension Cuboid
              ## 构建N维cuboid
              这些步骤是“逐层”构建cube的过程,每一步以前一步的输出作为输入,然后去掉一个维度以聚合得到一个子cuboid。举个例子,cuboid ABCD去掉A得到BCD,去掉B得到ACD。
              有些cuboid可以从一个以上的父cuboid聚合得到,这种情况下,Kylin会选择最小的一个父cuboid。举例,AB可以从ABC(id:1110)和ABD(id:1101)生成,则ABD会被选中,因为它的比ABC要小。
              在这基础上,如果D的基数较小,聚合运算的成本就会比较低。所以,当设计rowkey序列的时候,请记得将基数较小的维度放在末尾。这样不仅有利于cube构建,而且有助于cube查询,因为预聚合也遵循相同的规则。
              通常来说,从N维到(N/2)维的构建比较慢,因为这是cuboid数量爆炸性增长的阶段:N维有1个cuboid,(N-1)维有N个cuboid,(N-2)维有(N-2)*(N-1)个cuboid,以此类推。经过(N/2)维构建的步骤,整个构建任务会逐渐变快。
             */
            Collection<Long> myChildren = cuboidScheduler.getSpanningCuboid(cuboidId);
            // if still empty or null
            if (myChildren == null || myChildren.size() == 0) {
                context.getCounter(BatchConstants.MAPREDUCE_COUNTER_GROUP_NAME, "Skipped records").increment(1L);
                if (skipCounter++ % BatchConstants.NORMAL_RECORD_LOG_THRESHOLD == 0) {
                    logger.info("Skipping record with ordinal: " + skipCounter);
                }
                return;
            }           
            context.getCounter(BatchConstants.MAPREDUCE_COUNTER_GROUP_NAME, "Processed records").increment(1L);
            Pair<Integer, ByteArray> result;
            for (Long child : myChildren) {
                Cuboid childCuboid = Cuboid.findForMandatory(cubeDesc, child);
                result = ndCuboidBuilder.buildKey(parentCuboid, childCuboid, rowKeySplitter.getSplitBuffers());
                outputKey.set(result.getSecond().array(), 0, result.getFirst());
                context.write(outputKey, value);
            }         
        }
从base cuboid 逐层计算每层的cuboid,也是MapReduce作业,map阶段每层维度数依次减少,reduce阶段对指标进行聚合。

org.apache.kylin.engine.mr.steps.CuboidReducer
    public void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
        aggs.reset();  //MeasureAggregators 根据每种指标的不同类型对指标进行聚合
        for (Text value : values) {
            codec.decode(ByteBuffer.wrap(value.getBytes(), 0, value.getLength()), input);
            if (cuboidLevel > 0) { // Base Cuboid 的 cuboidLevel 是0
                aggs.aggregate(input, needAggr); //指标进行进一步聚合
            } else {
                aggs.aggregate(input);
            }
        }
        aggs.collectStates(result);
        ByteBuffer valueBuf = codec.encode(result);
        outputValue.set(valueBuf.array(), 0, valueBuf.position());
        context.write(key, outputValue);

3 读取Hive宽表直接在Mapper端预聚合构建完整Cube(Cube构建算法-快速算法)

快速算法的核心思想是清晰简单的,就是最大化利用Mapper端的CPU和内存,对分配的数据块,将需要的组合全都做计算后再输出给Reducer;由Reducer再做一次合并(merge),从而计算出完整数据的所有组合。如此,经过一轮Map-Reduce就完成了以前需要N轮的Cube计算。本质就是在Mapper端基于内存提前做预聚合。

org.apache.kylin.engine.mr.steps.InMemCuboidJob
org.apache.kylin.engine.mr.steps.InMemCuboidMapper
org.apache.kylin.engine.mr.steps.InMemCuboidReducer
map阶段生成key-value的代码如下:
    public void doMap(KEYIN key, VALUEIN value, Context context) throws IOException, InterruptedException {
        // put each row to the queue
        T row = getRecordFromKeyValue(key, value);
        if (offer(context, row, 1, TimeUnit.MINUTES, 60)) {
            counter++;
            countOfLastSplit++;
            if (counter % BatchConstants.NORMAL_RECORD_LOG_THRESHOLD == 0) {
                logger.info("Handled " + counter + " records, internal queue size = " + queue.size());
            }
        } else {
            throw new IOException("Failed to offer row to internal queue due to queue full!");
        }
        if (counter % unitRows == 0 && shouldCutSplit(nSplit, countOfLastSplit)) {
            if (offer(context, inputConverterUnit.getCutRow(), 1, TimeUnit.MINUTES, 60)) {
                countOfLastSplit = 0;
            } else {
                throw new IOException("Failed to offer row to internal queue due to queue full!");
            }
            nSplit++;
        }
}
 
reduce阶段整体合并的代码如下:
    public void doReduce(ByteArrayWritable key, Iterable<ByteArrayWritable> values, Context context) throws IOException, InterruptedException {
        aggs.reset();
        for (ByteArrayWritable value : values) {
            if (vcounter++ % BatchConstants.NORMAL_RECORD_LOG_THRESHOLD == 0) {
                logger.info("Handling value with ordinal (This is not KV number!): " + vcounter);
            }
            codec.decode(value.asBuffer(), input);
            aggs.aggregate(input);
        }
        aggs.collectStates(result);
        // output key
        outputKey.set(key.array(), key.offset(), key.length());
        // output value
        ByteBuffer valueBuf = codec.encode(result);
        outputValue.set(valueBuf.array(), 0, valueBuf.position());
        context.write(outputKey, outputValue);
    }
4 Cuboid 转化为HBase的HFile。

主要就是数据格式的转化。详情请参考: Hive 数据 bulkload 导入 HBase

不同类型的指标是如何进行聚合的
每种不同的指标都会有对应的聚合算法,所有指标聚合的基类是org.apache.kylin.measure.MeasureAggregator。其核心方法如下:   

    abstract public void reset();
    //不同类型的指标算法会实现该方法
    abstract public void aggregate(V value);
    abstract public V getState();
 
以最简单的long类型的sum指标为例:   

public class LongSumAggregator extends MeasureAggregator<LongMutable> {
        LongMutable sum = new LongMutable();
        @Override
        public void reset() {
            sum.set(0);
        }
        @Override
        public void aggregate(LongMutable value) {
            sum.set(sum.get() + value.get());
        }
        @Override
        public LongMutable getState() {
            return sum;
        }
}
SQL查询是如何转化为HBase的Scan操作的

还是以图2举例,假设查询SQL如下:   

select year, sum(price)
from table
where city = "beijing"
group by year
这个SQL涉及维度year和city,所以其对应的cuboid是00000011,又因为city的值是确定的beijing,所以在Scan HBase时就会Scan Rowkey以00000011开头且city的值是beijing的行,取到对应指标sum(price)的值,返回给用户。

总结
本文主要介绍了Apache Kylin是如何将Hive表中的数据转化为HBase的KV结构,并简单介绍了Kylin的SQL查询是如何转化为HBase的Scan操作。希望对大家有所帮助。
--------------------- 

Kylin三大引擎和Cube构建源码解析


    最近在工作中用到了kylin,相关资料还不是很多,关于源码的更是少之又少,于是结合《kylin权威指南》、《基于Apache Kylin构建大数据分析平台》、相关技术博客和自己对部分源码的理解进行了整理。

一、工作原理
每一个Cube都可以设定自己的数据源、计算引擎和存储引擎,这些设定信息均保存在Cube的元数据中。在构建Cube时,首先由工厂类创建数据源、计算引擎和存储引擎对象。这三个对象独立创建,相互之间没有关联。


要把它们串联起来,使用的是适配器模式。计算引擎好比是一块主板,主控整个Cube的构建过程。它以数据源为输入,以存储为Cube的输出,因此也定义了IN和OUT两个接口。数据源和存储引擎则需要适配IN和OUT,提供相应的接口实现,把自己接入计算引擎,适配过程见下图。适配完成之后,数据源和存储引擎即可被计算引擎调用。三大引擎连通,就能协同完成Cube构建。

计算引擎只提出接口需求,每个接口都可以有多种实现,也就是能接入多种不同的数据源和存储。类似的,每个数据源和存储也可以实现多个接口,适配到多种不同的计算引擎上。三者之间是多对多的关系,可以任意组合,十分灵活。
二、三大主要接口
(一)数据源接口ISource

·adaptToBuildEngine:适配指定的构建引擎接口。返回一个对象,实现指定的IN接口。该接口主要由计算引擎调用,要求数据源向计算引擎适配。如果数据源无法提供指定接口的实现,则适配失败,Cube构建将无法进行。
·createReadableTable:返回一个ReadableTable,用来顺序读取一个表。除了计算引擎之外,有时也会调用此方法顺序访问数据维表的内容,用来创建维度字典或维表快照。
(二)存储引擎接口IStorage

·adaptToBuildEngine:适配指定的构建引擎接口。返回一个对象,实现指定的OUT接口。该接口主要由计算引擎调用,要求存储引擎向计算引擎适配。如果存储引擎无法提供指定接口的实现,则适配失败,Cube构建无法进行。
·createQuery:创建一个查询对象IStorageQuery,用来查询给定的IRealization。简单来说,就是返回一个能够查询指定Cube的对象。IRealization是在Cube之上的一个抽象。其主要的实现就是Cube,此外还有被称为Hybrid的联合Cube。
(三)计算引擎接口IBatchCubingEngine

·createBatchCubingJob:返回一个工作流计划,用以构建指定的CubeSegment。这里的CubeSegment是一个刚完成初始化,但还不包含数据的CubeSegment。返回的DefaultChainedExecutable是一个工作流的描述对象。它将被保存并由工作流引擎在稍后调度执行,从而完成Cube的构建。
·createBatchMergeJob:返回一个工作流计划,用以合并指定的CubeSegment。这里的CubeSegment是一个待合并的CubeSegment,它的区间横跨了多个现有的CubeSegment。返回的工作流计划一样会在稍后被调度执行,执行的过程会将多个现有的CubeSegment合并为一个,从而降低Cube的碎片化成都。
·getSourceInterface:指明该计算引擎的IN接口。
·getStorageInterface:指明该计算引擎的OUT接口。
三、三大引擎互动过程
1.Rest API接收到构建(合并)CubeSegment的请求。
2.EngineFactory根据Cube元数据的定义,创建IBatchCubingEngine对象,并调用其上的createBatchCubingJob(或者createBatchMergeJob)方法。
3.IBatchCubingEngine根据Cube元数据的定义,通过SourceFactory和StorageFactory创建出相应的数据源ISource和存储IStorage对象。
4.IBatchCubingEngine调用ISource上的adaptToBuildEngine方法传入IN接口,要求数据源向自己适配。
5.IBatchCubingEngine调用IStorage上的adaptToBuildEngine方法,传入OUT接口,要求存储引擎向自己适配。
6.适配成功后,计算引擎协同数据源和存储引擎计划Cube构建的具体步骤,将结果以工作流的形式返回。
7.执行引擎将在稍后执行工作流,完成Cube构建。
四、Kylin默认三大引擎Hive+MapReduce+HBase的介绍和代码实现
(一)构建引擎MapReduce
每一个构建引擎必须实现接口IBatchCubingEngine,并在EngineFactory中注册实现类。只有这样才能在Cube元数据中引用该引擎,否则会在构建Cube时出现“找不到实现”的错误。
注册的方法是通过kylin.properties来完成的。在其中添加一行构建引擎的声明。比如:


EngineFactory在启动时会读取kylin.properties,默认引擎即为标号2的MRBatchCubingEngine2这个引擎。
1.MRBatchCubingEngine2

这是一个入口类,构建Cube的主要逻辑都封装在BatchCubingJobBuilder2和BatchMergeJobBuilder2中。其中的DefaultChainedExecutable,代表了一种可执行的对象,其中包含了很多子任务。它执行的过程就是一次串行执行每一个子任务,直到所有子任务都完成。kylin的构建比较复杂,要执行很多步骤,步骤之间有直接的依赖性和顺序性。DefaultChainedExecutable很好地抽象了这种连续依次执行的模型,可以用来表示Cube的构建的工作流。
另外,重要的输入输出接口也在这里进行声明。IMRInput是IN接口,由数据源适配实现;IMROutput2是OUT接口,由存储引擎适配实现。
2.BatchCubingJobBuilder2
BatchCubingJobBuilder2和BatchMergeJobBuilder2大同小异,这里以BatchCubingJobBuilder2为例。


BatchCubingJobBuilder2中的成员变量IMRBatchCubingInputSide inputSide和IMRBatchCubingOutputSide2 outputSide分别来自数据源接口IMRInput和存储接口IMROutput2,分别代表着输入和输出两端参与创建工作流。
BatchCubingJobBuilder2的主体函数build()中,整个Cube构建过程是一个子任务一次串行执行的过程,这些子任务又被分为四个阶段。
第一阶段:创建平表。
这一阶段的主要任务是预计算连接运算符,把事实表和维表连接为一张大表,也称为平表。这部分工作可通过调用数据源接口来完成,因为数据源一般有现成的计算表连接方法,高效且方便,没有必要在计算引擎中重复实现。
第二阶段:创建字典。
创建字典由三个子任务完成,由MR引擎完成,分别是抽取列值、创建字典和保存统计信息。是否使用字典是构建引擎的选择,使用字典的好处是有很好的数据压缩率,可降低存储空间,同时也提升存储读取的速度。缺点是构建字典需要较多的内存资源,创建维度基数超过千万的容易造成内存溢出。
第三阶段:构建Cube。
其中包含两种构建cube的算法,分别是分层构建和快速构建。对于不同的数据分布来说它们各有优劣,区别主要在于数据通过网络洗牌的策略不同。两种算法的子任务将全部被加入工作流计划中,在执行时会根据源数据的统计信息自动选择一种算法,未被选择的算法的子任务将被自动跳过。在构建cube的最后还将调用存储引擎的接口,存储引擎负责将计算完的cube放入引擎。
第四阶段:更新元数据和清理。
最后阶段,cube已经构建完毕,MR引擎将首先添加子任务更新cube元数据,然后分别调用数据源接口和存储引擎接口对临时数据进行清理。
3.IMRInput
这是BatchCubingJobBuilder2对数据源的要求,所有希望接入MRBatchCubingEngine2的数据源都必须实现该接口。

·getTableInputFormat方法返回一个IMRTableInputFormat对象,用以帮助MR任务从数据源中读取指定的关系表,为了适应MR编程接口,其中又有两个方法,configureJob在启动MR任务前被调用,负责配置所需的InputFormat,连接数据源中的关系表。由于不同的InputFormat所读入的对象类型各不相同,为了使得构建引擎能够统一处理,因此又引入了parseMapperInput方法,对Mapper的每一行输入都会调用该方法一次,该方法的输入是Mapper的输入,具体类型取决于InputFormat,输出为统一的字符串数组,每列为一个元素。整体表示关系表中的一行。这样Mapper救能遍历数据源中的表了。
·getBatchCubingInputSide方法返回一个IMRBatchCubingInputSide对象,参与创建一个CubeSegment的构建工作流,它内部包含三个方法,addStepPhase1_CreateFlatTable()方法由构建引擎调用,要求数据源在工作流中添加步骤完成平表的创建;getFlatTableInputFormat()方法帮助MR任务读取之前创建的平表;addStepPhase4_Cleanup()是进行清理收尾,清除已经没用的平表和其它临时对象,这三个方法将由构建引擎依次调用。
4.IMROutput2

这是BatchCubingJobBuilder2对存储引擎的要求,所有希望接入BatchCubingJobBuilder2的存储都必须实现该接口。
IMRBatchCubingOutputSide2代表存储引擎配合构建引擎创建工作流计划,该接口的内容如下:
·addStepPhase2_BuildDictionary:由构建引擎在字典创建后调用。存储引擎可以借此机会在工作流中添加步骤完成存储端的初始化或准备工作。
·addStepPhase3_BuildCube:由构建引擎在Cube计算完毕之后调用,通知存储引擎保存CubeSegment的内容。每个构建引擎计算Cube的方法和结果的存储格式可能都会有所不同。存储引擎必须依照数据接口的协议读取CubeSegment的内容,并加以保存。
·addStepPhase4_Cleanup:由构建引擎在最后清理阶段调用,给存储引擎清理临时垃圾和回收资源的机会。
(二)数据源Hive
Hive是kylin的默认数据源,由于数据源的实现依赖构建引擎对输入接口的定义,因此本节的具体内容只适用于MR引擎。
数据源HiveSource首先要实现ISource接口。


HiveSource实现了ISource接口中的方法。adaptToBuildEngine()只能适配IMRInput,返回HiveMRInput实例。另一个方法createReadableTable()返回一个ReadableTable对象,提供读取一张hive表的能力。
HiveMRInput

HiveMRInput实现了IMRInput接口,实现了它的两个方法。
一是HiveTableInputFormat实现了IMRTableInputFormat接口,它主要使用了HCatInputFormat作为MapReduce的输入格式,用通用的方式读取所有类型的Hive表。Mapper输入对象为DefaultHCatRecord,统一转换为String[]后交由构建引擎处理。

二是BatchCubingInputSide实现了IMRBatchCubingInputSide接口。主要实现了在构建的第一阶段创建平表的步骤。首先用count(*)查询获取Hive平表的总行数,然后用第二句HQL创建Hive平表,同时添加参数根据总行数分配Reducer数目。

(三)存储引擎HBase
存储引擎HBaseStorage实现了IStorage接口。

·createQuery方法,返回指定IRealization(数据索引实现)的一个查询对象。因为HBase存储是为Cube定制的,所以只支持Cube类型的数据索引。具体的IStorageQuery实现应根据存储引擎的版本而有所不同。
·adaptToBuildEngine方法,适配IMROutput2的输出接口。
HBaseMROutput2
观察IMRBatchCubingOutputSide2的实现。它在两个时间点参与Cube构建的工作流。一是在字典创建之后(Cube构造之前),在addStepPhase2_BuildDictionary()中添加了“创建HTable”这一步,估算最终CubeSegment的大小,并以此来切分HTable Regions,创建HTable。
第二个插入点是在Cube计算完毕之后,由构建引擎调用addStepPhase3_BuildCube()。这里要将Cube保存为HTable,实现分为“转换HFile”和“批量导入到HTable”两步。因为直接插入HTable比较缓慢,为了最快速地将数据导入到HTable,采取了Bulk Load的方法。先用一轮MapReduce将Cube数据转换为HBase的存储文件格式HFile,然后就可以直接将HFile导入空的HTable中,完成数据导入。
最后一个插入点是addStepPhase4_Cleanup()是空实现,对于HBase存储来说没有需要清理的资源。

五、CubingJob的构建过程
在Kylin构建CubeSegment的过程中,计算引擎居于主导地位,通过它来协调数据源和存储引擎。
在网页上向Kylin服务端发送构建新的CubeSegment的请求后,通过controller层来到service层,进入JobService类中的submitJob方法,方法内部再调用submitJobInternal方法,在build、merge和refresh的时候,通过EngineFactory.createBatchCubingJob(newSeg, submitter)返回一个job实例,从这里可以看出,CubingJob的构建入口是由计算引擎提供的,即默认的计算引擎MRBatchCubingEngine2。

Kylin所支持的所有计算引擎,都会在EngineFactory中注册,并保存在batchEngine中,可以通过配置文件kylin.properties选择计算引擎,目前Kylin支持的计算引擎有:

MRBatchCubingEngine2实现了createBatchCubingJob方法,方法内调用了BatchCubingJobBuild2的build方法。

在new的初始化过程中,super(newSegment,submitter)就是执行父类的构造方法,进行了一些属性的初始化赋值,其中的inputSide和outputSide就上上文提到的数据源和存储引擎实例,通过计算引擎的协调来进行CubingJob的构建。

数据源inputSide实例获取:


以上即为数据源实例获取过程的代码展现,BatchCubingJobBuilder2初始化的时候,调用MRUtil的getBatchCubingInputSide方法,它最终调用的其实还是MRBatchCubingEngine2这个计算引擎的getJoinedFlatTableDesc方法,它返回了一个IJoinedFlatTableDesc实例,这个对象就是对数据源表信息的封装。获得了这个flatDesc实例之后,就要来获取inputSide实例,与获取计算引擎代码类似,目前kylin中支持的数据源有:


Kylin默认的数据源是序号为0的HiveSource,所以最后调用的是HiveSource的adaptToBuildEngine,根据传入的IMRInput.class接口,最终返回得到HiveMRInput的实例,最后再通过它的getBatchCubingInputSide的方法获取inputSide的实例。
存储引擎outputSide实例获取:

以上即为存储引擎实例获取的代码展现,BatchCubingJobBuilder2初始化的时候,调用MRUtil的getBatchCubingOutputSide方法,方法内先调用了StorageFactory类的createEngineAdapter方法,方法内又调用实现了Storage接口的HBaseStorage类的adaptToBuildEngine方法,最后返回了HBaseMROutput2Transition实例,然后在通过它的getBatchCubingOutputSide方法就可以获取到outputSide的实例。目前kylin中支持的数据源有:


kylin默认的存储引擎是HBase。
——————————————————————————————————
通过构造函数,数据源、计算引擎和数据存储三个模块已经关联到一起了,上文介绍到的MRBatchCubingEngine2的方法中,在new出了一个BatchCubingJobBuild2实例后,接着就调用了build方法,最后返回了一个CubingJob实例。build方法逻辑如下:

方法的内容就是构建一个CubeSegment的步骤,依次顺序的加入到CubingJob的任务list中。
从第一行开始,调用了CubingJob的createBuildJob方法,里面又调用了initCubingJob方法。


initCubingJob方法就是获取到cube相关的一些配置信息进行初始化,它是根据cube的名字去查询所在的project,如果不同的project下创建了相同名字的cube,那返回的就会是一个List,然后看配置文件中是否开启了允许cube重名,如不允许则直接抛出异常,如果允许就在设置projectName时取返回List中的第一个元素,那么这里就可能导致projectName设置错误,所以最好保证cube的name是全局唯一的。
在CubingJob初始化之后,会获取cuboidRootPath,获取逻辑如下:

经过一连串的调用拼装,最终获取的路径格式如下:
hdfs:///kylin/kylin_metadata/kylin-jobId/cubeName/cuboid
接下来就是三大引擎相互协作,构建CubeSegment的过程,整个过程大致分为创建hive平表、创建字典、构建Cube和更新元数据和清理这四个步骤。
第一步和第四步是由数据源来实现的,具体是在HiveMRInput类实现了IMRInput接口的getBatchCubingInputSide方法中,它返回了一个BatchCubingInputSide实例,在这个类中完成了具体工作;第二步是由计算引擎实现的,依靠JobBuilderSupport类中的方法完成;第三步是由计算引擎和存储引擎共同完成的,包括构建cube和存储到HBase;第四步是由数据源和存储引擎分别完成的;我们按步骤对代码进行分析。


首先是第一步创建hive平表调用了HiveMRInput类中的静态内部类BatchCubingInputSide中的addStepPhase1_CreateFlatTable方法。

先获取cubeName、cubeConfig、hive命令(USE faltTableDatabase)三个变量。


接下来的方法就是抽取变量,进行hive命令的拼接,完成以下步骤:
一是从hive表中,将所需字段从事实表和维表中提取出来,构建一个宽表;
二是将上一步得到的宽表,按照某个字段进行重新分配,如果没有指定字段,则随机,目的是产生多个差不多大小的文件,作为后续构建任务的输入,防止数据倾斜。
三是将hive中的视图物化。
——————————————————————————————————
创建平表命令例子:
hive -e "USE default;
DROP TABLE IF EXISTS kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d;
CREATE EXTERNAL TABLE IF NOT EXISTS kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d
(
TACONFIRM_BUSINESSCODE string
,TACONFIRM_FUNDCODE string
,TACONFIRM_SHARETYPE string
,TACONFIRM_NETCODE string
,TACONFIRM_CURRENCYTYPE string
,TACONFIRM_CODEOFTARGETFUND string
,TACONFIRM_TARGETSHARETYPE string
,TACONFIRM_TARGETBRANCHCODE string
,TACONFIRM_RETURNCODE string
,TACONFIRM_DEFDIVIDENDMETHOD string
,TACONFIRM_FROZENCAUSE string
,TACONFIRM_TAINTERNALCODE string
,TACONFIRM_C_PROVICE string
,TAPROVINCE_PROVINCENAME string
,TASHARETYPE_SHARETYPENAME string
)
STORED AS SEQUENCEFILE
LOCATION 'hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-4c5d4bb4-791f-4ec3-b3d7-89780adc3f58/kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d';
ALTER TABLE kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d SET TBLPROPERTIES('auto.purge'='true');
INSERT OVERWRITE TABLE kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d SELECT
TACONFIRM.BUSINESSCODE as TACONFIRM_BUSINESSCODE
,TACONFIRM.FUNDCODE as TACONFIRM_FUNDCODE
,TACONFIRM.SHARETYPE as TACONFIRM_SHARETYPE
,TACONFIRM.NETCODE as TACONFIRM_NETCODE
,TACONFIRM.CURRENCYTYPE as TACONFIRM_CURRENCYTYPE
,TACONFIRM.CODEOFTARGETFUND as TACONFIRM_CODEOFTARGETFUND
,TACONFIRM.TARGETSHARETYPE as TACONFIRM_TARGETSHARETYPE
,TACONFIRM.TARGETBRANCHCODE as TACONFIRM_TARGETBRANCHCODE
,TACONFIRM.RETURNCODE as TACONFIRM_RETURNCODE
,TACONFIRM.DEFDIVIDENDMETHOD as TACONFIRM_DEFDIVIDENDMETHOD
,TACONFIRM.FROZENCAUSE as TACONFIRM_FROZENCAUSE
,TACONFIRM.TAINTERNALCODE as TACONFIRM_TAINTERNALCODE
,TACONFIRM.C_PROVICE as TACONFIRM_C_PROVICE
,TAPROVINCE.PROVINCENAME as TAPROVINCE_PROVINCENAME
,TASHARETYPE.SHARETYPENAME as TASHARETYPE_SHARETYPENAME
FROM DEFAULT.TACONFIRM as TACONFIRM 
INNER JOIN DEFAULT.TAPROVINCE as TAPROVINCE
ON TACONFIRM.C_PROVICE = TAPROVINCE.C_PROVICE
INNER JOIN DEFAULT.TASHARETYPE as TASHARETYPE
ON TACONFIRM.SHARETYPE = TASHARETYPE.SHARETYPE
WHERE 1=1;
" --hiveconf hive.merge.mapredfiles=false --hiveconf hive.auto.convert.join=true --hiveconf dfs.replication=2 --hiveconf hive.exec.compress.output=true --hiveconf hive.auto.convert.join.noconditionaltask=true --hiveconf mapreduce.job.split.metainfo.maxsize=-1 --hiveconf hive.merge.mapfiles=false --hiveconf hive.auto.convert.join.noconditionaltask.size=100000000 --hiveconf hive.stats.autogather=true
——————————————————————————————————
文件再分配和视图物化命令例子:
hive -e "USE default;

set mapreduce.job.reduces=3;

set hive.merge.mapredfiles=false;

INSERT OVERWRITE TABLE kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d SELECT * FROM kylin_intermediate_taconfirm_kylin_15all_ddacfb18_3d2e_4e1b_8975_f0871183418d DISTRIBUTE BY RAND();

" --hiveconf hive.merge.mapredfiles=false --hiveconf hive.auto.convert.join=true --hiveconf dfs.replication=2 --hiveconf hive.exec.compress.output=true --hiveconf hive.auto.convert.join.noconditionaltask=true --hiveconf mapreduce.job.split.metainfo.maxsize=-1 --hiveconf hive.merge.mapfiles=false --hiveconf hive.auto.convert.join.noconditionaltask.size=100000000 --hiveconf hive.stats.autogather=true
——————————————————————————————————

创建字典由三个子任务完成,分别是抽取列值、创建字典和保存统计信息,由MR引擎完成,所以直接在build方法中add到任务list中。是否使用字典是构建引擎的选择,使用字典的好处是有很好的数据压缩率,可降低存储空间,同时也提升存储读取的速度。缺点是构建字典需要较多的内存资源,创建维度基数超过千万的容易造成内存溢出。在这个过程最后,还要创建HTable,这属于存储引擎的任务,所以是在HBaseMROutput2Transition实例中完成的。
——————————————————————————————————
抽取列值步骤参数例子:
 -conf /usr/local/apps/apache-kylin-2.3.1-bin/conf/kylin_job_conf.xml -cubename Taconfirm_kylin_15all -output hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-4c5d4bb4-791f-4ec3-b3d7-89780adc3f58/Taconfirm_kylin_15all/fact_distinct_columns -segmentid ddacfb18-3d2e-4e1b-8975-f0871183418d -statisticsoutput hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-4c5d4bb4-791f-4ec3-b3d7-89780adc3f58/Taconfirm_kylin_15all/fact_distinct_columns/statistics -statisticssamplingpercent 100 -jobname Kylin_Fact_Distinct_Columns_Taconfirm_kylin_15all_Step -cubingJobId 4c5d4bb4-791f-4ec3-b3d7-89780adc3f58
——————————————————————————————————
 构建维度字典步骤参数例子 :
 -cubename Taconfirm_kylin_15all -segmentid ddacfb18-3d2e-4e1b-8975-f0871183418d -input hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-4c5d4bb4-791f-4ec3-b3d7-89780adc3f58/Taconfirm_kylin_15all/fact_distinct_columns -dictPath hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-4c5d4bb4-791f-4ec3-b3d7-89780adc3f58/Taconfirm_kylin_15all/dict
——————————————————————————————————
创建HTable步骤参数例子:
 -cubename Taconfirm_kylin_15all -segmentid ddacfb18-3d2e-4e1b-8975-f0871183418d -partitions hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-4c5d4bb4-791f-4ec3-b3d7-89780adc3f58/Taconfirm_kylin_15all/rowkey_stats/part-r-00000 -cuboidMode CURRENT
——————————————————————————————————


构建Cube属于计算引擎的任务,就是根据准备好的数据,依次产生cuboid的数据,在这里调用了两种构建方法,分别是分层构建和快速构建,但最终只会选择一种构建方法,分层构建首先调用createBaseCuboidStep方法,生成Base Cuboid数据文件,然后进入for循环,调用createNDimensionCuboidStep方法,根据Base Cuboid计算N层Cuboid数据。
在Cuboid的数据都产生好之后,还需要放到存储层中,所以接下来调用outputSide实例的addStepPhase3_BuildCube方法,HBaseMROutput2Transition类中的addStepPhase3_BuildCube方法主要有两步,一是createConvertCuboidToHfileStep方法,将计算引擎产生的cuboid数据转换成HBase要求的HFile格式,二是createBulkLoadStep方法,即把HFIle数据加载到HBase中。
——————————————————————————————————
构建Base Cuboid步骤参数例子:
 -conf /usr/local/apps/kylin/conf/kylin_job_conf.xml -cubename kylin_sales_cube -segmentid 392634bd-4964-428c-a905-9bbf28884452 -input FLAT_TABLE -output hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/cuboid/level_base_cuboid -jobname Kylin_Base_Cuboid_Builder_kylin_sales_cube -level 0 -cubingJobId 6f3c2a9e-7283-4d87-9487-a5ebaffef811
——————————————————————————————————
构建N层Cuboid步骤参数例子:
 -conf /usr/local/apps/kylin/conf/kylin_job_conf.xml -cubename kylin_sales_cube -segmentid 392634bd-4964-428c-a905-9bbf28884452 -input hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/cuboid/level_1_cuboid -output hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/cuboid/level_2_cuboid -jobname Kylin_ND-Cuboid_Builder_kylin_sales_cube_Step -level 2 -cubingJobId 6f3c2a9e-7283-4d87-9487-a5ebaffef811
——————————————————————————————————
转换HFile格式步骤参数例子:
 -conf /usr/local/apps/kylin/conf/kylin_job_conf.xml -cubename kylin_sales_cube -partitions hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/rowkey_stats/part-r-00000_hfile -input hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/cuboid/* -output hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/hfile -htablename KYLIN_O2SYZPV449 -jobname Kylin_HFile_Generator_kylin_sales_cube_Step
——————————————————————————————————
加载HFile到HBase步骤参数例子:
 -input hdfs://qtbj-sj-cdh-name:8020/kylin/kylin_metadata/kylin-6f3c2a9e-7283-4d87-9487-a5ebaffef811/kylin_sales_cube/hfile -htablename KYLIN_O2SYZPV449 -cubename kylin_sales_cube
——————————————————————————————————

最后一步就是一些收尾工作,包括更新Cube元数据信息,调用inputSide和outputSide实例进行中间临时数据的清理工作。

完成所有步骤之后,就回到了JobService的submitJob方法中,在得到CubingJob的实例后,会执行以上代码。这里做的是将CubingJob的信息物化到HBase的kylin_metadata表中,并没有真正的提交执行。
真正执行CubingJob的地方是在DefaultScheduler,它里面有一个线程会每隔一分钟,就去HBase的kylin_metadata表中扫一遍所有的CubingJob,然后将需要执行的job,提交到线程池执行。
kylin中任务的构建和执行是异步的。单个kylin节点有query、job和all三种角色,query只提供查询服务,job只提供真正的构建服务,all则兼具前两者功能。实际操作中kylin的三种角色节点都可以进行CubingJob的构建,但只有all和job模式的节点可以通过DefaultScheduler进行调度执行
--------------------- 

总目录
Kylin系列(一)—— 入门 
Kylin系列(二)—— Cube 构造算法

总目录
Kylin cube 构造算法
逐层算法(layer Cubing)
算法的优点
算法的缺点
快速Cube算法(Fast Cubing)
举个例子
子立方体生成树(Cuboid spanning Tree)的遍历次序
优点
缺点
By-layer Spark Cubing算法
改进
Spark中Cubing的过程
性能测试
Kylin cube 构造算法
逐层算法(layer Cubing)
我们知道,一个N维的Cube,是有1个N维子立方体、N个(N-1)维子立方体、N*(N-1)/2个(N-2)维子立方体、……、N个1维子立方体和1个0维子立方体构成,总共有2^N个子立方体。在逐层算法中,按照维度数逐层减少来计算,每个层级的计算(除了第一层,他是从原始数据聚合而来),是基于他上一层级的计算结果来计算的。

比如group by [A,B]的结果,可以基于group by [A,B,C]的结果,通过去掉C后聚合得来的,这样可以减少重复计算;当0维Cuboid计算出来的时候,整个Cube的计算也就完成了。

如上图所示,展示了一个4维的Cube构建过程。

此算法的Mapper和Reducer都比较简单。Mapper以上一层Cuboid的结果(key-value对)作为输入。由于Key是由各维度值拼接在一起,从其中找出要聚合的维度,去掉它的值成新的key,并对value进行操作,然后把新的key和value输出,进而Hadoop MapReduce对所有新的key进行排序、洗牌(shuffle)、再送到Reducer处;Reducer的输入会是一组具有相同key的value集合,对这些value做聚合运算,再结合key输出就完成了一轮计算。

举个例子: 
假设一共四个维度A/B/C/D,他们的成员分别是(A1、A2、A3),(B1、B2)、(C1)、(D1),有一个measure(对于这列V,计算sum(V)),这里忽略dictionary编码。原始表如下: 


那么base cuboid最终的输出如下 
(A1、B1、C1、D1、2) 
(A1、B2、C1、D1, 3) 
(A2、B1、C1、D1, 5) 
(A3、B1、C1、D1, 6) 
(A3、B2、C1、D1, 8) 
那么它作为下面一个cuboid的输入,对于第一行输入 
(A1、B1、C1、D1,2),mapper执行完成之后会输出 
(A1、B1、C1, 2)、 
(A1、B1、D1, 2)、 
(A1、C1、D1, 2)、 
(B1、C1、D1,2)这四项,同样对于其他的内一行也会输出四行,最终他们经过reducer的聚合运算,得到如下的结果: 
(A1、B1、C1, 2) 
(A1、B1、D1, 2) 
(A1、C1、D1, 2 + 3) 
(B1、C1、D1,2 + 5 +6)

这个例子其实在cube的构建过程中可以看到。

一定要注意,这里的每一轮计算都是MapReducer任务,且串行执行;一个N维的Cube,至少需要N次MapReduce Job。

算法的优点
此算法充分利用了MR的能力,处理了中间复杂的排序和洗牌工作,故而算法代码清晰简单,易于维护。
受益于Hadoop的日趋成熟,此算法对集群要求低,运行稳定。
算法的缺点
当Cube有比较多维度的时候,所需要的MR任务也相应增加;由于Hadoop的任务调度需要耗费额外资源,特别是集群较庞大的时候,反复递交任务造成的额外开销会很可观
由于Mapper不做预聚合,此算法会对Hadoop MR输出较多数据;虽然已经使用了Combiner来减少从Mapper端到Reducer端的数据传输,所有数据依然需要通过MR来排序和组合才能被聚合,无形之中增加了集群的压力。
对HDFS的读写操作较多:由于每一层计算的输出会用作下一层计算的输入,这些Key-value需要写到HDFS上;当所有计算都完成后,Kylin还需要额外一轮任务将这些文件转成Hbase的HFile格式,以导入到HBase中去。
总体而言,该算法的效率较低,尤其当Cube维度数较大的时候。
这里其实在困惑到底什么是0维,后来想明白了。举个例子,现在有一个度量叫成交量。有几个维度从大到小:业务类型、渠道、门店。3维的例子就是[业务类型、渠道、门店],二维的例子是[业务类型、渠道],一维[业务类型],0维其实就是没有维度,也就是全部聚合,举个例子就是

select sum(price) from table1
1
其实在我看来,逐层算法就是先算维度数最高的,一层算完后,再算维度数减少的一层,以此类推。至于为什么从层级高向层级低计算,而不是反过来,在于如果是反过来,那你每次的计算量都是初始数据,数据量非常大,没必要。

快速Cube算法(Fast Cubing)
快速Cube算法,它还被称作“逐段”(By Segment)或“逐块”(By Split)算法。

该算法的主要思想,对Mapper所分配的数据块,将它计算成一个完整的小Cube段(包含所有Cuboid);每个Mapper将计算完的Cube段输出给Reducer做合并,生成大Cube,也就是最终结果。

与旧算法相比,快速算法主要有两点不同:

Mapper会利用内存做预聚合,算出所有组合;Mapper输出的每个Key都是不同的,这样会减少输出到Hadoop MapReduce的数据量,Combiner也不再需要;
一轮MapReduce便会完成所有层次的计算,减少Hadoop任务的调配。
来说个比较。逐层算法的每一层的计算都有一个MapReduce任务,因为是从高维到低维的MR任务,任务之间传递的数据量是非常大的。比如上面的例子,生成4维的数据,需要在mapper中对全数据进行的整理,再传递给reducer聚合,如果数据量非常大,那么网络IO是很大的。而快速算法,它会对某个分片数据进行构造完整的cube(所有cuboid)。再将mapper中的数据送入reducer进行大聚合生成Cube。这其实是在map阶段就已经完成了聚合,IO是很小的。

举个例子
这里不理解没关系,看完后面的构建过程再翻回来看例子就能懂

一个Cube有4个维度:A,B,C,D;每个Mapper都有100万个源记录要处理;Mapper中的列基数是Car(A),Car(B),Car(C)和Car(D)。(cardinal 基数)

当讲源记录聚集到base cuboid(1111)时,使用旧的“逐层”算法,每个Mapper将向Hadoop输出1百万条记录;使用快速立方算法,在预聚合之后,它预聚合之后,它只向Hadoop输出[distinct A,B,C,D]记录的数量,这样肯定比源数据小;在正常情况下,他可以源记录大小的1/10到1/100.

当从父cuboid聚合到子cuboid时,从base cuboid(1111) 到3维cuboid 0111,将会聚合维度A;我们假设维度A与其他维度独立的,聚合后,cuboid 0111的维度base cuboid的1/Card(A);所以在这一步的输出将减少到原来的1/Card(A);

总的来说,假设维度的平均基数是Card(N),从Mapper到Reducer的写入记录可以减少到原始维度的1/Card(N);Hadoop的输出越少,I/O和计算越少,性能就越好。

这里要提一句,其实很多都是类似的,比如在hive中处理大表, 
各种的调优都和IO、计算有关系,因为他们都是基于MR任务。

子立方体生成树(Cuboid spanning Tree)的遍历次序
在旧算法中,Kylin按照层级,也就是广度优先遍历(Broad First Search)的次序计算出各个Cuboid;在快速Cube算法中,Mapper会按照深度优先遍历(Depth First Search)来计算各个Cuboid。 
深度优先遍历是一个递归方法,将父cuboid压栈以计算子Cuboid,直到没有子Cuboid需要计算才出栈并输出给Hadoop;需要最多暂存N个Cuboid,N是Cube维度数。

采用DFS,是为了兼顾CPU和内存。
从父Cuboid计算子Cuboid,避免重复计算。
只压栈当前计算的Cuboid的父Cuboid,减少内存占用。 
举个例子从3维到2维的MR任务中计算CD,BFS会压入ABC ABD ACD BCD,mapper进行切分,reducer进行聚合;而在DFS中,只会压入ABCD,BCD,内存大大减少。


上图是一个四维Cube的完整生成树:

按照DFS的次序,在0维Cuboid输出前的计算次序是ABCD-》BCD-》CD-》D-》0维,ABCD,BCD,CD和D需要被暂存;在被输出后,D可被输出,内存得到释放;在C被计算并输出后,CD就可以被输出,ABCD最后被输出。

使用DFS访问顺序,Mapper的输出已完全排序,因为Cuboid ID位于行键的开始位置,而内部的Cuboid的行已排序。


0000 
0001[D0] 
0001[D1] 
.... 
0010[C0] 
0010[C1] 
.... 
0011[C0][D0] 
0011[C0][D1] 
.... 
.... 
1111[A0][B0][C0][D0] 
.... 
这里的写法可以看构造过程。 

由于mapper的输出已经排序,Hadoop的排序效率会更高。

此外,mapper的预聚合发生在内存中,这样可以避免不必要的磁盘和网络IO,并减少了hadoop的开销。

在开发阶段,我们在mapper中遇到了OOM错误;这可能发生在: 
- Mapper的JVM堆大小很小 
- 使用 distinct count度量 
- 使用树太深(维度太多) 
- 给Mapper的数据太大

我们意识到Kylin不能认为mapper总是有足够的内存;Cubing算法需要自适应各种情况;

当主动检测到OOM错误,会优化内存使用并将数据spilling到磁盘上;结果是有希望的,OOM错误现在很少发生。

优点
它比旧的方法更快;从我们的比较测试中可以减少30%到50%的build总时间:快在排序,快在IO。
他在Hadoop上产生较少的工作负载,并在HDFS上留下较少的中间文件。
Cubing和Spark等其他立方体引起可以轻松地重复使用该立方体代码。
缺点
该算法有点复杂,这增加了维护工作;

虽然该算法可以自动将数据spill到磁盘,但他仍希望Mapper有足够的内存来获得最佳性能。

用户需要更多知识来调整立方体。

By-layer Spark Cubing算法
我们知道,RDD(Resilient Distributed DataSet)是Spark中的一个基本概念。N维立方体的组合可以很好地描述为RDD,N维立方体将具有N+1个RDD。这些RDD具有parent/child关系,因为这些parent RDD 可用于生成child RDD。通过将父RDD缓存在内存中,子RDD的生成可以比磁盘读取更有效。

改进
每一层的cuboid视为一个RDD
父RDD被尽可能cache到内存
RDD 被导出为sequence file
通过将“map”替换为“flatMap”,以及把“reduce”替换为“reduceByKey”,可以复用大部分代码
Spark中Cubing的过程
下图DAG(有向无环图),它详细说明了这个过程:

在Stage 5中,Kylin使用HiveContext读取中间Hive表,然后执行一个一对一映射的”map”操作将原始值编码为KV字节。完成后Kylin得到一个中间编码的RDD。

在Stage 6中,中间RDD用一个“ReduceByKey”操作聚合以获得RDD-1,这是base cuboid。接下来,在RDD-1做了一个flatMap(一对多map),因为base cuboid有N个cuboid。以此类推,各级RDD得到计算。在完成时,这些RDD将完整地保存在分布式文件系统,但可以缓存在内存中用于下一级计算。当生成子cuboid时,他将从缓存中删除。

其实我们和旧的逐层算法去比较会发现,他们之间的构建没有什么大的差别,只不过Spark的是在内存中进行的,无需从磁盘读取和网络IO。并且后面的stage的第一步是reduce。

性能测试


在所有这三种情况下,Spark都比MR快,总体而言它可以减少约一半的时间。

Kylin的构建算法以及和spark的改进 
http://cxy7.com/articles/2018/06/09/1528549073259.html 
https://www.cnblogs.com/zlslch/p/7404465.html
--------------------- 

e Kylin是一个开源的分布式分析引擎,提供Hadoop之上的SQL查询接口及多维分析(OLAP)能力以支持超大规模数据。它能在亚秒内查询巨大的Hive表。本文将详细介绍Apache Kylin 1.5中的Fast-Cubing算法。

Fast Cubing,也称快速数据立方算法, 是一个新的Cube算法。我们知道,Cube的思想是用空间换时间, 通过预先的计算,把索引及结果存储起来,以换取查询时候的高性能 。在Kylin v1.5以前,Kylin中的Cube只有一种算法:layered cubing,也称逐层算法:它是逐层由底向上,把所有组合算完的过程。

Cube构建算法介绍

1 逐层算法(Layer Cubing)

  我们知道,一个N维的Cube,是由1个N维子立方体、N个(N-1)维子立方体、N*(N-1)/2个(N-2)维子立方体、......、N个1维子立方体和1个0维子立方体构成,总共有2^N个子立方体组成,在逐层算法中,按维度数逐层减少来计算,每个层级的计算(除了第一层,它是从原始数据聚合而来),是基于它上一层级的结果来计算的。

比如,[Group by A, B]的结果,可以基于[Group by A, B, C]的结果,通过去掉C后聚合得来的;这样可以减少重复计算;当 0维度Cuboid计算出来的时候,整个Cube的计算也就完成了。           

                       逐层算法

 

  如上图所示,展示了一个4维的Cube构建过程。

  此算法的Mapper和Reducer都比较简单。Mapper以上一层Cuboid的结果(Key-Value对)作为输入。由于Key是由各维度值拼接在一起,从其中找出要聚合的维度,去掉它的值成新的Key,并对Value进行操作,然后把新Key和Value输出,进而Hadoop MapReduce对所有新Key进行排序、洗牌(shuffle)、再送到Reducer处;Reducer的输入会是一组有相同Key的Value集合,对这些Value做聚合计算,再结合Key输出就完成了一轮计算。

  每一轮的计算都是一个MapReduce任务,且串行执行; 一个N维的Cube,至少需要N次MapReduce Job。

Layer Cubing算法优点

此算法充分利用了MapReduce的能力,处理了中间复杂的排序和洗牌工作,故而算法代码清晰简单,易于维护;

受益于Hadoop的日趋成熟,此算法对集群要求低,运行稳定;在内部维护Kylin的过程中,很少遇到在这几步出错的情况;即便是在Hadoop集群比较繁忙的时候,任务也能完成。

Layer Cubing算法缺点

当Cube有比较多维度的时候,所需要的MapReduce任务也相应增加;由于Hadoop的任务调度需要耗费额外资源,特别是集群较庞大的时候,反复递交任务造成的额外开销会相当可观;

由于Mapper不做预聚合,此算法会对Hadoop MapReduce输出较多数据; 虽然已经使用了Combiner来减少从Mapper端到Reducer端的数据传输,所有数据依然需要通过Hadoop MapReduce来排序和组合才能被聚合,无形之中增加了集群的压力;

对HDFS的读写操作较多:由于每一层计算的输出会用做下一层计算的输入,这些Key-Value需要写到HDFS上;当所有计算都完成后,Kylin还需要额外的一轮任务将这些文件转成HBase的HFile格式,以导入到HBase中去;

总体而言,该算法的效率较低,尤其是当Cube维度数较大的时候;时常有用户问,是否能改进Cube算法,缩短时间。

2 快速Cube算法(Fast Cubing)

  快速Cube算法(Fast Cubing)是麒麟团队对新算法的一个统称,它还被称作“逐段”(By Segment) 或“逐块”(By Split) 算法。

  该算法的主要思想是,对Mapper所分配的数据块,将它计算成一个完整的小Cube 段(包含所有Cuboid);每个Mapper将计算完的Cube段输出给Reducer做合并,生成大Cube,也就是最终结果;图2解释了此流程。新算法的核心思想是清晰简单的,就是最大化利用Mapper端的CPU和内存,对分配的数据块,将需要的组合全都做计算后再输出给Reducer;由Reducer再做一次合并(merge),从而计算出完整数据的所有组合。如此,经过一轮Map-Reduce就完成了以前需要N轮的Cube计算。图2是此算法的概览。

在Mapper内部, 也可以有一些优化,图3是一个典型的四维Cube的生成树;第一步会计算Base Cuboid(所有维度都有的组合),再基于它计算减少一个维度的组合。基于parent节点计算child节点,可以重用之前的计算结果;当计算child节点时,需要parent节点的值尽可能留在内存中; 如果child节点还有child,那么递归向下,所以它是一个深度优先遍历。当有一个节点没有child,或者它的所有child都已经计算完,这时候它就可以被输出,占用的内存就可以释放。

如果内存够的话,可以多线程并行向下聚合。如此可以最大限度地把计算发生在Mapper这一端,一方面减少shuffle的数据量,另一方面减少Reducer端的计算量。

Fast Cubing的优点:

总的IO量比以前大大减少。 

此算法可以脱离Map-Reduce而对数据做Cube计算,故可以很容易地在其它场景或框架下执行,例如Streaming 和Spark。

Fast Cubing的缺点:

代码比以前复杂了很多: 由于要做多层的聚合,并且引入多线程机制,同时还要估算JVM可用内存,当内存不足时需要将数据暂存到磁盘,所有这些都增加复杂度。

对Hadoop资源要求较高,用户应尽可能在Mapper上多分配内存;如果内存很小,该算法需要频繁借助磁盘,性能优势就会较弱。在极端情况下(如数据量很大同时维度很多),任务可能会由于超时等原因失败;

要让Fast-Cubing算法获得更高的效率,用户需要了解更多一些“内情”。

首先,在v1.5里,Kylin在对Fast-Cubing请求资源时候,默认是为Mapper任务请求3Gb的内存,给JVM2.7Gb。如果Hadoop节点可用内存较多的话,用户可以让Kylin获得更多内存:在conf/kylin_job_conf_inmem.xml文件,由参数“mapreduce.map.memory.mb”和“mapreduce.map.java.opts”设定 。

其次,需要在并发性和Mapper端聚合之间找到一个平衡。在v1.5.2里,Kylin默认是给每个Mapper分配32兆的数据;这样可以获得较高的并发性。但如果Hadoop集群规模较小,或可用资源较少,过多的Mapper会造成任务排队。这时,将数据块切得更大,如 64兆,效果会更好。数据块是由Kylin创建Hive平表时生成的, 在kylin_hive_conf.xml由参数dfs.block.size决定的。从v1.5.3开始,分配策略又有改进,给每个mapper会分配一样的行数,从而避免数据块不均匀时的木桶效应:由conf/kylin.properteis里的“kylin.job.mapreduce.mapper.input.rows”配置,默认是100万,用户可以示自己集群的规模设置更小值获得更高并发,或更大值减少请求的Mapper数。

通常推荐Fast-Cubing 算法,但不是所有情况下都如此。举例说明,如果每个Mapper之间的key交叉重合度较低,fast cubing更适合;因为Mapper端将这块数据最终要计算的结果都达到了,Reducer只需少量的聚合。另一个极端是,每个Mapper计算出的key跟其它 Mapper算出的key深度重合,这意味着在reducer端仍需将各个Mapper的数据抓取来再次聚合计算;如果key的数量巨大,该过程IO开销依然显著。对于这种情况,Layered-Cubing更适合。

用户该如何选择算法呢?无需担心,Kylin会自动选择合适的算法。Kylin在计算Cube之前对数据进行采样,在“fact distinct”步,利用HyperLogLog模拟去重,估算每种组合有多少不同的key,从而计算出每个Mapper输出的数据大小,以及所有Mapper之间数据的重合度,据此来决定采用哪种算法更优。在对上百个Cube任务的时间做统计分析后,Kylin选择了7做为默认的算法选择阀值(参数kylin.cube.algorithm.layer-or-inmem-threshold):如果各个Mapper的小Cube的行数之和,大于reduce后的Cube行数的7倍,采用Layered Cubing, 反之采用Fast Cubing。如果用户在使用过程中,更倾向于使用Fast Cubing,可以适当调大此参数值,反之调小。

                int mapperNumLimit = kylinConf.getCubeAlgorithmAutoMapperLimit();
                double overlapThreshold = kylinConf.getCubeAlgorithmAutoThreshold(); // default 7
                logger.info("mapperNumber for " + seg + " is " + mapperNumber + " and threshold is " + mapperNumLimit);
                logger.info("mapperOverlapRatio for " + seg + " is " + mapperOverlapRatio + " and threshold is " + overlapThreshold);
                // in-mem cubing is good when
                // 1) the cluster has enough mapper slots to run in parallel
                // 2) the mapper overlap ratio is small, meaning the shuffle of in-mem MR has advantage
                alg = (mapperNumber <= mapperNumLimit && mapperOverlapRatio <= overlapThreshold)//
                        ? CubingJob.AlgorithmEnum.INMEM
                        : CubingJob.AlgorithmEnum.LAYER;
Kylin Cube 构建算法结论(逐层算法和快速算法):

1、如果每个Mapper之间的key交叉重合度较低,fast cubing更适合;因为Mapper端将这块数据最终要计算的结果都达到了,Reducer只需少量的聚合。另一个极端是,每个Mapper计算出的key跟其它 Mapper算出的key深度重合,这意味着在reducer端仍需将各个Mapper的数据抓取来再次聚合计算;如果key的数量巨大,该过程IO开销依然显著。对于这种情况,Layered-Cubing更适合。

2、在对上百个Cube任务的时间做统计分析后,Kylin选择了7做为默认的算法选择阀值(参数kylin.cube.algorithm.auto.threshold):如果各个Mapper的小Cube的行数之和,大于reduce后的Cube行数的8倍(各个Mapper的小Cube的行数之和 / reduce后的Cube行数 > 7),采用Layered Cubing, 反之采用Fast Cubing(本质就是各个Mapper之间的key重复度越小,就用Fast Cubing,重复度越大,就用Layered Cubing)
--------------------- 

转载于:https://my.oschina.net/hblt147/blog/3006400

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值