1. 程式人生 > 資料庫 >Redis學習—5種資料結構基本原理

Redis學習—5種資料結構基本原理

一、Redis 簡介

Redis 是一個開源,高階的鍵值儲存和一個適用的解決方案,用於構建高效能,可擴充套件的 Web 應用程式。Redis 也被作者戲稱為 資料結構伺服器 ,這意味著使用者可以通過一些命令,基於帶有 TCP 套接字的簡單 伺服器-客戶端 協議來訪問一組 可變資料結構 。(在 Redis 中都採用鍵值對的方式,只不過對應的資料結構不一樣罷了)

Redis 的優點

以下是 Redis 的一些優點:

  • 異常快 - Redis 非常快,每秒可執行大約 110000 次的設定(SET)操作,每秒大約可執行 81000 次的讀取/獲取(GET)操作。

  • 支援豐富的資料型別 - Redis 支援開發人員常用的大多數資料型別,例如列表,集合,排序集和雜湊等等。這使得 Redis 很容易被用來解決各種問題,因為我們知道哪些問題可以更好使用地哪些資料型別來處理解決。

  • 操作具有原子性 - 所有 Redis 操作都是原子操作,這確保如果兩個客戶端併發訪問,Redis 伺服器能接收更新的值。

  • 多實用工具 - Redis 是一個多實用工具,可用於多種用例,如:快取,訊息佇列(Redis 本地支援釋出/訂閱),應用程式中的任何短期資料,例如,web應用程式中的會話,網頁命中計數等。

Redis 五種基本資料結構

Redis 有 5 種基礎資料結構,它們分別是:string(字串)list(列表)hash(字典)set(集合) 和 zset(有序集合)。這 5 種是 Redis 相關知識中最基礎、最重要的部分,下面我們結合原始碼以及一些實踐來給大家分別講解一下。

二、String 字串

Redis 中的字串是一種 動態字串,這意味著使用者可以修改,它的底層實現有點類似於 Java 中的 ArrayList,有一個字元陣列。

2.1、儲存型別

可以用來儲存字串、整數、浮點數

2.2、使用場景

快取

在web服務中,使用MySQL作為資料庫,Redis作為快取。由於Redis具有支撐高併發的特性,通常能起到加速讀寫和降低後端壓力的作用。web端的大多數請求都是從Redis中獲取的資料,如果Redis中沒有需要的資料,則會從MySQL中去獲取,並將獲取到的資料寫入redis。

 計數

Redis中有一個字串相關的命令incr keyincr命令對值做自增操作,返回結果分為以下三種情況:

  • 值不是整數,返回錯誤

  • 值是整數,返回自增後的結果

  • key不存在,預設鍵為0,返回1

比如文章的閱讀量,視訊的播放量等等都會使用redis來計數,每播放一次,對應的播放量就會加1,同時將這些資料非同步儲存到資料庫中達到持久化的目的。

共享Session

在分散式系統中,使用者的每次請求會訪問到不同的伺服器,這就會導致session不同步的問題,假如一個用來獲取使用者資訊的請求落在A伺服器上,獲取到使用者資訊後存入session。下一個請求落在B伺服器上,想要從session中獲取使用者資訊就不能正常獲取了,因為使用者資訊的session在伺服器A上,為了解決這個問題,使用redis集中管理這些session,將session存入redis,使用的時候直接從redis中獲取就可以了。

 限速

為了安全考慮,有些網站會對IP進行限制,限制同一IP在一定時間內訪問次數不能超過n次。

2.3、儲存(實現)原理 外層的雜湊

資料模型 set hello word 為例,因為 Redis 是 KV 的資料庫,它是通過 hashtable 實現的(我 們把這個叫做外層的雜湊)。所以每個鍵值對都會有一個 dictEntry(原始碼位置:dict.h), 裡面指向了 key 和 value 的指標。next 指向下一個 dictEntry。

