一、HyperLogLog 簡介
HyperLogLog 是最早由 Flajolet 及其同事在 2007 年提出的一種 估算基數的近似最優算法。但跟原版論文不同的是,好像很多書包括 Redis 作者都把它稱為一種 新的數據結構(new datastruct) (算法實現確實需要一種特定的數據結構來實現)。
關於基數統計
基數統計(Cardinality Counting) 通常是用來統計一個集合中不重復的元素個數。
思考這樣的一個場景: 如果你負責開發維護一個大型的網站,有一天老板找產品經理要網站上每個網頁的 UV(獨立訪客,每個用戶每天只記錄一次),然后讓你來開發這個統計模塊,你會如何實現?
如果統計 PV(瀏覽量,用戶沒點一次記錄一次),那非常好辦,給每個頁面配置一個獨立的 Redis 計數器就可以了,把這個計數器的 key 后綴加上當天的日期。這樣每來一個請求,就執行 INCRBY
指令一次,最終就可以統計出所有的 PV 數據了。
但是 UV 不同,它要去重,同一個用戶一天之內的多次訪問請求只能計數一次。這就要求了每一個網頁請求都需要帶上用戶的 ID,無論是登錄用戶還是未登錄的用戶,都需要一個唯一 ID 來標識。
你也許馬上就想到了一個 簡單的解決方案:那就是 為每一個頁面設置一個獨立的 set 集合 來存儲所有當天訪問過此頁面的用戶 ID。但這樣的 問題 就是:
- 存儲空間巨大: 如果網站訪問量一大,你需要用來存儲的 set 集合就會非常大,如果頁面再一多.. 為了一個去重功能耗費的資源就可以直接讓你 老板打死你;
- 統計復雜: 這么多 set 集合如果要聚合統計一下,又是一個復雜的事情;
基數統計的常用方法
對於上述這樣需要 基數統計 的事情,通常來說有兩種比 set 集合更好的解決方案:
第一種:B 樹
B 樹最大的優勢就是插入和查找效率很高,如果用 B 樹存儲要統計的數據,可以快速判斷新來的數據是否存在,並快速將元素插入 B 樹。要計算基礎值,只需要計算 B 樹的節點個數就行了。
不過將 B 樹結構維護到內存中,能夠解決統計和計算的問題,但是 並沒有節省內存。
第二種:bitmap
bitmap 可以理解為通過一個 bit 數組來存儲特定數據的一種數據結構,每一個 bit 位都能獨立包含信息,bit 是數據的最小存儲單位,因此能大量節省空間,也可以將整個 bit 數據一次性 load 到內存計算。如果定義一個很大的 bit 數組,基礎統計中 每一個元素對應到 bit 數組中的一位,例如:
bitmap 還有一個明顯的優勢是 可以輕松合並多個統計結果,只需要對多個結果求異或就可以了,也可以大大減少存儲內存。可以簡單做一個計算,如果要統計 1 億 個數據的基數值,大約需要的內存:100_000_000/ 8/ 1024/ 1024 ≈ 12 M
,如果用 32 bit 的 int 代表 每一個 統計的數據,大約需要內存:32 * 100_000_000/ 8/ 1024/ 1024 ≈ 381 M
可以看到 bitmap 對於內存的節省顯而易見,但仍然不夠。統計一個對象的基數值就需要 12 M
,如果統計 1 萬個對象,就需要接近 120 G
,對於大數據的場景仍然不適用。
概率算法
實際上目前還沒有發現更好的在 大數據場景 中 准確計算 基數的高效算法,因此在不追求絕對精確的情況下,使用概率算法算是一個不錯的解決方案。
概率算法 不直接存儲 數據集合本身,通過一定的 概率統計方法預估基數值,這種方法可以大大節省內存,同時保證誤差控制在一定范圍內。目前用於基數計數的概率算法包括:
- Linear Counting(LC):早期的基數估計算法,LC 在空間復雜度方面並不算優秀,實際上 LC 的空間復雜度與上文中簡單 bitmap 方法是一樣的(但是有個常數項級別的降低),都是 O(Nmax)
- LogLog Counting(LLC):LogLog Counting 相比於 LC 更加節省內存,空間復雜度只有 O(log2(log2(Nmax)))
- HyperLogLog Counting(HLL):HyperLogLog Counting 是基於 LLC 的優化和改進,在同樣空間復雜度情況下,能夠比 LLC 的基數估計誤差更小
其中,HyperLogLog 的表現是驚人的,上面我們簡單計算過用 bitmap 存儲 1 個億 統計數據大概需要 12 M
內存,而在 HyperLoglog 中,只需要不到 1 K 內存就能夠做到!在 Redis 中實現的 HyperLoglog 也只需要 12 K 內存,在 標准誤差 0.81% 的前提下,能夠統計 264 個數據!
這是怎么做到的?! 下面趕緊來了解一下!
二、HyperLogLog 原理
我們來思考一個拋硬幣的游戲:你連續擲 n 次硬幣,然后說出其中連續擲為正面的最大次數,我來猜你一共拋了多少次。
這很容易理解吧,例如:你說你這一次 最多連續出現了 2 次 正面,那么我就可以知道你這一次投擲的次數並不多,所以 我可能會猜是 5 或者是其他小一些的數字,但如果你說你這一次 最多連續出現了 20 次 正面,雖然我覺得不可能,但我仍然知道你花了特別多的時間,所以 我說 GUN...。
這期間我可能會要求你重復實驗,然后我得到了更多的數據之后就會估計得更准。我們來把剛才的游戲換一種說法:
這張圖的意思是,我們給定一系列的隨機整數,記錄下低位連續零位的最大長度 K,即為圖中的 maxbit
,通過這個 K 值我們就可以估算出隨機數的數量 N。
代碼實驗
我們可以簡單編寫代碼做一個實驗,來探究一下 K
和 N
之間的關系:
public class PfTest {
static class BitKeeper {
private int maxbit;
public void random() {
long value = ThreadLocalRandom.current().nextLong(2L << 32);
int bit = lowZeros(value);
if (bit > this.maxbit) {
this.maxbit = bit;
}
}
private int lowZeros(long value) {
int i = 0;
for (; i < 32; i++) {
if (value >> i << i != value) {
break;
}
}
return i - 1;
}
}
static class Experiment {
private int n;
private BitKeeper keeper;
public Experiment(int n) {
this.n = n;
this.keeper = new BitKeeper();
}
public void work() {
for (int i = 0; i < n; i++) {
this.keeper.random();
}
}
public void debug() {
System.out
.printf("%d %.2f %d\n", this.n, Math.log(this.n) / Math.log(2), this.keeper.maxbit);
}
}
public static void main(String[] args) {
for (int i = 1000; i < 100000; i += 100) {
Experiment exp = new Experiment(i);
exp.work();
exp.debug();
}
}
}
跟上圖中的過程是一致的,話說為啥叫 PfTest
呢,包括 Redis 中的命令也一樣帶有一個 PF
前綴,還記得嘛,因為 HyperLogLog 的提出者上文提到過的,叫 Philippe Flajolet
。
截取部分輸出查看:
//n n/log2 maxbit
34000 15.05 13
35000 15.10 13
36000 15.14 16
37000 15.18 17
38000 15.21 14
39000 15.25 16
40000 15.29 14
41000 15.32 16
42000 15.36 18
會發現 K
和 N
的對數之間存在顯著的線性相關性:N 約等於 2k
更近一步:分桶平均
如果 N
介於 2k 和 2k+1 之間,用這種方式估計的值都等於 2k,這明顯是不合理的,所以我們可以使用多個 BitKeeper
進行加權估計,就可以得到一個比較准確的值了:
public class PfTest {
static class BitKeeper {
// 無變化, 代碼省略
}
static class Experiment {
private int n;
private int k;
private BitKeeper[] keepers;
public Experiment(int n) {
this(n, 1024);
}
public Experiment(int n, int k) {
this.n = n;
this.k = k;
this.keepers = new BitKeeper[k];
for (int i = 0; i < k; i++) {
this.keepers[i] = new BitKeeper();
}
}
public void work() {
for (int i = 0; i < this.n; i++) {
long m = ThreadLocalRandom.current().nextLong(1L << 32);
BitKeeper keeper = keepers[(int) (((m & 0xfff0000) >> 16) % keepers.length)];
keeper.random();
}
}
public double estimate() {
double sumbitsInverse = 0.0;
for (BitKeeper keeper : keepers) {
sumbitsInverse += 1.0 / (float) keeper.maxbit;
}
double avgBits = (float) keepers.length / sumbitsInverse;
return Math.pow(2, avgBits) * this.k;
}
}
public static void main(String[] args) {
for (int i = 100000; i < 1000000; i += 100000) {
Experiment exp = new Experiment(i);
exp.work();
double est = exp.estimate();
System.out.printf("%d %.2f %.2f\n", i, est, Math.abs(est - i) / i);
}
}
}
這個過程有點 類似於選秀節目里面的打分,一堆專業評委打分,但是有一些評委因為自己特別喜歡所以給高了,一些評委又打低了,所以一般都要 屏蔽最高分和最低分,然后 再計算平均值,這樣的出來的分數就差不多是公平公正的了。
上述代碼就有 1024 個 "評委",並且在計算平均值的時候,采用了 調和平均數,也就是倒數的平均值,它能有效地平滑離群值的影響:
avg = (3 + 4 + 5 + 104) / 4 = 29
avg = 4 / (1/3 + 1/4 + 1/5 + 1/104) = 5.044
觀察腳本的輸出,誤差率百分比控制在個位數:
100000 94274.94 0.06
200000 194092.62 0.03
300000 277329.92 0.08
400000 373281.66 0.07
500000 501551.60 0.00
600000 596078.40 0.01
700000 687265.72 0.02
800000 828778.96 0.04
900000 944683.53 0.05
真實的 HyperLogLog 要比上面的示例代碼更加復雜一些,也更加精確一些。上面這個算法在隨機次數很少的情況下會出現除零錯誤,因為 maxbit = 0
是不可以求倒數的。
真實的 HyperLogLog
有一個神奇的網站,可以動態地讓你觀察到 HyperLogLog 的算法到底是怎么執行的:http://content.research.neustar.biz/blog/hll.html
其中的一些概念這里稍微解釋一下,您就可以自行去點擊 step
來觀察了:
- m 表示分桶個數: 從圖中可以看到,這里分成了 64 個桶;
- 藍色的 bit 表示在桶中的位置: 例如圖中的
101110
實則表示二進制的46
,所以該元素被統計在中間大表格Register Values
中標紅的第 46 個桶之中; - 綠色的 bit 表示第一個 1 出現的位置: 從圖中可以看到標綠的 bit 中,從右往左數,第一位就是 1,所以在
Register Values
第 46 個桶中寫入 1; - 紅色 bit 表示綠色 bit 的值的累加: 下一個出現在第 46 個桶的元素值會被累加;
為什么要統計 Hash 值中第一個 1 出現的位置?
因為第一個 1 出現的位置可以同我們拋硬幣的游戲中第一次拋到正面的拋擲次數對應起來,根據上面擲硬幣實驗的結論,記錄每個數據的第一個出現的位置 K
,就可以通過其中最大值 Kmax 來推導出數據集合中的基數:N = 2Kmax
PF 的內存占用為什么是 12 KB?
我們上面的算法中使用了 1024 個桶,網站演示也只有 64 個桶,不過在 Redis 的 HyperLogLog 實現中,用的是 16384 個桶,即:214,也就是說,就像上面網站中間那個 Register Values
大表格有 16384 格。
而Redis 最大能夠統計的數據量是 264,即每個桶的 maxbit
需要 6 個 bit 來存儲,最大可以表示 maxbit = 63
,於是總共占用內存就是:(214) x 6 / 8 (每個桶 6 bit,而這么多桶本身要占用 16384 bit,再除以 8 轉換成 KB),算出來的結果就是 12 KB
。
三、Redis 中的 HyperLogLog 實現
從上面我們算是對 HyperLogLog 的算法和思想有了一定的了解,並且知道了一個 HyperLogLog 實際占用的空間大約是 12 KB
,但 Redis 對於內存的優化非常變態,當 計數比較小 的時候,大多數桶的計數值都是 零,這個時候 Redis 就會適當節約空間,轉換成另外一種 稀疏存儲方式,與之相對的,正常的存儲模式叫做 密集存儲,這種方式會恆定地占用 12 KB
。
密集型存儲結構
密集型的存儲結構非常簡單,就是 16384 個 6 bit 連續串成 的字符串位圖:
我們都知道,一個字節是由 8 個 bit 組成的,這樣 6 bit 排列的結構就會導致,有一些桶會 跨越字節邊界,我們需要 對這一個或者兩個字節進行適當的移位拼接 才可以得到具體的計數值。
假設桶的編號為 index
,這個 6 bity 計數值的起始字節偏移用 offset_bytes
表示,它在這個字節的其實比特位置偏移用 offset_bits
表示,於是我們有:
offset_bytes = (index * 6) / 8
offset_bits = (index * 6) % 8
前者是商,后者是余數。比如 bucket 2
的字節偏移是 1,也就是第 2 個字節。它的位偏移是 4,也就是第 2 個字節的第 5 個位開始是 bucket 2 的計數值。需要注意的是 字節位序是左邊低位右邊高位,而通常我們使用的字節都是左邊高位右邊低位。
這里就涉及到兩種情況,如果 offset_bits
小於等於 2,說明這 6 bit 在一個字節的內部,可以直接使用下面的表達式得到計數值 val
:
val = buffer[offset_bytes] >> offset_bits # 向右移位
如果 offset_bits
大於 2,那么就會涉及到 跨越字節邊界,我們需要拼接兩個字節的位片段:
# 低位值
low_val = buffer[offset_bytes] >> offset_bits
# 低位個數
low_bits = 8 - offset_bits
# 拼接,保留低6位
val = (high_val << low_bits | low_val) & 0b111111
不過下面 Redis 的源碼要晦澀一點,看形式它似乎只考慮了跨越字節邊界的情況。這是因為如果 6 bit 在單個字節內,上面代碼中的 high_val
的值是零,所以這一份代碼可以同時照顧單字節和雙字節:
// 獲取指定桶的計數值
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \
uint8_t *_p = (uint8_t*) p; \
unsigned long _byte = regnum*HLL_BITS/8; \
unsigned long _fb = regnum*HLL_BITS&7; \ # %8 = &7
unsigned long _fb8 = 8 - _fb; \
unsigned long b0 = _p[_byte]; \
unsigned long b1 = _p[_byte+1]; \
target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)
// 設置指定桶的計數值
#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \
uint8_t *_p = (uint8_t*) p; \
unsigned long _byte = regnum*HLL_BITS/8; \
unsigned long _fb = regnum*HLL_BITS&7; \
unsigned long _fb8 = 8 - _fb; \
unsigned long _v = val; \
_p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
_p[_byte] |= _v << _fb; \
_p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
_p[_byte+1] |= _v >> _fb8; \
} while(0)
稀疏存儲結構
稀疏存儲適用於很多計數值都是零的情況。下圖表示了一般稀疏存儲計數值的狀態:
當 多個連續桶的計數值都是零 時,Redis 提供了幾種不同的表達形式:
00xxxxxx
:前綴兩個零表示接下來的 6bit 整數值加 1 就是零值計數器的數量,注意這里要加 1 是因為數量如果為零是沒有意義的。比如00010101
表示連續22
個零值計數器。01xxxxxx yyyyyyyy
:6bit 最多只能表示連續64
個零值計數器,這樣擴展出的 14bit 可以表示最多連續16384
個零值計數器。這意味着 HyperLogLog 數據結構中16384
個桶的初始狀態,所有的計數器都是零值,可以直接使用 2 個字節來表示。1vvvvvxx
:中間 5bit 表示計數值,尾部 2bit 表示連續幾個桶。它的意思是連續(xx +1)
個計數值都是(vvvvv + 1)
。比如10101011
表示連續4
個計數值都是11
。
注意 上面第三種方式 的計數值最大只能表示到 32
,而 HyperLogLog 的密集存儲單個計數值用 6bit 表示,最大可以表示到 63
。當稀疏存儲的某個計數值需要調整到大於 32
時,Redis 就會立即轉換 HyperLogLog 的存儲結構,將稀疏存儲轉換成密集存儲。
對象頭
HyperLogLog 除了需要存儲 16384 個桶的計數值之外,它還有一些附加的字段需要存儲,比如總計數緩存、存儲類型。所以它使用了一個額外的對象頭來表示:
struct hllhdr {
char magic[4]; /* 魔術字符串"HYLL" */
uint8_t encoding; /* 存儲類型 HLL_DENSE or HLL_SPARSE. */
uint8_t notused[3]; /* 保留三個字節未來可能會使用 */
uint8_t card[8]; /* 總計數緩存 */
uint8_t registers[]; /* 所有桶的計數器 */
};
所以 HyperLogLog 整體的內部結構就是 HLL 對象頭 加上 16384 個桶的計數值位圖。它在 Redis 的內部結構表現就是一個字符串位圖。你可以把 HyperLogLog 對象當成普通的字符串來進行處理:
> PFADD codehole python java golang
(integer) 1
> GET codehole
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3"
但是 不可以 使用 HyperLogLog 指令來 操縱普通的字符串,因為它需要檢查對象頭魔術字符串是否是 "HYLL"。
四、HyperLogLog 的使用
HyperLogLog 提供了兩個指令 PFADD
和 PFCOUNT
,字面意思就是一個是增加,另一個是獲取計數。PFADD
和 set
集合的 SADD
的用法是一樣的,來一個用戶 ID,就將用戶 ID 塞進去就是,PFCOUNT
和 SCARD
的用法是一致的,直接獲取計數值:
> PFADD codehole user1
(interger) 1
> PFCOUNT codehole
(integer) 1
> PFADD codehole user2
(integer) 1
> PFCOUNT codehole
(integer) 2
> PFADD codehole user3
(integer) 1
> PFCOUNT codehole
(integer) 3
> PFADD codehole user4 user 5
(integer) 1
> PFCOUNT codehole
(integer) 5
我們可以用 Java 編寫一個腳本來試試 HyperLogLog 的准確性到底有多少:
public class JedisTest {
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
jedis.pfadd("codehole", "user" + i);
}
long total = jedis.pfcount("codehole");
System.out.printf("%d %d\n", 100000, total);
jedis.close();
}
}
結果輸出如下:
100000 99723
發現 10
萬條數據只差了 277
,按照百分比誤差率是 0.277%
,對於巨量的 UV 需求來說,這個誤差率真的不算高。
當然,除了上面的 PFADD
和 PFCOUNT
之外,還提供了第三個 PFMEGER
指令,用於將多個計數值累加在一起形成一個新的 pf
值:
> PFADD nosql "Redis" "MongoDB" "Memcached"
(integer) 1
> PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL"
(integer) 1
> PFMERGE databases nosql RDBMS
OK
> PFCOUNT databases
(integer) 6
相關閱讀
- Redis(1)——5種基本數據結構 - https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/
- Redis(2)——跳躍表 - https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/
- Redis(3)——分布式鎖深入探究 - https://www.wmyskxz.com/2020/03/01/redis-3/
擴展閱讀
- 【算法原文】HyperLogLog: the analysis of a near-optimal
cardinality estimation algorithm - http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf
參考資料
- 【Redis 作者博客】Redis new data structure: the HyperLogLog - http://antirez.com/news/75
- 神奇的HyperLogLog算法 - http://www.rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html
- 深度探索 Redis HyperLogLog 內部數據結構 - https://zhuanlan.zhihu.com/p/43426875
- 《Redis 深度歷險》 - 錢文品/ 著
- 本文已收錄至我的 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
- 個人公眾號 :wmyskxz,個人獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!
非常感謝各位人才能 看到這里,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!
創作不易,各位的支持和認可,就是我創作的最大動力,我們下篇文章見!