用戶行爲分析是數據分析中很是重要的一項內容,在統計活躍用戶,分析留存和轉化率,改進產品體驗、推進用戶增加等領域有重要做用。美團點評天天收集的用戶行爲日誌達到數百億條,如何在海量數據集上實現對用戶行爲的快速靈活分析,成爲一個巨大的挑戰。爲此,咱們提出並實現了一套面向海量數據的用戶行爲分析解決方案,將單次分析的耗時從小時級下降到秒級,極大的改善了分析體驗,提高了分析人員的工做效率。算法
本文以有序漏斗的需求爲例,詳細介紹了問題分析和思路設計,以及工程實現和優化的全過程。本文根據2017年12月ArchSummit北京站演講整理而成,略有刪改。安全
下圖描述了轉化率分析中一個常見場景,對訪問路徑「首頁-搜索-菜品-下單-支付」作分析,統計按照順序訪問每層節點的用戶數,獲得訪問過程的轉化率。bash
統計上有一些維度約束,好比日期,時間窗口(整個訪問過程在規定時間內完成,不然統計無效),城市或操做系統等,所以這也是一個典型的OLAP分析需求。此外,每一個訪問節點可能還有埋點屬性,好比搜索頁上的關鍵詞屬性,支付頁的價格屬性等。從結果上看,用戶數是逐層收斂的,在可視化上構成了一個漏斗的形狀,所以這一類需求又稱之爲「有序漏斗」。網絡
這類分析一般是基於用戶行爲的日誌表上進行的,其中每行數據記錄了某個用戶的一次事件的相關信息,包括髮生時間、用戶ID、事件類型以及相關屬性和維度信息等。如今業界流行的一般有兩種解決思路。數據結構
基於Join的SQL架構
select count (distinct t1.id1), count (distinct t2.id2), count (distinct t3.id3)
from (select uuid id1, timestamp ts1 from data where timestamp >= 1510329600 and timestamp < 1510416000 and page = '首頁') t1
left join
(select uuid id2, timestamp ts2 from data where timestamp >= 1510329600 and timestamp < 1510416000 and page = '搜索' and keyword = '中餐') t2
on t1.id1 = t2.id2 and t1.ts1 < t2.ts2 and t2.ts2 - t1.ts1 < 3600
left join
(select uuid id3, timestamp ts3 from data where timestamp >= 1510329600 and timestamp < 1510416000 and page = '菜品') t3
on t1.id1 = t3.id3 and t2.ts2 < t3.ts3 and t1.ts1 < t3.ts3 and t3.ts3 - t1.ts1 < 3600
複製代碼
基於UDAF(User Defined Aggregate Function)的SQL併發
select
funnel(timestamp, 3600, '首頁') stage0,
funnel(timestamp, 3600, '首頁', '搜索', keyword = '中餐') stage1, funnel(timestamp, 3600, '首頁', '搜索', '菜品') stage2
from data
where timestamp >= 1510329600 and timestamp < 1510416000 group by uuid
複製代碼
對於第一種解法,最大的問題是須要作大量join操做,並且關聯條件除了ID的等值鏈接以外,還有時間戳的非等值鏈接。當數據規模不大時,這種用法沒有什麼問題。但隨着數據規模愈來愈大,在幾百億的數據集上作join操做的代價很是高,甚至已經不可行。框架
第二種解法有了改進,經過聚合的方式避免了join操做,改成對聚合後的數據經過UDAF作數據匹配。這種解法的問題是沒有足夠的篩選手段,這意味着幾億用戶對應的幾億條數據都須要遍歷篩選,在性能上也難以接受。運維
那麼這個問題的難點在哪裏?爲何上述兩個解法在實際應用中變得愈來愈不可行?主要問題有這麼幾點。分佈式
基於上述難點和實際需求的分析,能夠總結出幾個實際困難,稱之爲「壞消息」。
另外一方面,仍是可以從問題的分析中獲得一些「好消息」, 這些也是在設計和優化中能夠利用的點。
若是用一句話總結這個問題的核心本質,那就是「多維分析和序列匹配基礎上的去重計數」。具體來講,最終結果就是每層節點符合條件的UUID有多少個,也就是去重後的計數值。這裏UUID要符合兩個條件,一是符合維度的篩選,二是事件序列能匹配漏斗的定義。去重計數是相對好解的問題,那麼問題的重點就是若是快速有效的作維度篩選和序列匹配。
下圖是部分行爲日誌的數據,前面已經提到,直接在這樣的數據上作維度篩選和序列匹配都是很困難的,所以考慮如何對數據作預處理,以提升執行效率。
很天然的想法是基於UUID作聚合,根據時間排序,這也是前面提到的UDAF的思路,以下圖所示。這裏的問題是沒有過濾的手段,每一個UUID都須要遍歷,成本很高。
再進一步,爲了更快更方便的作過濾,考慮把維度和屬性抽出來構成Key,把對應的UUID和時間戳組織起來構成value。若是有搜索引擎經驗的話,很容易看出來這很是像倒排的思路。
這個數據結構仍是存在問題。好比說要拿到某個Key對應的UUID列表時,須要遍歷全部的value才能夠。再好比作時間序列的匹配,這裏的時間戳信息被打散了,實際處理起來更困難。所以還能夠在此基礎上再優化。
能夠看到優化後的Key內容保持不變,value被拆成了UUID集合和時間戳序列集合這兩部分,這樣的好處有兩點:一是能夠作快速的UUID篩選,經過Key對應的UUID集合運算就能夠達成;二是在作時間序列匹配時,對於匹配算法和IO效率都是很友好的,由於時間戳是統一連續存放的,在處理時很方便。
基於上述的思路,最終的索引格式以下圖所示。這裏每一個色塊對應了一個索引的block,其中包括三部份內容,一是屬性名和取值;二是對應的UUID集合,數據經過bitmap格式存儲,在快速篩選時效率很高;三是每一個UUID對應的時間戳序列,用於序列匹配,在存儲時使用差值或變長編碼等一些編碼壓縮手段提升存儲效率。
在實際應用中,一般會同時指定多個屬性或維度條件,經過AND或OR的條件組織起來。這在處理時也很簡單,經過語法分析能夠把查詢條件轉爲一顆表達樹,樹上的葉子節點對應的是單個索引數據,非葉子節點就是AND或OR類型的索引,經過並集或交集的思路作集合篩選和序列匹配便可。
上面解決的是維度篩選的問題,另外一個序列匹配的問題相對簡單不少。基於上述的數據格式,讀取UUID對應的每一個事件的時間戳序列,檢查是否能按照順序匹配便可。須要注意的是,因爲存在最大時間窗口的限制,匹配算法中須要考慮回溯的狀況,下圖展現了一個具體的例子。在第一次匹配過程當中,因爲第一層節點的起始時間戳爲100,而且時間窗口爲10,因此第二層節點的時間戳101符合要求,但第三層節點的時間戳112超過了最大截止時間戳110,所以只能匹配兩層節點,但經過回溯以後,第二次能夠完整的匹配三層節點。
經過上述的討論和設計,完整的算法以下圖所示。其中的核心要點是先經過UUID集合作快速的過濾,再對過濾後的UUID分別作時間戳的匹配,同時上一層節點輸出也做爲下一層節點的輸入,由此達到快速過濾的目的。
有了明確的算法思路,接下來再看看工程如何落地。
首先明確的是須要一個分佈式的服務,主要包括接口服務、計算框架和文件系統三部分。其中接口服務用於接收查詢請求,分析請求並生成實際的查詢邏輯;計算框架用於分佈式的執行查詢邏輯;文件系統存儲實際的索引數據,用於響應具體的查詢。
這裏簡單談一下架構選型的方法論,主要有四點:簡單、成熟、可控、可調。
1.簡單。無論是架構設計,仍是邏輯複雜度和運維成本,都但願儘量簡單。這樣的系統能夠快速落地,也比較容易掌控。 2.成熟。評估一個系統是否成熟有不少方面,好比社區是否活躍,項目是否有明確的發展規劃並能持續落地推動?再好比業界有沒有足夠多的成功案例,實際應用效果如何?一個成熟的系統在落地時的問題相對較少,出現問題也能參考其它案例比較容易的解決,從而很大程度上下降了總體系統的風險。 3.可控。若是一個系統持續保持黑盒的狀態,那隻能是被動的使用,出了問題也很難解決。反之如今有不少的開源項目,能夠拿到完整的代碼,這樣就能夠有更強的掌控力,無論是問題的定位解決,仍是修改、定製、優化等,都更容易實現。 4.可調。一個設計良好的系統,在架構上必定是分層和模塊化的,且有合理的抽象。在這樣的架構下,針對其中一些邏輯作進一步定製或替換時就比較方便,不須要對代碼作大範圍的改動,下降了改形成本和出錯機率。
基於上述的選型思路,服務的三個核心架構分別選擇了Spring,Spark和Alluxio。其中Spring的應用很是普遍,在實際案例和文檔上都很是豐富,很容易落地實現;Spark自己是一個很是優秀的分佈式計算框架,目前團隊對Spark有很強的掌控力,調優經驗也很豐富,這樣只須要專一在計算邏輯的開發便可;Alluxio相對HDFS或HBase來講更加輕量,同時支持包括內存在內的多層異構存儲,這些特性可能會在後續優化中獲得利用。
在具體的部署方式上,Spring Server單獨啓動,Spark和Alluxio都採用Standalone模式,且兩個服務的slave節點在物理機上共同部署。Spring進程中經過SparkContext維持一個Spark長做業,這樣接到查詢請求後能夠快速提交邏輯,避免了申請節點資源和啓動Executor的時間開銷。
上述架構經過對數據的合理分區和資源的併發利用,能夠實現一個查詢請求在幾分鐘內完成。相對原來的幾個小時有了很大改觀,但仍是不能知足交互式分析的需求,所以還須要作進一步的優化。
下圖是對上述優化過程的對比展現。請注意縱軸是對數軸,也就是說圖中每格表明了一個數據級的優化。從圖中能夠看到,常規的UDAF方案一次查詢須要花幾千秒的時間,通過索引結構的設計、本地化調度、內存映射和Unsafe調用的優化過程以後,一次查詢只須要幾秒的時間,優化了3~4個數據級,徹底達到了交互式分析的需求。
這裏想多談幾句對這個優化結果的見解。主流的大數據生態系統都是基於JVM系語言開發的,包括Hadoop生態的Java,Spark的Scala等等。因爲JVM執行機制帶來的不可避免的性能損失,如今也有一些基於C++或其它語言開發的系統,有人宣稱在性能上有幾倍甚至幾十倍的提高。這種嘗試固然很好,但從上面的優化過程來看,整個系統主要是經過更高效的數據結構和更合理的系統架構達到了3個數量級的性能提高,語言特性只是在最後一步優化中有必定效果,在總體佔比中並很少。
有一句雞湯說「以大多數人的努力程度而言,根本沒有到拼天賦的地步」,套用在這裏就是「以大多數系統的架構設計而言,根本沒有到拼語言性能的地步」。語言自己不是門檻,代碼你們都會寫,但整個系統的架構是否合理,數據結構是否足夠高效,這些設計依賴的是對問題本質的理解和工程上的權衡,這纔是更考量設計能力和經驗的地方。
上述方案目前在美團點評內部已經實際落地,穩定運行超過半年以上。天天的數據有幾百億條,活躍用戶達到了上億的量級,埋點屬性超過了百萬,日均查詢量幾百次,單次查詢的TP95時間小於5秒,徹底可以知足交互式分析的預期。
整個方案從業務需求的實際理解和深刻分析出發,抽象出了維度篩選、序列匹配和去重計數三個核心問題,針對每一個問題都給出了合理高效的解決方案,其中結合實際數據特色對數據結構的優化是方案的最大亮點。在方案的實際工程落地和優化過程當中,秉持「簡單、成熟、可控、可調」的選型原則,快速落地實現了高效架構,經過一系列的優化手段和技巧,最終達成了3~4個數量級的性能提高。