相关概念
Project
Kylin的管理粒度。不同的project可以选择不同的Hive表进行数据分析。
Table
Kylin本质上是在关系表之上的OLAP分析,它支持从Hive加载表结构,或者从Kafka的消息队列进行表结构的映射,Kylin通过table来描述关系表的基本信息。
Model
OLAP分析对数据进行多维数据建模,Kylin支持星形模型和雪花模型。下图是Kylin的官方数据模型示例。这是一个雪花模型,包含一张销售事实表和四张维度表,包括用户维护表、日期维度表、类别维度表、国家维度表。事实表和维度表通过主键和外键进行关联。
cuboid
基于雪花模型进行分析时,我们会选定某些维度,进行聚合计算,统计一些量度。Kylin的cube预计算理论,就是预先在某些维度上进行预聚合,以空间换时间。cuboid就是指定某一维度组合下的进行预聚合的结果。
Cube
所有cuboid的集合就是cube。
Cube Segment
数据仓库中的数据会随着时间的增长而增长,我们对源数据按照时间分片,每个时间片段上计算出来的cube就成为cube segment。
源码分析
介绍完上述概念,下面再从源码层面具体看一下Kylin是如何实现元数据的相关操作。
数据结构
对应到上述各个概念,Kylin定义了相应的元数据类。这些元数据类,都继承了RootPersistentEntity抽象类父类。列举其中的部分元数据类型有:
ProjectInstance 表示project。该类中主要包含:
- 工程的基本信息,如名称、所有者、状态等
- 工程下包含哪些cube
- 工程下定义了哪些数据模型等。
DataModelDesc 表示多维数据模型。该类中主要包含:
- 多维数据模型的基本信息,如名称,描述,所有者等
- 事实表、维度表、二者的连接关系
- 各表中用作维度和度量的列
- 此外,我们还可以为多维数据模型补充时间列信息和过滤条件
CubeDesc 表示cube。该类中主要包含:
- cube的基本信息,比如唯一的名称,描述、邮件通知列表等保存在该类中。
- cube基于哪些维度列,在哪些量度上进行聚合,以及聚合类型。
- cube数据刷新的设置
- 维度的高级设置。如果cube有N个维度,那么这个cube将有2^N个cuboid。随着维度的增加,cuboid成指数增长。为了节约存储空间,缓解cube构建压力,Kylin引入了一系列高级优化设置,达到减少生成cuboid数目的目的。这些高级设置就包含聚合组、强制维度、层级维度、联合维度。
- rowkey属性。Kylin以key-value的形式将cube结果存储在HBase中,OLAP查询就是按照多个维度对度量进行检索,因此需要将多个纬度值拼成rowkey。
编辑入口
Kylin的project、model、cube等元数据可以通过控制台进行可视化编辑,也可以直接通过rest api接口传递json格式数据进行编辑。这里我们略过controller层,各类元数据的操作均是通过相应的manager类完成,例如project类元数据通过ProjectManager类完成操作。以ProjectManager为例进行源码分析,其采用单例模式,构造函数如下所示:
Java
private ProjectManager(KylinConfig config) throws IOException {
logger.info("Initializing ProjectManager with metadata url " + config);
this.config = config;
this.projectMap = new CaseInsensitiveStringCache<ProjectInstance>(config, "project");
this.l2Cache = new ProjectL2Cache(this);
this.crud = new CachedCrudAssist<ProjectInstance>(getStore(), ResourceStore.PROJECT_RESOURCE_ROOT,
ProjectInstance.class, projectMap) {
@Override
protected ProjectInstance initEntityAfterReload(ProjectInstance prj, String resourceName) {
prj.init();
return prj;
}
};
// touch lower level metadata before registering my listener
crud.reloadAll();
Broadcaster.getInstance(config).registerListener(new ProjectSyncListener(), "project");
}
从中可以看出,其引入了CRUD辅助类实例进行数据存储的读写操作,并引入了二级缓存加速查询,另外由于Kylin可使用集群方式部署,为了保证集群中每个节点内的元数据缓存保持一致,还引入了元数据变更广播机制,向广播器注册监听器,监听"project"事件进行同步,关于存储、缓存和同步后面会详细介绍。以下是数据写入代码:
Java
private ProjectInstance save(ProjectInstance prj) throws IOException {
crud.save(prj);
clearL2Cache(prj.getName());
return prj;
}
从中可以看出,其通过调用CRUD辅助类实例的"save"方法完成数据写入,并清空二级缓存。CRUD辅助类CachedCrudAssist是一个抽象类,从名称可以看出, 其在数据读写操作之上进行了缓存,其定义了抽象、通用的数据读写方法,而具体的存储和缓存操作分别由ResourceStore类和SingleValueCache类完成。在manager类的构造函数中,通过匿名内部类和泛型继承CachedCrudAssist,传入相应的ResourceStore类和SingleValueCache类实例,实现对某类元数据的操作。CachedCrudAssist中数据写入的代码部分如下所示:
Java
String path = resourcePath(resName);
logger.debug("Saving {} at {}", entityType.getSimpleName(), path);
store.checkAndPutResource(path, entity, serializer);
// just to trigger the event broadcast, the entity won't stay in cache
cache.put(resName, entity);
// keep the pass-in entity out of cache, the caller may use it for further update
// return a reloaded new object
return reload(resName);
从其注释可以看出,SingleValueCache类即负责缓存,也承担了触发事件广播的工作,这会在缓存部分详细介绍。下面先通过ResourceStore类介绍持久化存储。
持久化存储
Kylin通过ResourceStore抽象类定义元数据存储相关操作。对于project、table、model、cube等元数据,Kylin使用抽象的文件路径作为唯一标识符,且各类元数据均有相应的根目录,例如project元数据在根目录"/project"下,而官方示例project learn_kylin的元数据是"/projct/learn_kylin.json"。元数据存储采用json格式,通过序列化机制与相应的Java实体类相互转化,以下是"/project/learn_kylin.json"内容:
JSON
{
"uuid": "2fbca32a-a33e-4b69-83dd-0bb8b1f8c91b",
"name": "learn_kylin",
"realizations": [
{
"name": "kylin_sales_cube",
"type": "CUBE",
"realization": "kylin_sales_cube"
},
{
"name": "kylin_streaming_cube",
"type": "CUBE",
"realization": "kylin_streaming_cube"
}
],
"tables": [
"DEFAULT.KYLIN_SALES",
"DEFAULT.KYLIN_CAL_DT",
"DEFAULT.KYLIN_CATEGORY_GROUPINGS",
"DEFAULT.KYLIN_ACCOUNT",
"DEFAULT.KYLIN_COUNTRY",
"DEFAULT.KYLIN_STREAMING_TABLE"
],
"models": [
"kylin_sales_model",
"kylin_streaming_model"
],
"ext_filters": [],
"override_kylin_properties": {}
}
ResourceStore定义了多个方法用于元数据的相关读写操作,其中采用模板方法模式定义抽象、通用逻辑,而不涉及具体存储方式,暴露出抽象方法,通过继承进一步实现,从而满足具体存储方式的可扩展性。以下是写入操作的相关代码:
Java
private long checkAndPutResourceCheckpoint(String resPath, byte[] content, long oldTS, long newTS)
throws IOException, WriteConflictException {
beforeChange(resPath);
return checkAndPutResourceWithRetry(resPath, content, oldTS, newTS);
}
/**
* checks old timestamp when overwriting existing
*/
protected abstract long checkAndPutResourceImpl(String resPath, byte[] content, long oldTS, long newTS)
throws IOException, WriteConflictException;
private long checkAndPutResourceWithRetry(final String resPath, final byte[] content, final long oldTS,
final long newTS) throws IOException, WriteConflictException {
ExponentialBackoffRetry retry = new ExponentialBackoffRetry(this);
return retry.doWithRetry(() -> checkAndPutResourceImpl(resPath, content, oldTS, newTS));
}
写入采用CAS机制,使用时间戳作为版本号,解决并发写问题,而具体的CAS机制通过抽象方法交由子类来实现。对于具体的元数据存储,Kylin官方支持以下方式:
实现类 | 说明 |
FileResourceStore | 使用本地文件存储 |
HBaseResourceStore | 使用HBase存储,若存储内容过大,则使用HDFS存储 |
JDBCResourceStore | 使用关系数据库存储,若存储内容过大,则使用HDFS存储 |
HDFSResourceStore | 使用HDFS存储 |
通过Kylin的配置项"kylin.metadata.url"设置具体存储方式,该配置项格式是:
css
IDENTIFIER@SCHEME[,PARAM=VALUE,PARAM=VALUE...]
使用HBase作为具体存储方式的示例如下所示:
ini
kylin_meta_test@hbase,hbase.zookeeper.property.clientPort=xxx,zookeeper.znode.parent=xxx,hbase.zookeeper.quorum=xxx
其采用"kylin_meta_test"作为HBase中的元数据表名,并包含了HBase相关配置。ResourceStore提供了静态方法"getStore",根据Kylin配置获取存储方式实例,"getStore"先尝试从内存缓存获取,若没有,则调用"createResourceStore"方法创建。具体创建流程是根据配置的schema从KylinConfigBase类查找对应的ResourceStore实现类名,并反射生成类实例返回,代码如下:
Java
private static ResourceStore createResourceStore(KylinConfig kylinConfig) {
StorageURL metadataUrl = kylinConfig.getMetadataUrl();
logger.info("Using metadata url {} for resource store", metadataUrl);
String clsName = kylinConfig.getResourceStoreImpls().get(metadataUrl.getScheme());
try {
Class<? extends ResourceStore> cls = ClassUtil.forName(clsName, ResourceStore.class);
ResourceStore store = cls.getConstructor(KylinConfig.class).newInstance(kylinConfig);
if (!store.exists(METASTORE_UUID_TAG)) {
store.checkAndPutResource(METASTORE_UUID_TAG, new StringEntity(store.createMetaStoreUUID()), 0,
StringEntity.serializer);
}
return store;
} catch (Throwable e) {
throw new IllegalArgumentException("Failed to find metadata store by url: " + metadataUrl, e);
}
}
而KylinConfigBase中schema和 ResourceStore实现类的映射关系代码如下所示:
Java
public Map<String, String> getResourceStoreImpls() {
Map<String, String> r = Maps.newLinkedHashMap();
// ref constants in ISourceAware
r.put("", "org.apache.kylin.common.persistence.FileResourceStore");
r.put("hbase", "org.apache.kylin.storage.hbase.HBaseResourceStore");
r.put("hdfs", "org.apache.kylin.common.persistence.HDFSResourceStore");
r.put("ifile", "org.apache.kylin.common.persistence.IdentifierFileResourceStore");
r.put("jdbc", "org.apache.kylin.common.persistence.JDBCResourceStore");
r.putAll(getPropertiesByPrefix("kylin.metadata.resource-store-provider.")); // note the naming convention -- http://kylin.apache.org/development/coding_naming_convention.html
return r;
}
从中可以看出,HBase存储方式所对应的实现类是org.apache.kylin.storage.hbase.HBaseResourceStore。同时,还可以通过继承ResourceStore实现其他自定义的存储方式,并通过前缀为"kylin.metadata.resource-store-provider"的配置项进行配置。
回到之前介绍的ResourceStore类的写入方法,HBaseResourceStore进一步实现CAS机制实现写入的代码如下所示:
Java
protected long checkAndPutResourceImpl(String resPath, byte[] content, long oldTS, long newTS)
throws IOException, IllegalStateException {
Table table = getConnection().getTable(TableName.valueOf(tableName));
RollbackablePushdown pushdown = null;
try {
byte[] row = Bytes.toBytes(resPath);
byte[] bOldTS = oldTS == 0 ? null : Bytes.toBytes(oldTS);
if (content.length > kvSizeLimit) {
pushdown = writePushdown(resPath, ContentWriter.create(content));
content = BytesUtil.EMPTY_BYTE_ARRAY;
}
Put put = new Put(row);
put.addColumn(B_FAMILY, B_COLUMN, content);
put.addColumn(B_FAMILY, B_COLUMN_TS, Bytes.toBytes(newTS));
boolean ok = table.checkAndPut(row, B_FAMILY, B_COLUMN_TS, bOldTS, put);
logger.trace("Update row {} from oldTs: {}, to newTs: {}, operation result: {}", resPath, oldTS, newTS, ok);
if (!ok) {
long real = getResourceTimestampImpl(resPath);
throw new WriteConflictException(
"Overwriting conflict " + resPath +
", expect old TS " + oldTS +
", but it is " + real +
", the expected new TS: " + newTS);
}
return newTS;
} catch (Exception ex) {
if (pushdown != null)
pushdown.rollback();
throw ex;
} finally {
if (pushdown != null)
pushdown.close();
IOUtils.closeQuietly(table);
}
}
从中可以看出,key是文件路径,value列族中的一列是文件内容,另一列是时间戳,通过HBase原生类HTable的checkAndPut方法实现CAS机制的写入。
缓存和事件通知
下面再介绍一下SingleValueCache类,前面提到其同时承担了缓存和事件通知的工作。SingleValueCache核心属性有两个:
- ConcurrentMap<K, V> innerCache,负责缓存数据;
- String syncEntity,表示触发的事件通知类型,例如ProjectManager构建函数中创建的实例属性值为"project";
由于CachedCrudAssist类的数据写入方法会同时更新缓存,因此在更新缓存时触发事件通知,代码如下所示:
Java
public void put(K key, V value) {
boolean exists = innerCache.containsKey(key);
innerCache.put(key, value);
if (!exists) {
getBroadcaster().announce(syncEntity, Broadcaster.Event.CREATE.getType(), key.toString());
} else {
getBroadcaster().announce(syncEntity, Broadcaster.Event.UPDATE.getType(), key.toString());
}
}
其通过获取Broadcaster类实例,并调用其announce方法触发事件通知,而announce代码如下所示:
Java
public void announce(BroadcastEvent event) {
if (broadcastEvents == null)
return;
try {
counter.incrementAndGet();
broadcastEvents.putLast(event);
} catch (Exception e) {
counter.decrementAndGet();
logger.error("error putting BroadcastEvent", e);
}
}
其往broadcastEvents队列中写入事件,后续通知由Broadcaster完成。事件通知整体采用观察者模式,Broadcaster类是其核心,其构造函数如下所示:
Java
private Broadcaster(final KylinConfig config) {
this.config = config;
this.syncErrorHandler = getSyncErrorHandler(config);
this.announceMainLoop = Executors.newSingleThreadExecutor(new DaemonThreadFactory());
final String[] nodes = config.getRestServers();
if (nodes == null || nodes.length < 1) {
logger.warn("There is no available rest server; check the 'kylin.server.cluster-servers' config");
}
logger.debug("{} nodes in the cluster: {}", (nodes == null ? 0 : nodes.length), Arrays.toString(nodes));
int corePoolSize = (nodes == null || nodes.length < 1)? 1 : nodes.length;
int maximumPoolSize = (nodes == null || nodes.length < 1)? 10 : nodes.length * 2;
this.announceThreadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, 60L, TimeUnit.SECONDS,
workQueue, new DaemonThreadFactory());
announceMainLoop.execute(new Runnable() {
@Override
public void run() {
final Map<String, RestClient> restClientMap = Maps.newHashMap();
while (!announceThreadPool.isShutdown()) {
try {
final BroadcastEvent broadcastEvent = broadcastEvents.takeFirst();
String[] restServers = config.getRestServers();
logger.debug("Servers in the cluster: {}", Arrays.toString(restServers));
for (final String node : restServers) {
if (restClientMap.containsKey(node) == false) {
restClientMap.put(node, new RestClient(node));
}
}
String toWhere = broadcastEvent.getTargetNode();
if (toWhere == null)
toWhere = "all";
logger.debug("Announcing new broadcast to {}: {}", toWhere, broadcastEvent);
for (final String node : restServers) {
if (!(toWhere.equals("all") || toWhere.equals(node)))
continue;
announceThreadPool.execute(new Runnable() {
@Override
public void run() {
RestClient restClient = restClientMap.get(node);
try {
restClient.wipeCache(broadcastEvent.getEntity(), broadcastEvent.getEvent(),
broadcastEvent.getCacheKey());
} catch (IOException e) {
logger.error(
"Announce broadcast event failed, targetNode {} broadcastEvent {}, error msg: {}",
node, broadcastEvent, e);
syncErrorHandler.handleAnnounceError(node, restClient, broadcastEvent);
}
}
});
}
} catch (Exception e) {
logger.error("error running wiping", e);
}
}
}
});
}
从中可以看出,其通过一个后台线程不断轮询broadcastEvents队列,取出事件,通过线程池并发通知集群各节点,通知操作具体是通过调用各节点的rest api完成,而对于调用失败,则由SyncErrorHandler类处理,默认处理规则是重新触发事件通知。而各节点收到事件通知请求后,则是调用其Broadcaster实例的notifyListener方法触发监听器完成相关操作,例如,在ProjectManager构造函数中创建的ProjectSyncListener实例被添加到Broadcaster的属性Map<String, List> listenerMap中,并响应"project"事件,当其他节点的project发生变化触发"project"事件时,当前节点的Broadcaster实例会触发ProjectSyncListener的onEntityChange方法,代码如下所示:
Java
private class ProjectSyncListener extends Broadcaster.Listener {
@Override
public void onEntityChange(Broadcaster broadcaster, String entity, Event event, String cacheKey)
throws IOException {
String project = cacheKey;
if (event == Event.DROP) {
removeProjectLocal(project);
return;
}
reloadProjectQuietly(project);
broadcaster.notifyProjectSchemaUpdate(project);
broadcaster.notifyProjectDataUpdate(project);
}
}
从中可以看出,该监听器一方面会重新加载project数据,另一方面则会触发本节点其他监听project元数据变化的监听器。
结语
Kylin的核心优化理论是Cube模型和预先计算,在定义和存储元数据后,就可以触发相应的Cube构建,目前Kylin支持MapReduce、Spark方式的构建,在后续文章中,会继续详细介绍数据构建流程。