[案例分享] [Redis] 百萬資料請求的解決方案

DB 數百萬筆的龐大資料如何提供給 Client & 儲存進 Redis cache 層的設計案例分享

 

 

前言

我遇到一個需求,需要將某張 Table 的所有資料提供 API 給客戶

but!!那張 Table 的資料量高達 100 萬筆,考量到資料庫查詢效能,不能對 Table 直接 Select All,並且考量到 API 的 response time 不能太久 & response size 不能太龐大, API 的 Client 端也無法一次接收,我必須研究其他可行的方式來傳遞這些資料

 

 

分批存取 Database

由於不能對 Table 直接 Select 100 萬筆,所以勢必要分批提供,這裡我將 Timestamp 的欄位轉 BIGINT 來供 filter 使用,並限制他每次 Select 的數量

每次用 Timestamp 的 int 數值,進 DB 搜尋後面 1,000 筆的資料

 

DB Select example:

SELECT TOP (1000)
	teamId,
	teamName,
	teamTimestamp,
	CONVERT(BIGINT, teamTimestamp) AS intTimestamp,
FROM team WITH (NOLOCK)
WHERE teamTimestamp >= CAST (@VersionKey AS timestamp)
ORDER BY timestamp

Timestamp 的資料型態是二進位的格式,當該項資料後續一旦有任何一個內容有異動,Timestamp 就會更新

如果有新增的項目,也會往後遞增 Timestamp

所以當我用上一次 Select 的最後一筆 Timestamp + 1 去往後搜,即可取得 DB 後續有 add / update 的項目

 

 

需要為 Database 墊 cache 層

現在,資料請求的設計是:

請求端呼叫多次請求,每次取 1,000 筆,直到 count 為 0 停止請求

 

至此,就已經可以做到分批提供 100 萬筆資料給 client 了

but …

 

由於資料是透過 API 提供出去,為了避免 Client 端頻繁發送大量請求會對 DB 造成負擔,我需要在 Service 和 DB 之間加一層 Redis Cache,以確保查詢效率與系統穩定性。(不然會被 DBA 罵 😭)

 

這意味著我必須將這百萬筆資料存入 Redis,並且同時支援 Client 端使用 versionKey 進行分批查詢。

 

 

研究 Redis 資料儲存方式

對於以往只在 Redis 存過簡單 string 資料的我來說,如何高效地儲存、查詢與維護這批大規模數據是一個很大的挑戰。😟

為此我跑去鑽研 Redis 的各種數據結構類型:

String
  1. key/value 的單純結構
  2. value 可以儲存任何形式的 data(json/string 等)
  3. 適合作為一般簡易資料儲存
  4. Value limit 512MB
  5. 大多數基本操作都是 O(1),使用 SUBSTR、GETRANGE 和 SETRANGE 命令則是 O(n)
Set
  1. String 的集合
  2. value 不能重複
  3. 沒有順序
  4. 適合作多個集合比較交集差集的用途
  5. Max Size = 2^32 - 1 (4,294,967,295)
  6. 大多數基本操作都是 O(1),使用 SMEMBERS 則是 O(n)
List
  1. String 的集合
  2. value 可以重複
  3. 有順序
  4. 內部是雙向的 linked list
  5. 適合需按順序存取的應用 (ex : queue)
  6. Max Size = 2^32 - 1 (4,294,967,295)
  7. 訪問頭/尾的操作是 O(1),在 List 裡面操作元素則是 O(n)
Hash
  1. 以 field/value 形式儲存在一個 key 下的集合
  2. field 必須唯一,value 可以重複
  3. 沒有順序
  4. 適合儲存有唯一值的單層資料結構
  5. Max Size = 2^32 - 1 (4,294,967,295) 個 field/value
  6. 大多數操作都是 O(1),HKEYS、HVALS、HGETALL 和大多數與過期相關的命令則是 O(n)
Sorted Set
  1. member/score 的集合
  2. member 必須是唯一的,但 score 可以重複
  3. 有順序(會自動照 score 排序)
  4. 支援 by score range 查詢
  5. 適合用於需要排序 & 範圍查詢的資料
  6. ZRange:O(log(n) + m),m 是返回的結果數
  7. 其他大多數操作是 O(log(n)),n 是 member 數量
Json
  1. 支援 json 數據結構
  2. 以樹狀結構儲存為二進位數據,供快速訪問子元素
  3. json 最大深度 128
  4. 適合儲存多嵌套的資料結構
  5. 時間複雜度 O(N*M),N = field 數,M = json 深度

Json 在基本版 Redis 不可用,必須安裝 Redis Stack 或 Redis Enterprise,才可用 Redis Module 的 RedisJSON

 

了解各數據結構的功能後,發現只有 Sorted Set 才有辦法讓我做到 (1) 排序 (2) range 查詢,故決定使用 Sorted Set 來當作這次需求的 [篩選用] 儲存結構。

 

再來是資料 body 本身的其他欄位內容,我比較了 String / Hash / Json 三種數據儲存結構

Json 結構可以解決 String 無法存取部分 field 的問題,也可以達到 Hash 做不到的多層結構,在我這少量 & 固定 field 數量的情境下很適合使用,並且在考量到需求後續仍有可能調整內容的現有工作條件下,為了保留資料異動上的彈性,我最終選擇使用 Json 作為我的 [Detail內容用] 儲存結構。

在少量的 field 下,Json 與 Hash 的存取性能相近,若是扁平一層的 Json 資料,使用 Json 或 Hash 都可以,不過 RedisJSON 會隨著 field 數量 / Json 嵌套層級越多而性能越差,使用上要多注意~

 

上面提到的 “資料異動上的彈性”,是指像是需要更動 field name 的話,Json 就可以在 set key 的時候整個 Json 直接覆蓋掉,Hash 就必須要先刪掉 old field 再新增 new field,比較麻煩就是了~~

 

 

Job by Version key 取 Database 資料存進 Redis

這個同步的動作我放到 job 來定期處理,將 Database 中的新資料 (or 新異動的舊資料) 取出,改存放到 Redis 中

由於需要記住我上次最後一筆是同步到哪一筆資料,會在 Redis 存一個 lastVersionKey (也就是前面提到的轉成 BIGINT 的 Timestamp)

每次 Job 處理時去 Redis 取得 lastVersionKey,去 DB 往後取 1,000 筆 Data 後存到 Redis

 

Sorted Set

MemberScore
teamIdBIGINT 的 Timestamp

Json

KeyValue
teamId其他 field

 

 

API by Version key 取 range 資料

當 client 發送 request 帶 versionkey 進來時,就會改到 Redis 中的 sorted set 取 range (551939612 往後取 1,000 筆)

從 sorted set 中取得 1,000 筆項目後,再根據存放在 Member 中的 teamId,去各別 by Key Get 1,000 筆 Json 項目,回傳給 client,收工~~~

 

 

以上是我在此次需求情境中的應用方式,給大家參考,如果有人有其他不同做法也歡迎交流交流~~~ 😄