HyperLogLog 是最先由 Flajolet 及其同事在 2007 年提出的一種 估算基數的近似最優算法。但跟原版論文不一樣的是,好像不少書包括 Redis 做者都把它稱爲一種 新的數據結構(new datastruct) html
基數統計(Cardinality Counting) 一般是用來統計一個集合中不重複的元素個數。java
思考這樣的一個場景: 若是你負責開發維護一個大型的網站,有一天老闆找產品經理要網站上每一個網頁的 UV(獨立訪客,每一個用戶天天只記錄一次),而後讓你來開發這個統計模塊,你會如何實現?python
若是統計 PV(瀏覽量,用戶沒點一次記錄一次),那很是好辦,給每一個頁面配置一個獨立的 Redis 計數器就能夠了,把這個計數器的 key 後綴加上當天的日期。這樣每來一個請求,就執行 INCRBY
指令一次,最終就能夠統計出全部的 PV 數據了。git
可是 UV 不一樣,它要去重,同一個用戶一天以內的屢次訪問請求只能計數一次。這就要求了每個網頁請求都須要帶上用戶的 ID,不管是登陸用戶仍是未登陸的用戶,都須要一個惟一 ID 來標識。程序員
你也許立刻就想到了一個 github
對於上述這樣須要 基數統計 的事情,一般來講有兩種比 set 集合更好的解決方案:golang
B 樹最大的優點就是插入和查找效率很高,若是用 B 樹存儲要統計的數據,能夠快速判斷新來的數據是否存在,並快速將元素插入 B 樹。要計算基礎值,只須要計算 B 樹的節點個數就好了。redis
不過將 B 樹結構維護到內存中,可以解決統計和計算的問題,可是 並無節省內存。算法
bitmap 能夠理解爲經過一個 bit 數組來存儲特定數據的一種數據結構,每個 bit 位都能獨立包含信息,bit 是數據的最小存儲單位,所以能大量節省空間,也能夠將整個 bit 數據一次性 load 到內存計算。若是定義一個很大的 bit 數組,基礎統計中 每個元素對應到 bit 數組中的一位,例如:sql
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
,對於大數據的場景仍然不適用。
實際上目前尚未發現更好的在 大數據場景 中 準確計算 基數的高效算法,所以在不追求絕對精確的狀況下,使用機率算法算是一個不錯的解決方案。
機率算法 不直接存儲 數據集合自己,經過必定的 機率統計方法預估基數值,這種方法能夠大大節省內存,同時保證偏差控制在必定範圍內。目前用於基數計數的機率算法包括:
其中,HyperLogLog 的表現是驚人的,上面咱們簡單計算過用 bitmap 存儲 1 個億 統計數據大概須要 12 M
內存,而在 HyperLoglog 中,只須要不到 1 K 內存就可以作到!在 Redis 中實現的 HyperLoglog 也只須要 12 K 內存,在 標準偏差 0.81% 的前提下,可以統計 264 個數據!
這是怎麼作到的?! 下面趕忙來了解一下!
咱們來思考一個拋硬幣的遊戲:你連續擲 n 次硬幣,而後說出其中連續擲爲正面的最大次數,我來猜你一共拋了多少次。
這很容易理解吧,例如:你說你這一次
這期間我可能會要求你重複實驗,而後我獲得了更多的數據以後就會估計得更準。咱們來把剛纔的遊戲換一種說法:
這張圖的意思是,咱們給定一系列的隨機整數,記錄下低位連續零位的最大長度 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 的算法究竟是怎麼執行的:content.research.neustar.biz/blog/hll.ht…
其中的一些概念這裏稍微解釋一下,您就能夠自行去點擊 step
來觀察了:
101110
實則表示二進制的
46
,因此該元素被統計在中間大表格
Register Values
中標紅的第 46 個桶之中;
Register Values
第 46 個桶中寫入 1;
由於第一個 1 出現的位置能夠同咱們拋硬幣的遊戲中第一次拋到正面的拋擲次數對應起來,根據上面擲硬幣實驗的結論,記錄每一個數據的第一個出現的位置 K
,就能夠經過其中最大值 Kmax 來推導出數據集合中的基數:N = 2Kmax
咱們上面的算法中使用了 1024 個桶,網站演示也只有 64 個桶,不過在 Redis 的 HyperLogLog 實現中,用的是 16384 個桶,即:214,也就是說,就像上面網站中間那個 Register Values
大表格有 16384 格。
而Redis 最大可以統計的數據量是 264,即每一個桶的 maxbit
須要 6 個 bit 來存儲,最大能夠表示 maxbit = 63
,因而總共佔用內存就是:(214) x 6 / 8
12 KB
。
從上面咱們算是對 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 提供了兩個指令 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
複製代碼
本文已收錄至個人 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:github.com/wmyskxz/Mor… 我的公衆號 :wmyskxz, 我的獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!
很是感謝各位人才能 看到這裏,若是以爲本篇文章寫得不錯,以爲 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!
創做不易,各位的支持和承認,就是我創做的最大動力,咱們下篇文章見!