以一次Data Catalog架构升级为例聊业务系统的性能优化

摘要

字节的DataCatalog系统,在2021年进行过大规模重构,新版本的存储层基于Apache Atlas实现。迁移过程中,我们遇到了比较多的性能问题。本文以Data Catalog系统升级过程为例,与大家讨论业务系统性能优化方面的思考,也会介绍我们关于Apache Atlas相关的性能优化。

背景

字节跳动Data Catalog产品早期,是基于LinkedIn Wherehows进行二次改造,产品早期只支持Hive一种数据源。后续为了支持业务发展,做了很多修修补补的工作,系统的可维护性和扩展性变得不可忍受。比如为了支持数据血缘能力,引入了字节内部的图数据库veGraph,写入时,需要业务层处理MySQL、ElasticSearch和veGraph三种存储,模型也需要同时理解关系型和图两种。更多的背景可以参照之前的文章

新版本保留了原有版本全量的产品能力,将存储层替换成了Apache Atlas。然而,当我们把存量数据导入到新系统时,许多接口的读写性能都有严重下降,服务器资源的使用也被拉伸到夸张的地步,比如:

  • 写入一张超过3000列的Hive表元数据时,会持续将服务节点的CPU占用率提升到100%,十几分钟后触发超时

  • 一张几十列的埋点表,上下游很多,打开详情展示时需要等1分钟以上

为此,我们进行了一系列的性能调优,结合Data Catlog产品的特点,调整了Apache Atlas以及底层Janusgraph的实现或配置,并对优化性能的方法论做了一些总结。

业务系统优化的整体思路

在开始讨论更多细节之前,先概要介绍下我们做业务类系统优化的思路。本文中的业务系统,是相对于引擎系统的概念,特指解决某些业务场景,给用户直接暴露前端使用的Web类系统。

优化之前,首先应明确优化目标。与引擎类系统不同,业务类系统不会追求极致的性能体验,更多是以解决实际的业务场景和问题出发,做针对性的调优,需要格外注意避免过早优化与过度优化。

准确定位到瓶颈,才能事半功倍。一套业务系统中,可以优化的点通常有很多,从业务流程梳理到底层组件的性能提升,但是对瓶颈处优化,才是ROI最高的。

根据问题类型,挑性价比最高的解决方案。解决一个问题,通常会有很多种不同的方案,就像条条大路通罗马,但在实际工作中,我们通常不会追求最完美的方案,而是选用性价比最高的。

优化的效果得能快速得到验证。性能调优具有一定的不确定性,当我们做了某种优化策略后,通常不能上线观察效果,需要一种更敏捷的验证方式,才能确保及时发现策略的有效性,并及时做相应的调整。

业务系统优化的细节

优化目标的确定

在业务系统中做优化时,比较忌讳两件事情:

  • 过早优化:在一些功能、实现、依赖系统、部署环境还没有稳定时,过早的投入优化代码或者设计,在后续系统发生变更时,可能会造成精力浪费。

  • 过度优化:与引擎类系统不同,业务系统通常不需要跑分或者与其他系统产出性能对比报表,实际工作中更多的是贴合业务场景做优化。比如用户直接访问前端界面的系统,通常不需要将响应时间优化到ms以下,几十毫秒和几百毫秒,已经是满足要求的了。

优化范围选择

对于一个业务类Web服务来说,特别是重构阶段,优化范围比较容易圈定,主要是找出与之前系统相比,明显变慢的那部分API,比如可以通过以下方式收集需要优化的部分:

  • 通过前端的慢查询捕捉工具或者后端的监控系统,筛选出P90大于2s的API

  • 页面测试过程中,研发和测试同学陆续反馈的API

  • 数据导入过程中,研发发现的写入慢的API等

优化目标确立

针对不同的业务功能和场景,定义尽可能细致的优化目标,以Data Catalog系统为例:

接口或功能

现状

目标

元数据的详情展示页面

一张被广泛使用的埋点表打开需要~1 min

列数超过1000的Hive表加载时间>2 min

广泛引用埋点表<1s

展示超过1000列的表<10s,页面不崩溃

与业务标签相关的展示页面

>10s

~1s

元数据写入操作

小Entity(10个关系以内)秒级;

大Entity(100个关系到500个关系)分钟级;

超大Entity(>3000)写入时造成CPU 100%,写入失败

小Entity<1s

大Entity ~5s

超大Entity分钟级可写入

用户管理的元数据列表页面

>5s

<1s

血缘的影响分析页面

埋点表拉取十万级上下游需要20+min,经常失败

秒级到分钟级响应

定位性能瓶颈手段

系统复杂到一定程度时,一次简单的接口调用,都可能牵扯出底层广泛的调用,在优化某个具体的API时,如何准确找出造成性能问题的瓶颈,是后续其他步骤的关键。下面的表格是我们总结的常用瓶颈排查手段。