key 是字串,但是 Redis 沒有直接使用 C 的字元陣列,而是儲存在自定義的 SDS 中。

value 既不是直接作為字串儲存,也不是直接儲存在 SDS 中,而是儲存在 redisObject 中。實際上五種常用的資料型別的任何一種,都是通過 redisObject 來儲存 的。

      

2.4、redisObject value儲存

redisObject 定義在 src/server.h 檔案中。

2.5、內部編碼有三種

1、int,儲存 8 個位元組的長整型(long,2^63-1)。

2、embstr, 代表 embstr 格式的 SDS(Simple Dynamic String 簡單動態字串), 儲存小於 44 個位元組的字串。

3、raw,儲存大於 44 個位元組的字串(3.2 版本之前是 39 位元組)。

問題 1、什麼是 SDS?

Redis 中字串的實現。 在 3.2 以後的版本中,SDS 又有多種結構(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用於儲存不同的長度的字串,分別代表 2^5=32byte, 2^8=256byte,2^16=65536byte=64KB,2^32byte=4GB。

因為當字串比較短的時候,len 和 alloc 可以使用 byte 和 short 來表示,Redis 為了對記憶體做極致的優化,不同長度的字串使用不同的結構體來表示。

問題 2、為什麼 Redis 要用 SDS 實現字串?

為什麼不考慮直接使用 C 語言的字串呢?因為 C 語言這種簡單的字串表示方式 不符合 Redis 對字串在安全性、效率以及功能方面的要求。我們知道,C 語言使用了一個長度為 N+1 的字元陣列來表示長度為 N 的字串,並且字元陣列最後一個元素總是 '\0'

這樣簡單的資料結構可能會造成以下一些問題:

  • 獲取字串長度為 O(N) 級別的操作 → 因為 C 不儲存陣列的長度,每次都需要遍歷一遍整個陣列;

  • 不能很好的杜絕 緩衝區溢位/記憶體洩漏 的問題 → 跟上述問題原因一樣,如果執行拼接 or 縮短字串的操作,如果操作不當就很容易造成上述問題;

  • C 字串 只能儲存文字資料 → 通過從字串開始到結尾碰到的第一個'\0'來標記字串的結束,因此不能保 存圖片、音訊、視訊、壓縮檔案等二進位制(bytes)儲存的內容,二進位制不安全。

SDS 的特點:

  •  1、不用擔心記憶體溢位問題,如果需要會對 SDS 進行擴容。
  • 2、獲取字串長度時間複雜度為 O(1),因為定義了 len 屬性。
  • 3、通過“空間預分配”( sdsMakeRoomFor)和“惰性空間釋放”,防止多 次重分配記憶體。
  • 4、判斷是否結束的標誌是 len 屬性(它同樣以'\0'結尾是因為這樣就可以使用 C語言中函式庫操作字串的函數了),可以包含'\0'。
C 字串SDS
獲取字串長度的複雜度為 O(N)獲取字串長度的複雜度為 O(1)
API 是不安全的,可能會造成緩衝區溢位API 是安全的,不會造成個緩衝區溢位
修改字串長度 N 次必然需要執行 N 次記憶體重分配修改字串長度 N 次最多需要執行 N 次記憶體重分配
只能儲存文字資料可以儲存文字或者二進位制資料
可以使用所有庫中的函式可以使用一部分庫中的函式

問題 3、embstr 和 raw 的區別?

embstr 的使用只分配一次記憶體空間(因為 RedisObject 和 SDS 是連續的),而 raw 需要分配兩次記憶體空間(分別為 RedisObject 和 SDS 分配空間)。

因此與 raw 相比,embstr 的好處在於建立時少分配一次空間,刪除時少釋放一次 空間,以及物件的所有資料連在一起,尋找方便。

而 embstr 的壞處也很明顯,如果字串的長度增加需要重新分配記憶體時,整個 RedisObject 和 SDS 都需要重新分配空間,因此 Redis 中的 embstr 實現為只讀。

問題 4:int 和 embstr 什麼時候轉化為 raw?

當 int 數 據 不 再 是 整 數 , 或 大 小 超 過 了 long 的 範 圍 (2^63-1=9223372036854775807)時,自動轉化為 embstr。

embstr 超過44個位元組或者 value值被修改

問題 5:明明沒有超過閾值,為什麼變成 raw 了?

對於 embstr,由於其實現是隻讀的,因此在對 embstr 物件進行修改時,都會先 轉化為 raw 再進行修改。

因此,只要是修改 embstr 物件,修改後的物件一定是 raw 的,無論是否達到了 44 個位元組。

問題 6:當長度小於閾值時,會還原嗎?

關於 Redis 內部編碼的轉換,都符合以下規律:編碼轉換在 Redis 寫入資料時完 成,且轉換過程不可逆,只能從小記憶體編碼向大記憶體編碼轉換(但是不包括重新 set)。

問題 7:為什麼要對底層的資料結構進行一層包裝呢?

通過封裝,可以根據物件的型別動態地選擇儲存結構和可以使用的命令,實現節省 空間和優化查詢速度。

三、Hash 雜湊

Redis 中的字典相當於 Java 中的 HashMap,內部實現也差不多類似,都是通過 "陣列 + 連結串列" 的鏈地址法來解決部分 雜湊衝突,同時這樣的結構也吸收了兩種不同資料結構的優點。


3.1、儲存型別

 包含鍵值對的無序散列表。value 只能是字串,不能巢狀其他型別。

同樣是儲存字串,Hash 與 String 的主要區別?

  • 1、把所有相關的值聚集到一個 key 中,節省記憶體空間
  • 2、只使用一個 key,減少 key 衝突
  • 3、當需要批量獲取值的時候,只需要使用一個命令,減少記憶體/IO/CPU 的消耗

Hash 不適合的場景:

  • 1、Field 不能單獨設定過期時間
  • 2、沒有 bit 操作
  • 3、需要考慮資料量分佈的問題(value 值非常大的時候,無法分佈到多個節點)

3.2、使用場景

由於hash型別儲存的是一個鍵值對,比如資料庫有以下一個使用者表結構

將以上資訊存入redis,用表明:id作為key,使用者屬性作為值:

hset user:1 name Java旅途 age 18

使用雜湊儲存會比字串更加方便直觀

3.3、儲存(實現)原理

外層的雜湊(Redis KV 的實現)只用到了 hashtable。當儲存 hash 資料型別時, 我們把它叫做內層的雜湊。

內層的雜湊底層可以使用兩種資料結構實現:

ziplist:OBJ_ENCODING_ZIPLIST(壓縮列表)

hashtable:OBJ_ENCODING_HT(雜湊表)

3.4.1、ziplist(壓縮列表)

      ziplist 是一個經過特殊編碼的雙向連結串列,它不儲存指向上一個連結串列節點和指向下一 個連結串列節點的指標,而是儲存上一個節點長度和當前節點長度,通過犧牲部分讀寫效能, 來換取高效的記憶體空間利用率,是一種時間換空間的思想。只用在欄位個數少,欄位值 小的場景裡面。

ziplist 的內部結構

其記憶體儲存如下:

問題:什麼時候使用 ziplist 儲存?

當 hash 物件同時滿足以下兩個條件的時候,使用 ziplist 編碼:

1)所有的鍵值對的健和值的字串長度都小於等於 64byte(一個英文字母 一個位元組);

