【原创】用EMR建设实时数仓

建设实时数仓的目的和意义

实时数仓目的

数仓概念:数据尽可能多,保存时间尽可能久

图片描述

实时概念:数据流式,处理及时、瞬时、短时、事件或者微批响应

图片描述

数仓跟实时从概念上就有冲突,所以本质上不太适合处理广泛的问题,比如,对一个月,甚至是一年的数据进行统计计算。

图片描述

所以,实时数仓应该目前作为离线数仓的一种补充,解决因离线数仓实时性低而无法解决的问题,具体点说就是处理离线两个周期间隔的数据问题,不适合解决大批量数据聚合问题、业务性太强的以及对实时性要求很高问题。

实时数仓的意义

图片描述

实时数仓从概念上讲还是要靠近数仓的概念,数据分层,面向主题,数据尽可能集成,结构相对稳定,不易发生变化。

图片描述

对于实时数仓来讲,数据量不需要保存像离线那么久,上一节我们提到,实时数仓处理两个离线周期间隔的数据即可,如上图,以时报为例,实时数仓补充中间数据即可,以天为例,实时数仓最多只需要保留3~5天数据即可,能够支持一段时间的数据追溯和重导就可以了。

实时数仓可以解决哪类问题

图片描述

利用EMR建设实时数仓

实时数仓对比离线数仓

图片描述

实时数仓架构

图片描述

从图中可以看到,

1、ODS并不是实时数仓的一部分,是依赖外部数据源,比如binlog,流量日志,系统日志,或者是其他消息队列

2、应用层也不是实时数仓的一部分,对于数据的使用,通过实时数仓暴露Topic来使用

3、实时数仓要求层次要少,因为需要尽可能降低延迟

用EMR搭建实时数仓

图片描述

1、底层数据源可以接企业内部binlog、日志或者消息队列

2、从ODS层经过与维表轻度扩展,形成明细层明细表,明细表用一个Ckafka topic表示,计算采用Oceanus或者EMR FlinkSql 关联查询,维表采用EMR Hbase存储

3、从明细层经过进一步汇总计算,形成汇总层,此时数据已经是面向主题的汇总数据,就是传统意义上的大宽表,一个主题是一系列Ckafka topic,计算采用Oceanus或者EMR FlinkSql 关联查询以及汇总计算

实时数仓各层搭建

ODS层搭建

图片描述

1、 之所以没有把ODS层放在实时数仓的一部分,是因为实时数仓的ODS并不像离线数仓ODS是采集过来的原始数据,现在一般企业都已经具备了如上图的底层数据源

2、 Binlog,是数据库日志,通过Binlog可以自数据库主从间同步,可以同步关系型数据库数据,目前企业线上数据库都采用Mysql这样的数据库,可以通过抓取Mysql binlog 获取数据库变更信息,数仓中重要的业务数据,支付相关,用户相关,管理相关数据一般都从这种数据源获得

3、 Log日志,服务器日志,像服务器系统日志采集,都是通过这种形式进行采集

Ckafka,企业通过消息队列提供数据源服务,比如,点击流服务,会把用户点击事件通过上报服务器上报到Ckafka,为后续分析提供原始数据

该层搭建的注意点:
1、 业务选择数据源,尽量跟离线保持一致,比如某个业务,数据源即可以通过Binlog,也可以通过Log日志采集,如果离线数仓业务是通过Binlog,那么实时数仓也取Binlog,否则后续产生数据不一致,非常难以定位

2、数据源要求一致性,对于Ckafka和Binlog 需要进行分区一致性保证,解决数据乱序问题

明细层搭建

图片描述

建设标准与离线数仓目标一致,解决原始数据存在噪音,不完整,形式不统一等问题

图片描述

数据解析,业务整合,数据清洗,解决噪音,不完整,数据不一致问题;模型规范化(提前指定号规则,尽量跟离线保持一致),形成数据规范,规范尽可能跟离线保持一致,命名,比如,指标命名等;

与离线数仓不同之处在于,离线调度是有周期的,时报一小时,天报周期为一天,如果修改数据表字段,只要任务没开始,就可以修改,而实时是流式,7X24小时不间断运行的,想要修改流中的字段或者格式,对下游影响是不可预估的

实时数仓如果修改字段不像离线,在间隔期间通知下游把作业都改了就没事了,但是实时不一样,实时你改掉了字段,下游作业必须可以认识你修改的内容才行,kafka不是结构化存储,没有元数据的概念,不像Hive,如果表名不规范,找一个统一时间,把catolog改规范,然后把脚本一改就就解决了。

明细建设关键,我们会在每一条数据上增加一些额外字段到数仓里

图片描述

举例说明这些额外字段的意义

事件主键:对于上游数据重复问题,我们会根据一些数据内的字段来判断上有数据的唯一性,比如binlog,<集群id_><库id_><表id_>数据id_数据生成时间。

数据主键:唯一标识数据表的一行记录,可以使用数据库主键,主要用来解决分区一致性及分区有序。

数据元数据版本:上面介绍了,流式计算是7X24小时不间断的运算,当修改了数据结构,增加,删除了字段,对下游的影响是不可预估的,因此元数据变更需修改该字段,保持数据流中新老版本数据双跑,下游选择合适的时机进行数据切换。

数据批次:跟元数据用途相似,当明细层逻辑发现问题,需要重跑数据,为了对下游任务不产生影响,调整了明细层逻辑后,需要回倒位点重跑数据,同时需要跟老逻辑任务双跑,待下游业务都切换到新的逻辑后,老逻辑任务才可以停止。

还有一个思路,可以直接把明细层数据,也可以直接写到druid 直接用于分析。

维度层搭建

图片描述

维度数据处理:

图片描述

如上图,对于变化频率低,地理,节假日,代码转换,直接同步加载到缓存里,或者是新增数据,但是增加进来就不变了,通过数据接口,访问最新数据,然后通过本公司数据服务对外提供数据

图片描述

如上图,对于变化频率高,比如商品价格,也是需要监听变化消息,然后实时更新维度拉链表。对于比如像最近一个月没有消费用户这样的衍生维度,是需要根据变化消息,通过计算得到的衍生维度拉链。

因为维度数据也在发生变化,为了能够让源表数据匹配到维表,我们会给维表增加多版本minversion,然后通过TIMERANGE => [1303668804,
1303668904]筛选出源数据指定的维表版本数据。

这里有些同学可能觉得如果版本一致保存下去,会不会非常大,是的,我们响应的需要配置TTL保证维表数据量可控,上文我们介绍过,实时数仓解决是离线数仓两个间隔的问题,那么像这种变化频繁的数据我们TTL设置一周足够了。

关于源表与维表如果进行join,Flink原生sql以及Oceanus都是采用UDTF函数以及Lateral
Table 进行联合使用,其中UDTF我们可以实现查询数据服务获取维表数据的能力,Oceanus请参考相关材料。

汇总层搭建

图片描述

汇总层加工其实跟离线数仓是一致的,对共性指标进行加工,比如,pv,uv,订单优惠金额,交易额等,会在汇总层进行统一计算。

Flink提供了丰富的窗口计算,这使得我们可以做更细力度的聚合运算,例如,我们可以算最近5分钟,10分钟的数据聚合,根据时间窗口的间隔,也需要调整相应的TTL,保障内存高效实用。

Flink提供了丰富的聚合计算,数据都是要存在内存中的,因此需要注意设置state的TTL,例如,做Count(Distinct
x)。或者在进行PV,UV计算时候,都会使用大量的内存,这一块,当处理的基数比较大的时候,推荐使用一些非高精度去重算法,Bloom过滤器,Hyper LogLog等。

