Redis 實戰篇:巧用資料型別實現億級資料統計

碼哥位元組發表於2021-08-08

在移動應用的業務場景中,我們需要儲存這樣的資訊:一個 key 關聯了一個資料集合,同時還要對集合中的資料進行統計排序。

常見的場景如下:

  • 給一個 userId ,判斷使用者登陸狀態;
  • 兩億使用者最近 7 天的簽到情況,統計 7 天內連續簽到的使用者總數;
  • 統計每天的新增與第二天的留存使用者數;
  • 統計網站的對訪客(Unique Visitor,UV)量
  • 最新評論列表
  • 根據播放量音樂榜單

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

所以,我們必須要選擇能夠非常高效地統計大量資料(例如億級)的集合型別。

如何選擇合適的資料集合,我們首先要了解常用的統計模式,並運用合理的資料了性來解決實際問題。

四種統計型別:

  1. 二值狀態統計;
  2. 聚合統計;
  3. 排序統計;
  4. 基數統計。

本文將用到 String、Set、Zset、List、hash 以外的擴充資料型別 BitmapHyperLogLog 來實現。

文章涉及到的指令可以通過線上 Redis 客戶端執行除錯,地址:https://try.redis.io/,超方便的說。

寄語

多分享多付出,前期多給別人創造價值並且不計回報,從長遠來看,這些付出都會成倍的回報你。

特別是剛開始跟別人合作的時候,不要去計較短期的回報,沒有太大意義,更多的是鍛鍊自己的視野、視角以及解決問題的能力。

二值狀態統計

碼哥,什麼是二值狀態統計呀?

也就是集合中的元素的值只有 0 和 1 兩種,在簽到打卡和使用者是否登陸的場景中,只需記錄簽到(1)未簽到(0)已登入(1)未登陸(0)

假如我們在判斷使用者是否登陸的場景中使用 Redis 的 String 型別實現(key -> userId,value -> 0 表示下線,1 - 登陸),假如儲存 100 萬個使用者的登陸狀態,如果以字串的形式儲存,就需要儲存 100 萬個字串了,記憶體開銷太大。

對於二值狀態場景,我們就可以利用 Bitmap 來實現。比如登陸狀態我們用一個 bit 位表示,一億個使用者也只佔用 一億 個 bit 位記憶體 ≈ (100000000 / 8/ 1024/1024)12 MB。

大概的空間佔用計算公式是:($offset/8/1024/1024) MB

什麼是 Bitmap 呢?

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

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

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

Bitmap

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

判斷使用者登陸態

怎麼用 Bitmap 來判斷海量使用者中某個使用者是否線上呢?

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

只需要一個 key = login_status 表示儲存使用者登陸狀態集合資料, 將使用者 ID 作為 offset,線上就設定為 1,下線設定 0。通過 GETBIT判斷對應的使用者是否線上。 50000 萬 使用者只需要 6 MB 的空間。

SETBIT 命令

SETBIT <key> <offset> <value>

設定或者清空 key 的 value 在 offset 處的 bit 值(只能是 0 或者 1)。

GETBIT 命令

GETBIT <key> <offset>

獲取 key 的 value 在 offset 處的 bit 位的值,當 key 不存在時,返回 0。

假如我們要判斷 ID = 10086 的使用者的登陸情況:

第一步,執行以下指令,表示使用者已登入。

SETBIT login_status 10086 1

第二步,檢查該使用者是否登陸,返回值 1 表示已登入。

GETBIT login_status 10086

第三步,登出,將 offset 對應的 value 設定成 0。

SETBIT login_status 10086 0

使用者每個月的簽到情況

在簽到統計中,每個使用者每天的簽到用 1 個 bit 位表示,一年的簽到只需要 365 個 bit 位。一個月最多隻有 31 天,只需要 31 個 bit 位即可。

比如統計編號 89757 的使用者在 2021 年 5 月份的打卡情況要如何進行?

key 可以設計成 uid:sign:{userId}:{yyyyMM},月份的每一天的值 - 1 可以作為 offset(因為 offset 從 0 開始,所以 offset = 日期 - 1)。

第一步,執行下面指令表示記錄使用者在 2021 年 5 月 16 號打卡。

SETBIT uid:sign:89757:202105 15 1

第二步,判斷編號 89757 使用者在 2021 年 5 月 16 號是否打卡。

GETBIT uid:sign:89757:202105 15

