本文仅从普及角度让大家对元数据中心系统及其DataHub有个初步了解。DataHub部署、实战、更深入的技术剖析会单独给出
DataHub是由LinkedIn的数据团队开源的一款提供元数据搜索与发现的工具,在数据资产越来越重视的当下,探索数据治理解决方案,以满足不断增长的大数据复杂生态系统需求。
在这之前我们有必要先了解下整个大环境及其发展历程。
随着企业的发展,不同的业务场景产生了不同形式的海量技术、业务数据。如何提取出有用的数据来帮助解决特定场景、发现潜在价值成为数据科学家的核心难题之一。业界通过元数据来提高数据科学家的生产力,一种描述数据的数据。(元数据概念请自行了解)不同的用例通常都会有自己特殊的元数据定义及其关系,最常见的如用户元数据、报表元数据、关系元数据。我们需要一套完备的系统帮助专业人员收集、组织、访问和丰富元数据,以支持数据发现和管理(俗称:数据治理,在数据资产化下数据治理尤为重要)。而如何设计一套行之有效的系统,更方便、快速的丰富、查询、使用元数据成为各大企业探索的目标。(国外更多的称呼该系统为 元数据中心\元数据目录)
简单点说,你想要一份数据,如果通过一个人就能拿到,那可能就不需要该系统;但往往在大公司里,这些数据散落在不同系统、存储、地域,你甚至不知道有什么数据,找谁拿,而元数据中心系统就可以帮助你快速准确定位到想要的数据,同时还能知道谁在使用、谁创建的、数据依赖,什么时候数据从A变成B等等。
本文档编写时,元数据中心系统架构经历了三代演变。在该领域内Lyft’s Amundsen、DataHub处于领先者,Amundsen是社区最活跃的,DataHub也越来越被关注。
更详细的架构演进请跳转此文:LinkedIn-DataHub专题: 元数据中心系统架构演进
第三代架构确保我们能够以最具伸缩性和灵活性的方式集成、存储和处理元数据。本文的主角DataHub就是基于三代架构进行构建,市面上具有三代架构特性的还有Apache Atlas,Egeria,Uber Databook(非开源)。Atlas与Hadoop生态系统紧密耦合,最活跃的Amundsen现已可以与Atlas做整合;Egeria支持事件,但功能还不完整;Databook与DataHub较接近,但不开源。DataHub经历了WhereHows(第二代)的过渡,也存在内部版本(开源版版本与内部版本区别看这),在LinkedIn内部被广泛使用,每天处理超过千万实体和关系的变更事件,总计索引超过500万个实体和关系,毫秒级查询,用户体验也获得了极大的改进。不难看出LinkedIn的野心:推进DataHub成为数据资产的基础设施进程。
最新功能清单以官方在线版为准: https://github.com/linkedin/datahub/blob/master/docs/features.md
开源版的数据结构仅支持Datasets、People;数据集支持:Hive、Kafka、RDBMS(如果需要额外的数据集,需编程式定义);存储源支持Oracle、Postgres、MySQL、H2等主流RDBMS 、Elasticsearch和Neo4j。除了以下列的这些,还有部分功能也在规划中,比如仪表盘、指标信息、元数据结构变更记录、数据抓取任务执行记录等等。
DatahHub采用前后端分离+微服务/容器架构,但其完整的技术栈给我们带来了一定的挑战。(头大,会的越多越不会了)
DataHub 组成
GMA是datahub的基础设施,提供标准化的元数据模型和访问层
前端提供三种类型的交互:(1)搜索,(2)浏览,(3)查看/编辑元数据。
以下是一些实际应用的截图。
DataHub选择了Pegasus对元数据建模。由于Pegasus没有提供模型关系或关联的明确方法,因此引入了一些自定义扩展来支持这些用例。
以上图实体关系团来说,包含了三种类型实体:用户、组、数据集;同时也包含了三种关系:OwnedBy,HasMember和HasAdmin。与传统的ERD不同,我们将实体和关系的属性分别直接放在圆圈内和关系名称下面,以便将新类型的组件(称为“元数据方面”)附加到实体。不同的团队可以拥有和发展同一实体元数据的不同方面,而不会相互干扰,从而实现分布式元数据建模要求。三种类型的元数据方面:所有权,配置文件和成员资格在上面的示例中呈现为绿色矩形。虚线表示元数据方面与实体的关联。例如,配置文件可以与用户相关联,且所有权可以与数据集等相关联。
每个实体,关系和“元数据方面”都是单独的Pegasus文件(PDSC/PDL),User(PDL文件)实体和OwnedBy(PDL文件)关系分别如下(DataHub内部维护了两种文件类型 pdl和avsc (json格式),看官方说明,内部建模都会改成pdl,而网络传输(MCE)则用avsc格式):
关于PDSC/PDL, AVSC相关的请看该文档:https://linkedin.github.io/rest.li/pdl_schema
以OwnerBy为例,编译完会生成以下两个文件OwnerBy.avsc、OwnerBy.java:
{
"type" : "record",
"name" : "OwnedBy",
"namespace" : "com.linkedin.metadata.relationship",
"doc" : "A generic model for the Owned-By relationship",
"fields" : [ {
"name" : "source",
"type" : "string",
"doc" : "Urn for the source of the relationship",
"java" : {
"class" : "com.linkedin.common.urn.Urn"
}
}, {
"name" : "destination",
"type" : "string",
"doc" : "Urn for the destination of the relationship",
"java" : {
"class" : "com.linkedin.common.urn.Urn"
}
}, {
"name" : "type",
"type" : {
"type" : "enum",
"name" : "OwnershipType",
"namespace" : "com.linkedin.common",
"doc" : "Owner category or owner role",
"symbols" : [ "DEVELOPER", "DATAOWNER", "DELEGATE", "PRODUCER", "CONSUMER", "STAKEHOLDER" ],
"symbolDocs" : {
"CONSUMER" : "A person, group, or service that consumes the data",
"DATAOWNER" : "A person or group that is owning the data",
"DELEGATE" : "A person or a group that overseas the operation, e.g. a DBA or SRE.",
"DEVELOPER" : "A person or group that is in charge of developing the code",
"PRODUCER" : "A person, group, or service that produces/generates the data",
"STAKEHOLDER" : "A person or a group that has direct business interest"
}
},
"doc" : "The type of the ownership"
} ],
"pairings" : [ {
"destination" : "com.linkedin.common.urn.CorpuserUrn",
"source" : "com.linkedin.common.urn.DatasetUrn"
}, {
"destination" : "com.linkedin.common.urn.CorpuserUrn",
"source" : "com.linkedin.common.urn.DataProcessUrn"
} ]
}
package com.linkedin.metadata.relationship;
/**
* A generic model for the Owned-By relationship
*
*/
@Generated(value = "com.linkedin.pegasus.generator.JavaCodeUtil", comments = "Rest.li Data Template. Generated from metadata-models/src/main/pegasus/com/linkedin/metadata/relationship/OwnedBy.pdl.")
public class OwnedBy
extends RecordTemplate
{
private final static OwnedBy.Fields _fields = new OwnedBy.Fields();
private final static RecordDataSchema SCHEMA = ((RecordDataSchema) DataTemplateUtil.parseSchema("namespace com.linkedin.metadata.relationship/**A generic model for the Owned-By relationship*/@pairings=[{\"destination\":\"com.linkedin.common.urn.CorpuserUrn\",\"source\":\"com.linkedin.common.urn.DatasetUrn\"},{\"destination\":\"com.linkedin.common.urn.CorpuserUrn\",\"source\":\"com.linkedin.common.urn.DataProcessUrn\"}]record OwnedBy includes/**Common fields that apply to all relationships*/record BaseRelationship{/**Urn for the source of the relationship*/source:{namespace [email protected]=\"com.linkedin.common.urn.Urn\"typeref Urn=string}/**Urn for the destination of the relationship*/destination:com.linkedin.common.Urn}{/**The type of the ownership*/type:{namespace com.linkedin.common/**Owner category or owner role*/enum OwnershipType{/**A person or group that is in charge of developing the code*/DEVELOPER/**A person or group that is owning the data*/DATAOWNER/**A person or a group that overseas the operation, e.g. a DBA or SRE.*/DELEGATE/**A person, group, or service that produces/generates the data*/PRODUCER/**A person, group, or service that consumes the data*/CONSUMER/**A person or a group that has direct business interest*/STAKEHOLDER}}}", SchemaFormatType.PDL));
private final static RecordDataSchema.Field FIELD_Source = SCHEMA.getField("source");
private final static RecordDataSchema.Field FIELD_Destination = SCHEMA.getField("destination");
private final static RecordDataSchema.Field FIELD_Type = SCHEMA.getField("type");
static {
Custom.initializeCustomClass(com.linkedin.common.urn.Urn.class);
}
public OwnedBy() {
super(new DataMap(4, 0.75F), SCHEMA);
}
public OwnedBy(DataMap data) {
super(data, SCHEMA);
}
public static OwnedBy.Fields fields() {
return _fields;
}
/**
* Existence checker for source
*
* @see OwnedBy.Fields#source
*/
public boolean hasSource() {
return contains(FIELD_Source);
}
/**
* Remover for source
*
* @see OwnedBy.Fields#source
*/
public void removeSource() {
remove(FIELD_Source);
}
/**
* Getter for source
*
* @see OwnedBy.Fields#source
*/
public com.linkedin.common.urn.Urn getSource(GetMode mode) {
return obtainCustomType(FIELD_Source, com.linkedin.common.urn.Urn.class, mode);
}
/**
* Getter for source
*
* @return
* Required field. Could be null for partial record.
* @see OwnedBy.Fields#source
*/
@Nonnull
public com.linkedin.common.urn.Urn getSource() {
return obtainCustomType(FIELD_Source, com.linkedin.common.urn.Urn.class, GetMode.STRICT);
}
/**
* Setter for source
*
* @see OwnedBy.Fields#source
*/
public OwnedBy setSource(com.linkedin.common.urn.Urn value, SetMode mode) {
putCustomType(FIELD_Source, com.linkedin.common.urn.Urn.class, String.class, value, mode);
return this;
}
/**
* Setter for source
*
* @param value
* Must not be null. For more control, use setters with mode instead.
* @see OwnedBy.Fields#source
*/
public OwnedBy setSource(
@Nonnull
com.linkedin.common.urn.Urn value) {
putCustomType(FIELD_Source, com.linkedin.common.urn.Urn.class, String.class, value, SetMode.DISALLOW_NULL);
return this;
}
/**
* Existence checker for destination
*
* @see OwnedBy.Fields#destination
*/
public boolean hasDestination() {
return contains(FIELD_Destination);
}
/**
* Remover for destination
*
* @see OwnedBy.Fields#destination
*/
public void removeDestination() {
remove(FIELD_Destination);
}
/**
* Getter for destination
*
* @see OwnedBy.Fields#destination
*/
public com.linkedin.common.urn.Urn getDestination(GetMode mode) {
return obtainCustomType(FIELD_Destination, com.linkedin.common.urn.Urn.class, mode);
}
/**
* Getter for destination
*
* @return
* Required field. Could be null for partial record.
* @see OwnedBy.Fields#destination
*/
@Nonnull
public com.linkedin.common.urn.Urn getDestination() {
return obtainCustomType(FIELD_Destination, com.linkedin.common.urn.Urn.class, GetMode.STRICT);
}
/**
* Setter for destination
*
* @see OwnedBy.Fields#destination
*/
public OwnedBy setDestination(com.linkedin.common.urn.Urn value, SetMode mode) {
putCustomType(FIELD_Destination, com.linkedin.common.urn.Urn.class, String.class, value, mode);
return this;
}
/**
* Setter for destination
*
* @param value
* Must not be null. For more control, use setters with mode instead.
* @see OwnedBy.Fields#destination
*/
public OwnedBy setDestination(
@Nonnull
com.linkedin.common.urn.Urn value) {
putCustomType(FIELD_Destination, com.linkedin.common.urn.Urn.class, String.class, value, SetMode.DISALLOW_NULL);
return this;
}
/**
* Existence checker for type
*
* @see OwnedBy.Fields#type
*/
public boolean hasType() {
return contains(FIELD_Type);
}
/**
* Remover for type
*
* @see OwnedBy.Fields#type
*/
public void removeType() {
remove(FIELD_Type);
}
/**
* Getter for type
*
* @see OwnedBy.Fields#type
*/
public OwnershipType getType(GetMode mode) {
return obtainDirect(FIELD_Type, OwnershipType.class, mode);
}
/**
* Getter for type
*
* @return
* Required field. Could be null for partial record.
* @see OwnedBy.Fields#type
*/
@Nonnull
public OwnershipType getType() {
return obtainDirect(FIELD_Type, OwnershipType.class, GetMode.STRICT);
}
/**
* Setter for type
*
* @see OwnedBy.Fields#type
*/
public OwnedBy setType(OwnershipType value, SetMode mode) {
putDirect(FIELD_Type, OwnershipType.class, String.class, value, mode);
return this;
}
/**
* Setter for type
*
* @param value
* Must not be null. For more control, use setters with mode instead.
* @see OwnedBy.Fields#type
*/
public OwnedBy setType(
@Nonnull
OwnershipType value) {
putDirect(FIELD_Type, OwnershipType.class, String.class, value, SetMode.DISALLOW_NULL);
return this;
}
@Override
public OwnedBy clone()
throws CloneNotSupportedException
{
return ((OwnedBy) super.clone());
}
@Override
public OwnedBy copy()
throws CloneNotSupportedException
{
return ((OwnedBy) super.copy());
}
public static class Fields
extends PathSpec
{
public Fields(List<String> path, String name) {
super(path, name);
}
public Fields() {
super();
}
/**
* Urn for the source of the relationship
*
*/
public PathSpec source() {
return new PathSpec(getPathComponents(), "source");
}
/**
* Urn for the destination of the relationship
*
*/
public PathSpec destination() {
return new PathSpec(getPathComponents(), "destination");
}
/**
* The type of the ownership
*
*/
public PathSpec type() {
return new PathSpec(getPathComponents(), "type");
}
}
}
可以看下如果要新增一个元模型/实体要怎么操作。特别提下,URN类似于唯一标识/类型,数据建模相关后面会单开一篇来讲,暂不展开。
DataHub提供两种数据接入方式:API调用或Kafka流。
DataHub的API基于Rest.li,Rest.li使用的是Pegasus作为接口定义,因此可以复用元数据模型。Kafka方式接收MCE,传输的格式为Avro(json格式),由Pegasus自动生成。由Apache Samza作为流处理框架,将Avro数据格式转换回Pegasus,并调用相应API。
DataHub支持四中常见查询:1、面向文档的查询;2、面向图形的查询;3、支持连接的复杂查询;4、全文检索
DataHub底层采用多级存储,以适配以上检索场景。并抽象出DAO层,以满足上层无感知调用。
可以看出DataHub在元数据中心领域所做的努力,不但其架构的迭代、扩展性,以及未来将引入的新功能。希望将LinkedIn内部在元数据中心建设的经验分享并输出成业界通用的解决方案,借助LinkedIn内部和社区的发展,在未来还真有望成为下一代数据资产的基础设施。只是对国内来说,小众化的技术组件和不多的实践文档让企业决策者和开发者望而却步。但其先进的理念和架构,还是值得大家研究借鉴。
[1] Open sourcing DataHub: LinkedIn’s metadata search and discovery platform
[2] DataHub: Popular metadata architectures explained
[3] A Dive Into Metadata Hubs
[4] 数据治理篇-元数据: datahub概述
[5] DataPipeline丨LinkedIn元数据之旅的最新进展—Data Hub 【译】