汇总层也需要在每一条数据上增加一些额外字段到数仓里,这块与明细层一致,就不在单独讲解了。

数据质量保证

图片描述

对于实时数仓数据质量的管理,我们通常由三步操作组成

第一步,数据与离线数据进行对比

首先,将汇总层数据Topic通过平台接入任务接入到离线仓库,然后通过数据质量任务,定时对实时数仓和离线数仓数据进行对比,并配置报警,数据差异,数据波动等。

第二步,配置报警,我们会在明细层以及汇总层,Topic配置生产监控,与以往数据波动,上游数据延迟或者积压,都需要进行报警。

第三部,构建实时血缘, Flink 在读取数据时候,会把信息读到flink catalog 这样就知道这个任务读取了哪个表,在解析客户DDL代码时,可以获得目标表信息,同步到我们的元数据服务。

参考文献:

美团实时数仓搭建:https://tech.meituan.com/2018/10/18/meishi-data-flink.html

菜鸟实时数仓:https://mp.weixin.qq.com/s/9ZRG76-vCM7AlRZNFCLrqA

Spark 实践 | B站离线计算的实践

1. 背景介绍

2018年B站基于Hadoop开始搭建离线计算服务,计算集群规模从最初的两百台到发展到目前近万台,从单机房发展到多机房。我们先后在生产上大规模的使用了 Hive、Spark、Presto 作为离线计算引擎,其中 Hive 和 Spark 部署在 Yarn 上,具体的架构如下,目前每天有约20w的离线批作业运行在 Spark 和 Hive 上,下面介绍下我们做了哪些工作来确保这些作业的高效与稳定。

2. 从Hive到Spark

21年初的时候Hive还是B站主要的离线计算引擎,80%以上的离线作业使用 Hive 执行,Spark2.4作业占比接近20%,集群资源的使用率长期维持在80%以上。21年3月 Spark3.1 发布,相较于 Spark2.4 性能有了较大的提升,我们开始推动Spark3.1 在B站的落地,同时将 Hive-SQL 整体迁移至 Spark-SQL。

在B站,离线计算的调度已经完成了收口,80%以上的作业来自于自建的 BSK 调度平台,其余的作业基本也都是 airflow 提交上来的,只有少量的任务来自散落的开发机。在推动 Hive 升级 Spark 时只要将调度平台的作业完成迁移就可以覆盖90%以上的作业。起步阶段我们进行了少量的人工迁移,对用户 SQL 进行了简单改写,修改了输入输出表后由两个引擎执行,开发了一个结果对比的工具,通过对双跑结果分析保障迁移效果。基于这个操作链路我们自研了一个自动迁移工具,减少人工失误和人力压力。

2.1 语句转换

我们重写了 SparkSqlParser,将从调度系统中收集到的 SQL 进行输入输出表的替换,避免对生产环境的影响。调度平台进行作业调度时以 DAG 为单位,一个调度任务里面可能存在多条 SQL,这些 SQL的输入输出表间存在依赖关系,为了保证双跑尽可能的模拟生产表现,对一个 DAG 里面的多个调度作业进行输入输出表替换时进行整体替换,保证了相互间依赖。对于 Select语句因为本身没有输出表,需要将 Select 语句转换为 CTAS 语句,这样就能将执行结果落地进行对比,需要注意的是转换过程中要将列名进行编码防止中文列导致的建表失败。当迁移工具识别出 SQL 语句为 DDL 语句,如果不是 CTAS 这种需要消耗计算资源的就直接跳过对比,同时对该语句进行标记,保证交由 Hive 执行,防止意外的元信息修改。

2.2 结果对比

双跑输出结果的对比是保证数据准确性的关键。首先对两个结果表的 Schema 进行对比,这个通过调用 DESC 语法返回结果对照就可以完成。对于 Schema 一致的两个表则进行下一步操作,两表全量数据对比,我们设计了一个 SQL 对数据按行进行整体对比,具体的对比思路如图:

第一步将两表按所有列(这里是 name 和 num 字段)进行 GROUP BY,第二步 UNION ALL 两表数据,第三步再按所有列(这里是 name, num 和 cnt 字段) GROUP BY 一次产生最终表,在最终表中 cnts 值为2的行表示这行数据在两表中都有且重复值一致,对于值非2的数据就是差异行了。从上图的例子来说差异行 Jack|1|2|1 表示 Jack|1 这行数据数据在一个表中存在两行,结合差异行 Jack|1|1|1 来看其实就是 Jack|1 这行数据一个表有一行另一个表有两行。通过这个方式就可以对双跑产出的结果表进行一个全量的对比。通过这种结果对比方法可以完成大部分双跑任务的结果对比,但是对于结果表中存在 LIST、SET、MAP 这种容器类型的,因为在 toString 时顺序是无法保证的,所以会被识别为不一致,此外对于非稳定性的 SQL 如某列数据是 random 产生,因为每次执行产出的结果不一致,也会识别为对比失败,这两种情况下就需用人工的介入来分析了。

资源利用率的提升是做引擎升级的出发点,除了结果对比来保证数据准确性,我们还做了资源消耗对比来保证迁移的收益。对比系统收集了每个作业的执行时间以及消耗的资源,从执行时间、CPU 和内存的资源消耗进行两个引擎执行性能的对比,在执行最终迁移前依据收集的数据为用户提供了迁移的预期收益,提高了用户迁移任务的积极性。从迁移中收集的数据来看 hive 切到 spark 可以减少40%以上的执行时间,同时整体资源消耗降低30%以上。

2.3 迁移&回滚

迁移系统对每个任务都执行了至少3次的双跑对比,但依然不能完全消除执行迁移的风险,在实际迁移过程中的几次问题都是迁移后稳定性不符合预期导致的,因此迁移系统对于迁移后的任务增加了监控,在一个任务迁移后,该任务的前3次调度执行消耗的时间、CPU 和内存资源将被用来和迁移前的七次平均执行数据对比,如果存在负优化的情况则会将这个任务执行引擎进行回滚并通知我们介入进行进一步分析。

3. Spark 在B站的实践

3.1 稳定性改进

3.1.1 小文件问题

随着B站业务高速发展,数据量和作业数增长越来越快,伴随而来的小文件数也快速增长,小文件太多会增加 HDFS 元数据的压力,在计算引擎读取时也大大增加了读请求的数量降低了读取效率。为了解决小文件的问题,在写表场景下对 Spark 做了如下两种改造。

  • 兜底小文件合并:我们修改了数据的写出目录,引擎计算先写到一个中间目录,在 FileFormatWriter.write 结束后 refreshUpdatedPartitions 前,插入了一个文件合并逻辑,从中间目录中获取分区下文件的平均大小,对于不存在小文件情况的目录直接MV到最终目录,对于存在小文件的目录新增一个读 RDD coalesce 到一个合适值写出后 MV 到最终目录。
  • 基于 reparation 的小文件合并:可以看到兜底小文件合并方式需要先将数据落地到 HDFS,重新读取后再写出,这样做放大了 HDFS写操作(三副本),降低了计算引擎的执行性能。而 Spark3的 AQE 特性可以在有 shuffle 的场景下有效解决小文件的问题,很多情况下对于没有 shuffle 的场景新增一个 reparation 操作就可以借助 AQE 的能力解决小文件的问题。社区 AQE 对于 reparation 这个 hint 是不会调整目标分区数的,我们新增了一个 rebalance hint,本质上和reparation 一样只是将 AQE 的特性应用在了这个操作上,同时将 AQE 目标 size 相关的属性和 rebalance 设置属性做了隔离方便更好的设置文件大小而不影响计算的并行度。rebalance 操作会在最终写出前增加一个 shuffle stage,有些情况下没有这个 stage 上游输出就已经没有小文件了,为此作业是否增加 rebalance 操作依赖于我们对任务的画像通过 HBO 系统开启。