第三步,統計該使用者在 5 月份的打卡次數,使用 BITCOUNT 指令。該指令用於統計給定的 bit 陣列中,值 = 1 的 bit 位的數量。

BITCOUNT uid:sign:89757:202105

這樣我們就可以實現使用者每個月的打卡情況了,是不是很贊。

如何統計這個月首次打卡時間呢?

Redis 提供了 BITPOS key bitValue [start] [end]指令,返回資料表示 Bitmap 中第一個值為 bitValue 的 offset 位置。

在預設情況下, 命令將檢測整個點陣圖, 使用者可以通過可選的 start 引數和 end 引數指定要檢測的範圍。

所以我們可以通過執行以下指令來獲取 userID = 89757 在 2021 年 5 月份首次打卡日期:

BITPOS uid:sign:89757:202105 1

需要注意的是,我們需要將返回的 value + 1 表示首次打卡的天,因為 offset 從 0 開始。

連續簽到使用者總數

在記錄了一個億的使用者連續 7 天的打卡資料,如何統計出這連續 7 天連續打卡使用者總數呢?

我們把每天的日期作為 Bitmap 的 key,userId 作為 offset,若是打卡則將 offset 位置的 bit 設定成 1。

key 對應的集合的每個 bit 位的資料則是一個使用者在該日期的打卡記錄。

一共有 7 個這樣的 Bitmap,如果我們能對這 7 個 Bitmap 的對應的 bit 位做 『與』運算。

同樣的 UserID offset 都是一樣的,當一個 userID 在 7 個 Bitmap 對應對應的 offset 位置的 bit = 1 就說明該使用者 7 天連續打卡。

結果儲存到一個新 Bitmap 中,我們再通過 BITCOUNT 統計 bit = 1 的個數便得到了連續打卡 7 天的使用者總數了。

Redis 提供了 BITOP operation destkey key [key ...]這個指令用於對一個或者多個 鍵 = key 的 Bitmap 進行位元操作。

opration 可以是 andORNOTXOR。當 BITOP 處理不同長度的字串時,較短的那個字串所缺少的部分會被當做 0

空的 key 也被看作是包含 0 的字串序列。

便於理解,如下圖所示:

BITOP

3 個 Bitmap,對應的 bit 位做「與」操作,結果儲存到新的 Bitmap 中。

操作指令表示將 三個 bitmap 進行 AND 操作,並將結果儲存到 destmap 中。接著對 destmap 執行 BITCOUNT 統計。

// 與操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
// 統計 bit 位 =  1 的個數
BITCOUNT destmap

簡單計算下 一個一億個位的 Bitmap佔用的記憶體開銷,大約佔 12 MB 的記憶體(10^8/8/1024/1024),7 天的 Bitmap 的記憶體開銷約為 84 MB。同時我們最好給 Bitmap 設定過期時間,讓 Redis 刪除過期的打卡資料,節省記憶體

小結

思路才是最重要,當我們遇到的統計場景只需要統計資料的二值狀態,比如使用者是否存在、 ip 是否是黑名單、以及簽到打卡統計等場景就可以考慮使用 Bitmap。

只需要一個 bit 位就能表示 0 和 1,在統計海量資料的時候將大大減少記憶體佔用。

基數統計

基數統計:統計一個集合中不重複元素的個數,常見於計算獨立使用者數(UV)。

實現基數統計最直接的方法,就是採用集合(Set)這種資料結構,當一個元素從未出現過時,便在集合中增加一個元素;如果出現過,那麼集合仍保持不變。

當頁面訪問量巨大,就需要一個超大的 Set 集合來統計,將會浪費大量空間。

另外,這樣的資料也不需要很精確,到底有沒有更好的方案呢?

這個問題問得好,Redis 提供了 HyperLogLog 資料結構就是用來解決種種場景的統計問題。

HyperLogLog 是一種不精確的去重基數方案,它的統計規則是基於概率實現的,標準誤差 0.81%,這樣的精度足以滿足 UV 統計需求了。

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

網站的 UV

通過 Set 實現

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

使用者編號 89757 訪問 「Redis 為什麼這麼快 」時,我們將這個資訊放到 Set 中。

SADD Redis為什麼這麼快:uv 89757

當使用者編號 89757 多次訪問「Redis 為什麼這麼快」頁面,Set 的去重功能能保證不會重複記錄同一個使用者 ID。

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

SCARD Redis為什麼這麼快:uv

通過 Hash 實現

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

即使使用者重複訪問,重複執行命令,也只會把這個 userId 的值設定成 “1"。

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

