3. Redis有哪些數據類型?


Redis 的五種常見數據結構

Redis 的數據類型可謂是 Redis 的精華所在,同樣的數據類型,但不同的值對應的存儲結構也是不同的。比如:當你存儲一個短字符串(小於 44 字節),實際存儲的結構是 embstr;長字符串對應的實際存儲結構是 raw,這樣設計的目的就是為了更好的節約內存。

那么 Redis 都有哪些數據類型呢?

最常用的數據類型有 5 種:String(字符串類型)、Hash(字典類型)、List(列表類型)、Set(集合類型)、ZSet(有序集合類型),那么這些數據類型都支持哪些操作呢?我們來一一介紹。當然 Redis 支持的數據結構不止上面五種,還有幾個更高級的數據結構,我們后面介紹,但是最常用的還是上面五種。

不過我們首先要安裝 Redis,最方便的做法是采用 yum 安裝,或者采用 docker 安裝,由於過程比較簡單就不說了。但這兩種做法我們都不用,這里我們選擇下載源碼包、然后編譯安裝。可以直接去 https://download.redis.io/releases/ 這個網址進行下載,Redis 所有的發布版本都在里面,我下載的是 6.0.5。然后丟到 Linux 服務器上(我使用的是阿里雲 CentOS)進行安裝即可,命令如下:

tar -zxvf redis-6.0.5.tar.gz
cd redis-6.0.5
make && make install

可以看到安裝過程非常簡單,但是上面的命令先別急着執行,還有一些細節沒有說。由於 Redis 是使用 C 語言編寫的,所以編譯它需要 gcc 環境,並且對於 6.0 以上的 Redis  需要的也是高版本的 gcc。

可以看到,我當前的 gcc 版本是不高的,如果直接 make && make install 的話,是會出現編譯錯誤的,所以我們需要升級 gcc。

yum -y install centos-release-scl
yum -y install devtoolset-9-gcc devtoolset-9-gcc-c++ devtoolset-9-binutils
scl enable devtoolset-9 bash
echo "source /opt/rh/devtoolset-9/enable" >>/etc/profile

然后查看 gcc 版本,會發現已經高版本了。

接下來就可以編譯 Redis 了,解壓、進入 Redis 主目錄、執行 make && make install。安裝完畢之后,redis 的相關可執行文件都在 /usr/local/bin 目錄下

我們直接輸入 redis-server 即可啟動 redis,但這種啟動方式會選擇前台啟動,為了方便我們選擇后台啟動,所以需要修改配置文件 redis.conf,該文件位於 Redis 主目錄中,我們將其拷貝到 /etc 目錄下。

cp /root/redis-6.0.5/redis.conf /etc

redis.conf 里面的配置項非常多,后續會詳細介紹,這里先修改兩個,vim /etc/redis.conf:

# 將 bind 從 127.0.0.1 改成 0.0.0.0,因為我們不僅要本機訪問,后續還要通過 Python 和 Go 訪問
bind 0.0.0.0  
# 將 daemonize 改成 yes,選擇后台啟動
daemonize yes

保存之后啟動 Redis,啟動方式:redis-server /etc/redis.conf,這里是指定配置文件啟動。當然啟動的時候也可以不指定配置文件,直接把要修改的配置寫在命令行里面也是可以的,比如:

redis-server --bind 0.0.0.0 --daemonize yes

但這種方式適用於修改的配置項不多的時候,如果要修改的配置項很多,那么還是建議修改配置文件,然后通過指定配置文件啟動。但如果某個配置項在命令行和配置文件中都出現了,那么會以命令行為准。

這里我們就統一以配置文件的方式啟動了,但啟動之后,我們需要了解一下 Redis 的前置知識。

  • Redis 默認有16個數據庫,數據庫名類似於數組的下表,從 0 到 15,默認使用 0 號庫。
  • select:切換數據庫,比如 select 1 就表示切換到 1 號庫
  • 統一密碼管理,16 個庫都是一樣的密碼,要么都 ok 要么都連接不上。
  • 默認端口是 6379,當然可以通過配置文件修改

下面就來看一下 Redis 中常見的數據結構。

Redis 字符串 (String)

Redis 的 string 類型,是一個 key 對應一個 value,並且底層是使用自己內部實現的簡單動態字符串(SDS)來表示 String 類型,沒有直接使用 C 語言定義的字符串類型。

struct sdshdr{
    // 記錄 buf 數組中已使用字節的數量
    // 等於 SDS 保存的字符串的長度
    int len;
    // 記錄 buf 數組的總長度
    int alloc;
    // 字節數組,用於保存字符串
    char buf[];
}

然后我們來看看其支持的 api 操作。

 

set key value:給指定的 key 設置 value

127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> set word "hello world"
OK  # 如果字符串之間有空格,我們可以使用雙引號包起來
# 對同一個 key 多次設置 value 相當於更新,會保留最后一次設置的 value

設置成功之后會返回一個 ok,表示設置成功。除此之外,set 還可以指定一些可選參數。

  • set key value ex 60:設置的時候指定過期時間為 60 秒,等價於 setex key 60 value
  • set key value px 60:設置的時候指定過期時間為 60 毫秒,等價於 psetex key 60 value
  • set key value nx:只有 key 不存在的時候才會設置,存在的話會設置失敗,而如果不加 nx 則會覆蓋。等價於 setnx key value
  • set key value xx:只有 key 存在的時候才會設置,不存在的話會設置失敗。注意:沒有 setxx key value

我們發現默認參數使用 set 足夠了,因此未來可能會移除 setex、psetex、setnx。另外,我們可以同一個 key 多次 set,相當於對原來的值進行了覆蓋。

 

get key:獲取指定 key 對應的 value

127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get word
"hello world"
127.0.0.1:6379> get age
(nil)

如果 key 不存在,那么返回 nil,也就是 C 語言中的 NULL,Python 中的 None、Go 里的 nil。存在的話,則返回 key 對應的 value。

 

del key1 key2···:刪除指定 key,可以同時刪除多個

127.0.0.1:6379> set age 28
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get age
"28"  # 雖然我們設置的是一個數值,但是在 Redis 中都是字符串格式
127.0.0.1:6379> del name age gender
(integer) 2  # 會返回刪除的 key 的個數,表示有效刪除了兩個,而 gender 不存在,因此無法刪除一個不存在的 key
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get nil
(nil)
127.0.0.1:6379> 

 

append key value:追加

如果 key 存在,那么會將 value 的值追加到 key 對應的值的末尾;如果不存在,那么會重新設置,等價於於 set key value。

127.0.0.1:6379> set name han
OK
127.0.0.1:6379> set age 2
OK
127.0.0.1:6379> append name ser
(integer) 6  # 返回拼接之后的字符數量
127.0.0.1:6379> append age 8
(integer) 2  # 按照字符串的格式拼接
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get age
"28"
127.0.0.1:6379> append gender female
(integer) 6  # gender 不存在,相當於重新設置,或者理解為往一個空字符后面追加也行
127.0.0.1:6379> get gender
"female"
127.0.0.1:6379> 

 

strlen key:查看對應 key 的長度

127.0.0.1:6379> strlen name
(integer) 6
127.0.0.1:6379> strlen age
(integer) 2
127.0.0.1:6379> strlen not_exists
(integer) 0  # 不存在的 key 返回 0
127.0.0.1:6379> 

 

incr key:為 key 存儲的值自增 1,必須可以轉成整型,否則報錯。如果不存在 key,默認先設置該 key 值為 0,然后自增 1

127.0.0.1:6379> get age
"28"
127.0.0.1:6379> incr age
(integer) 29  # 返回自增后的結果
127.0.0.1:6379> get age
"29"
127.0.0.1:6379> incr age1 
(integer) 1
127.0.0.1:6379> get age1
"1"
127.0.0.1:6379> incr name
(error) ERR value is not an integer or out of range
127.0.0.1:6379> 

 

decr key:為 key 存儲的值自減 1,必須可以轉成整型,否則報錯。如果不存在 key,默認先設置該 key 值為 0,然后自減 1

127.0.0.1:6379> decr age
(integer) 28
127.0.0.1:6379> decr age2
(integer) -1
127.0.0.1:6379> 

 

incrby key number:為 key 存儲的值自增 number,必須可以轉成整型,否則報錯,如果不存在的話,默認先將該值設置為 0,然后自增 number

127.0.0.1:6379> incrby age 20
(integer) 48
127.0.0.1:6379> incrby age3 5
(integer) 5
127.0.0.1:6379> 

 

decrby key number:為 key 存儲的值自減 number,必須可以轉成整型,否則報錯,如果不存在的話,默認先將該值設置為 0,然后自減 number

127.0.0.1:6379> decrby age 20
(integer) 28
127.0.0.1:6379> decrby age4 5
(integer) -5
127.0.0.1:6379> decrby age4 -5
(integer) 0  # 指定負數也是可以的,同理 incrby 也是如此
127.0.0.1:6379> 

 

getrange key start end:獲取指定 value 的同時指定范圍,第一個字符為 0,最后一個為 -1。注意:redis 中的索引都是包含結尾的,不管是這里的 getrange,還是后面的列表操作,索引都是包含兩端的。

127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> getrange name 0 -1
"hanser"
127.0.0.1:6379> getrange name 0 4
"hanse"
127.0.0.1:6379> getrange name -3 -1
"ser"
127.0.0.1:6379> getrange name -3 10086
"ser"
127.0.0.1:6379> getrange name -3 -4
""
127.0.0.1:6379> 

我們看到,索引是可以從后往前數,但是只能從前往后、不能從后往前獲取。也就是 getrange word -1 -3 是不可以的,會返回一個空字符串,因為 -1 在 -3 的后面。

 

setrange key start value:從索引為 start 的地方開始,將 key 對應的值替換為 value,替換的個數等於 value 的個數。

127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> setrange name 0 you
(integer) 6  # 從索引為0的地方開始替換,替換三個字符,因為我們指定了3個字符
127.0.0.1:6379> get name
"youser"
127.0.0.1:6379> setrange name 10 you
(integer) 13  # 從索引為10的地方開始替換,但是字符串索引最大為6,因此會使用\x00填充
127.0.0.1:6379> get name
"youser\x00\x00\x00\x00you"
127.0.0.1:6379> setrange myself 3 gagaga
(integer) 9  # 對於不存在的key也是如此
127.0.0.1:6379> get myself
"\x00\x00\x00gagaga"
127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> setrange name 0 "han han han han"
(integer) 15  # 替換的字符串長度比相應的key長沒有關系,會自動擴充
127.0.0.1:6379> get name
"han han han han"

 

mset key1 value1 key2 value2:同時設置多個 key value