2)雜湊物件儲存的鍵值對數量小於 512 個。

3.4.2、hashtable(dict)

在 Redis 中,hashtable 被稱為字典(dictionary),它是一個數組+連結串列的結構。 原始碼位置:dict.h

前面我們知道了,Redis 的 KV 結構是通過一個 dictEntry 來實現的。 Redis 又對 dictEntry 進行了多層的封裝

dictEntry 放到了 dictht(hashtable 裡面):

ht 放到了 dict 裡面:

從最底層到最高層 dictEntry——dictht——dict——OBJ_ENCODING_HT

總結:雜湊的儲存結構:

可以從上面的原始碼中看到,實際上字典結構的內部包含兩個 hashtable,通常情況下只有一個 hashtable 是有值的,但是在字典擴容縮容時,需要分配新的 hashtable,然後進行 漸進式搬遷 (下面說原因)

問題1:為什麼要定義兩個雜湊表呢?ht[2]

redis 的 hash 預設使用的是 ht[0],ht[1]不會初始化和分配空間。

雜湊表 dictht 是用鏈地址法來解決碰撞問題的。在這種情況下,雜湊表的效能取決於它的大小(size 屬性)和它所儲存的節點的數量(used 屬性)之間的比率:

  • 比率在 1:1 時(一個雜湊表 ht 只儲存一個節點 entry),雜湊表的效能最好;
  • 如果節點數量比雜湊表的大小要大很多的話(這個比例用 ratio 表示,5 表示平均一個 ht 儲存 5 個 entry),那麼雜湊表就會退化成多個連結串列,雜湊表本身的效能 優勢就不再存在。