如下:

HSET redis叢集:uv userId:89757 1
// 統計 UV
HLEN redis叢集

HyperLogLog 王者方案

碼老溼,Set 雖好,如果文章非常火爆達到千萬級別,一個 Set 就儲存了千萬個使用者的 ID,頁面多了消耗的記憶體也太大了。同理,Hash資料型別也是如此。咋辦呢?

利用 Redis 提供的 HyperLogLog 高階資料結構(不要只知道 Redis 的五種基礎資料型別了)。這是一種用於基數統計的資料集合型別,即使資料量很大,計算基數需要的空間也是固定的。

每個 HyperLogLog 最多隻需要花費 12KB 記憶體就可以計算 2 的 64 次方個元素的基數。

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

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

PFADD

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

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

PFCOUNT

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

PFCOUNT Redis主從同步原理:uv

PFMERGE 使用場景

HyperLogLog 除了上面的 PFADDPFCOIUNT 外,還提供了 PFMERGE ,將多個 HyperLogLog 合併在一起形成一個新的 HyperLogLog 值。

語法

PFMERGE destkey sourcekey [sourcekey ...]

使用場景

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

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

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

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,只算訪問了一次。

排序統計

Redis 的 4 個集合型別中(List、Set、Hash、Sorted Set),List 和 Sorted Set 就是有序的。

  • List:按照元素插入 List 的順序排序,使用場景通常可以作為 訊息佇列、最新列表、排行榜;
  • Sorted Set:根據元素的 score 權重排序,我們可以自己決定每個元素的權重值。使用場景(排行榜,比如按照播放量、點贊數)。

最新評論列表

碼老溼,我可以利用 List 插入的順序排序實現評論列表

比如微信公眾號的後臺回覆列表(不要槓,舉例子),每一公眾號對應一個 List,這個 List 儲存該公眾號的所有的使用者評論。

每當一個使用者評論,則利用 LPUSH key value [value ...] 插入到 List 隊頭。

LPUSH 碼哥位元組 1 2 3 4 5 6

接著再用 LRANGE key star stop 獲取列表指定區間內的元素。

> LRANGE 碼哥位元組 0 4
1) "6"
2) "5"
3) "4"
4) "3"
5) "2"

注意,並不是所有最新列表都能用 List 實現,對於因為對於頻繁更新的列表,list型別的分頁可能導致列表元素重複或漏掉。

比如當前評論列表 List ={A, B, C, D},左邊表示最新的評論,D 是最早的評論。

LPUSH 碼哥位元組 D C B A

展示第一頁最新 2 個評論,獲取到 A、B:

LRANGE 碼哥位元組 0 1
1) "A"
2) "B"

按照我們想要的邏輯來說,第二頁可通過 LRANGE 碼哥位元組 2 3 獲取 C,D。

如果在展示第二頁之前,產生新評論 E,評論 E 通過 LPUSH 碼哥位元組 E 插入到 List 隊頭,List = {E, A, B, C, D }。

現在執行 LRANGE 碼哥位元組 2 3 獲取第二頁評論發現, B 又出現了。

LRANGE 碼哥位元組 2 3
1) "B"
2) "C"

出現這種情況的原因在於 List 是利用元素所在的位置排序,一旦有新元素插入,List = {E,A,B,C,D}

原先的資料在 List 的位置都往後移動一位,導致讀取都舊元素。

List最新列表

小結

只有不需要分頁(比如每次都只取列表的前 5 個元素)或者更新頻率低(比如每天凌晨統計更新一次)的列表才適合用 List 型別實現。

對於需要分頁並且會頻繁更新的列表,需用使用有序集合 Sorted Set 型別實現。

另外,需要通過時間範圍查詢的最新列表,List 型別也實現不了,需要通過有序集合 Sorted Set 型別實現,如以成交時間範圍作為條件來查詢的訂單列表。

排行榜

碼老溼,對於最新列表的場景,List 和 Sorted Set 都能實現,為啥還用 List 呢?直接使用 Sorted Set 不是更好,它還能設定 score 權重排序更加靈活。

原因是 Sorted Set 型別佔用的記憶體容量是 List 型別的數倍之多,對於列表數量不多的情況,可以用 Sorted Set 型別來實現。

比如要一週音樂榜單,我們需要實時更新播放量,並且需要分頁展示。

除此以外,排序是根據播放量來決定的,這個時候 List 就無法滿足了。

