Redis基礎——剖析基礎資料結構及其用法

detectiveHLH發表於2020-10-21

這是一個系列的文章,打算把Redis的基礎資料結構高階資料結構持久化的方式以及高可用的方式都講一遍,公眾號會比其他的平臺提前更新,感興趣的可以提前關注,「SH的全棧筆記」,下面開始正文。

如果你是一個有經驗的後端或者伺服器開發,那麼一定聽說過Redis,其全稱叫Remote Dictionary Server。是由C語言編寫的基於Key-Value的儲存系統。說直白點就是一個記憶體資料庫,既然是記憶體資料庫就會遇到如果伺服器意外當機造成的資料不一致的問題。

這跟很多遊戲伺服器也是一樣的,感興趣的可以參考我之前的文章遊戲伺服器和Web伺服器的區別。其資料首先會流向記憶體,基於快速的記憶體讀寫來實現高效能,然後定期將記憶體的資料中的資料落地。Redis其實也是這麼個流程,基於快速的記憶體讀寫操作,單機的Redis甚至能夠扛住10萬的QPS。

Redis除了高效能之外,還擁有豐富的資料結構,支援大多數的業務場景。這也是其為什麼如此受歡迎的原因之一,下面我們就來看一看Redis有哪些基礎資料型別,以及他們底層都是怎麼實現的。

1. 資料型別

其基礎資料型別有StringListHashSetSorted Set,這些都是常用的基礎資料型別,可以看到非常豐富,幾乎能夠滿足大部分的需求了。其實還有一些高階資料結構,我們在這章裡暫時先不提,只聊基礎的資料結構。

2. String

String可以說是最基礎的資料結構了, 用法上可以直接和Java中的String掛鉤,你可以把String型別用於儲存某個標誌位,某個計數器,甚至狠一點,序列化之後的JSON字串都行,其單個key限制為512M。其常見的命令為getsetincrdecrmget

2.1 使用

  • get 獲取某個key,如果key不存在會返回空指標
  • set 給key賦值,將key設定為指定的值,如果該key之前已經有值了,那麼將被新的值給覆蓋
  • incr 給當前的key的值+1,如果key不存在則會先給key呼叫set賦值為0,再呼叫incr。當然如果該key的型別不能做加法運算,例如字串,就會丟擲錯誤
  • decr 給當前key的值-1,其餘的同上
  • mget 同get,只是一次性返回多條資料,不存在的key將會返回空指標
string相關命令
string相關命令

可能大多數的人只是到用一用的地步,這也無可厚非,但是如果是作為一個對技術有追求的開發,或者說你有想近大廠的想法,一定要有刨根問底的精神。只有當你真正知道一個東西的底層原理時,你遇到問題時才能提供給你更多的思路去解決問題。接下來我們就來聊一下Redis中String底層是如何實現的。

2.2 原理

2.2.1 結構

我們知道Redis是用C語言寫的,但是Redis卻沒有直接使用,而是自己實現了一個叫SDS(Simple Dynamic String)的結構來實現字串,結構如下。

struct sdshdr {
  // 記錄buf中已使用的位元組數量
  int len;
  // 記錄buf中未使用的位元組數量
  int free;
  // 位元組陣列,用於儲存字串
  char buf[];
}

2.2.2 優點

為什麼Redis要自己實現SDS而不是直接用C的字串呢?主要是因為以下幾點。

  • 減少獲取字串長度開銷 C語言中獲取字串的長度需要遍歷整個字串,直到遇到結束標誌位\0,時間複雜度為O(n),而SDS直接維護了長度的變數,取長度的時間複雜度為O(1)
  • 避免緩衝區溢位 C語言中如果往一個位元組陣列中塞入超過其容量的位元組,那麼就會造成緩衝區溢位,而SDS通過維護free變數解決了這個問題。向buf陣列中寫入資料時,會先判斷剩餘的空間是否足夠塞入新資料,如果不夠,SDS就會重新分配緩衝區,加大之前的緩衝區。且加大的長度等於新增的資料的長度
  • 空間預分配&空間惰性釋放 C語言中,每次修改字串都會重新分配記憶體空間,如果對字串修改了n次,那麼必然會出現n次記憶體重新分配。而SDS由於冗餘了一部分空間,優化了這個問題,將必然重新分配n次變為最多分配n次,而資料從buf中移除的時候,空閒出來的記憶體也不會馬上被回收,防止新寫入資料而造成記憶體重新分配
  • 保證二進位制安全 C語言中,字串遇到\0會被截斷,而SDS不會因為資料中出現了\0而截斷字串,換句話說,不會因為一些特殊的字元影響實際的運算結果

可以結合下面的圖來理解SDS。

圖片來源於網路,侵刪
圖片來源於網路,侵刪

