天天看點

十五分鐘介紹 Redis資料結構

下面是一個對Redis官方文檔《A fifteen minute introduction to Redis data types》一文的翻譯,如其題目所言,此文目的在于讓一個初學者能通過15分鐘的簡單學習對Redis的資料結構有一個了解。

Redis是一種面向“鍵/值”對類型資料的分布式NoSQL資料庫系統,特點是高性能,持久存儲,适應高并發的應用場景。它起步較晚,發展迅速,目前已被許多大型機構采用,比如Github,看看誰在用它。

本文翻譯自Redis的一篇官方文檔:A fifteen minute introduction to Redis data types

友善感興趣的朋友,快速介紹Redis的資料類型。

中英文對照,如有疏漏敬請留言,某些關鍵詞不譯,便于閱讀。

—————————————————————————————————————

你也許已經知道Redis并不是簡單的key-value存儲,實際上他是一個資料結構伺服器,支援不同類型的值。也就是說,你不必僅僅把字元串當作鍵所指向的值。下列這些資料類型都可作為值類型。

  • 二進制安全的 字元串 string
  • 二進制安全的 字元串清單 list of string
  • 二進制安全的 字元串集合 set of string,換言之:它是一組無重複未排序的element。可以把它看成Ruby中的 hash–其key等于element,value都等于’true‘。
  • 有序集合sorted set of string,類似于集合set,但其中每個元素都和一個浮點數score(評分)關聯。element根據score排序。可以把它看成Ruby中的 hash–其key等于element,value等于score,但元素總是按score的順序排列,無需額外的排序操作。

Redis 鍵

Redis key值是二進制安全的,這意味着可以用任何二進制序列作為key值,從形如”foo”的簡單字元串到一個JPEG檔案的内容都可以。空字元串也是有效key值。

關于key的幾條規則:

  • 太長的鍵值不是個好主意,例如1024位元組的鍵值就不是個好主意,不僅因為消耗記憶體,而且在資料中查找這類鍵值的計算成本很高。
  • 太短的鍵值通常也不是好主意,如果你要用”u:1000:pwd”來代替”user:1000:password”,這沒有什麼問題,但後者更易閱讀,并且由此增加的空間消耗相對于key object和value object本身來說很小。當然,沒人阻止您一定要用更短的鍵值節省一丁點兒空間。
  • 最好堅持一種模式。例如:”object-type:id:field”就是個不錯的注意,像這樣”user:1000:password”。我喜歡對多單詞的字段名中加上一個點,就像這樣:”comment:1234:reply.to”。

字元串類型

這是最簡單Redis類型。如果你隻用這種類型,Redis就像一個可以持久化的memcached伺服器(注:memcache的資料僅儲存在記憶體中,伺服器重新開機後,資料将丢失)。

我們來玩兒一下字元串類型:

$ redis-cli set mykey "my binary safe value"
OK
$ redis-cli get mykey
my binary safe value      

正如你所見到的,通常用SET command 和 GET command來設定和擷取字元串值。

值可以是任何種類的字元串(包括二進制資料),例如你可以在一個鍵下儲存一副jpeg圖檔。值的長度不能超過1GB。

雖然字元串是Redis的基本值類型,但你仍然能通過它完成一些有趣的操作。例如:原子遞增:

$ redis-cli set counter 100
OK $ redis-cli incr counter
(integer) 101
$ redis-cli incr counter
(integer) 102
$ redis-cli incrby counter 10
(integer) 112      

INCR 指令将字元串值解析成整型,将其加一,最後将結果儲存為新的字元串值,類似的指令有INCRBY, DECR and DECRBY。實際上他們在内部就是同一個指令,隻是看上去有點兒不同。

INCR是原子操作意味着什麼呢?就是說即使多個用戶端對同一個key發出INCR指令,也決不會導緻競争的情況。例如如下情況永遠不可能發生:『用戶端1和用戶端2同時讀出“10”,他們倆都對其加到11,然後将新值設定為11』。最終的值一定是12,read-increment-set操作完成時,其他用戶端不會在同一時間執行任何指令。

對字元串,另一個的令人感興趣的操作是GETSET指令,行如其名:他為key設定新值并且傳回原值。這有什麼用處呢?例如:你的系統每當有新使用者通路時就用INCR指令操作一個Redis key。你希望每小時對這個資訊收集一次。你就可以GETSET這個key并給其指派0并讀取原值。

清單類型