3.1.2 shuffle 稳定性问题

Shuffle 稳定性直接影响了 Spark 作业的 SLA,在B站推动 Spark 升级过程中成为用户顾虑的点。

  • shuffle 磁盘分级:B站 Yarn 主集群采用 DataNode 和 NodeManage 混部模式,节点配置了多块 HDD 盘和少量 SSD 盘,NM 以 HDD 盘作为计算盘,由于和 DN 没有做到 IO 隔离,DN 和shuffle service 经常互相影响,因此我们对DiskBlockManager 进行了改造,优先使用 SSD 盘下的目录作为工作目录,当 SSD 盘存储空间或者 inode 紧张时则降级到 Yarn 配置的计算目录,借助 SSD 优异的随机 IO 能力,有效的提高的了 shuffle 稳定性。
  • remote shuffle service:push based shuffle 方案可以大量降低磁盘随机IO读请求,如下图:

通过中间服务将同属一个分区的数据进行归并,后续 reduce 操作就不需要从上游所有的 Map 节点拉取数据,在 shuffle 上下游 Task 数量多的情况下会对磁盘 IO 压力指数放大,生产上 shuffle heavy 的任务表现很不稳定,经常出现FetchFailed Exception。B站在推动 RSS 落地时选择了社区3.2 Push based shuffle 的方案,这个方案主要的优点是对 AQE 支持比较好,缺点是因为本地也要写一份数据放大了写。将数据先写本地后异步的发送到 driver 维护的 executor 节点的 external shuffle 节点上,后续生产实践中该方案有个问题,就是当作业启动时通常 driver 维护的 executor 数不足以满足远程节点的选择,而 SQL 作业参与计算的数据量通常是随着过滤条件层层递减的,通常 shuffle 数据量大的时候因为没有足够的节点会 fall back 到原先的 shuffle 方式,为了解决这个问题,我们新增了 shuffle  service master 节点,具体调用流程如下图,所有的 external shuffle 节点启动时都会注册到 shuffle master 节点上,后续节点本身也会周期性的上报心跳和节点繁忙程度,DAGScheduler 后续请求远程节点都从 shuffle master 申请,这样不仅解决了冷启动节点不足的问题,在节点选择上也考虑了节点的健康程度。因为是先落盘后发送,在 stage 执行结束后会有一个等待时间,这里面会有个性能回退的问题,对小任务不友好,所以在生产应用中我们基于任务画像系统 HBO 自动决定任务是否启用RSS服务,目前生产大约7%的大任务在使用RSS 服务,这些任务平均执行时间缩短了25%,稳定性有了显著提升。

目前B站生产中使用该方案基本解决了 shuffle 稳定性的问题,不过这套方案依旧需要计算节点配置本地 shuffle 盘,在本地落 shuffle 数据,无法支持存算分离的架构。后续我们在 k8s 上会大规模上线混部集群,需要尽量不依赖本地磁盘,避免对在线应用的影响,我们也关注到腾讯和阿里相继开源各自的 RSS 方案,我们也在尝试在生产中使用纯远程 shuffle 方案来满足 Spark on K8s 的技术需要。

3.1.3 大结果集溢写到磁盘

在adhoc 场景中用户通常会拉取大量结果到 driver 中,造成了大量的内存消耗,driver 稳定性又直接影响着用户即席查询的体验,为此专门优化了 executor fetch result 的过程,在获取结果时会监测 driver 内存使用情况,在高内存使用下将拉取到的结果直接写出到文件中,返回给用户时则直接分批从文件中获取,增加 driver 的稳定性。

3.1.4 单 SQL task 并行度、task 数、执行时间限制

生产上我们按队列隔离了用户的 adhoc 查询,在实践过程中经常性的遇到单个大作业直接占用了全部并行度,有些短作业直接因为获取不到资源导致长时间的 pending 的情况,为了解决这种问题首先对单个 SQL 执行时间和总 task 数进行了限制,此外考虑到在 task 调度时有资源就会全部调度出去,后续 SQL 过来就面临着完全无资源可用的情况,我们修改了调度方法对单个 SQL 参与调度的 task 数进行了限制,具体的限制数随着可用资源进行一个动态变化,在 current executor 数接近于 max executor 的情况下进行严格限制 ,在 current executor 数明显少于 max executor 的情况下,提高单 SQL 并行的 task 总数限制。

3.1.5 危险 join condition 发现& join 膨胀率检测

  • 危险 join condition 发现

在选择 join 方式的时候如果是等值 join 则按照 BHJ,SHJ,SMJ 的顺序选择,如果还没有选择出则判断 Cartesian Join,如果 join 类型是 InnerType 的就使用 Cartesian Join,Cartesian Join 会产生笛卡尔积比较慢,如果不是 InnerType,则使用 BNLJ,在判断 BHJ 时,表的大小就超过了 broadcast 阈值,因此将表 broadcast 出去可能会对 driver 内存造成压力,性能比较差甚至可能会 OOM,因此将这两种 join 类型定义为危险 join。

如果不是等值 join 则只能使用 BNLJ 或者 Cartesian Join,如果在第一次 BNLJ 时选不出 build side 说明两个表的大小都超过了 broadcast 阈值,则使用 Cartesian Join,如果 Join Type 不是 InnerType 则只能使用 BNLJ,因此 Join 策略选择Cartesian Join 和第二次选择 BNLJ 时为危险 join。

  • join 膨胀率检测

ShareState 中的 statusScheduler 用于收集 Execution 的状态和指标,这其中的指标就是按照 nodes 汇总了各个 task 汇报上来的 metrics,我们启动了一个 join 检测的线程定时的监控 Join 节点的 “number of output rows”及 Join 的2个父节点的 “number of output rows” 算出该 Join 节点的膨胀率。

  • 倾斜 Key 发现

数据倾斜是 ETL 任务比较常见的问题,以 shuffle 过程中的倾斜为例,通常有以下几个解决方法:增大 shuffle 的分区数量从而使数据分散到更多的分区中;修改逻辑,将 shuffle 时的 key 尽可能打散;单独找出产生了极大倾斜的 key,在逻辑中单独处理等等。但在进行这些处理之前,我们都需要先知道倾斜发生在 SQL 逻辑的哪个部分以及发生倾斜的是哪些 key。为了帮助用户自助高效的解决数据倾斜问题,我们实现了倾斜 key 发现的功能。以 SortMergeJoin 为例,在 shuffle fetch 阶段,首先根据 mapStatuses 计算出每个 partition size,并根据一定策略判断该 task 所处理的 partition 是否倾斜。如果倾斜,则在 join 阶段对数据进行采样,找到发生倾斜的 key,通过 TaskMetric 发送到 driver 端,driver 端消费 metric后会记录倾斜信息。

上面这些 bad case 在运行时发现后会自动将信息发送到我们内部作业诊断平台,用户可以查看并对语句做优化和改进。

3.2 性能优化

3.2.1 DPP 和 AQE 兼容