總結一下就是上面列表的四個小標題,為了減少獲取字串長度開銷、避免緩衝區溢位、空間預分配&空間惰性釋放和保證二進位制安全。

3. List

List也是一個使用頻率很高的資料結構,其設計到的命令太多了,就不像String那樣一個一個演示了,感興趣的大家可以去搜一搜。命令有lpush、lpushx、rpush、rpushx、lpop、rpop、lindex、linsert、lrange、llen、lrem、lset、ltrim、rpoplpush、brpoplpush、blpop、brpop,其都是對陣列中的元素的操作。

3.1 使用

List的用途我認為主要集中在以下兩個方面。

  1. 當作普通列表儲存資料(類似於Java的ArrayList)
  2. 用做非同步佇列

普通列表這個自然不必多說,其中存放的必然業務中需要的資料,下面來著重聊一下非同步佇列

啥玩意,List還能當成佇列來玩?

List除了能被用做佇列,還能當作棧來使用。在上面介紹了很多操作List命令,當我們用rpush/lpop組合命令的時候,實際上就是在使用一個佇列,而當我們用rpush/rpop命令組合的時候,就是在使用一個棧。lpush/rpop和lpush/lpop是同理的。

假設我們用的是rpush來生產訊息,當我們的程式需要消費訊息的時候,就使用lpop非同步佇列中消費訊息。但是如果採用這種方式,當佇列為空時,你可能需要不停的去詢問佇列中是否有資料,這樣會造成機器的CPU資源的浪費。

所以你可以採取讓當前執行緒Sleep一段時間,這樣的確可以節省一部分CPU資源。但是你可能就需要去考慮Sleep的時間,間隔太短,CPU上下文切換可能也是一筆不小的開銷,間隔太長,那麼可能造成這條訊息被延遲消費(不過都用非同步佇列了,應該可以忽略這個問題)。

除了Sleep,還有沒有其他的方式?

有,答案是blpop。當我們使用blpop去消費時,如果當前佇列是空的,那麼此時執行緒會阻塞住,直到下面兩種condition。

  1. 達到設定的timeout時間
  2. 佇列中有訊息可以被消費

比起Sleep一段時間,實時性會好一點;比起輪詢,對CPU資源更加友好。

3.2 原理

在Redis3.2之前,Redis採用的是ZipList(壓縮列表)或者LinkedList(連結串列)。當List中的元素同時滿足每個元素的小於64位元組List元素個數小於512個時,儲存的方式為ZipList。但凡有一個條件沒滿足就會轉換為LinkedList

而在3.2之後,其實現變成了QuickList(快速列表)。LinkedList由於是較為基礎的東西,此處就不贅述了。

3.2.1 ZipList

ZipList採用連續的記憶體緊湊儲存,不像連結串列那樣需要有額外的空間來儲存前驅節點和後續節點的指標。按照其儲存的區域劃分,大致可以分為三個部分,每個部分也有自己的劃分,其詳細的結構如下。

  • header ziplist的頭部資訊
    • zlbytes 標識ziplist所佔用的記憶體位元組數
    • zltail 到ziplist尾節點的偏移量
    • zllen ziplist中的儲存的節點數量
  • entries 儲存實際節點的資訊
    • pre_entry_length 記錄了前一個節點的長度,通過這個值可以快速的跳轉到上一個節點
    • encoding 顧名思義,儲存量元素的編碼格式
    • length 所儲存資料的長度
    • content 儲存節點的內容
  • end 標識ziplist的末尾

如果採用連結串列的儲存方式,連結串列中的元素由指標相連,這樣的方式可能會造成一定的記憶體碎片。而指標也會佔用額外的儲存空間。而ZipList不會存在這些情況,ZipList佔用的是一段連續的記憶體空間。

但是相應地,ZipList的修改操作效率較為低下,插入和刪除的操作會設計到頻繁的記憶體空間申請和釋放(有點類似於ArrayList重新擴容),且查詢效率同樣會受影響,本質上ZipList查詢元素就是遍歷連結串列。

3.2.2 QuickList

在3.2版本之後,list的實現就換成了QuickList。QuickList將list分成了多個節點,每一個節點採用ZipList儲存資料。

4. Hash

其用法就跟Java中的HashMap中一樣,都是往map中去丟鍵值對。

4.1 使用

基礎的命令如下:

  • hset 在hash中設定鍵值對
  • hget 獲hash中的某個key值
  • hdel 刪除hash中某個鍵
  • hlen 統計hash中元素的個數
  • hmget 批量的獲取hash中的鍵的值
  • hmset 批量的設定hash中的鍵和值
  • hexists 判斷hash中某個key是否存在
  • hkeys 返回hash中的所有鍵(不包含值)
  • hvals 返回hash中的所有值(不包含鍵)
  • hgetall 獲取所有的鍵值對,包含了鍵和值

