五種常見數據結構與使用方法
一:字符串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 爲了對內存做極致的優化,不同長度的字符串使用不同的結構體來表示。
/* 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。
對字符串的基本操作
MSET/MGET : 批量設置/獲取鍵值對
MSET key1 value1 key2 value2
127.0.0.1:6379> set a 11
OK
127.0.0.1:6379> get a
"11"
127.0.0.1:6379> mset b 22 c 33 //MSET key1 value1 key2 value2
OK
127.0.0.1:6379> mget a b c
1) "11"
2) "22"
3) "33"
127.0.0.1:6379> mset a aa b 11 //mset key值存在則覆蓋
OK
127.0.0.1:6379> mget a b c
1) "aa"
2) "11"
3) "33"
127.0.0.1:6379> set a 11 // set key值存在則覆蓋
OK
127.0.0.1:6379> mget a b c
1) "11"
2) "11"
3) "33"
EXISTS: 判斷鍵值對是否存在
127.0.0.1:6379> mget a b c
1) "11"
2) "11"
3) "33"
127.0.0.1:6379> exists a b c
(integer) 3
127.0.0.1:6379> exists a b
(integer) 2
127.0.0.1:6379> exists a b e
(integer) 2
DEL: 刪除鍵值對
127.0.0.1:6379> del a b
(integer) 2
127.0.0.1:6379> exists a b
(integer) 0
EXPIRE :設置過期時間(單位秒)
127.0.0.1:6379> expire c 10
(integer) 1
127.0.0.1:6379> get c
"33"
127.0.0.1:6379> get c
(nil)
SET+EXISTS 等價於SETNX(SET if Not eXists)命令(不存在則創建)
127.0.0.1:6379> get a
"12"
127.0.0.1:6379> setnx a bbb
(integer) 0
127.0.0.1:6379> get a
"12"
127.0.0.1:6379> setnx b bbb
(integer) 1
127.0.0.1:6379> mget a b
1) "12"
2) "bbb"
如果 value 是一個整數,還可以對它使用 INCR 命令進行 原子性 的自增操作,這意味着及時多個客戶端對同一個 key 進行操作,也決不會導致競爭的情況:
INCR:原子自增(整數)
127.0.0.1:6379> mget a b
1) "12"
2) "bbb"
127.0.0.1:6379> incr a
(integer) 13
127.0.0.1:6379> incr b
(error) ERR value is not an integer or out of range
127.0.0.1:6379> incr a 10
(error) ERR wrong number of arguments for 'incr' command
127.0.0.1:6379> incrby a 10
(integer) 23
GETSET:爲 key 設置一個值並返回原值
127.0.0.1:6379> get a
"23"
127.0.0.1:6379> getset a aa
"23"
127.0.0.1:6379> get a
"aa"
二:列表(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 的左邊(頭部)和右邊(尾部)添加一個新元素;
- LPOP和RPOP:分別可以向 list 的左邊(頭部)和右邊(尾部)取出一個新元素;
- LRANGE 命令可以從 list 中取出一定範圍的元素;
- LINDEX 命令可以從 list 中取出指定下表的元素,相當於 Java 鏈表操作中的 get(int index) 操作;
127.0.0.1:6379> lpush firstList aa
(integer) 1
127.0.0.1:6379> lpush firstList bb // 在頭部添加元素
(integer) 2
127.0.0.1:6379> lpush firstList bb cc
(integer) 4
127.0.0.1:6379> lrange firstList 0 -1
1) "cc"
2) "bb"
3) "bb"
4) "aa"
127.0.0.1:6379> rpush firstList ee // 在尾部添加元素
(integer) 5
127.0.0.1:6379> lrange firstList 0 -1
1) "cc"
2) "bb"
3) "bb"
4) "aa"
5) "ee"
list 實現隊列
隊列是先進先出的數據結構,常用於消息排隊和異步邏輯處理,它會確保元素的訪問順序:
127.0.0.1:6379> rpush nodeone aa bb cc dd
(integer) 4
127.0.0.1:6379> lrange nodeone 0 -1
1) "aa"
2) "bb"
3) "cc"
4) "dd"
127.0.0.1:6379> lpop nodeone
"aa"
127.0.0.1:6379> lpop nodeone
"bb"
127.0.0.1:6379> lpop nodeone
"cc"
127.0.0.1:6379> lpop nodeone
"dd"
127.0.0.1:6379> lrange nodeone 0 -1
(empty list or set)
list 實現棧
棧是先進後出的數據結構,跟隊列正好相反:
127.0.0.1:6379> rpush nodetwo 11 22 33
(integer) 3
127.0.0.1:6379> lrange nodetwo 0 -1
1) "11"
2) "22"
3) "33"
127.0.0.1:6379> rpop nodetwo
"33"
127.0.0.1:6379> rpop nodetwo
"22"
127.0.0.1:6379> rpop nodetwo
"11"
127.0.0.1:6379> rpop nodetwo
(nil)
127.0.0.1:6379> lrange nodetwo 0 -1
(empty list or set)
三:字典(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 還是字符串,需要根據實際情況再三權衡:
127.0.0.1:6379> hset books java "111 aaa"
(integer) 1
127.0.0.1:6379> hmset books java "java" hashlearn "learn hash" // 批量設置
OK
127.0.0.1:6379> hmget books java hashlearn
1) "java"
2) "learn hash"
127.0.0.1:6379> hgetall books
1) "java"
2) "java"
3) "hashlearn"
4) "learn hash"
四:集合 set
Redis 的集合相當於 Java 語言中的 HashSet,它內部的鍵值對是無序、唯一的。它的內部實現相當於一個特殊的字典,字典中所有的 value 都是一個值 NULL。
集合 set 的基本使用
由於該結構比較簡單,我們直接來看看是如何使用的:
sadd: 添加元素
smembers: 亂序輸出集合
sismember:某元素是否存在
scard:集合長度
spop:彈出一個元素
127.0.0.1:6379> sadd setone aa
(integer) 1
127.0.0.1:6379> sadd setone aa bb cc dd
(integer) 3
127.0.0.1:6379> smembers setone
1) "dd"
2) "cc"
3) "bb"
4) "aa"
127.0.0.1:6379> sismember setone aa
(integer) 1
127.0.0.1:6379> scard setone
(integer) 4
127.0.0.1:6379> spop setone
"bb"
127.0.0.1:6379> smembers setone
1) "dd"
2) "cc"
3) "aa"
五:有序列表 zset
這可能使 Redis 最具特色的一個數據結構了,它類似於 Java 中 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以爲每個 value 賦予一個 score 值,用來代表排序的權重。
它的內部實現用的是一種叫做 「跳躍表」 的數據結構,由於比較複雜,所以在這裏簡單提一下原理就好了:(後續詳細講解)
想象你是一家創業公司的老闆,剛開始只有幾個人,大家都平起平坐。後來隨着公司的發展,人數越來越多,團隊溝通成本逐漸增加,漸漸地引入了組長制,對團隊進行劃分,於是有一些人又是員工又有組長的身份。
再後來,公司規模進一步擴大,公司需要再進入一個層級:部門。於是每個部門又會從組長中推舉一位選出部長。
跳躍表就類似於這樣的機制,最下面一層所有的元素都會串起來,都是員工,然後每隔幾個元素就會挑選出一個代表,再把這幾個代表使用另外一級指針串起來。然後再在這些代表裏面挑出二級代表,再串起來。最終形成了一個金字塔的結構。
想一下你目前所在的地理位置:亞洲 > 中國 > 某省 > 某市 > ....,就是這樣一個結構!
有序列表 zset 基礎操作
zadd:添加 格式:zadd 表名 權重 元素值
zrange:按權重順序輸出
zrevrange:按權重逆序輸出
zcard:有序列表長度
zscore:元素權重
zrank:元素排名
zrangebyscore:按權重範圍順序進行輸出
zrem:刪除元素
127.0.0.1:6379> zadd book 8 aa 9 bb 10 cc
(integer) 3
127.0.0.1:6379> zrange book 0 -1
1) "aa"
2) "bb"
3) "cc"
127.0.0.1:6379> zrevrange book 0 -1
1) "cc"
2) "bb"
3) "aa"
127.0.0.1:6379> zcard book
(integer) 3
127.0.0.1:6379> zscore book aa
"8"
127.0.0.1:6379> zrank book bb
(integer) 1
127.0.0.1:6379> zrank book aa
(integer) 0
127.0.0.1:6379> zrangebyscore book 0 9
1) "aa"
2) "bb"
//# 根據分值區間 (-∞, 8.91] 遍歷 zset,同時返回分值。
//# inf 代表 infinite,無窮大的意思。
127.0.0.1:6379> zrangebyscore book -inf 9 withscores
1) "aa"
2) "8"
3) "bb"
4) "9"
127.0.0.1:6379> zrem book aa
(integer) 1
127.0.0.1:6379> zrange book 0 -1
1) "bb"
2) "cc"
你們的老婆來了!