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