Redis 精確去重計數 —— 咆哮點陣圖

老錢發表於2019-06-04

如果要統計一篇文章的閱讀量,可以直接使用 Redis 的 incr 指令來完成。如果要求閱讀量必須按使用者去重,那就可以使用 set 來記錄閱讀了這篇文章的所有使用者 id,獲取 set 集合的長度就是去重閱讀量。但是如果爆款文章閱讀量太大,set 會浪費太多儲存空間。這時候我們就要使用 Redis 提供的 HyperLogLog 資料結構來代替 set,它只會佔用最多 12k 的儲存空間就可以完成海量的去重統計。但是它犧牲了準確度,它是模糊計數,誤差率約為 0.81%。

那麼有沒有一種不怎麼浪費空間的精確計數方法呢?我們首先想到的就是點陣圖,可以使用點陣圖的一個位來表示一個使用者id。如果一個使用者id是32位元組,那麼使用點陣圖就只需要佔用 1/256 的空間就可以完成精確計數。但是如何將使用者id對映到點陣圖的位置呢?如果使用者id是連續的整數這很好辦,但是通常使用者系統的使用者id並不是整數,而是字串或者是有一定隨機性的大整數。

我們可以強行給每個使用者id賦予一個整數序列,然後將使用者id和整數的對應關係存在redis中。

$next_user_id = incr user_id_seq
set user_id_xxx   $next_user_id
$next_user_id = incr user_id_seq
set user_id_yyy  $next_user_id
$next_user_id = incr user_id_seq
set user_id_zzz   $next_user_id
複製程式碼

這裡你也許會提出疑問,你說是為了節省空間,這裡儲存使用者id和整數的對映關係就不浪費空間了麼?這個問題提的很好,但是同時我們也要看到這個對映關係是可以複用的,它可以統計所有文章的閱讀量,還可以統計簽到使用者的日活、月活,還可以用在很多其它的需要使用者去重的統計場合中。所謂「功在當代,利在千秋」就是這個意思。

有了這個對映關係,我們就很容易構造出每一篇文章的閱讀打點點陣圖,來一個使用者,就將相應點陣圖中相應的位置為一。如果位從0變成1,那麼就可以給閱讀數加1。這樣就可以很方便的獲得文章的閱讀數。

而且我們還可以動態計算閱讀了兩篇文章的公共使用者量有多少?將兩個點陣圖做一下 AND 計算,然後統計點陣圖中位 1 的個數。同樣,還可以有 OR 計算、XOR 計算等等都是可行的。

問題又來了!Redis 的點陣圖是密集點陣圖,什麼意思呢?如果有一個很大的點陣圖,它只有最後一個位是 1,其它都是零,這個點陣圖還是會佔用全部的記憶體空間,這就不是一般的浪費了。你可以想象大部分文章的閱讀量都不大,但是它們的佔用空間卻是很接近的,和哪些爆款文章佔據的記憶體差不多。

看來這個方案行不通,我們需要想想其它方案!這時咆哮點陣圖(RoaringBitmap)來了。

它將整個大點陣圖進行了分塊,如果整個塊都是零,那麼這整個塊就不用存了。但是如果位1比較分散,每個塊裡面都有1,雖然單個塊裡的1很少,這樣只進行分塊還是不夠的,那該怎麼辦呢?我們再想想,對於單個塊,是不是可以繼續優化?如果單個塊內部位 1 個數量很少,我們可以只儲存所有位1的塊內偏移量(整數),也就是存一個整數列表,那麼塊內的儲存也可以降下來。這就是單個塊點陣圖的稀疏儲存形式 —— 儲存偏移量整數列表。只有單塊內的位1超過了一個閾值,才會一次性將稀疏儲存轉換為密集儲存。

咆哮點陣圖除了可以大幅節約空間之外,還會降低 AND、OR 等位運算的計算效率。以前需要計算整個點陣圖,現在只需要計算部分塊。如果塊內非常稀疏,那麼只需要對這些小整數列表進行集合的 AND、OR 運算,如是計算量還能繼續減輕。

這裡既不是用空間換時間,也沒有用時間換空間,而是用邏輯的複雜度同時換取了空間和時間。

