淺談 Redis

前言

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 中有三種策略

  1. 定時刪除
  2. 惰性刪除
  3. 定期刪除

這三個策略中,定時刪除對內存最友好,通過設定定時器,刪除過期的 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設計與源碼分析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章