這是一個原子性操作,要么都設置成功,要么都設置不成功。注意:這些都是會覆蓋原來的值的,如果不想這樣的話,可以使用 msetnx,這個命令只會在所有的 key 都不存在的時候才會設置。

mget key1 key2:同時返回多個 key 對應的 value

如果有的 key 不存在,那么返回 nil。

127.0.0.1:6379> get name
"han han han han"
127.0.0.1:6379> mset name hanser age 28
OK
127.0.0.1:6379> mget name age
1) "hanser"
2) "28"
127.0.0.1:6379> 

 

getset key value:先返回 key 的舊值,然后設置新值

127.0.0.1:6379> getset name yousa
"hanser"
127.0.0.1:6379> get name
"yousa"
127.0.0.1:6379> getset ping pong
(nil)
127.0.0.1:6379> get ping
"pong"
127.0.0.1:6379> 

如果有的 key 不存在,那么返回 nil,然后設置。

 

另外,Redis 中還有很多關於 key 的操作,這些操作不是專門針對 string 結構的,但是有必要提前說一下。

首先在 Redis 中,string、list、hash、set、zset 都有自己的 key,key 不可以重名,比如有一個 key 為 name 的 string,那么就不可以再有一個 key 還為 name 的 list。因為在 Redis 內部會維護一個全局的哈希表,存放所有的 key、value,而哈希表里面的 key 是不重復的。

key 是一個 string,而 value 可以是 Redis 中任意的一種數據結構,而全局哈希表則負責維護所有的 key value。

 

keys pattern:查看所有名稱滿足 pattern 的 key,至於 key 對應的 value 則可以是 Redis 的任意類型

127.0.0.1:6379> keys *  # 查看所有的 key
 1) "gender"
 2) "word"
 3) "ping"
 4) "name"
 5) "age2"
 6) "age1"
 7) "age"
 8) "myself"
 9) "age3"
10) "age4"
127.0.0.1:6379> keys *a*  # 查看包含 a 的 key
1) "name"
2) "age2"
3) "age1"
4) "age"
5) "age3"
6) "age4"
127.0.0.1:6379> keys age?  # 查看以 age 開頭、總共 4 個字符的 key
1) "age2"
2) "age1"
3) "age3"
4) "age4"

 

exists key:判斷某個 key 是否存在

127.0.0.1:6379> exists name
(integer) 1
127.0.0.1:6379> exists name1
(integer) 0  # 存在返回 1,不存在返回 0
127.0.0.1:6379> exists name name1
(integer) 1  # 也可以指定多個 key,返回存在的 key 的個數,但是此時無法判斷到底是哪個 key 存在

 

ttl key:查看還有多少秒過期,-1 表示永不過期,-2 表示已過期

127.0.0.1:6379> ttl name
(integer) -1  # -1 表示永不過期
127.0.0.1:6379> ttl name1
(integer) -2  # -2 表示已經過期
127.0.0.1:6379> 

key 是可以設置過期時間的,如果過期了就不能再用了。我們看到 name1 這個 key 壓根就不存在,返回的也是 -2,因為過期了就相當於不存在了。而 name 是 -1,表示永不過期。

 

expire key 秒鍾:為給定的 key 設置過期時間

127.0.0.1:6379> expire name 60
(integer) 1  # 設置 60s,設置成功返回 1
127.0.0.1:6379> ttl name
(integer) 55  # 查看時間,還剩下 55 秒
127.0.0.1:6379> expire name1 60
(integer) 0  # name1 不存在,設置失敗,返回 0
127.0.0.1:6379> 

這里設置 60s 的過期時間,另外設置完之后,在過期時間結束之前是可以再次設置的,比如我先設置了 60s,然后快結束的時候我再次設置 60s,那么還會再持續 60s。

 

type key:查看你的 key 是什么類型

127.0.0.1:6379> type name
none  # name過期了,相當於不存在了,因此為none
127.0.0.1:6379> type age
string  # 類型為string
127.0.0.1:6379>

 

move key db:將 key 移動到指定的 db 中

127.0.0.1:6379> flushdb
OK  # 清空當前庫,如果是清空所有庫,可以使用 flushall,當然后面都可以加上 async,表示異步刪除,我們前面說過的
127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> keys *
1) "name"
127.0.0.1:6379> move name 3
(integer) 1  # 將 name 移動到索引為3的庫中
127.0.0.1:6379> keys *
(empty array)  # 此時當前庫已經沒有 name 了
127.0.0.1:6379> select 3
OK  # 切換到索引為 3 的庫中
127.0.0.1:6379[3]> keys *
1) "name"  # keys * 查看,發現 name 已經有了
127.0.0.1:6379[3]> select 0  # 切換回來
OK
127.0.0.1:6379> 

 

那么字符串這種數據結構可以用在什么地方呢?

我們討論完字符串的相關操作,那么我們還要理解字符串要用在什么地方。

首先字符串類型的使用場景有很多,但從功能的角度來區分,大致可分為以下兩種:

  • 1. 字符串存儲和操作;
  • 2. 整數類型和浮點類型的存儲和計算。

其最常用的業務場景大致分為以下幾個。

1. 頁面數據緩存

我們知道,一個系統最寶貴的資源就是數據庫資源,隨着公司業務的發展壯大,數據庫的存儲量也會越來越大,並且要處理的請求也越來越多,當數據量和並發量到達一定級別之后,數據庫就變成了拖慢系統運行的 “罪魁禍首”,為了避免這種情況的發生,我們可以把查詢結果放入緩存(Redis)中,讓下次同樣的查詢直接去緩存系統取結果,而非查詢數據庫,這樣既減少了數據庫的壓力,同時也提高了程序的運行速度。

畫一張圖來說明一下:

2. 數據計算與統計

Redis 可以用來存儲整數和浮點類型的數據,並且可以通過命令直接累加並存儲整數信息,這樣就省去了每次先要取數據、轉換數據、拼加數據、再存入數據的麻煩,只需要使用一個命令就可以完成此流程。比如:微博、嗶哩嗶哩等社交平台,我們經常會點贊,然后還有點贊數。每點一個贊,點贊數就加 1,這個功能就完全可以交給 Redis 實現。

3. 共享 Session 信息

通常我們在開發后台管理系統時,會使用 Session 來保存用戶的會話(登錄)狀態,這些 Session 信息會被保存在服務器端,但這只適用於單系統應用,如果是分布式系統此模式將不再適用。

例如用戶 A 的 Session 信息被存儲在第一台服務器,但第二次訪問時用戶 A 的請求被分配到第二台服務器,這個時候該服務器並沒有用戶 A 的 Session 信息,就會出現需要重復登錄的問題。由於分布式系統每次會把請求隨機分配到不同的服務器,因此我們需要借助緩存系統對這些 Session 信息進行統一的存儲和管理,這樣無論請求發送到哪台服務器,服務器都會去統一的緩存系統獲取相關的 Session 信息,這樣就解決了分布式系統下 Session 存儲的問題。

  • 分布式系統單獨存儲Session

  • 分布式系統使用統一的緩存系統存儲Session

雖然這確實是 Redis 使用場景之一,只不過在現在的 web 開發中已經很少會使用共享 session 的方式了。

使用 Python 操作 Redis 字符串

下面看看如何使用 Python 操作 Redis 字符串,首先 Python 想操作 Redis 需要使用一個第三方庫,也叫 redis,直接 pip install redis 即可。

安裝完畢之后,我們來操作一波。

import redis

# 獲取 value 時得到的默認是字節,指定 decode_responses,會自動進行解碼
# 當然里面還有許多其它參數,但基本上都是見名知意,可以點擊源碼中看一下
# 比如端口不是 6379,那么就通過 port 參數指定端口,有密碼的話就使用 password 參數指定密碼,還有連接超時時間等等
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 1. set key value
client.set("name", "yousa", ex=None, px=None, nx=False, xx=False)

# 2. get key
print(client.get("name"), client.get("age"))  # yousa None

# 3. del key1 key2 ...
print(client.delete("name", "age"))  # 1

# 4. apend key value
client.set("name", "han")
print(client.append("name", "ser"))  # 6
print(client.get("name"))  # hanser

# 5. strlen key
print(client.strlen("name"))  # 6

# 6. incr key
client.set("age", 28)
# incr key 其實等價於 incrby key 1,因此在這里兩個命令都是通過 incr 實現
# 第二個參數為 1,是默認值,當然我們也可以自己指定
client.incr("age", 1)
print(client.get("age"))  # 29

# 7. decr key
client.decr("age", 10)
print(client.get("age"))  # 19

# 8. getrange key start end
print(client.getrange("name", -3, -1))  # ser

# 9. setrange key start value
client.setrange("name", 3, "sa")
print(client.get("name"))  # hansar

# 10. mset key1 value1 key2 value2
client.mset({"name": "yousa", "age": 20})

# 11. mget key1 key2
print(client.mget(["name", "age", "gender"]))  # ['yousa', '20', None]

# 12. getset key value
print(client.getset("name", "hanser"))  # yousa
print(client.get("name"))  # hanser

# 13. keys pattern
print(client.keys("*"))  # ['name', 'age']

# 14. exists key
print(client.exists("name"), client.exists("ping"))  # 1 0

# 15. ttl key
print(client.ttl("name"))  # -1

# 16. expire key 秒鍾
client.expire("name", 60) 
import time; time.sleep(2)
print(client.ttl("name"))  # 58

# 17. type key
print(client.type("name"))  # string

# 18. move key db
client.move("name", 15)
print(client.get("name"))  # None
# 需要重新連接,連接到 15 號庫
print(redis.Redis(host="47.94.174.89", decode_responses="utf-8", db=15).get("name"))  # hanser

我們看到,和 Redis 命令之間是幾乎沒有什么區別的。

使用 Go 操作 Redis 字符串

下面再來看看如何使用 Go 操作 Redis 字符串,而 Go 想操作 Redis 也需要使用相應的第三方庫,因為 Go 標准庫沒有提供連接 Redis 的包。

# 設置代理,因為從 GitHub 上拉取代碼比較慢
go env -w GOPROXY=https://goproxy.cn,direct
# 下載連接 Redis 的第三方庫 go-redis
go get github.com/go-redis/redis/v8

注意:如果是高版本的 go-redis,那么需要你初始化一個 go module 之后才可以使用。

# 啟用 go module 模式
go env -w GO111MODULE=on
# 在工程目錄中初始化一個 module,"go mod init 隨便起個名字",
go mod init redis
go mod tidy

此時在你的工程目錄中會有一個 go.mod 文件,內容如下:

module redis

go 1.16

然后我們把需要的包加進去即可,比如:

module redis

go 1.16

require github.com/go-redis/redis/v8 v8.11.1