spark3.1 的 DPP 和 AQE 存在兼容问题,在使用 AQE 后 DPP 的策略就无法生效,这个问题在3.2得到了修复,我们将3.2的相关代码 backport 回来,从 TPCDS 测试上看对3.1有很明显的提升。

3.2.2 AQE 支持 ShuffledHashJoin

AQE 通过对 map 阶段收集的指标数据来优化 Join 方式,对于存在小表的情况能将 SMJ 优化为 BHJ,这个操作可以显著的优化性能。Spark的 shuffle 策略还有一个就是 ShuffledHashJoin,该策略性能相对较好,但内存压力大,在默认情况下为了保证任务的稳定性我们将其关闭,基于 AQE 的思想,在 map 完成后收集 partition size,当最大的 partition size 小于定义的值后,通过新增 DynamicJoin 优化策略将 SMJ 优化为 SHJ。

3.2.3 Runtime filter

DPP 通过对大表直接进行 partition 级别的裁剪,可以大大提高查询速度,但 DPP 的适用条件也相对严格,需要大表的分区列参与 join,但如果大表参与 join 的列为非分区列则无法应用。我们知道 shuffle 是比较耗时的操作,shuffle 的数据量越大,耗时越久,而且对网络,机器 IO 都会产生比较大的压力。如果能在大表 shuffle 前根据非分区列的 join 列对其进行过滤,即使无法像 DPP 一样直接减少从存储中读取的数据量,但减小了其参与 shuffle 以及后续操作的数据量,也能获得比较不错的收益,这就是 runtime filter 的动机,即运行时预先扫描小表获取 join 列的值,构造 bloom filter 对大表进行过滤。具体实现思路和 DPP 基本一致,首先在 SparkOptimizer 新增 DynamicBloomFilterPruning 规则,逻辑上类似PartitionPruning,符合一系列判断条件后插入一个节点 DynamicBloomFilterPruningSubquery。与 DPP 不同的是,如果 join 可以被转化为 BroadcastHashJoin,则不会应用该规则,因为在 BroadcastHashJoin 的情况下对大表进行预先的过滤其实是多余的(非 pushdown 的情况下)。判断是否加入 filter 节点的主要逻辑如下,这里以裁剪左表(左右两侧都为 logicalPlan,为了方便表达,用左右表指代)为例进行说明,需要满足以下条件:

  • 右表 rowCount 需要小于左表
  • Join 类型支持裁剪左表
  • 右表 rowCount > 0
  • 右表 rowCount 小于 spark.sql.optimizer.dynamicBloomFilterJoinPruning.maxBloomFilterEntries,默认值为100000000,避免 bloom filter 占用内存过大
  • 右表中没有DynamicBloomFilterPruningSubquery
  • 右表不是 stream 且存在 SelectivePredicate
  • 左表(这里的左表是真正的左表或者包含左表的Filter节点)没有 SelectivePredicate,因为如果存在 SelectivePredicate,那么下一步便无法根据统计信息去计算过滤收益

在 prepare 阶段,PlanAdaptiveSubqueries 会把 DynamicBloomFilterPruningSubquery 节点替换为 DynamicPruningExpression(InBloomFilterSubqueryExec(_, _, _)),扩展了PlanAdaptiveDynamicPruningFilters,支持对以上节点进行处理。新增了 BuildBloomFilter 和 InBloomFilter 两个 UDF。BuildBloomFilter 在 sparkPlan prepare 阶段提交任务构造 BloomFilter 并 broadcast 出去,具体的 evaluate 逻辑还是交给 InBloomFilter。另外在 AQE 的reOptimize 阶段也新增了规则 OptimizeBloomFilterJoin,这个规则主要是用来根据执行过程的 metric 信息更新BuildBloomFilter的expectedNumItems。

可以看到在开启了runtime filter后数据量在join前从120亿条降至3W条,收益还是相当明显的。

3.2.4 Data skipping

