前言
開始之前,我們可以設(shè)想一下,假設(shè)是我們自己要設(shè)計(jì)一款內(nèi)存緩存系統(tǒng),需要用什么結(jié)構(gòu)來保存我們的數(shù)據(jù)呢?首選的當(dāng)然就是map結(jié)構(gòu)啦,保存key-value形式的鍵值對,簡單易懂。
其實(shí)redis也是一樣的,只不過redis有一些額外的數(shù)據(jù)結(jié)構(gòu)用來支持其他功能(例如過期鍵,多數(shù)據(jù)庫,RDB持久化),接下來我們來探究Redis中的數(shù)據(jù)庫。其中Redis過期功能的實(shí)現(xiàn),也是面試中的高頻考察點(diǎn)之一,相信讀者讀完此篇之后,會對過期功能有個(gè)本質(zhì)的認(rèn)識。
服務(wù)器中的數(shù)據(jù)庫
Redis服務(wù)器將所有的數(shù)據(jù)庫都保存在redisServer結(jié)構(gòu)的db數(shù)組中,意味著它不止一個(gè)數(shù)據(jù)庫,數(shù)組保存著redisDB結(jié)構(gòu),每個(gè)redisDB結(jié)構(gòu)都代表一個(gè)數(shù)據(jù)庫對象。
struct redisServer {
...
//數(shù)據(jù)庫數(shù)量(默認(rèn)為16個(gè))
int dbnum;
//數(shù)據(jù)庫對象數(shù)組,保存著服務(wù)器中所有的數(shù)據(jù)庫
redisDb *db;
//其他屬性對象......
}
dbnum屬性的值由服務(wù)器配置的database選項(xiàng)決定,默認(rèn)情況下,該選項(xiàng)的值為16,所以Redis服務(wù)器默認(rèn)會創(chuàng)建16個(gè)數(shù)據(jù)庫,如下圖所示:

切換數(shù)據(jù)庫
默認(rèn)情況下,Redis客戶端的目標(biāo)數(shù)據(jù)庫為0號數(shù)據(jù)庫,但是客戶端可以通過執(zhí)行SELECT命令來切換目標(biāo)數(shù)據(jù)庫。
typedef struct redisClient {
//記錄客戶端正在使用的數(shù)據(jù)庫
redisDB *db;
} redisClient;
在redis客戶端執(zhí)行> SELECT 1 時(shí),將切換成1號數(shù)據(jù)庫,如下圖所示:

需要注意的是,當(dāng)你的程序在處理多數(shù)據(jù)庫的Redis時(shí),多次切換數(shù)據(jù)庫后可能忘記當(dāng)前處理哪個(gè)數(shù)據(jù)庫,為了避免誤操作,在執(zhí)行redis命令如 flushdb時(shí),最好先執(zhí)行select命令顯示切換到目標(biāo)數(shù)據(jù)庫。
數(shù)據(jù)庫鍵空間
Redis服務(wù)器中的每個(gè)數(shù)據(jù)庫都由一個(gè)redis.h/redisDb結(jié)構(gòu)表示,redisDb結(jié)構(gòu)的dict字典保存了數(shù)據(jù)庫中的所有鍵值對,稱為鍵空間(key space):
typedef struct redisDb {
// ...
// 數(shù)據(jù)庫鍵空間,保存著數(shù)據(jù)庫中所有鍵值對
dict *dict;
// ...
} redisDb;
鍵空間就是我們平時(shí)操作Redis存取數(shù)據(jù)的地方
- 鍵空間里面的key就是客戶端命令的key,每個(gè)key都是一個(gè)字符串對象。
- 鍵空間里面的value就是客戶端命令的各種值了,可以是字符串對象,哈希對象,列表對象,集合對象,有序集合對象等等Redis中的對象。
假設(shè)我們執(zhí)行以下幾個(gè)命令:
redis> SET message "hello world"
OK
redis> RPUSH alphabet "a" "b" "c"
(integer) 3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
執(zhí)行命令后,數(shù)據(jù)庫的鍵空間如圖:

我們對Redis的增刪改查等操作,都是通過對鍵空間字典進(jìn)行操作來實(shí)現(xiàn)的,包括 exists, rename, keys等。
讀寫鍵空間時(shí)的維護(hù)操作
當(dāng)使用Redis命令對數(shù)據(jù)庫進(jìn)行讀寫操作時(shí),服務(wù)器不僅會對鍵空間執(zhí)行相應(yīng)的命令操作,還會執(zhí)行一些額外的維護(hù)操作,包括:
- 讀取一個(gè)鍵之后,服務(wù)器會根據(jù)鍵是否存在來更新鍵空間命中次數(shù)(hit)和miss次數(shù),這個(gè)兩個(gè)屬性的值可以在INFO stats命令的 keyspacehits屬性和keyspacemissses查看。
- 讀取一個(gè)鍵后,需要更新該鍵的lru時(shí)間,lru用于計(jì)算閑置時(shí)間,在Redis控制內(nèi)存使用時(shí)會用到此屬性。
- 如果讀取該鍵的時(shí)候發(fā)現(xiàn)鍵已經(jīng)過期了,那么會刪除這個(gè)過期鍵,直接進(jìn)行余下的操作。
- 如果有客戶端使用watch命令監(jiān)視了某個(gè)鍵,那么服務(wù)器對被監(jiān)視的鍵進(jìn)行修改之后,會標(biāo)記這個(gè)鍵為臟,從而讓事務(wù)程序注意到鍵已經(jīng)被修改了。
- 每次修改鍵,都會在數(shù)據(jù)庫的臟值計(jì)數(shù)器+1(或者稱之為修改次數(shù)計(jì)數(shù)器),這個(gè)計(jì)數(shù)器會觸發(fā)數(shù)據(jù)的持久化和復(fù)制操作。
- 如果開啟了數(shù)據(jù)庫通知功能,那么對鍵進(jìn)行修改之后,將按照配置發(fā)送相應(yīng)的數(shù)據(jù)庫通知。
鍵操作
設(shè)置過期時(shí)間
通過expire命令或者pexpire命令,客戶端可以通過秒或者毫秒的精度為數(shù)據(jù)庫中的某個(gè)鍵設(shè)置國過期時(shí)間(Time To Live, TTL)。
redis> set name "jasorchen"
OK
redis> expire name 5
(integer) 1
redis> get name // 5秒內(nèi)
"jasorchen"
redis> get name // 5秒后
(nil)
與expire命令類似的,還有expireat命令,設(shè)置指定過期時(shí)間為指定的時(shí)間戳,本質(zhì)上是一樣的,expire命令就是在當(dāng)前時(shí)間戳的基礎(chǔ)上加上過期時(shí)間而已。Redis提供了四個(gè)命令設(shè)置鍵的過期時(shí)間:
- EXPIRE <key> <ttl>:將鍵key的生存時(shí)間設(shè)置為 ttl 秒
- PEXPIRE <key> <ttl>:將鍵key的生存時(shí)間設(shè)置為 ttl 毫秒
- EXPIREAT <key> <timestamp>:將鍵key的過期時(shí)間設(shè)置在tiemstamp指定的秒級別時(shí)間戳
- PEXPIREAT <key> <timestamp>:將鍵key的過期時(shí)間設(shè)置在tiemstamp指定的毫秒級別時(shí)間戳
雖然有多種命令設(shè)置鍵的過期時(shí)間,但是最終都是通過轉(zhuǎn)換成PEXPIREAT命令來實(shí)現(xiàn)的。

過期時(shí)間的保存
redisDb結(jié)構(gòu)的expires字典保存了數(shù)據(jù)庫中所有鍵的過期時(shí)間,稱為過期字典:
- 過期字典的鍵是一個(gè)指針,指向設(shè)置了過期時(shí)間的鍵。
- 過期字典的值是一個(gè)long long 類型的整數(shù),保存了該鍵的過期時(shí)間。
typedef struct redisDb {
// ...
//過期字典,保存鍵的過期時(shí)間
dict *expires;
// ...
} redisDb;

