Redis HyperLogLog 是什麼?這些場景使用它,讓我槍出如龍,一笑破蒼穹

碼哥位元組發表於2022-04-13

在移動網際網路的業務場景中,資料量很大,我們需要儲存這樣的資訊:一個 key 關聯了一個資料集合,同時對這個資料集合做統計。

比如:

  • 統計一個 APP 的日活、月活數;
  • 統計一個頁面的每天被多少個不同賬戶訪問量(Unique Visitor,UV));
  • 統計使用者每天搜尋不同詞條的個數;
  • 統計註冊 IP 數。

通常情況下,我們面臨的使用者數量以及訪問量都是巨大的,比如百萬、千萬級別的使用者數量,或者千萬級別、甚至億級別的訪問資訊。

今天「碼哥」分別使用不同的資料型別來實現:統計一個頁面的每天被多少個不同賬戶訪問量這個功能,循序漸進的引出 HyperLogLog的原理與 Java 中整合 Redission 實戰。

告訴大家一個技巧,Redis 官方網站現在能線上執行 Redis 指令了:https://redis.io/。如圖:

Redis 線上執行

使用 Set 實現

一個使用者一天內多次訪問一個網站只能算作一次,所以很容易就想到通過 Redis 的 Set 集合來實現。

比如微信 ID為「肖菜雞」訪問 「Redis為什麼這麼快」這篇文章時,我們把這個資訊存到 Set 中。

SADD Redis為什麼這麼快:uv 肖菜雞 謝霸哥 肖菜雞
(integer) 1

「肖菜雞」多次訪問「Redis為什麼這麼快」頁面,Set 的去重功能保證不會重複記錄同一個「微信 ID」。

通過 SCARD 命令,統計「Redis 為什麼這麼快」頁面 UV。指令返回一個集合的元素個數(也就是使用者 ID)。

SCARD Redis為什麼這麼快:uv
(integer) 2

使用 Hash 實現

碼老溼,還可以利用 Hash 型別實現,將使用者 ID 作為 Hash 集合的 key,訪問頁面則執行 HSET 命令將 value 設定成 1。

即使「肖菜雞」重複訪問頁面,重複執行命令,也只會把 key 等於「肖菜雞」的 value 設定成 1。

最後,利用 HLEN 命令統計 Hash 集合中的元素個數就是 UV。

如下:

HSET Redis為什麼這麼快 肖菜雞 1
// 統計 UV
HLEN Redis為什麼這麼快

使用 Bitmap 實現

Bitmap 的底層資料結構用的是 String 型別的 SDS 資料結構來儲存位陣列,Redis 把每個位元組陣列的 8 個 bit 位利用起來,每個 bit 位 表示一個元素的二值狀態(不是 0 就是 1)。

Bitmap 提供了 GETBIT、SETBIT 操作,通過一個偏移值 offset 對 bit 陣列的 offset 位置的 bit 位進行讀寫操作,需要注意的是 offset 從 0 開始。

可以將 Bitmap 看成是一個 bit 為單位的陣列,陣列的每個單元只能儲存 0 或者 1,陣列的下標在 Bitmap 中叫做 offset 偏移量。

為了直觀展示,我們可以理解成 buf 陣列的每個位元組用一行表示,每一行有 8 個 bit 位,8 個格子分別表示這個位元組中的 8 個 bit 位,如下圖所示:

Bitmap

8 個 bit 組成一個 Byte,所以 Bitmap 會極大地節省儲存空間。 這就是 Bitmap 的優勢。

如何使用 Bitmap 來統計頁面的獨立使用者訪問量呢?

Bitmap 提供了 SETBIT 和 BITCOUNT 操作,前者通過一個偏移值 offset 對 bit 陣列的 offset 位置的 bit 位進行寫操作,需要注意的是 offset 從 0 開始。

後者統計給定指定的 bit 陣列中,值 = 1 的 bit 位的數量。

需要注意的事,我們需要把「微信 ID」轉換成數字,因為offset 是下標。

假設我們將「肖菜雞」轉換成編碼6

第一步,執行下面指令表示「肖菜雞」的編碼為 6 並 訪問「巧用Redis 資料型別實現億級資料統計」這篇文章。

SETBIT 巧用Redis資料型別實現億級資料統計 6 1

第二步,統計頁面訪問次數,使用 BITCOUNT 指令。該指令用於統計給定的 bit 陣列中,值 = 1 的 bit 位的數量。

BITCOUNT 巧用Redis資料型別實現億級資料統計

HyperLogLog 王者方案

