火山引擎DataLeap作为一站式数据中台套件, 汇集了字节内部多年积累的数据集成、开发、运维、治理、资产、安全等全套数据中台建设的经验,助力企业客户提升数据研发治理效率、降低管理成本。
Data Catalog是一种元数据管理的服务,会收集技术元数据,并在其基础上提供更丰富的业务上下文与语义,通常支持元数据编目、查找、详情浏览等功能。 目前Data Catalog作为火山引擎大数据研发治理套件DataLeap产品的核心功能之一,经过多年打磨,服务于字节跳动内部几乎所有核心业务线,解决了数据生产者和消费者对于元数据和资产管理的各项核心需求。
Data Catalog系统的存储层,依赖Apache Atlas,传递依赖JanusGraph。JanusGraph的存储后端,通常是一个Key-Column-Value模型的系统, 本文主要讲述了使用MySQL作为JanusGraph存储后端时,在设计上面的思考,以及在实际过程中遇到的一些问题。
文 | 国祥 火山引擎DataLeap团队
实际生产环境,我们使用的存储系统维护成本较高,有一定的运维压力,于是想要寻求替代方案。在这个过程中,我们试验了很多存储系统, 其中MySQL是重点投入调研和开发的备选之一。
另一方面,除了字节内部外,在ToB场景,MySQL的运维成本也会明显小于其他大数据组件, 如果MySQL的方案跑通,我们可以在ToB场景多一种选择。
基于以上两点,我们投入了一定的人力调研和实现基于MySQL的存储后端。
在设计上,JanusGraph的存储后端是可插拔的,只要做对应的适配即可,并且官方已经支持了一批存储系统。结合字节的技术栈以及我们的诉求,做了以下的评估。
/ 各类存储系统比较 /
●
最终我们挑选了MySQL来推进到下一步。
/ MySQL的理论可行性 /
●
●
●
/ 细节设计 /
1. 存储模型
JanusGraph要求column-family类型存储(如 Cassandra, HBase),也就是说,数据存储由一系列行组成,每行都由一个键(key)唯一标识,每行由多个列值(column-value)对组成,也会对列进行排序和过滤;
如果是非 column-family的类型存储,则需要另行适配,适配时数据模型有两种方式:Key-Column-Value和Key-Value。
KCV模型:
● 会将key\column\value在存储中区分开来。
● 对应的接口为:KeyColumnValueStoreManager。
KV模型:
● 在存储中仅有key和value两部分,此处的key相当于KVC模型中的key+column;
● 如果要根据column进行过滤,需要额外的适配工作;
● 对应的接口为:KeyValueStoreManager, 该接口有子类OrderedKeyValueStoreManager,提供了保证查询结果有序性的接口;
● 同时提供了OrderedKeyValueStoreManagerAdapter接口,用于对Key-Column-Value模型进行适配,将其转化为Key-Value模型。
MySQL的存储实现采用了KCV模型,每个表会有4列,一个自增的ID列,作为主键,同时还有3列分别对应模型中的key\column\value, 数据库中的一条记录相当于一个独立的KCV结构,多行数据库记录代表一个点或者边。
表中key和column这两列会组成联合索引,既保证了根据key进行查询时的效率,也支持了对column的排序以及条件过滤。
2. 多租户
存储层面: 默认情况下,JanusGraph会需要存储edgestore, graphindex, system_properties, txlog等多种数据类型,每个类型在MySQL中都有各自对的表,且表名使用租户名作为前缀,如tenantA_edgestore。
这样即使不同租户的数据在同一个数据库,在存储层面租户之间的数据也进行了隔离,减少了相互影响,方便日常运维。(理论上每个租户可以单独分配一个数据库)
具体实现: 每个租户都会有各自的MySQL连接配置,启动之后会为各个租户分别初始化数据库连接,所有和JanusGraph的请求都会通过Context传递租户信息,以便在操作数据库时选择该租户对应的连接。
具体代码:
● MysqlKcvTx:
实现了AbstractStoreTransaction,对具体的MySQL连接进行了封装,负责和数据库的交互,它的commit和rollback方法由封装的MySQL连接真正完成。
● MysqlKcvStore:
实现了KeyColumnValueStore,是具体执行读写操作的入口,每一个类型的Store对应一个MysqlKcvStore实例,MysqlKcvStore处理读写逻辑时,根据租户信息完全自主组装SQL语句,SQL语句会由MysqlKcvTx真正执行。
● MysqlKcvStoreManager:
实现了KeyColumnValueStoreManager,作为管理所有MySQL连接和租户的入口,也维护了所有Store和MysqlKcvStore对象的映射关系。在处理不同租户对不同Store的读写请求时,根据租户信息,创建MysqlKcvTx对象,并将其分配给对应的MysqlKcvStore去执行。
public class MysqlKcvStoreManager implements KeyColumnValueStoreManager {
@Override
public StoreTransaction beginTransaction(BaseTransactionConfig config) throws BackendException {
String tenant = TenantContext.getTenant();
if (!tenantToDataSourceMap.containsKey(tenant)) {
try {
// 初始化单个租户的DataSource
initSingleDataSource(tenant);
} catch (SQLException e) {
log.error("init mysql database source failed due to", e);
throw new BackendSQLException(String.format("init mysql database source failed due to", e.getMessage()));
}
}
// 获取数据库连接
Connection connection = tenantToDataSourceMap.get(tenant).getConnection(false);
return new MysqlKcvTx(config, tenant, connection);
}
}
3.事务
几乎所有与 JanusGraph 的交互都会开启事务,而且事务对于多个线程并发使用是安全的,但是JanusGraph的事务并不都支持ACID,是否支持会取决于底层存储组件, 对于某些存储组件来说,提供可序列化隔离机制或者多行原子写入代价会比较大。
JanusGraph中的每个图形操作都发生在事务的上下文中,根据TinkerPop的事务规范,每个线程执行图形上的第一个操作时便会打开针对图形数据库的事务,所有图形元素都与检索或者创建它们的事务范围相关联,在使用commit或者rollback方法显式的关闭事务之后,与该事务关联的图形元素都将过时且不可用。
JanusGraph提供了AbstractStoreTransaction接口,该接口包含commit和rollback的操作入口,在MySQL存储的实现中,MysqlKcvTx实现了AbstractStoreTransaction,对具体的MySQL连接进行了封装, 在其commit和rollback方法中调用SQL连接的commit和rollback方法,以此实现对于JanusGraph事务的支持。
public class MysqlKcvTx extends AbstractStoreTransaction {
private static final Logger log = LoggerFactory.getLogger(MysqlKcvTx.class);
private final Connection connection;
@Getter
private final String tenant;
public MysqlKcvTx(BaseTransactionConfig config, String tenant, Connection connection) {
super(config);
this.tenant = tenant;
this.connection = connection;
}
@Override
public synchronized void commit() {
try {
if (Objects.nonNull(connection)) {
connection.commit();
connection.close();
}
if (log.isDebugEnabled()) {
log.debug("tx has been committed");
}
} catch (SQLException e) {
log.error("failed to commit transaction", e);
}
}
@Override
public synchronized void rollback() {
try {
if (Objects.nonNull(connection)) {
connection.rollback();
connection.close();
}
if (log.isDebugEnabled()) {
log.debug("tx has been rollback");
}
} catch (SQLException e) {
log.error("failed to rollback transaction", e);
}
}
public Connection getConnection() {
return connection;
}
}
4.数据库连接池
Hikari是SpringBoot内置的数据库连接池,快速、简单,做了很多优化,如使用FastList替换ArrayList,自行研发无所集合类ConcurrentBag,字节码精简等, 在性能测试中表现的也比其他竞品要好。
Druid是另一个也非常优秀的数据库连接池,为监控而生,内置强大的监控功能,监控特性不影响性能。功能强大,能防SQL注入,内置Loging能诊断Hack应用行为。
关于两者的对比很多,此处不再赘述,虽然Hikari的性能号称要优于Druid,但是考虑到Hikari监控功能比较弱, 最终在实现的时候还是选择了Druid。
/ 疑难问题 /
1. 连接超时
现象 : 在进行数据导入测试时,服务报错" The last packet successfully received from the server was X milliseconds ago",导致数据写入失败。
原因 :存在超大table(有8000甚至10000列),这些table的元数据处理非常耗时(10000列的可能需要30分钟),而且在处理过程中有很长一段时间和数据库并没有交互,数据库连接一直空闲。
解决办法 :
● 调整mysql server端的wait_timeout参数,已调整到3600s。
● 调整client端数据库配置中连接的最小空闲时间,已调整到2400s。
分析过程 :
1. 怀疑是mysql client端没有增加空闲清理或者保活机制,conneciton在线程池中长时间没有使用,mysql服务端已经关闭该链接导致。尝试修改客户端connection空闲时间,增加validationQuery等常见措施,无果;
2. 根据打点发现单条消息处理耗时过高,疑似线程卡死;
3. 新增打点发现线程没卡死,只是在执行一些非常耗时的逻辑,这时候已经获取到了数据库连接,但是在执行那些耗时逻辑的过程中和数据库没有任何交互,长时间没有使用数据库连接,最终导致连接被回收;
4. 调高了MySQL server端的wait_timeout,以及client端的最小空闲时间,问题解决。
2. 并行写入死锁
现象 : 线程thread-p-3-a-0和线程thread-p-7-a-0在执行过程中都出现Deadlock。
具体日志如下:
[thread-p-3-a-0] ERROR org.janusgraph.diskstorage.mysql.MysqlKcvStore 313 - failed to insert query:INSERT INTO default_edgestore (g_key, g_column, g_value) VALUES (?,?,?) ON DUPLICATE KEY UPDATE g_value=?, params: key=A800000000001500, column=55A0, value=008000017CE616D0DD03674495
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
[thread-p-7-a-0] ERROR org.janusgraph.diskstorage.mysql.MysqlKcvStore 313 - failed to insert query:INSERT INTO default_edgestore (g_key, g_column, g_value) VALUES (?,?,?) ON DUPLICATE KEY UPDATE g_value=?, params: key=A800000000001500, column=55A0, value=008000017CE616D8E1036F3495
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
[thread-p-3-a-0] ERROR org.janusgraph.diskstorage.mysql.MysqlKcvStore 313 - failed to insert query:INSERT INTO default_edgestore (g_key, g_column, g_value) VALUES (?,?,?) ON DUPLICATE KEY UPDATE g_value=?, params: key=5000000000000080, column=55A0, value=008000017CE616F3C10442108A
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
[thread-p-7-a-0] ERROR org.janusgraph.diskstorage.mysql.MysqlKcvStore 313 - failed to insert query:INSERT INTO default_edgestore (g_key, g_column, g_value) VALUES (?,?,?) ON DUPLICATE KEY UPDATE g_value=?, params: key=5000000000000080, column=55A0, value=008000017CE61752B50556208A
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
原因 :
1. 结合日志分析,两个线程并发执行,需要对同样的多个记录加锁,但是顺序不一致,进而导致了死锁。
2. 55A0这个column对应的property是"__modificationTimestamp",该属性是atlas的系统属性,当对图库中的点或者边有更新时,对应点或者边的"__modificationTimestamp"属性会被更新。在并发导入数据的时候,加剧了资源竞争,所以会偶发死锁问题。
解决办法 : 业务中并没有用到"__modificationTimestamp"这个属性,通过修改Atlas代码,仅在创建点和边的时候为该属性赋值,后续更新时不再更新该属性,问题得到解决。
/ 环境搭建 /
在字节内部JanusGraph主要用作Data Catalog服务的存储层,关于MySQL作为存储的性能测试并没有在JanusGraph层面进行,而是模拟Data Catalog服务的业务使用场景和数据, 使用业务接口进行测试,主要会关注接口的响应时间。
接口逻辑有所裁剪,在不影响核心读写流程的情况下,屏蔽掉对其他服务的依赖。
模拟单租户表单分片情况下,库表元数据创建、更新、查询,表之间血缘关系的创建、查询, 以此反映在图库单次读写和多次读写情况下MySQL的表现。
整个测试环境搭建在火山引擎上,总共使用6台8C32G的机器,硬件条件如下:
测试场景如下:
/ 测试结论 /
总计10万个表(库数量为个位数,可忽略)
在10万个表且模拟了表之间血缘关系的情况下,graphindex表的数据量已有7000万,edgestore表的数据量已有1亿3000万, 业务接口的响应时间基本在预期范围内,可满足中小规模Data Catalog服务的存储要求。
MySQL作为JanusGraph的存储,有部署简单,方便运维等优势,也能保持良好的扩展性,在中小规模的Data Catalog存储服务中也能保持较好的性能水准,可以作为一个存储选择。
市面上也有比较成熟的MySQL分库分表方案,未来可以考虑将其引入,以满足更大规模的存储需求。
火山引擎Data Catalog产品是基于字节跳动内部平台,经过多年业务场景和产品能力打磨,在公有云进行部署和发布,期望帮助更多外部客户创造数据价值。
目前公有云产品已包含内部成熟的产品功能同时扩展若干ToB核心功能,正在逐步对齐业界领先Data Catalog云产品各项能力。
产品介绍
火山引擎大数据研发治理套件DataLeap
一站式数据中台套件,帮助用户快速完成数据集成、开发、运维、治理、资产、安全等全套数据中台建设,帮助数据团队有效的降低工作成本和数据维护成本、挖掘数据价值、为企业决策提供数据支撑。 后台回复数字“2”了解产品。