問題2:擴容 (漸進式rehash)步驟

大字典的擴容是比較耗時間的,需要重新申請新的陣列,然後將舊字典所有連結串列中的元素重新掛接到新的陣列下面,這是一個 O(n) 級別的操作,作為單執行緒的 Redis 很難承受這樣耗時的過程,所以 Redis 使用 漸進式 rehash 小步搬遷:

漸進式 rehash 會在 rehash 的同時,保留新舊兩個 hash 結構,如上圖所示,查詢時會同時查詢兩個 hash 結構,然後在後續的定時任務以及 hash 操作指令中,循序漸進的把舊字典的內容遷移到新字典中。當搬遷完成了,就會使用新的 hash 結構取而代之。

rehash 的步驟:

  • 1、為字元 ht[1]雜湊表分配空間,這個雜湊表的空間大小取決於要執行的操作,以 及 ht[0]當前包含的鍵值對的數量。 擴充套件:ht[1]的大小為第一個大於等於 ht[0].used*2。
  • 2、將所有的 ht[0]上的節點 rehash 到 ht[1]上,重新計算 hash 值和索引,然後放 入指定的位置。
  • 3、當 ht[0]全部遷移到了 ht[1]之後,釋放 ht[0]的空間,將 ht[1]設定為 ht[0]表, 並建立新的 ht[1],為下次 rehash 做準備

問題3:什麼時候觸發擴容?

正常情況下,當 hash 表中 元素的個數等於第一維陣列的長度時,就會開始擴容,擴容的新陣列是 原陣列大小的 2 倍。不過如果 Redis 正在做 bgsave(持久化命令),為了減少記憶體也得過多分離,Redis 儘量不去擴容,但是如果 hash 表非常滿了,達到了第一維陣列長度的 5 倍了,這個時候就會 強制擴容

當 hash 表因為元素逐漸被刪除變得越來越稀疏時,Redis 會對 hash 表進行縮容來減少 hash 表的第一維陣列空間佔用。所用的條件是 元素個數低於陣列長度的 10%,縮容不會考慮 Redis 是否在做 bgsave

四、List 列表

Redis 的列表相當於 Java 語言中的 LinkedList,注意它是連結串列而不是陣列。這意味著 list 的插入和刪除操作非常快,時間複雜度為 O(1),但是索引定位很慢,時間複雜度為 O(n)。

4.1、儲存型別

儲存有序的字串(從左到右),元素可以重複。可以充當佇列和棧的角色。

4.2、使用場景

訊息佇列

列表用來儲存多個有序的字串,既然是有序的,那麼就滿足訊息佇列的特點。使用lpush+rpop或者rpush+lpop實現訊息佇列。除此之外,redis支援阻塞操作,在彈出元素的時候使用阻塞命令來實現阻塞佇列。

