【譯】Redis喜提新資料結構:Redis Streams

面向Google程式設計發表於2019-03-29

本文是Redis作者antirez的一篇部落格

原文地址:antirez.com/news/128

我們在Redis5版本迎來了一個新的資料結構,它的名字叫做"Streams"。(撒花)Streams一經推出,就引起了社群中各位大佬的關注。所以我決定過一段時間做一個社群調查,討論一下它的使用場景,並會在部落格中將結果記錄下來(是Redis作者的部落格)。今天我想聊的是另一個問題:我懷疑有很多使用者認為Streams的使用場景是和Kafka一樣的。實際上,這個資料結構的設計背景也是訊息的生產和消費,但你應該認為Redis Streams只是更擅長做這樣的事情。流是一種很好的模型和"心理模型",它能幫助我們更好的設計系統,但是Redis Streams像其他Redis資料結構一樣,它更加通用,可以用來處理更多不同的問題。所以這篇部落格我們會重點關注Redis Streams作為一種資料結構有哪些特性,而完全忽略它的阻塞操作、消費群和所有訊息相關的內容。

Streams是steroids上的CSV檔案

如果你想記錄一系列的結構化資料,並且確定資料庫是足夠大的,你可能會說:我們以追加寫入的方式開啟一個檔案,每一行記錄是一個CSV資料項:

time=1553096724033,cpu_temp=23.4,load=2.3 time=1553096725029,cpu_temp=23.2,load=2.1

這看起來很簡單,然後人們一直這樣做了好多年,並且一直持續著:如果你知道你在做什麼,那麼這將成為一種固定的模式。如果同樣的事情發生在記憶體中會怎樣呢?記憶體的順序寫入能力更強,並且會自動移除掉CSV檔案的一些限制:

  1. 很難批量查詢
  2. 太多的冗餘資訊:每個條目的時間幾乎相同,欄位也相同。但是移除欄位會降低靈活性,就不能再增加別的欄位了
  3. 每個條目的偏移量都是它在檔案中的位元組偏移量,而如果我們修改了檔案結構,那麼這些偏移量就會失效。所以這裡缺少一個唯一標識的ID。
  4. 不能刪除條目,只能標記無效。如果不重寫日誌的話,又沒有垃圾回收,重寫日誌經常會因為各種原因出錯,所以最好不要重寫。

不過使用這樣的CSV條目也有一些好處:沒有固定格式,欄位可以改變,生成比較容易,而且儲存格式比較緊湊。我們保留了其優點,去掉了限制,於是設計出了像Redis Sorted Set這樣的混合資料結構——Redis Streams。他們看起來像基本資料結構一樣,但是為了得到這樣的效果,內部是有多種表現形式的。

Streams 101(就是Streams基礎部分)

Redis Streams是一種通過基數樹連線的增量壓縮的巨集節點。(好難理解的概念,把原話貼出來:Redis Streams are represented as delta-compressed macro nodes that are linked together by a radix tree)。它的作用是,快速查詢一個隨機項,獲取範圍值,刪除舊值來建立一個有大小上限的流。對程式設計師來說,我們的介面和CSV檔案很相似:

> XADD mystream * cpu-temp 23.4 load 2.3
"1553097561402-0"
> XADD mystream * cpu-temp 23.2 load 2.1
"1553097568315-0"
複製程式碼

通過這個例子可以看到,XADD命令自動生成並返回了一個entry ID。它是單調遞增的,並且有兩部分組成,<時間>-<數量>,時間是毫秒級,而數量則是同一毫秒生成的entry數量遞增。

所以第一個從上面所說的"追加寫入CSV"檔案抽象出來概念就是,如果用星號作為XADD命令的ID引數,就從伺服器獲取了一個entry ID。這個ID不僅僅是entry的唯一標識,也和entry加入流的時間有關。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引數限制結果的數量。我也可以將起止引數都設定為時間戳,獲取一段時間內的資料項。