目前B站离线表存储主要使用 orc、parquet 格式,列式存储都支持一定程度的 data skipping,比如 orc 有三个级别的统计信息,file/stripe/row group,统计信息中会包含count,对于原始类型的列,还会记录 min/max 值,对于数值类型的列,也会记录 sum 值。在查询时,就可以根据不同粒度的统计信息以及 index 决定该 file/stripe/row 是否符合条件,不符合条件的直接跳过。对于统计信息及索引的细节见orc format  (https://orc.apache.org/specification/ORCv1/)  和 orc index (https://orc.apache.org/docs/indexes.html)  。Parquet 与 orc 类似,也有相应的设计,具体见parquet format (https://github.com/apache/parquet-format)  和 parquet pageIndex (https://github.com/apache/parquet-format/blob/master/PageIndex.md)  。虽然 orc/parquet 都有 data skipping 的能力,但这种能力非常依赖数据的分布。前面提到统计信息中会包含每一列的 min/max 值,理论上如果查询条件(比如> < =)不在这个范围内,那么这个file/stripe/row group 就可以被跳过。但如果数据没有按照 filter 列排序,那最坏的情况下,可能每个 file/stripe/row group的min/max 值都一样,这样就造成任何粒度的数据都不能被跳过。为了增加列式存储 data skipping 效果,可以通过对数据增加额外的组织,如下:

 select     count(1)   from     tpcds.archive_spl_cluster   where     log_date = '20211124'     and state = -16

表 archive_spl,不调整任何分布与排序

表 archive_spl_order,order by state,avid

通过对 state 进行 order 后 scan 阶段数据量直接从亿级别降至数十万级别。在生产中我们通过对 SQL 进行血缘分析找到那些热点表及高频 filter 列,将这些热列作为 table properties 存入 hms 中,在 Spark 执行时根据从 hms 中获取的列信息,通过相应的优化规则,物理计划自动增加 sort 算子,完成对数据组织。这个方案是基于列存优化数据组织来进行 data skipping,目前我们也在往索引方向上进一步探索。

3.3 功能性改进

3.3.1 对于ZSTD的支持

Spark 社区在3.2版本全面支持了 ZSTD 压缩,为了更好的使用 ZSTD,我们在 Spark3.1  的基础上引入了社区的相关 patch。其中也遇到了一些问题。在测试 ZSTD 的过程中偶然发现下推到 ORC 的过滤条件没有生效,经调查发现是 ORC 代码的 bug,在和社区讨论之后,我们修复了该 bug并将 patch提交给了社区:https://issues.apache.org/jira/browse/ORC-1121 。

离线平台的 Presto 也承接了很多 ETL 任务,由于 Presto 使用的是自己实现的 ORC reader/writer,所以在 Spark 升级 ORC 版本之后,对一些 Presto 写出的表,出现了查询结果错误的问题。正常情况下,Apache ORC writer 在写文件时会记录每个 stripe/rowGroup 中每列的统计信息,如 min/max 等。Apache ORC reader 在读取文件时会根据这些统计信息结合下推的过滤条件进行 stripe/rowGroup 级别的过滤。但 Presto ORC writer 在写文件时,如果 String 类型的列长度超过64 bytes,则这一列不会记录 min/max 信息。虽然 Presto ORC reader 可以正常处理这类文件,但 Spark/Hive 使用的 Apache ORC reader 因为无法正常的反序列化 columnStatistics 得到正确的统计信息,导致做 stripe/rowGroup 级别的过滤时出现了错误的结果。我们也发现这个问题是由于 ORC 1.6 版本的一次代码重构导致,1.5及之前是没有该问题的。我们已在内部分支修复了该问题,也已将问题反馈给社区。

3.3.2 多格式混合读兼容

历史上很多表使用了 text 存储,在资源上造成了很大的浪费,通过修改表的元信息可以保障新增分区切换到列存,这就造成了一个离线表可能存在多种 fileformat 的情况,为了兼容我们修改了 DataSourceScanExec 相关的逻辑,将reader 的实例化从基于table元信息粒度细化到分区元信息粒度。

3.3.3 转表&小文件合并语法

为了方便用户修改表的存储格式和文件压缩格式我们在引擎层提供了相关语法及具体实现。用户可以通过指定分区条件对特定分区进行转换。

CONVERT TABLE target=tableIdentifier        (convertFormat | compressType)  partitionClause?               #convertTableMERGE TABLE target=tableIdentifier        partitionClause?                                               #mergeTable

3.3.4 字段血缘

作业间的依赖关系分析、数据地图等业务都需要SQL血缘的支持,团队后续工作(z-order , analyze , index)也需要依赖血缘,我们通过注册一个 LineageQueryListener 继承 QueryExecutionListener,在 onSuccess 方法拿到当前执行的QueryExecution,通过 analyzedLogicalPlan,利用 NamedExpression 的 exprId 映射关系,对其进行遍历和解析,构建出字段级血缘(PROJECTION/PREDICATE)和 levelRelation(层级关系)。

3.4 基于历史执行的自动参数优化(HBO)

Spark 提供了大量的参数设置,对于用户而言了解这些参数并使用好需要花费很大的代价,在很多情况下不同的参数调优对于 spark 的作业执行和资源消耗会有很大差异。为了尽可能的适配任务执行,我们预设了一组参数,这种统一配置存在很多问题,以内存而言为了适配尽可能多的任务,该值设置偏大,通过对执行的分析发现大量的任务存在资源浪费的问题,整体的内存利用率仅20%左右。要求每个用户成为专家对作业进行细致的调优显然不可能,因此我们设计了 HBO 系统,具体的思路如下图:

首先对任务执行的 SQL 进行了指纹计算,通过指纹来标识该任务每天执行情况,将每次执行中采集到的 metrics 收集后用策略进行分析给出相应的参数优化建议,在下次执行的时候根据指纹来获取推荐的执行参数,对于使用默认参数的任务则进行覆盖,对于那些用户指定的参数则优先使用用户参数。

  • 内存优化策略:通过收集每个 executor 的峰值内存,如果峰值内存占配置内存比值低于30%,就推荐使用更少的内存来执行此次的计算,对于峰值内存占比过高的任务,则调大内存配置。通过这个策略生产上的内存使用率提升至50%左右。
  • 并行度优化策略:生产上开启了动态资源配置,在对数据分析时发现有些节点从分配后就没有task执行过,完全浪费了节点的资源,对于这些任务会在下次执行的时候降低 spark.dynamicAllocation.executorAllocationRatio 值来降低执行并行度,此外默认提供的 spark.sql.shuffle.partitions 值对于大任务来说执行并行度不够,后续也将进行自动的调整。
  • 优化shuffle策略:如上文所讲 RSS 对小任务存在性能下降的问题,通过对 block size、shuffle 数据量的分析,HBO 系统只会对那些 shuffle heavy 任务开启使用 RSS 服务。
  • 小文件合并策略:小文件合并会消耗额外的资源,对于不存在小文件情况的作业 HBO 系统会关闭小文件合并相关的配置。

此外平时工作中一些 feature 的上线也会依赖该系统进行一个灰度过程。

3.5 Smart Data Manager (SDM) 

Smart Data Manager(SDM)是我们自研的一个对数据进行组织和分析的服务,通过对数据的额外处理将我们对 Spark 的一些技改真正落地。它的整体架构如图,目前提供了如下的几个数据组织和分析能力:

  • 表存储和压缩方式的转换:将表从 Text 存储转换为 ORC 或 Parquet 存储,将压缩类型从 None 或 Snappy 转换为 ZSTD 可以带来不错的存储和性能收益,SDM 提供了按分区对表异步进行转换的能力。
  • 数据重组织:在分区内部按列对数据进行 order/zorder 组织可以有效的提高 data skipping 的效果,新增分区通过查询 table properties 中的排序列 meta 来改写执行计划应用,存量分区就可以通过 SDM 重刷。
  • Statistics 的统计:开启 CBO 时需要依赖对表统计信息的收集,在对 hive 表的列进行索引时也依赖收集到的列基数和操作信息选择合适的索引类型,通过 sdm 监听 hms 的 partition 事件就可以在分区更新时异步完成信息采样。
  • 小文件合并:对有小文件较多的分区和表异步进行小文件合并,减少 namenode 的压力
  • Hive 表索引:通过分析血缘信息得到热表热列上的高频操作(点查,范围查询),基于此在分区文件层面异步的建立索引来加速查询。
  • 血缘解析:解析语句,分析字段血缘,吐出 UDF 血缘、算子(order by / sort by / group by…)影响关系等

对数据进行重组织时会涉及到对数据的读写,为了防止对生产作业的影响我们在进行操作时会修改相关表的 Table Properties 增加锁表标记,各个计算引擎适配实现了类 Hive 的锁管理机制,由 Hive metastore 统一作为 lock manager,在对表和分区并发操作场景下,做到对用户完全透明。

4. Hive Meta Store 上的优化

B站使用 HMS(Hive MetaStore)管理所有的离线表元信息,整个的离线计算的可用性都依赖 HMS 的稳定性。业务方在使用分区表时存在不少4级及以上分区的情况,有多个表分区数超百万。分区元信息庞大单次分区获取代价高,原生 HMS 基于单个 MySQL 实例存在性能瓶颈。

4.1 MetaStore Federation

随着多机房业务的推进,独立业务的 HDFS 数据和计算资源已经迁移到新机房,但是 HIVE 元数据仍在原有机房的 Mysql 中,这时候如果发生机房间的网络分区,就会影响新机房的任务。

为了解决上述问题,我们进行了方案调研,有两种方案供我们选择:

  • WaggleDance
  • HMS Federation

4.1.1 WaggleDance

WaggleDance是开源的一个项目(https://github.com/ExpediaGroup/waggle-dance),该项目主要是联合多个 HMS 的数据查询服务,实现了一个统一的路由接口解决多套 HMS 环境间的元数据共享问题。并且 WaggleDance 支持 HMS Client的接口调用。主要是通过 DB,把请求路由到对应的 HMS。

4.1.2 HMS Federation

HMS Federation 是解决多机房场景下的 HIVE 元数据存储问题,HIVE 元数据和 HDFS 数据存储在同一个机房,并且允许跨机房访问 HIVE 元数据。比如主站业务的 HDFS 数据存放在 IDC1,那么主站业务 HDFS 数据对应的 HIVE 元数据就存在IDC1 的 Mysql,同样直播业务的 HDFS 数据和 HIVE 元数据都存放在 IDC2。

同时 HMS Federation 也提供了 Mysql 的横向扩容能力,允许一个机房可以有多个 Mysql 来存放 HIVE 元数据,如果单个 Mysql 的压力过大,可以把单个 Mysql 的数据存放到多个 Mysql 里面,分担 Mysql 的压力。比如主站业务的 HIVE 库,zhu_zhan 和 zhu_zhan_tmp,可以分别放在 idc1-mysql1 和 idc1-mysql2。

我们在 HMS Federation 中加入了一个 StateStore 的角色,该角色可以理解为一个路由器,HMS 在查询 Hive 库/表/分区之前,先问 StateStore 所要访问的 HIVE 元信息存放在哪一个 Mysql 中,获取到了对应的 Mysql 后,构建相应的ObjectStore,进行 SQL 拼接或者是利用 JDO 查询后端 Mysql。

4.1.3 HMS Federation 与 WaggleDance 的对比

数据迁移

我们的主要目的是实现 HIVE 元数据按业务划分到各自 IDC 的 Mysql

  • WaggleDance 并没有提供相应元数据迁移工具,要迁移需要停止整个 HIVE 库新建表/分区,才能够开始迁移过去,对业务影响较大。
  • HMS Federation 可以按表的粒度迁移,对业务影响较小,并且可以指定某个 HIVE 库下,新建表在新的 Mysql,旧的等待着锁表迁移。

运维复杂度

  • WaggleDance 方案需要不同的 HMS,配置不同的 Mysql 地址,增加了 HMS 配置的复杂度。WaggleDance 是一个独立的服务,为了保证可用性,运维复杂度会再一次提升。
  • HMS Fedration 是 HMS 的功能升级,在 HMS 代码上开发,并且使用统一的配置。

综合上述对比,我们最终选择了 HMS Federation 的方案。通过修改 HMS 的代码,实现元数据跨 Mysql 存储。

4.2 MetaStore 请求追踪和流量控制

HMS 在处理 getPartitions 相关请求的时候,如果拉取的分区数量非常多,会给 HMS 的堆内存,以及后端的 Mysql 带来很大的压力,导致 HMS 服务响应延迟。

为了能够快速的定位到有问题的任务,我们在 Driver 中将 Job 相关的信息保存到 Hadoop CallerContext 中,在调用 HMS 接口的时候将 CallerContext 中的相关属性设置到 EnvironmentContext 中透传到 HMS 端,同时扩展了所有getPartitions 相关的接口支持传递 EnvironmentContext,EnvironmentContext 中的 properties 会在 HMS 的 audit log 中打印出来,方便问题任务的定位。

同时为了提高 HMS 服务的稳定性,我们在 HMS 端也做了接口的限流和主动关闭大查询。对于限流,我们新增了一个 TrafficControlListener,当接口被调用的时候会以 function 和 user 为单位记录 Counters 保存在该 Listener 中,同时在该Listener 中启动采集 used memory 和 counters 的线程,当平均使用内存达到阈值时,检查接口的QPS,如果qps达到阈值会让调用接口的线程 sleep 一段时间,下一次检查通过或者达到最大等待时间后放行。HMS 也有可能因为 getPartitions 方法返回的分区数量太大导致内存被打满,一方面我们限制了 getPartitions 从 mysql 返回的分区数量,超过一定数量就直接拒绝该请求,另一方面我们在 TProcessor 中以 threadId 和 socket 为 key 和 value 保存当前的连接,在检查 partition 数量时我们也按照 threadId 和 num partitions 为 key 和 value 保存 partition 的 cost,当 HMS 平均使用内存达到阈值超过一定时间后,会选择 num partitions 最大的 threadId,再根据 threadId 获取对应的连接,主动 close 该连接,来缓解内存压力。

5. 未来的一些工作

  • 调研不落地的 Remote Shuffle Service 来更好的适配 K8S 混部的场景
  • 使用向量化技术加速 Spark 的执行引擎,提升计算性能
  • 增强自动排错诊断系统,提升平台用户体验

我们会和业界同行和开源社区保持密切技术交流,在服务好内部用户作业的同时,也会积极反馈社区,共建社区生态。

from:https://mp.weixin.qq.com/s/2rYkFV5xVxJpIP4Qg4r7eg

美团外卖iOS App冷启动治理(转载)

一、背景

冷启动时长是App性能的重要指标,作为用户体验的第一道“门”,直接决定着用户对App的第一印象。美团外卖iOS客户端从2013年11月开始,历经几十个版本的迭代开发,产品形态不断完善,业务功能日趋复杂;同时外卖App也已经由原来的独立业务App演进成为一个平台App,陆续接入了闪购、跑腿等其他新业务。因此,更多更复杂的工作需要在App冷启动的时候被完成,这给App的冷启动性能带来了挑战。对此,我们团队基于业务形态的变化和外卖App的特点,对冷启动进行了持续且有针对性的优化工作,目的就是为了呈现更加流畅的用户体验。

二、冷启动定义

一般而言,大家把iOS冷启动的过程定义为:从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止。这个过程主要分为两个阶段:

  • T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数。
  • T2:main()函数之后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

然而,当didFinishLaunchingWithOptions执行完成时,用户还没有看到App的主界面,也不能开始使用App。例如在外卖App中,App还需要做一些初始化工作,然后经历定位、首页请求、首页渲染等过程后,用户才能真正看到数据内容并开始使用,我们认为这个时候冷启动才算完成。我们把这个过程定义为T3。

综上,外卖App把冷启动过程定义为:从用户点击App图标开始到用户能看到App主界面内容为止这个过程,即T1+T2+T3。在App冷启动过程当中,这三个阶段中的每个阶段都存在很多可以被优化的点。

三、问题现状

性能存量问题

美团外卖iOS客户端经过几十个版本的迭代开发后,在冷启动过程中已经积累了若干性能问题,解决这些性能瓶颈是冷启动优化工作的首要目标,这些问题主要包括:

注:启动项的定义,在App启动过程中需要被完成的某项工作,我们称之为一个启动项。例如某个SDK的初始化、某个功能的预加载等。

性能增量问题

一般情况下,在App早期阶段,冷启动不会有明显的性能问题。冷启动性能问题也不是在某个版本突然出现的,而是随着版本迭代,App功能越来越复杂,启动任务越来越多,冷启动时间也一点点延长。最后当我们注意到,并想要优化它的时候,这个问题已经变得很棘手了。外卖App的性能问题增量主要来自启动项的增加,随着版本迭代,启动项任务简单粗暴地堆积在启动流程中。如果每个版本冷启动时间增加0.1s,那么几个版本下来,冷启动时长就会明显增加很多。

四、治理思路

冷启动性能问题的治理目标主要有三个:

  1. 解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
  2. 管控增量问题:冷启动流程规范化,通过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
  3. 完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。

五、规范启动流程

截止至2017年底,美团外卖用户数已达2.5亿,而美团外卖App也已完成了从支撑单一业务的App到支持多业务的平台型App的演进(美团外卖iOS多端复用的推动、支撑与思考),公司的一些新兴业务也陆续集成到外卖App当中。下面是外卖App的架构图,外卖的架构主要分为三层,底层是基础组件层,中层是外卖平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口,上层是基础组件层,包括外卖业务拆分的子业务组件(外卖App和美团App中的外卖频道可以复用子业务组件)和接入的其他非外卖业务。

App的平台化为业务方提供了高效、标准的统一平台,但与此同时,平台化和业务的快速迭代也给冷启动带来了问题:

  1. 现有的启动项堆积严重,拖慢启动速度。
  2. 新的启动项缺乏添加范式,杂乱无章,修改风险大,难以阅读和维护。

面对这个问题,我们首先梳理了目前启动流程中所有的启动项,然后针对App平台化设计了新的启动项管理方式:分阶段启动和启动项自注册

分阶段启动

早期由于业务比较简单,所有启动项都是不加以区分,简单地堆积到didFinishLaunchingWithOptions方法中,但随着业务的增加,越来越多的启动项代码堆积在一起,性能较差,代码臃肿而混乱。

通过对SDK的梳理和分析,我们发现启动项也需要根据所完成的任务被分类,有些启动项是需要刚启动就执行的操作,如Crash监控、统计上报等,否则会导致信息收集的缺失;有些启动项需要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则可以被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。我们所做的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,然后依据每个启动项所做的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

下面是我们对美团外卖App启动阶段进行的重新定义,对所有启动项进行的梳理和重新分类,把它们对应到合理的启动阶段。这样做一方面可以推迟执行那些不必过早执行的启动项,缩短启动时间;另一方面,把启动项进行归类,方便后续的阅读和维护。然后把这些规则落地为启动项的维护文档,指导后续启动项的新增和维护。

通过上面的工作,我们梳理出了十几个可以推迟执行的启动项,占所有启动项的30%左右,有效地优化了启动项所占的这部分冷启动时间。

启动项自注册

确定了启动项分阶段启动的方案后,我们面对的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时创建一个启动管理器,然后读取所有启动项,然后当时间节点到来时由启动器触发启动项执行。这种方式存在两个问题:

  1. 所有启动项都要预先写到一个文件中(在.m文件import,或用.plist文件组织),这种中心化的写法会导致臃肿的代码,难以阅读维护。
  2. 启动项代码无法复用:启动项无法收敛到子业务库内部,在外卖App和美团App中要重复实现,和外卖App平台化的方向不符。

而我们希望的方式是,启动项维护方式可插拔,启动项之间、业务模块之间不耦合,且一次实现可在两端复用。下图是我们采用的启动项管理方式,我们称之为启动项的自注册:一个启动项定义在子业务模块内部,被封装成一个方法,并且自声明启动阶段(例如一个启动项A,在独立App中可以声明为在willFinishLaunch阶段被执行,在美团App中则声明在resignActive阶段被执行)。这种方式下,启动项即实现了两端复用,不相关的启动项互相隔离,添加/删除启动项都更加方便。

那么如何给一个启动项声明启动阶段?又如何在正确的时机触发启动项的执行呢?在代码上,一个启动项最终都会对应到一个函数的执行,所以在运行时只要能获取到函数的指针,就可以触发启动项。美团平台开发的组件启动治理基建Kylin正是这样做的:Kylin的核心思想就是在编译时把数据(如函数指针)写入到可执行文件的__DATA段中,运行时再从__DATA段取出数据进行相应的操作(调用函数)。

为什么要用借用__DATA段呢?原因就是为了能够覆盖所有的启动阶段,例如main()之前的阶段。

Kylin实现原理简述:Clang 提供了很多的编译器函数,它们可以完成不同的功能。其中一种就是 section() 函数,section()函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段。 在具体的实现中,主要分为编译期和运行时两个部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key代表不同的启动阶段), *pointer}对到数据段。到运行时,在合适的时间节点,在根据key读取出函数指针,完成函数的调用。

