Reids(4)——神奇的HyperLoglog解決統計問題

1、HyperLogLog 簡介

HyperLogLog 是最先由 Flajolet 及其同事在 2007 年提出的一種 估算基數的近似最優算法。但跟原版論文不一樣的是,好像不少書包括 Redis 做者都把它稱爲一種 新的數據結構(new datastruct) html

(算法實現確實須要一種特定的數據結構來實現)

關於基數統計

基數統計(Cardinality Counting) 一般是用來統計一個集合中不重複的元素個數。java

思考這樣的一個場景: 若是你負責開發維護一個大型的網站,有一天老闆找產品經理要網站上每一個網頁的 UV(獨立訪客,每一個用戶天天只記錄一次),而後讓你來開發這個統計模塊,你會如何實現?python

若是統計 PV(瀏覽量,用戶沒點一次記錄一次),那很是好辦,給每一個頁面配置一個獨立的 Redis 計數器就能夠了,把這個計數器的 key 後綴加上當天的日期。這樣每來一個請求,就執行 INCRBY 指令一次,最終就能夠統計出全部的 PV 數據了。git

可是 UV 不一樣,它要去重,同一個用戶一天以內的屢次訪問請求只能計數一次。這就要求了每個網頁請求都須要帶上用戶的 ID,不管是登陸用戶仍是未登陸的用戶,都須要一個惟一 ID 來標識。程序員

你也許立刻就想到了一個 github

簡單的解決方案
:那就是 爲每個頁面設置一個獨立的 set 集合 來存儲全部當天訪問過此頁面的用戶 ID。但這樣的 問題 就是:

  1. 存儲空間巨大: 若是網站訪問量一大,你須要用來存儲的 set 集合就會很是大,若是頁面再一多.. 爲了一個去重功能耗費的資源就能夠直接讓你 老闆打死你
  2. 統計複雜: 這麼多 set 集合若是要聚合統計一下,又是一個複雜的事情;

基數統計的經常使用方法

對於上述這樣須要 基數統計 的事情,一般來講有兩種比 set 集合更好的解決方案:golang

第一種:B 樹

B 樹最大的優點就是插入和查找效率很高,若是用 B 樹存儲要統計的數據,能夠快速判斷新來的數據是否存在,並快速將元素插入 B 樹。要計算基礎值,只須要計算 B 樹的節點個數就好了。redis

不過將 B 樹結構維護到內存中,可以解決統計和計算的問題,可是 並無節省內存算法

第二種:bitmap

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,對於大數據的場景仍然不適用。

機率算法

實際上目前尚未發現更好的在 大數據場景準確計算 基數的高效算法,所以在不追求絕對精確的狀況下,使用機率算法算是一個不錯的解決方案。

機率算法 不直接存儲 數據集合自己,經過必定的 機率統計方法預估基數值,這種方法能夠大大節省內存,同時保證偏差控制在必定範圍內。目前用於基數計數的機率算法包括:

  • Linear Counting(LC):早期的基數估計算法,LC 在空間複雜度方面並不算優秀,實際上 LC 的空間複雜度與上文中簡單 bitmap 方法是同樣的(可是有個常數項級別的下降),都是 O(N max)
  • LogLog Counting(LLC):LogLog Counting 相比於 LC 更加節省內存,空間複雜度只有 O(log 2(log 2(N max)))
  • HyperLogLog Counting(HLL):HyperLogLog Counting 是基於 LLC 的優化和改進,在一樣空間複雜度狀況下,可以比 LLC 的基數估計偏差更小

其中,HyperLogLog 的表現是驚人的,上面咱們簡單計算過用 bitmap 存儲 1 個億 統計數據大概須要 12 M 內存,而在 HyperLoglog 中,只須要不到 1 K 內存就可以作到!在 Redis 中實現的 HyperLoglog 也只須要 12 K 內存,在 標準偏差 0.81% 的前提下,可以統計 264 個數據

這是怎麼作到的?! 下面趕忙來了解一下!

2、HyperLogLog 原理

咱們來思考一個拋硬幣的遊戲:你連續擲 n 次硬幣,而後說出其中連續擲爲正面的最大次數,我來猜你一共拋了多少次

這很容易理解吧,例如:你說你這一次

最多連續出現了 2 次
正面,那麼我就能夠知道你這一次投擲的次數並很少,因此
我可能會猜是 5
或者是其餘小一些的數字,但若是你說你這一次
最多連續出現了 20 次
正面,雖然我以爲不可能,但我仍然知道你花了特別多的時間,因此
我說 GUN...

這期間我可能會要求你重複實驗,而後我獲得了更多的數據以後就會估計得更準。咱們來把剛纔的遊戲換一種說法

這張圖的意思是,咱們給定一系列的隨機整數,記錄下低位連續零位的最大長度 K,即爲圖中的 maxbit經過這個 K 值咱們就能夠估算出隨機數的數量 N

代碼實驗

咱們能夠簡單編寫代碼作一個實驗,來探究一下 KN 之間的關係:

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
複製代碼

會發現 KN 的對數之間存在顯著的線性相關性: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 的算法究竟是怎麼執行的:content.research.neustar.biz/blog/hll.ht…

其中的一些概念這裏稍微解釋一下,您就能夠自行去點擊 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

3、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"

4、HyperLogLog 的使用

HyperLogLog 提供了兩個指令 PFADDPFCOUNT,字面意思就是一個是增長,另外一個是獲取計數。PFADDset 集合的 SADD 的用法是同樣的,來一個用戶 ID,就將用戶 ID 塞進去就是,PFCOUNTSCARD 的用法是一致的,直接獲取計數值:

> 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 需求來講,這個偏差率真的不算高。

固然,除了上面的 PFADDPFCOUNT 以外,還提供了第三個 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
複製代碼

相關閱讀

  1. Redis(1)——5種基本數據結構 - www.wmyskxz.com/2020/02/28/…
  2. Redis(2)——跳躍表 - www.wmyskxz.com/2020/02/29/…
  3. Redis(3)——分佈式鎖深刻探究 - www.wmyskxz.com/2020/03/01/…

擴展閱讀

  1. 【算法原文】HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm - algo.inria.fr/flajolet/Pu…

參考資料

  1. 【Redis 做者博客】Redis new data structure: the HyperLogLog - antirez.com/news/75
  2. 神奇的HyperLogLog算法 - www.rainybowe.com/blog/2017/0…
  3. 深度探索 Redis HyperLogLog 內部數據結構 - zhuanlan.zhihu.com/p/43426875
  4. 《Redis 深度歷險》 - 錢文品/ 著
  • 本文已收錄至個人 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:github.com/wmyskxz/Mor…
  • 我的公衆號 :wmyskxz, 我的獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

很是感謝各位人才能 看到這裏,若是以爲本篇文章寫得不錯,以爲 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創做不易,各位的支持和承認,就是我創做的最大動力,咱們下篇文章見!

相關文章
相關標籤/搜索