Redis數據庫
1.Redis服務器
Redis服務器將所有數據庫都保存在服務器狀態server.h/redisServer結構的db數組中,db數組的每個項都是一個server.h/redisDb結構,每個redisDb結構代表一個數據庫:
struct redisServer { // ... // 一個數組,保存着服務器中的所有數據庫 redisDb *db; // ... //服務器的數據庫數量 int dbnum; };
dbnum屬性的值由服務器配置的database選項決定,默認情況下,該選項的值為16,所以Redis服務器默認會創建16個數據庫。
默認情況下,Redis客戶端的目標數據庫為0號數據庫,但客戶端可以通過執行SELECT命令來切換目標數據庫。
2.Redis客戶端
在服務器內部,客戶端狀態redisClient結構的db屬性記錄了客戶端當前的目標數據庫,這個屬性是一個指向redisDb結構的指針:
typedef struct redisClient { // ... //記錄客戶端當前正在使用的數據庫 redisDb *db; // ... } redisClient;
3.Redis數據庫
typedef struct redisDb { dict *dict; /* 當前數據庫的鍵空間 */ dict *expires; /* 鍵的過期時間 */ dict *blocking_keys; /* 處於阻塞狀態的鍵和相應的client(主要用於List類型的阻塞操作)*/ dict *ready_keys; /* 准備好數據可以解除阻塞狀態的鍵和相應的client */ dict *watched_keys; /* 被watch命令監控的key和相應client */ int id; /* 數據庫ID標識 */ long long avg_ttl; /* 數據庫內所有鍵的平均TTL(生存時間) */ list *defrag_later; /*逐一嘗試整理碎片的關鍵名稱列表 */ } redisDb;
redisDb結構的dict字典保存了數據庫中的所有鍵值對,我們將這個字典稱為鍵空間(key space) 鍵空間和用戶所見的數據庫是直接對應的:
❑鍵空間的鍵也就是數據庫的鍵,每個鍵都是一個字符串對象。
❑鍵空間的值也就是數據庫的值,每個值可以是字符串對象、列表對象、哈希表對象、集合對象和有序集合對象中的任意一種Redis對象。
4.Redis鍵過期時間和過期時間查詢TTL
通過EXPIRE命令或者PEXPIRE命令,客戶端可以以秒或者毫秒精度為數據庫中的某個鍵設置生存時間(Time To Live,TTL),在經過指定的秒數或者毫秒數之后,服務器就會自動刪除生存時間為0的鍵。
expires字段也是一個字典dict結構,字典的鍵為key,值為該key對應的過期時間,過期時間為long long類型整數,是以毫秒為單位的過期 UNIX 時間戳。setExpire函數的作用是為指定key設置過期時間。
/* 為指定key設置過期時間 */ void setExpire(redisDb *db, robj *key, long long when) { dictEntry *kde, *de; /* Reuse the sds from the main dict in the expire dict */ // db->dict和db->expires是共用key字符串對象的 // 取出key kde = dictFind(db->dict,key->ptr); redisAssertWithInfo(NULL,key,kde != NULL); // 取出過期時間 de = dictReplaceRaw(db->expires,dictGetKey(kde)); // 重置key的過期時間 dictSetSignedIntegerVal(de,when); }
有了過期時間戳我們就很容易判斷某個key是否過期:只要將當前時間戳跟過期時間戳比較一下即可,如果當前時間戳大於過期時間戳顯然該key已經過期了。
在Redis中,如果沒有為一個key設置過期時間,那么該key就不會出現在db->expires字典中。也就是說db->expires字段只保存了設置有過期時間的key。
- 設置過期時間
Redis有四個不同的命令可以用於設置鍵的生存時間(鍵可以存在多久)或過期時間(鍵什么時候會被刪除):(expire.c中)
❑EXPIRE<key><ttl>命令用於將鍵key的生存時間設置為ttl秒。
❑PEXPIRE<key><ttl>命令用於將鍵key的生存時間設置為ttl毫秒。
❑EXPIREAT<key><timestamp>命令用於將鍵key的過期時間設置為timestamp所指定的秒數時間戳。
❑PEXPIREAT<key><timestamp>命令用於將鍵key的過期時間設置為timestamp所指定的毫秒數時間戳。
EXPIREAT命令與EXPIRE命令的差別在於前者使用Unix時間作為第二個參數表示鍵的生存時間的截止時間。PEXPIREAT命令與EXPIREAT命令的區別是前者的時間單位是毫秒。
雖然有多種不同單位和不同形式的設置命令,但實際上EXPIRE、PEXPIRE、EXPIREAT三個命令都是使用PEXPIREAT命令來實現的:無論客戶端執行的是以上四個命令中的哪一個,經過轉換之后,最終的執行效果都和執行PEXPIREAT命令一樣。
/* EXPIRE key seconds */ void expireCommand(client *c) { expireGenericCommand(c,mstime(),UNIT_SECONDS); } /* EXPIREAT key time */ void expireatCommand(client *c) { expireGenericCommand(c,0,UNIT_SECONDS); } /* PEXPIRE key milliseconds */ void pexpireCommand(client *c) { expireGenericCommand(c,mstime(),UNIT_MILLISECONDS); } /* PEXPIREAT key ms_time */ void pexpireatCommand(client *c) { expireGenericCommand(c,0,UNIT_MILLISECONDS); } void expireGenericCommand(redisClient *c, long long basetime, int unit) { robj *key = c->argv[1], *param = c->argv[2]; // 以毫秒為單位的unix時間戳 long long when; // 獲取過期時間 if (getLongLongFromObjectOrReply(c, param, &when, NULL) != REDIS_OK) return; // 如果傳入的過期時間是以秒為單位,則轉換為毫秒為單位 if (unit == UNIT_SECONDS) when *= 1000; // 加上basetime得到過期時間戳 when += basetime; /* No key, return zero. */ // 取出key,如果該key不存在直接返回 if (lookupKeyRead(c->db,key) == NULL) { addReply(c,shared.czero); return; } if (when <= mstime() && !server.loading && !server.masterhost) { // 如果when指定的時間已經過期,而且當前為服務器的主節點,並且目前沒有載入數據 robj *aux; redisAssertWithInfo(c,key,dbDelete(c->db,key)); server.dirty++; // 傳播一個顯式的DEL命令 aux = createStringObject("DEL",3); rewriteClientCommandVector(c,2,aux,key); decrRefCount(aux); signalModifiedKey(c->db,key); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"del",key,c->db->id); addReply(c, shared.cone); return; } else { // 設置key的過期時間(when提供的時間可能已經過期) setExpire(c->db,key,when); addReply(c,shared.cone); signalModifiedKey(c->db,key); notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,"expire",key,c->db->id); server.dirty++; return; } }
- TTL命令
TTL命令以秒為單位返回鍵的剩余生存時間,而PTTL命令則以毫秒為單位返回鍵的剩余生存時間
/* TTL key */ void ttlCommand(client *c) { ttlGenericCommand(c, 0); } /* PTTL key */ void pttlCommand(client *c) { ttlGenericCommand(c, 1); } void ttlGenericCommand(client *c, int output_ms) { long long expire, ttl = -1; /* 如果這個鍵不存在 return -2 */ if (lookupKeyReadWithFlags(c->db,c->argv[1],LOOKUP_NOTOUCH) == NULL) { addReplyLongLong(c,-2); return; } /* 鍵存在. Return -1 if 已經過期, or the 實際的TTL值otherwise. */ expire = getExpire(c->db,c->argv[1]); if (expire != -1) { ttl = expire-mstime(); if (ttl < 0) ttl = 0; } if (ttl == -1) { addReplyLongLong(c,-1); } else { addReplyLongLong(c,output_ms ? ttl : ((ttl+500)/1000)); } }
- 過期鍵刪除策略
如果一個鍵過期了,那么它什么時候會被刪除呢?這個問題有三種可能的答案,它們分別代表了三種不同的刪除策略:
❑定時刪除:在設置鍵的過期時間的同時,創建一個定時器(timer),讓定時器在鍵的過期時間來臨時,立即執行對鍵的刪除操作。
❑惰性刪除:放任鍵過期不管,但是每次從鍵空間中獲取鍵時,都檢查取得的鍵是否過期,如果過期的話,就刪除該鍵;如果沒有過期,就返回該鍵。
❑定期刪除:每隔一段時間,程序就對數據庫進行一次檢查,刪除里面的過期鍵。至於要刪除多少過期鍵,以及要檢查多少個數據庫,則由算法決定。
在這三種策略中,第一種和第三種為主動刪除策略,而第二種則為被動刪除策略。
1)過期鍵的惰性刪除策略由db.c/expireIfNeeded函數實現,所有讀寫數據庫的Redis命令在執行之前都會調用expireIfNeeded函數對輸入鍵進行檢查:
❑如果輸入鍵已經過期,那么expireIfNeeded函數將輸入鍵從數據庫中刪除。
❑如果輸入鍵未過期,那么expireIfNeeded函數不做動作。
對於過期的key,Redis負責將該key刪除,為了提高運行效率,Redis采取這么一種處理方式:只有當真正要訪問該key時才檢查該key是否過期。如果過期就刪除,如果沒過期就正常訪問。通常我們把這種只有在訪問時才檢查過期的策略叫做“惰性刪除”。
int expireIfNeeded(redisDb *db, robj *key) { // 獲取key的過期時間 mstime_t when = getExpire(db,key); mstime_t now; // 如果該key沒有過期時間,返回0 if (when < 0) return 0; // 如果服務器正在加載操作中,則不進行過期檢查,返回0 if (server.loading) return 0; //如果我們處在Lua腳本的上下文中,我們假設直到Lua腳本啟動時間不變的。 //通過這種方式,key只能在第一次訪問而不是在腳本執行過程中過期, //從而使slave/AOF傳播一致。 now = server.lua_caller ? server.lua_time_start : mstime(); // 如果當前程序運行在slave節點,該key的過期操作是由master節點控制的(master節點會發出DEL操作) // 在這種情況下該函數先返回一個正確值,即如果key未過期返回0,否則返回1。 // 真正的刪除操作等待master節點發來的DEL命令后再執行 if (server.masterhost != NULL) return now > when; // 如果未過期,返回0 if (now <= when) return 0; // 如果已過期,刪除該key server.stat_expiredkeys++; propagateExpire(db,key,server.lazyfree_lazy_expire); notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired",key,db->id); return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key); }
對於過期key,Redis主(master)節點和附屬(slave)節點有不同的處理策略,具體如下:
如果當前Redis服務器是主節點,即if (server.masterhost != NULL)語句判斷為false,那么當它發現一個過期key后,會調用propagateExpire函數向所有附屬節點發送一個 DEL 命令,然后再刪除該key。這種做法使得對key的過期操作可以集中在一個地方處理。
如果當前Redis服務器是附屬節點,即if (server.masterhost != NULL)語句判斷為true,那么它立即向程序返回該key是否已經過期的信息。即便該key已經過期也不會真正的刪除該key。直到該節點接到從主節點發來的DEL 命令之后,才會真正執行刪除操作。
當Redis從數據庫db中取出指定key的對象時,總是先調用調用expireIfNeeded函數來檢查對應key是否過期,然后再從數據庫中查找對象。
robj *lookupKeyRead(redisDb *db, robj *key) { return lookupKeyReadWithFlags(db,key,LOOKUP_NONE); } robj *lookupKeyReadWithFlags (redisDb *db, robj *key, int flags) { robj *val; // 如果key已過期,刪除該key if (expireIfNeeded(db,key) == 1) { /*密鑰過期, 如果當前為master,expireIfNeeded(),僅當密鑰不存在時才返回0,所以它很安全,盡快返回NULL*/ if (server.masterhost == NULL) return NULL; /* 如果當前處於slave節點,expireIfNeeded只返回信息*/ if (server.current_client && server.current_client != server.master && server.current_client->cmd && server.current_client->cmd->flags & CMD_READONLY) { return NULL; } } // 從數據庫db中找到指定key的對象 val = lookupKey(db,key, flags); if (val == NULL) // 更新“未命中”次數 server.stat_keyspace_misses++; else // 更新“命中”次數 server.stat_keyspace_hits++; return val; }
/* 該函數是為寫操作而從數據庫db中取出指定key的對象。 如果敢函數執行成功則返回目標對象,否則返回NULL。*/ robj *lookupKeyWrite(redisDb *db, robj *key) { // 如果key已過期,刪除該key expireIfNeeded(db,key); // 從數據庫db中找到指定key的對象 return lookupKey(db,key,LOOKUP_NONE); }
2)過期鍵的定期刪除策略由expire.c/activeExpireCycle函數實現,周期性過期是通過周期心跳函數(serverCron)來觸發的,每當Redis的服務器周期性操作serverCron函數執行時,activeExpireCycle函數就會被調用,它在規定的時間內,分多次遍歷服務器中的各個數據庫,從數據庫的expires字典中隨機檢查一部分鍵的過期時間,並刪除其中的過期鍵。
//周期性操作中進行慢速過期鍵刪除,執行頻率同databasesCron的執行頻率 //執行時長為1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100 void databasesCron(void) { if (server.active_expire_enabled && server.masterhost == NULL) { activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW); } else if (server.masterhost != NULL) { expireSlaveKeys(); } }//進行快速過期鍵刪除,執行間隔和執行時長都為ACTIVE_EXPIRE_CYCLE_FAST_DURATION void beforeSleep(struct aeEventLoop *eventLoop) { if (server.active_expire_enabled && server.masterhost == NULL) activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST); }
void activeExpireCycle(int type) { static unsigned int current_db = 0; /* 上次定期刪除遍歷到的數據庫id*/ static int timelimit_exit = 0; /* Time limit hit in previous call? */ static long long last_fast_cycle = 0; /* 上一次執行快速定期刪除的時間點 */ int j, iteration = 0; int dbs_per_call = CRON_DBS_PER_CALL;//每次定期刪除,遍歷的數據庫的數量
long long start = ustime(), timelimit; /*當客戶端暫停時,數據集應該是靜態的,不僅僅客戶端的命令無法寫入的,而且key到期的指令無法執行*/ if (clientsArePaused()) return; if (type == ACTIVE_EXPIRE_CYCLE_FAST) { if (!timelimit_exit) return; //快速定期刪除的時間間隔是ACTIVE_EXPIRE_CYCLE_FAST_DURATION //ACTIVE_EXPIRE_CYCLE_FAST_DURATION是快速定期刪除的執行時長 if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return; last_fast_cycle = start; } //我們通常應該每次迭代測試CRON_DBS_PER_CALL if (dbs_per_call > server.dbnum || timelimit_exit) dbs_per_call = server.dbnum; //慢速定期刪除的執行時長 timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100; timelimit_exit = 0; if (timelimit <= 0) timelimit = 1; if (type == ACTIVE_EXPIRE_CYCLE_FAST) timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /*刪除操作的執行時長 */ //當我們將密鑰過期時,累積一些全局統計信息,以了解已經邏輯過期但仍存在於數據庫內的密鑰數量。 long total_sampled = 0;
long total_expired = 0;
for (j = 0; j < dbs_per_call; j++) { int expired; redisDb *db = server.db+(current_db % server.dbnum); current_db++; do { unsigned long num, slots; long long now, ttl_sum; int ttl_samples; iteration++; //如果沒有要刪除的鍵就轉向下一個數據庫 if ((num = dictSize(db->expires)) == 0) { db->avg_ttl = 0; break; } slots = dictSlots(db->expires); now = mstime(); //當槽的填充小於1%,key顯得很重要,因此會等待更好的時機進行鍵清除。 if (num && slots > DICT_HT_INITIAL_SIZE && (num*100/slots < 1)) break; } } expired = 0; ttl_sum = 0; ttl_samples = 0; //在每個數據庫中檢查的鍵的數量 if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP) num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP; //從db->expires中隨機選取num個鍵進行檢查 while (num--) { if ((de = dictGetRandomKey(db->expires)) == NULL) break; ttl = dictGetSignedIntegerVal(de)-now; //過期檢查,並對過期鍵進行刪除 if (activeExpireCycleTryExpire(db,de,now)) expired++; if (ttl > 0) { /* We want the average TTL of keys yet not expired. */ ttl_sum += ttl; ttl_samples++; } total_sampled++; } //更新平均過期時間 if (ttl_samples) { long long avg_ttl = ttl_sum/ttl_samples; //用幾個樣本做一個簡單的運行平均值。我們只使用當前的估計值,權重為2%和以前的估計98%的權重。 if (db->avg_ttl == 0) db->avg_ttl = avg_ttl; db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50); } //即使有很多密鑰過期,我們也不能永遠阻止。 因此,給定的毫秒數量返回給調用者,等待另一個活動的過期周期。 if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */ elapsed = ustime()-start; if (elapsed > timelimit) { timelimit_exit = 1; server.stat_expired_time_cap_reached_count++; break; } } } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4); //每次檢查只刪除ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4個過期鍵 } }
activeExpireCycle函數的工作模式可以總結如下:
❑函數每次運行時,都從一定數量的數據庫中取出一定數量的隨機鍵進行檢查,並刪除其中的過期鍵。
❑全局變量current_db會記錄當前activeExpireCycle函數檢查的進度,並在下一次activeExpireCycle函數調用時,接着上一次的進度進行處理。比如說,如果當前activeExpireCycle函數在遍歷10號數據庫時返回了,那么下次activeExpireCycle函數執行時,將從11號數據庫開始查找並刪除過期鍵。
❑隨着activeExpireCycle函數的不斷執行,服務器中的所有數據庫都會被檢查一遍,這時函數將current_db變量重置為0,然后再次開始新一輪的檢查工作。
總結:
❑Redis服務器的所有數據庫都保存在redisServer.db數組中,而數據庫的數量則由redisServer.dbnum屬性保存。
❑客戶端通過修改目標數據庫指針,讓它指向redisServer.db數組中的不同元素來切換不同的數據庫。
❑數據庫主要由dict和expires兩個字典構成,其中dict字典負責保存鍵值對,而expires字典則負責保存鍵的過期時間。
❑因為數據庫由字典構成,所以對數據庫的操作都是建立在字典操作之上的。
❑數據庫的鍵總是一個字符串對象,而值則可以是任意一種Redis對象類型,包括字符串對象、哈希表對象、集合對象、列表對象和有序集合對象,分別對應字符串鍵、哈希表鍵、集合鍵、列表鍵和有序集合鍵。
❑expires字典的鍵指向數據庫中的某個鍵,而值則記錄了數據庫鍵的過期時間,過期時間是一個以毫秒為單位的UNIX時間戳。
❑Redis使用惰性刪除和定期刪除兩種策略來刪除過期的鍵:惰性刪除策略只在碰到過期鍵時才進行刪除操作,定期刪除策略則每隔一段時間主動查找並刪除過期鍵。
❑執行SAVE命令或者BGSAVE命令所產生的新RDB文件不會包含已經過期的鍵。
❑執行BGREWRITEAOF命令所產生的重寫AOF文件不會包含已經過期的鍵。
❑當一個過期鍵被刪除之后,服務器會追加一條DEL命令到現有AOF文件的末尾,顯式地刪除過期鍵。
❑當主服務器刪除一個過期鍵之后,它會向所有從服務器發送一條DEL命令,顯式地刪除過期鍵。
❑從服務器即使發現過期鍵也不會自作主張地刪除它,而是等待主節點發來DEL命令,這種統一、中心化的過期鍵刪除策略可以保證主從服務器數據的一致性。
❑當Redis命令對數據庫進行修改之后,服務器會根據配置向客戶端發送數據庫通知。