Redis 高級主題之HyperLogLog

1. 基數計數

在瞭解 HyperLogLog 以前,先來簡單瞭解一下基數計數(Cardinality Counting).python

1.1 概念

基數計數是用於統計一個集合中不重複的元素個數,好比平常需求場景有,統計頁面的UV或者統計在線的用戶數、註冊IP數等。git

若是讓你實現這個需求,會怎麼思考實現了?簡單的作法就是記錄集合中的全部不重複的 集合S,新來一個元素x,首先判斷x在不在S中,若是不在,則將x加入到S,不然不記錄。經常使用的SET數據結構就能夠實現。github

可是這樣實現,若是數據量愈來愈大,會形成什麼問題?redis

  • 當統計的數據量變大時,相應的存儲內存會線性增加。
  • 當集合S越大時,判斷x元素是否在集合S中的所花的成本會越大。

還有別的方案能減小上面2個問題帶來的困擾嗎,答案確定是有的,下面簡單介紹一下。算法

1.2 方法

經常使用的基數計數有三種: B+樹、bitmap、機率算法。數組

  • B+ 樹。 B+ 樹插入和查找效率比較高。能夠快速查找元素是否存在,以及進行插入。若是要計算基數值(不重複的元素值),則只須要樹的節點個數便可。可是依然存在沒有節省內存空間的問題。
  • bitmap。 bitmap 是經過一個bit數組來存在特定數據的一種數據結構。基數計數則將每個元素對應到bit數組的其中一位,好比Bit數組010010101,表明[1,4,6,8]。新加入的元素只須要已有的Bit數組和新加入的元素進行按位或計算。這種方式能夠大大減小內存,若是存儲1億數據的話,大概只須要 100000000/8/1024/1024 ≈ 12M 的內存。 相比B+樹確實節省很多,可是在某些很是大數據的場景下,若是有10000個對象有1億數據,則須要120G內存,能夠說在特定場景下內存的消耗仍是蠻大的。
  • 機率算法,機率算法是經過犧牲準確率來換取空間,對於不要求絕對準確率的場景下,機率算法是一種不錯的選擇,由於機率算法不直接存儲數據集合自己,經過必定的機率統計方法預估基數值,同時保證偏差在必定範圍內,這種方式能夠大大減小內存。HyperLogLog就是機率算法的一種實現,下面重點介紹一下此算法。

2. HyperLogLog

2.1 原理

HyperLogLog 原理思路是經過給定 n 個的元素集合,記錄集合中數字的比特串第一個1出現位置的最大值k,也能夠理解爲統計二進制低位連續爲零的最大個數。經過k值能夠估算集合中不重複元素的數量m,m近似等於2^k。網絡

下圖來源於網絡,經過給定必定數量的用戶User,經過Hash獲得一串Bitstring,記錄其中最大連續零位的計數爲4,User的不重複個數爲 2 ^ 4 = 16.數據結構

下面代碼演示一下。dom

2.2 代碼演示

代碼有部分參考https://kuaibao.qq.com/s/20180917G0N2C300?refer=cp_1026測試

# content of hyperloglog_test.py
class BitsBucket(object):
    def __init__(self):
        self.maxbit = 0

 @staticmethod
    def get_zeros(value):
        for i in range(31):
            if (value >> i) & 1:
                break
        return i

    def add(self, m):
        self.maxbit = max(self.maxbit, self.get_zeros(m))

class HyperLogLogTest(object):
    def __init__(self, n, bucket_cnt=1024):
        self.n = n
        self.bucket_cnt = bucket_cnt
        self.bits_bucket = [BitsBucket() for i in range(bucket_cnt)]

 @staticmethod
    def generate_value():
        return random.randint(1, 2**32 - 1)

    def pfadd(self):
        for i in range(self.n):
            value = self.generate_value()
            bucket = self.bits_bucket[((value & 0xfff0000) >> 16) % self.bucket_cnt]
            bucket.add(value)

    def pfcount(self):
        sumbits_inverse = 0
        for bucket in self.bits_bucket:
            if bucket.maxbit == 0:
                continue
            sumbits_inverse += 1.0 / float(bucket.maxbit)
        avgbits = float(self.bucket_cnt) / sumbits_inverse
        return 2**avgbits * self.bucket_cnt