要說清楚清單資料類型,最好先講一點兒理論背景,在資訊技術界List這個詞常常被使用不當。例如”Python Lists”就名不副實(名為Linked Lists),但他們實際上是數組(同樣的資料類型在Ruby中叫數組)

一般意義上講,清單就是有序元素的序列:10,20,1,2,3就是一個清單。但用數組實作的List和用Linked List實作的List,在屬性方面大不相同。

Redis lists基于Linked Lists實作。這意味着即使在一個list中有數百萬個元素,在頭部或尾部添加一個元素的操作,其時間複雜度也是常數級别的。用LPUSH 指令在十個元素的list頭部添加新元素,和在千萬元素list頭部添加新元素的速度相同。

那麼,壞消息是什麼?在數組實作的list中利用索引通路元素的速度極快,而同樣的操作在linked list實作的list上沒有那麼快。

Redis Lists are implemented with linked lists because for a database system it is crucial to be able to add elements to a very long list in a very fast way. Another strong advantage is, as you’ll see in a moment, that Redis Lists can be taken at constant length in constant time.

Redis Lists用linked list實作的原因是:對于資料庫系統來說,至關重要的特性是:能非常快的在很大的清單上添加元素。另一個重要因素是,正如你将要看到的:Redis lists能在常數時間取得常數長度。

Redis lists 入門

LPUSH 指令可向list的左邊(頭部)添加一個新元素,而RPUSH指令可向list的右邊(尾部)添加一個新元素。最後LRANGE 指令可從list中取出一定範圍的元素
$ redis-cli rpush messages "Hello how are you?"
OK
$ redis-cli rpush messages "Fine thanks. I‘m having fun with Redis"
OK
$ redis-cli rpush messages "I should look into this NOSQL thing ASAP"
OK
$ redis-cli lrange messages 0 2
1. Hello how are you?
2. Fine thanks. I‘m having fun with Redis
3. I should look into this NOSQL thing ASAP      

注意LRANGE 帶有兩個索引,一定範圍的第一個和最後一個元素。這兩個索引都可以為負來告知Redis從尾部開始計數,是以-1表示最後一個元素,-2表示list中的倒數第二個元素,以此類推。

As you can guess from the example above, lists can be used, for instance, in order to implement a chat system. Another use is as queues in order to route messages between different processes. But the key point is that you can use Redis lists every time you require to access data in the same order they are added. This will not require any SQL ORDER BY operation, will be very fast, and will scale to millions of elements even with a toy Linux box.

正如你可以從上面的例子中猜到的,list可被用來實作聊天系統。還可以作為不同程序間傳遞消息的隊列。關鍵是,你可以每次都以原先添加的順序通路資料。這不需要任何SQL ORDER BY 操作,将會非常快,也會很容易擴充到百萬級别元素的規模。

例如在評級系統中,比如社會化新聞網站 reddit.com,你可以把每個新送出的連結添加到一個list,用LRANGE可簡單的對結果分頁。

在部落格引擎實作中,你可為每篇日志設定一個list,在該list中推入進部落格評論,等等。

向Redis list壓入ID而不是實際的資料

在上面的例子裡 ,我們将“對象”(此例中是簡單消息)直接壓入Redis list,但通常不應這麼做,由于對象可能被多次引用:例如在一個list中維護其時間順序,在一個集合中儲存它的類别,隻要有必要,它還會出現在其他list中,等等。

讓我們回到reddit.com的例子,将使用者送出的連結(新聞)添加到list中,有更可靠的方法如下所示:

$ redis-cli incr next.news.id
(integer) 1
$ redis-cli set news:1:title "Redis is simple"
OK
$ redis-cli set news:1:url "http://code.google.com/p/redis"
OK
$ redis-cli lpush submitted.news 1
OK      

我們自增一個key,很容易得到一個獨一無二的自增ID,然後通過此ID建立對象–為對象的每個字段設定一個key。最後将新對象的ID壓入submitted.news list。

這隻是牛刀小試。在指令參考文檔中可以讀到所有和list有關的指令。你可以删除元素,旋轉list,根據索引擷取和設定元素,當然也可以用LLEN得到list的長度。

Redis 集合

Redis集合是未排序的集合,其元素是二進制安全的字元串。SADD指令可以向集合添加一個新元素。和sets相關的操作也有許多,比如檢測某個元素是否存在,以及實作交集,并集,差集等等。一例勝千言:
$ redis-cli sadd myset 1
(integer) 1
$ redis-cli sadd myset 2
(integer) 1
$ redis-cli sadd myset 3
(integer) 1
$ redis-cli smembers myset
1. 3
2. 1
3. 2      