只有當包以上面這種方式加入到 go.mod 中才可以使用,加入方式:在自己的工程目錄中輸入 go get github.com/go-redis/redis/v8 即可,和下載對應的命令一樣。當然,如果你用的是 Goland 這種智能編輯器的話,也可以不用手動做這一步,只需要直接導入即可,會自動將依賴加入到 go.mod 中,前提是相關的依賴包你已經安裝了。

但不得不說,Go 采用 GitHub 做包管理工具真的是讓人難受。

下面就來操作一波,但是注意:之前為了方便,Redis 沒有設密碼,結果阿里雲服務器遭到惡意腳本投遞了,所以這里設置密碼,通過配置 requirepass 指定。

# 將密碼設置成 "satori"
requirepass satori

Redis 的 6379 端口真的非常容易遭到入侵,在生產環境中 Redis 的端口不要對外開放,如果真的要對外開放使用,那么一定要設置密碼,並且最好把監聽的端口從 6379 改成別的。這里我們設置個密碼吧,端口就不改了。

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
    "time"
)

func main() {
    // 設置連接參數,其它參數可以進入源碼中查看,注釋非常詳細
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    // 創建連接 Redis Server 的客戶端
    client := redis.NewClient(&options)
    // 創建 Context 對象
    ctx := context.Background()
    // 下面開始發送請求

    {
        // 設置鍵值對,參數一:Context 對象,參數二:key,參數三:value,參數四:過期時間
        // Go 里面設置過期時間統一用納秒表示,這里設置 30 秒后過期,-1 表示永不過期
        client.Set(ctx, "name", "matsuri", time.Second*30)
        // 不像 Python,Go 沒有關鍵字參數,所以把 nx 和 xx 放在 Set 函數里面會不方便
        // 為此 go-redis 專門提供了兩個函數 SetNX 和 SetXX,雖然 Redis 命令沒有 setxx,但是這里提供了 SetXX 函數
        client.SetNX(ctx, "age", 17, -1)
    }

    {
        // 獲取鍵對應的值,這里會返回 *StringCmd,里面提供了很多方法
        cmd := client.Get(ctx, "name")
        // 獲取 value
        fmt.Println(cmd.Val()) // matsuri
        // 如果 key 不存在那么會返回空字符串,這樣我們就無法判斷到底是 key 不存在,還是 value 本身就是空字符串
        // 所以可以使用 result.Result(),會返回 value 和 error,然后通過 error 進行判斷
        value, err := cmd.Result()
        fmt.Println(value, err) // matsuri <nil>
        // 即便設置的是整型,那么得到的也是一個字符串
        fmt.Println(client.Get(ctx, "age").Val() == "17") // true
        // 所以我們可以調用 Int(),會自動轉換,由於可能轉換失敗,所以還會返回一個 error
        fmt.Println(client.Get(ctx, "age").Int()) // 17 <nil>
        // 除了 Int(),還有 Float32()、Float64()、Bool() 等等
        _, err = client.Get(ctx, "age").Bool()
        // 這里轉化失敗,字符串 17 無法轉為 bool 類型
        fmt.Println(err) // strconv.ParseBool: parsing "17": invalid syntax

    }

    {
        // 刪除 key,返回 *IntCmd
        cmd := client.Del(ctx, "name", "age", "gender")
        fmt.Println(cmd.Result())  // 2 <nil>
    }

    {
        // apend key value
        client.Set(ctx, "name", "han", -1)
        // 所有的操作都會返回一個 *...Cmd,然后調用 Result 拿到結果
        fmt.Println(client.Append(ctx, "name", "ser").Result())  // 6 <nil>
        fmt.Println(client.Get(ctx, "name").Result())  // hanser <nil>
    }

    {
        // strlen key
        fmt.Println(client.StrLen(ctx, "name").Result()) // 6 <nil>
    }

    {
        // incr key
        client.Set(ctx, "age", 28, -1)
        client.Incr(ctx, "age")
        client.IncrBy(ctx, "age", 10)
        fmt.Println(client.Get(ctx, "age").Result())  // 39 <nil>
    }

    {
        // decr key
        client.Decr(ctx, "age")
        client.DecrBy(ctx, "age", 10)
        fmt.Println(client.Get(ctx, "age").Result())  // 28 <nil>
    }

    {
        // getrange key start end
        fmt.Println(client.GetRange(ctx, "name", -3, -1).Result())  // ser <nil>
    }

    {
        // setrange key start value
        client.SetRange(ctx, "name", 3, "sa")
        fmt.Println(client.Get(ctx, "name").Result())  // hansar <nil>
    }

    {
        // mset key1 value1 key2 value2
        client.MSet(ctx, "name", "yousa", "age", 20)
        // mget key1 key2
        fmt.Println(client.MGet(ctx, "name", "age").Result())  // [yousa 20] <nil>
        fmt.Println(client.MGet(ctx, "name", "age1").Result())  // [yousa <nil>] <nil>
    }

    {
        // getset key value
        fmt.Println(client.GetSet(ctx, "name", "hanser").Result())  // yousa <nil>
        fmt.Println(client.Get(ctx, "name").Result())  // hanser <nil>
    }

    {
        // keys pattern
        fmt.Println(client.Keys(ctx, "*").Result())  // [age name]
        // exists key,返回 1 表示存在,返回 0 表示不存在
        fmt.Println(client.Exists(ctx, "name").Result())  // 1 <nil>
        fmt.Println(client.Exists(ctx, "name1").Result())  // 0 <nil>
    }

    {
        // ttl key
        fmt.Println(client.TTL(ctx, "name").Result())  // -1ns <nil>
        // expire key
        client.Expire(ctx, "name", time.Second * 60)
        time.Sleep(time.Second * 2)
        fmt.Println(client.TTL(ctx, "name").Result())  // 58s <nil>
    }

    {
        // type key
        fmt.Println(client.Type(ctx, "name").Result())  // string <nil>
    }

    {
        // move key db
        client.Move(ctx, "name", 15)
        if _, err := client.Get(ctx, "name").Result(); err != nil {
            fmt.Println("key 不存在")  // key 不存在
        }

        // 重新連接 15 號庫
        options.DB = 15
        client = redis.NewClient(&options)
        fmt.Println(client.Get(ctx, "name").Result())  // hanser <nil>
    }
}

由於 Go 是靜態語言,所以操作起來會稍微復雜一些,不過這些沒有必要刻意去記,借助於 Goland IDE 自動提示即可,返回值以及相應的方法也可以跳轉到源碼中查看,這就是靜態語言的好處。因為不管代碼多復雜,通過 IDE 都能一層一層地找下去。

Redis 列表 (List)

列表類型(List)是一個使用鏈表結構存儲的有序結構,它的元素插入會按照先后順序存儲到鏈表結構中,因此它的元素操作(插入、刪除)時間復雜度為 \(O(1)\),所以相對來說速度還是比較快的,但它的查詢時間復雜度為 \(O(n)\),因此查詢可能會比較慢。

Redis 中的列表和字符串比較類似,只不過字符串是一個 key 對應一個 value、獲取的時候直接通過 key 來獲取;而列表是一個 key 對應的多個 value、獲取的時候通過 key + 索引 來獲取。

 

下面我們來看看它所支持的 api 操作

lpush key value1 value2 ...:將多個值設置到列表里面,從左邊 push

rpush key value1 value2 ...:將多個值設置到列表里面,從右邊 push

127.0.0.1:6379> lpush girls mashiro koishi
(integer) 2  # 返回插入成功之后,列表的元素個數。這里是 lpush,所以此時列表內的元素是 koishi mashiro
127.0.0.1:6379> rpush girls satori
(integer) 3
127.0.0.1:6379> 

lrange key start end:遍歷列表,索引從 0 開始,最后一個為 -1,且包含兩端

127.0.0.1:6379> lrange girls 0 -1
1) "koishi"
2) "mashiro"
3) "satori"
127.0.0.1:6379> lrange girls 0 2
1) "koishi"
2) "mashiro"
3) "satori"
127.0.0.1:6379> lrange girls 0 1
1) "koishi"
2) "mashiro"
127.0.0.1:6379> lrange lst 0 -1
(empty array)  # 對不存在的列表使用 lrange,會得到空數組

lpop key:從列表的左端彈出一個值,列表長度改變

rpop key:從列表的右端彈出一個值,列表長度改變

127.0.0.1:6379> lpop girls
"koishi"
127.0.0.1:6379> rpop girls
"satori"
127.0.0.1:6379> lrange girls 0 -1
1) "mashiro"
127.0.0.1:6379> 

lindex key index:獲取指定索引位置的元素,列表長度不變

127.0.0.1:6379> lindex girls 0
"mashiro"
127.0.0.1:6379> lrange girls 0 -1
1) "mashiro"
127.0.0.1:6379> lindex lst 0 
(nil)  # 對不存在的列表使用 lindex,會得到 nil
127.0.0.1:6379> 

llen key:獲取指定列表的長度

127.0.0.1:6379> llen girls
(integer) 1
127.0.0.1:6379> llen lst
(integer) 0  # 對不存在的列表使用 llen,會得到 0。
127.0.0.1:6379> 

lrem key count value:刪除 count 個 value,如果 count 為 0,那么將全部刪除

127.0.0.1:6379> lpush lst  1 1 1 1
(integer) 4
127.0.0.1:6379> lrem lst 3 1
(integer) 3  # 刪除 3 個 1
127.0.0.1:6379> lrange lst 0 -1
1) "1"
127.0.0.1:6379> 

ltrim key start end:從 start 截取到 end,再重新賦值給 key

127.0.0.1:6379> rpush lst 2 3 4 5
(integer) 5
127.0.0.1:6379> lrange lst 0 -1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
127.0.0.1:6379> ltrim lst 4 -1
OK  # 將 5 重新賦值給 lst
127.0.0.1:6379> lrange lst 0 -1
1) "5"
127.0.0.1:6379> 

rpoplpush key1 key2:移除 key1 的最后一個元素,並添加到 key2 的開頭

127.0.0.1:6379> rpush lst1 1 2 3
(integer) 3
127.0.0.1:6379> rpush lst2 11 22 33
(integer) 3
127.0.0.1:6379> rpoplpush lst1 lst2
"3"
127.0.0.1:6379> lrange lst2 0 -1
1) "3"
2) "11"
3) "22"
4) "33"
127.0.0.1:6379> 

lset key index value:將 key 中索引為 index 的元素設置為 value

127.0.0.1:6379> lrange lst2 0 -1
1) "3"
2) "11"
3) "22"
4) "33"
127.0.0.1:6379> lset lst2 1 2333
OK
127.0.0.1:6379> lrange lst2 0 -1
1) "3"
2) "2333"
3) "22"
4) "33"
127.0.0.1:6379> lset lst2 10 2333
(error) ERR index out of range  # 索引越界則報錯,顯然索引為 10 越界了
127.0.0.1:6379> 