上述方式,可以封装成一个宏,来达到代码的简化,以调用宏 KLN_STRINGS_EXPORT(“Key”, “Value”)为例,最终会被展开为:

__attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"};

使用示例,编译器把启动项函数注册到启动阶段A:

KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m文件中,通过注册宏,把启动项A声明为在STAGE_KEY_A阶段执行
    // 启动项代码A
}
KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m文件中,把启动项B声明为在STAGE_KEY_A阶段执行
    // 启动项代码B
}

在启动流程中,在启动阶段STAGE_KEY_A触发所有注册到STAGE_KEY_A时间节点的启动项,通过对这种方式,几乎没有任何额外的辅助代码,我们用一种很简洁的方式完成了启动项的自注册。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 其他逻辑
    [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A];  // 在此触发所有注册到STAGE_KEY_A时间节点的启动项
    // 其他逻辑
    return YES;
}

完成对现有的启动项的梳理和优化后,我们也输出了后续启动项的添加&维护规范,规范后续启动项的分类原则,优先级和启动阶段。目的是管控性能问题增量,保证优化成果。

六、优化main()之前

在调用main()函数之前,基本所有的工作都是由操作系统完成的,开发者能够插手的地方不多,所以如果想要优化这段时间,就必须先了解一下,操作系统在main()之前做了什么。main()之前操作系统所做的工作就是把可执行文件(Mach-O格式)加载到内存空间,然后加载动态链接库dyld,再执行一系列动态链接操作和初始化操作的过程(加载、绑定、及初始化方法)。这方面的资料网上比较多,但重复性较高,此处附上一篇WWDC的Topic:Optimizing App Startup Time 。