Set 雖好,如果文章非常火爆達到千萬級別,一個 Set 就儲存了千萬個使用者的 ID,頁面多了消耗的記憶體也太大了。

同理,Hash資料型別也是如此。

至於 Bitmap,它更適合於「二值狀態統計」的使用場景,統計精度高,雖然記憶體佔用要比HashMap少,但是對於大量資料還是會佔用較大記憶體。

咋辦呢?

這些就是典型的「基數統計」應用場景,基數統計:統計一個集合中不重複元素的個數。

HyperLogLog 的優點在於它所需的記憶體並不會因為集合的大小而改變,無論集合包含的元素有多少個,HyperLogLog進行計算所需的記憶體總是固定的,並且是非常少的

每個 HyperLogLog 最多隻需要花費 12KB 記憶體,在標準誤差 0.81%的前提下,就可以計算 2 的 64 次方個元素的基數。

Redis 實戰

HyperLogLog 使用太簡單了。PFADD、PFCOUNT、PFMERGE三個指令打天下。

PFADD

將訪問頁面的每個使用者 ID 新增到 HyperLogLog 中。

PFADD Redis主從同步原理:uv userID1 userID 2 useID3

PFCOUNT

利用 PFCOUNT 獲取 「Redis主從同步原理」文章的 UV值。

PFCOUNT Redis主從同步原理:uv

PFMERGE 使用場景

HyperLogLog` 除了上面的 `PFADD` 和 `PFCOIUNT` 外,還提供了 `PFMERGE

語法

PFMERGE destkey sourcekey [sourcekey ...]

比如在網站中我們有兩個內容差不多的頁面,運營說需要這兩個頁面的資料進行合併。

其中頁面的 UV 訪問量也需要合併,那這個時候 PFMERGE 就可以派上用場了,也就是同樣的使用者訪問這兩個頁面則只算做一次

如下所示:Redis、MySQL 兩個 HyperLogLog 集合分別儲存了兩個頁面使用者訪問資料。

PFADD Redis資料 user1 user2 user3
PFADD MySQL資料 user1 user2 user4
PFMERGE 資料庫 Redis資料 MySQL資料
PFCOUNT 資料庫 // 返回值 = 4

將多個 HyperLogLog 合併(merge)為一個 HyperLogLog , 合併後的 HyperLogLog 的基數接近於所有輸入 HyperLogLog 的可見集合(observed set)的並集。

user1、user2 都訪問了 Redis 和 MySQL,只算訪問了一次。

Redission 實戰

詳細原始碼「碼哥」上傳到 GitHub 了:https://github.com/MageByte-Zero/springboot-parent-pom.git

pom 依賴

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.16.7</version>
</dependency>

新增資料到 Log

// 新增單個元素
public <T> void add(String logName, T item) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  hyperLogLog.add(item);
}

// 將集合資料新增到 HyperLogLog
public <T> void addAll(String logName, List<T> items) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  hyperLogLog.addAll(items);
}

合併

/**
 * 將 otherLogNames 的 log 合併到 logName
 *
 * @param logName       當前 log
 * @param otherLogNames 需要合併到當前 log 的其他 logs
 * @param <T>
 */
public <T> void merge(String logName, String... otherLogNames) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  hyperLogLog.mergeWith(otherLogNames);
}

統計基數

public <T> long count(String logName) {
  RHyperLogLog<T> hyperLogLog = redissonClient.getHyperLogLog(logName);
  return hyperLogLog.count();
}

單元測試

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = RedissionApplication.class)
public class HyperLogLogTest {

    @Autowired
    private HyperLogLogService hyperLogLogService;

    @Test
    public void testAdd() {
        String logName = "碼哥位元組:Redis為什麼這麼快:uv";
        String item = "肖菜雞";
        hyperLogLogService.add(logName, item);
        log.info("新增元素[{}]到 log [{}] 中。", item, logName);
    }

    @Test
    public void testCount() {
        String logName = "碼哥位元組:Redis為什麼這麼快:uv";
        long count = hyperLogLogService.count(logName);
        log.info("logName = {} count = {}.", logName, count);
    }

    @Test
    public void testMerge() {
        ArrayList<String> items = new ArrayList<>();
        items.add("肖菜雞");
        items.add("謝霸哥");
        items.add("陳小白");

        String otherLogName = "碼哥位元組:Redis多執行緒模型原理與實戰:uv";
        hyperLogLogService.addAll(otherLogName, items);
        log.info("新增 {} 個元素到 log [{}] 中。", items.size(), otherLogName);

        String logName = "碼哥位元組:Redis為什麼這麼快:uv";
        hyperLogLogService.merge(logName, otherLogName);
        log.info("將 {} 合併到 {}.", otherLogName, logName);

        long count = hyperLogLogService.count(logName);
        log.info("合併後的 count = {}.", count);
    }
}