手段

说明

优点

缺点

优先级

实践举例

监控

在请求的最外部,或者是怀疑有性能问题的函数添加监控(通常是调用花费时间),可以使用监控客户端提供的Bucket数据结构,输出P90等统计信息

比较通用和标准的做法,上线后,也可以继续使用

粒度比较粗

P0

新DataCatalog系统中,我们使用AOP的手段,为主要函数添加了监控切面,暴露出调用次数和P90的响应时间

写日志

在可能有问题的地方输出协助排查的日志

粒度细

对于性能问题,日志可能打很多,需要借助日志分析工具或人工手段,从中挑选出真正有用的

P2

在排查元数据详情展示页面的性能问题时,我们在拉取与当前元数据相关的其他实体的函数中添加了日志,详细打印出了拉取的逻辑,发现代码实现中,会拉取很多不需要的关系

jvm指令

使用jvm自带的jmap,jstat,jstack等指令,分析jvm的行为和状态

比较方便,对于jvm类程序有比较好的效果

需要一些经验,有一定门槛;粒度有些粗,需要进一步的加工与分析

P0

遇到ElasticSearch写入慢的问题,通过看jstat -gc指令看GC count发现系统在频繁GC;定位CPU 100%问题时,通过jstack指令看线程栈;定位Janusgraph的transaction 内存泄漏通过jmap做heap dump

第3方工具

使用Arthas等第三方增强过的工具

粒度细,定位准确,辅助调查效果好

需要额外安装部署;有学习成本

P1

写入Entity瓶颈,通过Arthas的timer profiler输出调用耗时图定位

优化策略

在找到某个接口的性能瓶颈后,下一步是着手处理。同一个问题,修复的手段可能有多种,实际工作中,我们优先考虑性价比高的,也就是实现简单且有明确效果。

策略

子策略

说明

优点

缺点

优先级

实践举例

加资源

(增加带宽、CPU、内存等物理资源)

纵向

升级机器的CPU和Memory

简单易实现,当前大部分公司都在使用云部署服务,云平台触发升级任务后就可验证

不一定可以解决问题;对性能的提升不一定随着资源线性增加;有隐藏真实问题的风险

P0

初期将机型的CPU和内存扩展一倍

横向

增加服务器个数

将每个机房的服务节点扩展了一倍

切换部署方式

将共享集群转换为专有集群等

新系统对ElasticSearch的依赖更重,额外申请了专有集群

调参数

(通过提升资源的利用率,换取更好的性能)

调节GC的参数

对于JVM型的应用,可以通过调节GC的一些参数来更高效的使用内存,或减少GC的停顿

成本小,容易快速验证

需要对要调节的参数有一定的经验,门槛比加资源高;

很多参数的应用场景会有限制,不一定可以解决问题

P0

将GC的算法从CMS调整为G1;开启云主机内存与JVM内存的配比关系等

调节Web服务本身的参数

开启一些服务端本身支持的调优参数,比如增加线程池大小

调节Web服务中,处理血缘相关请求线程池的大小

调节依赖系统Client的参数

调节访问某项服务Client的配置,比如增加数据库连接池个数

对Janusgraph的参数做调整

改实现

(修改代码,替换依赖项等)

换依赖

将某项依赖换成功能类似,但是性能更好的组件

效果比较明显

改动量可能比较大,更多的应该在技术选型阶段调研清楚

P1

系统中对数据采样的功能,查询引擎由Hive迁移至Presto

精简计算或者返回的数据

忽略掉不需要拉取数据,分支中更早的返回

效果明显,针对IO bound和CPU bound的情况,都有比较好的效果

普适性不一定好,通常是针对某些特定场景定制;需要比较深入的理解代码,门槛高

P1

Data Catalog系统的详情展示页面,默认只拉取技术和业务类元数据,忽略血缘关系

加Cache

对于读多写少的场景,添加本地Cache/LRUCache/Redis缓存等,都是非常有效的优化手段

效果明显,实现简单

Cache会消耗内存,避免过度占用;需要考虑一致性问题,也就是Cache的时效性和更新等

P0

系统中有几处较慢的数据读取场景,数据本身是天级别更新的,添加天级别刷新的本地Cache后提升明显

多线程

对于IO bound的场景,增加并行通常可以增加系统吞吐

相对容易实现,通用性比较好

对CPU资源和内存资源的消耗可能比较多,需要评估;可能隐藏真正的问题

P0

系统中有一部分功能,是先拉取回一批技术元数据,再为这些元数据逐个添加业务类属性,后面一个步骤适合放到线程池中并行处理

采用更高效的算法

比如将O(N)的算法替换成O(logN)

看上去比较厉害

效果不一定好,除非代码实现有特别明显的问题;普适性不好

