一、背景
随着旅游市场的回暖、出行需求的激增,去哪儿网酒店的单日预订量也刷新了历年的前高还在不断突破产生新高。
与此同时,酒店数仓每天处理的数据也在不断上涨,为了保障日常 SA 级的报表正常产出,需要我们持续优化数据处理的链路,消除存在的瓶颈与卡点。
酒店流量链路产出的核心宽表为:搜索( search S页 )、列表( list L页 )、详情( Detail D 页)、预订( booking B 页)和提交订单( order O 页)流量表,对应了酒店主流程各个页面的用户流量数据。
我们以一个具体的案例 " L 页流量表" 优化作为切入点,来展开对流量链路的优化实践,承诺 SLA、体量够大、关联够多、逻辑够复杂、使用够广一直是 L 页流量表的内在标签。
二、问题
酒店列表页流量表(简称L页流量表)为主流程最常用表,是进入酒店主流程业务的第一入口。
同时又承接了用户窄口径转化、搜索排序、分销提流等众多 SA 级别报表,需要确保 L 页流量表产出的及时性来保障我们 SA 级报表的及时产出。
在各页流量表中L页是任务链路最长、产出时间最长的表,随着流量的不断上涨产出时间一再告破 SLA 基线。
能够为S级报表( S 级报表的 SLA 基线为 9 点)预留的执行 buffer 时间越来越少,尤其进入 4 月后对应 S 报表的产出时间出现了明显的延迟。
三、现状分析
(酒店列表页优化前执行流程图)
1、图解说明
图中紫色背景为产出L页流量表的两大任务:L 页中间表和 L 页流量表,其中L 页流量表背景包含了依赖上游的事实表和相关维度表。
数据产出的先后时间通过颜色由浅及深进行了表示,颜色最浅任务产出数据在 1 点前,颜色最深的产出数据时间在 4 点后。
"相对稳定 【 5 分钟】【 1 点 30 】"分别代表了上游依赖中:数据产出时间的稳定性;第一个中扩号为任务的执行的时间;第二个中扩号为执行完成时间。
2、问题分析
序号⑩:L 页流量表的产出任务,随流量上涨和上游依赖的不稳定产出时间也一直后延,从年初 6 点左右到四月中已经需要 8 9 点才能产出;关联的都是大表,任务本身执行时间长,其中个别 Stage 并行度非常低;
序号⑥:L 页中间表前置任务,从原始日志中进行了清洗、转化并扩展了外围的基础信息; 该任务逻辑非常重本身执行时间就长,需要 2~3 个小时才能完成,因为依赖上游不稳定的 ③④ 事实,导致该任务迟迟不能执行;
序号①②:L 页的主数据,数据处理的路径通过紫色箭头标识一直到序号⑦,这条路径上游依赖的产出时间为 1 点半左右,可以单独拆分;
序号③:公共埋点数据,为依赖的外部数据产出时间的部位的,一方面需要积极的推动外部进行优化解决;另一方面在后置该数据的使用时间;
序号④:主流程接口请求数据,本身执行时间不稳定的定位原因进行解决,依赖上游③公共埋点数据也是其产出晚的重要原因;
序号⑤:用户访问路径(窄口径)数据,任务本身的执行时长稳定,不稳定的原因在于上游;
序号⑧⑨:基础维表的一系列数据,只使用各表中的几个个别字段对L页流量表的基础信息进行了扩展,这类数据产出时间非常早,可提前进行'列'、'行'的剪裁;
综上通过分析各个环节存在的问题,后去定位深挖其根本的原因,我们可以通过数据链路、数据倾斜、数据存储三个方面来入手进行优化解决。
四、优化方案
1、"拆"、"提"、"并"、"延"对数据的绝对把握
1.1 "拆" 大段长逻辑拆分
可以梳理依赖的上游和逻辑的相关性对大段的 SQL 进行拆分,如序号 ⑥ 中产出的 mid 表任务中依赖了多张数据表,使用了大段的串联逻辑,只有在上游依赖全部完成才能执行。
拆分如下:
- USER:新老客(9)
- DIM:基础信息(⑧)
- LIST:酒店列表主数据(①②)
- EVENT:埋点曝光(③)
- SEARCH:搜索信息(④)
通过以上五线路的拆分,非依赖的数据可以在上游依赖任务完成后及时的进入处理状态中。
这里需要注意错峰执行,一个关键数据的产出往往对应非常多的下游依赖任务,我们应该将非重要、非核心任务改为定时执行,选择执行任务个数较少的时间段来执行。
减少瞬时任务堆积造成计算资源的争抢,还可以降低 HiveServer 服务器的负载,避免 HiveServer 因负载过高超过阈值自动重启,导致跑任务失败重新执行,更加延长了数据的产出时间。
1.2 "提" 上游完成立即执行
"提"这里分为两方面:
一是,提前执行,还是在一个任务中对大段逻辑按照上游依赖拆分出的多个子逻辑,放入不同的执行方法,分别检测各自的上游任务的完成情况,上游完成后可以立即执行。
如对 MID 表 ⑥ 产出任务的拆分,可以拆逻辑而不拆分任务,确保任务的可读性。
二是,提前过滤,对数据过滤这里分为两部分:把过滤条件进行提前,裁剪下游不使用的字段。
如合并任务 ⑦ 中的"剔除非主流程数据" 可以直接提前至序号 ① ODS 的直接下游进行过滤,从源头来减少数据量;
各维表 ⑧ 中的数据不是都会使用到,提前把不需要的字段裁剪掉以减少需要处理的数据量。
1.3 "并" 非依赖子查询并行
并行执行提高资源的利用效率,在更短的时间内完成大量数据处理任务,适用于核心链路的数据产出,需要平衡执行队列确保有足够的资源。
而在产出 MID 表 ⑥ 这个大任务中拆分出的四条执行线,各自上游依赖完成的时间不同,可以在各自的时间线上并行执行,避免了对计算资源的争抢;
对取维表 ⑧ 和新老客 ⑨ 的一系列扩展信息,做提前过滤和列剪裁也都可以并行来完成。
1.4 "延" 为不稳定的依赖留足 buffer
当我们数据链路长的时候,一些产出时间晚、产出时间不稳定的数据,放到执行链路的末尾来最大程度的降低其带来的影响,也可以为其预留更多的 Buffer 时间来执行。
埋点曝光链路 ③ 和主流程搜索请求 ④ 链路受外部依赖不稳定和自身数据倾斜的影响,产出时间晚一直拖累着 MID 表;
将这两条链路的产出数据后置到 DWD 结果表 ⑩ 中进行使用,加 DWD 结果表的 MR 并行度进行提速,关联任务所在的 Stage 可以在 5~10 分钟完成,要比在 mid 表中等待花费的时间小好多倍。
2、消除长尾带来的时间增长
长尾任务是指在一些列任务中,少数的任务处理的数据量远远大于大多数任务的数据量,使得这少数任务的执行时间显著长于其他任务,成为整个任务的瓶颈。
2.1 小心动态分区排序的陷阱
表中为主流程接口请求数据 ④,随着数据量的增加处理时间出现了非常不合理的翻倍的上涨。
数据量(G) | 处理时间(小时) |
---|---|
25.4 | 01:15 |
30.9 | 01:33 |
34 | 01:43 |
38.4 | 02:19 |
43.3 | 03:35 |
47.2 | 03:50 |
我们结合 Hive 的执行计划来逐步分析来定位问题点,首先观察各阶段 Stage 的执行情况,发现最后一个 Stage-6 出现了长尾情况,并且 MR 中有 reduce 任务。
Stage-6 为 MR 数据写入
在 Hive 中最后一个 Stage 的 MR 任务是用于写表的任务(output task),在输出任务的 MR 中通常情况下 Reduce 阶段不一定是必需的。
如果输出数据可以直接写入 HDFS 或其他存储系统,而无需进行聚合或排序操作,则可以省略 Reduce 阶段,这样可以提高任务的性能。
这种情况的 MR 任务是 map-only 任务,但是如果需要进行聚合或排序操作,则需要执行 Reduce 阶段。
search 任务部分清洗逻辑
上面是任务中的部分代码段,主要为对 ods 日志的一个清洗转化过程,最后并没有聚合和排序操作;
分析这里就比较奇怪了,是什么导致了写表任务出现了排序,我们可以通过执行计划来一探究竟。
查看执行计划
执行计划 Stage-6部分
整体的执行计划非常长我们只取 Stage-6 这个阶段,最为关键的是 Reduce Output Operator 这一步
MapReduce 计算引擎,在 Map 阶段和 Reduce 阶段输出的都是键-值对的形式
- key expressions:表示为 Map 阶段输出的键(key)所用的数据列
- sort order:表示输出是否进行排序,+ 表示正序,- 表示倒序
- Map-reduce partition columns:表示 Map 阶段输出到 Reduce 阶段的分区列
- value expressions:表示为 Map 阶段输出的值(value)所用的数据列
Map 阶段输出的 key 正是我们分区的字段_ col22:platform _col23:hour,对key进行了正序排列
insert overwrite table dw_hotel_search_di partition(dt=${DATE},platform,hour)
到这里就定位到了我们在写入表的时候,dt 是直接指定的静态分区;platform和 hour 是动态分区,现在动态分区出现了排序的情况并且导致了长尾任务。
Hive 中动态分区的分区个数非常大的时候,会出现 OOM。在每个 task 进行数据写出时为每个分区目录开启一个文件写入器(file writer),数据会先进入缓存区后批量写入。
如我们在刷一个大表的历史数据时,当内存中的文件句柄越来越多的时候数据内存会被逐渐填满,导致 OOM 的发生。
而动态分区排序正是为了解决这个问题,开启动态排序后会对分区 key 进行全局排序,排序后每个 task 内对应一个分区的数据这样有效的解决了打开文件句柄多 OOM 的发生。
但是,同样也引入了一个问题,全局排序后某 Reduce 对应分区中的数据量非常多的时候出现倾斜,执行缓慢。
公司的 hive 组件是默认开启动态分区排序的 "hive.optimize.sort.dynamic.partition=true"。
我们现在 dw_hotel_search_di 表中 platform 通常为 adr 和 ios,hour 是 24 小时。分区个数极少开启的文件写入器不会造成内存被吃满 OOM 的发生,将动态分区排序进行关闭。
2.2 JOIN KEY的空值加"盐"
窄口径是指通过用户的请求 Trace 把访问过的页面逐级串联起来,去统计用户的转化率。
下图是 L 页流量表通过 Trace 去串联各页面的流程图,TraceID 是用户访问当前页面产生的,TraceLog 为透传的上一个页面的 TraceID,这样就可以逐级串联起来。
如图我们有十个用户访问了 L 页,其中两个不太满意也就不会进入下一个页面 D 页,也就是我们所谓的流失用户,同样用这两个用户的 L 页 Traceid 是在 D 页的 TraceLog 中找不到。
这样就会出现一个问题,十个用户:D 页 TraceID 获取的为空 2 个,B 页TraceID 获取的为空 6 个,O 页 TraceID 获取的为空 7 个;
在进行 JOIN 操作时,如果 JOIN KEY 中包含为空的列,会导致数据倾斜。因为 Hive 会将所有空值都映射到同一个 Reduce 任务中,如果某个键对应的数据量很大,会使这个 Reduce 任务成为瓶颈。
这些空的 TraceID 作为 JOIN KEY 在关联下一级页面的时候会被分配到一个 reduce 中造成数据倾斜,严重拖累任务整体的完成进度。
解决方法是在空值列上追加一个随机"盐",使相同的空值也能映射到不同的 Reduce 任务,从而缓解数据倾斜的问题。
3、选择压缩时机产生的不同收益
Hive 支持在多个层面上进行数据压缩选择不同的压缩时机,可获得不同的收益如:
- 在数据存储上压缩可以减少存储空间和读取数据量
- 在中间结果序列化上压缩可以减少 Shuffle 数据量
- 在输出阶段压缩可以减少最终结果数据量
需要根据具体场景选择最佳的压缩策略,我们这次的案例是针对中间表和最终产出表来进行的优化压缩实践
3.1 临时中间表的压缩场景
临时表的压缩有两个不同的场景小的维表和大的中间结果表
1、先说小的维度表:
在链路优化中将 DIM :基础信息 (⑧) 的关联逻辑从进行了拆分,从多段的 join 子查询中提了出来。
提前错峰执行缓解瞬时任务堆积造成计算资源压力是一方面原因。
更重要的是提前对数据做了行和列的剪裁,然后对产出临时表做数据的压缩,进一步为了降低数据量从而降低到满足 mapjoin 的阈值;
使其放入到 hashTable 里共享至分布式缓存中,在 map 端完成 JOIN 操作,由 CommonJoin 转化为 MapJoin,极大的提高 join 效率。
当然也需要我们来观察剪裁后的表是否满足 hive.mapjoin.smalltable.filesize 阈值,在差异不大的情况下可以适当对该值调大,默认是非常保守的 25M。
2、然后再说中间结果表:
背景: hive 中的表一般都是 ORC 默认用 Zlib 即使我们不指定压缩格式也会进行自动压缩,通常会有十几倍的压缩效率。
如果我产出的一个中间结果表使用了 ORC 格式,占用存储大小就会大大降低,然后在用这个表去 JOIN 其他表的时候,按数据大小切片后产生的 Task 个数(并行度)就会降低很多倍。
问题分析: L 页流量表的产出任务中就存在了这样的情况,下面是任务的各 Stage 执行情况:
共有 15 个 Stage ,开头 Stage-22 是小表 (319kb) 处理,结尾 Stage-12 是写表 Task 这两个任务较小
Stage:17、18、19 是处理详情、预订、提交三个阶段窄口径的,用的都是UserPath 用户窄口径所以并行度一致
Stage1:是整个关联查询 SQL 主表(也就是最左表的第一个关联查询)的执行阶段
最左表为 L 页中间表使用了 ORC 格式,map 任务在数据切片时候才拆分了 187 个任务,需要 40 多分钟才能执行完成
其他 Stage 的并行度都很匀称,平均 map 在 8K 左右、reduce 在 2.5K 左右,执行时间也无卡点
通常 map 任务个数=表数据存储大小 /mapred.max.split.size ,L 页中间表每天平均 ORC 存储大小为几十G数据,分片大小为 128Mb
解决方案: 要想提升任务的并行度,一个是降低切片的大小、另一个是增加表数据存储的大小
- 降低切片大小:如果我们将分片大小从 128mb 降到 96mb,Stage1 本身收益并不明显,同时其他的 Stage 数据体量大并行度会激增对我们计算资源造成压力
- 增加表存储大小:我们只增大 L 页中间表的存储大小也就是 Stage1 处理的数据量来提个其并行度,其他的 Stage 完全不受影响
我们最终选择增加表存储的大小来提高并行度,具体操作为对L页中间表转为 textfile 来存储并且不使用压缩算法,放大其数据量,从而提高数据切片个数增大 map 任务的并行度。
优化效果如下:在其他 Stage 依然保持均衡,极大的提高了 Stage1 的并行度,优化后 Stage1 从原来的 40 分钟缩短至十多分钟。
3.2 产出表如何获得更好的压缩率
L 页流量表 (dwd_flow_app_searchlist_di) 一直是使用 ORC 数据格式进行存储默认压缩工具是 Zlib。
通常 ORC+Zlib 的压缩率有十几倍,而 L 页流量表的压缩比(压缩前/压缩后)只有 3.5 : 1 完全没有达到常规的压缩比,而且查询性能低。
1、 我们来了解一下 ORC 的存储结构,这样才能更好的发挥其压缩效果
ORC 是列式存储的自解析文件,文件中有自己的元数据,包括了多个Stripe,Stripe 中也包括了索引信息、主数据和元数据信息。整体的层级分为三级,每级都有对应的索引信息
- 文件级 (file):这一级的索引信息记录文件中所有 stripe 的位置信息,以及文件中所存储的每列数据的统计信息;
- 条带级 (stripe):该级别索引记录每个 stripe 所存储数据的统计信息;
- 行组级 (row):在 stripe 中,每 10000 行构成一个行组,该级别的索引信息就是记录这个行组中存储的数据的统计信息。
我们聚焦在行组这个级别,1W 行构成的行组中每一个字段以单独的 Stream 来存储,每个 Stream 中的数据类型为一致,这样在行组中数据同质化越高压缩效果越明显
架构如下:
2、线上实际表中的 ORC 是如何存储的呢,我们可以一探究竟
先从线上表的目录中将一个 ORC 的数据文件 down 下来,可以通过 orc-tools 来进行分析其元数据内容。
头部的基础信息包括了文件的大小,版本,条数,压缩格式和数据列名称类型 schema 信息。
重点关注 Stripe Statistics,对其中每个 Stripe 的统计项:
- count: 当前 stripe 中该值的个数
- hasNull: 是否包括 null
- min: 最小值
- max: 最大值
- sum: 累计值
在打印出来的 ORC 元数据中我们可以清晰的看到数据在文件中的分布,当一个 stripe 中存储的数据段同质化的程度越高的时候压缩的比率就会越大
3、L页流量表的优化实现,同质化数据的集中
如何使表中的数据在任务执行后写入文件时,将同质化的数据写入到一起呢?
我们可以采用重新排序(distribute by xxx),当然排序字段需要保证相对较高的基数避免数据倾斜;计算引擎会以排序字段为分区键 hash 进行分发到不同的计算节点,同一计算节点的 hash key 是一致的这样就保证了数据的同质化程度,但毫无疑问的是直接引入额外的排序后会影响我任务的产出效率。
如何能够使数据同质化和产出性能二者兼顾呢?
我们可以具体分析 L 页流量表中是酒店列表页的展示数据,同一次请求中( trace 粒度)包括的基础信息重复度就会越高,但是 traceid 的基数非常高以它来排序的话,数据相对太分散。
我们退而求其次选用用户粒度,兼顾了信息重复度和数据基数,那有如何避免引入额外的排序呢?
实际任务中往往我们调整一下 join 表的顺序就可以了,如产出 L 页流量表我们把关联新老客的数据放到结尾;
这样最后的执行任务以 user_id 为 joinkey,hash 后分发到不同的下游处理阶段完成 Join 操作,而我们也达到了想要的目的。
五、优化效果
通过以上一些列的策略进行优化后,新链路任务在 4 月 18 号完成上线,产出4 月 17 号 (T-1) 的数据为优化后的。
1、产出时间提前了近 4 个小时
任务平均完成时间从原来 9 点半提前至 5 点半,产出时间提前了近 4 个小时;天任务运行时间从原来 2.5~3 小时缩短至 1 个小时,近期随着预订五一酒店的流量上涨运行时间依然稳定在 1 小时。
任务的开始时间为 4 点左右并不是依赖的上游流量链路晚,而是现在已经卷到订单链路。目前依赖的订单资金产出是时间为 4 点左右,后续我们对订单链路优化后流量的产出时间会更早。
2、存储空间节约了 93%
L 页流量表原先就是 ORC 存储默认使用 Zlib 进行压缩,优化后存储和压缩格式都没有改变完全兼容了历史 DT 的数据避免了数据回刷。
表中除了没有删减字段还新增一个资金相关的字段,存储资源的节约得益于对 ORC 数据格式特性的充分了解和利用,占用的存储资源平均由原来 230G 下降至 15G,存储空间节约了 93% 。
3、查询效率提升了6倍
查询效率对于产、运、分析同学更为关注。在下面验证查询效率的 SQL 中总共有 18 个去重统计项,10 个汇总统计项,验证是在一个空闲的资源队列完成的。
分别用优化前(16日)、优化后(17日)的数据进行统计,查询时间由原来 2 小时 21 分缩短至 22 分钟,查询效率提升了 6 倍!
查询效率验证 SQL
六、总结
在策略上: 通过"拆"、"提"、"并"、"延"的方法对流量链路进行了调整。
在技术上: 对任务执行过程进行分析解决了倾斜的卡点、提高了压缩的效率。
在外部: 通过值班归因避免 case by case ,推动了日志收集、调度系统、公共埋点和平台服务的稳定性降低起夜率,为保障五一注入了强力稳定剂。
数据的产出时间大大缩短,存储空间显著减小,查询效率也得到了极大的提升,优化效果显著。
另外对上游链路中 search 任务的优化产生了更多的附加收益。
涵盖了主流程的所有接口请求日志,通过对不同接口的拆解我们会得到搜索( search S 页)、详情( Detail D 页)、预订( booking B 页)和提交订单( order O 页)流量表。
在今年五一期间流量到达峰后(上表中数据量为十几倍压缩率压缩后的值),数据的处理时间表现非常平稳,保障了下游 S、D、B、O 各级流量表产出的及时性。
未来展望,将我们的策略、优化方法进行沉淀,形成一系列的规则,展开应用到全链路中。
最后送给各位读者一句笔者总结的心得:勤于思考 善于追问 抓的住本质 拿的到结果~
以上就是本次分享的所有内容,最后为大家带来一个内推信息,欢迎优秀的你加入驼厂~