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 key
,incr
命令對值做自增操作,返回結果分為以下三種情況:
-
值不是整數,返回錯誤
-
值是整數,返回自增後的結果
-
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 更加簡潔。
編碼轉換總結
參考:咕泡學院架構視訊