> 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了。我們有相關的文件,感興趣的同學可以去閱讀。目前為止,我們只需要關注基本使用方法:XADD用來增加資料,XRANGE(或XREAD)用來讀取資料。我們來看一下我為什麼說Streams是一個強大的資料結構。

網球運動員

前幾天我和一個最近在學習Redis的朋友一起建模一個應用程式:這是一個用來追蹤當地網球場、球員和比賽的app。很明顯,球員是一個小的模型,在Redis中只需要用一個hash就足夠了,key的形式可以是player:<id>。當你進一步使用Redis建模時,就會意識到你需要去追蹤指定網球俱樂部的一場比賽。如果球員1和球員2打了一場比賽,球員1獲勝。那麼我們可以這樣來記錄:

> XADD club:1234.matches * player-a 1 player-b 2 winner 1
"1553254144387-0"
複製程式碼

通過這樣簡單的操作,我們就可以獲得如下的資訊:

  1. 一場比賽的唯一標識:流裡的ID
  2. 不需要建立一個表示比賽的物件
  3. 分頁查詢比賽情況,或者檢視某場比賽是否在指定時間就進行

在Streams出現之前,我們需要建立一個Sorted Set,分數是時間。Sorted Set的元素是比賽的ID(存在一個Hash裡)。這不僅是增加了工作量,而且還造成了更多的記憶體浪費,比你想象的要多得多。

現在看起來Streams像是一個追加模式的,以時間為分數,元素是小型Hash的Sorted Set。簡而言之,這是Rediscover建模環境中的一次革命。

記憶體使用情況

上面的例子不僅僅是固化模式的問題,相比舊有的Sorted Set+ Hash的模式,Streams對記憶體的節省做了很好的優化,然而這一點是不容易被發現的。

假設我們要記錄100萬場比賽,

Sorted Set + Hash的記憶體使用量是220MB(242RSS)

Stream的記憶體使用量是16.8MB(18.11RSS)

這不僅僅是一個數量級的差異(實際上是13倍的差異),這意味著我們舊有的模式實在是太浪費了,而新的模式是完美可行的。Redis Streams還有其他神奇的地方:巨集節點可以包括多個元素,它們使用叫做listpack的資料進行編碼。listpacks會對二進位制形式的整數進行編碼,即時它是語義字串。最重要的是,我們使用了增量壓縮和相同欄位壓縮。我們可以通過ID或時間進行查詢,因為巨集節點是用基數樹連線的。基數樹葉被設計為使用很少的記憶體。所有的事情都使用極少的記憶體,但有趣的是,使用者並不能從語義上看到使Streams更加高效的實現細節。

現在我們來做一個簡單的計算,如果我儲存了100萬個entry,使用了18MB記憶體,那麼1000萬個就是180MB,1億個使用1.8GB,儲存10億資料也只使用18GB記憶體。

時間序列

有一個比較重要的事情需要注意,在我看來,上面我們用來記錄網球比賽的例子與把Redis Streams作為一個時間序列來使用非常不同。沒錯,邏輯上我們仍然是記錄一類事件,但本質上的區別是記錄日誌和建立一個entry並存入物件的不同。在使用時間序列時,我們只是記錄一個外部事件,而不需要真的展示一個物件。你可能認為這個區別不重要,但事實不是這樣。對Redis使用者來說很重要的是,如果需要儲存一系列有序的物件,並且給每個物件賦一個ID,那麼就需要使用Redis Streams。

然而即時是一個簡單的時間序列,也是一個很大的用例,因為在Streams出現之前,Redis在面對這種用例時令人有些絕望。一個節省記憶體,並且靈活的流,對開發者來說是一個重要的工具。

結論

Streams非常靈活並且有很多使用場景,我想盡量用簡短的語言,以確保上面的例子和記憶體分析更加通俗易懂。也許大多數讀者已經搞懂了。不過在上個月我和別人交流時感覺到Streams和流式處理還是有著很強的關聯。就像這個資料結構只能用來處理流一樣,事實並非如此。

相關文章