1. 程式人生 > 其它 >高效大資料開發之 bitmap 思想的應用

高效大資料開發之 bitmap 思想的應用

一、背景

資料倉庫的資料統計,可以歸納為三類:增量類、累計類、留存類。而累計類又分為歷史至今的累計與最近一段時間內的累計(比如滾動月活躍天,滾動周活躍天,最近 N 天消費情況等),藉助 bitmap 思想統計的模型表可以快速統計最近一段時間內的累計類與留存類。

二、業務場景

我們先來看幾個最近一段時間內的累計類與留存類的具體業務問題,作為做大資料的你建議先不要急著往下閱讀,認真思考一下你的實現方案:

1.統計最近 30 天使用者的累計活躍天(每個使用者在 30 天裡有 N 天使用微視 app,N 為 1-30,然後將月活躍使用者的 N 天加總)?

2.統計最近 7 天的使用者累計使用時長?

3.統計最近 30 天有播放的累計使用者數?

4.統計最近 30 天活躍使用者有多少在最近 30 天裡有連續 3 天及以上活躍?

5.統計 28 天前活躍使用者的 1、3、7、14、28 天留存率?

三、傳統解決方案

在進入本文真正主題之前,我們先來看看常規的解決思路:1.統計最近 30 天使用者的累計活躍天?

--用dau表(使用者ID唯一),取最近30天分割槽,sum(活躍日期)。
select
sum(imp_date)active_date
from
weishi_dau_active_table
where
imp_date>=20200701
andimp_date<=20200730

2.統計最近 7 天的使用者累計使用時長?

--用dau表(使用者ID唯一),取最近7天分割槽,sum(使用時長)。
select
sum(log_time)log_time
from
weishi_dau_active_table
where
imp_date>=20200701
andimp_date<=20200707

3.統計最近 30 天有播放的累計使用者數?

--用使用者播放表(使用者ID唯一),取最近30天分割槽,count(distinct if(播放次數>0,使用者ID,null))。
select
count(distinctif(play_vv_begin>0,qimei,null))play_user
from
weishi_play_active_table
where
imp_date>=20200701
andimp_date<=20200730

4.統計最近 30 天活躍使用者有多少在最近 30 天裡有連續 3 天及以上活躍?

--用dau表(使用者ID唯一),取最近30天分割槽,關聯兩次最近30天分割槽,關聯條件右表分別為imp_date-1,imp_date-2。
select
count(distincta.qimei)active_num
from
(select
imp_date
,qimei
from
weishi_dau_active_table
where
imp_date>=20200701
andimp_date<=20200730
)a
join--第一次join,先取出連續2天的使用者,因為7月1日使用者與7月2號-1天關聯得上,表示一個使用者在1號和2號都活躍
(select
date_sub(imp_date,1)imp_date
,qimei
from
weishi_dau_active_table
where
imp_date>=20200701
andimp_date<=20200730
)b
on
a.imp_date=b.imp_date
anda.qimei=b.qimei
join--第二次join,取出連續3天的使用者,因為第一次join已經取出連續兩天活躍的使用者了,再拿這些7月1日使用者關聯7月3日-2天關聯得上,表示一個使用者在1號和3號都活躍,結合第一步join得出使用者至少3天連續活躍了
(select
date_sub(imp_date,2)imp_date
,qimei
from
weishi_dau_active_table
where
imp_date>=20200701
andimp_date<=20200730
)c
on
a.imp_date=c.imp_date
anda.qimei=c.qimei

當然這裡也可以用視窗函式 lead 來實現,通過求每個使用者後 1 條日期與後 2 條日期,再拿這兩個日期分佈 datediff 當前日期是否為日期相差 1 且相差 2 來判斷是否 3 天以上活躍,但是這個方法也還是避免不了拿 30 天分割槽統計,統計更多天連續活躍時的擴充套件性不好的情況 5.統計 28 天前活躍使用者的 1、3、7、14、28 天留存率?