加载过程—从exec()到main()

真正的加载过程从exec()函数开始,exec()是一个系统调用。操作系统首先为进程分配一段内存空间,然后执行如下操作:

  1. 把App对应的可执行文件加载到内存。
  2. 把Dyld加载到内存。
  3. Dyld进行动态链接。

下面我们简要分析一下Dyld在各阶段所做的事情:

阶段工作
加载动态库Dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合
Rebase和Bind– Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正
– Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现
Objc setup– 注册Objc类 (class registration)
– 把category的定义插入方法列表 (category registration)
– 保证每一个selector唯一 (selector uniquing)
Initializers– Objc的+load()函数
– C++的构造函数属性函数
– 非基本类型的C++静态全局变量的创建(通常是类或结构体)

最后 dyld 会调用 main() 函数,main() 会调用 UIApplicationMain(),before main()的过程也就此完成。

了解完main()之前的加载过程后,我们可以分析出一些影响T1时间的因素:

  1. 动态库加载越多,启动越慢。
  2. ObjC类,方法越多,启动越慢。
  3. ObjC的+load越多,启动越慢。
  4. C的constructor函数越多,启动越慢。
  5. C++静态对象越多,启动越慢。

针对以上几点,我们做了如下一些优化工作:

代码瘦身

随着业务的迭代,不断有新的代码加入,同时也会废弃掉无用的代码和资源文件,但是工程中经常有无用的代码和文件被遗弃在角落里,没有及时被清理掉。这些无用的部分一方面增大了App的包体积,另一方便也拖慢了App的冷启动速度,所以及时清理掉这些无用的代码和资源十分有必要。

通过对Mach-O文件的了解,可以知道__TEXT:__objcmethname:中包含了代码中的所有方法,而\_DATA__objc_selrefs中则包含了所有被使用的方法的引用,通过取两个集合的差集就可以得到所有未被使用的代码。核心方法如下,具体可以参考:objc_cover:

def referenced_selectors(path):
    re_sel = re.compile("__TEXT:__objc_methname:(.+)") //获取所有方法
    refs = set()
    lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() # ios & mac //真正被使用的方法
    for line in lines:
        results = re_sel.findall(line)
        if results:
            refs.add(results[0])
    return refs
}

通过这种方法,我们排查了十几个无用类和250+无用的方法。

+load优化

目前iOS App中或多或少的都会写一些+load方法,用于在App启动执行一些操作,+load方法在Initializers阶段被执行,但过多+load方法则会拖慢启动速度,对于大中型的App更是如此。通过对App中+load的方法分析,发现很多代码虽然需要在App启动时较早的时机进行初始化,但并不需要在+load这样非常靠前的位置,完全是可以延迟到App冷启动后的某个时间节点,例如一些路由操作。其实+load也可以被当做一种启动项来处理,所以在替换+load方法的具体实现上,我们仍然采用了上面的Kylin方式。

使用示例:

// 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING声明替换+load声明即可,不需其他改动
WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() { 
    // 原+load方法中的代码
}
// 在某个合适的时机触发注册到该阶段的所有方法,如冷启动结束后
[[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY] 
}