linsert key before/after value1 value2:在 value1 的前面或者后面插入一個 value2

127.0.0.1:6379> rpush lst3 1 2 2 3
(integer) 4
127.0.0.1:6379> linsert lst3 before 2 666
(integer) 5
127.0.0.1:6379> lrange lst3 0 -1
1) "1"
2) "666"
3) "2"
4) "2"
5) "3"
127.0.0.1:6379> linsert lst3 after 2 2333
(integer) 6
127.0.0.1:6379> lrange lst3 0 -1
1) "1"
2) "666"
3) "2"
4) "2333"
5) "2"
6) "3"
127.0.0.1:6379> 

我們看到插入位置是由第一個元素決定的。

 

然后我們來分析一下 Redis 列表類型的內部實現:

127.0.0.1:6379> object encoding list
"quicklist"
127.0.0.1:6379> 

我們看到列表底層的數據類型是 quicklist(快速列表),quicklist 是 Redis3.2 引入的數據類型,早期的列表是使用 ziplist(壓縮列表)和雙向列表組成的,Redis3.2 的時候改為 quicklist,下面就來看一下它的底層實現。

// src/quicklist.h
typedef struct quicklist { 
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;                       /* ziplist 的個數 */
    unsigned long len;                         /* quicklist 的節點數 */
    unsigned int compress : 16;                /* LZF 壓縮算法深度 */
    //...
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;                         /* 對應的 ziplist */
    unsigned int sz;                           /* ziplist 字節數 */
    unsigned int count : 16;                   /* ziplist 個數 */
    unsigned int encoding : 2;                 /* RAW==1 or LZF==2 */
    unsigned int container : 2;                /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1;               /* 該節點先前是否被壓縮 */
    unsigned int attempted_compress : 1;       /* 節點太小無法壓縮 */
    //...
} quicklistNode;

typedef struct quicklistLZF {
    unsigned int sz; 
    char compressed[];
} quicklistLZF;

從源碼中可以看出 quicklist 是一個雙向鏈表,鏈表中的每一個節點實際上是一個 quicklistNode,每個 quicklistNode 對應一個 ziplist,對應結構如圖所示。

ziplist 作為 quicklist 的實際存儲結構,它本質是一個字節數組,ziplist 數據結構如下圖所示:

  • zlbytes:壓縮列表字節長度,占 4 字節
  • zltail:壓縮列表尾元素相對於起始元素地址的偏移量,占 4 字節
  • zllen:壓縮列表的元素個數
  • entryX:壓縮列表存儲的所有元素,可以是字節數組或者是整數
  • zlend:壓縮列表的結尾,占 1 字節

在壓縮列表中,如果我們要查找定位第一個元素和最后一個元素,可以通過表頭三個字段的長度直接定位,復雜度是 \(O(1)\)。而查找其他元素時,就沒有這么高效了,只能逐個查找,此時的復雜度就是 \(O(n)\) 了。

 

使用場景

列表的典型使用場景有以下兩個:

  • 消息隊列:列表類型可以使用 rpush 實現先進先出的功能,同時又可以使用 lpop 輕松的彈出(查詢並刪除)第一個元素,所以列表類型可以用來實現消息隊列;
  • 文章列表:對於博客站點來說,當用戶和文章都越來越多時,為了加快程序的響應速度,我們可以把用戶自己的文章存入到 List 中,因為 List 是有序的結構,所以這樣又可以完美的實現分頁功能,從而加速了程序的響應速度;

使用 Python 操作 Redis 列表

老規矩,我們來看看如何使用 Python 來操作 Redis 中的列表,和操作字符串是類似的,因為 Python 操作 Redis 的模塊提供的 api 和 redis-cli 控制台所使用的 api 是高度一致的,包括后面的 Go 也是。

import redis
 
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")
 
# 1. lpush key value1 value2 ...
client.lpush("list", 1, 2, 3)
 
# 2. rpush key value1 value2 ...
client.rpush("list", 2, 2, 2)
 
# 3. lrange key start end
print(client.lrange("list", 0, -1))  # ['3', '2', '1', '2', '2', '2']
 
# 4. lpop key
print(client.lpop("list"))  # 3
 
# 5. rpop key
print(client.rpop("list"))  # 2
 
# 6. lindex key
print(client.lindex("list", 0))  # 2
 
# 7. llen key
print(client.llen("list"))  # 4
 
# 8. lrem key count value
client.lrem("list", 1, 2)
print(client.lrange("list", 0, -1))  # ['1', '2', '2']
 
# 9. ltrim key start end
print(client.lrange("list", 0, -1))  # ['1', '2', '2']
client.ltrim("list", 0, -2)
print(client.lrange("list", 0, -1))  # ['1', '2]
 
# 10. rpoplpush key1 key2
client.rpush("list1", 1, 2, 3)
client.rpush("list2", 11, 22, 33)
client.rpoplpush("list1", "list2") 
print(client.lrange("list2", 0, -1))  # ['3', '11', '22', '33']
 
# 11. lset key index value
client.lset("list2", -1, "古明地覺")
print(client.lrange("list2", 0, -1))  # ['3', '11', '22', '古明地覺']
 
# 12. linsert key before/after value1 value2
client.linsert("list2", "before", 22, "aaa")
client.linsert("list2", "after", 22, "bbb")
print(client.lrange("list2", 0, -1))  # ['3', '11', 'aaa', '22', 'bbb', '古明地覺']

使用 Go 操作 Redis 列表

再來看看如何使用 Go 來操作 Redis 中的列表,做法是類似的,這里我們先將上面使用 Python 創建的 key 給刪掉。

127.0.0.1:6379> del list list1 list2
(integer) 3

下面使用 Go 連接 Redis。

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 添加元素
    client.LPush(ctx, "list", 1, 2, 3)
    client.RPush(ctx, "list", 2, 2, 2)

    // 查看元素
    cmd := client.LRange(ctx, "list", 0, -1)
    fmt.Println(cmd.Result())  // [3 2 1 2 2 2] <nil>

    // 彈出元素
    fmt.Println(client.LPop(ctx, "list").Result())  // 3 <nil>
    fmt.Println(client.RPop(ctx, "list").Result())  // 2 <nil>

    // 索引元素
    fmt.Println(client.LIndex(ctx, "list", 0).Val())  // 2

    // 查看長度
    fmt.Println(client.LLen(ctx, "list").Val())  // 4

    // 刪除指定 count 個 value
    client.LRem(ctx, "list", 1, 2)
    fmt.Println(client.LRange(ctx, "list", 0, -1).Val())  // [1 2 2]

    // 截取指定位置
    fmt.Println(client.LRange(ctx, "list", 0, -1).Val())  // [1 2 2]
    client.LTrim(ctx, "list", 0, -2)
    fmt.Println(client.LRange(ctx, "list", 0, -1).Val())  // [1 2]

    // 從 key1 的尾部刪除一個元素,並移動到 key2 的開頭
    client.RPush(ctx, "list1", 1, 2, 3)
    client.RPush(ctx, "list2", 11, 22, 33)
    client.RPopLPush(ctx, "list1", "list2")
    fmt.Println(client.LRange(ctx, "list2", 0, -1).Val())  // [3 11 22 33]

    // 將 key 中索引為 index 的元素設置為 value
    client.LSet(ctx, "list2", -1, "satori")
    fmt.Println(client.LRange(ctx, "list2", 0, -1).Val())  // [3 11 22 satori]

    // 在 value1 的前面或后面添加一個 value2
    client.LInsert(ctx, "list2", "before", 22, "aaa")
    client.LInsert(ctx, "list2", "after", 22, "bbb")
    fmt.Println(client.LRange(ctx, "list2", 0, -1).Val())  // [3 11 aaa 22 bbb satori]
}

Redis 字典 (Hash)

字典類型(Hash)又被成為散列類型或是哈希表類型,它底層是通過哈希表存儲的,這個哈希表包含兩列數據:字段和值,假設我們使用字典來存儲文章的詳情信息,存儲結構如圖所示:

同理我們也可以使用字典來存儲用戶信息,並且使用字典存儲此類信息是不需要序列化和反序列化的,所以使用起來更加的方便和高效。

下面看看字典所支持的api

hset key field1 value1 field2 value2···:設置鍵值對,可同時設置多個。這里的鍵值對指的是 field、value,而命令中的 key 指的是字典、或者哈希表的名稱

127.0.0.1:6379> hset girl name hanser age 28 gender f
(integer) 3  # 返回 3 表示成功設置 3 個鍵值對
127.0.0.1:6379> 

hget key field:獲取 hash 中 field 對應的 value

127.0.0.1:6379> hget girl name
"hanser"
127.0.0.1:6379> 

hgetall key:獲取 hash 中所有的鍵值對

127.0.0.1:6379> hgetall girl
1) "name"
2) "hanser"
3) "age"
4) "28"
5) "gender"
6) "f"
127.0.0.1:6379> 

hlen key:獲取 hash 中鍵值對的個數

127.0.0.1:6379> hlen girl
(integer) 3
127.0.0.1:6379> 

hexists key field:判斷 hash 中是否存在指定的 field

127.0.0.1:6379> hexists girl name
(integer) 1  # 存在返回 1
127.0.0.1:6379> hexists girl where
(integer) 0  # 不存在返回 0
127.0.0.1:6379> 

hkeys/hvals key:獲取 hash 中所有的 field 和所有的 value

127.0.0.1:6379> hkeys girl
1) "name"
2) "age"
3) "gender"
127.0.0.1:6379> hvals girl
1) "hanser"
2) "28"
3) "f"
127.0.0.1:6379> 

hincrby key field number:將 hash 中字段 field 對應的值自增 number,number 必須指定,顯然 field 對應的 value 要能解析成整型

127.0.0.1:6379> hincrby girl age 3
(integer) 31  # 返回增加之后的值
127.0.0.1:6379> hincrby girl age -3
(integer) 28  # 可以為正、可以為負
127.0.0.1:6379> 

hsetnx key field1 value1:每次只能設置一個鍵值對,不存在則設置,存在則無效。

127.0.0.1:6379> hsetnx girl name yousa
(integer) 0  # name 存在,所以設置失敗
127.0.0.1:6379> hget girl name
"hanser"  # 還是原來的結果
127.0.0.1:6379> hsetnx girl length 155.5
(integer) 1  # 設置成功
127.0.0.1:6379> hget girl length
"155.5"
127.0.0.1:6379> 

hdel key field1 field2······:刪除 hash 中的鍵,當然鍵沒了,整個鍵值對就沒了