--用dau表(使用者ID唯一),取統計天的活躍使用者 left join 1、3、7、14、28天后的活躍使用者,關聯得上則說明對應天有留存。
select
'20200701'imp_date
,count(distinctif(date_sub=1,b.qimei,null))/count(distincta.qimei)1d_retain_rate
,count(distinctif(date_sub=3,b.qimei,null))/count(distincta.qimei)3d_retain_rate
,count(distinctif(date_sub=7,b.qimei,null))/count(distincta.qimei)7d_retain_rate
,count(distinctif(date_sub=14,b.qimei,null))/count(distincta.qimei)14d_retain_rate
,count(distinctif(date_sub=28,b.qimei,null))/count(distincta.qimei)28d_retain_rate
from
weishi_dau_active_tablepartition(p_20200701)a
leftjoin
(select
datediff(imp_date,'20200701')date_sub
,qimei
from
weishi_dau_active_table
where
datediff(imp_date,'20200701')in(1,3,7,14,28)
)b
on
a.qimeib=b.qimei

四、傳統解決方案存在的問題

1.每天大量中間資料重複計算,比如昨天最近 30 天是 8 月 1 日~ 8 月 30 日,今天最近 30 天為 8 月 2 日~ 8 月 31 日,中間 8 月 2 日~ 8 月 30 日就重複計算了。

2.統計邏輯複雜,類似業務場景 4,困難點在於統計每一天活躍的使用者第二天是否還繼續活躍。

3.耗費叢集資源大,場景 4 和場景 5 都用到了 join 操作,場景 4 還不止一個 join,join 操作涉及 shuffle 操作,shuffle 操作需要大量的網路 IO 操作,因此在叢集中是比較耗效能的,我們應該儘量避免執行這樣的操作。

4.以上統計邏輯可擴充套件性差,由於資料分析經常進行探索性分析,上面傳統方案能解決上面幾個問題,但是資料分析稍微改變一下需求,就得重新開發,例如增加一個 15 天留存,或者統計最近 2 周的活躍天等。

五、bitmap 原理

上面的業務場景能否在一個模型表很簡單就能統計出,且不需要資料重複計算,也不需要 join 操作,還能滿足資料分析更多指標探索分析呢?答案是肯定的,可以藉助 bitmap 思想。

何為 bitmap?bitmap 就是用一個 bit 位來標記某個元素,而陣列下標是該元素,該元素是否存在時用 bit 位的 1,0 表示。比如 10 億個 int 型別的數,如果用 int 陣列儲存的話,那麼需要大約 4G 記憶體,當我們用 int 型別來模擬 bitmap 時,一個 int 4 個位元組共 4*8 = 32 位,可以表示 32 個數,原來 10 億個 int 型別的數用 bitmap 只需要 4GB / 32 = 128 MB 的記憶體。

六、具體實現過程

大資料開發參考 bitmap 思想,就是參考其通過陣列下標表示該元素的思想,將最近 31 天活躍使用者是否活躍用逗號分隔的 0 1 串儲存下來,將最近 31 天的播放 vv、贊轉評等消費數也用逗號分隔的具體數值儲存下來,形成一個字元陣列,陣列每一個下標表示距離最新一天資料的天數差值,第一位下標為 0,表示距離今天最新一天資料間隔為 0 天,如下所示:

active_date_set 表示 31 天活躍集,0 表示對應下標(距離今天的 N 天前)不活躍,1 表示活躍;這個資料是 8 月 23 日統計的,1,0,0,1,…… 即使用者在 8 月 23 日,8 月 20 日有活躍,8 月 22 日,8 月 21 日並沒有活躍。play_vv_begin_set 表示 31 天播放 vv 集,0 表示對應下標(距離今天的 N 天前)沒有播放視訊,正整數表示當天的播放視訊次數;這裡使用者雖然在 8 月 23 日,8 月 20 日有活躍,但是該使用者一天只播放了一次視訊就離開微視了。這樣做的好處一方面也是大大壓縮了儲存,極端狀態下使用者 31 天都來,那麼就可以將 31 行記錄壓縮在一行儲存。

假如 1 天活躍使用者 1 億,且這些使用者 31 天都活躍,那麼就可以將 31 億行記錄壓縮在 1 億行裡,當然遊戲賬號購買地圖實際不會出現這樣的情況,因為會有一部分老使用者流失,一部分新使用者加入,按照目前微視的統計可以節省 80%多的儲存;另一方面可以更簡單快捷地統計每個使用者最近一個月在微視的活躍與播放、消費(贊轉評)等情況。

該模型表的詳細實現過程如下:

1.該模型表的前 31 天需要初始化一個集合,將第一天的資料寫到該表,然後一天一天滾動壘起來,累計 31 天之後就得到這個可用的集合表了,也就可以例行化跑下去。

