Kylin元数据源码分析

相关概念

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。该类中主要包含:

  1. 工程的基本信息,如名称、所有者、状态等
  2. 工程下包含哪些cube
  3. 工程下定义了哪些数据模型等。

DataModelDesc 表示多维数据模型。该类中主要包含:

  1. 多维数据模型的基本信息,如名称,描述,所有者等
  2. 事实表、维度表、二者的连接关系
  3. 各表中用作维度和度量的列
  4. 此外,我们还可以为多维数据模型补充时间列信息和过滤条件

CubeDesc 表示cube。该类中主要包含:

  1. cube的基本信息,比如唯一的名称,描述、邮件通知列表等保存在该类中。
  2. cube基于哪些维度列,在哪些量度上进行聚合,以及聚合类型。
  3. cube数据刷新的设置
  4. 维度的高级设置。如果cube有N个维度,那么这个cube将有2^N个cuboid。随着维度的增加,cuboid成指数增长。为了节约存储空间,缓解cube构建压力,Kylin引入了一系列高级优化设置,达到减少生成cuboid数目的目的。这些高级设置就包含聚合组、强制维度、层级维度、联合维度。
  5. 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核心属性有两个:

  1. ConcurrentMap<K, V> innerCache,负责缓存数据;
  2. 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方式的构建,在后续文章中,会继续详细介绍数据构建流程。

相关推荐
2401_857622662 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589362 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没3 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch4 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
杨哥带你写代码5 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries6 小时前
读《show your work》的一点感悟
后端
A尘埃6 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-23076 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code6 小时前
(Django)初步使用
后端·python·django
代码之光_19806 小时前
SpringBoot校园资料分享平台:设计与实现
java·spring boot·后端