基本原理

HyperLogLog 是一種概率資料結構,它使用概率演算法來統計集合的近似基數。而它演算法的最本源則是伯努利過程。

伯努利過程就是一個拋硬幣實驗的過程。拋一枚正常硬幣,落地可能是正面,也可能是反面,二者的概率都是 1/2

伯努利過程就是一直拋硬幣,直到落地時出現正面位置,並記錄下拋擲次數k

比如說,拋一次硬幣就出現正面了,此時 k1; 第一次拋硬幣是反面,則繼續拋,直到第三次才出現正面,此時 k 為 3。

對於 n 次伯努利過程,我們會得到 n 個出現正面的投擲次數值 k1, k2 ... kn , 其中這裡的最大值是 k_max

根據一頓數學推導,我們可以得出一個結論: 2^{k_ max} 來作為n的估計值。

也就是說你可以根據最大投擲次數近似的推算出進行了幾次伯努利過程。

所以 HyperLogLog 的基本思想是利用集合中數字的位元串第一個 1 出現位置的最大值來預估整體基數,但是這種預估方法存在較大誤差,為了改善誤差情況,HyperLogLog中引入分桶平均的概念,計算 m 個桶的調和平均值。

Redis 中 HyperLogLog 一共分了 2^14 個桶,也就是 16384 個桶。每個桶中是一個 6 bit 的陣列,如下圖所示。

圖片來源:程式設計師歷小冰

關於 HyperLogLog 的原理過於複雜,如果想要了解的請移步:

Redis 對 HyperLogLog 的儲存進行了優化,在計數比較小的時候,儲存空間採用係數矩陣,佔用空間很小。

只有在計數很大,稀疏矩陣佔用的空間超過了閾值才會轉變成稠密矩陣,佔用 12KB 空間。

為何只需要 12 KB 呀?

HyperLogLog 實現中用到的是 16384 個桶,也就是 2^14,每個桶的 maxbits 需要 6 個 bits 來儲存,最大可以表示 maxbits=63,於是總共佔用記憶體就是2^14 * 6 / 8 = 12k位元組。

總結

分別使用了 HashBitmapHyperLogLog 來實現:

  • Hash:演算法簡單,統計精度高,少量資料下使用,對於海量資料會佔據大量記憶體;

  • Bitmap:點陣圖演算法,適合用於「二值統計場景」,具體可參考我這篇文章,對於大量不同頁面資料統計還是會佔用較大記憶體。

  • Set:利用去重特性實現,一個 Set 就儲存了千萬個使用者的 ID,頁面多了消耗的記憶體也太大了。在 Redis 裡面,每個 HyperLogLog 鍵只需要花費 12 KB 記憶體,就可以計算接近 2^64 個不同元素的基數。因為 HyperLogLog 只會根據輸入元素來計算基數,而不會儲存輸入元素本身,所以 HyperLogLog 不能像集合那樣,返回輸入的各個元素。

  • HyperLogLog是一種演算法,並非 Redis 獨有

  • 目的是做基數統計,故不是集合,不會儲存後設資料,只記錄數量而不是數值

  • 耗空間極小,支援輸入非常體積的資料量

  • 核心是基數估算演算法,主要表現為計算時記憶體的使用和資料合併的處理。最終數值存在一定誤差

  • Redis中每個Hyperloglog key佔用了12K的記憶體用於標記基數(官方文件)

  • pfadd 命令並不會一次性分配12k記憶體,而是隨著基數的增加而逐漸增加記憶體分配;而pfmerge操作則會將sourcekey合併後儲存在12k大小的key中,由hyperloglog合併操作的原理(兩個Hyperloglog合併時需要單獨比較每個桶的值)可以很容易理解。

  • 誤差說明:基數估計的結果是一個帶有 0.81% 標準錯誤(standard error)的近似值。是可接受的範圍

  • RedisHyperLogLog 的儲存進行優化,在計數比較小時,儲存空間採用稀疏矩陣儲存,空間佔用很小,僅僅在計數慢慢變大,稀疏矩陣佔用空間漸漸超過了閾值時才會一次性轉變成稠密矩陣,才會佔用 12k 的空間

好文推薦

參考資料

[2]: Redis 使用手冊

相關文章