127.0.0.1:6379> hdel girl name age
(integer) 2
127.0.0.1:6379> hget girl name
(nil)
127.0.0.1:6379> hget girl age
(nil)
127.0.0.1:6379> 

那么 Redis 中的字典是如何實現的呢?

字典類型本質上是由數組和鏈表結構組成的,來看字典類型的源碼實現:

typedef struct dictEntry { // dict.h
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 下一個 entry
} dictEntry;

字典類型的數據結構,如下圖所示:

通常情況下字典類型會使用數組的方式來存儲相關的數據,但發生哈希沖突時才會使用鏈表的結構來存儲數據。

哈希沖突

字典類型的存儲流程是先將鍵進行 Hash 計算,得到存儲鍵對應的數組索引,再根據數組索引進行數據存儲,但在小概率事件下可能會出完全不相同的鍵進行 Hash 計算之后,得到相同的 Hash 值,這種情況我們稱之為哈希沖突。

哈希沖突一般通過鏈表的形式解決,相同的哈希值會對應一個鏈表結構,每次有哈希沖突時,就把新的元素插入到鏈表的尾部,請參考上面數據結構的那張圖。

鍵查詢的流程如下:

  • 通過算法(Hash,計算和取余等)操作獲得數組的索引值,根據索引值找到對應的元素;
  • 判斷元素和查找的鍵值是否相等,相等則成功返回數據,否則需要查看 next 指針是否還有對應其他元素,如果沒有,則返回 null,如果有的話,重復此步驟。

漸進式 rehash

Redis 為了保證應用的高性能運行,提供了一個重要的機制——漸進式 rehash。 漸進式 rehash 是用來保證字典縮放效率的,也就是說在字典進行擴容或者縮容是會采取漸進式 rehash 的機制。

1) 擴容

當元素數量等於數組長度時就會進行擴容操作,源碼在 dict.c 文件中,核心代碼如下:

int dictExpand(dict *d, unsigned long size)
{
    /* 需要的容量小於當前容量,則不需要擴容 */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
    dictht n; 
    unsigned long realsize = _dictNextPower(size); // 重新計算擴容后的值
    /* 計算新的擴容大小等於當前容量,不需要擴容 */
    if (realsize == d->ht[0].size) return DICT_ERR;
    /* 分配一個新的哈希表,並將所有指針初始化為 NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    if (d->ht[0].table == NULL) {
        // 第一次初始化
        d->ht[0] = n;
        return DICT_OK;
    }
    d->ht[1] = n; // 把增量輸入放入新 ht[1] 中
    d->rehashidx = 0; // 非默認值 -1,表示需要進行 rehash
    return DICT_OK;
}

從以上源碼可以看出,如果需要擴容則會申請一個新的內存地址賦值給 ht[1],並把字典的 rehashindex 設置為 0,表示之后需要進行 rehash 操作。

2) 縮容

當字典的使用容量不足總空間的 10% 時就會觸發縮容,Redis 在進行縮容時也會把 rehashindex 設置為 0,表示之后需要進行 rehash 操作。

3) 漸進式 rehash 流程

在進行漸進式 rehash 時,會同時保留兩個 hash 結構,新鍵值對加入時會直接插入到新的 hash 結構中,並會把舊 hash 結構中的元素一點一點的移動到新的 hash 結構中,當移除完最后一個元素時,清空舊 hash 結構,主要的執行流程如下:

  • 1. 擴容或者縮容時把字典中的字段 rehashidx 標識為 0;
  • 2. 在執行定時任務或者執行客戶端的 hset、hdel 等操作指令時,判斷是否需要觸發 rehash 操作(通過 rehashidx 標識判斷),如果需要觸發 rehash 操作,也就是調用 dictRehash 函數,dictRehash 函數會把 ht[0] 中的元素依次添加到新的 Hash 表 ht[1] 中;
  • 3. rehash 操作完成之后,清空 Hash 表 ht[0],然后對調 ht[1] 和 ht[0] 的值,把新的數據表 ht[1] 更改為 ht[0],然后把字典中的 rehashidx 標識為 -1,表示不需要執行 rehash 操作。

通過漸進式 rehash,可以把一次性大量拷貝的開銷分攤到多次處理的請求中,避免了耗時操作,從而保證數據的快速訪問。

那么 Redis 中的字典都在哪些場景中使用呢?

哈希字典的典型使用場景如下:

  • 商品購物車,購物車非常適合用哈希字典表示,使用人員唯一編號作為 key(哈希表的名稱),哈希表本身則負責存儲商品的 id 和數量等信息;比如:hset person_id0001 product_id product001 count 20
  • 存儲用戶的屬性信息,使用人員唯一編號作為 key,哈希表存儲屬性字段和對應的值;
  • 存儲文章詳情頁信息等。

因此通過上面內容我們知道了字典類型實際是由數組和鏈表組成的,當字典進行擴容或者縮容時會進行漸進式 rehash 操作,漸進式 rehash 是用來保證 Redis 運行效率的,它的執行流程是同時保留兩個哈希表,把舊表中的元素一點一點的移動到新表中,查詢的時候會先查詢兩個哈希表,當所有元素都移動到新的哈希表之后,就會刪除舊的哈希表。

使用 Python 操作 Redis 字典

下面看看 Python 如何操作 Redis 的字典

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 1. hset key field1 value1 field2 value2···
# 這里的 hset 只能設置單個值,如果設置多個需要使用 hmset,傳入 key 和字典
client.hmset("girl1", {"name": "yousa", "age": 26, "length": 148})

# 2. hget key field
print(client.hget("girl1", "name"))  # yousa

# 3. hgetall key
print(client.hgetall("girl1"))  # {'name': 'yousa', 'age': '26', 'length': '148'}
# 這里還支持同時獲取多個值
print(client.hmget("girl1", ["name", "age"]))  # ['yousa', '26']

# 4. hlen key
print(client.hlen("girl1"))  # 3

# 5. hexists key field
print(client.hexists("girl1", "name"))  # True
print(client.hexists("girl1", "name1"))  # False

# 6. hkeys/hvals key
print(client.hkeys("girl1"))  # ['name', 'age', 'length']
print(client.hvals("girl1"))  # ['yousa', '26', '148']

# 7. hincrby key field number
client.hincrby("girl1", "age", 2)  
print(client.hget("girl1", "age"))  # 28

# 8. hsetnx key field1 value1
client.hsetnx("girl1", "name", "hanser")
client.hsetnx("girl1", "gender", "female")
print(client.hmget("girl1", ["name", "gender"]))  # ['yousa', 'female']

# 9. hdel key field1 field2······
client.hdel("girl1", "name", "age")
print(client.hmget("girl1", ["name", "age"]))  # [None, None]

使用 Go 操作 Redis 字典

下面看看 Go 如何操作 Redis 的字典

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 設置元素
    client.HMSet(ctx, "girl1", "name", "hanser", "age", 28, "length", 155)

    // 獲取元素
    fmt.Println(client.HGet(ctx, "girl1", "name").Val())  // hanser
    fmt.Println(client.HMGet(ctx, "girl1", "name", "age").Val())  // [hanser 28]
    fmt.Println(client.HGetAll(ctx, "girl1").Val())  // map[age:28 length:155 name:hanser]

    // 獲取長度
    fmt.Println(client.HLen(ctx, "girl1").Val())  // 3

    // 判斷某個 field 是否存在
    fmt.Println(client.HExists(ctx, "girl1", "name").Val())  // true
    fmt.Println(client.HExists(ctx, "girl1", "name1").Val())  // false

    // 獲取所有的 key、value,注意這里的 key 是字典里面的 key
    // 或者就把這里的 key 看做是 field,這里的 key 並不是全局哈希表里面的 key
    fmt.Println(client.HKeys(ctx, "girl1").Val())  // [name age length]
    fmt.Println(client.HVals(ctx, "girl1").Val())  // [hanser 28 155]

    // 給某個類型為 int 的 key 自增 number
    client.HIncrBy(ctx, "girl1", "age", 1)
    fmt.Println(client.HGet(ctx, "girl1", "age").Val())  // 29

    // 設置一個鍵值對,但只有不存在時才會設置
    fmt.Println(client.HMGet(ctx, "girl1", "name", "gender").Val())  // [hanser <nil>]
    client.HSetNX(ctx, "girl1", "name", "憨")
    client.HSetNX(ctx, "girl1", "gender", "female")
    fmt.Println(client.HMGet(ctx, "girl1", "name", "gender").Val())  // [hanser female]

    // 刪除指定的鍵值對
    client.HDel(ctx, "girl1", "name", "age")
    fmt.Println(client.HMGet(ctx, "girl1", "name", "age").Val())  // [<nil> <nil>]
}

Redis 集合 (Set)

Redis的集合和列表是類似的,都是用來存儲多個標量,但是它和列表又有不同:

  • 1. 列表中的元素是可以重復的,而集合中的元組不會重復。
  • 2. 列表在插入元素的時候可以保持順序,而集合不保證順序(集合在存儲數據時,底層也是使用了哈希表,后面會說)。

下面我們來看看它所支持的 api 操作

sadd key value1 value2···:向集合插入多個元素,如果重復會自動去重

127.0.0.1:6379> sadd set1 1 1 2 3
(integer) 3  # 返回成功插入的元素的個數,這里是 3 個,因為元素有重復。兩個 1,只會插入一個
127.0.0.1:6379> 

smembers key:查看集合的所有元素

127.0.0.1:6379> smembers set1
1) "1"
2) "2"
3) "3"
127.0.0.1:6379> 

sismember key value:查看 value 是否在集合中

127.0.0.1:6379> sismember set1 1
(integer) 1  # 在的話返回 1
127.0.0.1:6379> sismember set1 5
(integer) 0  # 不在返回 0
127.0.0.1:6379> 

scard key:查看集合的元素個數

127.0.0.1:6379> scard set1
(integer) 3
127.0.0.1:6379> 

srem key value1 value2 ······:刪除集合中的元素

127.0.0.1:6379> srem set1 1 2
(integer) 2  # 返回刪除成功的元素個數
127.0.0.1:6379> srem set1 1 2
(integer) 0
127.0.0.1:6379> 

spop key count:隨機彈出集合中 count 個元素,注意:count 是可以省略的,如果省略則彈出 1 個。另外一旦彈出,原來的集合里面也就沒有了。

127.0.0.1:6379> smembers set1
1) "3"  # 還有一個元素
127.0.0.1:6379> sadd set1 1 2
(integer) 2  # 添加兩個進去
127.0.0.1:6379> 
127.0.0.1:6379> smembers set1
1) "1"
2) "2"
3) "3"
127.0.0.1:6379> spop set1 1
1) "2"  # 彈出 1 個元素,返回彈出的元素
127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> 

srandmember key count:隨機獲取集合中 count 個元素,注意:count 是可以省略的,如果省略則獲取 1 個。可以看到類似 spop,但是 srandmember 不會刪除集合中的元素。

127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> srandmember set1 1
1) "1"
127.0.0.1:6379> smembers set1
1) "1"
2) "3"

smove key1 key2 value:將 key1 當中的 value 移動到 key2 當中,因此 key1 當中的元素會少一個,key2 會多一個(前提是 value 在 key2 中不重復,否則 key2 還和原來保持一致)。

127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> smembers set2
1) "1"
127.0.0.1:6379> smove set1 set2 3
(integer) 1
127.0.0.1:6379> smembers set1
1) "1"
127.0.0.1:6379> smembers set2
1) "1"
2) "3"
127.0.0.1:6379> 

sinter key1 key2:返回即在 key1 中,又在 key2 中的元素

sunion key1 key2:返回在 key1 中,或者在 key2 中的元素

sdiff key1 key2:返回在 key1 中,但不在 key2 中的元素

127.0.0.1:6379> sinter set1 set2
1) "2"
2) "3"
127.0.0.1:6379> sunion set1 set2
1) "1"
2) "2"
3) "3"
4) "4"
127.0.0.1:6379> sdiff set1 set2
1) "1"
127.0.0.1:6379> sdiff set2 set1
1) "4"
127.0.0.1:6379> 

 

那么 Redis 的集合底層是如何實現的呢?

集合類型是由 intset(整數集合)或 hashtable(普通哈希表)組成的。當集合類型以 hashtable 存儲時,哈希表的 key 為要插入的元素值,而哈希表的 value 則為 Null,如下圖所示:

當集合中所有的值都為整數時,Redis 會使用 intset 結構(Redis 為整數專門設計的一種集合結構)來存儲,如下代碼所示:

127.0.0.1:6379> sadd s 1 2 3
(integer) 3
127.0.0.1:6379> object encoding s
"intset"
127.0.0.1:6379> 

從上面代碼可以看出,當所有元素都為整數時,集合會以 intset 結構進行數據存儲。 當發生以下兩種情況時,會導致集合類型使用 hashtable 而非 intset 存儲:

  • 1)當元素的個數超過一定數量時,默認是 512 個,該值可通過命令 set-max-intset-entries xxx 來配置
  • 2)當元素為非整數時,集合將會使用 hashtable 來存儲,如下代碼所示:
import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

client.sadd("s1", *range(513))
# 超過 512 個,使用哈希表存儲
print(client.object("encoding", "s1"))  # hashtable

client.sadd("s2", *range(512))
# 沒超過 512 個,使用 intset
print(client.object("encoding", "s2"))  # intset

client.sadd("s3", "hanser")
# 不是整數,使用哈希表存儲
print(client.object("encoding", "s3"))  # hashtable

源碼解析

集合源碼在 t_set.c 文件中,核心源碼如下:

/* 
 * 添加元素到集合
 * 如果當前值已經存在,則返回 0 不作任何處理,否則就添加該元素,並返回 1。
 */
int setTypeAdd(robj *subject, sds value) {
    long long llval;
    if (subject->encoding == OBJ_ENCODING_HT) { // 字典類型
        dict *ht = subject->ptr;
        dictEntry *de = dictAddRaw(ht,value,NULL);
        if (de) {
            // 把 value 作為字典到 key,將 Null 作為字典到 value,將元素存入到字典
            dictSetKey(ht,de,sdsdup(value));
            dictSetVal(ht,de,NULL);
            return 1;
        }
    } else if (subject->encoding == OBJ_ENCODING_INTSET) { // inset 數據類型
        if (isSdsRepresentableAsLongLong(value,&llval) == C_OK) {
            uint8_t success = 0;
            subject->ptr = intsetAdd(subject->ptr,llval,&success);
            if (success) {
                // 超過 intset 的最大存儲數量,則使用字典類型存儲
                if (intsetLen(subject->ptr) > server.set_max_intset_entries)
                    setTypeConvert(subject,OBJ_ENCODING_HT);
                return 1;
            }
        } else {
            // 轉化為整數類型失敗,使用字典類型存儲
            setTypeConvert(subject,OBJ_ENCODING_HT);

            serverAssert(dictAdd(subject->ptr,sdsdup(value),NULL) == DICT_OK);
            return 1;
        }
    } else {
        // 未知編碼(類型)
        serverPanic("Unknown set encoding");
    }
    return 0;
}

以上這些代碼驗證了,我們上面所說的內容,當元素都為整數並且元素的個數沒有到達設置的最大值時,鍵值的存儲使用的是 intset 的數據結構,反之到元素超過了一定的范圍,又或者是存儲的元素為非整數時,集合會選擇使用 hashtable 的數據結構進行存儲。

使用場景

集合類型的經典使用場景如下:

  • 微博關注我的人和我關注的人都適合用集合存儲,可以保證人員不會重復;
  • 中獎人信息也適合用集合類型存儲,這樣可以保證一個人不會重復中獎。

使用 Python 操作 Redis 集合

下面看看 Python 如何操作 Redis 的集合

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 1. sadd key value1 value2·····
client.sadd("s1", 1, 2, 3, 1)  

# 2. smembers key
print(client.smembers("s1"))  # {'2', '1', '3'}

# 3. sismember key value
print(client.sismember("s1", 1))  # True
print(client.sismember("s1", 5))  # False

# 4. scard key
print(client.scard("s1"))  # 3

# 5. srem key value1 value2······
client.srem("s1", 1, 2)
print(client.smembers("s1"))  # {'3'}

# 6. spop key count
print(client.smembers("s1"))  # {'3'}
print(client.spop("s1", 1))  # ['3']
print(client.smembers("s1"))  # set()

# 7. srandmember key count
client.sadd("s1", 1, 2, 3)
print(client.smembers("s1"))  # {'2', '1', '3'}
print(client.srandmember("s1", 2))  # ['2', '3']
print(client.smembers("s1"))  # {'2', '1', '3'}

# 8. smove key1 key2 value
client.sadd("s2", 1)
client.smove("s1", "s2", 3)
print(client.smembers("s2"))  # {'1', '3'}

# 9. sinter key1 key2
# 10. sunion key1 key2
# 11. sdiff key1 key2
client.sadd("s3", 1, 2, 3)
client.sadd("s4", 2, 3, 4)
print(client.sinter("s3", "s4"))  # {'2', '3'}
print(client.sunion("s3", "s4"))  # {'2', '4', '1', '3'}
print(client.sdiff("s3", "s4"))  # {'1'}

使用 Go 操作 Redis 集合

下面看看 Go 如何操作 Redis 的集合

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 添加元素
    client.SAdd(ctx, "s1", 1, 2, 3, 1)

    // 獲取元素
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [1 2 3]

    // 查看是否包含某個元素
    fmt.Println(client.SIsMember(ctx, "s1", 1).Val())  // true
    fmt.Println(client.SIsMember(ctx, "s1", 5).Val())  // false

    // 查看集合內部的元素數量
    fmt.Println(client.SCard(ctx, "s1").Val())  // 3

    // 刪除集合內部的元素
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [1 2 3]
    client.SRem(ctx, "s1", 1, 3)
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [2]

    // 從集合中彈出 count 個元素
    // 彈出一個的話也等價於 SPop,但返回的不是一個切片、而是一個標量
    fmt.Println(client.SPopN(ctx, "s1", 1).Val())  // [2]
    fmt.Println(client.SMembers(ctx, "s1").Val())  // []

    // 隨機獲取 count 個元素
    client.SAdd(ctx, "s1", 1, 2, 3)
    fmt.Println(client.SRandMember(ctx, "s1").Val())  // 1
    fmt.Println(client.SRandMemberN(ctx, "s1", 2).Val())  // [3 1]
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [1 2 3]

    // 將 value 從 s1 移動到 s2 中
    client.SAdd(ctx, "s2", 1)
    client.SMove(ctx, "s1", "s2", 1)
    fmt.Println(client.SMembers(ctx, "s2").Val())  // [1]

    // 交集、並集、差集
    client.SAdd(ctx, "s3", 1, 2, 3)
    client.SAdd(ctx, "s4", 2, 3, 4)
    fmt.Println(client.SInter(ctx, "s3", "s4").Val())  // [2 3]
    fmt.Println(client.SUnion(ctx, "s3", "s4").Val())  // [1 2 3 4]
    fmt.Println(client.SDiff(ctx, "s3", "s4").Val())  // [1]
}

