导读:Apache Doris 是小米集团内部应用最为广泛的 OLAP 引擎之一,本文主要从数据的角度分析 A/B 实验场景查询的性能现状,探讨基于 Apache Doris 的性能优化的解决方案。经过一系列基于 Doris 的性能优化和测试,A/B 实验场景查询性能的提升超过了我们的预期。希望本次分享可以给有需要的朋友提供一些参考。
业务背景
A/B 实验是互联网场景中对比策略优劣的重要手段。为了验证一个新策略的效果,需要准备原策略 A 和新策略 B 两种方案。随后在总体用户中取出一小部分,将这部分用户完全随机地分在两个组中,使两组用户在统计角度无差别。将原策略 A 和新策略 B 分别展示给不同的用户组,一段时间后,结合统计方法分析数据,得到两种策略生效后指标的变化结果,并以此判断新策略 B 是否符合预期。
小米 A/B 实验平台是一款通过 A/B 实验的方式,借助实验分组、流量拆分与科学评估来辅助完成科学的业务决策,最终实现业务增长的一款运营工具产品。其广泛的应用于产品研发生命周期中各个环节:
数据平台架构
本文主要从数据的角度分析 A/B 实验场景查询的性能现状,探讨一下基于 Apache Doris 的性能优化的解决方案。A/B实验平台的架构如下图所示:
- 平台使用的数据主要包含平台自用的实验配置数据、元数据,以及业务方上报的日志数据。
- 由于业务方引入 SDK,并与分流服务进行交互,日志数据中包含其参与的实验组 ID 信息。
- 用户在实验平台上配置、分析、查询,以获得报告结论满足业务诉求。
鉴于 AB 实验报告各个业务方上报数据的链路都大体类似,我们就拿头部业务方广告业务举例,数据流程如下图所示:
从上图可知,整个数据链路并不复杂,日志数据传入后,经过必要的数据处理和清洗工作进入 Talos(小米自研消息队列),通过 Flink 任务以明细数据的形式实时写入到 Doris 表中,同时 Talos 数据也会同步到 Hive 表进行备份,以便问题排查和数据修复。
出于对高效写入以及字段增减需求的考虑,Doris 明细表以 Duplicate 模型来建模:
CREATE TABLE `dwd_xxxxxx` (
`olap_date` int(11) NULL COMMENT "分区日期",
`user_id` varchar(256) NULL COMMENT "用户id",
`exp_id` varchar(512) NULL COMMENT "实验组ID",
`dimension1` varchar(256) NULL COMMENT "",
`dimension2` varchar(256) NULL COMMENT "",
......
`dimensionN` bigint(20) NULL COMMENT "",
`index1` decimal(20, 3) NULL COMMENT "",
......
`indexN` int(11) NULL COMMENT "",
) ENGINE=OLAP
DUPLICATE KEY(`olap_date`, `user_id`)
COMMENT "OLAP"
PARTITION BY RANGE(`olap_date`)
(
PARTITION p20221101 VALUES [("20221101"), ("20221102")),
PARTITION p20221102 VALUES [("20221102"), ("20221103")),
PARTITION p20221103 VALUES [("20221103"), ("20221104"))
)
DISTRIBUTED BY HASH(`user_id`) BUCKETS 300
;
数据现状分析
在提速之前,小米 A/B 实验平台完成实验报告查询的 P95 时间为小时级,实验报告使用数据的方式存在诸多的性能问题,直接影响业务部门做运营和决策的效率。
报告查询基于明细
当前报告查询的数据来源为明细表,而明细表的数据量巨大:
而且,实验报告的查询条件中时间范围常常横跨多天。基于历史查询报告统计,查询条件中时间范围大于一天的报告占比 69.1%,具体的时间跨度占比分布如下:
明细数据的巨大扫描量给集群带来了不小的压力,且由于报告查询存在并发以及 SQL 的拆分,如果一个 SQL 请求不能快速的返回结果释放资源,也会影响到请求的排队状况。因此在工作时间段内 Doris 集群BE节点 CPU 负载状况基本是持续满载,磁盘 IO 也持续处于高负荷状态,如下图所示:
BE节点CPU使用率
BE节点磁盘IO
个人思考:
- 当前报告所有查询基于明细数据,且平均查询时间跨度为 4 天,查询扫描数据量上百亿。由于扫描数据量级大,计算成本高,给集群造成较大压力,导致数据查询效率不高。
- 如果通过对数据进行预聚合处理,控制 Scan Rows 和 Scan Bytes,减小集群的压力,查询性能会大幅提升。
字段查询热度分层分布
由于之前流程管控机制相对宽松,用户添加的埋点字段都会进入到明细表中,导致字段冗余较多。统计历史查询报告发现,明细表中常用的维度和指标只集中在部分字段,且查询热度分层分布:
参与计算的指标也集中在部分字段,且大部分都是聚合计算(sum)或可以转化为聚合计算(avg):
个人思考:
- 明细表中参与使用的维度只占 54.3%,高频使用的维度只占 15.2%,维度查询频次分层分布。
- 数据聚合需要对明细表中维度字段做取舍,选择部分维度进行上卷从而达到合并的目的,但舍弃部分字段必然会影响聚合数据对查询请求的覆盖情况。而维度查询频次分层分布的场景非常适合根据维度字段的热度做不同层次的数据聚合,同时兼顾聚合表的聚合程度和覆盖率。
实验组 ID 匹配效率低
当前明细数据的格式为:
明细数据中的实验组 ID 以逗号分隔的字符串形式聚拢在一个字段中,而实验报告的每条查询语句都会使用到exp_id
过滤,查询数据时使用 LIKE 方式匹配,查询效率低下。
个人思考:
- 将实验组 ID 建模成一个单独的维度,可使用完全匹配代替 LIKE 查询,且可利用到 Doris 索引,提高数据查询效率。
- 将逗号分隔的实验组 ID 直接打平会引起数据量的急剧膨胀,因此需要设计合理的方案,同时兼顾到数据量和查询效率。
进组人数计算有待改进
进组人数查询是实验报告的必查指标,因此其查询速度很大程度上影响实验报告的整体查询效率,当前主要问题如下:
- 当进组人数作为独立指标计算时,使用近似计算函数
APPROX_COUNT_DISTINCT
处理,是通过牺牲准确性的方式提升查询效率。 - 当进组人数作为复合指标的分母进行计算时,使用
COUNT DISTINCT
处理,此方式在大数据量计算场景效率较低。
个人思考:
- AB实验报告的数据结论会影响到用户决策,牺牲准确性的方式提升查询效率是不可取的,特别是广告这类涉及金钱和业绩的业务场合,用户不可能接受近似结果。
- 进组人数使用的
COUNT DISTINCT
计算需要依赖明细信息,这也是之前查询基于明细数据的重要因素。必须为此类场景设计新的方案,使进组人数的计算在保证数据准确的前提下提高效率。
数据优化方案
基于以上的数据现状,我们优化的核心点是将明细数据预聚合处理,通过压缩数据来控制 Doris 查询的 Scan Rows 和 Scan Bytes。与此同时,使聚合数据尽可能多的覆盖报告查询。从而达到,减小集群的压力,提高查询效率的目的。
新的数据流程如下图所示:
整个流程在明细链路的基础上增加聚合链路,Talos 数据一方面写入 Doris 明细表,另一方面增量落盘到 Iceberg 表中,Iceberg 表同时用作回溯明细数据以及生成聚合数据,我们通过工场 Alpha(小米自研数据开发平台)的实时集成和离线集成保证任务的稳定运行和数据的一致性。
选取高频使用维度聚合
在生成数据聚合的过程中,聚合程度与请求覆盖率是负相关的。使用的维度越少,能覆盖的请求就越少,但数据聚合程度越高;使用的维度越多,覆盖的请求也越多,但数据粒度就越细,聚合程度也越低。因此需要在聚合表建模的过程中取得一个平衡。
我们的具体做法是:拉取历史(近半年)查询日志进行分析,根据维度字段的使用频次排序确认进入聚合表的优先级。在此基础上得出聚合表的覆盖率和数据量随着建模字段增加而变化的曲线,如下图所示:
其中覆盖率根据历史请求日志代入聚合表计算得出。
我们的原则是:针对 OLAP 查询,聚合表的数据量应尽可能的控制在单日 1 亿条以内,请求覆盖率尽可能达到 80%以上。
因此不难得出结论:选择 14 个维度字段对聚合表建模比较理想,数据量能控制到单日 8 千万条左右,且请求覆盖率约为 83% 。
使用物化视图
在分析报告历史查询日志时,我们发现不同的维度字段查询频次有明显的分层:
Top7 维度字段几乎出现在所有报告的查询条件之中,对于如此高频的查询,值得做进一步的投入,使查询效率尽可能的提升到最佳。Doris 的物化视图能够很好的服务于此类场景。
什么是物化视图?
物化视图是一种特殊的物理表,其中保存基于基表(base table)部分字段进一步上卷聚合的结果。
虽然在物理上独立存储,但它是对用户透明的。为一张基表配置好物化视图之后,不需要为其写入和查询做任何额外的工作:
- 当向基表写入和更新数据时,集群会自动同步到物化视图,并通过事务方式保证数据一致性。
- 当对基表进行查询时,集群会自动判断是否路由到物化视图获取结果。当查询字段能被物化视图完全覆盖时,会优先使用物化视图。
因此我们的查询路由如下图所示:
用户的查询请求会尽可能的路由到聚合表物化视图,然后是聚合表基表,最后才是明细表。
如此使用多梯度的聚合模型的配合来应对热度分层的查询请求,使聚合数据的效能尽可能的发挥到最大。
精确匹配取代 LIKE 查询
既然物化视图这么好用,为什么我们不是基于 Doris 明细表配置物化视图,而是单独开发聚合表呢?是因为明细数据中的实验组ID字段存储和查询方式并不合理,聚合数据并不适合通过明细数据直接上卷来得到。
上文中已经提到,exp_id
(实验组ID)在明细表中以逗号分隔的字符串进行存储,查询数据时使用 LIKE 方式匹配。作为 AB 实验报告查询的必查条件,这种查询方式无疑是低效的。
我们希望的聚合方式如下图所示:
我们需要将exp_id
字段拆开,把数据打平,使用精确匹配来取代LIKE查询,提高查询的效率。
控制聚合表数据量
如果只做拆分打平的处理必然会导致数据量的激增,未必能达到正向优化的效果,因此我们还需要想办法来压缩exp_id
打平后的数据量:
- 聚合表选取维度字段建模的时候,除了上文提到的,以字段的使用频次热度作为依据之外,也要关注字段的取值基数,进行综合取舍。如果取值基数过高的维度字段进入聚合表,必然会对控制聚合表的数据量造成阻碍。因此,我们在保证聚合表请求覆盖量的前提下,酌情舍弃部分高基数(取值有十万种以上)的维度。
- 从业务的角度尽可能过滤无效数据(比如一个实验组的流量为 0% 或者 100%,业务上就没有对照的意义,用户也不会去查,这样的数据就不需要进入聚合表)。
经过这一系列步骤,最终聚合表的数据量被控制在单日约 8000 万条,并没有因为 exp_id
打平而膨胀。
值得一提的是,exp_id
字段拆分后,除了查询从LIKE匹配变为精确匹配,还额外带来了两项收益:
- 字段从
String
类型变为Int
类型,作为查询条件时的比对效率变高。 - 能利用Doris的前缀索引和布隆过滤器等能力,进一步提高查询效率。
使用 BITMAP 去重代替 COUNT DISTINCT
要提速实验报告查询,针对进组人数(去重用户数)的优化是非常重要的一个部分。作为一个对明细数据强依赖的指标,我们如何在不丢失明细信息的前提下,实现像 Sum,Min,Max 等指标一样高效的预聚合计算呢?BITMAP 去重计算可以很好的满足我们的需求。
什么是BITMAP去重?
BITMAP 去重简单来说就是建立一种数据结构,表现形式为内存中连续的二进制位(bit),参与去重计算的每个元素(必须为整型)都可以映射成这个数据结构的一个 bit 位的下标,如下图所示:
计算去重用户数时,数据以 bit_or
的方式进行合并,以bit_count
的方式得到结果。更重要的是,如此能实现去重用户数的预聚合。BITMAP 性能优势主要体现在两个方面:
- 空间紧凑:通过一个 bit 位是否置位表示一个数字是否存在,能节省大量空间。以 Int32 为例,传统的存储空间为 4 个字节,而在 BITMAP 计算时只需为其分配 1/8 字节(1个 bit 位)的空间。
- 计算高效:BITMAP 去重计算包括对给定下标的 bit 置位,统计 BITMAP 的置位个数,分别为 O(1) 和 O(n) 的操作,并且后者可使用 CLZ,CTZ 等指令高效计算。此外,BITMAP 去重在 Doris 等 MPP 执行引擎中还可以并行加速处理,每个节点各自计算本地子 BITMAP,而后进行合并。
当然,以上只是一个简化的介绍,这项技术发展至今已经做了很多优化实现,比如RoaringBitmap,感兴趣的同学可以看看:https://github.com/RoaringBitmap/RoaringBitmap
全局字典
要实现 BITMAP 去重计算,必须保证参与计算的元素为 UInt32 / UInt64,而我们的user_id
为String
类型,因此我们还需设计维护一个全局字典,将user_id
映射为数字,从而实现 BITMAP 去重计算。
由于聚合数据目前只服务于离线查询,我们选择基于Hive表实现全局字典,其流程如下:
指标聚合
生成 Doris 聚合表时,将 user_id
作为查询指标以 BITMAP 类型来存储,其他常规查询指标则通过 COUNT/SUM/MAX/MIN 等方式聚合:
如此明细表和聚合表的指标计算对应关系如下:
优化效果
SQL视角
查询请求转换成 SQL 之后,在明细表和聚合表的表现对比如下:
- 常规聚合指标查询的性能提升自不必说(速度提升 50~60 倍)
- 进组人数查询性能的提升也非常可观(速度提升 10 倍左右)
集群视角
SQL 查询的快进快出,使查询占用的资源能快速释放,对集群压力的缓解也有正向的作用。
Doris 集群 BE 节点 CPU 使用情况和磁盘IO 状况的改变效果显著:
需要说明的是,集群状况的改善(包括实验报告查询 P95 提升)并不全归功于数据预聚合优化工作,这是各方合力协作(如产品业务形态调整,后端查询引擎排队优化,缓存调优,Doris 集群调优等)的综合结果。
小技巧
由于业务查询需求的多样,在查询明细表时,会出现一个字段既作为维度又作为指标来使用的情况。
如广告业务表中的targetConvNum
(目标转化个数)字段,此字段的取值为 0 和 1,查询场景如下:
--作为维度
select targetConvNum,count(distinct user_id)
from analysis.doris_xxx_event
where olap_date = 20221105
and event_name='CONVERSION'
and exp_id like '%154556%'
group by targetConvNum;
--作为指标
select sum(targetConvNum)
from analysis.doris_xxx_event
where olap_date = 20221105
and event_name='CONVERSION'
and exp_id like '%154556%';
如果这个字段被选取进入聚合表,应该如何处理呢?
我们的处理方式是:
- 在聚合表中把这类字段建模成维度
- 聚合表中需要一个计数指标 cnt,表示聚合表中一条数据由明细表多少条数据聚合得
- 当这类字段被作为指标查询时,可将其与cnt指标配合计算得到正确结果
明细表查询:
select sum(targetConvNum)
from analysis.doris_xxx_event
where olap_date = 20221105
and event_name='CONVERSION'
and exp_id like '%154556%';
对应的聚合表查询:
select sum(targetConvNum * cnt)
from agg.doris_xxx_event_agg
where olap_date = 20221105
and event_name = 'CONVERSION'
and exp_id = 154556;
结束语
经过这一系列基于 Doris 的性能优化和测试,A/B 实验场景查询性能的提升超过了我们的预期。值得一提的是,Doris 较高的稳定性和完备的监控、分析工具也为我们的优化工作提效不少。 希望本次分享可以给有需要的朋友提供一些参考。
最后,感谢 SelectDB 公司和 Apache Doris 社区对我们的鼎力支持。Apache Doris 是小米集团内部应用最为广泛的 OLAP 引擎之一,目前集团内部正在推进最新的向量化版本升级工作。未来一段时间我们将会把业务优化工作和 Doris 最新的向量化版本进行适配,进一步助力业务的正向发展。