移除過期時(shí)間
PERSISIT命令可以移除一個(gè)鍵的過期時(shí)間,其大致原理就是在過期字典中找到給定的鍵,并從過期字典中移除。
過期鍵的判定
通過過期字典,程序可以判斷以下步驟檢查一個(gè)鍵是否過期:
- 檢查鍵是否在過期字典中,如果在,取得過期時(shí)間。
- 判斷過期時(shí)間是否到期,如果已到期,則判斷為過期,否則未過期。
過期鍵的刪除策略
如果一個(gè)鍵過期了,那么在何時(shí)會被刪除呢?有如下三種策略:
- 立即刪除:設(shè)置過期鍵的時(shí)間的同時(shí),也創(chuàng)建一個(gè)定時(shí)器,讓定時(shí)器在鍵的過期時(shí)間來臨時(shí),立即執(zhí)行對鍵的刪除操作。
- 惰性刪除:不去主動刪除,只有從鍵空間獲取鍵的時(shí)候判斷一下是否需要刪除,如果需要就刪除。因此如果不訪問這個(gè)鍵,就永遠(yuǎn)不會刪除。
- 定期刪除:每個(gè)一段時(shí)間,就對數(shù)據(jù)庫的過期字典做一次檢查,刪除過期鍵。
過期鍵刪除策略——立即刪除
- 優(yōu)點(diǎn):對內(nèi)存友好,可以保證過期鍵會盡可能快的被刪除,釋放鍵所占用的空間。
- 缺點(diǎn):對CPU時(shí)間非常不友好,而且?guī)泶罅康亩〞r(shí)器,且客戶端如果做了存活時(shí)間修改,需要再次維護(hù)定時(shí)器,這對服務(wù)器的響應(yīng)時(shí)間和吞吐量造成影響。因此這種方案在現(xiàn)階段來說不現(xiàn)實(shí),Redis并沒有采用這種方式。
過期鍵刪除策略——惰性刪除
- 優(yōu)點(diǎn):對CPU時(shí)間來說是最友好的,程序只會在取出鍵的時(shí)候才會對鍵進(jìn)行檢查過期與否。
- 缺點(diǎn):對內(nèi)存不友好:如果一個(gè)鍵已經(jīng)過期,而這個(gè)鍵仍然保留在數(shù)據(jù)庫中,且不再訪問,那么它占用的內(nèi)存就一直不會釋放。從某種程度上來說是一種內(nèi)存泄漏。
過期鍵刪除策略——定期刪除
定期策略是前面兩種策略的一種整合折中:
每隔一段時(shí)間執(zhí)行一次刪除過期鍵操作,并通過限制刪除操作執(zhí)行的市場和頻率來減少刪除操作對CPU時(shí)間的影響,通過定期刪除,有效的減少了因?yàn)檫^期鍵而帶來的內(nèi)存浪費(fèi)。
該策略的難點(diǎn)是確定刪除操作執(zhí)行的時(shí)長和頻率:
1.如果刪除操作執(zhí)行得太頻繁,或者執(zhí)行的時(shí)間太長,CPU將消耗過多的時(shí)間在刪除鍵上面。
2.如果刪除頻率太低,或者執(zhí)行的時(shí)間太短,一樣會造成內(nèi)存浪費(fèi)的情況。
Redis的過期鍵刪除策略
前面討論了立即刪除、惰性刪除和定期刪除三種過期鍵刪除策略,那么Redis服務(wù)器實(shí)際采用了哪種方式刪除過期鍵呢?
答案是,惰性刪除和定期刪除結(jié)合使用,使得服務(wù)器可以在合理使用CPU時(shí)間和避免浪費(fèi)內(nèi)存空間之間取得平衡。
惰性刪除策略的實(shí)現(xiàn)
鍵的惰性刪除策略由db.c/expireIfNeeded函數(shù)實(shí)現(xiàn),所有讀寫數(shù)據(jù)庫的Redis命令在執(zhí)行前,都必須調(diào)用該函數(shù)作檢查:
- 如果該鍵已經(jīng)過期,那么expireIfNeeded函數(shù)將鍵從數(shù)據(jù)庫刪除
-
如果未過期,expireIfNeeded函數(shù)不做任何動作
惰性刪除策略
定期刪除策略實(shí)現(xiàn)
鍵的定期刪除策略由activeExpireCycle函數(shù)實(shí)現(xiàn),每當(dāng)Redis的服務(wù)器周期性操作serverCron函數(shù)執(zhí)行時(shí),activeExpireCycle函數(shù)就會被調(diào)用,它在規(guī)定的時(shí)間內(nèi),分多次遍歷服務(wù)器的各個(gè)數(shù)據(jù)庫,從數(shù)據(jù)庫的expires字典中隨機(jī)檢查一部分鍵的過期時(shí)間,并刪除其中的過期鍵。
在下一次activeExpireCycle函數(shù)調(diào)用時(shí),會接著上一次的進(jìn)度進(jìn)行處理。
偽代碼如下
//默認(rèn)每次檢查的數(shù)據(jù)庫數(shù)量
DEFAULT_DB_NUMBERS = 16;
//默認(rèn)每次檢查的鍵數(shù)量
DEFAULT_KEY_NUMBERS = 20;
//全局變量,記錄數(shù)據(jù)庫檢查進(jìn)度
current_db = 0;
def activeExpireCycle() {
//確保數(shù)據(jù)庫數(shù)量不會越界
if server.dbnum < DEFAULT_DB_NUMBERS;
db_numbers = server.dbnum;
else
db_numbers = DEFAULT_DB_NUMBERS;
//獲取當(dāng)前要處理的數(shù)據(jù)庫
redisDB = server.db[current_db];
//數(shù)據(jù)庫索引加一
current_db++;
//獲取過期字典里面的鍵,
for (int i = 0; i < DEFAULT_KEY_NUMBERS; i++) {
//沒有過期鍵的直接跳過
if (redisDB.expires.size() == 0) {
break;
}
//隨機(jī)獲取一個(gè)帶有過期時(shí)間的鍵
key_with_ttl = redisDB.expires.getRandomKey();
//檢查是否過期
if (isExcepired(key_with_ttl)) {
deleteKey(key_with_ttl)
}
//如果已經(jīng)達(dá)到時(shí)間上限,停止處理
if (reach_time_limit())
return;
}
}
AOF、RDB和復(fù)制功能對過期鍵的處理
在Redis的持久化(AOF和RDB),以及復(fù)制功能時(shí),是如何處理數(shù)據(jù)庫的過期鍵的呢?
生成RDB文件
用save命令或者bgsave命令創(chuàng)建一個(gè)新的RDB文件時(shí),程序會對數(shù)據(jù)庫中的鍵進(jìn)行檢查,已經(jīng)過期的鍵不會被保存在新創(chuàng)建的RDB文件中。因此不會對生成RDB文件有任何的影響。
載入RDB文件
在啟動Redis服務(wù)器時(shí),如果開啟了RDB功能,那么服務(wù)器將對RDB文件進(jìn)行載入:
- 如果服務(wù)器以主服務(wù)器模式運(yùn)行,那么在載入RDB文件時(shí),程序會對文件中保存的鍵進(jìn)行檢查,未過期的鍵會被載入到數(shù)據(jù)庫中,過期鍵直接忽略。
- 如果服務(wù)器以從庫方式啟動,那么載入RDB文件時(shí),文件中保存的所有鍵,不論是否過期,都會被載入到數(shù)據(jù)庫中。不過因?yàn)橹鲝耐綍r(shí),從服務(wù)器的數(shù)據(jù)庫會被清空,因此過期鍵對于載入RDB的從庫也沒有影響。
AOF文件寫入
當(dāng)服務(wù)器以AOF持久化模式運(yùn)行時(shí),如果數(shù)據(jù)庫中某個(gè)鍵已經(jīng)過期,但它還沒有被各種刪除策略發(fā)現(xiàn),那么AOF文件不會因?yàn)檫@個(gè)過期鍵而產(chǎn)生任何影響。當(dāng)鍵被惰性或者定期策略刪除刪除后,程序會像AOF文件追加一條DEL命令, 顯示的刪除這個(gè)鍵。
因?yàn)锳OF保存的操作命令,所以鍵的讀寫操作命令都會被保存在AOF文件中,當(dāng)鍵過期時(shí),通過惰性刪除或者定時(shí)刪除之后,AOF會保存相應(yīng)的刪除命令,顯式記錄該鍵已經(jīng)被刪除。
如:客戶端使用GET message訪問過期的message鍵,那么服務(wù)器將執(zhí)行以下三個(gè)動作:
- 從數(shù)據(jù)庫中刪除message鍵
- 追加一條DEL message命令道AOF文件
- 向執(zhí)行GET命令的客戶端返回空回復(fù)
AOF重寫
執(zhí)行AOF文件重寫,程序會對鍵進(jìn)行檢查,已經(jīng)過期的不寫入到新的AOF文件中。
為什么會有AOF重寫?
- AOF 持久化是通過保存被執(zhí)行的寫命令來記錄數(shù)據(jù)庫狀態(tài)的,所以AOF文件的大小隨著時(shí)間的流逝一定會越來越大;影響包括但不限于:對于Redis服務(wù)器,計(jì)算機(jī)的存儲壓力;AOF還原出數(shù)據(jù)庫狀態(tài)的時(shí)間增加;
- 為了解決AOF文件體積膨脹的問題,Redis提供了AOF重寫功能:Redis服務(wù)器可以創(chuàng)建一個(gè)新的AOF文件來替代現(xiàn)有的AOF文件,新舊兩個(gè)文件所保存的數(shù)據(jù)庫狀態(tài)是相同的,但是新的AOF文件不會包含任何浪費(fèi)空間的冗余命令,通常體積會較舊AOF文件小很多。
主從復(fù)制對過期鍵的處理
當(dāng)服務(wù)器運(yùn)行在復(fù)制模式下,從服務(wù)器的過期鍵刪除策略又主服務(wù)器控制:
- 主服務(wù)器刪除一個(gè)過期鍵之后,會顯式的向所有從服務(wù)器發(fā)送一條DEL命令。
- 從服務(wù)器接收到客戶端的讀取key請求,即使該鍵已經(jīng)過期,也不刪除,而是當(dāng)做未過期鍵來處理。
- 從服務(wù)器只有在接收到主服務(wù)器發(fā)送的DEL命令后,才會刪除過期鍵。
通過主服務(wù)器來控制從服務(wù)器統(tǒng)一地刪除過期鍵,可以最大程度的保證主從數(shù)據(jù)的一致性,由主服務(wù)器來確定何時(shí)刪除過期鍵。