其實大多數情況下的使用跟HashMap是差不多的,沒有什麼較為特殊的地方。

4.2 原理

hash的底層實現也是有兩種,ZipList和HashTable。但具體採用哪一種與Redis的版本無關,而與當前hash中所存的元素有關。首先當我們建立一個hash的時候,採用的ZipList進行儲存。隨著hash中的元素增多,達到了Redis設定的閾值,就會轉換為HashTable。

其設定的閾值如下:

  • 儲存的某個鍵或者值長度大於預設值(64)
  • ZipList中儲存的元素數量大於預設值(512)

ZipList上面我們專門簡單分析了一下,理解這個設定應該也比較容易。當ZipList中的元素過多的時候,其查詢的效率就會變得低下。而HashTable的底層設計其實和Java中的HashMap差不多,都是通過拉鍊法解決雜湊衝突。具體的可以參考從基礎的使用來深挖HashMap這篇文章。

5. Set

Set的概念可以與Java中的Set劃等號,用於儲存一個不包含重複元素的集合。

5.1 使用

其主要的命令如下,key代表redis中的Set,member代表集合中的元素。

  • sadd sadd key member [...] 將一個或者多個元素加入到集合中,如果有已經存在的元素會忽略掉。
  • srem srem key member [...]將一個或者多個元素從集合中移除,不存在的元素會被忽略掉
  • smembers smembers key返回集合中的所有成員
  • sismember dismember key member判斷member在key中是否存在,如果存在則返回1,如果不存在則返回0
  • scard scard key返回集合key中的元素的數量
  • smove move source destination member將元素從source集合移動到destination集合。如果source中不包含member,則不會執行任何操作,當且僅當存在才會從集合中移出。如果destination已經存在元素則不會對destination做任何操作。該命令是原子操作。
  • spop spop key隨機刪除並返回集合中的一個元素
  • srandmember srandmember key與spop一樣,只不過不會將元素刪除,可以理解為從集合中隨機出一個元素來。
  • sinter 求一個或者多個集合的交集
  • sinterstore sinterstore destination key [...]與sinter類似,但是會將得出的結果存到destination中。
  • sunion 求一個或者多個集合的並集
  • sunionstore sunionstore destination key [...]
  • sdiff 求一個或者多個集合的差集
  • sdiffstore sdiffstore destination key [...]與sdiff類似,但是會將得出的結果存到destination中。

5.2 原理

我們知道Java中的Set有多種實現。在Redis中也是,有IntSetHashTable兩種實現,首先初始化的時候使用的是IntSet,而滿足如下的條件時,就會轉換成HashTable

  • 當集合中儲存的所有元素都是整數時
  • 集合物件儲存的元素數量不超過512

上面已經簡單的介紹了HashTable了,所以這裡只聊聊IntSet。

5.2.1 IntSet

intset底層是一個陣列,既然資料結構是陣列,那麼儲存資料就可以是有序的,這也使得intset的底層查詢是通過二分查詢來實現。其結構如下。

struct intset {
  // 編碼方式
  uint32_t encoding;
  // 集合包含元素的數量
  uint32_t length;
  // 儲存元素的陣列
  int8_t contents[];
}

與ZipList類似,IntSet也是使用的一連串的記憶體空間,但是不同的是ZipList可以儲存二進位制的內容,而IntSet只能儲存整數;且ZipList儲存是無序的,IntSet則是有序的,這樣一來,元素個數相同的前提下,IntSet的查詢效率會更高。

6. Sorted Set

其與Set的功能大致類似,只不過在此基礎上,可以給每一個元素賦予一個權重。你可以理解為Java的TreeSet。與List、Hash、Set一樣,其底層的實現也有兩種,分別是ZipListSkipList(跳錶)。

初始化Sorted Set的時候,會採用ZipList作為其實現,其實很好理解,這個時候元素的數量很少,採用ZipList進行緊湊的儲存會更加的節省空間。當期達到如下的條件時,就會轉換為SkipList:

  • 其儲存的元素數量的個數小於128個
  • 其儲存的所有元素長度小於64位元組

6.1 使用