我向集合中添加了三個元素,并讓Redis傳回所有元素。如你所見它們是無序的。

現在讓我們檢查某個元素是否存在:

$ redis-cli sismember myset 3
(integer) 1
$ redis-cli sismember myset 30
(integer) 0      

“3″是這個集合的成員,而“30”不是。集合特别适合表現對象之間的關系。例如用Redis集合可以很容易實作标簽功能。

下面是一個簡單的方案:對每個想加标簽的對象,用一個标簽ID集合與之關聯,并且對每個已有的标簽,一組對象ID與之關聯。

例如假設我們的新聞ID 1000被加了三個标簽tag 1,2,5和77,就可以設定下面兩個集合:

$ redis-cli sadd news:1000:tags 1
(integer) 1
$ redis-cli sadd news:1000:tags 2
(integer) 1
$ redis-cli sadd news:1000:tags 5
(integer) 1
$ redis-cli sadd news:1000:tags 77
(integer) 1
$ redis-cli sadd tag:1:objects 1000
(integer) 1
$ redis-cli sadd tag:2:objects 1000
(integer) 1
$ redis-cli sadd tag:5:objects 1000
(integer) 1
$ redis-cli sadd tag:77:objects 1000
(integer) 1      
要擷取一個對象的所有标簽,如此簡單:
$ redis-cli smembers news:1000:tags
1. 5
2. 1
3. 77
4. 2      
而有些看上去并不簡單的操作仍然能使用相應的Redis指令輕松實作。例如我們也許想獲得一份同時擁有标簽1, 2, 10和27的對象清單。這可以用SINTER指令來做,他可以在不同集合之間取出交集。是以為達目的我們隻需:
$ redis-cli sinter tag:1:objects tag:2:objects tag:10:objects tag:27:objects
... no result in our dataset composed of just one object   ...      
在指令參考文檔中可以找到和集合相關的其他指令,令人感興趣的一抓一大把。一定要留意SORT指令,Redis集合和list都是可排序的。

題外話:如何為字元串擷取唯一辨別

在标簽的例子裡,我們用到了标簽ID,卻沒有提到ID從何而來。基本上你得為每個加入系統的标簽配置設定一個唯一辨別。你也希望在多個用戶端同時試着添加同樣的标簽時不要出現競争的情況。此外,如果标簽已存在,你希望傳回他的ID,否則建立一個新的唯一辨別并将其與此标簽關聯。

Redis 1.4将增加Hash類型。有了它,字元串和唯一ID關聯的事兒将不值一提,但如今我們如何用現有Redis指令可靠的解決它呢?

我們首先的嘗試(以失敗告終)可能如下。假設我們想為标簽“redis”擷取一個唯一ID:

  • 為了讓算法是二進制安全的(隻是标簽而不考慮utf8,空格等等)我們對标簽做SHA1簽名。SHA1(redis)=b840fc02d524045429941cc15f59e41cb7be6c52。
  • 檢查這個标簽是否已與一個唯一ID關聯,

    用指令GET tag:b840fc02d524045429941cc15f59e41cb7be6c52:id

  • 如果上面的GET操作傳回一個ID,則将其傳回給使用者。标簽已經存在了。
  • 否則… 用INCR next.tag.id指令生成一個新的唯一ID(假定它傳回123456)。
  • 最後關聯标簽和新的ID,

    SET tag:b840fc02d524045429941cc15f59e41cb7be6c52:id 123456

    并将新ID傳回給調用者。

多美妙,或許更好…等等!當兩個用戶端同時使用這組指令嘗試為标簽“redis”擷取唯一ID時會發生什麼呢?如果時間湊巧,他們倆都會從GET操作獲得nil,都将對next.tag.id key做自增操作,這個key會被自增兩次。其中一個用戶端會将錯誤的ID傳回給調用者。幸運的是修複這個算法并不難,這是明智的版本:
  • 下面關聯标簽和新的ID,(注意用到一個新的指令)

    SETNX tag:b840fc02d524045429941cc15f59e41cb7be6c52:id 123456。如果另一個用戶端比目前用戶端更快,SETNX将不會設定key。而且,當key被成功設定時SETNX傳回1,否則傳回0。那麼…讓我們再做最後一步運算。

  • 如果SETNX傳回1(key設定成功)則将123456傳回給調用者,這就是我們的标簽ID,否則執行GET tag:b840fc02d524045429941cc15f59e41cb7be6c52:id 并将其結果傳回給調用者。

