Java架構直通車——Redis的PF實現原理:HyperLogLog

引入

之前的文章Java架構直通車——點贊功能用Mysql還是Redis?一文中,我們介紹了分別從mysql和redis實現點贊功能統計的可行性。

這裏要介紹一個HyperLogLog算法,雖然應用場景不同,但是兩者還是具有一定的相似之處的。

HyperLogLog 是最早由 Flajolet 及其同事在 2007 年提出的一種 估算基數的近似最優算法。但跟原版論文不同的是,好像很多書包括 Redis 作者都把它稱爲一種 新的數據結構(new datastruct) (算法實現確實需要一種特定的數據結構來實現)。

什麼是基數統計

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

如果統計 PV(瀏覽量,用戶每點一次記錄一次) ,那非常好辦,給每個頁面配置一個獨立的 Redis 計數器就可以了,把這個計數器的 key 後綴加上當天的日期。這樣每來一個請求,就執行 INCRBY 指令一次,最終就可以統計出所有的 PV 數據了。

但是 UV 不同,它要去重,同一個用戶一天之內的多次訪問請求只能計數一次。這就要求了每一個網頁請求都需要帶上用戶的 ID,無論是登錄用戶還是未登錄的用戶,都需要一個唯一 ID 來標識。

你也許馬上就想到了一個 簡單的解決方案:那就是 爲每一個頁面設置一個獨立的 set 集合 來存儲所有當天訪問過此頁面的用戶 ID(之前redis的方案不就是使用set或者hash來保存的嘛)。

如果採用set:

  • 存儲空間巨大: 如果網站訪問量一大,你需要用來存儲的 set 集合就會非常大,如果頁面再一多… 爲了一個去重功能耗費的資源就可以直接讓你 老闆打死你;
  • 統計複雜: 如果多個 set 集合如果要聚合統計一下,又是一個複雜的事情;

實現一個簡單的統計UV的功能就要耗費這麼大的空間和時間,得不償失。所以可以採用統計學的方法,並不是像點贊功能那樣要精確到某個人是否真正點了贊,可以 允許有誤差

基數統計的常用方法

  • 第一種:B 樹

B 樹最大的優勢就是插入和查找效率很高,如果用 B 樹存儲要統計的數據,可以快速判斷新來的數據是否存在,並快速將元素插入 B 樹。要計算基礎值,只需要計算 B 樹的節點個數就行了。

不過將 B 樹結構維護到內存中,能夠解決統計和計算的問題,但是 並沒有節省內存

  • 第二種:bitmap

bitmap 可以理解爲通過一個 bit 數組來存儲特定數據的一種數據結構,每一個 bit 位都能獨立包含信息,bit 是數據的最小存儲單位,因此能大量節省空間,也可以將整個 bit 數據一次性 load 到內存計算。
如果定義一個很大的 bit 數組,基礎統計中 每一個元素對應到 bit 數組中的一位,例如:bit數組 001101001001101001代表實際數組[2,3,5,8]。新加入一個元素,只需要將已有的bit數組和新加入的數字做按位或 (or)(or)計算。bitmap中1的數量就是集合的基數值。沒錯就和我們之前的布隆過濾器差不多的思想,是一種不準確的統計。

bitmap對多個結果求異或可以大大減少存儲內存。可以簡單做一個計算,如果要統計 1 億 個數據的基數值,大約需要的內存:100_000_000/ 8/ 1024/ 1024 ≈ 12 Mb

HyperLogLog原理

HyperLogLog 的表現是驚人的,用 bitmap 存儲 1 個億 統計數據大概需要 12 M 內存,而在 HyperLoglog 中,只需要不到 1 K 內存就能夠做到!在 Redis 中實現的 HyperLoglog 也只需要 12 K 內存,在 標準誤差 0.81% 的前提下,能夠統計 264 個數據!

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

比如說,你說你這一次 最多連續出現了 2 次 正面,那麼我就可以知道你這一次投擲的次數並不多,所以 我可能會猜是 5 或者是其他小一些的數字,但如果你說你這一次 最多連續出現了 20 次 正面,雖然我覺得不可能,但我仍然知道你花了特別多的時間,所以 我說 GUN…。

我們知道連續出現20次正面的可能性是 (12)20(\frac{1}{2})^{20}

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

在這裏插入圖片描述
這張圖的意思是,我們給定一系列的隨機整數,記錄下低位連續零位的最大長度maxbit,當maxbit=16的時候,n是多少
換句話說,可以通過maxbit獲知n的值大概是多少。

具體方式,可以寫代碼來估算下。

import java.util.Random;

/**
 * Author: leesanghyuk
 * Date: 2020-06-29 09:16
 * Description:
 */
public class Solution {
    class PFBitMap{
        private int maxbit;//最低位連續長度
        private int n;//統計n次

        public PFBitMap(int n) {
            this.n = n;
        }

        /**
         * 獲取在n次實驗統計下,maxbit與n的關係
         */
        public void getPFResult(){
            for (int i = 0; i < n; i++) {
                random();//產生一個隨機數
            }
            System.out.printf("%d %.2f %d\n",
                    n, Math.log(n) / Math.log(2), maxbit);
        }

        /**
         * 產生一個隨機值
         */
        private void random() {
            long value = new Random().nextLong();
            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;
        }
    }



    public static void main(String[] args) {
        for (int i=1000;i<100000;i+=5000){
            PFBitMap pfBitMap=new Solution().new PFBitMap(i);
            pfBitMap.getPFResult();
        }
    }
}

輸出爲:

1000 9.97 11
6000 12.55 13
11000 13.43 13
16000 13.97 13
21000 14.36 15
26000 14.67 16
31000 14.92 17
36000 15.14 15
41000 15.32 17
46000 15.49 16
51000 15.64 15
56000 15.77 18
61000 15.90 15
66000 16.01 15
71000 16.12 19
76000 16.21 14
81000 16.31 15
86000 16.39 17
91000 16.47 16
96000 16.55 17

經過統計,maxbit 和 n 的對數之間存在顯著的線性相關性:n 約等於 2maxbit2^{maxbit}

再近一步:分桶平均

如果 N 介於 2k 和 2k+1 之間,用這種方式估計的值都等於 2k,這明顯是不合理的,所以我們可以使用多個 PFBitMap 進行加權估計。其中的Random函數改成多線程的ThreadLocalRandom.current().nextLong(1L << 32);即可,然後求加權平均。

這個過程有點 類似於選秀節目裏面的打分,一堆專業評委打分,但是有一些評委因爲自己特別喜歡所以給高了,一些評委又打低了,所以一般都要 屏蔽最高分和最低分,然後 再計算平均值,這樣的出來的分數就差不多是公平公正的了。

觀察腳本的輸出,誤差率百分比控制在個位數:

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

真實的 HyperLogLog 要比上面的算法更加複雜一些,也更加精確一些。但是原理已經再之前說清楚了。

有一個神奇的網站,可以動態地讓你觀察到 HyperLogLog 的算法到底是怎麼執行的。

Redis 中的 HyperLogLog 實現提供了兩個指令 PFADDPFCOUNT,字面意思就是一個是增加,另一個是獲取計數。PFADD 和 set 集合的 SADD 的用法是一樣的,來一個用戶 ID,就將用戶 ID 塞進去就是,PFCOUNT 和 SCARD 的用法是一致的,直接獲取計數值:

> PFADD codehole user1
(interger) 1
> PFCOUNT codehole
(integer) 1

我們可以用 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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章