由於列表儲存的是有序字串,滿足佇列的特點,也就能滿足棧先進後出的特點,使用lpush+lpop或者rpush+rpop實現棧。

文章列表

因為列表的元素不但是有序的,而且還支援按照索引範圍獲取元素。因此我們可以使用命令lrange key 0 9分頁獲取文章列表

4.3、儲存(實現)原理

在早期的版本中,資料量較小時用 ziplist 儲存,達到臨界值時轉換為 linkedlist 進 行儲存,分別對應 OBJ_ENCODING_ZIPLIST 和 OBJ_ENCODING_LINKEDLIST 。

3.2 版本之後,統一用 quicklist 來儲存。quicklist 儲存了一個雙向連結串列,每個節點 都是一個 ziplist

4.3.1、quicklist

quicklist(快速列表)是 ziplist 和 linkedlist 的結合體。

quicklist.h,head 和 tail 指向雙向列表的表頭和表尾。

quicklistNode 中的*zl 指向一個 ziplist,一個 ziplist 可以存放多個元素。

整體實現圖統一用 quicklist 來儲存。quicklist 儲存了一個雙向連結串列,每個節點 都是一個 ziplist

4.4、連結串列的基本操作

  • LPUSH 和 RPUSH 分別可以向 list 的左邊(頭部)和右邊(尾部)新增一個新元素;

  • LRANGE 命令可以從 list 中取出一定範圍的元素;

  • LINDEX 命令可以從 list 中取出指定下表的元素,相當於 Java 連結串列操作中的 get(int index) 操作;

五、集合SET

集合型別也可以儲存多個字串元素,與列表不同的是,集合中不允許有重複元素並且集合中的元素是無序的。一個集合最多可以儲存2^32-1個元素。

5.1、儲存型別

String 型別的無序集合,最大儲存數量 2^32-1(40 億左右)。

5.2、應用場景

 使用者標籤

例如一個使用者對籃球、足球感興趣,另一個使用者對橄欖球、乒乓球感興趣,這些興趣點就是一個標籤。有了這些資料就可以得到喜歡同一個標籤的人,以及使用者的共同感興趣的標籤。給使用者打標籤的時候需要①給使用者打標籤,②給標籤加使用者,需要給這兩個操作增加事務。

  • 給使用者打標籤

sadd user:1:tags tag1 tag2

  • 給標籤新增使用者

sadd tag1:users user:1

sadd tag2:users user:1

使用交集(sinter)求兩個user的共同標籤

sinter user:1:tags user:2:tags

 抽獎功能

集合有兩個命令支援獲取隨機數,分別是:

  • 隨機獲取count個元素,集合元素個數不變

srandmember key [count]

  • 隨機彈出count個元素,元素從集合彈出,集合元素個數改變

spop key [count]

使用者點選抽獎按鈕,引數抽獎,將使用者編號放入集合,然後抽獎,分別抽一等獎、二等獎,如果已經抽中一等獎的使用者不能引數抽二等獎則使用spop,反之使用srandmember

5.3、儲存(實現)原理

Redis 用 intset 或 hashtable 儲存 set。如果元素都是整數型別,就用 inset 儲存。 如果不是整數型別,就用 hashtable(陣列+連結串列的存來儲結構)。

問題:KV 怎麼儲存 set 的元素?key 就是元素的值,value 為 null。 如果元素個數超過 512 個,也會用 hashtable 儲存

六:有序列表 zset

有序集合和集合一樣,不能有重複元素。但是可以排序,它給每個元素設定一個score作為排序的依據。最多可以儲存2^32-1個元素。

這可能使 Redis 最具特色的一個數據結構了,它類似於 Java 中 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以為每個 value 賦予一個 score 值,用來代表排序的權重。

6.1、使用場景

排行榜

使用者釋出了n篇文章,其他人看到文章後給喜歡的文章點贊,使用score來記錄點贊數,有序集合會根據score排行。流程如下