Redis 有序集合 (zset,Sorted Set)

Redis的有序集合相比集合多了一個排序屬性:score(分值),對於有序集合 zset 來說,每個存儲元素相當於有兩個值,一個是有序集合的元素值,一個是排序值(分值)。有序集合存儲的元素值也是不重復的,但分數可以重復。

當我們把學生的成績存儲在有序集合中,它的存儲結構如下圖所示:

下面我們來看看它所支持的 api 操作

zadd key score1 value1 score2 value2:設置 score 和 value

127.0.0.1:6379> zadd zset1 1 n1 2 n2 3 n2
(integer) 2
127.0.0.1:6379> 

一個 score 對應一個 value,value 不會重復,因此即便我們這里添加了 3 個,但是后面兩個的 value 都是 n2,所以實際上只有兩個元素,並且 n2 是以后一個 score 為准,因為相當於覆蓋了。

zscore key value:獲取 value 對應的 score

127.0.0.1:6379> zscore zset1 n2
"3"
127.0.0.1:6379>

zrange key start end:獲取指定范圍的 value,遞增排列,這里是基於索引獲取

127.0.0.1:6379> zadd zset2 1 n1 3 n3 2 n2 4 n4
(integer) 4
127.0.0.1:6379> zrange zset2 0 -1
1) "n1"
2) "n2"
3) "n3"
4) "n4"
127.0.0.1:6379> zrange zset2 0 2
1) "n1"
2) "n2"
3) "n3"
127.0.0.1:6379> 

如果結尾加上 with scores 參數,那么會和 score 一同返回,注意:score 是在下面。我們看到這個 zset 有點像 hash 啊,value 是 hash 的 k,score 是 hash 的 v。

127.0.0.1:6379> zrange zset2 0 2 withscores
1) "n1"
2) "1"
3) "n2"
4) "2"
5) "n3"
6) "3"
127.0.0.1:6379> 

zrevrange key start end:獲取所有的 value,遞減排列,同理也有 withscores 參數

127.0.0.1:6379> zrevrange zset2 0 -1
1) "n4"
2) "n3"
3) "n2"
4) "n1"
127.0.0.1:6379> 