下面的命令中,key代表zset的名字;4代表score,也就是權重;而member就是zset中的key的名稱。

  • zadd zadd key 4 member用於增加元素
  • zcard zcard key用於獲取zset中的元素的數量
  • zrem zrem key member [...]刪除zset中一個或者多個key
  • zincrby zincrby key 1 member給key的權重值加上score的值(也就是1)
  • zscore zscore key member用於獲取指定key的權重值
  • zrange zrange key 0 -1獲取zset中所有的元素,zrange key 0 -1 withscores獲取所有元素和權重,withscores引數的作用是決定是否將權重值也一起返回。其返回的元素按照從小到大排序,如果元素具有相同的權重,則會按照字典序排序。
  • zrevrange zrevrange key 0 -1 withscores,其與zrange類似,只不過zrevrange按照從大到小排序。
  • zrangebyscore zrangebyscore key 1 5,返回key中權重在區間(1, 5]範圍內元素。當然也可以使用withscores來將權重值一併返回。其元素按照從小到大排序。1代表min,5代表max,他們也可以分別是**-infinf**,當你不知道key中的score區間時,就可以使用這個。還有一個類似於SQL中的limit的可選引數,在此就不贅述。

除了能夠對其中的元素新增權重之外,使用ZSet還可以實現延遲佇列

延遲佇列用於存放延遲任務,那什麼是延遲佇列呢?

舉個很簡單的例子, 你在某個電商APP中下訂單,但是沒有付款,此時它會提醒你,「訂單如果超過1個小時沒有支付,將會自動關閉」;再比如在某個活動結束前1個小時給使用者推送訊息;再比如訂單完成後多少天自動確認收貨等等。

用人話解釋一遍,那就是過會才要乾的事情。

那ZSet怎麼實現這個功能?

其實很簡單,就是將任務的執行時間設定為ZSet中的元素權重,然後通過一個後臺執行緒定時的從ZSet中查詢出權重最小的元素,然後通過與當前時間戳判斷,如果大於當前時間戳(也就是該執行了)就將其從ZSet中取出。

那,怎麼取?

其實我看很多講Redis實現延遲佇列的部落格都沒有把這個怎麼取講清楚,到底該用什麼命令,傳什麼引數。我們使用zrangebyscore命令來實現,還記得-inf和inf嗎,其全稱是infinity,分別表示無限小和無限大。

由於我們並不知道延遲佇列當中的score(也就是任務執行時間)的範圍,所以我們可以直接使用-inf和inf,完整命令如下。

zrangescore key -inf inf limt 0 1 withscores

還是有點用,那ZSet底層是咋實現的呢?

上面已經講過了ZipList,就不贅述,下面聊聊SkipList。

6.2 原理

6.2.1 SkipList

SkipList存在於zset(Sorted Set)的結構中,如下:

struct zset {
  // 字典
  dict *dict;
  // 跳錶
  zskiplist *zsl;
}

而SkipList的結構如下:

struct zskiplist {
  // 表頭節點和表尾節點
  struct zskiplistNode *header, *tail;
  // 表中節點的數量
  unsigned long length;
  // 表中層數最大的節點的層數
  int level;
}

不知道大家是否有想過,為什麼Redis要使用SkipList來實現ZSet,而不用陣列呢?

首先ZSet如果陣列儲存的話,由於ZSet中儲存的元素是有序的,存入的時候需要將元素放入陣列中對應的位置。這樣就會對陣列進行頻繁的增刪,而頻繁的增刪在陣列中效率並不高,因為涉及到陣列元素的移動,如果元素插入的位置是首位,那麼後面的所有元素都要被移動。

所以為了應付頻繁增刪的場景,我們需要使用到連結串列。但是隨著連結串列的元素增多,同樣的會出現問題,雖然增刪的效率提升了,但是查詢的效率變低了,因為查詢元素會從頭到尾的遍歷連結串列。所有如果有什麼方法能夠提升連結串列的查詢效率就好了。

於是跳錶就誕生了。基於單連結串列,從第一個節點開始,每隔一個節點,建立索引。其實也是單連結串列。只不是中間省略了節點。

例如存在個單連結串列 1 3 4 5 7 8 9 10 13 16 17 18

抽象之後的索引為 1 4 7 9 13 17

如果要查詢16只需要在索引層遍歷到13,然後根據13儲存的下層節點(真實連結串列節點的地址),此時只需要再遍歷兩個節點就可以找到值為16的節點。

所以可以重新給跳錶下一個定義,連結串列加多級索引的結構,就是跳錶

在跳錶中,查詢任意資料的時間複雜度是O(logn)。時間複雜度跟二分查詢是一樣的。可以換句話說,用單連結串列實現了二分查詢。但這也是一種用空間換時間的思路,並不是免費的。

End

關於Redis的基礎資料結構和其底層的原理就簡單的聊到這裡,之後的幾篇應該會聊聊Redis的高可用和其對應的解決方案,感興趣的可以持續關注,公眾號會比其他的平臺都先更新。

往期文章:

如果你覺得這篇文章對你有幫助,還麻煩點個贊關個注分個享留個言

也可以微信搜尋公眾號【SH的全棧筆記】,當然也可以直接掃描二維碼關注

相關文章