P2

在业务系统中直接优化的情况不多,更多是替换依赖间接使用

改功能

(通过评估需求的合理性,修改功能,原先需要解决的复杂性能问题,可能变得简单些)

大量数据只返回部分

用户通过前端界面使用的系统,返回的数据量可做采样,规避一次性拉取大规模数据的场景

功能层的修改,比较容易实现

场景受限,需要对业务有较准确的判断

P0

对于血缘关系庞大的元数据节点,默认只返回部分,用户在确认需要读取更多时,再触发拉取全部的请求

调整交互

通过分页等手段,降低每次的计算或者数据开销

快速验证

优化的过程通常需要不断的尝试,所以快速验证特别关键,直接影响优化的效率。

验证手段

子手段

优点

缺点

优先级

实践举例

本地测试

单元测试

开销最小,最方便

性能问题很多时候跟数据量有关,本地不太容易模拟,更多的只能做功能性验证

P0

对于使用修改代码类的优化,通常本地运行Debug模式,确保优化的代码按照预期被调用后,再做后续的部署测试

IDE Debug

代码打包后命令行起服务

链接测试环境

部署特定的线上测试环境

线上挑一台机器部署验证

可以比较接近线上环境模拟,验证的结论可靠

验证链路有点长;对运维有一定要求,潜在的影响线上用户的风险

P1

当采用添加日志等优化手段后,通常会挑选一台机器部署,然后想办法把测试请求打到这台机器上,方便追踪日志

搭建只有一台机器的线上测试环境

P0

添加分支逻辑,只对特定请求触发

模拟情况最真实

临时代码,后续需要额外清理;风险较高,潜在影响线上用户

P2

在请求的Header中添加标记,代码中识别到后调用特殊的逻辑

输出Profile

代码中输出Profile信息

真实情况,信息详实

临时代码,后续需要额外清理;风险较高,潜在影响线上用户

P2

为了避免大量日志打印,对优化的函数做一定的统计信息输出

交互式命令行

信息详实,验证速度快,结论可靠

方案不一定通用,受场景限制

P0

为JanusGraph搭建Gremlin Server,通过Console可以测试验证跟代码中一样的语句,输出详细的Profile信息;使用Arthas产出调用的Profile信息

Data Catalog系统优化举例

在我们升级字节Data Catalog系统的过程中,广泛使用了上文中介绍的各种技巧。本章节,我们挑选一些较典型的案例,详细介绍优化的过程。

调节JanusGraph配置

实践中,我们发现以下两个参数对于JanusGraph的查询性能有比较大的影响:

  • query.batch = ture

  • query.batch-property-prefetch=true

其中,关于第二个配置项的细节,可以参照我们之前发布的文章。这里重点讲一下第一个配置。

JanusGraph做查询的行为,有两种方式:

方式

配置

优点

缺点

典型场景

有查询请求时就访问存储

query.batch = false

小数据量时响应时间快

对关系多节点查询时效率较差

对延迟敏感,每次查询的数据量很少

缓存一部分查询请求,批量查询存储

query.batch = true

节点关系多时效率更高

消耗更多资源;潜在拉取更多无用数据

存储Backend与Client没有部署在一起;每次查询的关系比较多

针对字节内部的应用场景,元数据间的关系较多,且元数据结构复杂,大部分查询都会触发较多的节点访问,我们将query.batch设置成true时,整体的效果更好。

调整Gremlin语句减少计算和IO

一个比较典型的应用场景,是对通过关系拉取的其他节点,根据某种属性做Count。在我们的系统中,有一个叫“BusinessDomain”的标签类型,产品上,需要获取与某个此类标签相关联的元数据类型,以及每种类型的数量,返回类似下面的结构体: "HiveTable", "count: 601 } ] }

{
                "guid": "XXXXXX",
                "typeName": "BusinessDomain",
                "attributes": {
                    "nameCN": "直播",
                    "nameEN": null,
                    "creator": "XXXX",
                    "department": "XXXX",
                    "description": "直播业务标签"
                },
                "statistics": [
                    {
                        "typeName": "ClickhouseTable",
                        "count": 68
                    },
                    {
                        "typeName": "HiveTable",
                        "count": 601
                    }
                ]
            }

我们的初始实现转化为Gremlin语句后,如下所示,耗时2~3s:

g.V().has('__typeName', 'BusinessDomain')
    .has('__qualifiedName', eq('XXXX'))
    .out('r:DataStoreBusinessDomainRelationship')
    .groupCount().by('__typeName')
    .profile();

优化后的Gremlin如下,耗时~50ms:

g.V().has('__typeName', 'BusinessDomain')
    .has('__qualifiedName', eq('XXXX'))
    .out('r:DataStoreBusinessDomainRelationship')
    .values('__typeName').groupCount().by()
    .profile();

