做者:楊承波
GrowingIO 大數據工程師,主要負責 SaaS 分析和廣告模塊的技術設計與開發,目前專一於 GrowingIO 物化視圖引擎的建設。
本篇文章主要講解基於 GrowingIO 內部數據存儲結構 Bitmap實現的 Percentile,並簡單介紹一下 Hive Percentile,Spark Percentile,Bitmap Percentile 之間的差別。git
講解具體函數算法實現以前,先搞清楚下面兩個問題:github
😱分位數是啥?算法
針對一個從小到大的有序集合,找一個數來將集合按分位對應比例拆解成兩個集合。sql
注意:分位數並不是是指數據集合中的某個元素,而是找到一個數來拆解集合segmentfault
🤔來點兒暈頭轉向的東西,90 分位怎麼出?數組
這個數將樣本集合分紅左右兩個集合,並且左邊部分的集合佔 90%,右邊部分佔 10%。數據結構
①元素個數爲奇數的集合如何求 90 分位數?app
原始集合:【35, 40, 41, 44, 45, 46, 49, 50, 53, 55, 58】函數
拆解集合:【35, 40, 41, 44, 45, 46, 49, 50, 53】, 55, 【58】性能
左邊集合元素個數:9
右邊集合元素個數:1
正好在原始集合中有這個數能將樣本按 90 分位比例拆解成兩個集合
因此上面樣本的 90 分位是 55
②若是換成元素個數爲偶數的集合呢?
原始集合:【40, 41, 44, 45, 46, 49, 50, 53, 55, 58】
拆解集合:【40, 41, 44, 45, 46, 49, 50, 53, 55】, X, 【58】
這個數存在於 【55 → 58】
X = 58 - (58 - 55) * 0.9 = 55.3
或 X = 55 + (58 - 55) * (1 - 0.9) = 55.3
90 分位含義:有90%的數據小於等於 55.3,有 10% 的數據大於等於 55.3
👉現有【訂單支付成功】事件,獲取過去七天每一個用戶作過該事件總次數的 90 分位值,以下圖:
👉仍是【訂單支付成功】事件,獲取昨天每一個用戶在該事件下實際購買金額的總和,求 75 分位值,以下圖:
環境準備:Core[16], 內存 2G
對比測試:[基於 Bitmap 實現的 Percentile] VS [SparkSQL 內置 Percentile_approx]
場景:必定數量的用戶以及隨機生成對應的 count,隨機生成分位進行計算分位數,獲取百次平均消耗
x 軸含義: 數據量
y 軸含義: 計算時間, 單位毫秒。
Hive Sql 中 Percentile 求解時針對的是一列進行操做,即表裏的某一個字段,面對動不動幾千萬的數據處理,若是把每條數據全都加載到內存中,結局只有一個——卡死。
因此 Hive 須要在 UDAF 的計算中將數據進行壓縮或預處理,那麼 Mapper 是須要在生成時不斷經過聚合計算更新,其內部實現基於 histogram。
Hive 的 percentile_approx 實現靈感出自《A Streaming Parallel Decision Tree Algorithm》,這篇論文提出 On-line Histogram Building 算法。
什麼是 histogram?定義以下:
A histogram is a set of B pairs (called bins) of real numbers {(p1,m1),...,(pB,mB)}, where B is a preset constant integer.
在 histogram 的定義裏面,有一個用於標識 bins 數量的常量 B。爲什麼必定要引入這個常量?假設咱們有一個簡單的 sample 數據集(元素可重複):
[1, 1, 1, 2, 2, 2, 3, 4, 4, 5, 6, 7, 8, 9, 9, 10, 10]
其 histogram 爲:[(1, 3), (2, 3), (3, 1), (4, 2), (5, 1), (6, 1), (7, 1), (8, 1), (9, 2), (10, 2)]。
能夠看出,這個 histogram 內部的 bins(數據點和頻率一塊兒構成的 pair) 數組長度實質上就是 sample 數據集的基數(不一樣元素的個數)。
histogram 的存儲開銷會隨着 sample 數據集的基數線性增加,這意味着若是不作額外的優化,histogram 將沒法適應超大規模數據集合的統計需求。常量 B 就是在這種背景下引入的,其目的在於控制 histogram 的 bins 數組長度(內存開銷)。
Hive 的 Percentile_approx 由 GenericUDAFPercentileApprox 實現,其核心實現是在 histogram 的 bins 數組前面加上一個用於標識分位點的序列。其 merge 操做結果僅保留 histogram 序列,最後從 histogram 計算結果,源碼以下:
/** * Gets an approximate quantile value from the current histogram. Some popular * quantiles are 0.5 (median), 0.95, and 0.98. * * @param q The requested quantile, must be strictly within the range (0,1). * @return The quantile value. */ public double quantile(double q) { assert(bins != null && nusedbins > 0 && nbins > 0); double sum = 0, csum = 0; int b; for(b = 0; b < nusedbins; b++) { sum += bins.get(b).y; } for(b = 0; b < nusedbins; b++) { csum += bins.get(b).y; if(csum / sum >= q) { if(b == 0) { return bins.get(b).x; } csum -= bins.get(b).y; double r = bins.get(b-1).x + (q*sum - csum) * (bins.get(b).x - bins.get(b-1).x)/(bins.get(b).y); return r; } } return -1; // for Xlint, code will never reach here }
OpenHashMap 爲了加快速度,增長了一個假設:
OpenHashMap 底層數據爲 OpenHashSet,因此本質上是看 OpenHashSet 爲啥快。
OpenHashSet 中用 BitSet (位圖)來存儲是否存在於集合中(位運算),另外一個數組存儲實際數據,結構以下:
protected var _bitset = new BitSet(_capacity) protected var _data: Array[T] = _ _data = new Array[T](_capacity)
OpenHashSet 快的緣由:
論文參見《Space-efficient Online Computation of Quantile Summaries》
底層實現經過 QuantileSummaries 實現,主要有兩個成員變量:
sample: Array[Stat] : 存放桶,超過1000個桶的時候就壓縮(生成新的三元組); headSampled: ArrayBuffer[Double]:緩衝區,每次達到5000個,就排序後更新到sample.
主要思想是減小空間佔用,spark 實現 merge sample 時甚至未處理 samples 已經有序,直接 sortBy:
// TODO: could replace full sort by ordered merge, the two lists are known to be sorted already. val res = (sampled ++ other.sampled).sortBy(_.value) val comp = compressImmut(res, mergeThreshold = 2 * relativeError * count) new QuantileSummaries(other.compressThreshold, other.relativeError, comp, other.count + count)
Stat 的定義:
/** * Statistics from the Greenwald-Khanna paper. * @param value the sampled value * @param g the minimum rank jump from the previous value's minimum rank * @param delta the maximum span of the rank. */ case class Stats(value: Double, g: Int, delta: Int)
插入函數:每 N 個數,排序至少 1 次(merge 還有 1 次),時間複雜度 O(NlogN):
def insert(x: Double): QuantileSummaries = { headSampled += x if (headSampled.size >= defaultHeadSize) { val result = this.withHeadBufferInserted if (result.sampled.length >= compressThreshold) { result.compress() } else { result } } else { this } }
獲取結果: 時間複雜度 O(n)
// Target rank val rank = math.ceil(quantile * count).toInt val targetError = math.ceil(relativeError * count) // Minimum rank at current sample var minRank = 0 var i = 1 while (i < sampled.length - 1) { val curSample = sampled(i) minRank += curSample.g val maxRank = minRank + curSample.delta if (maxRank - targetError <= rank && rank <= minRank + targetError) { return Some(curSample.value) } i += 1 }
乾飯人,你是否還記得凱哥以前的分享《GrowingIO 基於 BitMap 的海量數據分析》
這裏只是簡單回顧 CBitmap 和補充一丟丟權重相關的東西
非數值型指標 Bitmap 怎麼存儲?
這裏只用單個事件 + 單個維度分析,多維度的可參考以前的分享文檔,中華傳統功夫以點到爲止。
生成 CBitmap 統計結果。
CBitmap: Map(short → Map(dimsSortId → RoaringBitmap(uid)))
數值型指標 Bitmap 怎麼存儲?
上面咱們講述的都是統計的某個指標發生的次數比較小的整數類型數據,那麼問題來了:
①當我統計的結果不是個整數呢,換句話說,咱們統計的指標是訂單金額呢?
②當我統計的結果都是整數,可是吧,有些東西喜歡按」K」做爲單位,用你的小腦殼瓜想想,統計結果中次數從 0 → 1024【bit位 0 → 10】就存了個寂寞
計算精度,指望對全部值統計出一個公約數,從而減小存儲量,這裏引出權重 Weight 的概念:
例如 對於 100,200,300, 能夠提出一個 100 做爲公約數,只保存 1 2 3, 同理 0.01 0.02 0.03 也能夠提出 0.01 如下部分再也不詳細講解,有興趣能夠瞅瞅 /** * 可是咱們實現沒法知道數據的分佈,只能預估一個值,具體預估流程: * 1. 計算一個 high 和 low, high 能夠認爲是求 log10 + 1,也就是和 1 差的數量級, * low 能夠認爲是保證精度在 1e-4(精度能夠修改) 之內至少要保留的位數,對於整數來講 low = 0 * 2. 根據全部的 high 和 low,統計一個相對合理的 high 和 low,只要這個 high 對應的數據佔比高於平均佔比的 1/10 便可 * 3. high 表明了最大須要將數據放大多少個數量級,low 表明最小能夠將數據縮小多少個數量級, * 求一個折中值 Math.max(high-n, -1*low) 這裏的 n 會影響整數的精度,一開始是 6,後來改成了 7 * * 例若有一系列數字: * 10,000 100 10 0.1 0.01 * 能夠求得對應的 high 和 low: * 5 3 2 -1 -2 * 0 0 0 1 2 * 而後求得合併的 high = 5, low = 2 * 最後獲得 weight = 0.1(n=6), 0.01(n=7) * * 再舉一個極端的例子: * 10000...(20個0) 0.00000...1(20個0) * 求得的 high 和 low: * 21 -21 * 0 0 * 求得合併的 high = 21, low = 0 * 合併獲得 weight= 100...(21-7個0) */
計算邏輯
若是忽略 dimsSortId 的存在,獲得一個新的 CBitmap 結構:
CBitmap:
Map(short → Map(dimsSortId → RoaringBitmap(uid)))
轉化爲 Map(short → RoaringBitmap(uid))
以前生成事件指標的Cbitmap以下:
{
1 → {0 → (1), 1 → (1)},
0 → {0 → (2, 3, 4)}
}
轉化後的CBitmap以下:
{
1 → (1),
0 → (2, 3, 4)
}
Bitmap 分位數到底咋算
🤔 給點數據,給個需求,先來個簡單的,數據以下:
cBitmap = { 3 -> (1001, 1006) 2 -> (1003, 1005, 1006) 1 -> (1004) 0 -> (1001, 1002, 1003) } 求這個CBitmap的75分位數?
🤔 把每一個用戶對應的 cnt 按照順序拿出來,再按照公式求分位數?
cBitmap轉成cnt從小到大排序後的映射關係(uid -> cnt) 【 (1002 -> 1), (1004 -> 2), (1005 -> 4), (1003 -> 5), (1001 -> 9), (1006 -> 12) 】 75分位數求解: x = (1, 2, 4, 5, 9, 12) (6 - 1) * 0.75 = 3.75 分位數 = x[i] + decimalPart * (x[i+1] - x[i]) = 5 + 0.75 * (9 - 5) = 8
看似木得問題,實則慢得一匹。。。
從上到下依次遍歷每個 C 位獲取每個用戶對應的 cnt,獲得 cnt 的排序數組,最後再根據公式才能求出結果。
🤔 簡單點兒,求解的方式簡單點兒?
既然 CBitmap 自己就是計次且有序的,爲啥不充分利用起來?
對於 cbitmap 求分位數,前提就是獲取排序後第 i 我的和第 i + 1 我的對應的 cnt 數
① 計算終究須要知道 integerPart 是多少?
(6 - 1) * 0.75 = 3.75
i = 3 且有小數部分,須要獲取 x[i] 和 x[i+1]
② 因爲 cbitmap 中高位的數據必定比低位的數據大,因此 cbitmap 計算第 i 我的能夠從高位開始遍歷排除數據,拿到第 i 我的的 c 位
totalRbm = cbitmap 去重後用戶集合
currIdx = 當前遍歷的 c 位
currRbm = 當前指針位置對應的 roaringBitmap
persistRbm = 必定是小於當前指針位置的這部分用戶 = totalRbm andNot currRbm
preDiscardRbm = 本次遍歷準備排除的高位的用戶 = totalRbm and currRbm
cBitmap = { 3 -> (1001, 1006) 2 -> (1003, 1005, 1006) 1 -> (1004) 0 -> (1001, 1002, 1003) } totalRbm = (1001, 1002, 1003, 1004, 1005, 1006) currIdx -> currRbm = 3 -> (1001, 1006) persistRbm = (1002, 1003, 1004, 1005) preDiscardRbm = (1001, 1006)
1) 下一步是在 perDiscardRbm 中找到須要的那我的,新的位置 = 以前的 i - persistRbm 人數。
2) 並將 preDiscardRbm 置爲 totalRbm, currIdx -= 1,從新計算重要變量進入排除算法。
將接收變量換成數組,在排除算法中一次遍歷獲取第 i 個用戶和第 i+1 個用戶,只須要考慮如下兩個情形:
1)當第 i 個用戶和第 i+1 個用戶在同一 cnt 位上,則後續排除算法的判斷邏輯無差別。
2)當出現第 i+1 個用戶在 currIdx,而第 i 個用戶在 currIdx - 1時,致使 totalRbm 不一致,須要分開進行計算。
分位數 = (x[i] + decimalPart (x[i+1] - x[i])) weight
本篇主要揭曉基於 BitMap 來做爲底層的數據模型實現的 Percentile 算法的優點,Bitmap 的高度壓縮在存儲方面帶來巨大優點的同時,還能根據其數據結構靈活計算統計數據,快速計算許多相似 Percentile 的需求。
BitMap 還有不少的擴展性和亮點,下面列舉幾個,敬請期待:
參考資料:
關於 GrowingIO
GrowingIO 是國內領先的一站式數字化增加總體方案服務商。爲產品、運營、市場、數據團隊及管理者提供客戶數據平臺、廣告分析、產品分析、智能運營等產品和諮詢服務,幫助企業在數字化轉型的路上,提高數據驅動能力,實現更好的增加。
點擊「此處」,獲取 GrowingIO 15 天免費試用!