什麼是留存,好比在20200701這天操做了「點擊banner」的用戶有100個,這部分用戶在20200702這天操做了「點擊app簽到」的有20個,那麼對於分析時間是20200701,且「點擊banner」的用戶在第二天「點擊app簽到」的留存率是20%。javascript
關於用戶留存模型是各大商業數據分析平臺必不可少的功能,企業通常用該模型衡量用戶的活躍狀況,也是能直接反應產品功能價值的直接指標;如,boss想要了解商城改版後,對用戶加購以及後續下單狀況的影響等。以下圖,這就是一個典型的留存分析功能:
java
問題
一般實現上述需求的傳統作法是多表關聯,瞭解clickhouse的攻城獅都清楚,多表關聯簡直就是clickhouse的天敵;如一張用戶行爲日誌表中至少包含:用戶id、行爲事件、操做時間、地點屬性等,想分析20200909日河南省註冊用戶第二天的下單狀況,那麼SQL通常會這麼寫:算法
select count(distinct t1.uid) r1, count(distinct t2.uid) r2 from( select uid from action_log where day='20200909' and action='login' and province='河南省') as t1 left join( select uid from action_log where day='20200910' and action='order' and province='河南省') as t2using uid
這種方式書寫簡單、好理解,可是性能會不好,在超大數據集上進行運算是不只僅影響用戶體驗,還會因長期佔有物理資源而拖垮整個clickhouse上的業務。sql
解決方法有兩種:shell
使用clickhouse自帶的retention函數數據庫
Roaringbitmap 經過對數據進行壓縮和位運算提升查詢性能小程序
Roaringbitmap
經過Roaringbitmap進行用戶行爲分析是騰訊廣告業務中經常使用的一種實現方案,點擊查看 ,文章中內容較多這裏挑選乾貨進行講解:數組
bitmap能夠理解爲一個長度很長且只存儲0/1數字的集合,如某個用戶經過特定的哈希算法映射到位圖內時,那麼該位置就會被置爲1,不然爲0;經過這種方式對數據進行壓縮,空間利用率可提示數十倍,數據能夠很容易被系統cache,大大減小了IO操做。微信
在查詢以前須要先對數據進行預處理,這裏額外構建兩張表,用來存儲用戶的位圖信息。session
用戶行爲日誌表:
table_oper_bit
向位圖表插入數據,原始數據十幾億,插入後結果只有幾萬行,並且隨着數據範圍的再擴大,位圖表的數據增量變化也不會很明顯
用戶基本信息表:table_attribute_bit
同理table_attribute_bit插入後數據也獲得了極大的壓縮,最終數據以下圖:
應用案例
a. 操做了某個行爲的用戶在後續某一天操做了另外一個行爲的留存:
如「20200701點擊了banner的用戶在第二天點擊app簽到的留存人數」,就能夠用如下的sql快速求解:
b. 操做了某個行爲而且帶有某個屬性的用戶在後續的某一天操做了另外一個行爲的留存:
如「20200701點擊了banner且來自廣東/江西/河南的用戶在第二天點擊app簽到的留存人數」:
c. 操做了某個行爲而且帶有某幾個屬性的用戶在後續的某一天操做了另外一個行爲的留存:
如「20200701點擊了banner、來自廣東且新進渠道是小米商店的用戶在第二天點擊app簽到的留存人數」:
其中bitmapCardinality用來計算位圖中不重複數據個數,在大數據量下會有必定的數據偏差,bitmapAnd用來計算兩個bitmap的與操做,即返回同時出如今兩個bitmap中用戶數量
查詢速度
clickhouse集羣現狀:12核125G內存機器10臺。
clickhouse版本:20.4.7.67。
查詢的表都存放在其中一臺機器上。
測試了查詢在20200701操做了行爲oper_name_1(用戶數量級爲3000+w)的用戶在後續7天內天天操做了另外一個行爲oper_name_2(用戶數量級爲2700+w)的留存數據(用戶重合度在1000w以上),耗時0.2秒左右
該方法的確比較靈活,不只僅能解決留存問題,還有不少關於事件分析的需求等待咱們去探索;然而它的缺點是操做複雜,且不支持對實時數據的分析
retention
經過上面的例子不難看出,騰訊的作法雖然提高了查詢的性能,可是操做過於複雜,不便於用戶理解和後期的維護;關於這些痛點易企秀數倉這邊作法是採用retention進行實現
retention function是clickhouse中高級聚合函數,較bitmap的方式實現留存分析會更加簡單、高效;語法以下:
retention(cond1, cond2, ..., cond32);# cond 爲判斷條件# 支持最長32個參數的輸入,也就是說 至少支持一個完整天然月的留存分析查詢
其中知足條件1的數據會置爲1,以後的每個表達式成立的前提都要創建在條件1成立的基礎之上,這正好符合咱們對留存模型的定義
那麼咱們還以上面的3個場景爲例方便對比說明:
20200701點擊了banner的用戶在第二天點擊app簽到的留存人數
SELECT sum(r[1]) AS r1, sum(r[2]) AS r2, r2/r1FROM(SELECT uid, retention(date = '20200701' and type='點擊banner', date = '20200702' and type='點擊app簽到' ) AS rFROM action_logWHERE date IN ('20200701', '20200702')GROUP BY uid )
20200701點擊了banner且來自廣東/江西/河南的用戶在第二天點擊app簽到的留存人數
SELECT sum(r[1]) AS r1, sum(r[2]) AS r2, r2/r1FROM(SELECT uid, retention(date = '20200701' and type='點擊banner', date = '20200702' and type='點擊app簽到' ) AS rFROM action_logWHERE date IN ('20200701', '20200702') and province IN ('廣東', '江西', '河南')GROUP BY uid )
按照上面的方式第三個場景也能很快實現,這裏留給你們去嘗試...
不過該方式與bitmap比也有缺陷,那就是若是用戶日誌表中不存儲用戶屬性信息時,就須要與用戶屬性表進行關聯查詢,兩張大表關聯,查詢性能會至關慢。
什麼是有序漏斗,有序漏斗須要知足全部用戶事件鏈上的操做都是逡巡時間前後關係的,且漏斗事件不能有斷層,觸達當前事件層的用戶也須要經歷前面的事件層
接上一章智能路徑分析,假設咱們已經獲得了觸達支付購買的路徑有 「首頁->詳情頁->購買頁->支付「 和 「搜索頁->詳情頁->購買頁->支付「 兩個主要路徑,可是咱們不清楚哪條路徑轉化率高,那麼這個時候漏斗分析就派上用場了
漏斗模型是一個倒置的金字塔形狀,主要用來分析頁面與頁面 功能模塊以前的轉化狀況,下面一層都是基於緊鄰的上一層轉化而來的,也就是說前一個條件是後一個條件成立的基礎;解決此類場景clickhouse提供了一個名叫windowFunnel的函數來實現:
windowFunnel(window)(timestamp, cond1, cond2, ..., condN)
window:
窗口大小,從第一個事件開始,日後推移一個窗口大小來提取事件數據
timestamp:
能夠是時間或時間戳類型,用來對時事件進行排序
cond:
每層知足的事件
爲了便於你們理解,這裏舉個簡單的栗子:
CREATE TABLE test.action( `uid` Int32, `event_type` String, `time` datetime)ENGINE = MergeTree()PARTITION BY uidORDER BY xxHash32(uid)SAMPLE BY xxHash32(uid)SETTINGS index_granularity = 8192
插入測試數據
insert into action values(1,'瀏覽','2020-01-02 11:00:00');insert into action values(1,'點擊','2020-01-02 11:10:00');insert into action values(1,'下單','2020-01-02 11:20:00');insert into action values(1,'支付','2020-01-02 11:30:00');
insert into action values(2,'下單','2020-01-02 11:00:00');insert into action values(2,'支付','2020-01-02 11:10:00');
insert into action values(1,'瀏覽','2020-01-02 11:00:00');
insert into action values(3,'瀏覽','2020-01-02 11:20:00');insert into action values(3,'點擊','2020-01-02 12:00:00');
insert into action values(4,'瀏覽','2020-01-02 11:50:00');insert into action values(4,'點擊','2020-01-02 12:00:00');
insert into action values(5,'瀏覽','2020-01-02 11:50:00');insert into action values(5,'點擊','2020-01-02 12:00:00');insert into action values(5,'下單','2020-01-02 11:10:00');
insert into action values(6,'瀏覽','2020-01-02 11:50:00');insert into action values(6,'點擊','2020-01-02 12:00:00');insert into action values(6,'下單','2020-01-02 12:10:00');
已30分鐘做爲一個時間窗口,看下windowFunnel返回了什麼樣的數據
SELECT user_id, windowFunnel(1800)(time, event_type = '瀏覽', event_type = '點擊', event_type = '下單', event_type = '支付') AS levelFROM ( SELECT time, event_type, uid AS user_id FROM action)GROUP BY user_id
┌─user_id─┬─level─┐│ 3 │ 1 ││ 2 │ 0 ││ 5 │ 2 ││ 1 │ 4 ││ 6 │ 3 │└─────────┴───────┘
這裏level只記錄了路徑中最後一次事件所屬的層級,若是直接對level分組統計就會丟失以前的層級數據,致使漏斗不能呈現金字塔狀
模型
繼續使用上面的測試數據,經過數組的高階函數對上述結果數據進行二次加工處理以獲取完整漏斗展現效果。
案例
分析"2020-01-02"這天 路徑爲「瀏覽->點擊->下單->支付」的轉化狀況。
SELECT level_index,count(1) FROM( SELECT user_id, arrayWithConstant(level, 1) levels, arrayJoin(arrayEnumerate( levels )) level_index FROM ( SELECT user_id, windowFunnel(1800)( time, event_type = '瀏覽', event_type = '點擊' , event_type = '下單', event_type = '支付' ) AS level FROM ( SELECT time, event_type , uid as user_id FROM test.action WHERE toDate(time) = '2020-01-02' ) GROUP BY user_id ))group by level_indexORDER BY level_index
爲何要有路徑分析,舉個最簡單的例子,你的領導想要知道用戶在完成下單前的一個小時都作了什麼?絕大多數人拿到這個需求的作法就是進行數據抽樣觀察以及進行一些簡單的問卷調參工做,這種方式不但費時費力還不具備表明性,那麼這個時候你就須要一套用戶行爲路徑分析的模型做爲支撐,才能快速幫組你找到最佳答案
clickhouse是我見過最完美的OLAP數據庫,它不只將性能發揮到了極致,還在數據分析層面作了大量改進和支撐,爲用戶提供了大量的高級聚合函數和基於數組的高階lambda函數。
企業中經常使用的路徑分析模型通常有兩種:
已經明確了要分析的路徑,須要看下這些訪問路徑上的用戶數據:關鍵路徑分析
不肯定有哪些路徑,可是清楚目標路徑是什麼,須要知道用戶在指定時間範圍內都是經過哪些途徑觸達目標路徑的:智能路徑分析
關鍵路徑分析
由於咱們接下來要經過sequenceCount完成模型的開發,因此須要先來了解一下該函數的使用:
sequenceCount(pattern)(timestamp, cond1, cond2, ...)
該函數經過pattern指定事件鏈,當用戶行爲徹底知足事件鏈的定義是會+1;其中time時間類型或時間戳,單位是秒,若是兩個事件發生在同一秒時,是沒法準確區分事件的發生前後關係的,因此會存在必定的偏差。
pattern支持3中匹配模式:
(?N):表示時間序列中的第N個事件,從1開始,最長支持32個條件輸入;如,(?1)對應的是cond1
(?t op secs):插入兩個事件之間,表示它們發生時須要知足的時間條件(單位爲秒),支持 >=, >, <, <= 。例如上述SQL中,(?1)(?t<=15)(?2)即表示事件1和2發生的時間間隔在15秒之內,期間可能會發生若干次非指定事件。
.*:表示任意的非指定事件。
例如,boos要看在會員購買頁超過10分鐘才下單的用戶數據 那麼就能夠這麼寫
SELECT count(1) AS c1, sum(cn) AS c2FROM ( SELECT u_i, sequenceCount('(?1)(?t>600)(?2)')(toDateTime(time), act = '會員購買頁', act = '會員支付成功') AS cn FROM app.scene_tracker WHERE day = '2020-09-07' GROUP BY u_i)WHERE cn >= 1
┌──c1─┬──c2─┐│ 102 │ 109 │└─────┴─────┘
根據上面數據能夠看出完成支付以前在會員購買頁停留超過10分鐘的用戶有100多個,那麼是什麼緣由致使用戶遲遲不願下單,接下來咱們就可使用智能路徑針對這100個用戶展開分析,看看他們在此期間都作了什麼。
智能路徑分析
智能路徑分析模型比較複雜,但同時支持的分析需求也會更加複雜,如分析給按期望的路徑終點、途經點和最大事件時間間隔,統計出每條路徑的用戶數,並按照用戶數對路徑進行倒序排列
雖然clickhouse沒有提供現成的分析函數支持到該場景,可是能夠經過clickhouse提供的高階數組函數進行曲線救國,大體SQL以下:
方案一
SELECT result_chain, uniqCombined(user_id) AS user_countFROM ( WITH toDateTime(maxIf(time, act = '會員支付成功')) AS end_event_maxt, arrayCompact(arraySort( x -> x.1, arrayFilter( x -> x.1 <= end_event_maxt, groupArray((toDateTime(time), (act, page_name))) ) )) AS sorted_events, arrayEnumerate(sorted_events) AS event_idxs, arrayFilter( (x, y, z) -> z.1 <= end_event_maxt AND (z.2.1 = '會員支付成功' OR y > 600), event_idxs, arrayDifference(sorted_events.1), sorted_events ) AS gap_idxs, arrayMap(x -> x + 1, gap_idxs) AS gap_idxs_, arrayMap(x -> if(has(gap_idxs_, x), 1, 0), event_idxs) AS gap_masks, arraySplit((x, y) -> y, sorted_events, gap_masks) AS split_events SELECT user_id, arrayJoin(split_events) AS event_chain_, arrayCompact(event_chain_.2) AS event_chain, hasAll(event_chain, [('pay_button_click', '會員購買頁')]) AS has_midway_hit, arrayStringConcat(arrayMap( x -> concat(x.1, '#', x.2), event_chain ), ' -> ') AS result_chain FROM ( SELECT time,act,page_name,u_i as user_id FROM app.scene_tracker WHERE toDate(time) >= '2020-09-30' AND toDate(time) <= '2020-10-02' AND user_id IN (10266,10022,10339,10030) ) GROUP BY user_id HAVING length(event_chain) > 1)WHERE event_chain[length(event_chain)].1 = '會員支付成功' AND has_midway_hit = 1 GROUP BY result_chainORDER BY user_count DESC LIMIT 20;
實現思路:
將用戶的行爲用groupArray函數整理成<時間, <事件名, 頁面名>>的元組,並用arraySort函數按時間升序排序;
利用arrayEnumerate函數獲取原始行爲鏈的下標數組;
利用arrayFilter和arrayDifference函數,過濾出原始行爲鏈中的分界點下標。分界點的條件是路徑終點或者時間差大於最大間隔;
利用arrayMap和has函數獲取下標數組的掩碼(由0和1組成的序列),用於最終切分,1表示分界點;
調用arraySplit函數將原始行爲鏈按分界點切分紅單次訪問的行爲鏈。注意該函數會將分界點做爲新鏈的起始點,因此前面要將分界點的下標加1;
調用arrayJoin和arrayCompact函數將事件鏈的數組打平成多行單列,並去除相鄰重複項。
調用hasAll函數肯定是否所有存在指定的途經點。若是要求有任意一個途經點存在便可,就換用hasAny函數。固然,也能夠修改WHERE謂詞來排除指定的途經點。
將最終結果整理成可讀的字符串,按行爲鏈統計用戶基數,完成。
方案二
不設置途經點,且僅以用戶最後一次到達目標事件做爲參考
SELECT result_chain, uniqCombined(user_id) AS user_count FROM ( select u_i as user_id, arrayStringConcat( #獲取訪問路徑字符串 arrayCompact( #相鄰事件去重 arrayMap( b - > tupleElement(b, 1), arraySort( #對用戶事件進行排序獲得用戶日誌的前後順序 y - > tupleElement(y, 2), arrayFilter( (x, y) - > y - x.2 > 3600 #找到目標節點前1小時內的全部事件 arrayMap( (x, y) - > (x, y), groupArray(e_t), groupArray(time) ), arrayWithConstant( length(groupArray(time)), maxIf(time, e_t = '會員支付成功') #設置目標節點 ) ) ) ) ), '->' ) result_chain from bw.scene_tracker where toDate(time) >= '2020-09-30' AND toDate(time) <= '2020-10-02' AND user_id IN (10266,10022,10339,10030) group by u_i ) tab GROUP BY result_chain ORDER BY user_count DESC LIMIT 20;
簡單說一下上面用到的幾個高階函數:
arrayJoin
能夠理解爲行轉列操做
SELECT arrayJoin([1, 2, 3, 4]) AS data
┌─data─┐│ 1 ││ 2 ││ 3 ││ 4 │└──────┘
uniqCombined
clickhouse中的高性能去重統計函數,相似count(distinct field),數據量比較小的時候使用數組進行去重,中的數據使用set集合去重,當數據量很大時會使用hyperloglog方式進行j近似去重統計;若是想要精度更改可使用uniqCombined64支持64位bit
SELECT uniqCombined(data)FROM ( SELECT arrayJoin([1, 2, 3, 1, 4, 2]) AS data)
┌─uniqCombined(data)─┐│ 4 │└────────────────────┘
arrayCompact
對數組中的數據進行相鄰去重,用戶重複操做的事件只記錄一次
SELECT arrayCompact([1, 2, 3, 3, 1, 1, 4, 2]) AS data
┌─data──────────┐│ [1,2,3,1,4,2] │└───────────────┘
arraySort
對數組中的數據按照指定列進行升序排列;降序排列參考arrayReverseSort
SELECT arraySort(x -> (x.1), [(1, 'a'), (4, 'd'), (2, 'b'), (3, 'c')]) AS data
┌─data──────────────────────────────┐│ [(1,'a'),(2,'b'),(3,'c'),(4,'d')] │└───────────────────────────────────┘
arrayFilter
只保留數組中知足條件的數據
SELECT arrayFilter(x -> (x > 2), [12, 3, 4, 1, 0]) AS data
┌─data─────┐│ [12,3,4] │└──────────┘
groupArray
將分組下的數據聚合到一個數組集合中,相似hive中的collect_list函數
SELECT a.2, groupArray(a.1)FROM ( SELECT arrayJoin([(1, 'a'), (4, 'a'), (3, 'a'), (2, 'c')]) AS a)GROUP BY a.2
┌─tupleElement(a, 2)─┬─groupArray(tupleElement(a, 1))─┐│ c │ [2] ││ a │ [1,4,3] │└────────────────────┴────────────────────────────────┘
arrayEnumerate
或取數組的下標掩碼序列
SELECT arrayEnumerate([1, 2, 3, 3, 1, 1, 4, 2]) AS data
┌─data──────────────┐│ [1,2,3,4,5,6,7,8] │└───────────────────┘
arrayDifference
參數必須是數值類型;計算數組中相鄰數字的差值,第一個值爲0
SELECT arrayDifference([3, 1, 1, 4, 2]) AS data
┌─data──────────┐│ [0,-2,0,3,-2] │└───────────────┘
arrayMap
對數組中的每一列進行處理,並返回長度相同的新數組
SELECT arrayMap(x -> concat(toString(x.1), ':', x.2), [(1, 'a'), (4, 'a'), (3, 'a'), (2, 'c')]) AS data
┌─data──────────────────────┐│ ['1:a','4:a','3:a','2:c'] │└───────────────────────────┘
arraySplit
按照規則對數組進行分割
SELECT arraySplit((x, y) -> y, ['a', 'b', 'c', 'd', 'e'], [1, 0, 0, 1, 0]) AS data
┌─data──────────────────────┐│ [['a','b','c'],['d','e']] │└───────────────────────────┘
## 遇到下標爲1時進行分割,分割點爲下一個 數組的起始點;注意,首項爲1仍是0不影響結果
has
判斷數組中是否包含某個數據
SELECT has([1, 2, 3, 4], 2) AS data
┌─data─┐│ 1 │└──────┘
hasAll
判斷數組中是否包含指定子集
SELECT hasAll([1, 2, 3, 4], [4, 2]) AS data
┌─data─┐│ 1 │└──────┘
---
SELECT hasAll([1, 2, 3, 4], [0, 2]) AS data
┌─data─┐│ 0 │└──────┘
arrayStringConcat
將數組轉爲字符串,須要注意的是,這裏的數組項須要是字符串類型
SELECT arrayStringConcat(['a', 'b', 'c'], '->') AS data
┌─data────┐│ a->b->c │└─────────┘
arrayWithConstant
以某個值進行填充生成數組
SELECT arrayWithConstant(4, 'abc') AS data
┌─data──────────────────────┐│ ['abc','abc','abc','abc'] │└───────────────────────────┘