2.最新一天需要統計時,需要拿前一天的集合表,剔除掉相對今天來說第 31 天前的資料,然後每個集合欄位將最後一位刪除掉 。

3.拿最新一天的增量資料(下面用 A 表替代) fulljoin 第 2 步處理後的前一天表(下面用 B 表替代)關聯。

這裡有三種情況需要處理:

a.既出現在 A 表,也出現在 B 表,這種情況,只需直接拼接 A 表的最新值與 B 表的陣列集即可(在微視裡就是最近 30 天使用者有活躍,且在最新一天有留存);

b.只出現在 B 表(在微視裡是最近 30 天活躍的使用者在最新一天沒留存),這時需要拿 “0,” 拼接一個 B 表的陣列集,“0,” 放在第一位;

c.只出現在 A 表(在微視裡是新使用者或者 31 天前活躍的迴流使用者),這時需要拿 “1,”拼接一個 30 位長的預設陣列集 “0,0,0,…,0,0” ,“1,” 放在第一位。經過如此幾步,就可以生成最新一天的集合表了,具體脫敏程式碼如下:

select
20200823imp_date
,nvl(a.qimei,b.qimei)qimei
,case
whena.qimei=b.qimeithenconcat(b.active_date_set,',',a.active_date_set)
whenb.qimeiisnullthenconcat('0,',a.active_date_set)
whena.qimeiisnullthenconcat(b.active_date_set,',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')
endactive_date_set
,case
whena.qimei=b.qimeithenconcat(b.log_num_set,',',a.log_num_set)
whenb.qimeiisnullthenconcat('0,',a.log_num_set)
whena.qimeiisnullthenconcat(b.log_num_set,',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')
endlog_num_set
,case
whena.qimei=b.qimeithenconcat(b.log_time_set,',',a.log_time_set)
whenb.qimeiisnullthenconcat('0,',a.log_time_set)
whena.qimeiisnullthenconcat(b.log_time_set,',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')
endlog_time_set
,case
whena.qimei=b.qimeithenconcat(b.play_vv_begin_set,',',a.play_vv_begin_set)
whenb.qimeiisnullthenconcat('0,',a.play_vv_begin_set)
whena.qimeiisnullthenconcat(b.play_vv_begin_set,',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')
endplay_vv_begin_set
from
(select
qimei
,substr(active_date_set,1,instr(active_date_set,',',1,30)-1)active_date_set
,substr(log_num_set,1,instr(log_num_set,',',1,30)-1)log_num_set
,substr(log_time_set,1,instr(log_time_set,',',1,30)-1)log_time_set
,substr(play_vv_begin_set,1,instr(play_vv_begin_set,',',1,30)-1)play_vv_begin_set
from
weishi_31d_active_set_tablepartition(p_20200822)a
where
last_time>=20200723
)a
fulljoin
(select
qimei
,'1'active_date_set
,cast(log_numasstring)log_num_set
,cast(log_timeasstring)log_time_set
,cast(play_vv_beginasstring)play_vv_begin_set
from
weishi_dau_active_tablepartition(p_20200823)a
)b
on
a.qimei=b.qimei

初始化集合程式碼相對簡單,只需保留第一位為實際數值,然後拼接一個 30 位的預設值 0 串,初始化脫敏程式碼如下:

select
20200823imp_date
,qimei
,'1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0'active_date_set
,concat(cast(log_numasstring),',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')log_num_set
,concat(cast(log_timeasstring),',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')log_time_set
,concat(cast(play_vv_beginasstring),',0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0')play_vv_begin_set
from
weishi_dau_active_tablepartition(p_20200823)a

七、具體使用案例

在 hive 裡對這些 0 1 集合串的使用是比較困難的,為了讓這個模型表的可用性更高,因此寫了幾個 UDF 函式來直接對陣列集合進行簡單地運算,目前寫了如下幾個:str_sum()、str_count()、str_min()、str_max(),其中 str_sum、str_min、str_max 這幾個函式的引數一樣,第一個傳入一個數組集合字串,第二位傳入一個整數,代表要計算最近 N 天的結果,第三個引數是傳入一個分隔符,在本模型裡分隔符均為逗號“,”。

