Redis Streams 作為純粹的資料結構
以「Streams」為名在 Redis 5 中推出的全新 Redis 資料結構在社群中引起了相當大的關注。遲早我想要進行一次社群調查,與有實際生產環境使用案例的使用者對談,並撰寫相關文章。今天我想談另一個議題:我開始懷疑許多使用者只是把 Streams 想成解決類似 Kafka(TM) 使用情境的方法。實際上,這個資料結構的設計*也*能在生產者與消費者的訊息傳遞情境中運作,但若認為 Redis Streams 僅擅長於此,那就過於狹隘了。串流是一種出色的模式與「心智模型」,在設計系統時能帶來極大的成功,但 Redis Streams 和大多數 Redis 資料結構一樣,具有更廣泛的通用性,可以用來建模數十種不同且互不相關的問題。因此,在這篇部落格文章中,我將專注於把 Streams 視為一種純粹的資料結構,完全不去談它的阻塞操作、消費群組以及所有與訊息傳遞相關的部分。
Streams 是強化版的 CSV 檔案
如果你想記錄一系列結構化的資料項目,又覺得資料庫終究被高估了,你可能會這樣說:那就直接以唯附加(append only)模式開啟一個檔案,並將每一列都以 CSV(Comma Separated Value,逗號分隔值)項目的形式記錄下來:
(open data.csv in append only) time=1553096724033,cpu_temp=23.4,load=2.3 time=1553096725029,cpu_temp=23.2,load=2.1
看起來很簡單,而且人們長久以來都是這樣做,現在也依然如此:如果你清楚自己在做什麼,這是一個穩固的模式。但它在記憶體中的對等物是什麼呢?記憶體比唯附加檔案更強大,可以自動地消除像這樣的 CSV 檔案的限制:
- 在這裡要做範圍查詢很困難(效率不佳)。
- 有太多冗餘資訊:每一筆資料的時間幾乎都相同,欄位也不斷重複。同時,若將其移除,又會讓格式變得不夠彈性,假如我想換成不同的欄位組合時就會遇到問題。
- 項目的位移(offset)僅僅是檔案中的位元組位移:如果我們改變檔案結構,位移就會出錯,因此這裡並沒有真正的主鍵 ID 概念。基本上,資料項目並沒有以某種方式被唯一定址。
- 我無法刪除資料項目,只能將其標記為不再有效,若不重寫整個日誌,就無法進行垃圾回收。重寫日誌通常因為種種原因很糟糕,如果能避免,當然是好事。
儘管如此,這種 CSV 項目的日誌在某些方面也很棒:它沒有固定的結構,欄位可以隨意變動,產生起來非常容易,而且終究也相當精簡。Redis Streams 的構想就是保留這些優點,同時克服其限制。成果是一個非常類似於 Redis Sorted Sets(有序集合) 的混合式資料結構:它們*感覺起來*像是一種基礎的資料結構,但為了達到這種效果,內部其實使用了多種表示法。
Streams 入門(如果你已經了解 Redis Streams 的基礎,可以跳過本節)
Redis Streams 在內部是以 delta-compressed(差值壓縮) 的 macro nodes(巨集節點) 來表示,這些節點由 radix tree(基數樹) 串連在一起。效果是能夠以非常快的方式隨機定位到任意資料項目、依需要取得範圍資料、移除舊項目以建立 capped stream(有容量上限的串流),等等。然而,我們提供給開發者的介面卻非常類似於 CSV 檔案:
> XADD mystream * cpu-temp 23.4 load 2.3 "1553097561402-0" > XADD mystream * cpu-temp 23.2 load 2.1 "1553097568315-0"
如同你在上述範例中所見,XADD 指令會自動產生並回傳資料項目的 ID,該 ID 會單調遞增,並由兩個部分組成:<time>-<counter>。時間部分以毫秒為單位,而計數器則會針對在同一毫秒內產生的資料項目遞增。
因此,在「唯附加 CSV 檔案」這個概念之上,第一個新的抽象層是,由於我們在 XADD 的 ID 參數中使用了星號(*),伺服器會免費幫我們產生資料項目的 ID。這樣的 ID 不僅可用來指向串流中的特定項目,還與該項目被加入串流的時間相關。事實上,使用 XRANGE 就能執行範圍查詢或擷取單一項目:
> XRANGE mystream 1553097561402-0 1553097561402-0
1) 1) "1553097561402-0"
2) 1) "cpu-temp"
2) "23.4"
3) "load"
4) "2.3"在這個例子中,我將同一個 ID 同時作為範圍的起始與結束,以識別單一元素。然而,我可以使用任意範圍,並透過 COUNT 參數來限制結果數量。同樣地,指定範圍時也不需要提供完整的 ID,我可以只使用 ID 中的毫秒級 Unix 時間部分,來取得特定時間範圍內的元素:
> XRANGE mystream 1553097560000 1553097570000
1) 1) "1553097561402-0"
2) 1) "cpu-temp"
2) "23.4"
3) "load"
4) "2.3"
2) 1) "1553097568315-0"
2) 1) "cpu-temp"
2) "23.2"
3) "load"
4) "2.1"目前沒有必要再向你展示更多的 Streams API,相關內容請參閱 Redis 官方文件。現在,讓我們先專注於這種使用模式:使用 XADD 來新增資料,使用 XRANGE(還有 XREAD)來取回範圍資料(取決於你想做什麼),然後來看看為什麼我說 Streams 作為一種資料結構是如此強大。
不過,如果你想進一步了解 Redis Streams 及其 API,請務必參考這裡的教學:https://redis.io/topics/streams-intro
網球選手
幾天前,我和一位最近正在學習 Redis 的朋友一起為一個應用程式建模:這個應用程式用來追蹤當地的網球場、選手與比賽。在 Redis 中為選手建模的方式相當直觀,選手是一個小型的物件,所以只需要一個 Hash 就夠了,鍵名像是 player:<id>。當你進一步為應用程式資料建模,並將 Redis 作為主要資料庫時,你馬上會發現需要一種方式來追蹤在特定網球俱樂部中進行的比賽。如果 player:1 和 player:2 進行了一場比賽,且由 player 1 獲勝,我們可以在串流中寫入如下的資料項目:
> XADD club:1234.matches * player-a 1 player-b 2 winner 1 "1553254144387-0"
透過這個簡單的操作,我們就擁有了:
- 比賽的唯一識別碼:即串流中的 ID。
- 不需要為了識別一場比賽而另外建立一個物件。
- 免費獲得範圍查詢功能,可用來為比賽結果分頁,或查詢在過去某個時間點進行的比賽。
在 Streams 出現之前,我們需要建立一個以時間作為分數(score)的 Sorted Set:Sorted Set 的元素會是比賽的 ID,而該 ID 所對應的 Hash 值則儲存在不同的鍵中。這不僅更費工,也浪費了驚人的記憶體。比你想像的還要多得多(稍後會說明)。
目前要說明的是,Redis Streams 有點像是一個處於唯附加模式、以時間作為鍵的 Sorted Set,其中每個元素都是一個小型的 Hash。而就其簡潔性而言,這在 Redis 建模的脈絡中是一場革命。
記憶體使用量
上述使用案例不僅僅是模式更穩固的問題。相較於過去為每個物件都建立一個 Sorted Set 加上 Hash 的做法,Stream 解決方案的記憶體成本差異之大,使得過去不可行的某些事情,現在變得完全可行。
以下是按照前述配置儲存一百萬場比賽的數據:
Sorted Set + Hash memory usage = 220 MB (242 RSS) Stream memory usage = 16.8 MB (18.11 RSS)
這是超過一個數量級的差距(精確來說是 13 倍的差異),這意味著過去對於記憶體儲存而言成本過高的使用情境,現在都變得完全可行。奧妙全在於 Redis Streams 的表示方式:macro nodes 可以包含多個元素,這些元素以一種稱為 listpack(列表壓縮) 的資料結構以非常精簡的方式編碼。舉例來說,listpack 會負責將整數以二進位形式編碼,即使它們在語意上是字串。在此之上,我們再套用 delta compression(差值壓縮) 與 same-fields compression(相同欄位壓縮)。然而,我們仍然能夠透過 ID 或時間進行定位,因為這些 macro nodes 透過 radix tree 串連在一起,而 radix tree 本身也被設計為只使用極少的記憶體。所有這些加在一起造就了低記憶體使用量,但有趣的是,從語意上來看,使用者完全看不到讓 Streams 如此高效的那些實作細節。
現在來做個簡單的數學計算。如果我可以用約 18 MB 的記憶體儲存 100 萬筆資料,那麼我就可以用 180 MB 儲存 1,000 萬筆,用 1.8 GB 儲存 1 億筆。只要有 18 GB 的記憶體,我就能擁有 10 億筆資料。
時間序列
在我看來,有一件重要的事值得注意,那就是上述我們使用 Stream 來表示一場網球比賽的用法,在語意上與使用 Redis Stream 來處理時間序列*非常不同*。是的,從邏輯上來說,我們仍然是在記錄某種事件,但一個根本的差異在於,在其中一種情況下,我們是利用日誌記錄與資料項目的建立來呈現物件。而在時間序列的情況下,我們只是在度量外部發生的某件事,它本身並不真正代表一個物件。你可能會認為這種差異微不足道,但事實並非如此。對 Redis 使用者而言,建立這樣的觀念很重要:Redis Streams 可以用來建立具有全序(total order)關係的小型物件,並為這些物件指派 ID。
然而,即使是最基本的時間序列使用情境,顯然在這裡也是一個巨大的應用,因為在 Streams 出現之前,Redis 在這類使用情境上有點束手無策。串流的記憶體特性與彈性,再加上能夠擁有有容量上限的串流(capped stream)(請參閱 XADD 選項),對開發者而言是非常重要的工具。
結論
Streams 非常靈活且擁有大量的使用情境,不過我希望這篇部落格文章能保持簡短,以確保上述範例與記憶體使用量分析能傳達出明確的核心訊息。或許這對許多讀者來說已經顯而易見,但過去幾個月與人們的交談讓我感覺到,大家對 Streams 與串流使用情境之間有著強烈的連結,彷彿這個資料結構只擅長於此。事實並非如此 :-)
隨機一篇部落格