我們可以將音樂 ID 儲存到 Sorted Set 集合中,score 設定成每首歌的播放量,該音樂每播放一次則設定 score = score +1。

ZADD

比如我們將《青花瓷》和《花田錯》播放量新增到 musicTop 集合中:

ZADD musicTop 100000000 青花瓷 8999999 花田錯

ZINCRBY

《青花瓷》每播放一次就通過 ZINCRBY指令將 score + 1。

> ZINCRBY musicTop 1 青花瓷
100000001

ZRANGEBYSCORE

最後我們需要獲取 musicTop 前十播放量音樂榜單,目前最大播放量是 N ,可通過如下指令獲取:

ZRANGEBYSCORE musicTop N-9 N WITHSCORES

65哥:可是這個 N 我們怎麼獲取呀?

ZREVRANGE

可通過 ZREVRANGE key start stop [WITHSCORES]指令。

其中元素的排序按 score 值遞減(從大到小)來排列。

具有相同 score 值的成員按字典序的逆序(reverse lexicographical order)排列。

> ZREVRANGE musicTop 0 0 WITHSCORES
1) "青花瓷"
2) 100000000

小結

即使集合中的元素頻繁更新,Sorted Set 也能通過 ZRANGEBYSCORE 命令準確地獲取到按序排列的資料。

在面對需要展示最新列表、排行榜等場景時,如果資料更新頻繁或者需要分頁顯示,建議優先考慮使用 Sorted Set。

聚合統計

指的就是統計多個集合元素的聚合結果,比如說:

  • 統計多個元素的共有資料(交集);
  • 統計兩個集合其中的一個獨有元素(差集統計);
  • 統計多個集合的所有元素(並集統計)。

碼老溼,什麼樣的場景會用到交集、差集、並集呢?

Redis 的 Set 型別支援集合內的增刪改查,底層使用了 Hash 資料結構,無論是 add、remove 都是 O(1) 時間複雜度。

並且支援多個集合間的交集、並集、差集操作,利用這些集合操作,解決上邊提到的統計問題。

交集-共同好友

比如 QQ 中的共同好友正是聚合統計中的交集。我們將賬號作為 Key,該賬號的好友作為 Set 集合的 value。

模擬兩個使用者的好友集合:

SADD user:碼哥位元組 R大 Linux大神 PHP之父
SADD user:大佬 Linux大神 Python大神 C++菜雞

交集

統計兩個使用者的共同好友只需要兩個 Set 集合的交集,如下命令:

SINTERSTORE user:共同好友 user:碼哥位元組 user:大佬

命令的執行後,「user:碼哥位元組」、「user:大佬」兩個集合的交集資料儲存到 user:共同好友這個集合中。

差集-每日新增好友數

比如,統計某個 App 每日新增註冊使用者量,只需要對近兩天的總註冊使用者量集合取差集即可。

比如,2021-06-01 的總註冊使用者量存放在 key = user:20210601 set 集合中,2021-06-02 的總使用者量存放在 key = user:20210602 的集合中。

set差集

如下指令,執行差集計算並將結果存放到 user:new 集合中。

SDIFFSTORE  user:new  user:20210602 user:20210601

執行完畢,此時的 user:new 集合將是 2021/06/02 日新增使用者量。

除此之外,QQ 上有個可能認識的人功能,也可以使用差集實現,就是把你朋友的好友集合減去你們共同的好友即是可能認識的人。

並集-總共新增好友

還是差集的例子,統計 2021/06/01 和 2021/06/02 兩天總共新增的使用者量,只需要對兩個集合執行並集。

SUNIONSTORE  userid:new user:20210602 user:20210601

此時新的集合 userid:new 則是兩日新增的好友。

小結

Set 的差集、並集和交集的計算複雜度較高,在資料量較大的情況下,如果直接執行這些計算,會導致 Redis 例項阻塞。

所以,可以專門部署一個叢集用於統計,讓它專門負責聚合計算,或者是把資料讀取到客戶端,在客戶端來完成聚合統計,這樣就可以規避由於阻塞導致其他服務無法響應。

往期推薦

Redis 核心篇:唯快不破的祕密

Redis 日誌篇:無畏當機快速恢復的殺手鐗

Redis 高可用篇:你管這叫主從架構資料同步原理?

Redis 高可用篇:你管這叫 Sentinel 哨兵叢集原理

Redis 高可用篇:Cluster 叢集能支撐的資料有多大?

碼哥位元組

相關文章