做者:陳凱GrowingIO 數據開發工程師,主要負責 SaaS 和 OP 產品數據平臺的開發和設計,目前專攻於微服務、數倉建設方向。java
GrowingIO 天天須要處理近千億的用戶行爲數據,平臺的「事件分析」模塊是使用比較頻繁的功能,簡單且強大。在事件分析中,客戶能夠很靈活地使用多種維度組合去查看某個指標,而且查詢的速度也十分可觀。數組
本文抽取 GrowingIO 在事件分析中的通用數據模型,揭曉該功能背後的存儲模型和實現原理。瀏覽器
在用戶行爲的數據分析中,不管是無埋點,仍是埋點,對於某一條行爲數據的表達形式每每是:「某人」於「某個時間」在「某個維度」下作了「某個動做」「多少次」。緩存
因此在數據統計中,這種表達形式能夠拆解成「指標量」和「維度」,指標量能夠是用戶量、頁面瀏覽量、某個埋點的次數等,維度能夠是時間、城市、瀏覽器、用戶屬性等。數據結構
在海量數據的背景下,如何比較高效地完成指標+維度的計算,一直是大數據分析領域比較熱門的話題,下面將講述在 GrowingIO ,咱們是如何高效解決的。架構
1.從一個數據需求提及📈併發
假設給定以下一組用戶行爲的原始數據:框架
數據含義: 表示某個用戶的某次訪問記錄。(這裏僅列舉了地區和設備維度,固然還會存在瀏覽器、平臺、版本等維度,這裏不一一列舉了。)分佈式
1.1 使用 SQL 分析統計微服務
🤔 如今業務想計算「過去7天」在「地區」維度下,「設備: Mac」的人數是多少?So Easy,一個 SQL 搞定
使用 GrowingIO 平臺的分析工具能夠表示以下:
可是經過 SQL 這種現查的方式,隨着數據量的愈來愈大,幾十億或上百億的時候,對計算所須要的資源和響應時間也會線性地增加,此時客戶在使用平臺工具最直觀的感覺就是「菊花」轉轉轉,圖表一直加載不出來。
1.2 如何使查詢更加高效
1.2.1 堆機器,加資源
最直接粗暴的方式,就是增長更多的計算資源,或者對查詢的結果進行緩存、預熱。可是對於 SaaS 產品來說,在查詢併發比較高的時候,再多的計算資源也會由於查詢排隊而遇到性能瓶頸。
1.2.2 數據分層
😼 在數倉的分層架構中,對於常用的查詢結果,咱們能夠經過離線計算的方式生成了一個結果表「過去7天-地區-設備-指標表」,示例以下:
這樣在特性的查詢場景中,只須要查詢結果表就行,很大的減小了計算量,響應時間也短了很多。
😱 可是業務那邊的需求每每是變幻無窮的,而後一堆統計的需求砸到了你的腦殼上
......
😫 你看了看生成結果表,發現並不能徹底解決這些問題,你以爲須要生成更多的結果表來知足更多的需求。然而到最後你發現有些表竟然僅僅只使用了一次,數倉裏面堆了一堆垃圾。
1.2.3 數據預聚合
和數倉分層的理念相似,對數據進行預計算、預聚合,使用空間換時間的思想加快計算。這也是目前一些主流開源框架的解決思路,好比 SparkSQL 的物化視圖、Kylin的 Cube、Druid 的 Segment 、Carbondata 的 MV 等。
下面使用一張圖展現主要區別:
基於咱們所追求的方式,咱們首先須要尋找一種高效而且靈活的存儲模型。
1.3 優化存儲模型
基於上節中數據預聚合的思路,從預聚合的結果中,咱們不難發現,其中有幾個沒有擺脫的點:
🤔 如何才能更好的讓維度和指標爲所欲爲地組合呢?咱們在預聚合結果的基礎之上作了一些改進:
2. 基於 BitMap 的存儲模型 💻
2.1 縱向存儲維度(人數)
依然以開篇的那組數據爲例,此時將維度進行縱向存儲:
此時想取「地區: 北京」和「設備: Mac」的「用戶量」
OK!這樣能夠很靈活的解決各類維度組合起來的問題了,並且連用戶的羣體也能直接獲取。
😇 可是從表格中發現,用戶存儲「用戶集合」的數據結構尚未解決。那麼既能以相似數組的方式存儲整數值,還能使用交集(and)操做,還須要達到更好的數據壓縮和計算。
此時你應該想到了 BitMap 這種數據結構
至於爲什麼選用 BitMap 的數據結構,以及 BitMap 的功能和基本使用,這裏再也不探討。能夠參考 java 的實現 BitSet 以及優化的庫 RoaringBitmap。
2.2 存儲指標量(次數)
爲了解決存儲指標次數的存儲問題,你須要用一個Map 的結構來存儲「總的次數」: Map<Int, BitMap> (其中key爲次數,value爲符合訪問次數的人)
訪問量表示: 總共訪問「1次」有哪些人,訪問「2次」的有哪些人等等。
此時計算「地區: 北京」和「設備: Mac」的「訪問量」
2.3 使用更優雅的方式存儲次數
在 Map<Int, BitMap> 這個結構中,key 存儲的是 10 進制的數字。這就會致使 Map 的 key 變得特別特別多,因此須要有一種方式來優化一下結構。
方式就是用將 10 進制轉化爲 2 進制的方式去存儲次數,此時 Map 的 key 存儲是 二進制爲 1 的位置:
好比 2 的二進制是: 「10」,從右向左分別表示(下標i從0開始)「第0位是0」,「第1位是1」。因此將key爲1的 bitmap 中存儲這我的。
好比5的二進制是: 「101」,從右向左分別表示「第0位是1」,「第1位是0」,「第2位是1」。因此將 key 爲 0 和 2 的 bitmap 中存儲這我的。
而後將上節 2.2 中結果表示以下:
此時計算「地區: 北京」和「設備: Mac」的「訪問量」
3. 多維度交叉的問題 ⚔️
理想是美好的,可是現實很殘酷。在 2.1 小節的例子中,每一個用戶的維度組合只有一種,可是現實中每每一個用戶行爲可能會存在多種維度組合的狀況。
那麼什麼是維度組合: 一條數據中惟一的全部維度值,稱爲一個組合。
PS: 若是你的系統中某個 ID 的維度組合只有一種。好比某個訂單,一旦生成了,他的價格,商品,物流等信息基本都是固定的。那麼以前的模型基本都能知足大多場景了。
3.1 面臨的問題
🤔 那麼會致使什麼問題呢?此時回到起點,又來了一批用戶行爲的數據以下:
此時多了一個「用戶1」在「杭州」使用了「Windows」。若是按照以前的模型存儲以下:
此時計算「地區: 北京」的用戶:直接返回 [ 1 , 2 , 3 ],問題不大
此時計算「地區: 北京」和「設備: Windows」的用戶
❌ 你會發現,得出的結果是錯的,應該只有「用戶 3 」知足纔對。
3.2 使用維度組合編號的方式解決
其實問題出在將維度分開進行存儲的時候,丟失了「維度組合關係」這個重要的衡量條件。「用戶 1 」雖然在「北京」待過,也使用過「Windows」,可是他卻沒有同時知足這個條件,這就是問題所在。
因此須要一種方式來存儲「維度組合關係」這一重要信息。
將每一個人維度組合進行順序編號,獲得以下結果:
注意:編號是對應到每一個人的,相同的維度組合,編號是同樣的。
此時對應的存儲結構也發生了變化:Map<Short, BitMap>( key 表明編號,value 表明人的集合
此時咱們再來計算「地區: 北京」和 「設備: Windows」維度的用戶
最後獲得的結果就是「用戶3」,算出來的數據就變準確了。
3.3 多維度狀況下計算次數
其實稍微想一下就是兩層的 Map 結構:Map<Int, Map<Short, BitMap>>,好比剛剛的那組數據表示以下:
好比「用戶1」這個用戶,在「編號0」發生的 2 次,在「編號1」發生了 1 次
此時咱們來計算「地區: 北京」和 「設備: Mac」維度的訪問量
4. 簡單的性能對比
環境準備:SparkSQL(local[16], 內存4G), BitMap 單線程計算(內存4G)
場景:簡單的 2 ~ 3 個維度組合求人數、次數,按照值的降序取 Top 1000
x軸含義: 數據量/用戶量。
y軸含義: 計算時間, 單位毫秒。
能夠看到隨着數據量的不斷遞增,SparkSQL 的計算時間也在不斷遞增,可是 BitMap 的計算時間卻相對比較穩定。
5. 總結
BitMap 是一個兼併計算和存儲優點的數據結構,在存儲上百萬甚至上千萬的 ID 時,也能獲得很好的計算效果。
而且當你使用 BitMap 存儲的時候,就已經自然支持不少的業務場景,好比分羣計算、標籤計算、漏斗分析、留存分析、用戶觸達等,由於無需再從新計算人羣。
本篇主要揭曉咱們是如何基於 BitMap 來做爲底層的數據模型,固然在實際應用過程當中還有不少的挑戰,因爲篇幅緣由,這裏就不展開講述了。
如下列出一些咱們後續的工做內容和攻克方向:
bitmap 是以 int 值進行存儲的,可是在實際生產中,你的 ID 數據多是相似 UUID 的這種字符串,那麼須要解決 string 轉惟一 int 的問題。
......
關於 GrowingIO
GrowingIO 是基於用戶行爲數據的增加平臺,國內領先的數據運營解決方案供應商。爲產品、運營、市場、數據團隊及管理者提供客戶數據平臺、獲客分析、產品分析、智能運營等產品和諮詢服務,幫助企業在數據化升級的路上,提高數據驅動能力,實現更好的增加。
若是對咱們的產品感興趣,歡迎點擊此處領取 15 天免費試用!