這幾個函式都是返回一個 int 值,str_sum 返回來的是最近 N 天的數值加總,str_min 返回該陣列集合元素裡最小的值,str_max 返回該陣列集合元素裡最大的值;str_count 前 3 個引數與前面三個函式一樣,第 4 個引數是傳入要統計的值,返回來的也是 int 值,返回傳入的統計值在陣列集合出現的次數,具體使用方法如下,由於是自定義函式,在 tdw 叢集跑的 sql 前面需加@pyspark:

以上函式的具體使用案例脫敏程式碼如下:

@pysparkselect
qimei
,str_sum(active_date_set,30,',')active_date_num--每個使用者最近30天活躍天數
,str_sum(play_vv_begin_set,30,',')play_vv_begin--每個使用者最近30天播放視訊次數
,30-str_count(interact_num_set,30,',','0')interact_date_num--每個使用者最近30天有互動的天數,通過30-互動天數為0統計得到
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200724

當然除了上面幾種 udf 統計所需指標之外,也可以通過正則表示式進行使用,比如統計活躍天可以這樣統計:

--將陣列集合裡的'0'和','用正則表示式匹配去掉再來看剩下1的個數即可。
select
count(qimei)--月活
,sum(length(regexp_replace(substr(active_date_set,1,60),'0|,','')))active_date_num--月活躍天
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200724

開篇前的幾個業務場景,也可以通過該錶快速統計:1.統計最近 30 天使用者的累計活躍天?

@pyspark
select
sum(active_date_num)active_date_num--滾動月活躍天
,count(1)uv--滾動月活
from
(select
qimei
,str_sum(active_date_set,30,',')active_date_num
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200724
)a

2.統計最近 7 天的使用者累計使用時長?

@pyspark
select
sum(log_time)log_time--滾動周活躍天
,count(1)uv--滾動周活
from
(select
qimei
,str_sum(log_time_set,7,',')log_time
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200817
)a

3.統計最近 30 天有播放的累計使用者數?

@pyspark
select
count(1)uv--播放次數>0
from
(select
qimei
,str_sum(play_vv_begin_set,30,',','0')play_vv_begin
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200724
)a
where
play_vv_begin>0

4.統計最近 30 天活躍使用者有多少在最近 30 天裡有連續 3 天及以上活躍?

--只是判斷活躍集合裡面有連續3位1,1,1,即可select
count(if(substr(active_date_set,1,60)like'%1,1,1,%',qimei,null))active_date_num
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200724

5.統計 28 天前活躍使用者的 1、3、7、14、28 天留存率?

--不需要join操作,只需找到活躍日期集對應位是否1即可select
'20200723'imp_date
,count(if(split(active_date_set,',')['29']='1',qimei,null))/count(1)1d_retain_rate
,count(if(split(active_date_set,',')['27']='1',qimei,null))/count(1)3d_retain_rate
,count(if(split(active_date_set,',')['23']='1',qimei,null))/count(1)7d_retain_rate
,count(if(split(active_date_set,',')['16']='1',qimei,null))/count(1)14d_retain_rate
,count(if(split(active_date_set,',')['2']='1',qimei,null))/count(1)28d_retain_rate
from
weishi_31d_active_set_tablepartition(p_20200823)a
where
last_time>20200723
andsplit(active_date_set,',')['30']='1'

八、總結

從上面 5 個業務場景可以看出來,只要有這樣一個藉助 bitmap 思想統計的模型表,不管統計最近一段時間的累計(月活躍天、月播放使用者等)與統計 1 個月內的留存,都可以一條簡單語句即可統計,不需要 join 操作,每天例行化跑時不需要重複跑接近一個月的分割槽,1 個月內可以支援任意統計,比如只需最近 2 周的活躍天等,因此這樣的模型相對通用,另外如果業務需要用到 2 個月的資料,也可以將模型從 31 位擴充套件到 61 位。

當然任何事情不可能只有優點,而不存在缺點的情況,這裡這個優化的模型只是參考了 bitmap 思想,並不是 bitmap 方案實現,雖然可以將 31 天活躍使用者壓縮 80%多儲存,但是每天都儲存 31 天活躍使用者的壓縮資料,因此相比之前只保留天增量表來說,還是增加了實際儲存空間,但是這個以儲存換計算的方案是符合數倉設計原則的,因為計算是用成本昂貴的 cpu 和記憶體資源,儲存是用成本低廉的磁碟資源,因此有涉及最近 N 天累計或者留存計算需求的朋友可以借鑑這樣的思路。