前言
Redis 作爲一個高性能的內存數據庫,其讀效率達到 10w qps/s,寫也能到達 4-5w qps/s。今天就來簡單的聊下其底層的實現,達到知其然並知其所以然。
redisServer
Redis 是個典型的 C/S 模式,客戶端連接到服務端,然後進行交互。那麼就來看看服務端的數據結構
// server.h
struct redisServer {
……
redisDb *db;
int dbnum; /* Total number of configured DBs */
……
}
其中 db 爲 redis 數據庫數組,存放着 key-value, dbnum 爲其數組長度, 默認爲 16,可以通過配置文件查看
// redis.conf
# Set the number of databases. The default database is DB 0, you can select
# a different one on a per-connection basis using SELECT <dbid> where
# dbid is a number between 0 and 'databases'-1
databases 16
默認爲第 0 個數據庫,也可以通過 select dbid(0-15) 來選擇切換數據庫。不過這裏建議使用默認的第 0 個數據庫來使用,因爲一旦在項目中使用了除 0 之外的數據庫,那在項目沒有一個統一的說明情況下,很有可能會造成數據紊亂。比如,你使用了 1,同事採用了 0,又或是,你 select 了其他的數據庫暫時處理些數據,但忘了 select 回來。
客戶端連接到服務端後,服務端默認的第 0 個數據庫就是其目標數據庫,這可以在 redis-cli 上查看到
[root@FJR-bt-kvm-72-27 supdev]# redis-cli
127.0.0.1:6379> select 1
OK
127.0.0.1:6379[1]> select 2
OK
127.0.0.1:6379[2]>
就是 127.0.0.1:6379 後面的那個 [1]、[2],因爲 0 是默認的,所以沒顯示。
// server.h
/* With multiplexing we need to take per-client state.
* Clients are taken in a linked list. */
typedef struct client {
uint64_t id; /* Client incremental unique ID. */
int fd; /* Client socket. */
redisDb *db; /* Pointer to currently SELECTed DB. */
……
} client;
這裏也可以看看 client 的數據結構,連接成功後,會有一個 8 個字節的唯一的 ID,以及一個 socket,再就是剛提到的對應的 server 分配的數據庫。
redisDb
Redis 是鍵值對類型的非關係型數據庫,屬於 NoSQL 範疇,區別於 MySQL。比如你在客戶端存入了一個數據
127.0.0.1:6379> set name molaifeng
OK
簡單的一個命令過後,是否好奇數據存在哪了。關係型數據庫如 MySQL,若使用的引擎是 InnoDB 的話,會以 B+ 樹的形式存在硬盤上。那 Redis 呢?
/* Redis database representation. There are multiple databases identified
* by integers from 0 (the default database) up to the max configured
* database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
前面提到,客戶端連接到服務端後,會選擇一個默認的數據庫,當然了也可以手動選擇。之後數據存儲都放到此數據庫裏,也就是上面的 redisDb 裏。這裏着重說下兩個屬性,dict 和 expires,dict 存儲的是所有的 key-value 對;而 expires 存儲的則是在客戶端顯示設置的過期時間的 key。
dict
dict 是個字典的數據結構,保存了數據庫中的所有鍵值對,那又是如何保存的呢,且聽我娓娓道來。
// dict.h
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
這裏就關注 ht[2] 這個數組元素,其中 ht[0] 是真正用來存放 key-value 的,而 ht[1] 則是在 rehash 時使用的,是爲了提升效率用的。比如,默認 ht[0] 存放 4 個值,那麼達到 4 個後,就會擴容。但不是立馬把 ht[0] 擴容,而是平滑的把數據遷移 ht[1] 上,rehash 結束後清空 ht[0],然後 ht[0] 和 ht[1] 對調,讓擴容的時候,也可以對外提供服務。
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
dictEntry **table; /* 哈希表數組 */
unsigned long size; /* 哈希表大小,即哈希表數組大小 */
unsigned long sizemask; /* 哈希表大小掩碼,總是等於size-1,主要用於計算索引 */
unsigned long used; /* 已使用節點數,即已使用鍵值對數 */
} dictht;
ht 指向的就是 dictht 這個數據結構,其中 table 數組存放的就是具體的 key-value 了。
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
dictEntry 就是最終的存放的數據了,其中 key 就是存放鍵值對中的鍵了,而 value 則存放在 v 這個聯合體內,熟悉 PHP 底層的 zval 結構的童鞋應該清楚這個,通過複用字段達到了減少字段的目的。 Redis 在鍵衝突時,採用的是拉鍊法,next 中記錄了當鍵衝突的元素的指針,通過過頭插法,形成單鏈表,這裏還得提下,PHP7 底層也是這麼幹的。
redisObject
在 dictEntry 中用來存放鍵值對的 key 和 v 字段,實際上這兩個字段都分別指向 redisObject 結構體,此結構體通過類型和編碼判斷來存儲着最終的值。大家都知道 Redis 五種類型,字符串、列表、哈希、集合、有序集合,都是存在這個 v 裏。不同的場景下,使用不用的字段。當用字典存放整個數據庫的鍵值對時,用的則是 v 裏面的 *val 字段,val 字段存放 redisObject 指針。下面來看看 redisObject 的數據結構,並依次介紹下里面各字段的含義,就會對 Redis 有個清晰的認識。
// 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;
4 位的 type 表示 Redis 的具體數據類型, 2^4 = 16 足夠表示常用的 5 個類型了
/* The actual Redis Object */
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
4 位的 encoding 表示具體類型的編碼方式,同一種數據類型可能有不同的編碼方式。目前 Redis 中主要有 11 種編碼方式:
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding' field of the object
* is set to one of this fields for this object. */
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
關於這個 encoding 可以在客戶端上查看某個 key 的編碼,比如上面的 name 編碼
127.0.0.1:6379> object encoding name
"embstr"
可以看到 name 的 encoding 爲 OBJ_ENCODING_EMBSTR,這個屬於字符串的類型的
127.0.0.1:6379> set name 1
OK
127.0.0.1:6379> object encoding name
"int"
更新了 name 的值爲整數 1 後,發現其編碼變成了 OBJ_ENCODING_INT。
127.0.0.1:6379> set name "hello world,this is my redis record.hello world,this is my redis record.hello world,this is my redis record.hello world,this is my redis record"
OK
127.0.0.1:6379> object encoding name
"raw"
這次則變成了 OBJ_ENCODING_RAW 。也就是說對於類型爲字符串來說,同一個 key 的 value 值不同,其儲存的編碼會適時變的,這個在列表、哈希表、集合、有序集合都有類似的變化。往深入的一點想,由於 Redis 是內存數據庫,最大化利用內存則是性能極致的體現。
低 24 位的 lru,當用於 LRU 時表示最後一次訪問時間,當用於 LFU 時,高 16 位記錄分鐘級別的訪問時間,低 8 位記錄訪問頻率 0 到 255。
/* Set the LRU to the current lruclock (minutes resolution), or
* alternatively the LFU counter. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
這裏提下這個結構體佔 (4+4+24)/8 + 4 + 8 = 16 個字節,按位分配的時候可以節省內存。關於 Redis 的緩存淘汰策略可以通過 redis.conf 查看
# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select among five behaviors:
#
# volatile-lru -> remove the key with an expire set using an LRU algorithm
# allkeys-lru -> remove any key according to the LRU algorithm
# volatile-random -> remove a random key with an expire set
# allkeys-random -> remove a random key, any key
# volatile-ttl -> remove the key with the nearest expire time (minor TTL)
# noeviction -> don't expire at all, just return an error on write operations
#
# Note: with any of the above policies, Redis will return an error on write
# operations, when there are no suitable keys for eviction.
#
# At the date of writing these commands are: set setnx setex append
# incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd
# sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby
# zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby
# getset mset msetnx exec sort
#
# The default is:
#
# maxmemory-policy noeviction
在 4.0 版本之前,Redis 提供了 6 種的淘汰策略,其中默認的是 noeviction。
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 4.0 版本之後及現在(Redis 6.0),淘汰策略增加了 2 種,達到了 8 種
volatile-lfu:從所有配置了過期時間的鍵中驅逐使用頻率最少的鍵
allkeys-lfu:從所有鍵中驅逐使用頻率最少的鍵
新增的兩種策略主要是爲了解決老版本中 lru 不足的問題,比如一個冷門的 key 偶爾被訪問,按時間熱度的 lru 的策略是不會給淘汰的,但按頻率的 lfu 則不會漏掉此種情況。
若想使淘汰策略生效,需進行以下配置
maxmemory 3G
maxmemory-policy volatile-lfu
maxmemory-samples 5
當內存達到 3G 時,那麼就會按照給定的 maxmemory-policy 策略,如上配置爲 volatile-lfu,那麼隨機選擇 5 個配置了過期時間的 key,淘汰使用頻率最少的鍵。
refcount 表示對象的引用計數,這個也和 PHP 類似,當對象共享時 +1,刪除對象時 -1,爲 0 時則釋放對象空間。
*ptr 則指向類型編碼具體的實現了,這個要講的話,一篇博客寫不完,以後單開系列來講。
expires
過期策略
再回到開頭,dict 的已經講完了,也就是命令行裏存入的 key-value 存放在哪,已經怎麼存放的,那麼現在說說過期策略了。
127.0.0.1:6379> ttl name
(integer) -1
由於一開始並沒有給 name 加過期時間,所以返回 -1。這次我們加上過期時間
127.0.0.1:6379> expire name 3600
(integer) 1
127.0.0.1:6379> ttl name
(integer) 3598
設置過期時間爲一個小時,返回查看剩餘的過期時間。其實在 Redis 中最終保存的都是 UNIX 形式的毫秒數,也即使如下
127.0.0.1:6379> pexpireat name 1584722037000
(integer) 1
127.0.0.1:6379> ttl name
(integer) 1584722036997
也就是 name 的鍵將在 2020-03-21 00:33:57 過期。看完上面的 dict 介紹,這裏發現 expires 對應的結構也是字典,那麼就會發現其存儲的也如 key-value 一樣,只是這裏存儲的是 key 以及對應的失效時間,再細究的話發現 ,失效時間爲 int,key 爲字符串類型。其實在 Redis 中,key 都爲字符串類型。
當然了給 key 加過期時間,也可以給其解除過期時間
127.0.0.1:6379> persist name
(integer) 1
127.0.0.1:6379> ttl name
(integer) -1
解除後,此 key 在 expires 字典裏便會被移除。
Redis 畢竟是個內存數據庫,單臺服務器上的內存也有限,Redis 也不能像貔貅似的,只進不出,這時得就得介紹下過期策略了。
Redis 中有三種策略
- 定時刪除
- 惰性刪除
- 定期刪除
這三個策略中,定時刪除對內存最友好,通過設定定時器,刪除過期的 key,但對 CPU 不友好,如果一段時間內要過期的 key 多,那麼檢索和刪除都會佔用 CPU 時間,會影響到服務器的吞吐量的。
惰性刪除是被動防禦的策略,對 CPU 最友好。也就是每次訪問 key 的時候,判斷該 db 是否有過期的 key 並且 key 是否設置過期時間,若設置了,在判斷是否過期了,若過期了,則返回 nil。但,這個有個很大的問題,只對訪問的 key 做檢查,那麼對於沒有訪問的但已經過期的 key 則在內存中則是浪費了,除非訪問到,否則就一直佔用着。
定期刪除,則是每隔一段時間執行下刪除過期 key 的操作,並通過限制刪除操作執行的時長和頻率來減少刪除操作對 CPU 時間的影響。當然了,這裏也有個度,比如刪除操作執行太頻繁,或者執行太長;又或者執行操作太少,或者執行時間太短。前者會退化爲定時策略,後者則爲惰性策略了。
Redis 採用的是惰性加定期這兩種策略。不過即使這兩者結合還是會有問題的。如果定期刪除沒刪除 key。然後你也沒即時去請求 key,也就是說惰性刪除也沒生效。這樣,redis 的內存會越來越高。那麼就應該採用內存淘汰機制,這裏可以參見之前講 lru 策略。
過期策略對於 RDB 及 AOF 的影響
前面講過 Redis 是個內存數據庫,但是它還可以把內存裏數據保存到硬盤中,在重啓後通過讀取 RDB 或 AOF 中的文件來恢復之前的內存數據,可以通過配置文件來設置
// redis.conf
save 900 1
save 300 10
; 60 秒內有 10000 個增刪改的操作,會觸發寫入
save 60 10000
; rdb 及 aof 文件保存目錄
dir /usr/local/redis-cluster/data/
dbfilename dump.rdb
; 開啓 aof 文件寫入
appendonly yes
; 表示每秒同步一次(默認值很快,但可能會丟失一秒以內的數據)
appendfsync everysec
; 默認達到 64m 就開始重寫,生產環境需要調大,起碼得五六個G
auto-aof-rewrite-min-size 64mb
; 一開始爲 64m,然後觸發 aof 重寫機制,比如重寫後爲 40m,那麼下次爲 40m + 40m*100% = 80m 再觸發重寫
auto-aof-rewrite-percentage 100
在客戶端指向 save 或 bgsave 命令後 Redis 創建新的 RDB 文件時,會對過期的 key 進行檢查,已過期的 key 則不會保存在 RDB 文件中。啓動 Redis 後,載入 RDB 文件時會對 key 進行過濾,同時如果是在主從環境下,主庫會同步到從庫,那麼從庫也會把過期的 key 清空,但是從庫不會主動清理的,即使在 get 查詢一個過期的 key 時,只要主庫沒有同步過來,從庫也不會執行刪除動態。
在 AOF 文件寫入時,如果某個 key 已經過期,但是還沒被惰性+定期策略刪除,那麼還是會寫入到文件的。當被兩個策略中一個刪除時,那麼被會 append 一條 DEL 命令,來顯式地刪除此 key。在 AOF 重寫時,會主動的對 key 進行過期檢查,已過期的不會被寫入 AOF 文件中。在主從環境中,和 RDB 一樣,從庫不會主動刪除過期的 key,而是由主庫同步。當主庫刪除一個過期的 key 後,會向所有的從庫發送一個 DEL 命令,告知從庫刪除這個 key。
【注】 此博文中的 Redis 版本爲 5.0。
參考書籍 :
【1】redis設計與實現(第二版)
【2】Redis 5設計與源碼分析