使用者釋出一篇文章,初始點贊數為0,即score為0

zadd user:article 0 a

有人給文章a點贊,遞增1

zincrby user:article 1 a

查詢點贊前三篇文章

zrevrangebyscore user:article 0 2

查詢點贊後三篇文章

zrangebyscore user:article 0 2

延遲訊息佇列

下單系統,下單後需要在15分鐘內進行支付,如果15分鐘未支付則自動取消訂單。將下單後的十五分鐘後時間作為score,訂單作為value存入redis,消費者輪詢去消費,如果消費的大於等於這筆記錄的score,則將這筆記錄移除佇列,取消訂單。

6.2、實現原理

有序集合型別的內部編碼有兩種:

  • ziplist(壓縮列表):當有序集合的元素個數小於list-max-ziplist-entries配置(預設128個)同時所有值都小於list-max-ziplist-value配置(預設64位元組)時使用。ziplist使用更加緊湊的結構實現多個元素的連續儲存,更加節省記憶體。

  • skiplist(跳躍表):當不滿足ziplist的要求時,會使用skiplist。

6.3.1、skiplist+dict 儲存

skiplist編碼的有序集合物件底層實現是跳躍表和字典兩種:

  • 1、每個跳躍表節點都儲存一個集合元素,並按分值從小到大排列;節點的object屬性儲存了元素的成員,score屬性儲存分值;
  • 2、字典的每個鍵值對儲存一個集合元素,字典的鍵儲存元素的成員,字典的值儲存分值。

為何skiplist編碼要同時使用跳躍表和字典實現?

  • 跳躍表優點是有序,但是查詢分值複雜度為O(logn);
  • 字典查詢分值複雜度為O(1) ,但是無序,所以結合連個結構的有點進行實現。

雖然採用兩個結構但是集合的元素成員和分值是共享的,兩種結構通過指標指向同一地址,不會浪費記憶體。

問題:什麼是 skiplist?

參考:

我們先來看一下有序連結串列:

在這樣一個連結串列中,如果我們要查詢某個資料,那麼需要從頭開始逐個進行比較, 直到找到包含資料的那個節點,或者找到第一個比給定資料大的節點為止(沒找到)。 也就是說,時間複雜度為 O(n)。同樣,當我們要插入新資料的時候,也要經歷同樣的查 找過程,從而確定插入位置。

而二分查詢法只適用於有序陣列,不適用於連結串列。

假如我們每相鄰兩個節點增加一個指標(或者理解為有三個元素進入了第二層),

讓指標指向下下個節點。

這樣所有新增加的指標連成了一個新的連結串列,但它包含的節點個數只有原來的一半 (上圖中是 7, 19, 26)。在插入一個數據的時候,決定要放到那一層,取決於一個演算法 (在 redis 中 t_zset.c 有一個 zslRandomLevel 這個方法)。

現在當我們想查詢資料的時候,可以先沿著這個新連結串列進行查詢。當碰到比待查數 據大的節點時,再回到原來的連結串列中的下一層進行查詢。比如,我們想查詢 23,查詢的路徑是沿著下圖中標紅的指標所指向的方向進行的:

  • 1. 23 首先和 7 比較,再和 19 比較,比它們都大,繼續向後比較。
  • 2. 但 23 和 26 比較的時候,比 26 要小,因此回到下面的連結串列(原連結串列),與 22 比較。
  • 3. 23 比 22 要大,沿下面的指標繼續向後和 26 比較。23 比 26 小,說明待查數 據 23 在原連結串列中不存在 在這個查詢過程中,由於新增加的指標,我們不再需要與連結串列中每個節點逐個進行 比較了。需要比較的節點數大概只有原來的一半。這是跳躍表。

為什麼不用 AVL 樹或者紅黑樹?因為 skiplist 更加簡潔。

編碼轉換總結

 

參考:咕泡學院架構視訊