廣告案例|10億數(shù)據(jù)、查詢<10s,論基于OLAP搭建廣告系統(tǒng)的正確姿勢
由于流量紅利逐漸消退,越來越多的廣告企業(yè)和從業(yè)者開始探索精細(xì)化營銷的新路徑,取代以往的全流量、粗放式的廣告轟炸。精細(xì)化營銷意味著要在數(shù)以億計(jì)的人群中優(yōu)選出那些最具潛力的目標(biāo)受眾,這無疑對提供基礎(chǔ)引擎支持的數(shù)據(jù)倉庫能力,提出了極大的技術(shù)挑戰(zhàn)。
背景
人群圈選分析是客戶畫像平臺(tái)(CDP)中的核心功能。分析師利用各種標(biāo)簽組合,挑選出最合適的人群,進(jìn)而進(jìn)行廣告推送,達(dá)到精準(zhǔn)投放的效果。同時(shí)由于人群查詢在不同標(biāo)簽組合下的結(jié)果集大小不同,在一次廣告投放中,分析師需要經(jīng)過多次的邏輯調(diào)整,以獲得"最好"的人群包。在這種高頻的操作下,畫像平臺(tái)通常會(huì)遇到兩方面的問題:
- 第一,由于此類查詢分析是臨時(shí)性的,各種標(biāo)簽組合數(shù)巨大,離線預(yù)計(jì)算無法滿足此類靈活性。
- 第二,由于此類查詢是實(shí)時(shí)場景,查詢性能變得非常關(guān)鍵, 通常一次查詢在分鐘級(jí),耗時(shí)較長,無法滿足分析師需求。
這篇文章中,我們將會(huì)分享人群圈選查詢在實(shí)時(shí)分析OLAP場景下的解決思路,同時(shí)介紹如何利用ByteHouse來加速此類查詢。從數(shù)據(jù)表現(xiàn)上看,在10億級(jí)用戶測試數(shù)據(jù)下,ByteHouse的人群查詢P99小于10s,展現(xiàn)了優(yōu)異的性能。
場景模型
一個(gè)支持人群圈選的數(shù)據(jù)架構(gòu)大致如下:
圖片
用戶的注冊信息通過用戶流進(jìn)入數(shù)據(jù)湖,同時(shí)用戶的行為信息通過事件流進(jìn)入數(shù)據(jù)湖。之后通過標(biāo)簽生產(chǎn)任務(wù),我們?yōu)槊總€(gè)用戶打上標(biāo)簽。
由于即時(shí)查詢的實(shí)時(shí)性和靈活性,轉(zhuǎn)化好的數(shù)據(jù)通常會(huì)寫入OLAP引擎,例如ByteHouse,以提供靈活且實(shí)時(shí)的SQL查詢。用戶在分析時(shí),一般會(huì)從畫像平臺(tái)應(yīng)用界面去可視化構(gòu)建標(biāo)簽邏輯,再由平臺(tái)應(yīng)用將這些邏輯轉(zhuǎn)化成SQL,發(fā)給ByteHouse進(jìn)行處理。
從數(shù)據(jù)模型上看, 數(shù)據(jù)倉庫或者數(shù)據(jù)湖里存儲(chǔ)的格式多數(shù)以id-tag為主,例如:
user_id | sex | age | tags |
10001 | F | 20 | [] |
10002 | M | 22 | [tag_1,tag_2] |
10003 | F | 23 | [tag_1] |
10004 | M | 24 | [tag_2] |
10005 | F | 25 | [tag_1,tag_2] |
在人群分析中,以下以tag為主的模式會(huì)更合適,例如:
tags | active_users |
tag_1 | [10002,10003,10005] |
tag_2 | [10002,10005] |
數(shù)據(jù)是通常是基于用戶作為主體存儲(chǔ),這種情況導(dǎo)致用戶數(shù)量非常多,同時(shí)存在很多不必要字段。那么當(dāng)用戶通過組合標(biāo)簽(tag) 過濾人群時(shí),幾乎所有的行都需要被掃描, 使得性能開銷隨著標(biāo)簽和用戶的增長越來越大。
當(dāng)數(shù)據(jù)以標(biāo)簽作為主體時(shí),有兩個(gè)比較大的改動(dòng):
- 其一,只有跟人群相關(guān)的維度會(huì)被保留,其他信息例如sex,age等會(huì)被移除。
- 其二,active_users以數(shù)組(array)的形式存放所有的用戶id, 這種操作帶來的一個(gè)重要的收益是減少了行數(shù),同時(shí)減少了數(shù)據(jù)大小。
在這種模型下, 根據(jù)tag組合選取用戶就會(huì)變成集合的交并補(bǔ)操作,性能對比第一種模型會(huì)有顯著提升。
ByteHouse Bitmap類型
第二種存儲(chǔ)模型可以用如下ByteHouse SQL建表:
CREATE TABLE id_tags (
tags String,
active_users Array<UInt64>
) Engine = CnchMergeTree() order by tags
人群圈選查詢,例如找到同時(shí)滿足tag_1和tag_2的人群的數(shù)量,可以用如下SQL完成:
WITH (SELECT active_users as tag_1
FROM id_tags
WHERE tags = 'tag_1') as tag_1_user,
WITH(SELECT active_users as tag_2
FROM id_tags
WHERE tags = 'tag_2') as tag_2_user,
SELECT length(arrayIntersect(tag_1_user, tag_2_user))
雖然該模型可以簡化部分操作,但是每個(gè)tag的選取需要有一個(gè)子查詢(with 部分)。這種方式對于表的掃描有大量浪費(fèi),而且跟標(biāo)簽的數(shù)量線性相關(guān)。
為了解決這個(gè)問題,ByteHouse內(nèi)置BitMap類型,可以直接用位(bit)來表示一個(gè)tag是否能存在。
沿用以上例子, 在利用BitMap后,建表語句改為:
CREATE TABLE id_tags (
tags String,
active_users BitMap64
) Engine = CnchMergeTree() order by tags
此處注意,我們只是將active_users的類型由Array改成 BitMap64,其余的部分沒有變動(dòng)。
對于同樣的“找到同時(shí)滿足tag_1和tag_2的人群的數(shù)量”的查詢,用以下查詢:
SELECT bitmapCount('tag_1&tag_2')
FROM tag_uids_map
我們用bit代替了原始的數(shù)組,使得該查詢可以被優(yōu)化到在一次表掃描中完成。
基于字節(jié)跳動(dòng)內(nèi)部線上場景,我們觀測到上述的查詢優(yōu)化在多標(biāo)簽場景下,能有10~50倍的性能提升。
數(shù)據(jù)導(dǎo)入
寫入數(shù)據(jù)進(jìn)入bitmap表跟普通表沒有顯著差異。例如,小批量insert的方式可以用如下方式:
INSERT INTO TABLE id_tags values ('tag_1', [2,4,6]),('tag_2', [1,3,5])
因?yàn)閕d_tags中active_users定義為BitMap64的類型, 數(shù)組值[1,3,5], [2,4,6]會(huì)被自動(dòng)轉(zhuǎn)化為BitMap64。之后的計(jì)算和存儲(chǔ)都會(huì)是BitMap64類型。
大批量文件導(dǎo)入時(shí),我們可以利用ByteHouse提供的導(dǎo)入服務(wù),目前離線(TOS, LASFS)以及實(shí)時(shí)(Kafka)等導(dǎo)入模式均已支持BitMap數(shù)據(jù)導(dǎo)入。流式寫入(如Flink直寫)可以通過JDBC接口用insert的方式寫入。
相關(guān)函數(shù)
ByteHouse除了支持BitMap類型的數(shù)據(jù)進(jìn)行交并補(bǔ)操作,也內(nèi)置了大量的列函數(shù),例如bitmapColumnAnd
用來接收一個(gè)bitmap列,對該列所有bitmap做and
運(yùn)算;以及bitmapColumnCardinality
用來返回一個(gè)列中所有bitmap的元素個(gè)數(shù)。詳情可以參考官方文檔。
BitEngine原理介紹
BitMap結(jié)構(gòu)解析
假設(shè)一個(gè)用戶ID用32位unsigned integer表示, 那么使用常規(guī)bit存儲(chǔ)的方式需要2^32 bits ~ 512MB 的空間。如果需要為每個(gè)標(biāo)簽對應(yīng)512MB空間,在標(biāo)簽量增長時(shí),存儲(chǔ)量會(huì)變得巨大。實(shí)際上,很少有業(yè)務(wù)會(huì)遇到2^32 大約40億用戶,因此實(shí)際場景中用戶ID的分布是很稀疏的。
我們可以基于這個(gè)特性,利用Roaring bitmap來進(jìn)一步壓縮這個(gè)空間。如下圖所示:
圖片
在32位的Roaring bitmap中,前16位用于分桶,該取值范圍內(nèi)沒有數(shù)據(jù)則bucket不會(huì)被創(chuàng)建,后16位存在對應(yīng)的container中。Container有兩種類型:
- Array container: 數(shù)據(jù)量較少的時(shí)候(一般少于8K容量),更省空間
- Bitmap container 適合存儲(chǔ)稠密數(shù)據(jù)、占用空間小
在計(jì)算的時(shí)候只要對某些bucket中的值進(jìn)行計(jì)算即可。擴(kuò)展到64位的roaringbitmap的時(shí)候,我們可以通過一個(gè)map<uint32_t, Roaring>來支持,前32位作為map的key,后32位用roaringbitmap存儲(chǔ)。
字典優(yōu)化
在大部分場景中,以上的roaring bitmap已經(jīng)有很好的性能。但是在字節(jié)的實(shí)際場景中,我們發(fā)現(xiàn)由于user_id 不是連續(xù)生成的,array container的數(shù)量占比會(huì)很高。對兩個(gè)稀疏人群的交并補(bǔ)操作就變成了對兩個(gè)有序數(shù)組的計(jì)算,這種計(jì)算對比單純的位計(jì)算,在性能上還是有明顯的差異。
因此在ByteHouse中,我們通過字典方式,對數(shù)據(jù)進(jìn)行編碼,讓數(shù)據(jù)更加集中。
開啟字典優(yōu)化的方式如下:
CREATE TABLE id_tags (
tags String,
active_users BitMap64 BitEngineEncode
) Engine = CnchMergeTree() order by tags
本質(zhì)上字典服務(wù)是個(gè)onto映射, 可以通過key 查找value, 也可以通過value反查key, 其中key原始值,value時(shí)編碼值。開啟編碼之后,ByteHouse會(huì)依賴一個(gè)字典文件。在默認(rèn)情況下,ByteHouse會(huì)在內(nèi)部維護(hù)一個(gè)字典文件。
當(dāng)?shù)妆砀聲r(shí),內(nèi)部字典文件也會(huì)隨之異步更新。ByteHouse同時(shí)也支持用戶維護(hù)外部字典,這里不做展開。
總結(jié)
人群分析是畫像平臺(tái)的基礎(chǔ)功能,本文介紹了如何利用ByteHouse內(nèi)置的BitMap類型來支持實(shí)時(shí)的畫像查詢分析。目前ByteHouse云數(shù)倉以及企業(yè)版均已登陸火山引擎。未來,火山引擎將通過 ByteHouse 來為客戶持續(xù)提供字節(jié)跳動(dòng)和外部最佳實(shí)踐,構(gòu)建交互式大數(shù)據(jù)分析平臺(tái),以應(yīng)對復(fù)雜多變的業(yè)務(wù)需求和高速增長的數(shù)據(jù)場景。