“
本期技术干货,我们邀请到了小米集团大数据工程师乐涛,和大家分享基于Apache Doris的小米A/B实验场景查询提速实践。
”
一、业务背景
A/B实验是互联网场景中对比策略优劣的重要手段。为了验证一个新策略的效果,需要准备原策略A和新策略B两种方案。随后在总体用户中取出一小部分,将这部分用户完全随机地分在两个组中,使两组用户在统计角度无差别。
将原策略A和新策略B分别展示给不同的用户组,一段时间后,结合统计方法分析数据,得到两种策略生效后指标的变化结果,并以此判断新策略B是否符合预期。
小米A/B实验平台是一款通过A/B实验的方式,借助实验分组、流量拆分与科学评估来辅助完成科学的业务决策,最终实现业务增长的一款运营工具产品。其广泛的应用于产品研发生命周期中各个环节:
角色 | 场景 |
产品 | 产品功能迭代、用户路径设计、UI&交互优化 |
运营 | 运营活动优化:落地页、素材... 推送方案择优:推送时机、文案... |
研发 | 推荐算法优化、排序算法优化... |
本文主要从数据的角度分析A/B实验场景查询的性能现状,探讨一下性能优化的解决方案。
二、数据平台架构
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时间为小时级,实验报告使用数据的方式存在诸多的性能问题,直接影响业务部门做运营和决策的效率。
>>>> 3.1 报告查询基于明细
当前报告查询的数据来源为明细表,而明细表的数据量巨大:
单日记录数量级 | 单日存储量级(Doris replication_num=3) | 字段数量 |
十亿级 | TB级别 | 百余个 |
而且,实验报告的查询条件中时间范围常常横跨多天。基于历史查询报告统计,查询条件中时间范围大于一天的报告占比69.1%,具体的时间跨度占比分布如下:
明细数据的巨大扫描量给集群带来了不小的压力,且由于报告查询存在并发以及SQL的拆分,如果一个SQL请求不能快速的返回结果释放资源,也会影响到请求的排队状况。
因此在工作时间段内Doris集群BE节点CPU负载状况基本是持续满载,磁盘IO也持续处于高负荷状态,如下图所示:
BE节点CPU使用率
BE节点磁盘IO
个人思考:
当前报告所有查询基于明细数据,且平均查询时间跨度为4天,查询扫描数据量上百亿。由于扫描数据量级大,计算成本高,给集群造成较大压力,导致数据查询效率不高。
如果通过对数据进行预聚合处理,控制Scan Rows和Scan Bytes,减小集群的压力,查询性能会大幅提升。
>>>> 3.2 字段查询热度分层分布
由于之前流程管控机制相对宽松,用户添加的埋点字段都会进入到明细表中,导致字段冗余较多。统计历史查询报告发现,明细表中常用的维度和指标只集中在部分字段,且查询热度分层分布:
维度字段名 | 使用次数 |
exp_id | 4000+ |
维度1 | |
维度2 | |
维度3 | |
维度4 | |
维度5 | |
维度6 | |
维度7 | 1000-4000 |
维度8 | |
维度9 | |
维度10 | |
维度11 | |
维度12 | 小于1000 |
维度13 | |
其余11个维度字段 |
参与计算的指标也集中在部分字段,且大部分都是聚合计算(sum)或可以转化为聚合计算(avg):
字段名 | 算子 | 使用次数 |
指标1 | sum | 1000+ |
指标2 | sum | |
指标3 | sum | |
指标4 | sum | |
指标5 | avg(sum/cnt) | |
指标6 | sum | |
指标7 | sum | |
指标8 | avg(sum/cnt) | |
指标9 | sum | |
指标10 | sum | |
指标11 | sum | |
指标12 | avg(sum/cnt) | |
指标13 | uniq_user_avg(sum/count(distinct user_id)) |
个人思考:
明细表中参与使用的维度只占54.3%,高频使用的维度只占15.2%,维度查询频次分层分布。
数据聚合需要对明细表中维度字段做取舍,选择部分维度进行上卷从而达到合并的目的,但舍弃部分字段必然会影响聚合数据对查询请求的覆盖情况。而维度查询频次分层分布的场景非常适合根据维度字段的热度做不同层次的数据聚合,同时兼顾聚合表的聚合程度和覆盖率。
>>>> 3.3 实验组ID匹配效率低
当前明细数据的格式为:
明细数据中的实验组ID以逗号分隔的字符串形式聚拢在一个字段中,而实验报告的每条查询语句都会使用到exp_id过滤,查询数据时使用LIKE方式匹配,查询效率低下。
个人思考:
将实验组ID建模成一个单独的维度,可使用完全匹配代替LIKE查询,且可利用到Doris索引,提高数据查询效率。
将逗号分隔的实验组ID直接打平会引起数据量的急剧膨胀,因此需要设计合理的方案,同时兼顾到数据量和查询效率。
>>>> 3.4 进组人数计算有待改进
进组人数查询是实验报告的必查指标,因此其查询速度很大程度上影响实验报告的整体查询效率,当前主要问题如下:
当进组人数作为独立指标计算时,使用近似计算函数APPROX_COUNT_DISTINCT处理,是通过牺牲准确性的方式提升查询效率。
当进组人数作为复合指标的分母进行计算时,使用COUNT DISTINCT处理,此方式在大数据量计算场景效率较低。
个人思考:
AB实验报告的数据结论会影响到用户决策,牺牲准确性的方式提升查询效率是不可取的,特别是广告这类涉及金钱和业绩的业务场合,用户不可能接受近似结果。
进组人数使用的COUNT DISTINCT计算需要依赖明细信息,这也是之前查询基于明细数据的重要因素。必须为此类场景设计新的方案,使进组人数的计算在保证数据准确的前提下提高效率。
四、数据优化方案
基于以上的数据现状,我们优化的核心点是将明细数据预聚合处理,通过压缩数据来控制Doris查询的Scan Rows和Scan Bytes。与此同时,使聚合数据尽可能多的覆盖报告查询。从而达到,减小集群的压力,提高查询效率的目的。
新的数据流程如下图所示:
整个流程在明细链路的基础上增加聚合链路,Talos数据一方面写入Doris明细表,另一方面增量落盘到Iceberg表中,Iceberg表同时用作回溯明细数据以及生成聚合数据。我们通过工场Alpha(小米自研数据开发平台)的实时集成和离线集成保证任务的稳定运行和数据的一致性。
>>>> 4.1 选取高频使用维度聚合
在生成数据聚合的过程中,聚合程度与请求覆盖率是负相关的。使用的维度越少,能覆盖的请求就越少,但数据聚合程度越高;使用的维度越多,覆盖的请求也越多,但数据粒度就越细,聚合程度也越低。因此需要在聚合表建模的过程中取得一个平衡。
我们的具体做法是:拉取历史(近半年)查询日志进行分析,根据维度字段的使用频次排序确认进入聚合表的优先级。在此基础上得出聚合表的覆盖率和数据量随着建模字段增加而变化的曲线,如下图所示:
其中覆盖率根据历史请求日志代入聚合表计算得出。
我们的原则是:针对OLAP查询,聚合表的数据量应尽可能的控制在单日1亿条以内,请求覆盖率尽可能达到80%以上。因此不难得出结论:选择14个维度字段对聚合表建模比较理想,数据量能控制到单日8千万条左右,且请求覆盖率约为83%。
>>>> 4.2 使用物化视图
在分析报告历史查询日志时,我们发现不同的维度字段查询频次有明显的分层:
聚合表维度 | 使用此维度的报告占比 |
exp_id | 100% |
维度1 | 100% |
维度2 | 99.29% |
维度3 | 99.29% |
维度4 | 99.12% |
维度5 | 98.98% |
维度6 | 98.67% |
维度7 | 35.63% |
维度8 | 35.63% |
维度9 | 26.78% |
维度10 | 23.66% |
维度11 | 15.23% |
维度12 | 2.31% |
维度13 | 2.19% |
Top7维度字段几乎出现在所有报告的查询条件之中,对于如此高频的查询,值得做进一步的投入,使查询效率尽可能的提升到最佳。
Doris的物化视图能够很好的服务于此类场景。
▍什么是物化视图?
物化视图是一种特殊的物理表,其中保存基于基表(base table)部分字段进一步上卷聚合的结果。
虽然在物理上独立存储,但它是对用户透明的。为一张基表配置好物化视图之后,不需要为其写入和查询做任何额外的工作:
当向基表写入和更新数据时,集群会自动同步到物化视图,并通过事务方式保证数据一致性。
当对基表进行查询时,集群会自动判断是否路由到物化视图获取结果。当查询字段能被物化视图完全覆盖时,会优先使用物化视图。
因此我们的查询路由如下图所示:
用户的查询请求会尽可能的路由到聚合表物化视图,然后是聚合表基表,最后才是明细表。如此使用多梯度的聚合模型的配合来应对热度分层的查询请求,使聚合数据的效能尽可能的发挥到最大。
>>>> 4.3 精确匹配取代LIKE查询
既然物化视图这么好用,为什么我们不是基于Doris明细表配置物化视图,而是单独开发聚合表呢?
是因为明细数据中的实验组ID字段存储和查询方式并不合理,聚合数据并不适合通过明细数据直接上卷来得到。3.3节已经提到,exp_id(实验组ID)在明细表中以逗号分隔的字符串进行存储,查询数据时使用LIKE方式匹配。作为AB实验报告查询的必查条件,这种查询方式无疑是低效的。
我们希望的聚合方式如下图所示:
我们需要将exp_id字段拆开,把数据打平,使用精确匹配来取代LIKE查询,提高查询的效率。
▍控制聚合表数据量
如果只做拆分打平的处理必然会导致数据量的激增,未必能达到正向优化的效果,因此我们还需要想办法来压缩exp_id打平后的数据量:
聚合表选取维度字段建模的时候,除了4.1节提到的,以字段的使用频次热度作为依据之外,也要关注字段的取值基数,进行综合取舍。如果取值基数过高的维度字段进入聚合表,必然会对控制聚合表的数据量造成阻碍。因此,我们在保证聚合表请求覆盖量的前提下,酌情舍弃部分高基数(取值有十万种以上)的维度。
从业务的角度尽可能过滤无效数据(比如一个实验组的流量为0%或者100%,业务上就没有对照的意义,用户也不会去查,这样的数据就不需要进入聚合表)。
经过这一系列步骤,最终聚合表的数据量被控制在单日约8000万条,并没有因为exp_id打平而膨胀。
值得一提的是,exp_id字段拆分后,除了查询从LIKE匹配变为精确匹配,还额外带来了两项收益:
字段从String类型变为Int类型,作为查询条件时的比对效率变高。
能利用Doris的前缀索引和布隆过滤器等能力,进一步提高查询效率。
>>>> 4.4 使用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等方式聚合:
如此明细表和聚合表的指标计算对应关系如下:
指标计算 | 明细表 | 聚合表 |
[总数] | COUNT(*) | SUM(cnt) |
[总和] | SUM($col) | SUM($col_sum) |
[最大值] | MAX($col) | MAX($col_max) |
[最小值] | MIN($col) | MIN($col_min) |
[去重用户数] | SELECT COUNT(DISTINCT user_id) | BITMAP_UNION_COUNT($bitmap_user_id) |
五、优化效果
5.1. SQL视角
查询请求转换成SQL之后,在明细表和聚合表的表现对比如下:
[总数] -优化前 |
|
[总数] -优化后 |
|
[常规聚合] -优化前 |
|
[常规聚合] -优化后 |
|
[进组人数] -优化前 |
|
[进组人数] -优化前 |
|
常规聚合指标查询的性能提升自不必说(速度提升50~60倍)
进组人数查询性能的提升也非常可观(速度提升10倍左右)
5.2. 集群视角
SQL查询的快进快出,使查询占用的资源能快速释放,对集群压力的缓解也有正向的作用。Doris集群BE节点CPU使用情况和磁盘IO状况的改变效果显著:
CPU使用率 -优化前 | |
CPU使用率 -优化后 | |
磁盘IO -优化前 | |
磁盘IO -优化后 |
需要说明的是,集群状况的改善(包括实验报告查询P95提升)并不全归功于数据预聚合优化工作,这是各方合力协作(如产品业务形态调整,后端查询引擎排队优化,缓存调优,Doris集群调优等)的综合结果。
六、小技巧
由于业务查询需求的多样,在查询明细表时,会出现一个字段既作为维度又作为指标来使用的情况。
如广告业务表中的targetConvNum(目标转化个数)字段,此字段的取值为0和1,查询场景如下:
--作为维度
select targetConvNum,count(distinct user_id)
from doris_xxx_event
where olap_date = 20221105
and event_name='CONVERSION'
and exp_id like '%154556%'
group by targetConvNum;
--作为指标
select sum(targetConvNum)
from doris_xxx_event
where olap_date = 20221105
and event_name='CONVERSION'
and exp_id like '%154556%';
如果这个字段被选取进入聚合表,应该如何处理呢?
我们的处理方式是:
在聚合表中把这类字段建模成维度
聚合表中需要一个计数指标cnt,表示聚合表中一条数据由明细表多少条数据聚合得到
当这类字段被作为指标查询时,可将其与cnt指标配合计算得到正确结果
明细表查询 |
|
对应的聚合表查询 |
|
七、结束语
经过这一系列基于Doris的性能优化和测试,A/B实验场景查询性能的提升超过了我们的预期。值得一提的是,Doris较高的稳定性和完备的监控、分析工具也为我们的优化工作提效不少。希望本次分享可以给有需要的朋友提供一些参考。