數據類型和應用場景#
Redis 提供了豐富的數據類型,常見的有五種:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。隨著 Redis 版本的更新,後面又支持了四種數據類型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
String#
String 是最基本的 key-value 結構,key 是唯一標識,value 是具體的值,value 其實不僅是字符串, 也可以是數字(整數或浮點數),value 最多可以容納的數據長度是 512M。
內部實現#
String 類型的底層的數據結構實現主要是 int 和 SDS(簡單動態字符串)。
SDS 相比於 C 的原生字符串:
- SDS 不僅可以保存文本數據,還可以保存二進制數據。因為 SDS 使用 len 屬性的值而不是空字符來判斷字符串是否結束,並且 SDS 的所有 API 都會以處理二進制的方式來處理 SDS 存放在 buf [] 陣列裡的數據。所以 SDS 不光能存放文本數據,而且能保存圖片、音頻、視頻、壓縮文件這樣的二進制數據。
- SDS 獲取字符串長度的時間複雜度是 O (1)。因為 C 語言的字符串並不記錄自身長度,所以獲取長度的複雜度為 O (n);而 SDS 結構裡用 len 屬性記錄了字符串長度,所以複雜度為 O (1)。
- Redis 的 SDS API 是安全的,拼接字符串不會造成緩衝區溢出。因為 SDS 在拼接字符串之前會檢查 SDS 空間是否滿足要求,如果空間不夠會自動擴容,所以不會導致緩衝區溢出的问题。
字符串對象的內部編碼(encoding)有 3 種 :int、raw和 embstr。
如果一個字符串對象保存的是整數值,並且這個整數值可以用 long 類型來表示,那麼字符串對象會將整數值保存在字符串對象結構的 ptr 屬裡面(將 void * 轉換成 long),並將字符串對象的編碼設置為 int。
如果字符串對象保存的是一個字符串,並且這個字符申的長度小於等於 32 字節(redis 2.+ 版本),那麼字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串,並將對象的編碼設置為 embstr, embstr 編碼是專門用於保存短字符串的一種優化編碼方式。
如果字符串對象保存的是一個字符串,並且這個字符串的長度大於 32 字節(redis 2.+ 版本),那麼字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串,並將對象的編碼設置為 raw。
注意,embstr 編碼和 raw 編碼的邊界在 redis 不同版本中是不一樣的:
- redis 2.+ 是 32 字節
- redis 3.0-4.0 是 39 字節
- redis 5.0 是 44 字節
可以看到 embstr 和 raw 編碼都會使用 SDS 來保存值,但不同之處在於embstr 會通過一次內存分配函數來分配一塊連續的內存空間來保存 redisObject 和 SDS,而raw 編碼會通過調用兩次內存分配函數來分別分配兩塊空間來保存 redisObject 和 SDS。Redis 這樣做會有很多好處:
- embstr 編碼將創建字符串對象所需的內存分配次數從 raw 編碼的兩次降低為一次;
- 釋放 embstr 編碼的字符串對象同樣只需要調用一次內存釋放函數;
- 因為 embstr 編碼的字符串對象的所有數據都保存在一塊連續的內存裡面可以更好的利用 CPU 緩存提升性能。
但是 embstr 也有缺點的:
- 如果字符串的長度增加需要重新分配內存時,整個 redisObject 和 sds 都需要重新分配空間,所以 embstr 編碼的字符串對象實際上是只讀的,redis 沒有為 embstr 編碼的字符串對象編寫任何相應的修改程序。當我們對 embstr 編碼的字符串對象執行任何修改命令(例如 append)時,程序會先將對象的編碼從 embstr 轉換成 raw,然後再執行修改命令。
常用指令#
# 設置 key-value 類型的值
> SET name lin
OK
# 根據 key 獲得對應的 value
> GET name
"lin"
# 判斷某個 key 是否存在
> EXISTS name
(integer) 1
# 返回 key 所儲存的字符串值的長度
> STRLEN name
(integer) 3
# 刪除某個 key 對應的值
> DEL name
(integer) 1
# 批量設置 key-value 類型的值
> MSET key1 value1 key2 value2
OK
# 批量獲取多個 key 對應的 value
> MGET key1 key2
1) "value1"
2) "value2"
# 設置 key-value 類型的值
> SET number 0
OK
# 將 key 中儲存的數字值增一
> INCR number
(integer) 1
# 將key中存儲的數字值加 10
> INCRBY number 10
(integer) 11
# 將 key 中儲存的數字值減一
> DECR number
(integer) 10
# 將key中存儲的數字值鍵 10
> DECRBY number 10
(integer) 0
# 設置 key 在 60 秒後過期(該方法是針對已存在的key設置過期時間)
> EXPIRE name 60
(integer) 1
# 查看數據還有多久過期
> TTL name
(integer) 51
#設置 key-value 類型的值,並設置該key的過期時間為 60 秒
> SET key value EX 60
OK
> SETEX key 60 value
OK
# 不存在就插入(not exists)
>SETNX key value
(integer) 1
應用場景#
快取對象#
- 直接快取整個對象的 JSON,命令例子: SET user:1 '{"name":"xiaolin", "age":18}'。
- 采用將 key 進行分離為 user:ID: 屬性,采用 MSET 存儲,用 MGET 獲取各屬性值,命令例子: MSET user:1 xiaolin user:1 18 user:2 xiaomei user:2 20。
常規計數#
因為 Redis 處理命令是單線程,所以執行命令的過程是原子的。因此 String 數據類型適合計數場景,比如計算訪問次數、點讚、轉發、庫存數量等等。
分佈式鎖#
SET 命令有個 NX 參數可以實現「key 不存在才插入」,可以用它來實現分佈式鎖:
- 如果 key 不存在,則顯示插入成功,可以用來表示加鎖成功;
- 如果 key 存在,則會顯示插入失敗,可以用來表示加鎖失敗。
一般而言,還會對分佈式鎖加上過期時間,分佈式鎖的命令如下:
SET lock_key unique_value NX PX 10000
而解鎖的過程就是將 lock_key 鍵刪除,但不能亂刪,要保證執行操作的客戶端就是加鎖的客戶端。所以,解鎖的時候,我們要先判斷鎖的 unique_value 是否為加鎖客戶端,是的話,才將 lock_key 鍵刪除。
共享 Session 信息#
通常我們在開發後台管理系統時,會使用 Session 來保存用戶的會話 (登錄) 狀態,這些 Session 信息會被保存在伺服器端,但這只適用於單系統應用,如果是分佈式系統此模式將不再適用。
因此,我們需要借助 Redis 對這些 Session 信息進行統一的存儲和管理,這樣無論請求發送到那台伺服器,伺服器都會去同一個 Redis 獲取相關的 Session 信息,這樣就解決了分佈式系統下 Session 存儲的問題。
List#
List 列表是簡單的字符串列表,按照插入順序排序,可以從頭部或尾部向 List 列表添加元素。列表的最大長度為 2^32 - 1,也即每個列表支持超過 40 億個元素。
內部實現#
List 類型的底層數據結構是由雙向鏈表或壓縮列表實現的
- 如果列表的元素個數小於 512 個(默認值,可由 list-max-ziplist-entries 配置),列表每個元素的值都小於 64 字節(默認值,可由 list-max-ziplist-value 配置),Redis 會使用壓縮列表作為 List 類型的底層數據結構;
- 如果列表的元素不滿足上面的條件,Redis 會使用雙向鏈表作為 List 類型的底層數據結構;
但是在 Redis 3.2 版本之後,List 數據類型底層數據結構就只由 quicklist 實現了,替代了雙向鏈表和壓縮列表。
常用指令#
# 將一個或多個值value插入到key列表的表頭(最左邊),最後的值在最前面
LPUSH key value [value ...]
# 將一個或多個值value插入到key列表的表尾(最右邊)
RPUSH key value [value ...]
# 移除並返回key列表的頭元素
LPOP key
# 移除並返回key列表的尾元素
RPOP key
# 返回列表key中指定區間內的元素,區間以偏移量start和stop指定,從0開始
LRANGE key start stop
# 從key列表表頭彈出一個元素,沒有就阻塞timeout秒,如果timeout=0則一直阻塞
BLPOP key [key ...] timeout
# 從key列表表尾彈出一個元素,沒有就阻塞timeout秒,如果timeout=0則一直阻塞
BRPOP key [key ...] timeout
應用場景#
消息隊列#
消息隊列在存取消息時,必須要滿足三個需求,分別是消息保序、處理重複的消息和保證消息可靠性。
Redis 的 List 和 Stream 兩種數據類型,就可以滿足消息隊列的這三個需求。
List 作為消息隊列有什麼缺陷?
List 不支持多個消費者消費同一條消息,因為一旦消費者拉取一條消息後,這條消息就從 List 中刪除了,無法被其它消費者再次消費。
要實現一條消息可以被多個消費者消費,那麼就要將多個消費者組成一個消費組,使得多個消費者可以消費同一條消息,但是 List 類型並不支持消費組的實現。
Hash#
Hash 是一個鍵值對(key - value)集合,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]。Hash 特別適合用於存儲對象。
內部實現#
Hash 類型的底層數據結構是由壓縮列表或哈希表實現的:
- 如果哈希類型元素個數小於 512 個(默認值,可由 hash-max-ziplist-entries 配置),所有值小於 64 字節(默認值,可由 hash-max-ziplist-value 配置)話,Redis 會使用壓縮列表作為 Hash 類型的底層數據結構;
- 如果哈希類型元素不滿足上面條件,Redis 會使用哈希表作為 Hash 類型的 底層數據結構。
在 Redis 7.0 中,壓縮列表數據結構已經廢棄了,交由 listpack 數據結構來實現了。
常用命令#
# 存儲一個哈希表key的鍵值
HSET key field value
# 獲取哈希表key對應的field鍵值
HGET key field
# 在一個哈希表key中存儲多個鍵值對
HMSET key field value [field value...]
# 批量獲取哈希表key中多個field鍵值
HMGET key field [field ...]
# 刪除哈希表key中的field鍵值
HDEL key field [field ...]
# 返回哈希表key中field的數量
HLEN key
# 返回哈希表key中所有的鍵值
HGETALL key
# 為哈希表key中field鍵的值加上增量n
HINCRBY key field n
應用場景#
快取對象#
Hash 類型的 (key,field, value) 的結構與對象的(對象 id, 屬性, 值)的結構相似,也可以用來存儲對象。
# 存儲一個哈希表uid:1的鍵值
> HMSET uid:1 name Tom age 15
2
# 存儲一個哈希表uid:2的鍵值
> HMSET uid:2 name Jerry age 13
2
# 獲取哈希表用戶id為1中所有的鍵值
> HGETALL uid:1
1) "name"
2) "Tom"
3) "age"
4) "15"
購物車#
以用戶 id 為 key,商品 id 為 field,商品數量為 value,恰好構成了購物車的 3 個要素,如下圖所示。
涉及的命令如下:
- 添加商品:HSET cart:{用戶 id} {商品 id} 1
- 添加數量:HINCRBY cart:{用戶 id} {商品 id} 1
- 商品總數:HLEN cart:{用戶 id}
- 刪除商品:HDEL cart:{用戶 id} {商品 id}
- 獲取購物車所有商品:HGETALL cart:{用戶 id}
前僅僅是將商品 ID 存儲到了 Redis 中,在回顯商品具體信息的時候,還需要拿著商品 id 查詢一次數據庫,獲取完整的商品的信息。
Set#
Set 類型是一個無序並唯一的鍵值集合,它的存儲順序不會按照插入的先後順序進行存儲。
一個集合最多可以存儲 2^32-1 個元素。概念和數學中個的集合基本類似,可以交集,並集,差集等等,所以 Set 類型除了支持集合內的增刪改查,同時還支持多個集合取交集、並集、差集。
內部實現#
Set 類型的底層數據結構是由哈希表或整數集合實現的:
- 如果集合中的元素都是整數且元素個數小於 512 (默認值,set-maxintset-entries 配置)個,Redis 會使用整數集合作為 Set 類型的底層數據結構;
- 如果集合中的元素不滿足上面條件,則 Redis 使用哈希表作為 Set 類型的底層數據結構。
常用命令#
# 往集合key中存入元素,元素存在則忽略,若key不存在則新建
SADD key member [member ...]
# 從集合key中刪除元素
SREM key member [member ...]
# 獲取集合key中所有元素
SMEMBERS key
# 獲取集合key中的元素個數
SCARD key
# 判斷member元素是否存在於集合key中
SISMEMBER key member
# 從集合key中隨機選出count個元素,元素不從key中刪除
SRANDMEMBER key [count]
# 從集合key中隨機選出count個元素,元素從key中刪除
SPOP key [count]
# 交集運算
SINTER key [key ...]
# 將交集結果存入新集合destination中
SINTERSTORE destination key [key ...]
# 並集運算
SUNION key [key ...]
# 將並集結果存入新集合destination中
SUNIONSTORE destination key [key ...]
# 差集運算
SDIFF key [key ...]
# 將差集結果存入新集合destination中
SDIFFSTORE destination key [key ...]
應用場景#
Set 類型比較適合用來數據去重和保障數據的唯一性,還可以用來統計多個集合的交集、錯集和並集等,但是這裡有一個潛在的風險。Set 的差集、並集和交集的計算複雜度較高,在數據量較大的情況下,如果直接執行這些計算,會導致 Redis 實例阻塞。
點讚#
Set 類型可以保證一個用戶只能點一個讚,這裡舉例子一個場景,key 是文章 id,value 是用戶 id。
# uid:1 用戶對文章 article:1 點讚
> SADD article:1 uid:1
(integer) 1
# uid:2 用戶對文章 article:1 點讚
> SADD article:1 uid:2
(integer) 1
# uid:3 用戶對文章 article:1 點讚
> SADD article:1 uid:3
(integer) 1
uid:1 取消了對 article:1 文章點讚。
> SREM article:1 uid:1
(integer) 1
獲取 article:1 文章所有點讚用戶 :
> SMEMBERS article:1
1) "uid:3"
2) "uid:2"
獲取 article:1 文章的點讚用戶數量:
> SCARD article:1
(integer) 2
判斷用戶 uid:1 是否對文章 article:1 點讚了:
> SISMEMBER article:1 uid:1
(integer) 0 # 返回0說明沒點讚,返回1則說明點讚了
共同關注#
Set 類型支持交集運算,所以可以用來計算共同關注的好友、公众号等。
key 可以是用戶 id,value 則是已關注的公众号的 id。
# uid:1 用戶關注公众号 id 為 5、6、7、8、9
> SADD uid:1 5 6 7 8 9
(integer) 5
# uid:2 用戶關注公众号 id 為 7、8、9、10、11
> SADD uid:2 7 8 9 10 11
(integer) 5
uid:1 和 uid:2 共同關注的公众号:
# 獲取共同關注
> SINTER uid:1 uid:2
1) "7"
2) "8"
3) "9"
給 uid:2 推薦 uid:1 關注的公众号:
> SDIFF uid:1 uid:2
1) "5"
2) "6"
驗證某個公众号是否同時被 uid:1 或 uid:2 關注:
> SISMEMBER uid:1 5
(integer) 1 # 返回0,說明關注了
> SISMEMBER uid:2 5
(integer) 0 # 返回0,說明沒關注
抽獎活動#
存儲某活動中中獎的用戶名 ,Set 類型因為有去重功能,可以保證同一個用戶不會中獎兩次。
key為抽獎活動名,value為員工名稱,把所有員工名稱放入抽獎箱 :
>SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark
(integer) 5
如果允許重複中獎,可以使用 SRANDMEMBER 命令。
# 抽取 1 個一等奖:
> SRANDMEMBER lucky 1
1) "Tom"
# 抽取 2 個二等奖:
> SRANDMEMBER lucky 2
1) "Mark"
2) "Jerry"
# 抽取 3 個三等奖:
> SRANDMEMBER lucky 3
1) "Sary"
2) "Tom"
3) "Jerry"
如果不允許重複中獎,可以使用 SPOP 命令。
# 抽取一等奖1個
> SPOP lucky 1
1) "Sary"
# 抽取二等奖2個
> SPOP lucky 2
1) "Jerry"
2) "Mark"
# 抽取三等奖3個
> SPOP lucky 3
1) "John"
2) "Sean"
3) "Lindy"
Zet#
Zset 類型(有序集合類型)相比於 Set 類型多了個排序屬性 score(分值),對於有序集合 ZSet 來說,每個存儲元素相當於有兩個值組成的,一個是有序集合的元素值,一個是排序值。
有序集合保留了集合不能有重複成員的特性(分值可以重複),但不同的是,有序集合中的元素可以排序。
內部實現#
Zset 類型的底層數據結構是由壓縮列表或跳表實現的:
- 如果有序集合的元素個數小於 128 個,並且每個元素的值小於 64 字節時,Redis 會使用壓縮列表作為 Zset 類型的底層數據結構;
- 如果有序集合的元素不滿足上面的條件,Redis 會使用跳表作為 Zset 類型的底層數據結構;
在 Redis 7.0 中,壓縮列表數據結構已經廢棄了,交由 listpack 數據結構來實現了。
常用命令#
Zset 常用操作:
# 往有序集合key中加入帶分值元素
ZADD key score member [[score member]...]
# 往有序集合key中刪除元素
ZREM key member [member...]
# 返回有序集合key中元素member的分值
ZSCORE key member
# 返回有序集合key中元素個數
ZCARD key
# 為有序集合key中元素member的分值加上increment
ZINCRBY key increment member
# 正序獲取有序集合key從start下標到stop下標的元素
ZRANGE key start stop [WITHSCORES]
# 倒序獲取有序集合key從start下標到stop下標的元素
ZREVRANGE key start stop [WITHSCORES]
# 返回有序集合中指定分數區間內的成員,分數由低到高排序。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
# 返回指定成員區間內的成員,按字典正序排列, 分數必須相同。
ZRANGEBYLEX key min max [LIMIT offset count]
# 返回指定成員區間內的成員,按字典倒序排列, 分數必須相同
ZREVRANGEBYLEX key max min [LIMIT offset count]
Zset 運算操作(相比於 Set 類型,ZSet 類型沒有支持差集運算):
# 並集計算(相同元素分值相加),numberkeys一共多少個key,WEIGHTS每個key對應的分值乘積
ZUNIONSTORE destkey numberkeys key [key...]
# 交集計算(相同元素分值相加),numberkeys一共多少個key,WEIGHTS每個key對應的分值乘積
ZINTERSTORE destkey numberkeys key [key...]
應用場景#
排行榜#
arcticle:1 文章獲得了200個讚
> ZADD user:xiaolin:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章獲得了40個讚
> ZADD user:xiaolin:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章獲得了100個讚
> ZADD user:xiaolin:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章獲得了50個讚
> ZADD user:xiaolin:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章獲得了150個讚
> ZADD user:xiaolin:ranking 150 arcticle:5
(integer) 1
文章 arcticle:4 新增一個讚,可以使用 ZINCRBY 命令(為有序集合key中元素member的分值加上increment):
> ZINCRBY user:xiaolin:ranking 1 arcticle:4
"51"
查看某篇文章的讚數,可以使用 ZSCORE 命令(返回有序集合key中元素個數):
> ZSCORE user:xiaolin:ranking arcticle:4
"50"
獲取小林文章讚數最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序獲取有序集合 key 從start下標到stop下標的元素):
# WITHSCORES 表示把 score 也顯示出來
> ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"
獲取小林 100 讚到 200 讚的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分數區間內的成員,分數由低到高排序):
> ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES
1) "arcticle:3"
2) "100"
3) "arcticle:5"
4) "150"
5) "arcticle:1"
6) "200"
BitMap#
Bitmap,即位圖,是一串連續的二進制數組(0 和 1),可以通過偏移量(offset)定位元素。BitMap 通過最小的單位 bit 來進行 0|1 的設置,表示某個元素的值或者狀態,時間複雜度為 O (1)。
由於 bit 是計算機中最小的單位,使用它進行儲存將非常節省空間,特別適合一些數據量大且使用二值統計的場景。
內部實現#
Bitmap 本身是用 String 類型作為底層數據結構實現的一種統計二值狀態的數據類型。
String 類型是會保存為二進制的字節數組,所以,Redis 就把字節數組的每個 bit 位利用起來,用來表示一個元素的二值狀態,你可以把 Bitmap 看作是一個 bit 數組。
bitmap 基本操作:#
# 設置值,其中value只能是 0 和 1
SETBIT key offset value
# 獲取值
GETBIT key offset
# 獲取指定範圍內值為 1 的個數
# start 和 end 以字節為單位
BITCOUNT key start end
bitmap 運算操作:
# BitMap間的運算
# operations 位移操作符,枚舉值
AND 與運算 &
OR 或運算 |
XOR 异或 ^
NOT 取反 ~
# result 計算的結果,會存儲在該key中
# key1 … keyn 參與運算的key,可以有多個,空格分割,not運算只能一個key
# 當 BITOP 處理不同長度的字符串時,較短的那個字符串所缺少的部分會被看作 0。返回值是保存到 destkey 的字符串的長度(以字節byte為單位),和輸入 key 中最長的字符串長度相等。
BITOP [operations] [result] [key1] [keyn…]
# 返回指定key中第一次出現指定value(0/1)的位置
BITPOS [key] [value]
應用場景#
簽到統計#
假設我們要統計 ID 100 的用戶在 2022 年 6 月份的簽到情況,就可以按照下面的步驟進行操作。
第一步,執行下面的命令,記錄該用戶 6 月 3 號已簽到。
SETBIT uid:sign:100:202206 2 1
第二步,檢查該用戶 6 月 3 日是否簽到。
GETBIT uid:sign:100:202206 2
第三步,統計該用戶在 6 月份的簽到次數。
BITCOUNT uid:sign:100:202206
這樣,我們就知道該用戶在 6 月份的簽到情況了。
我們可以通過執行這條命令來獲取 userID = 100 在 2022 年 6 月份首次打卡日期:
BITPOS uid:sign:100:202206 1
連續簽到用戶個數#
假設要統計 3 天連續打卡的用戶數,則是將三個 bitmap 進行 AND 操作,並將結果保存到 destmap 中,接著對 destmap 執行 BITCOUNT 統計,如下命令:
# 與操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
# 統計 bit 位 = 1 的個數
BITCOUNT destmap
即使一天產生一個億的數據,Bitmap 占用的內存也不大,大約占 12 MB 的內存(10^8/8/1024/1024),7 天的 Bitmap 的內存開銷約為 84 MB。同時我們最好給 Bitmap 設置過期時間,讓 Redis 刪除過期的打卡數據,節省內存。
HyperLogLog#
HyperLogLog 提供不精確的去重計數。在 Redis 裡面,每個 HyperLogLog 鍵只需要花費 12 KB 內存,就可以計算接近 2^64 個不同元素的基數,和元素越多就越耗費內存的 Set 和 Hash 類型相比,HyperLogLog 就非常節省空間。
內部實現#
常見命令#
# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]
# 返回給定 HyperLogLog 的基數估算值。
PFCOUNT key [key ...]
# 將多個 HyperLogLog 合併為一個 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]
應用場景#
百萬級網頁 UV 計數#
在統計 UV 時,你可以用 PFADD 命令(用於向 HyperLogLog 中添加新元素)把訪問頁面的每個用戶都添加到 HyperLogLog 中。
PFADD page1:uv user1 user2 user3 user4 user5
接下來,就可以用 PFCOUNT 命令直接獲得 page1 的 UV 值了,這個命令的作用就是返回 HyperLogLog 的統計結果。
PFCOUNT page1:uv
GEO#
主要用於存儲地理位置信息,並對存儲的信息進行操作。
在日常生活中,我們越來越依賴搜索 “附近的餐館”、在打車軟件上叫車,這些都離不開基於位置信息服務(Location-Based Service,LBS)的應用。LBS 應用訪問的數據是和人或物關聯的一組經緯度信息,而且要能查詢相鄰的經緯度範圍,GEO 就非常適合應用在 LBS 服務的場景中。
內部實現#
GEO 本身並沒有設計新的底層數據結構,而是直接使用了 Sorted Set 集合類型。
GEO 類型使用 GeoHash 編碼方法實現了經緯度到 Sorted Set 中元素權重分數的轉換,這其中的兩個關鍵機制就是「對二維地圖做區間劃分」和「對區間進行編碼」。一組經緯度落在某個區間後,就用區間的編碼值來表示,並把編碼值作為 Sorted Set 元素的權重分數。
這樣一來,我們就可以把經緯度保存到 Sorted Set 中,利用 Sorted Set 提供的 “按權重進行有序範圍查找” 的特性,實現 LBS 服務中頻繁使用的 “搜索附近” 的需求。
常見命令#
# 存儲指定的地理空間位置,可以將一個或多個經度(longitude)、緯度(latitude)、位置名稱(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]
# 從給定的 key 裡返回所有指定名稱(member)的位置(經度和緯度),不存在的返回 nil。
GEOPOS key member [member ...]
# 返回兩個給定位置之間的距離。
GEODIST key member1 member2 [m|km|ft|mi]
# 根據用戶給定的經緯度坐標來獲取指定範圍內的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
應用場景#
執行下面的這個命令,就可以把 ID 號為 33 的車輛的當前經緯度位置存入 GEO 集合中:
> GEOADD cars:locations 116.034579 39.030452 33
當用戶想要尋找自己附近的網約車時,LBS 應用就可以使用 GEORADIUS 命令。
例如,LBS 應用執行下面的命令時,Redis 會根據輸入的用戶的經緯度信息(116.054579,39.030452 ),查找以這個經緯度為中心的 5 公里內的車輛信息,並返回給 LBS 應用。
> GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
Stream#
Redis Stream 是 Redis 5.0 版本新增加的數據類型,Redis 專門為消息隊列設計的數據類型。
用於完美地實現消息隊列,它支持消息的持久化、支持自動生成全局唯一 ID、支持 ack 確認消息的模式、支持消費組模式等,讓消息隊列更加的穩定和可靠。
常見命令#
Stream 消息隊列操作命令:
XADD:插入消息,保證有序,可以自動生成全局唯一 ID;
XLEN :查詢消息長度;
XREAD:用於讀取消息,可以按 ID 讀取數據;
XDEL : 根據消息 ID 刪除消息;
DEL :刪除整個 Stream;
XRANGE :讀取區間消息
XREADGROUP:按消費組形式讀取消息;
XPENDING 和 XACK:
XPENDING 命令可以用來查詢每個消費組內所有消費者「已讀取、但尚未確認」的消息;
XACK 命令用於向消息隊列確認消息處理已完成;
應用場景#
生產者通過 XADD 命令插入一條消息:
# * 表示讓 Redis 為插入的數據自動生成一個全局唯一的 ID
# 往名稱為 mymq 的消息隊列中插入一條消息,消息的鍵是 name,值是 xiaolin
> XADD mymq * name xiaolin
"1654254953808-0"
消費者通過 XREAD 命令從消息隊列中讀取消息時,可以指定一個消息 ID,並從這個消息 ID 的下一條消息開始進行讀取(注意是輸入消息 ID 的下一條信息開始讀取,不是查詢輸入 ID 的消息)。
# 從 ID 号為 1654254953807-0 的消息開始,讀取後續的所有消息(示例中一共 1 條)。
> XREAD STREAMS mymq 1654254953807-0
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
如果想要實現阻塞讀(當沒有數據時,阻塞住),可以調用 XRAED 時設定 BLOCK 配置項,實現類似於 BRPOP 的阻塞讀取操作。
比如,下面這命令,設置了 BLOCK 10000 的配置項,10000 的單位是毫秒,表明 XREAD 在讀取最新消息時,如果沒有消息到來,XREAD 將阻塞 10000 毫秒(即 10 秒),然後再返回。
# 命令最後的“$”符號表示讀取最新的消息
> XREAD BLOCK 10000 STREAMS mymq $
(nil)
(10.00s)
Stream 可以以使用 XGROUP 創建消費組,創建消費組之後,Stream 可以使用 XREADGROUP 命令讓消費組內的消費者讀取消息。
創建兩個消費組,這兩個消費組消費的消息隊列是 mymq,都指定從第一條消息開始讀取:
# 創建一個名為 group1 的消費組,0-0 表示從第一條消息開始讀取。
> XGROUP CREATE mymq group1 0-0
OK
# 創建一個名為 group2 的消費組,0-0 表示從第一條消息開始讀取。
> XGROUP CREATE mymq group2 0-0
OK
消費組 group1 內的消費者 consumer1 從 mymq 消息隊列中讀取所有消息的命令如下:
# 命令最後的參數“>”,表示從第一條尚未被消費的消息開始讀取。
> XREADGROUP GROUP group1 consumer1 STREAMS mymq >
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
消息隊列中的消息一旦被消費組裡的一個消費者讀取了,就不能再被該消費組內的其他消費者讀取了,即同一個消費組裡的消費者不能消費同一條消息。
但是,不同消費組的消費者可以消費同一條消息(但是有前提條件,創建消息組的時候,不同消費組指定了相同位置開始讀取消息)。
使用消費組的目的是讓組內的多個消費者共同分擔讀取消息,所以,我們通常會讓每個消費者讀取部分消息,從而實現消息讀取負載在多個消費者間是均衡分布的。
基於 Stream 實現的消息隊列,如何保證消費者在發生故障或宕機再次重啟後,仍然可以讀取未處理完的消息?
Streams 會自動使用內部隊列(也稱為 PENDING List)留存消費組裡每個消費者讀取的消息,直到消費者使用 XACK
命令通知 Streams “消息已經處理完成”。
好了,基於 Stream 實現的消息隊列就說到這裡了,小結一下:
- 消息保序:XADD/XREAD
- 阻塞讀取:XREAD block
- 重複消息處理:Stream 在使用 XADD 命令,會自動生成全局唯一 ID;
- 消息可靠性:內部使用 PENDING List 自動保存消息,使用 XPENDING 命令查看消費組已讀取但是未被確認的消息,消費者使用 XACK 確認消息;
- 支持消費組形式消費數據
Redis 基於 Stream 消息隊列與專業的消息隊列有哪些差距?
一個專業的消息隊列,必須要做到兩大塊:
- 消息不丟。
- 消息可堆積。
- Redis Stream 消息會丟失嗎?
一個消息隊列,其實就分為三大塊:生產者、隊列中間件、消費者
Redis Stream 消息隊列能不能保證三個環節都不丟失數據?
- Redis 生產者會不會丟消息?生產者會不會丟消息,取決於生產者對於異常情況的處理是否合理。 從消息被生產出來,然後提交給 MQ 的過程,只要能正常收到 ( MQ 中間件) 的 ack 確認響應,就表示發送成功,所以只要處理好返回值和異常,如果返回異常則進行消息重發,那麼這個階段是不會出現消息丟失的。
- Redis 消費者會不會丟消息?不會,因為 Stream ( MQ 中間件)會自動使用內部隊列(也稱為 PENDING List)留存消費組裡每個消費者讀取的消息,但是未被確認的消息。消費者可以在重啟後,用 XPENDING 命令查看已讀取、但尚未確認處理完成的消息。等到消費者執行完業務邏輯後,再發送消費確認 XACK 命令,也能保證消息的不丟失。
- Redis 消息中間件會不會丟消息?會,Redis 在以下 2 個場景下,都会導致數據丟失:
- AOF 持久化配置為每秒寫盤,但這個寫盤過程是異步的,Redis 宕機時會存在數據丟失的可能
- 主從複製也是異步的,主從切換時,也存在丟失數據的可能。
- Redis Stream 消息可堆積嗎?
Redis 的數據都存儲在內存中,這就意味著一旦發生消息積压,則會導致 Redis 的內存持續增長,如果超過機器內存上限,就會面臨被 OOM 的風險。
所以 Redis 的 Stream 提供了可以指定隊列最大長度的功能,就是為了避免這種情況發生。
當指定隊列最大長度時,隊列長度超過上限後,舊消息會被刪除,只保留固定長度的新消息。這麼來看,Stream 在消息積壓時,如果指定了最大長度,還是有可能丟失消息的。
但 Kafka、RabbitMQ 專業的消息隊列它們的數據都是存儲在磁盤上,當消息積壓時,無非就是多佔用一些磁盤空間。
因此,把 Redis 當作隊列來使用時,會面臨的 2 個問題:
- Redis 本身可能會丟數據;
- 面對消息擠壓,內存資源會緊張;
所以,能不能將 Redis 作為消息隊列來使用,關鍵看你的業務場景:
- 如果你的業務場景足夠簡單,對於數據丟失不敏感,而且消息積壓概率比較小的情況下,把 Redis 當作隊列是完全可以的。
- 如果你的業務有海量消息,消息積壓的概率比較大,並且不能接受數據丟失,那麼還是用專業的消息隊列中間件吧。
Redis 數據結構#
鍵值對#
Redis 的鍵值對中的 key 就是字符串對象,而 value 可以是字符串對象,也可以是集合數據類型的對象,比如 List 對象、Hash 對象、Set 對象和 Zset 對象。
這些鍵值對是如何保存在 Redis 中的呢?
Redis 是使用了個「哈希表」保存所有鍵值對,哈希表的最大好處就是讓我們可以用 O (1) 的時間複雜度來快速查找到鍵值對。哈希表其實就是一個數組,數組中的元素叫做哈希桶。
Redis 的哈希桶是怎麼保存鍵值對數據的呢?
哈希桶存放的是指向鍵值對數據的指針(dictEntry*),這樣通過指針就能找到鍵值對數據,然後因為鍵值對的值可以保存字符串對象和集合數據類型的對象,所以鍵值對的數據結構中並不是直接保存值本身,而是保存了 void * key 和 void * value 指針,分別指向了實際的鍵對象和值對象,這樣一來,即使值是集合數據,也可以通過 void * value 指針找到。
SDS#
數據結構中的每個成員變量分別介紹下:
- len,記錄了字符串長度。這樣獲取字符串長度的時候,只需要返回這個成員變量值就行,時間複雜度只需要 O(1)。
- alloc,分配給字符數組的空間長度。這樣在修改字符串的時候,可以通過 alloc - len 計算出剩余的空間大小,可以用來判斷空間是否滿足修改需求,如果不滿足的話,就會自動將 SDS 的空間擴展至執行修改所需的大小,然後才執行實際的修改操作,所以使用 SDS 既不需要手動修改 SDS 的空間大小,也不会出现前面所說的緩衝區溢出的问题。
- flags,用來表示不同類型的 SDS。一共設計了 5 種類型,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,後面在說明區別之處。
- buf [],字符數組,用來保存實際數據。不僅可以保存字符串,也可以保存二進制數據。
SDS 相比於 C 的原生字符串:
- SDS 不僅可以保存文本數據,還可以保存二進制數據。因為 SDS 使用 len 屬性的值而不是空字符來判斷字符串是否結束,並且 SDS 的所有 API 都會以處理二進制的方式來處理 SDS 存放在 buf [] 陣列裡的數據。所以 SDS 不光能存放文本數據,而且能保存圖片、音頻、視頻、壓縮文件這樣的二進制數據。
- SDS 獲取字符串長度的時間複雜度是 O (1)。因為 C 語言的字符串並不記錄自身長度,所以獲取長度的複雜度為 O (n);而 SDS 結構裡用 len 屬性記錄了字符串長度,所以複雜度為 O (1)。
- Redis 的 SDS API 是安全的,拼接字符串不會造成緩衝區溢出。因為 SDS 在拼接字符串之前會檢查 SDS 空間是否滿足要求,如果空間不夠會自動擴容,所以不會導致緩衝區溢出的问题。
- 節省內存空間。SDS 結構中有個 flags 成員變量,表示的是 SDS 類型。
Redis 一共設計了 5 種類型,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。這 5 種類型的主要區別就在於,它們數據結構中的 len 和 alloc 成員變量的數據類型不同。
sdshdr16 類型的 len 和 alloc 的數據類型都是 uint16_t,表示字符數組長度和分配空間大小不能超過 2 的 16 次方。
之所以 SDS 設計不同類型的結構體,是為了能靈活保存不同大小的字符串,從而有效節省內存空間。比如,在保存小字符串時,結構頭佔用空間也比較少。
除了設計不同類型的結構體,Redis 在編程上還使用了專門的編譯優化來節省內存空間,即在 struct 聲明了__attribute__ ((packed))
,它的作用是:告訴編譯器取消結構體在編譯過程中的優化對齊,按照實際佔用字節數進行對齊。
鏈表#
list 結構為鏈表提供了鏈表頭指針 head、鏈表尾節點 tail、鏈表節點數量 len、以及可以自定義實現的 dup、free、match 函數。
因為鏈表內存都是不連續的,意味著無法很好利用 CPU 緩存,鏈表節點的值都需要一個鏈表節點結構頭的分配,內存開銷較大,所以 Redis 3.0 的 List 對象在數據量比較少的情況下,會采用「壓縮列表」作為底層數據結構的實現,它的優勢是節省內存空間,並且是內存緊湊型的數據結構。
Redis 5.0 設計了新的數據結構 listpack,沿用了壓縮列表緊湊型的內存佈局,最終在最新的 Redis 版本,將 Hash 對象和 Zset 對象的底層數據結構實現之一的壓縮列表,替換成由 listpack 實現。
壓縮列表#
壓縮列表是 Redis 為了節約內存而開發的,它是由連續內存塊組成的順序型數據結構,有點類似於數組。
壓縮列表的缺陷也是有的:
- 不能保存過多的元素,否則查詢效率就會降低;
- 新增或修改某個元素時,壓縮列表佔用的內存空間需要重新分配,甚至可能引發連鎖更新的問題。
因此,Redis 對象(List 對象、Hash 對象、Zset 對象)包含的元素數量較少,或者元素值不大的情況才會使用壓縮列表作為底層數據結構。
結構設計#
壓縮列表在表頭有三個字段:
- zlbytes,記錄整個壓縮列表佔用對內存字節數;
- zltail,記錄壓縮列表「尾部」節點距離起始地址由多少字節,也就是列表尾的偏移量;
- zllen,記錄壓縮列表包含的節點數量;
- zlend,標記壓縮列表的結束點,固定值 0xFF(十進制 255)。
在壓縮列表中,如果我們要查找定位第一個元素和最後一個元素,可以通過表頭三個字段(zllen)的長度直接定位,複雜度是 O (1)。而查找其他元素時,就沒有這麼高效了,只能逐個查找,此時的複雜度就是 O (N) 了,因此壓縮列表不適合保存過多的元素。
另外,壓縮列表節點(entry)的構成如下:
- prevlen,記錄了「前一個節點」的長度,目的是為了實現從後向前遍歷;
- encoding,記錄了當前節點實際數據的「類型和長度」,類型主要有兩種:字符串和整數。
- data,記錄了當前節點的實際數據,類型和長度都由 encoding 決定;
這種根據數據大小和類型進行不同的空間大小分配的設計思想,正是 Redis 為了節省內存而採用的。
分別說下,prevlen 和 encoding 是如何根據數據的大小和類型來進行不同的空間大小分配。
壓縮列表裡的每個節點中的 prevlen 屬性都記錄了「前一個節點的長度」,而且 prevlen 屬性的空間大小跟前一個節點長度值有關,比如:
- 如果前一個節點的長度小於 254 字節,那麼 prevlen 屬性需要用 1 字節的空間來保存這個長度值;
- 如果前一個節點的長度大於等於 254 字節,那麼 prevlen 屬性需要用 5 字節的空間來保存這個長度值;
encoding 屬性的空間大小跟數據是字符串還是整數,以及字符串的長度有關,如下圖(下圖中的 content 表示的是實際數據,即本文的 data 字段):
- 如果當前節點的數據是整數,則 encoding 會使用 1 字節的空間進行編碼,也就是 encoding 長度為 1 字節。通過 encoding 確認了整數類型,就可以確認整數數據的實際大小了,比如如果 encoding 編碼確認了數據是 int16 整數,那麼 data 的長度就是 int16 的大小。
- 如果當前節點的數據是字符串,根據字符串的長度大小,encoding 會使用 1 字節 / 2 字節 / 5 字節的空間進行編碼,encoding 編碼的前兩個 bit 表示數據的類型,後續的其他 bit 標識字符串數據的實際長度,即 data 的長度。
連鎖更新#
壓縮列表新增某個元素或修改某個元素時,如果空間不不夠,壓縮列表佔用的內存空間就需要重新分配。而當新插入的元素較大時,可能會導致後續元素的 prevlen 佔用空間都發生變化,從而引起「連鎖更新」問題,導致每個元素的空間都要重新分配,造成訪問壓縮列表性能的下降。
因此,壓縮列表只會用於保存的節點數量不多的場景,只要節點數量足夠小,即使發生連鎖更新,也是能接受的。
哈希表#
哈希表是一個數組(dictEntry **table),數組的每個元素是一個指向「哈希表節點(dictEntry)」的指針。
dictEntry 結構裡不僅包含指向鍵和值的指針,還包含了指向下一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對鏈接起來,以此來解決哈希衝突的問題,這就是鏈式哈希。
哈希衝突#
鏈式哈希#
實現的方式就是每個哈希表節點都有一個 next 指針,用於指向下一個哈希表節點,因此多個哈希表節點可以用 next 指針構成一個單項鏈表,被分配到同一個哈希桶上的多個節點可以用這個單項鏈表連接起來,這樣就解決了哈希衝突。
鏈式哈希局限性也很明顯,隨著鏈表長度的增加,在查詢這一位置上的數據的耗時就會增加,畢竟鏈表的查詢的時間複雜度是 O (n)。
要想解決這一問題,就需要進行 rehash,也就是對哈希表的大小進行擴展。
Rehash#
在實際使用哈希表時,Redis 定義一個 dict 結構體,這個結構體裡定義了兩個哈希表(ht [2])。之所以定義了 2 個哈希表,是因為進行 rehash 的時候,需要用上 2 個哈希表了。
在正常服務請求階段,插入的數據,都是寫入到「哈希表 1」,此時的「哈希表 2 」 並沒有被分配空間。
隨著數據逐步增多,觸發了 rehash 操作,這個過程分為三步:
- 給「哈希表 2」 分配空間,一般會比「哈希表 1」 大 2 倍;
- 將「哈希表 1 」的數據遷移到「哈希表 2」 中;
- 遷移完成後,「哈希表 1 」的空間會被釋放,並把「哈希表 2」 設置為「哈希表 1」,然後在「哈希表 2」 新創建一個空白的哈希表,為下次 rehash 做準備。
第二步很有問題,如果「哈希表 1 」的數據量非常大,那麼在遷移至「哈希表 2 」的時候,因為會涉及大量的數據拷貝,此時可能會對 Redis 造成阻塞,無法服務其他請求。
觸發條件:
觸發 rehash 操作的條件,主要有兩個:
- 當負載因子大於等於 1 ,並且 Redis 沒有在執行 bgsave 命令或者 bgrewiteaof 命令,也就是沒有執行 RDB 快照或沒有進行 AOF 重寫的時候,就會進行 rehash 操作。
- 當負載因子大於等於 5 時,此時說明哈希衝突非常嚴重了,不管有沒有有在執行 RDB 快照或 AOF 重寫,都会強制進行 rehash 操作。
漸進式 rehash#
將數據的遷移的工作不再是一次性遷移完成,而是分多次遷移。
漸進式 rehash 步驟如下:
- 給「哈希表 2」 分配空間;
- 在 rehash 進行期間,每次哈希表元素進行新增、刪除、查找或者更新操作時,Redis 除了會執行對應的操作之外,還會順序將「哈希表 1 」中索引位置上的所有 key-value 遷移到「哈希表 2」 上;
- 隨著處理客戶端發起的哈希表操作請求數量越多,最終在某個時間點會把「哈希表 1 」的所有 key-value 遷移到「哈希表 2」,從而完成 rehash 操作。
整數集合#
整數集合是 Set 對象的底層實現之一。當一個 Set 對象只包含整數值元素,並且元素數量不大時,就會使用整數集這個數據結構作為底層實現。
整數集合本質上是一塊連續內存空間,它的結構定義如下:
typedef struct intset {
//編碼方式
uint32_t encoding;
//集合包含的元素數量
uint32_t length;
//保存元素的數組
int8_t contents[];
} intset;
可以看到,保存元素的容器是一個 contents 陣列,雖然 contents 被聲明為 int8_t 類型的數組,但是實際上 contents 陣列並不保存任何 int8_t 類型的元素,contents 陣列的真正類型取決於 intset 結構體裡的 encoding 屬性的值。
整數集合的升級操作#
整數集合會有一個升級規則,就是當我們將一個新元素加入到整數集合裡面的時候,如果新元素的類型(int32_t)比整數集合現有所有元素的類型(int16_t)都要長時,整數集合需要先進行升級,也就是按新元素的類型(int32_t)擴展 contents 陣列的空間大小,然後才能將新元素加入到整數集合裡,當然升級的過程中,也要維持整數集合的有序性。
整數集合升級的過程不會重新分配一個新類型的數組,而是在原本的數組上擴展空間,然後在將每個元素按間隔類型大小分割,如果 encoding 屬性值為 INTSET_ENC_INT16,則每個元素的間隔就是 16 位。
節省資源,不支持降級操作
跳表#
Redis 只有 Zset 對象的底層實現用到了跳表,跳表的優勢是能支持平均 O (logN) 複雜度的節點查找。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Zset 對象在執行數據插入或是數據更新的過程中,會依次在跳表和哈希表中插入或更新相應的數據,從而保證了跳表和哈希表中記錄的信息一致。
Zset 對象能支持範圍查詢(如 ZRANGEBYSCORE 操作),這是因為它的數據結構設計采用了跳表,而又能以常數複雜度獲取元素權重(如 ZSCORE 操作),這是因為它同時采用了哈希表進行索引。
struct zset 中的哈希表只是用於以常數複雜度獲取元素權重,大部分操作都是跳表實現的。
跳表結構設計#
跳表是在鏈表基礎上改進過來的,實現了一種「多層」的有序鏈表,這樣的好處是能快讀定位數據。
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
//Zset 對象的元素值
sds ele;
//元素權重值
double score;
//後向指針
struct zskiplistNode *backward;
//節點的level數組,保存每層上的前向指針和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
Zset 對象要同時保存「元素」和「元素的權重」,對應到跳表節點結構裡就是 sds 類型的 ele 變量和 double 類型的 score 變量。每個跳表節點都有一個後向指針(struct zskiplistNode *backward),指向前一個節點,目的是為了方便從跳表的尾節點開始訪問節點這樣倒序查找時很方便。
跳表是一個帶有層級關係的鏈表,而且每一層級可以包含多個節點,每一個節點通過指針連接起來,實現這一特性就是靠跳表節點結構體中的 zskiplistLevel 結構體類型的 level 陣列。
level 陣列中的每一個元素代表跳表的一層,也就是由 zskiplistLevel 結構體表示,比如 leve [0] 就表示第一層,leve [1] 就表示第二層。zskiplistLevel 結構體裡定義了「指向下一個跳表節點的指針」和「跨度」,跨度時用來記錄兩個節點之間的距離,實際上是為了計算這個節點在跳表中的排位。
跳表節點查詢過程#
查找一個跳表節點的過程時,跳表會從頭節點的最高層開始,逐一遍歷每一層。在遍歷某一層的跳表節點時,會用跳表節點中的 SDS 類型的元素和元素的權重來進行判斷,共有兩個判斷條件:
- 如果當前節點的權重「小於」要查找的權重時,跳表就會訪問該層上的下個節點。
- 如果當前節點的權重「等於」要查找的權重時,並且當前節點的 SDS 類型數據「小於」要查找的數據時,跳表就會訪問該層上的下個節點。
- 如果上面兩個條件都不滿足,或者下個節點為空時,跳表就會使用目前遍歷到的節點的 level 陣列裡的下一層指針,然後沿著下一層指針繼續查找,這就相當於跳到了下一層接著查找。
跳表節點層數設置#
跳表的相鄰兩層的節點數量的比例會影響跳表的查詢性能。
跳表的相鄰兩層的節點數量最理想的比例是 2:1,查找複雜度可以降低到 O (logN)。
Redis 則采用一種巧妙的方法是,跳表在創建節點的時候,隨機生成每個節點的層數,並沒有嚴格維持相鄰兩層的節點數量比例為 2 : 1 的情況。
具體的做法是,跳表在創建節點的時候,會生成範圍為 [0-1] 的一個隨機數,如果這個隨機數小於 0.25(相當於概率 25%),那麼層數就增加 1 層,然後繼續生成下一個隨機數,直到隨機數的結果大於 0.25 結束,最終確定該節點的層數。
這樣的做法,相當於每增加一層的概率不超過 25%,層數越高,概率越低,層高最大限制是 64。
為什麼用跳表而不用平衡樹?#
- 從內存佔用上來比較,跳表比平衡樹更靈活一些。平衡樹每個節點包含 2 個指針(分別指向左右子樹),而跳表每個節點包含的指針數目平均為 1/(1-p),具體取決於參數 p 的大小。如果像 Redis 裡的實現一樣,取 p=1/4,那麼平均每個節點包含 1.33 個指針,比平衡樹更有優勢。
- 在做範圍查找的時候,跳表比平衡樹操作要簡單。在平衡樹上,我們找到指定範圍的小值之後,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這裡的中序遍歷並不容易實現。而在跳表上