咆哮點陣圖的位長最大為 2^32,對應的空間為 512M(普通點陣圖),位偏移被分割成高 16 位和低 16 位,高 16 位表示塊偏移,低16位表示塊內位置,單個塊可以表達 64k 的位長,也就是 8K 位元組。最多會有64k個塊。現代處理器的 L1 快取普遍要大於 8K,這樣可以保證單個塊都可以全部放入 L1 Cache,可以顯著提升效能。

如果單個塊所有的位全是零,那麼它就不需要儲存。具體某個塊是否存在也可以是用點陣圖來表達,當塊很少時,用整數列表表示,當塊多了就可以轉換成普通點陣圖。整數列表佔用的空間少,它還有類似於 ArrayList 的動態擴容機制避免反覆擴容複製陣列內容。當列表中的數字超出4096個時,會立即轉變成普通點陣圖。

用來表達塊是否存在的資料結構和表達單個塊資料的結構可以是同一個,因為塊是否存在本質上也是 0 和 1,就是普通的位標誌。

但是 Redis 並沒有原生支援咆哮點陣圖這個資料結構啊?我們該如何使用呢?

Redis 確實沒有原生的,但是咆哮點陣圖的 Redis Module 有。

github.com/aviggiano/r…

這個專案的 star 數量並不是很多,我們來看看它的官方效能對比

OP TIME/OP (us) ST.DEV. (us)
R.SETBIT 31.89 28.49
SETBIT 29.98 29.23
R.GETBIT 29.90 14.60
GETBIT 28.63 14.58
R.BITCOUNT 32.13 0.10
BITCOUNT 192.38 0.96
R.BITPOS 70.27 0.14
BITPOS 87.70 0.62
R.BITOP NOT 156.66 3.15
BITOP NOT 364.46 5.62
R.BITOP AND 81.56 0.48
BITOP AND 492.97 8.32
R.BITOP OR 107.03 2.44
BITOP OR 461.68 8.42
R.BITOP XOR 69.07 2.82
BITOP XOR 440.75 7.90

很明顯這裡對比的是稀疏點陣圖,只有稀疏點陣圖才可以呈現出這樣好看的數字。如果是密集點陣圖,咆哮點陣圖的效能肯定要稍弱於普通點陣圖,但是通常也不會弱太多。

下面我們來觀察一下原始碼看看它的內部結構是怎樣的

// 單個塊
typedef struct roaring_array_s {
    int32_t size;
    int32_t allocation_size;
    void **containers;  // 指向整數陣列或者普通點陣圖
    uint16_t *keys;
    uint8_t *typecodes;
    uint8_t flags;
} roaring_array_t;

// 所有塊
typedef struct roaring_bitmap_s {
    roaring_array_t high_low_container;
} roaring_bitmap_t;
複製程式碼

很明顯可以看到塊存在與否和塊內資料都是使用同樣的資料結構表達的,它們都是 roaring_bitmap_t。這個結構裡面有多種編碼形式,型別使用 typecodes 欄位來表示。

#define BITSET_CONTAINER_TYPE_CODE 1
#define ARRAY_CONTAINER_TYPE_CODE 2
#define RUN_CONTAINER_TYPE_CODE 3
#define SHARED_CONTAINER_TYPE_CODE 4
複製程式碼

看到這裡的型別定義,我們發現它不止前面提到的普通點陣圖和陣列列表兩種形式,還有 RUN 和 SHARED 這兩種型別。RUN 形式是點陣圖的壓縮形式,比如連續的幾個位 101,102,103,104,105,106,107,108,109 表示成 RUN 後就是 101,8(1 後面是 8 個自增的整數),這樣在空間上就可以明顯壓縮不少。在正常情況下咆哮點陣圖內部沒有 RUN 型別的塊。只有顯示呼叫了咆哮點陣圖的優化 API 才會轉換成 RUN 格式,這個 API 是 roaring_bitmap_run_optimize。

而 SHARED 型別用於在多個咆哮點陣圖之間共享塊,它還提供了寫複製功能。當這個塊被修改時將會複製出新的一份。

咆哮點陣圖的計算邏輯還有更多的細節,我們後面有空再繼續介紹。

Redis 精確去重計數  —— 咆哮點陣圖

相關文章