複製代碼

BitsBucket 類,是計算一個集合中連續低位的最大個數,HyperLogLogTest實現2個方法,pfadd是隨機n個元素,將元素加入某一集合桶中,pfcount是算出bucket_cnt個桶的平均基數計數值。

爲何會去計算bucket_cnt桶了,由於此算法隨機機率性,若是一個桶,偏差率很是大,而後就提出了分桶平均的概念,將統計數據劃分爲m個桶,每一個桶分別統計各自的基數預估值,最後對這些預估值求平均獲得總體的基數估計值。

如今測試一下:

# content of hyperloglog_test.py
def main(bucket_cnt=1024):
    print("bucket cnt: {}, start".format(bucket_cnt))
    for i in range(100000, 1000000, 100000):
        hyperloglog = HyperLogLogTest(i, bucket_cnt)
        hyperloglog.pfadd()
        pfcount = hyperloglog.pfcount()
        print("original count: {} ".format(i),
              "pfcount: {}".format('%.2f' % pfcount), "error rate: {}%".format(
                  '%.2f' % (abs(pfcount - i) / i * 100)))
    print("bucket cnt: {}, end \n\n".format(bucket_cnt))


buckets = [1, 1024]
for cnt in buckets:
    main(cnt)
複製代碼

分別對 bucket_cnt 爲1 和 1024 進行測試,結果以下:

➜  HyperLogLog git:(master) ✗ python3 hyperloglog_test.py
bucket cnt: 1, start
original count: 100000  pfcount: 65536.00 error rate: 34.46%
original count: 200000  pfcount: 131072.00 error rate: 34.46%
original count: 300000  pfcount: 131072.00 error rate: 56.31%
original count: 400000  pfcount: 524288.00 error rate: 31.07%
original count: 500000  pfcount: 1048576.00 error rate: 109.72%
original count: 600000  pfcount: 2097152.00 error rate: 249.53%
original count: 700000  pfcount: 262144.00 error rate: 62.55%
original count: 800000  pfcount: 1048576.00 error rate: 31.07%
original count: 900000  pfcount: 262144.00 error rate: 70.87%
bucket cnt: 1, end

bucket cnt: 1024, start
original count: 100000  pfcount: 97397.13 error rate: 2.60%
original count: 200000  pfcount: 192659.65 error rate: 3.67%
original count: 300000  pfcount: 287909.86 error rate: 4.03%
original count: 400000  pfcount: 399678.34 error rate: 0.08%
original count: 500000  pfcount: 515970.76 error rate: 3.19%
original count: 600000  pfcount: 615906.34 error rate: 2.65%
original count: 700000  pfcount: 735321.47 error rate: 5.05%
original count: 800000  pfcount: 808206.55 error rate: 1.03%
original count: 900000  pfcount: 950692.17 error rate: 5.63%
bucket cnt: 1024, end
複製代碼

能夠看到bucket_cnt=1,偏差很是大,爲1024時則算法基本可使用。而Redis中實現的HyperLogLog更復雜,能夠控制偏差在0.81%。下面重點看看Redis中HyperLogLog的應用。

3. Redis中HyperLogLog實現

Redis中HyperLogLog在 2.8.9 版本中出現,想了解其中細節,能夠查看Redis做者antirez寫的一篇博文:Redis new data structure: the HyperLogLog

3.1 用法

用法涉及到3個命令:

  • pfadd 增長一個元素到key中
  • pfcount 統計key中不重複元素的個數
  • Pfmerge 合併多個Key中的元素
127.0.0.1:6379> PFADD pf_tc tc01
(integer) 1
127.0.0.1:6379> PFADD pf_tc tc02
(integer) 1
127.0.0.1:6379> PFADD pf_tc tc03
(integer) 1
127.0.0.1:6379> PFADD pf_tc tc04 tc05 tc06
(integer) 1
127.0.0.1:6379> PFCOUNT pf_tc
(integer) 6
127.0.0.1:6379> PFADD pf_tc tc04 tc05 tc06
(integer) 0
127.0.0.1:6379> PFCOUNT pf_tc
(integer) 6

