Redis(1)——5種基本資料結構
一、Redis 簡介
"Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker." —— Redis是一個開放原始碼(BSD許可)的記憶體中資料結構儲存,用作資料庫,快取和訊息代理。 (摘自官網)
Redis 是一個開源,高階的鍵值儲存和一個適用的解決方案,用於構建高效能,可擴充套件的 Web 應用程式。Redis 也被作者戲稱為 資料結構伺服器 ,這意味著使用者可以通過一些命令,基於帶有 TCP 套接字的簡單 伺服器-客戶端
Redis 的優點
以下是 Redis 的一些優點:
- 異常快 - Redis 非常快,每秒可執行大約 110000 次的設定(SET)操作,每秒大約可執行 81000 次的讀取/獲取(GET)操作。
- 支援豐富的資料型別 - Redis 支援開發人員常用的大多數資料型別,例如列表,集合,排序集和雜湊等等。這使得 Redis 很容易被用來解決各種問題,因為我們知道哪些問題可以更好使用地哪些資料型別來處理解決。
- 操作具有原子性 - 所有 Redis 操作都是原子操作,這確保如果兩個客戶端併發訪問,Redis 伺服器能接收更新的值。
- 多實用工具 - Redis 是一個多實用工具,可用於多種用例,如:快取,訊息佇列(Redis 本地支援釋出/訂閱),應用程式中的任何短期資料,例如,web應用程式中的會話,網頁命中計數等。
Redis 的安裝
這一步比較簡單,你可以在網上搜到許多滿意的教程,這裡就不再贅述。
給一個菜鳥教程的安裝教程用作參考:https://www.runoob.com/redis/redis-install.html
測試本地 Redis 效能
當你安裝完成之後,你可以先執行 redis-server
讓 Redis 啟動起來,然後執行命令 redis-benchmark -n 100000 -q
來檢測本地同時執行 10 萬個請求時的效能:
當然不同電腦之間由於各方面的原因會存在效能差距,這個測試您可以權當是一種 「樂趣」 就好。
二、Redis 五種基本資料結構
Redis 有 5 種基礎資料結構,它們分別是:string(字串)、list(列表)、hash(字典)、set(集合) 和 zset(有序集合)。這 5 種是 Redis 相關知識中最基礎、最重要的部分,下面我們結合原始碼以及一些實踐來給大家分別講解一下。
1)字串 string
Redis 中的字串是一種 動態字串,這意味著使用者可以修改,它的底層實現有點類似於 Java 中的 ArrayList,有一個字元陣列,從原始碼的 sds.h/sdshdr 檔案 中可以看到 Redis 底層對於字串的定義 SDS,即 Simple Dynamic String 結構:
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
你會發現同樣一組結構 Redis 使用泛型定義了好多次,為什麼不直接使用 int 型別呢?
因為當字串比較短的時候,len 和 alloc 可以使用 byte 和 short 來表示,Redis 為了對記憶體做極致的優化,不同長度的字串使用不同的結構體來表示。
SDS 與 C 字串的區別
為什麼不考慮直接使用 C 語言的字串呢?因為 C 語言這種簡單的字串表示方式 不符合 Redis 對字串在安全性、效率以及功能方面的要求。我們知道,C 語言使用了一個長度為 N+1 的字元陣列來表示長度為 N 的字串,並且字元陣列最後一個元素總是 '\0'
。(下圖就展示了 C 語言中值為 "Redis" 的一個字元陣列)
這樣簡單的資料結構可能會造成以下一些問題:
- 獲取字串長度為 O(N) 級別的操作 → 因為 C 不儲存陣列的長度,每次都需要遍歷一遍整個陣列;
- 不能很好的杜絕 緩衝區溢位/記憶體洩漏 的問題 → 跟上述問題原因一樣,如果執行拼接 or 縮短字串的操作,如果操作不當就很容易造成上述問題;
- C 字串 只能儲存文字資料 → 因為 C 語言中的字串必須符合某種編碼(比如 ASCII),例如中間出現的
'\0'
可能會被判定為提前結束的字串而識別不了;
我們以追加字串的操作舉例,Redis 原始碼如下:
/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
* end of the specified sds string 's'.
*
* After the call, the passed sds string is no longer valid and all the
* references must be substituted with the new pointer returned by the call. */
sds sdscatlen(sds s, const void *t, size_t len) {
// 獲取原字串的長度
size_t curlen = sdslen(s);
// 按需調整空間,如果容量不夠容納追加的內容,就會重新分配位元組陣列並複製原字串的內容到新陣列中
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL; // 記憶體不足
memcpy(s+curlen, t, len); // 追加目標字串到位元組陣列中
sdssetlen(s, curlen+len); // 設定追加後的長度
s[curlen+len] = '\0'; // 讓字串以 \0 結尾,便於除錯列印
return s;
}
- 注:Redis 規定了字串的長度不得超過 512 MB。
對字串的基本操作
安裝好 Redis,我們可以使用 redis-cli
來對 Redis 進行命令列的操作,當然 Redis 官方也提供了線上的偵錯程式,你也可以在裡面敲入命令進行操作:http://try.redis.io/#run
設定和獲取鍵值對
> SET key value
OK
> GET key
"value"
正如你看到的,我們通常使用 SET
和 GET
來設定和獲取字串值。
值可以是任何種類的字串(包括二進位制資料),例如你可以在一個鍵下儲存一張 .jpeg
圖片,只需要注意不要超過 512 MB 的最大限度就好了。
當 key 存在時,SET
命令會覆蓋掉你上一次設定的值:
> SET key newValue
OK
> GET key
"newValue"
另外你還可以使用 EXISTS
和 DEL
關鍵字來查詢是否存在和刪除鍵值對:
> EXISTS key
(integer) 1
> DEL key
(integer) 1
> GET key
(nil)
批量設定鍵值對
> SET key1 value1
OK
> SET key2 value2
OK
> MGET key1 key2 key3 # 返回一個列表
1) "value1"
2) "value2"
3) (nil)
> MSET key1 value1 key2 value2
> MGET key1 key2
1) "value1"
2) "value2"
過期和 SET 命令擴充套件
可以對 key 設定過期時間,到時間會被自動刪除,這個功能常用來控制快取的失效時間。(過期可以是任意資料結構)
> SET key value1
> GET key
"value1"
> EXPIRE name 5 # 5s 後過期
... # 等待 5s
> GET key
(nil)
等價於 SET
+ EXPIRE
的 SETNX
命令:
> SETNX key value1
... # 等待 5s 後獲取
> GET key
(nil)
> SETNX key value1 # 如果 key 不存在則 SET 成功
(integer) 1
> SETNX key value1 # 如果 key 存在則 SET 失敗
(integer) 0
> GET key
"value" # 沒有改變
計數
如果 value 是一個整數,還可以對它使用 INCR
命令進行 原子性 的自增操作,這意味著及時多個客戶端對同一個 key 進行操作,也決不會導致競爭的情況:
> SET counter 100
> INCR count
(interger) 101
> INCRBY counter 50
(integer) 151
返回原值的 GETSET 命令
對字串,還有一個 GETSET
比較讓人覺得有意思,它的功能跟它名字一樣:為 key 設定一個值並返回原值:
> SET key value
> GETSET key value1
"value"
這可以對於某一些需要隔一段時間就統計的 key 很方便的設定和檢視,例如:系統每當由使用者進入的時候你就是用 INCR
命令操作一個 key,當需要統計時候你就把這個 key 使用 GETSET
命令重新賦值為 0,這樣就達到了統計的目的。
2)列表 list
Redis 的列表相當於 Java 語言中的 LinkedList,注意它是連結串列而不是陣列。這意味著 list 的插入和刪除操作非常快,時間複雜度為 O(1),但是索引定位很慢,時間複雜度為 O(n)。
我們可以從原始碼的 adlist.h/listNode
來看到對其的定義:
/* Node, List, and Iterator are the only data structures used currently. */
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
typedef struct listIter {
listNode *next;
int direction;
} listIter;
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;
可以看到,多個 listNode 可以通過 prev
和 next
指標組成雙向連結串列:
雖然僅僅使用多個 listNode 結構就可以組成連結串列,但是使用 adlist.h/list
結構來持有連結串列的話,操作起來會更加方便:
連結串列的基本操作
LPUSH
和RPUSH
分別可以向 list 的左邊(頭部)和右邊(尾部)新增一個新元素;LRANGE
命令可以從 list 中取出一定範圍的元素;LINDEX
命令可以從 list 中取出指定下表的元素,相當於 Java 連結串列操作中的get(int index)
操作;
示範:
> rpush mylist A
(integer) 1
> rpush mylist B
(integer) 2
> lpush mylist first
(integer) 3
> lrange mylist 0 -1 # -1 表示倒數第一個元素, 這裡表示從第一個元素到最後一個元素,即所有
1) "first"
2) "A"
3) "B"
list 實現佇列
佇列是先進先出的資料結構,常用於訊息排隊和非同步邏輯處理,它會確保元素的訪問順序:
> RPUSH books python java golang
(integer) 3
> LPOP books
"python"
> LPOP books
"java"
> LPOP books
"golang"
> LPOP books
(nil)
list 實現棧
棧是先進後出的資料結構,跟佇列正好相反:
> RPUSH books python java golang
> RPOP books
"golang"
> RPOP books
"java"
> RPOP books
"python"
> RPOP books
(nil)
3)字典 hash
Redis 中的字典相當於 Java 中的 HashMap,內部實現也差不多類似,都是通過 "陣列 + 連結串列" 的鏈地址法來解決部分 雜湊衝突,同時這樣的結構也吸收了兩種不同資料結構的優點。原始碼定義如 dict.h/dictht
定義:
typedef struct dictht {
// 雜湊表陣列
dictEntry **table;
// 雜湊表大小
unsigned long size;
// 雜湊表大小掩碼,用於計算索引值,總是等於 size - 1
unsigned long sizemask;
// 該雜湊表已有節點的數量
unsigned long used;
} dictht;
typedef struct dict {
dictType *type;
void *privdata;
// 內部有兩個 dictht 結構
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
table
屬性是一個數組,陣列中的每個元素都是一個指向 dict.h/dictEntry
結構的指標,而每個 dictEntry
結構儲存著一個鍵值對:
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下個雜湊表節點,形成連結串列
struct dictEntry *next;
} dictEntry;
可以從上面的原始碼中看到,實際上字典結構的內部包含兩個 hashtable,通常情況下只有一個 hashtable 是有值的,但是在字典擴容縮容時,需要分配新的 hashtable,然後進行 漸進式搬遷 (下面說原因)。
漸進式 rehash
大字典的擴容是比較耗時間的,需要重新申請新的陣列,然後將舊字典所有連結串列中的元素重新掛接到新的陣列下面,這是一個 O(n) 級別的操作,作為單執行緒的 Redis 很難承受這樣耗時的過程,所以 Redis 使用 漸進式 rehash 小步搬遷:
漸進式 rehash 會在 rehash 的同時,保留新舊兩個 hash 結構,如上圖所示,查詢時會同時查詢兩個 hash 結構,然後在後續的定時任務以及 hash 操作指令中,循序漸進的把舊字典的內容遷移到新字典中。當搬遷完成了,就會使用新的 hash 結構取而代之。
擴縮容的條件
正常情況下,當 hash 表中 元素的個數等於第一維陣列的長度時,就會開始擴容,擴容的新陣列是 原陣列大小的 2 倍。不過如果 Redis 正在做 bgsave(持久化命令)
,為了減少記憶體也得過多分離,Redis 儘量不去擴容,但是如果 hash 表非常滿了,達到了第一維陣列長度的 5 倍了,這個時候就會 強制擴容。
當 hash 表因為元素逐漸被刪除變得越來越稀疏時,Redis 會對 hash 表進行縮容來減少 hash 表的第一維陣列空間佔用。所用的條件是 元素個數低於陣列長度的 10%,縮容不會考慮 Redis 是否在做 bgsave
。
字典的基本操作
hash 也有缺點,hash 結構的儲存消耗要高於單個字串,所以到底該使用 hash 還是字串,需要根據實際情況再三權衡:
> HSET books java "think in java" # 命令列的字串如果包含空格則需要使用引號包裹
(integer) 1
> HSET books python "python cookbook"
(integer) 1
> HGETALL books # key 和 value 間隔出現
1) "java"
2) "think in java"
3) "python"
4) "python cookbook"
> HGET books java
"think in java"
> HSET books java "head first java"
(integer) 0 # 因為是更新操作,所以返回 0
> HMSET books java "effetive java" python "learning python" # 批量操作
OK
4)集合 set
Redis 的集合相當於 Java 語言中的 HashSet,它內部的鍵值對是無序、唯一的。它的內部實現相當於一個特殊的字典,字典中所有的 value 都是一個值 NULL。
集合 set 的基本使用
由於該結構比較簡單,我們直接來看看是如何使用的:
> SADD books java
(integer) 1
> SADD books java # 重複
(integer) 0
> SADD books python golang
(integer) 2
> SMEMBERS books # 注意順序,set 是無序的
1) "java"
2) "python"
3) "golang"
> SISMEMBER books java # 查詢某個 value 是否存在,相當於 contains
(integer) 1
> SCARD books # 獲取長度
(integer) 3
> SPOP books # 彈出一個
"java"
5)有序列表 zset
這可能使 Redis 最具特色的一個數據結構了,它類似於 Java 中 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以為每個 value 賦予一個 score 值,用來代表排序的權重。
它的內部實現用的是一種叫做 「跳躍表」 的資料結構,由於比較複雜,所以在這裡簡單提一下原理就好了:
想象你是一家創業公司的老闆,剛開始只有幾個人,大家都平起平坐。後來隨著公司的發展,人數越來越多,團隊溝通成本逐漸增加,漸漸地引入了組長制,對團隊進行劃分,於是有一些人又是員工又有組長的身份。
再後來,公司規模進一步擴大,公司需要再進入一個層級:部門。於是每個部門又會從組長中推舉一位選出部長。
跳躍表就類似於這樣的機制,最下面一層所有的元素都會串起來,都是員工,然後每隔幾個元素就會挑選出一個代表,再把這幾個代表使用另外一級指標串起來。然後再在這些代表裡面挑出二級代表,再串起來。最終形成了一個金字塔的結構。
想一下你目前所在的地理位置:亞洲 > 中國 > 某省 > 某市 > ....,就是這樣一個結構!
有序列表 zset 基礎操作
> ZADD books 9.0 "think in java"
> ZADD books 8.9 "java concurrency"
> ZADD books 8.6 "java cookbook"
> ZRANGE books 0 -1 # 按 score 排序列出,引數區間為排名範圍
1) "java cookbook"
2) "java concurrency"
3) "think in java"
> ZREVRANGE books 0 -1 # 按 score 逆序列出,引數區間為排名範圍
1) "think in java"
2) "java concurrency"
3) "java cookbook"
> ZCARD books # 相當於 count()
(integer) 3
> ZSCORE books "java concurrency" # 獲取指定 value 的 score
"8.9000000000000004" # 內部 score 使用 double 型別進行儲存,所以存在小數點精度問題
> ZRANK books "java concurrency" # 排名
(integer) 1
> ZRANGEBYSCORE books 0 8.91 # 根據分值區間遍歷 zset
1) "java cookbook"
2) "java concurrency"
> ZRANGEBYSCORE books -inf 8.91 withscores # 根據分值區間 (-∞, 8.91] 遍歷 zset,同時返回分值。inf 代表 infinite,無窮大的意思。
1) "java cookbook"
2) "8.5999999999999996"
3) "java concurrency"
4) "8.9000000000000004"
> ZREM books "java concurrency" # 刪除 value
(integer) 1
> ZRANGE books 0 -1
1) "java cookbook"
2) "think in java"
擴充套件/相關閱讀
- 阿里雲 Redis 開發規範 - https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px
- 為什麼要防止 bigkey? - https://mp.weixin.qq.com/s?__biz=Mzg2NTEyNzE0OA==&mid=2247483677&idx=1&sn=5c320b46f0e06ce9369a29909d62b401&chksm=ce5f9e9ef928178834021b6f9b939550ac400abae5c31e1933bafca2f16b23d028cc51813aec&scene=21#wechat_redirect
- Redis【入門】就這一篇! - https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/
參考資料
- 《Redis 設計與實現》 - http://redisbook.com/
- 【官方文件】Redis 資料型別介紹 - http://www.redis.cn/topics/data-types-intro.html
- 《Redis 深度歷險》 - https://book.douban.com/subject/30386804/
- 阿里雲 Redis 開發規範 - https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px
- Redis 快速入門 - 易百教程 - https://www.yiibai.com/redis/redis_quick_guide.html
- Redis【入門】就這一篇! - https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/
- 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
- 個人公眾號 :wmyskxz,堅持原創輸出,下方掃碼關注,2020,與您共同成長!
非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!
創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見