作為純資料結構的 Redis Streams
原文由 Salvatore Sanfilippo 于 發布,訂閱此部落格
在 Redis 5 中以「Streams」為名推出的全新 Redis 資料結構,在社群中引起了相當大的關注。遲早我想要做一次社群調查,和那些在正式環境中使用它的使用者聊聊,再寫成文章分享。不過今天我想談的是另一個問題:我開始懷疑,許多使用者只把 Streams 想成是用來解決類似 Kafka(TM) 這類使用情境的工具。實際上,這個資料結構的設計的確*也*考慮了在生產者與消費者訊息傳遞的場景下運作,但若認為 Redis Streams 就只擅長做這件事,那就過於狹隘了。串流(Streaming)是一種非常棒的模式與「心智模型」,在系統設計中能發揮極大的作用,但 Redis Streams 就像大多數 Redis 資料結構一樣,更為通用,可以用來建模數十種彼此無關的不同問題。因此,在這篇文章中,我將完全把 Streams 當作一種純粹的資料結構來看待,完全不談它的阻塞操作、消費者群組以及所有與訊息傳遞相關的部分。
Streams 是吃了類固醇的 CSV 檔
如果你想記錄一系列結構化的資料項目,又覺得資料庫終究有點小題大作,你可能會這麼想:乾脆就以唯附加(append only)模式打開一個檔案,把每一筆資料都以 CSV(逗號分隔值)格式記錄下來:
(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 檔案的種種限制:
- 在這裡要做範圍查詢很困難(而且沒效率)。
- 有太多重複的資訊:每一筆資料的時間幾乎都一樣,欄位名稱也不斷重複。同時,如果為了精簡而把這些拿掉,格式的彈性又會變差,萬一我想換成另一組欄位就會很麻煩。
- 項目的位移只是檔案中的位元組位移:如果改變了檔案結構,位移就會錯亂,所以這裡其實沒有真正的主鍵(primary ID)概念。基本上,根本無法用唯一的方式來定址每一筆資料。
- 我無法真正刪除項目,只能將它們標記為無效,卻沒辦法進行垃圾回收,除非重寫整個日誌。而重寫日誌通常因為各種原因都很麻煩,能避免就該避免。
不過,這種 CSV 條目的日誌在某些方面還是很棒的:它沒有固定的結構,欄位可以隨時改變,產生起來非常簡單,而且整體來說也相當精簡。Redis Streams 的想法,就是保留這些優點,同時克服上述的限制。結果就是一個混合式的資料結構,和 Redis 的 Sorted Set 非常相似:它們*用起來*就像是一種基礎的資料結構,但為了達到這種效果,內部其實用了多種不同的表示方式。
Streams 101(如果你已經熟悉 Redis Streams 的基礎,可以跳過這段)
Redis Streams 在底層是以經過 delta 壓縮的巨集節點(macro nodes)來表示,這些節點再透過基數樹(radix tree)串連在一起。這樣設計的效果是,能夠非常快速地隨機定位到任何一筆資料,需要時取得某個範圍的資料,移除舊資料以建立有容量上限的 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>。time 是以毫秒為單位的時間戳記,而 counter 則是在同一毫秒內產生的項目所遞增的計數器。
所以,在「唯附加 CSV 檔」這個想法的基礎上,第一個新的抽象層就是:由於我們在 XADD 中將 ID 參數設為星號(*),伺服器會免費幫我們產生項目的 ID。這個 ID 不僅可以用來指向 Stream 中的特定項目,它還與該項目被加入 Stream 的時間相關。事實上,透過 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 獲勝,我們就可以在 Stream 中寫入這樣一筆資料:
> XADD club:1234.matches * player-a 1 player-b 2 winner 1 "1553254144387-0"
透過這個簡單的操作,我們就得到了:
- 比賽的唯一識別碼:也就是 Stream 中的 ID。
- 不需要為了識別一場比賽而另外建立一個物件。
- 免費獲得範圍查詢能力,可以用來為比賽列表做分頁,或查詢過去某個時間點進行的比賽。
在 Streams 出現之前,我們得建立一個以時間為分數(score)的 Sorted Set:Sorted Set 中的元素是比賽的 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 壓縮和相同欄位壓縮。儘管如此,我們仍然能夠透過 ID 或時間進行快速查找,因為這些巨集節點是透過基數樹(radix tree)串連起來的,而基數樹本身也被設計成非常省記憶體。所有這些因素加在一起,造就了極低的記憶體用量,但有趣的是,就語意層面而言,使用者完全看不到這些讓 Streams 變得高效的實作細節。
現在來做個簡單的數學計算。如果我能用大約 18 MB 的記憶體儲存 100 萬筆資料,那麼用 180 MB 就能存 1,000 萬筆,1.8 GB 就能存 1 億筆。只要 18 GB 的記憶體,我就能擁有 10 億筆資料。
時間序列
在我看來,有一件重要的事值得注意,那就是上面我們用 Stream 來表示一場網球比賽的用法,在語意上與將 Redis Stream 用於時間序列是*非常不同*的。是的,從邏輯上來說,我們仍然是在記錄某種事件,但一個根本的差異在於,在前者的情況下,我們是透過記錄和建立條目來呈現物件;而在時間序列的情況下,我們只是在度量外部發生的某件事,它本身並不真正代表一個物件。你可能會覺得這個差異微不足道,但事實並非如此。對於 Redis 使用者來說,建立起這樣的觀念很重要:Redis Streams 可以用來建立具有全序關係的小型物件,並為這些物件賦予 ID。
不過,即使是最基本的時間序列使用情境,顯然在這裡也是一個非常重要的應用,因為在 Streams 出現之前,Redis 在這類情境上有點一籌莫展。Stream 的記憶體特性與彈性,再加上能夠建立有容量上限的 Stream(可參考 XADD 的選項),對開發者來說是一項非常重要的工具。
結論
Streams 非常靈活,擁有大量的使用情境,不過我希望這篇文章保持簡短,以確保透過上述範例和記憶體用量的分析,能傳達出一個清晰的核心訊息。或許對許多讀者來說這已經是顯而易見的事,但過去幾個月與人們交談後,我感覺到大家還是強烈地將 Streams 與串流處理的使用情境連結在一起,好像這個資料結構就只擅長做這件事。事實並非如此 :-)
隨機一篇部落格
留言
登入後參與討論