七、优化耗时操作

在main()之后主要工作是各种启动项的执行(上面已经叙述),主界面的构建,例如TabBarVC,HomeVC等等。资源的加载,如图片I/O、图片解码、archive文档等。这些操作中可能会隐含着一些耗时操作,靠单纯阅读非常难以发现,如何发现这些耗时点呢?找到合适的工具就会事半功倍。

Time Profiler

Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每一个线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获得一个近似值。Time Profiler的使用方法网上有很多使用教程,这里我们也不过多介绍,附上一篇使用文档:Instruments Tutorial with Swift: Getting Started

火焰图

除了Time Profiler,火焰图也是一个分析CPU耗时的利器,相比于Time Profiler,火焰图更加清晰。火焰图分析的产物是一张调用栈耗时图片,之所以称为火焰图,是因为整个图形看起来就像一团跳动的火焰,火焰尖部是调用栈的栈顶,底部是栈底,纵向表示调用栈的深度,横向表示消耗的时间。一个格子的宽度越大,越说明其可能是瓶颈。分析火焰图主要就是看那些比较宽大的火苗,特别留意那些类似“平顶山”的火苗。下面是美团平台开发的性能分析工具-Caesium的分析效果图:

通过对火焰图的分析,我们发现了冷启动过程中存在着不少问题,并成功优化了0.3S+的时间。优化内容总结如下:

优化点举例
发现隐晦的耗时操作发现在冷启动过程中archive了一张图片,非常耗时
推迟&减少I/O操作减少动画图片组的数量,替换大图资源等。因为相比于内存操作,硬盘I/O是非常耗时的操作
推迟执行的一些任务如一些资源的I/O,一些布局逻辑,对象的创建时机等

八、优化串行操作

在冷启动过程中,有很多操作是串行执行的,若干个任务串行执行,时间必然比较长。如果能变串行为并行,那么冷启动时间就能够大大缩短。

闪屏页的使用

现在许多App在启动时并不直接进入首页,而是会向用户展示一个持续一小段时间的闪屏页,如果使用恰当,这个闪屏页就能帮我们节省一些启动时间。因为当一个App比较复杂的时候,启动时首次构建App的UI就是一个比较耗时的过程,假定这个时间是0.2秒,如果我们是先构建首页UI,然后再在Window上加上这个闪屏页,那么冷启动时,App就会实实在在地卡住0.2秒,但是如果我们是先把闪屏页作为App的RootViewController,那么这个构建过程就会很快。因为闪屏页只有一个简单的ImageView,而这个ImageView则会向用户展示一小段时间,这时我们就可以利用这一段时间来构建首页UI了,一举两得。

缓存定位&首页预请求

美团外卖App冷启动过程中一个重要的串行流程就是:首页定位–>首页请求–>首页渲染过程,这三个操作占了整个首页加载时间的77%左右,所以想要缩短冷启动时间,就一定要从这三点出发进行优化。

之前串行操作流程如下:

优化后的设计,在发起定位的同时,使用客户端缓存定位,进行首页数据的预请求,使定位和请求并行进行。然后当用户真实定位成功后,判断真实定位是否命中缓存定位,如果命中,则刚才的预请求数据有效,这样可以节省大概40%的时间首页加载时间,效果非常明显;如果未命中,则弃用预请求数据,重新请求。

九、数据监控

Time Profiler和Caesium火焰图都只能在线下分析App在单台设备中的耗时操作,局限性比较大,无法在线上监控App在用户设备上的表现。外卖App使用公司内部自研的Metrics性能监控系统,长期监控App的性能指标,帮助我们掌握App在线上各种环境下的真实表现,并为技术优化项目提供可靠的数据支持。Metrics监控的核心指标之一,就是冷启动时间。

冷启动开始&结束时间节点

  1. 结束时间点:结束时间比较好确定,我们可以将首页某些视图元素的展示作为首页加载完成的标志。
  2. 开始时间点:一般情况下,我们都是在main()之后才开始接管App,但以main()函数作为冷启动起始点显然不合适,因为这样无法统计到T1时间段。那么,起始时间如何确定呢?目前业界常见的有两种方法,一是以可执行文件中任意一个类的+load方法的执行时间作为起始点;二是分析dylib的依赖关系,找到叶子节点的dylib,然后以其中某个类的+load方法的执行时间作为起始点。根据Dyld对dylib的加载顺序,后者的时机更早。但是这两种方法获取的起始点都只在Initializers阶段,而Initializers之前的时长都没有被计入。Metrics则另辟蹊径,以App的进程创建时间(即exec函数执行时间)作为冷启动的起始时间。因为系统允许我们通过sysctl函数获得进程的有关信息,其中就包括进程创建的时间戳。
#import <sys/sysctl.h>
#import <mach/mach.h>

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime
{
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"无法取得进程的信息");
        return 0;
    }
}

进程创建的时机非常早。经过实验,在一个新建的空白App中,进程创建时间比叶子节点dylib中的+load方法执行时间早12ms,比main函数的执行时间早13ms(实验设备:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外卖App线上的数据则更加明显,同样的机型(iPhone 7 Plus)和系统版本(iOS 12.0),进程创建时间比叶子节点dylib中的+load方法执行时间早688ms。而在全部机型和系统版本中,这一数据则是878ms。

冷启动过程时间节点

我们也在App冷启动过程中的所有关键节点打上一连串测速点,Metrics会记录下测速点的名称,及其距离进程创建时间的时长。我们没有采用自动打点的方式,是因为外卖App的冷启动过程十分复杂,而自动打点无法做到如此细致,并不实用。另外,Metrics记录的是时间轴上以进程创建时间为原点的一组顺序的时间点,而不是一组时间段,是因为顺序的时间点可以计算任意两个时间点之间的距离,即可以将时间点处理成时间段。但是,一组时间段可能无法还原为顺序的时间点,因为时间段之间可能并不是首尾相接的,特别是对于异步执行或者多线程的情况。

在测速完毕后,Metrics会统一将所有测速点上报到后台。下图是美团外卖App 6.10版本的部分过程节点监控数据截图:

Metrics还会由后台对数据做聚合计算,得到冷启动总时长和各个测速点时长的50分位数、90分位数和95分位数的统计数据,这样我们就能从宏观上对冷启动时长分布情况有所了解。下图中横轴为时长,纵轴为上报的样本数。

十、总结

对于快速迭代的App,随着业务复杂度的增加,冷启动时长会不可避免的增加。冷启动流程也是一个比较复杂的过程,当遇到冷启动性能瓶颈时,我们可以根据App自身的特点,配合工具的使用,从多方面、多角度进行优化。同时,优化冷启动存量问题只是冷启动治理的第一步,因为冷启动性能问题并不是一日造成的,也不能简单的通过一次优化工作就能解决,我们需要通过合理的设计、规范的约束,来有效地管控性能问题的增量,并通过持续的线上监控来及时发现并修正性能问题,这样才能够长期保证良好的App冷启动体验。

作者简介

郭赛,美团点评资深工程师。2015年加入美团,目前作为外卖iOS团队主力开发,负责移动端业务开发,业务类基础设施的建设与维护。

徐宏,美团点评资深工程师。2016年加入美团,目前作为外卖iOS团队主力开发,负责移动端APM性能监控,高可用基础设施支撑相关推进工作。

来自:https://tech.meituan.com/waimai_ios_optimizing_startup.html