zrangebyscore key 開始score 結束score:獲取 >=開始score  and <=結束score 的 value,遞增排列,同理也有 withscores 參數

zrevrangebyscore key 結束score 開始score:獲取 >=開始score and <=結束score 的value,遞減排列,同理也有 withscores 參數。注意:這里的開始和結束是相反的。

127.0.0.1:6379> zadd zset3 1 n1 2 n2 3 n3 4 n4 5 n5 6 n6 7 n7
(integer) 7
127.0.0.1:6379> zrangebyscore zset3 3 6
1) "n3"
2) "n4"
3) "n5"
4) "n6"
127.0.0.1:6379> zrevrangebyscore zset3 6 3
1) "n6"
2) "n5"
3) "n4"
4) "n3"
127.0.0.1:6379> zrangebyscore zset3 (3 (6
1) "n4"  # 如果在分數前面加上了 (, 那么會不匹配邊界,同理也支持 withscores
2) "n5"
127.0.0.1:6379> zrevrangebyscore zset3 (6 (3
1) "n5"
2) "n4"
127.0.0.1:6379> 

zrem key value1 value2···:移除對應的 value

127.0.0.1:6379> zrem zset3 n1 n2 n3 n4
(integer) 4
127.0.0.1:6379> zrange zset3 0 -1
1) "n5"
2) "n6"
3) "n7"

zcard key:獲取集合的元素個數

127.0.0.1:6379> zcard zset3
(integer) 3
127.0.0.1:6379> 

zcount key 開始分數區間 結束分數區間:獲取集合指定分數區間內的元素個數

127.0.0.1:6379> zcount zset3 6 8
(integer) 2
127.0.0.1:6379> zcount zset3 5 7
(integer) 3
127.0.0.1:6379> 

 

下面看看 Redis 有序集合的底層實現

有序集合是由 ziplist(壓縮列表)或 skiplist(跳躍表)組成的。

1)ziplist

當數據比較少時,有序集合使用的是 ziplist 存儲的,如下代碼所示:

127.0.0.1:6379> zadd my_zset 1 n1 2 n2
(integer) 2
127.0.0.1:6379> object encoding my_zset 
"ziplist"
127.0.0.1:6379> 

從結果可以看出,有序集合把鍵值對存儲在 ziplist 結構中了。 有序集合使用 ziplist 格式存儲必須滿足以下兩個條件:

  • 有序集合保存的元素個數要小於等於 128 個;
  • 有序集合保存的所有元素成員的長度都必須小於等於 64 字節。

如果不能滿足以上兩個條件中的任意一個,有序集合將會使用 skiplist 結構進行存儲。 接下來我們來測試以下,當有序集合中某個元素長度大於 64 字節時會發生什么情況? 代碼如下:

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 元素長度超過64
client.zadd("my_zset2", {"a" * 65: 1})
print(client.object("encoding", "my_zset2"))  # skiplist

# 集合元素超過128個
client.zadd("my_zset3", dict(zip(range(129), range(129))))
print(client.object("encoding", "my_zset3"))  # skiplist

通過以上代碼可以看出,當有序集合保存的元素的長度大於 64 字節、或者元素個數超過128個時,有序集合就會從 ziplist 轉換成為 skiplist。

可以通過配置文件中的 zset-max-ziplist-entries(默認 128)和 zset-max-ziplist-value(默認 64)來設置有序集合使用 ziplist 存儲的臨界值。

2)skiplist

skiplist 數據編碼底層是使用 zset 結構實現的,而 zset 結構中包含了一個字典和一個跳躍表,源碼如下:

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

1. 跳躍表實現原理

跳躍表的結構如下圖所示:

根據以上圖片展示,當我們在跳躍表中查詢值 62 時,執行流程如下:

  • 從最上層開始找,1 比 62 小,在當前層移動到下一個節點進行比較;
  • 27 比 62 小,當前層移動下一個節點比較,然后 100 大於 62,所以以 27 為目標,移動到下一層繼續向后比較;
  • 50 小於 62,繼續向后移動查找,對比 100 大於 62,以 50 為目標,移動到下一層繼續向后比較;
  • 對比 62 等於 62,值被順利找到。

從上面的流程可以看出,跳躍表會先從最上層開始找起,依次向后查找,如果本層的節點大於要找的值,或者本層的節點為 Null 時,以上一個節點為目標,往下移一層繼續向后查找並循環此流程,直到找到該節點並返回,如果對比到最后一個元素仍未找到,則返回 Null。

2. 為什么是跳躍表?而非紅黑樹?

因為跳躍表的性能和紅黑樹基本相近,但卻比紅黑樹更好實現,所以 Redis 的有序集合會選用跳躍表來實現存儲。

使用場景

有序集合的經典使用場景如下:

  • 學生成績排名
  • 粉絲列表,根據關注的先后時間排序

總結

關於有序集合,我們了解到了如下幾點:

  • 有序集合具有唯一性和排序的功能,排序功能是借助分值字段 score 實現的,score 字段不僅可以實現排序功能,還可以實現數據的篩選與過濾的功能。
  • 有序集合是由 壓縮列表 (ziplist) 或跳躍列表 (skiplist) 來存儲的,當元素個數小於 128 個,並且所有元素的值都小於 64 字節時,有序集合會采取 ziplist 來存儲,反之則會用 skiplist 來存儲。
  • skiplist 是從上往下、從前往后進行元素查找的,相比於傳統的普通列表,可能會快很多,因為普通列表只能從前往后依次查找。

使用 Python 操作 Redis 有序集合

下面看看如何使用 Python 操作 Redis 的有序集合。

import redis
 
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")
 
# 1. zadd key score1 value1 score2 value2
# 這里使用字典的方式傳遞,因為 value 不重復,所以作為字典傳遞的話,value 作為鍵、分數作為值
client.zadd("zset1", {"n1": 1, "n2": 2, "n3": 3})
 
# 2. zscore key value
print(client.zscore("zset1", "n1"))  # 1.0
 
# 3. zrange key start end
print(client.zrange("zset1", 0, -1))  # ['n1', 'n2', 'n3']
print(client.zrange("zset1", 0, -1, withscores=True))  # [('n1', 1.0), ('n2', 2.0), ('n3', 3.0)]
 
# 4. zrevrange key start end
print(client.zrevrange("zset1", 0, -1))  # ['n3', 'n2', 'n1']
print(client.zrevrange("zset1", 0, -1, withscores=True))  # [('n3', 3.0), ('n2', 2.0), ('n1', 1.0)]
 
# 5. zrangebyscore key 開始score 結束score
# 6. zrevrangebyscore key 結束score 開始score
print(client.zrangebyscore("zset1", 1, 3))  # ['n1', 'n2', 'n3']
print(client.zrevrangebyscore("zset1", 3, 1))  # ['n3', 'n2', 'n1']
 
# 7. zrem key value1 value2······
client.zrem("zset1", "n1", "n2")
print(client.zrange("zset1", 0, -1))  # ['n3']
 
# 8. zcard key
print(client.zcard("zset1"))  # 1
 
# 9. zcount key 開始分數區間 結束分數區間
client.zadd("zset2", {"n1": 1, "n2": 2, "n3": 3, "n4": 4, "n5": 5})
print(client.zcount("zset2", 1, 4))  # 4

使用 Go 操作 Redis 有序集合

下面看看如何使用 Go 操作 Redis 的有序集合。

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 添加元素,Go-Redis 里面不叫 value、叫 member,不過沒什么區別
    client.ZAdd(ctx, "zset1",
        &redis.Z{Score: 1, Member: "n1"},
        &redis.Z{Score: 2, Member: "n2"},
        &redis.Z{Score: 3, Member: "n3"})

    // 根據 value 獲取 score
    fmt.Println(client.ZScore(ctx, "zset1", "n1").Val())  // 1

    // zrange key start end
    fmt.Println(client.ZRange(ctx, "zset1", 0, -1).Val())  // [n1 n2 n3]
    fmt.Println(client.ZRangeWithScores(ctx, "zset1", 0, -1).Val())  // [{1 n1} {2 n2} {3 n3}]

    // zrevrange key start end
    fmt.Println(client.ZRevRange(ctx, "zset1", 0, -1).Val())  // [n3 n2 n1]
    fmt.Println(client.ZRevRangeWithScores(ctx, "zset1", 0, -1).Val())  // [{3 n3} {2 n2} {1 n1}]

    // zrangebyscore key 開始score 結束score
    // zrevrangebyscore key 結束score 開始score
    fmt.Println(client.ZRangeByScore(ctx, "zset1", &redis.ZRangeBy{Min: "1", Max: "3"}).Val())  // [n1 n2 n3]
    fmt.Println(client.ZRevRangeByScore(ctx, "zset1", &redis.ZRangeBy{Min: "1", Max: "3"}).Val())  // [n3 n2 n1]

    // zrem key value1 value2······
    client.ZRem(ctx, "zset1", "n1", "n2")
    fmt.Println(client.ZRange(ctx, "zset1", 0, -1).Val())  // [n3]

    // zcard key
    fmt.Println(client.ZCard(ctx, "zset1").Val())  // 1

    // zcount key 開始分數區間 結束分數區間
    client.ZAdd(ctx, "zset2",
        &redis.Z{Score: 1, Member: "n1"},
        &redis.Z{Score: 2, Member: "n2"},
        &redis.Z{Score: 3, Member: "n3"},
        &redis.Z{Score: 4, Member: "n4"},
        &redis.Z{Score: 5, Member: "n5"})
    fmt.Println(client.ZCount(ctx, "zset2", "1", "4").Val())  // 4
}

 

以上就是 Redis 基本的數據結構、相關命令行操作,以及 Python 和 Go 的 api 操作。最開始我們就說過,Redis 的數據結構是一大亮點,不僅豐富,而且針對不同的數據量有着不同的實現。當然我們說 Redis 不止上面這五種,但這五種絕對是最常用的,至於 Redis 更高級的數據結構以及其它用法我們后面再慢慢說。

Redis 的 String 你真的用對了嗎?

介紹完這幾種數據結構之后,相信你已經知道它們的使用場景了,但有些時候我們不光要考慮使用上的便捷性,還要考慮內存的開銷。比如某個場景下使用 String 是非常自然的選擇,但當你深入思考之后會發現當數據量非常大的時候 String 並不適合,當然這是一個正常現象,因為很多架構設計也是如此,本來非常完美的架構,但隨着數據量或者業務體量的增大而不斷地暴露出各種問題。那么下面我們就從內存使用、資源利用率的角度來分析一下,不同的數據結構應該用在什么地方,首先是 String。

