前言
- redis效能為什麼這麼出色?它與其他快取中介軟體有什麼區別?
- redis底層使用了哪些資料結構支撐它如此高效的效能?
- 內部豐富的資料型別底層為什麼都使用至少兩種資料結構實現?分別是什麼?
- 如果合理的使用redis才能發揮它最大的優勢?
學習完《redis設計與實現》前面關於資料結構與物件的章節,以上問題都能得到解答。你也能瞭解到redis作者如此的煞費苦心設計了這麼多豐富的資料結構,目的就是優化記憶體。學完這些內容,在使用redis的過程中,也會合理的使用以適應它內部的特點。當然新版本的redis支援了更多更豐富的特性,該書基於redis3版本,還沒有涉及到那些內容。
《redis設計與實現》這本書非常淺顯易懂,作者黃建巨集老師,90後。另外還是《redis實戰》的譯者
另一篇可參考《redis設計與實現》2-資料庫實現篇
概述
特點
- c語言開發,效能出色,純記憶體操作,每秒可處理超過10w讀寫(QPS)
- 多種資料結構,單個最大限制可到1GB(memcached只支援字串,最大1M)
- 受實體記憶體限制,不能作海量資料的讀寫。適用於較小資料量的高效能操作和運算上
- 支援事務,持久化
- 單執行緒模型(memcached是多執行緒)
支援的資料型別
- Sring
- List
- Set
- SortedSet
- hash
- Bitmap
- Hyperloglogs
- Geo
- pub/sub
redis為什麼這麼快
- 純記憶體操作,沒有磁碟io
- 單執行緒處理請求,沒有執行緒切換開銷和競爭條件,也不存在加鎖問題
- 多路複用模型epoll,非阻塞io(多路:多個網路連線;複用:複用同一個執行緒) 多路複用技術可以讓單個執行緒高效的處理多個連線請求
- 資料結構簡單,對資料操作也簡單。還做了自己的資料結構優化
redis為什麼是單執行緒的
- 單執行緒已經很快了,減少多執行緒帶來的網路開銷,鎖操作
- 後續的4.0版本在考慮多執行緒
- 單執行緒是指處理網路請求的時候只有一個執行緒,並不是redis-server只有一個執行緒在工作。持久化的時候,就是通過fork一個子執行緒來執行。
- 缺點:耗時的命令會導致併發的下降,比如keys *
redis的回收策略
- volatile-lru:從過期的資料集 server.db[i].expires中挑選最近最少使用的資料
- volatile-ttl:從過期的資料集 server.db[i].expires中挑選將要過期的資料淘汰
- volatile-random: server.db[i].expires中挑選任意資料淘汰
- allkeys-lru: 從資料集(server.db[i].dict)中挑選最近最少使用的資料淘汰
- allkeys-random:從資料集(server.db[i].dict)中任意選擇資料淘汰
- no-enviction(驅逐):禁止驅逐資料
使用注意
- redis單執行緒無法發揮多核cpu效能,可以通過單機開多個redis例項來完善
- redis實現分散式鎖:先用setnx(如果不存在才設定)爭搶鎖,搶到後,expire設定過期時間,防止忘記釋放。
- redis實現一對多訊息訂閱:sub/pub資料結構
- redis實現延時訊息佇列:zadd時間戳作為score 消費的時候根據時間戳+延時時間做查詢操作。
各大版本介紹
redis5版本新增功能:
- zpopmax zpopmin以及阻塞變種:返回集合中給定分值最大最小的資料數量
reids4版本新增功能:
- 模組功能,提供類似於外掛的方式,自己開發一個.so模組,並加裝 作者本人提供了一個神經網路的module。 可到redis-modules-hub上檢視更多的module 模組功能使得使用者可以將 Redis 用作基礎設施, 並在上面構建更多功能, 這給 Redis 帶來了無數新的可能性。
- PSYNC:解決了舊版本的 Redis 在複製時的一些不夠優化的地方
- 快取清理策略優化 新增last frequently used 對已有策略進行優化
- 非阻塞DEL FLUSHDB FLUSHALL 解決了之前執行這些命令的時候導致阻塞的問題 Flushdb async, flushall async, unlink(替代del)
- 新增了swapdb:交換資料庫
- 混合RDB-AOF的持久化格式
- 新增記憶體使用情況命令:MEMORY
資料結構
- redis裡面每個鍵值對都是由物件組成的
- 鍵總是一個字串物件,
- 值則可以是以下物件的一種:
- 字串物件
- 列表物件
- 雜湊物件
- 集合物件
- 有序結合物件
簡單動態字串SDS
資料結構
struct sdshdr {
uint8_t len; /* used,使用的位元組數 */
uint8_t alloc; /* excluding the header and null terminator,預分配總位元組數,不包括結束符\0的長度 */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[]; /*c風格的字元,包括結束符\0*/
};
複製程式碼
- 位於sds.h檔案
- SDS遵循C字串以\0結尾的慣例,儲存在buf中(不同於nginx的底層實現,nginx實現時不儲存最後一個\0)
- 但是不計算最後一個字元的長度到len中
- 保留c風格buf的好處是可以重用一部分c函式庫的函式
分配和釋放策略
空間預分配
- 用於優化SDS字串增長操作,以減少連續執行增長操作所需的記憶體重分配次數
- 擴充套件SDS空間時,先檢查未使用的空間是否足夠,如果足夠直接使用,如果不夠,不僅分配夠用,還預分配一些空間
- 預分配策略:
- 修改後的SDS長度(len的值)< 1MB,預分配同樣len大小的空間
- 修改後的SDS長度(len的值)>= 1MB,預分配1MB大小的空間
惰性空間釋放
- 用於優化SDS字元縮短操作
- 縮短SDS空間時,並不立即進行記憶體重分配釋放空間,而是記錄free的位元組數
- SDS提供相應api,有需要時真正釋放空間
比C字串的優勢
- 獲取字串的長度時間複雜度由O(N)降到O(1)
- 避免緩衝區溢位
- 減少修改字串時帶來的記憶體重分配次數。記憶體分配會涉及複雜演算法,且可能需要系統呼叫,非常耗時。
- 二進位制安全:c語言的結束符限制了它只能儲存文字資料,不能儲存圖片,音訊等二進位制資料
連結串列
資料結構
位於adlist.h檔案
typedef struct listNode {
struct listNode *prev; // 前置節點
struct listNode *next; // 後置節點
void *value;//節點值
} listNode;
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;
複製程式碼
特點
- 雙端佇列,可以獲取某個節點前置節點和後置節點,複雜度為O(1)
- 無環
- 獲取表頭和表尾複雜度為O(1)
- 帶長度,獲取連結串列長度複雜度為O(1)
- 多型:使用void*儲存節點值,可儲存不同型別的值
字典
資料結構
位於dict.h檔案
雜湊表
// 雜湊表
typedef struct dictht {
dictEntry **table; // 一個陣列,陣列中每個元素都是指向dictEntry結構的指標
unsigned long size; // table陣列的大小
unsigned long sizemask; // 值總數size-1
unsigned long used; // 雜湊表目前已有節點(鍵值對)的數量
} dictht;
複製程式碼
雜湊節點
// 每個dictEntry都儲存著一個鍵值對,表示雜湊表節點
typedef struct dictEntry {
void *key; // 鍵值對的鍵
// 鍵值對的值,可以是指標,整形,浮點型
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 雜湊表節點指標,用於解決鍵衝突問題
} dictEntry;
複製程式碼
字典型別
每個字典型別儲存一簇用於操作特定型別鍵值對的函式
typedef struct dictType {
// 計算雜湊值的函式
uint64_t (*hashFunction)(const void *key);
// 複製鍵的函式
void *(*keyDup)(void *privdata, const void *key);
// 複製值的函式
void *(*valDup)(void *privdata, const void *obj);
// 對比鍵的函式
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 銷燬鍵的函式
void (*keyDestructor)(void *privdata, void *key);
// 銷燬值的函式
void (*valDestructor)(void *privdata, void *obj);
} dictType;
複製程式碼
字典
// 字典
typedef struct dict {
dictType *type; // 不同鍵值對型別對應的操作函式
void *privdata; // 需要傳遞給對應函式的引數
dictht ht[2]; // ht[0]用於存放資料,ht[1]在進行rehash時使用
long rehashidx; /* rehashing not in progress if rehashidx == -1,目前rehash的進度*/
unsigned long iterators; /* number of iterators currently running */
} dict;
複製程式碼
雜湊演算法
- redis使用MurmurHash2演算法計算鍵的hash值
- 雜湊值與sizemask取或,得到雜湊索引
- 雜湊衝突(兩個或以上數量鍵被分配到雜湊表陣列同一個索引上):鏈地址法解決衝突
rehash
- 對雜湊表進行擴充套件或收縮,以使雜湊表的負載因子維持在一個合理範圍之內
- 負載因子 = 儲存的節點數(used)/ 雜湊表大小(size)
rehash步驟包括
- 為字典的ht[1]雜湊表分配空間,大小取決於要執行的操作以及ht[0]當前包含的鍵值對數量
- 擴充套件操作:ht[1]大小為第一個大於等於ht[0].used乘以2的2的n次冪
- 收縮操作:ht[1]大小為第一個大於等於ht[0].used的2的n次冪
- 將儲存在ht[0]的所有鍵值對rehash到ht[1]上面:重新計算鍵的雜湊值和索引值
- 當所有ht[0]的鍵值對都遷移到ht[1]之後,釋放ht[0],將ht[1]置為ht[0],並新建一個恐怖hash作為ht[1]
自動擴充套件的條件
- 伺服器沒有執行BGSave命令或GBRewriteAOF命令,並且雜湊表的負載因子 >= 1
- 伺服器正在執行BGSave命令或GBRewriteAOF命令,並且雜湊表的負載因子 >= 5
- BGSave命令或GBRewriteAOF命令時,伺服器需要建立當前伺服器程式的子程式,會耗費記憶體,提高負載因子避免寫入,節約記憶體
自動收縮的條件
- 雜湊表負載因子小於0.1時,自動收縮
漸進式rehash
- ht[0]資料重新索引到ht[1]不是一次性集中完成的,而是多次漸進式完成(避免hash表過大時導致效能問題)
漸進式rehash詳細步驟
- 為ht[1]分配空間,讓自動同時持有兩個雜湊表
- 字典中rehashidx置為0,表示開始執行rehash(預設值為-1)
- rehash期間,每次對字典執行操作時,順帶將ht[0]雜湊表在rehashidx索引上的所有鍵值對rehash到ht[1]
- 全部rehash完畢時,rehashidx設為-1
注意點
- rehash的所有操作會在兩個雜湊表進行
- 新增加的值一律放入ht[1],保證資料只會減少不會增加
跳躍表
- 跳躍表是一種有序資料結構,通過在每個節點維持多個指向其他節點的指標,達到快速訪問節點的目的
- 時間複雜度:最壞O(N),平均O(logN)
- 大部分情況下,效率可與平衡樹媲美,不過比平衡樹實現簡單
- 有序集合的底層實現之一
資料結構
位於server.h檔案中
// 跳躍表節點
typedef struct zskiplistNode {
sds ele; // 成員物件
double score; // 分值,從小到大排序
struct zskiplistNode *backward; // 後退指標,從表尾向表頭遍歷時使用
struct zskiplistLevel {
struct zskiplistNode *forward; // 前進指標
unsigned long span; // 跨度,記錄兩個節點之間的距離
} level[]; // 層,是一個陣列
} zskiplistNode;
// 跳躍表相關資訊
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 表頭和表尾
unsigned long length; // 跳躍表長度(包含節點的數量)
int level; // 跳躍表內層數最大那個節點的層數(不包括表頭節點層數)
} zskiplist;
複製程式碼
- level陣列的大小在每次新建跳躍表的時候,隨機生成,大小介於1-32直接
- 遍歷操作只使用前進指標,跨度用來計算排位(rank),沿途訪問的所有層跨度加起來就是節點的排位
- 多個節點可以包含相同的分支,但每個節點成員物件是唯一的
整數集合
- intset是集合鍵的底層實現之一
- 當一個集合只包含整數值原素,且數量不多時,會使用整數集合作為底層實現
資料結構
位於intset.h檔案
typedef struct intset {
uint32_t encoding; // 編碼方式
uint32_t length; // 長度
int8_t contents[]; // 內容,陣列內容型別取決於encoding屬性,並不是int8_t。按照大小排序,沒有重複
} intset;
複製程式碼
升級
- 當我們要將一個新元素新增到整數集合裡,並且新元素的型別比整數集合現有所有的元素型別都要長時,集合要先進行升級才能新增新資料
- 升級步驟包括三步:
- 根據型別,擴充套件大小,分配空間
- 將底層陣列資料都轉換成新的型別,並反倒正確位置
- 新元素新增到底層陣列裡面
- 新增元素可能導致升級,所以新增新元素的世界複雜度為O(N)
- 不支援降級,升級後將一直保持新的資料型別
升級的好處
- 提高靈活性
- 節約記憶體
壓縮列表
- ziplist是列表鍵和雜湊鍵的底層實現之一
- redis為了節約記憶體而開發的順序型資料結構
- 當列表鍵只包含少量列表項,且每個列表項要麼是小整數,要麼是短字串,就使用ziplist作為列表鍵底層實現
- 壓縮列表遍歷時,從表位向表頭回溯遍歷
- ziplist沒有專門的struct來表示
壓縮列表的構成
屬性 | 型別 | 長度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4位元組 | 整個壓縮列表佔用的記憶體位元組數 |
zltail | uint32_t | 4位元組 | 表尾節點距離壓縮列表起始地址有多少位元組,無需遍歷就可得到表尾節點 |
zllen | uint16_t | 2位元組 | 節點數量,小於65535時是實際值,超過時需要遍歷才能算出 |
entryN | 列表節點 | 不定 | 包含的各個節點 |
zlend | uint8_t | 1位元組 | 特殊值0xFF,末端標記 |
壓縮列表節點的構成
- previos_entry_length:前一個節點的長度,用於從表尾向表頭回溯用
- 如果前面節點長度小於254位元組,preivos_entry_length用1位元組表示
- 如果前面節點長度小於254位元組,preivos_entry_length用5位元組表示,第1個位元組為0xFE(254),後面四個位元組表示實際長度
- encoding:記錄content的型別以及長度,encoding分為兩部分,高兩位和餘下的位數,最高兩位的取值有以下情況:
最高兩位取值 表示是資料型別 encoding位元組數 餘下的bit數 最大範圍 00 字元陣列 一個位元組 6bit 63位 01 字元陣列 兩個位元組 14bit 2^14-1 10 字元陣列 五個位元組 4*8,第一個位元組餘下的6bit留空 2^32-1位 11 整數 1個位元組 000000 int16_t型別整數 11 整數 1個位元組 010000 int32_t型別整數 11 整數 1個位元組 100000 int64_t型別整數 11 整數 1個位元組 110000 24位有符號整數 11 整數 1個位元組 111110 8位有符號整數 11 整數 1個位元組 xxxxxx 沒有content,xxxx本身就表示了0-12的整數 - content:儲存節點的值
連鎖更新
- 連續多個節點大小介於254左右的節點,因擴充套件導致連續記憶體分配的情況。不過在時間情況下,這種情況比較少。
物件
概述
- redis並沒有直接使用前面的資料結構來實現鍵值對的資料庫,而是基於資料結構建立了一個物件系統,每種物件都用到前面至少一種資料結構
- 每個物件都由一個redisObject結構來表示
//server.h
typedef struct redisObject {
unsigned type:4; //型別
unsigned encoding:4; // 編碼
// 物件最後一個被命令程式訪問的時間
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount; // 引用計數
void *ptr; // 指向底層的資料結構指標
} robj;
複製程式碼
使用物件的好處
- 在執行命令之前,根據物件型別判斷一個物件是否可以執行給定的命令
- 針對不同廠家,Wie物件設定多種不同的資料結構實現,從而優化效率
- 實現了基於引用計數的記憶體回收機制,不再使用的物件,記憶體會自動釋放
- 引用計數實現物件共享機制,多個資料庫共享同一個物件以節約記憶體
- 物件帶有時間時間積累資訊,用於計算空轉時間
redis中的物件
- 字串物件
- 列表物件
- 雜湊物件
- 集合物件
- 有序結合物件
物件的型別與編碼
物件的型別
物件 | 物件type屬性 | type命令的輸出 |
---|---|---|
字串物件 | REDIS_STRING | string |
列表物件 | REDIS_LIST | list |
雜湊物件 | REDIS_HASH | hash |
集合物件 | REDIS_SET | set |
有序集合物件 | REDIS_ZSET | zset |
物件的編碼
- 編碼決定了ptr指向的資料型別,表明使用什麼資料型別作為底層實現
- 每種型別物件至少使用兩種不同的編碼
- 通過編碼,redis可以根據不同場景設定不同編碼,極大提高靈活性和效率
編碼常量 | 對應的資料結構 | OBJECT ENCODING命令輸出 |
---|---|---|
REDIS_ENCODING_INT | long型別的整數 | “int” |
REDIS_ENCODING_EMBSTR | embstr編碼的簡單動態字串 | “embstr” |
REDIS_ENCODING_RAW | 簡單動態字串 | “raw” |
REDIS_ENCODING_HT | 字典 | “hashtable” |
REDIS_ENCODING_LINKEDLIST | 雙端連結串列 | “linkedlist” |
REDIS_ENCODING_ZIPLIST | 壓縮列表 | “ziplist” |
REDIS_ENCODING_INTSET | 整數集合 | “intset” |
REDIS_ENCODING_SKIPLIST | 跳躍表和字典 | “skiplist” |
字串物件
- 字串物件的編碼可以是
- int
- raw
- embstr
- 浮點數在redis中也是作為字串物件儲存,涉及計算時,先轉回浮點數。
字串物件內容 | 長度 | 編碼型別 |
---|---|---|
整數值 | - | int |
字串值 | 小於32位元組 | embstr |
字串值 | 大於32位元組 | raw |
embstr編碼是專門用於儲存短字串的一種優化編碼方式。這種編碼和raw編碼一樣,都使用redisObject結構和sdshdr結構來表示物件。區別在於:
- raw編碼呼叫兩次記憶體分配函式來分別建立redisObject和sdrhdr結構
- embstr則呼叫一次記憶體分配函式來建立一塊連續空間,裡面包括redisObject和sdrhdr
編碼轉換
int編碼和embstr編碼的物件滿足條件時會自動轉換為raw編碼的字串物件
- int編碼物件,執行命令導致物件不再是整數時,會轉換為raw物件
- embstr編碼沒有相應執行函式,是隻讀編碼。涉及修改時,會轉換為raw物件
字串命令
redis中所有鍵都是字串物件,所以所有對於鍵的命令都是針對字串鍵來構建的
- set
- get
- append
- incrbyfloat
- incrby
- decrby
- strlen
- strrange
- getrange
列表物件
- 列表物件的編碼可以是
- ziplist
- linkedlist
編碼轉換
使用ziplist編碼的兩個條件如下,不滿足的都用linkedlist編碼(這兩個條件可以在配置檔案中修改):
- 儲存的所有字串元素的長度都小於64位元組
- 列表的元素數量小於512個
列表命令
- lpush
- rpush
- lpop
- rpop
- lindex
- llen
- linsert
- lrem
- ltrim
- lset
雜湊物件
雜湊物件的編碼可以是
- ziplist
- hashtable
編碼轉換
- 使用ziplist需要滿足兩個條件,不滿足則都使用hashtable(這兩個條件可以在配置檔案中修改)
- 所有鍵值對的鍵和值的字串長度都小於64位元組
- 鍵值對數量小於512個
雜湊命令
- hset
- hget
- hexists
- hdel
- hlen
- hgetall
集合物件
集合物件的編碼可以是:
- intset:所有元素儲存在整數集合裡
- hashtale:字典的值為null
編碼轉換
集合使用intset需要滿足兩個條件,不滿足時使用hashtable(引數可通過配置檔案修改)
- 儲存的所有元素都是整數值
- 元素數量不超過512個
集合命令
- sadd
- scard
- sismember
- smembers
- srandmember
- spop
- srem
有序結合物件
有序集合的編碼可以是
- ziplist:每個元素使用兩個緊挨在一起的節點表示,第一個表示成員,第二個表示分值。分值小的靠近表頭,分值大的靠近表尾
- skiplist:使用zset作為底層實現,zset結構同時包含了字典和跳躍表,分別用於根據key查詢score和分值排序或範圍查詢
// 兩種資料結構通過指標共享元素成員和分值,不會浪費記憶體
typedef struct zset {
zskplist *zsl; //跳躍表,方便zrank,zrange
dict *dict; //字典,方便zscore
}zset;
複製程式碼
編碼轉換
當滿足以下兩個條件時,使用ziplist編碼,否則使用skiplist(可通過配置檔案修改)
- 儲存的元素數量少於128個
- 成員長度小於64位元組
有序集合命令
- zadd
- zcard
- zcount
- zrange
- zrevrange
- zrem
- zscore
型別檢查和命令多型
redis的命令可以分為兩大類:
- 可以對任意型別的鍵執行,如
- del
- expire
- rename
- type
- object
- 只能對特定型別的鍵執行,比如前面各種物件的命令。是通過redisObject的type屬性實現的
記憶體回收
redis通過物件的refcount屬性記錄物件引用計數資訊,適當的時候自動釋放物件進行記憶體回收
物件共享
- 包含同樣數值的物件,鍵的值指向同一個物件,以節約記憶體。
- redis在初始化時,建立一萬個字串物件,包含從0-9999的所有整數值,當需要用到這些值時,伺服器會共享這些物件,而不是新建物件
- 數量可通過配置檔案修改
- 目前不包含字串的物件共享,因為要比對字串是否相同本身就會造成效能問題
物件空轉時長
- 空轉時長=現在時間-redisObject.lru,lru記錄物件最後一次被訪問的時間
- 當redis配置了最大記憶體(maxmemory)時,回收演算法判斷記憶體超過該值時,空轉時長高的會優先被釋放以回收記憶體
參考命令
# 設定字串
set msg "hello world"
rpush numbers 1 2 3 4 5
llen numbers
lrange numbers 0 5
# 獲取鍵值使用的底層資料結構
object encoding numbers
# 檢視物件的引用計數值
object refcount numbers
# 物件空轉時長: value=now-object.lru
object idletime numbers
複製程式碼
參考文獻
- 《redis設計與實現》