有序集合

集合是使用頻率很高的資料類型,但是…對許多問題來說他們也有點兒太不講順序了;)是以Redis1.2引入了有序集合。他和集合非常相似,也是二進制安全的字元串集合,但是這次帶有關聯的score,以及一個類似LRANGE的操作可以傳回有序元素,此操作隻能作用于有序集合,它就是,ZRANGE 指令。

基本上有序集合從某種程度上說是SQL世界的索引在Redis中的等價物。例如在上面提到的reddit.com例子中,并沒有提到如何根據使用者投票和時間因素将新聞討論區合生成首頁。我們将看到有序集合如何解決這個問題,但最好先從更簡單的事情開始,闡明這個進階資料類型是如何工作的。讓我們添加幾個黑客,并将他們的生日作為“score”。

$ redis-cli zadd hackers 1940 "Alan Kay"
(integer) 1
$ redis-cli zadd hackers 1953 "Richard Stallman"
(integer) 1
$ redis-cli zadd hackers 1965 "Yukihiro Matsumoto"
(integer) 1
$ redis-cli zadd hackers 1916 "Claude Shannon"
(integer) 1
$ redis-cli zadd hackers 1969 "Linus Torvalds"
(integer) 1
$ redis-cli zadd hackers 1912 "Alan Turing"
(integer) 1      
對有序集合來說,按生日排序傳回這些黑客易如反掌,因為他們已經是有序的。有序集合是通過一個dual-ported 資料結構實作的,它包含一個精簡的有序清單和一個hash table,是以添加一個元素的時間複雜度是O(log(N))。這還行,但當我們需要通路有序的元素時,Redis不必再做任何事情,它已經是有序的了:
$ redis-cli zrange hackers 0 -1
1. Alan Turing
2. Claude Shannon
3. Alan Kay
4. Richard Stallman
5. Yukihiro Matsumoto
6. Linus Torvalds      

你知道Linus比Yukihiro年輕嗎

無論如何,我想反向對這些元素排序,這次就用 ZREVRANGE 代替 ZRANGE 吧:

$ redis-cli zrevrange hackers 0 -1
1. Linus Torvalds
2. Yukihiro Matsumoto
3. Richard Stallman
4. Alan Kay
5. Claude Shannon
6. Alan Turing      
一個非常重要的小貼士,ZSets隻是有一個“預設的”順序,但你仍然可以用 SORT 指令對有序集合做不同的排序(但這次伺服器要耗費CPU了)。要想得到多種排序,一種可選方案是同時将每個元素加入多個有序集合。

區間操作

有序集合之能不止于此,他能在區間上操作。例如擷取所有1950年之前出生的人。我們用 ZRANGEBYSCORE 指令來做:
$ redis-cli zrangebyscore hackers -inf 1950
1. Alan Turing
2. Claude Shannon
3. Alan Kay      

我們請求Redis傳回score介于負無窮到1950年之間的元素(兩個極值也包含了)。

也可以删除區間内的元素。例如從有序集合中删除生日介于1940到1960年之間的黑客。

$ redis-cli zremrangebyscore hackers 1940 1960
(integer) 2      
ZREMRANGEBYSCORE 這個名字雖然不算好,但他卻非常有用,還會傳回已删除的元素數量。

回到Reddit的例子

最後,回到 Reddit的例子。現在我們有個基于有序集合的像樣方案來生成首頁。用一個有序集合來包含最近幾天的新聞(用 ZREMRANGEBYSCORE 不時的删除舊新聞)。用一個背景任務從有序集合中擷取所有元素,根據使用者投票和新聞時間計算score,然後用新聞IDs和scores關聯生成 reddit.home.page 有序集合。要顯示首頁,我們隻需閃電般的調用 ZRANGE。

不時的從 reddit.home.page 有序集合中删除過舊的新聞也是為了讓我們的系統總是工作在有限的新聞集合之上。

更新有序集合的scores

結束這篇指南之前還有最後一個小貼士。有序集合scores可以在任何時候更新。隻要用 ZADD 對有序集合内的元素操作就會更新它的score(和位置),時間複雜度是O(log(N)),是以即使大量更新,有序集合也是合适的。

繼續閱讀