假設有一個圖片存儲系統,我們往里面上傳的每一張圖片,存儲系統都會為其生成一個長度 10 的字符串,作為該圖片的 "圖片存儲對象 ID",根據這個 "圖片存儲對象 ID" 即可從存儲系統中下載指定的圖片。但是上傳到存儲系統的圖片本身就自帶一個 ID("圖片 ID"),也是長度為 10 的字符串,后續再訪問圖片時都是根據 "圖片 ID" 進行訪問的。因此我們就需要將上傳圖片時自帶的 "圖片 ID" 和存儲完之后自動生成的 "圖片存儲對象 ID" 都保存起來,並且后續傳遞 "圖片 ID" 時能夠快速查找到對應的 "圖片存儲對象 ID",然后再將圖片下載下來。

那么我們應該用 Redis 的哪一個數據結構進行存儲呢?首先 String 肯定可以,直接將 key 作為 "圖片 ID",value 作為 "圖片存儲對象 ID",直接就可以根據 key 找到 value。

# 比如某張圖片的 "圖片 ID" 是 100b68d6f3,"圖片存儲對象 ID" 是 e7e4eac9i3
set 100b68d6f3 e7e4eac9i3
# 這里我們只關注兩個 ID 之間的關系,至於圖片本身我們不需要關心

這個做法從設計上來講是沒有任何問題的,但如果你的圖片非常多,比如上億,那么會發現 Redis 內存的使用量會非常大,那么在生成 RDB 的時候就會響應變慢。所以很多設計從一開始都是沒有問題的,但數據量一大,問題就會凸顯出來。所以 String 雖然很方便,但短板就是在保存數據時所消耗的內存空間較多,下面就來解釋一下原因。

為什么 String 類型開銷大

除了記錄實際數據,String 類型還需要額外的內存空間記錄數據長度、空間使用等信息,這些信息也叫作元數據。當實際保存的數據較小時,元數據的空間開銷就顯得比較大了,有點 "喧賓奪主" 的意思。那么 String 到底是怎么保存數據的呢?

首先該類型雖然叫 String,但它也可以保存整數,當你保存 64 位有符號整數時,String 類型會把它保存為一個 8 字節的 Long 類型整數,這種保存方式通常也叫作 int 編碼方式。

但是,當你保存的數據中包含字符時,String 類型就會用簡單動態字符串(Simple Dynamic String,SDS)結構體來保存,如下圖所示:

  • len:占 4 個字節,表示 buf 中已存儲字符的長度
  • alloc:buf 的總長度
  • buf:字符數組,自帶一個 \0

可以看到在 SDS 中,buf 保存實際數據,而 len 和 alloc 本身其實是 SDS 結構體的額外開銷。但是對於 String 類型來說,除了 SDS 的額外開銷,還有一個來自於 RedisObject 結構體的開銷。因為 Redis 的數據類型有很多,而不同數據類型都有些相同的元數據要記錄(比如最后一次訪問的時間、被引用的次數等),所以 Redis 會用一個 RedisObject 結構體來統一記錄這些元數據,同時指向實際數據。

Redis 中的數據實際上就是一個 RedisObject 實例,RedisObject 里面記錄了數據的元信息(8 字節),並存儲了一個指針(8 字節),這個指針指向的內存才是具體數據類型的實際數據所在,例如指向 String 類型的 RedisObject 存儲的指針指向的就是 SDS 結構體。關於 RedisObject 的具體結構細節,我們會在后面詳細介紹,現在只要了解它的基本結構和元數據開銷就行了。

然而為了節省內存空間,Redis 還對 Long 類型整數和 SDS 的內存布局做了專門的設計。

當保存的是 Long 類型整數時,RedisObject 中的指針就直接賦值為整數數據了,這樣就不用額外的指針再指向整數了,節省了指針的空間開銷。

當保存的是字符串數據,並且字符串小於等於 44 字節時,RedisObject 中的元數據、指針和 SDS 是一塊連續的內存區域,這樣就可以避免內存碎片。這種布局方式也被稱為 embstr 編碼方式。

當保存的是字符串,並且字符串大於 44 字節時,SDS 的數據量就開始變多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是會給 SDS 分配獨立的空間,並用指針指向 SDS 結構。這種布局方式被稱為 raw 編碼模式。

好了現在我們可以計算創建一個 String 類型的鍵值對所需要的額外開銷了,首先元數據 8 字節、指針 8 字節,SDS 中的 len 占 4 字節,alloc 占 4 字節,此時就已經有 24 字節的額外開銷了。當然 buf 的長度一般會比實際存儲的字符串個數要多一些,因為我們字符串可以動態追加,所以 Redis 的內存分配庫 jemalloc 在分配內存時,是按照 2 的冪次方進行進行分配的,比如:2、4、8、16、32、64、128,......。然后根據我們申請的字節數 N,找一個比 N 大、但是最接近 N 的數作為分配的空間,假設申請的字節數為 1023,那么實際會申請 1024,如果申請的是 1024,那么實際會申請 2048。所以長度為 10 的 ID,實際上會申請 16 個字節,因此加上 6 總共就有 30 字節的額外開銷。

但是注意,還沒完,我們上面說 Redis 會用一個全局哈希表來存儲所有的鍵值對,哈希表的每一項是一個 dickEntry 結構體,dictEntry 里面有三個指針:key(指向具體的鍵)、value(指向具體的 value)、next(指向下一個 dictEntry)。

然后三個指針又用了 24 個字節,所以總共有 54 字節的額外開銷,相信到這里你應該明白為什么 String 在面對這種場景會有如此嚴重的內存浪費了,實際數據總共 10 字節,但是額外空間就占了 54 字節,所以盡管 String 用起來很方便,但是在數量非常大的時候它並不是一個好的選擇。

用什么數據結構可以節省內存?

既然 String 浪費內存嚴重,那么我們應該使用什么結構來應對當前這種場景呢?

Redis 有一種底層數據結構,叫壓縮列表(ziplist),我們上面說過的,這是一種非常節省內存的結構。我們先回顧下壓縮列表的構成。表頭有三個字段 zlbytes、zltail 和 zllen,分別表示列表長度、壓縮列表尾元素相對於起始元素地址的偏移量、以及列表中的 entry 個數。壓縮列表尾還有一個 zlend,表示列表結束。

壓縮列表之所以能節省內存,就在於它是用一系列連續的 entry 保存數據,每個 entry 的元數據包括下面幾部分。

  • prev_len:,表示前一個 entry 的長度,prev_len 有兩種取值情況:1 字節或 5 字節。當 prev_len 占 1 字節時,表示上一個 entry 的長度小於 254 字節。雖然 1 字節的值能表示的數值范圍是 0 到 255,但是壓縮列表中 zlend 的取值默認是 255,因此,就默認用 255 表示整個壓縮列表的結束,其他表示長度的地方就不能再用 255 這個值了。所以,當上一個 entry 長度小於 254 字節時,prev_len 占為 1 字節,否則,就占為 5 字節。
  • encoding:表示編碼方式,1 字節
  • len:表示自身長度,4 字節
  • content:保存實際數據

這些 entry 會挨個兒放置在內存中,不需要再用額外的指針進行連接,這樣就可以節省指針所占用的空間。我們以剛才的保存圖片存儲對象 ID 為例,來分析一下壓縮列表是如何節省內存空間的。

每個 entry 保存一個圖片存儲對象 ID(10 字節),此時每個 entry 的 prev_len 只需要 1 個字節就行,因為每個 entry 的前一個 entry 長度都只有 10 字節,小於 254 字節。這樣一來,一個圖片的存儲對象 ID 所占用的內存大小是 16 字節(1+4+1+10=16)。

Redis 基於壓縮列表實現了 List、Hash 和 Sorted Set 這樣的集合類型,這樣做的最大好處就是節省了 dictEntry 的開銷。當用 String 類型時,一個鍵值對就有一個 dictEntry,但采用集合類型時,一個 key 就對應一個集合的數據,能保存的數據多了很多,但也只用了一個 dictEntry,這樣就節省了內存。

只不過這個方案聽起來很好,但還存在一個問題:在用集合類型保存鍵值對時,一個鍵對應了一個集合的數據,但是在我們的場景中,一個 "圖片 ID" 只對應一個 "圖片存儲對象 ID",我們該怎么用集合類型呢?換句話說,在一個鍵對應一個值(也就是單值鍵值對)的情況下,我們該怎么用集合類型來保存這種單值鍵值對呢?

使用 Hash 保存單值的鍵值對

在保存單值的鍵值對時,可以采用基於 Hash 類型的二級編碼方法。這里說的二級編碼,就是把一個單值的數據拆分成兩部分,前一部分作為 Hash 集合的 key,后一部分作為 Hash 集合的 value,這樣一來,我們就可以把單值數據保存到 Hash 集合中了。

我們可以把圖片 ID 的前 7 位作為 Hash 類型的鍵,把圖片 ID 的最后 3 位和圖片存儲對象 ID 分別作為 Hash 類型值中的 key 和 value,然后通過 info memory 查看的內存使用的話,會發現只有使用 String 的四分之一,因此滿足了節省內存空間的需要。

但你可能也會有疑惑:二級編碼一定要把 "圖片 ID" 的前 7 位作為 Hash 類型的鍵,把最后 3 位作為 Hash 類型值中的 key 嗎?

其實,二級編碼方法中采用的 ID 長度是有講究的,我們說過 Redis Hash 類型的兩種底層實現結構,分別是壓縮列表和哈希表。壓縮列表中存了兩個閾值,當數據量沒有達到這兩個閾值時,使用壓縮列表存儲,否則就使用哈希表存儲,而這里的閾值由通過以下兩個配置決定:

  • hash-max-ziplist-entries:表示用壓縮列表保存時哈希集合中的最大元素個數
  • hash-max-ziplist-value:表示用壓縮列表保存時哈希集合中單個元素的最大長度

一旦從壓縮列表轉為了哈希表,Hash 類型就會一直用哈希表進行保存,而不會再轉回壓縮列表了。但在節省內存空間方面,哈希表就沒有壓縮列表那么高效了。所以為了能充分使用壓縮列表的精簡內存布局,我們一般要控制保存在 Hash 集合中的元素個數。因此在剛才的二級編碼中,我們只用圖片 ID 最后 3 位作為 Hash 集合的 key,也就保證了 Hash 集合的元素個數不超過 1000,同時,我們把 hash-max-ziplist-entries 設置為 1000,這樣一來,Hash 集合就可以一直使用壓縮列表來節省內存空間了。

當然以上只能說是根據當前業務進行抽象而設計出的方案,它並不是一個通用的辦法,這里只是為了更好的配合我們的主題,也就是在極端情況下 String 的表現。至於工作中,還要根據自身業務靈活變通,但絕大部分情況下,只要稍微一分析都能選擇出最合適的數據結構。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM