注:Kylin源码分析系列基于Kylin的2.5.0版本的源码,其他版本可以类比。
前面一篇文章介绍了Kylin中的任务调度服务,本篇文章正式介绍Kylin的核心内容Cube,主要讲述Cube构建的过程。下面的构建过程选择使用spark构建引擎来说明(MR引擎自行类比阅读相关源码)。
首先介绍下Cube构建的整体流程,看下kylin web页面上展示的构建过程:
主要有如下几个步骤:
3. 接着Kylin获取维度列的distinct值(即维度基数),用于后面一步进行字典编码。
4. 这一步就根据前面获得的维度的distinct值来构建字典,通常这一步会很快,但是如果distinct值的集合很大,Kylin可能会报 错,例如,“Too high cardinality is not suitable for dictionary”。对于UHC(超大维度基数)列,请使用其他编码方式,例 如“fixed_length”,“integer”等。
5. 这步操作很简单,只是保存cube的一些相关统计数据,比如有多少cuboid,每个cuboid有多少行数据等。
6. 这一步是创建保存cube数据的hbase表,目前的版本cube数据只支持保存到hbase中,kylin社区目前正在开发将cube数据 直接保存为parquet格式文件(适用于云上环境);这里有一点需要说明一下,在建表的时候启用了hbase协处理的功能 (endpoint模式),需要将协处理器的相关jar包deploy到对应的hbase表上,后面会详细介绍,这样做是为了提升Kylin的查 询性能。
7. 这里就是真正的创建cube了,本文的描述是基于spark构建引擎的,使用的by layer的方式构建的,即先构建Base Cuboid,然后一层一层的往上聚合,得到其他的cuboid的数据;当使用MR引擎的时候,可以配置cube构建算法,通过 kylin.cube.algorithm来配置,值有[“auto”, “layer”, “inmem”],默认值为auto,用户根据环境的资源情况来进行配置,使用 auto的时候,kylin会根据系统资源情况来选择layer还是inmem,layer算法是一层一层的计算,需要的资源较少,但是花费 的时间可能会更长,而使用inmem算法则构建的更快,但是会消耗更多的内存,具体可以参考 https://blog.csdn.net/sunnyyoona/article/details/52318176。
8. 这一步将Cuboid数据转化为HFile文件。
9. 将转化后的HFile文件直接load到HBase里面供后续查询使用。
10. 更新Cube的相关信息。
11. 清理Hive中的临时数据。
下面从源码来看Cube的构建过程:
在Kylin页面上点击build后,触发的是一个任务提交的流程,该任务提交的流程简要介绍下:
1.页面点击Submit按钮,通过js触发rebuild事件,发送restful请求:
rebuild的具体处理源码在webapp/app/js/controllers/cubes.js中:
最终调用restful api接口/kylin/api/cubes/{cubeName}/rebuild将请求发送至服务端,CubeService定义在webapp/app/js/services/cubes.js。
2.Rest Server服务端接收到restful请求,根据请求的URL将请求分发到对应的控制器进行处理(使用了Spring的@Controller和@RequestMapping注解),这里的Cube构建请求最终被分发到CubeController控制器由rebuild函数进行处理:
/** Build/Rebuild a cube segment */
/**
* Build/Rebuild a cube segment
*/
@RequestMapping(value = "/{cubeName}/rebuild", method = { RequestMethod.PUT }, produces = { "application/json" })
@ResponseBody
public JobInstance rebuild(@PathVariable String cubeName, @RequestBody JobBuildRequest req) {
return buildInternal(cubeName, new TSRange(req.getStartTime(), req.getEndTime()), null, null, null,
req.getBuildType(), req.isForce() || req.isForceMergeEmptySegment());
}
然后看buildInternal函数:
private JobInstance buildInternal(String cubeName, TSRange tsRange, SegmentRange segRange, //
Map sourcePartitionOffsetStart, Map sourcePartitionOffsetEnd,
String buildType, boolean force) {
try {
//获取提交任务的用户的用户名
String submitter = SecurityContextHolder.getContext().getAuthentication().getName();
//获取Cube实例
CubeInstance cube = jobService.getCubeManager().getCube(cubeName);
//检测有多少个处于即将构建的状态的job,默认只能同时提10个job,大于则会抛异常,提交失败
checkBuildingSegment(cube);
//通过jobService来提交任务,即为上篇文章介绍的Cube任务调度服务
return jobService.submitJob(cube, tsRange, segRange, sourcePartitionOffsetStart, sourcePartitionOffsetEnd,
CubeBuildTypeEnum.valueOf(buildType), force, submitter);
} catch (Throwable e) {
logger.error(e.getLocalizedMessage(), e);
throw new InternalErrorException(e.getLocalizedMessage(), e);
}
}
然后看JobService中的submitJob,该函数只是做了权限认证,然后直接调用了submitJobInternal:
public JobInstance submitJobInternal(CubeInstance cube, TSRange tsRange, SegmentRange segRange, //
Map sourcePartitionOffsetStart, Map sourcePartitionOffsetEnd, //
CubeBuildTypeEnum buildType, boolean force, String submitter) throws IOException {
. . .
try {
if (buildType == CubeBuildTypeEnum.BUILD) {
//获取数据源类型(HiveSource、JdbcSource、KafkaSource)
ISource source = SourceManager.getSource(cube);
//数据范围
SourcePartition src = new SourcePartition(tsRange, segRange, sourcePartitionOffsetStart,
sourcePartitionOffsetEnd);
//kafka数据源确定start offset和endoffset
src = source.enrichSourcePartitionBeforeBuild(cube, src);
//添加segment
newSeg = getCubeManager().appendSegment(cube, src);
//通过构建引擎来构建Job
job = EngineFactory.createBatchCubingJob(newSeg, submitter);
} else if (buildType == CubeBuildTypeEnum.MERGE) {
newSeg = getCubeManager().mergeSegments(cube, tsRange, segRange, force);
job = EngineFactory.createBatchMergeJob(newSeg, submitter);
} else if (buildType == CubeBuildTypeEnum.REFRESH) {
newSeg = getCubeManager().refreshSegment(cube, tsRange, segRange);
job = EngineFactory.createBatchCubingJob(newSeg, submitter);
} else {
throw new BadRequestException(String.format(msg.getINVALID_BUILD_TYPE(), buildType));
}
//提交任务,可以参考前面任务调度的文章了解任务具体是怎么执行的
getExecutableManager().addJob(job);
} catch (Exception e) {
. . .
}
JobInstance jobInstance = getSingleJobInstance(job);
return jobInstance;
}
接着看EngineFactory.createBatchCubingJob方法,根据cube实例中配置的引擎类型来确定使用什么引擎,目前有mapreduce和spark两种引擎,开发者也可以添加自己的构建引擎(通过kylin.engine.provider加入)。下面以spark引擎来继续分析,后面直接到SparkBatchCubingJobBuilder2的build,这个函数就是cube构建任务的核心:
public CubingJob build() {
logger.info("Spark new job to BUILD segment " + seg);
//构建job任务(DefaultChainedExecutable类型,是一个任务链)
final CubingJob result = CubingJob.createBuildJob(seg, submitter, config);
final String jobId = result.getId();
//获取cuboid在hdfs上的数据目录
final String cuboidRootPath = getCuboidRootPath(jobId);
// Phase 1: Create Flat Table & Materialize Hive View in Lookup Tables
inputSide.addStepPhase1_CreateFlatTable(result);
// Phase 2: Build Dictionary
// 获取维度列的distinct值(即维度基数)
result.addTask(createFactDistinctColumnsSparkStep(jobId));
// 针对高基数维度(Ultra High Cardinality)单独起MR任务来构建字典,主要是ShardByColumns
// 和GlobalDictionaryColumns
if (isEnableUHCDictStep()) {
result.addTask(createBuildUHCDictStep(jobId));
}
// 创建维度字典
result.addTask(createBuildDictionaryStep(jobId));
// 保存一些统计数据
result.addTask(createSaveStatisticsStep(jobId));
// add materialize lookup tables if needed
LookupMaterializeContext lookupMaterializeContext = addMaterializeLookupTableSteps(result);
// 创建hbase表
outputSide.addStepPhase2_BuildDictionary(result);
// Phase 3: Build Cube
addLayerCubingSteps(result, jobId, cuboidRootPath); // layer cubing, only selected algorithm will execute
//将上一步计算后的cuboid文件转换成hfile,然后将hfile load到hbase的表中
outputSide.addStepPhase3_BuildCube(result);
// Phase 4: Update Metadata & Cleanup
result.addTask(createUpdateCubeInfoAfterBuildStep(jobId, lookupMaterializeContext));
inputSide.addStepPhase4_Cleanup(result);
outputSide.addStepPhase4_Cleanup(result);
return result;
}
上述代码中的流程与页面上的构建过程基本一致,下面详细看下Cube计算这个步骤的实现过程,即addLayerCubingSteps(result, jobId, cuboidRootPath)。
protected void addLayerCubingSteps(final CubingJob result, final String jobId, final String cuboidRootPath) {
final SparkExecutable sparkExecutable = new SparkExecutable();
// 设置cube计算的类
sparkExecutable.setClassName(SparkCubingByLayer.class.getName());
// 配置spark任务,主要为数据来源和cuboid数据保存位置
configureSparkJob(seg, sparkExecutable, jobId, cuboidRootPath);
// task加入到job中
result.addTask(sparkExecutable);
}
接着看SparkCubingByLayer中的execute方法,最终任务调度服务调度执行job中的该task时,是调用execute方法来执行的,具体的调用过程可以参考上一篇任务调度的文章:
protected void execute(OptionsHelper optionsHelper) throws Exception {
String metaUrl = optionsHelper.getOptionValue(OPTION_META_URL);
String hiveTable = optionsHelper.getOptionValue(OPTION_INPUT_TABLE);
String inputPath = optionsHelper.getOptionValue(OPTION_INPUT_PATH);
String cubeName = optionsHelper.getOptionValue(OPTION_CUBE_NAME);
String segmentId = optionsHelper.getOptionValue(OPTION_SEGMENT_ID);
String outputPath = optionsHelper.getOptionValue(OPTION_OUTPUT_PATH);
Class[] kryoClassArray = new Class[] { Class.forName("scala.reflect.ClassTag$$anon$1") };
SparkConf conf = new SparkConf().setAppName("Cubing for:" + cubeName + " segment " + segmentId);
//serialization conf
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer");
conf.set("spark.kryo.registrator", "org.apache.kylin.engine.spark.KylinKryoRegistrator");
conf.set("spark.kryo.registrationRequired", "true").registerKryoClasses(kryoClassArray);
KylinSparkJobListener jobListener = new KylinSparkJobListener();
JavaSparkContext sc = new JavaSparkContext(conf);
sc.sc().addSparkListener(jobListener);
// 清空cuboid文件目录
HadoopUtil.deletePath(sc.hadoopConfiguration(), new Path(outputPath));
SparkUtil.modifySparkHadoopConfiguration(sc.sc()); // set dfs.replication=2 and enable compress
final SerializableConfiguration sConf = new SerializableConfiguration(sc.hadoopConfiguration());
KylinConfig envConfig = AbstractHadoopJob.loadKylinConfigFromHdfs(sConf, metaUrl);
final CubeInstance cubeInstance = CubeManager.getInstance(envConfig).getCube(cubeName);
final CubeDesc cubeDesc = cubeInstance.getDescriptor();
final CubeSegment cubeSegment = cubeInstance.getSegmentById(segmentId);
logger.info("RDD input path: {}", inputPath);
logger.info("RDD Output path: {}", outputPath);
final Job job = Job.getInstance(sConf.get());
SparkUtil.setHadoopConfForCuboid(job, cubeSegment, metaUrl);
int countMeasureIndex = 0;
for (MeasureDesc measureDesc : cubeDesc.getMeasures()) {
if (measureDesc.getFunction().isCount() == true) {
break;
} else {
countMeasureIndex++;
}
}
final CubeStatsReader cubeStatsReader = new CubeStatsReader(cubeSegment, envConfig);
boolean[] needAggr = new boolean[cubeDesc.getMeasures().size()];
boolean allNormalMeasure = true;
for (int i = 0; i < cubeDesc.getMeasures().size(); i++) {
// RawMeasureType这里为true,其他均为false
needAggr[i] = !cubeDesc.getMeasures().get(i).getFunction().getMeasureType().onlyAggrInBaseCuboid();
allNormalMeasure = allNormalMeasure && needAggr[i];
}
logger.info("All measure are normal (agg on all cuboids) ? : " + allNormalMeasure);
StorageLevel storageLevel = StorageLevel.fromString(envConfig.getSparkStorageLevel());
// 默认为true
boolean isSequenceFile = JoinedFlatTable.SEQUENCEFILE.equalsIgnoreCase(envConfig.getFlatTableStorageFormat());
// 从hive数据源表中构建出RDD,hiveRecordInputRDD得到格式为每行数据的每列的值的
// RDD(JavaRDD),maptoPair是按照basecubiod(每个维度都包含),计算出格式为
// rowkey(shard id+cuboid id+values)和每列的值的RDD encodedBaseRDD
final JavaPairRDD encodedBaseRDD = SparkUtil.hiveRecordInputRDD(isSequenceFile, sc, inputPath, hiveTable)
.mapToPair(new EncodeBaseCuboid(cubeName, segmentId, metaUrl, sConf));
Long totalCount = 0L;
// 默认为false
if (envConfig.isSparkSanityCheckEnabled()) {
// 数据总条数
totalCount = encodedBaseRDD.count();
}
// 聚合度量值的具体方法
final BaseCuboidReducerFunction2 baseCuboidReducerFunction = new BaseCuboidReducerFunction2(cubeName, metaUrl, sConf);
BaseCuboidReducerFunction2 reducerFunction2 = baseCuboidReducerFunction;
// 度量没有RAW的为true
if (allNormalMeasure == false) {
reducerFunction2 = new CuboidReducerFunction2(cubeName, metaUrl, sConf, needAggr);
}
final int totalLevels = cubeSegment.getCuboidScheduler().getBuildLevel();
JavaPairRDD[] allRDDs = new JavaPairRDD[totalLevels + 1];
int level = 0;
int partition = SparkUtil.estimateLayerPartitionNum(level, cubeStatsReader, envConfig);
// aggregate to calculate base cuboid
allRDDs[0] = encodedBaseRDD.reduceByKey(baseCuboidReducerFunction, partition).persist(storageLevel);
// 数据保存到hdfs上
saveToHDFS(allRDDs[0], metaUrl, cubeName, cubeSegment, outputPath, 0, job, envConfig);
// 根据base cuboid上卷聚合各个层级的数据,改变数据的rowKey,去掉相应的维度
PairFlatMapFunction flatMapFunction = new CuboidFlatMap(cubeName, segmentId,
metaUrl, sConf);
// aggregate to ND cuboids
for (level = 1; level <= totalLevels; level++) {
partition = SparkUtil.estimateLayerPartitionNum(level, cubeStatsReader, envConfig);
// flatMapToPair得到上卷聚合后的数据,reduceByKey再进一步根据新的rowKey进行聚合操作,
因为进行flatMapToPair操作后会有部分数据的rowKey值相同
allRDDs[level] = allRDDs[level - 1].flatMapToPair(flatMapFunction).reduceByKey(reducerFunction2, partition)
.persist(storageLevel);
allRDDs[level - 1].unpersist();
if (envConfig.isSparkSanityCheckEnabled() == true) {
sanityCheck(allRDDs[level], totalCount, level, cubeStatsReader, countMeasureIndex);
}
saveToHDFS(allRDDs[level], metaUrl, cubeName, cubeSegment, outputPath, level, job, envConfig);
}
allRDDs[totalLevels].unpersist();
logger.info("Finished on calculating all level cuboids.");
logger.info("HDFS: Number of bytes written=" + jobListener.metrics.getBytesWritten());
//HadoopUtil.deleteHDFSMeta(metaUrl);
}
Cube在构建完所有的cuboid,原始的cuboid文件会存到hdfs目录下(例:/kylin/kylin_metadata/kylin-43be1d7f-4a50-b3a8-6dea-b998acec2d7b/kylin_sales_cube/cuboid),后面的createConvertCuboidToHfileStep任务会将cuboid文件转换成hfile文件保存到/kylin/kylin_metadata/kylin-43be1d7f-4a50-b3a8-6dea-b998acec2d7b/kylin_sales_cube/hfile目录下,最后会由createBulkLoadStep任务将hfile文件load到hbase表中(后面hfile目录会被删除),这样就完成了Cube的构建。这里需要注意的是cuboid文件在Cube构建完成后不会被删除,因为后面做Cube Segment的merge操作时是直接用已有的cuboid文件,而不需要重新进行计算,加快合并的速度,如果你确认后面不会进行segment的合并操作,cuboid文件可以手动删除掉以节省hdfs的存储空间。