若是要統計一篇文章的閱讀量,能夠直接使用 Redis 的 incr 指令來完成。若是要求閱讀量必須按用戶去重,那就可使用 set 來記錄閱讀了這篇文章的全部用戶 id,獲取 set 集合的長度就是去重閱讀量。可是若是爆款文章閱讀量太大,set 會浪費太多存儲空間。這時候咱們就要使用 Redis 提供的 HyperLogLog 數據結構來代替 set,它只會佔用最多 12k 的存儲空間就能夠完成海量的去重統計。可是它犧牲了準確度,它是模糊計數,偏差率約爲 0.81%。git
那麼有沒有一種不怎麼浪費空間的精確計數方法呢?咱們首先想到的就是位圖,可使用位圖的一個位來表示一個用戶id。若是一個用戶id是32字節,那麼使用位圖就只須要佔用 1/256 的空間就能夠完成精確計數。可是如何將用戶id映射到位圖的位置呢?若是用戶id是連續的整數這很好辦,可是一般用戶系統的用戶id並非整數,而是字符串或者是有必定隨機性的大整數。github
咱們能夠強行給每一個用戶id賦予一個整數序列,而後將用戶id和整數的對應關係存在redis中。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超過了一個閾值,纔會一次性將稀疏存儲轉換爲密集存儲。ui
咆哮位圖除了能夠大幅節約空間以外,還會下降 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 有。
這個項目的 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 類型用於在多個咆哮位圖之間共享塊,它還提供了寫複製功能。當這個塊被修改時將會複製出新的一份。
咆哮位圖的計算邏輯還有更多的細節,咱們後面有空再繼續介紹。