Atlas中根据Guid拉取数据计算逻辑调整

对于详情展示等场景,会根据Guid拉取与实体相关的数据。我们优化了部分EntityGraphRetriever中的实现,比如:

  • mapVertexToAtlasEntity中,修改边遍历的读数据方式,调整为以点以及点上的属性过滤拉取,触发multiPreFetch优化。

  • 支持根据边类型拉取数据,在应用层根据不同的场景,指定不同的边类型集合,做数据的裁剪。最典型的应用是,在详情展示页面,去掉对血缘关系的拉取。

  • 限制关系拉取的深度,在我们的业务中,大部分关系只需要拉取一层,个别的需要一次性拉取两层,所以我们接口实现上,支持传入拉取关系的深度,默认一层。

配合其他的修改,对于被广泛引用的埋点表,读取的耗时从~1min下降为1s以内。

对大量节点依次获取信息加并行处理

在血缘相关接口中,有个场景是需要根据血缘关系,拉取某个元数据的上下游N层元数据,新拉取出的元数据,需要额外再查询一次,做属性的扩充。

我们采用增加并行的方式优化,简单来说:

  • 设置一个Core线程较少,但Max线程数较多的线程池:需要拉取全量上下游的情况是少数,大部分情况下几个Core线程就够用,对于少数情况,再启用额外的线程。

  • 在批量拉取某一层的元数据后,将每个新拉取的元数据顶点加入到一个线程中,在线程中单独做属性扩充

  • 等待所有的线程返回

对于关系较多的元数据,优化效果可以从分钟级到秒级。

对于写入瓶颈的优化

字节的数仓中有部分大宽表,列数超过3000。对于这类元数据,初始的版本几乎没法成功写入,耗时也经常超过15 min,CPU的利用率会飙升到100%。

定位写入的瓶颈

我们将线上的一台机器从LoadBalance中移除,并构造了一个拥有超过3000个列的元数据写入请求,使用Arthas的itemer做Profile,得到下图:

从上图可知,总体70%左右的时间,花费在createOrUpdate中引用的addProperty函数。

耗时分析

1. JanusGraph在写入一个property的时候,会先找到跟这个property相关的组合索引,然后从中筛选出Coordinality为“Single”的索引

2. 在写入之前,会check这些为Single的索引是否已经含有了当前要写入的propertyValue

3. 组合索引在JanusGraph中的存储格式为:

4. Atlas默认创建的“guid”属性被标记为globalUnique,他所对应的组合索引是__guid。

5. 对于其他在类型定义文件中被声明为“Unique”的属性,比如我们业务语义上全局唯一的“qualifiedName”,Atlas会理解为“perTypeUnique”,对于这个Property本身,如果也需要建索引,会建出一个coordinity是set的完全索引,为“propertyName+typeName”生成一个唯一的完全索引

6. 在调用“addProperty”时,会首先根据属性的类型定义,查找“Unique”的索引。针对“globalUnique”的属性,比如“guid”,返回的是“__guid”;针对“perTypeUnique”的属性,比如“qualifiedName”,返回的是“propertyName+typeName”的组合索引。

7. 针对唯一索引,会尝试检查“Unique”属性是否已经存在了。方法是拼接一个查询语句,然后到图里查询

8. 在我们的设计中,写入表的场景,每一列都有被标记为唯一的“guid”和“qualifiedName”,“guid”会作为全局唯一来查询对应的完全索引,“qualifiedName”会作为“perTypeUnique”的查询“propertyName+typeName”的组合完全索引,且整个过程是顺序的,因此当写入列很多、属性很多、关系很多时,总体上比较耗时。

优化思路

  • 对于“guid”,其实在创建时已经根据“guid”的生成规则保证了全局唯一性,几乎不可能有冲突,所以我们可以考虑去掉写入时对“guid”的唯一性检查,节省了一半时间。

  • 对于“qualifiedName”,根据业务的生成规则,也是“globalUnique”的,与“perTypeUnique”的性能差别几乎是一倍:

优化实现效果

  • 去除Atlas中对于“guid”的唯一性的检查。

  • 添加“Global_Unqiue”配置项,在类型定义时使用,在初始化时对“__qualifiedName”建立全局唯一索引。

  • 配合其他优化手段,对于超多属性与关系的Entity写入,耗时可以降低为分钟级。

总结

  • 业务类系统的性能优化,通常会以解决某个具体的业务场景为目标,从接口入手,逐层解决

  • 性能优化基本遵循思路:发现问题->定位问题->解决问题->验证效果->总结提升

  • 优先考虑“巧”办法,“土”办法,比如加机器改参数,不为了追求高大上而走弯路

欢迎跳转火山引擎大数据研发治理套件DataLeap官网了解详情! 欢迎关注字节跳动数据平台同名公众号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值