127.0.0.1:6379> PFADD pf_tc01 tc07 tc08 tc09 tc10 tc01 tc02 tc03
(integer) 1
127.0.0.1:6379> PFCOUNT pf_tc01
(integer) 7
127.0.0.1:6379> PFMERGE pf_tc pf_tc01
OK
127.0.0.1:6379> PFCOUNT pf_tc
(integer) 10
127.0.0.1:6379> PFCOUNT pf_tc01
(integer) 7
複製代碼

感受是否是很準,接下來寫個腳本測試一下。

3.2 偏差分析

下面寫一段Python代碼測試一下偏差

class HyperLogLogRedis(object):
    def __init__(self, n):
        self.n = n
        self.redis_client = redis.StrictRedis()
        self.key = "pftest:{}".format(n)

 @staticmethod
    def generate_value():
        return random.randint(1, 2**32 - 1)

    def pfadd(self):
        for i in range(self.n):
            value = self.generate_value()
            self.redis_client.pfadd(self.key, value)

    def pfcount(self):
        return self.redis_client.pfcount(self.key)


def main():
    for i in range(100000, 1000000, 100000):
        hyperloglog = HyperLogLogRedis(i)
        hyperloglog.pfadd()
        pfcount = hyperloglog.pfcount()
        print("original count: {} ".format(i),
              "pfcount: {}".format('%.2f' % pfcount), "error rate: {}%".format(
                  '%.2f' % (abs(pfcount - i) / i * 100)))

main()
複製代碼

代碼部分仍是在2.2的基礎稍微改動,將redis的HyperLogLog功能替換以前本身測試的部分。

測試結果以下:

➜  HyperLogLog git:(master) ✗ python3 hyperloglog_redis.py
original count: 100000  pfcount: 99763.00 error rate: 0.24%
original count: 200000  pfcount: 200154.00 error rate: 0.08%
original count: 300000  pfcount: 298060.00 error rate: 0.65%
original count: 400000  pfcount: 394419.00 error rate: 1.40%
original count: 500000  pfcount: 496263.00 error rate: 0.75%
original count: 600000  pfcount: 595397.00 error rate: 0.77%
original count: 700000  pfcount: 712731.00 error rate: 1.82%
original count: 800000  pfcount: 793678.00 error rate: 0.79%
original count: 900000  pfcount: 899268.00 error rate: 0.08%
複製代碼

基本偏差都在 0.81% 左右,爲何標準的偏差是0.81%了,由於Redis中用了16384個桶,HyperLogLog的標準偏差公式是1.04/sqrt(m), m是桶的個數,因此在Redis中,m=16384,標準偏差則爲0.81%。

3.3 內存分析

Redis採用了16384個桶來存儲計算HyperLogLog,那所佔的內存會是多少? Redis最大能夠統計2^64個數據,也就是說每一個桶的最大maxbits須要 6 個bit來存儲(2^6=64)。那麼所佔內存就是 16384 * 6 / 8 = 12kb。

第一節提到 BitMap 1億數據就須要 12M,若是 2^64個數據,粗略計算須要 1500 TB,而 HyperLogLog 只須要12kb,能夠想象HyperLogLog的強大,但這裏並非說bitmap很差,每個數據結構都有它最適合的應用場景,只能說在基數統計的場景中HyperLogLog是目前很是強大的算法。

若是元素個數很少時,Redis會採用稀疏存儲結構,其大小會少於12kb,採用密集存儲結構,大小固定爲12kb,存儲的實現採用Redis的字符串位圖bitmap實現,即連續個16384個桶,每一個桶佔6個Bits。

更多的細節能夠閱讀Redis的源碼:github.com/antirez/red…

相關文章:

相關代碼在github.com/fuzctc/tc-r…

更多Redis相關文章和討論,請關注公衆號:『 天澄技術雜談 』

相關文章
相關標籤/搜索