redis4.0之lazyfree
背景
redis重度使用患者應該都遇到過使用 DEL 命令刪除體積較大的鍵, 又或者在使用 FLUSHDB 和 FLUSHALL 刪除包含大量鍵的數據庫時,造成redis阻塞的情況;另外redis在清理過期數據和淘汰內存超限的數據時,如果碰巧撞到了大體積的鍵也會造成服務器阻塞。
為了解決以上問題, redis 4.0 引入了lazyfree的機製,它可以將刪除鍵或數據庫的操作放在後台線程裏執行, 從而盡可能地避免服務器阻塞。
lazyfree機製
lazyfree的原理不難想象,就是在刪除對象時隻是進行邏輯刪除,然後把對象丟給後台,讓後台線程去執行真正的destruct,避免由於對象體積過大而造成阻塞。redis的lazyfree實現即是如此,下麵我們由幾個命令來介紹下lazyfree的實現。
1. UNLINK命令
首先我們來看下新增的unlink命令:
void unlinkCommand(client *c) {
delGenericCommand(c, 1);
}
入口很簡單,就是調用delGenericCommand,第二個參數為1表示需要異步刪除。
/* This command implements DEL and LAZYDEL. */
void delGenericCommand(client *c, int lazy) {
int numdel = 0, j;
for (j = 1; j < c->argc; j++) {
expireIfNeeded(c->db,c->argv[j]);
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
dbSyncDelete(c->db,c->argv[j]);
if (deleted) {
signalModifiedKey(c->db,c->argv[j]);
notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,
"del",c->argv[j],c->db->id);
server.dirty++;
numdel++;
}
}
addReplyLongLong(c,numdel);
}
delGenericCommand函數根據lazy參數來決定是同步刪除還是異步刪除,同步刪除的邏輯沒有什麼變化就不細講了,我們重點看下新增的異步刪除的實現。
#define LAZYFREE_THRESHOLD 64
// 首先定義了啟用後台刪除的閾值,對象中的元素大於該閾值時才真正丟給後台線程去刪除,如果對象中包含的元素太少就沒有必要丟給後台線程,因為線程同步也要一定的消耗。
int dbAsyncDelete(redisDb *db, robj *key) {
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
//清除待刪除key的過期時間
dictEntry *de = dictUnlink(db->dict,key->ptr);
//dictUnlink返回數據庫字典中包含key的條目指針,並從數據庫字典中摘除該條目(並不會釋放資源)
if (de) {
robj *val = dictGetVal(de);
size_t free_effort = lazyfreeGetFreeEffort(val);
//lazyfreeGetFreeEffort來獲取val對象所包含的元素個數
if (free_effort > LAZYFREE_THRESHOLD) {
atomicIncr(lazyfree_objects,1);
//原子操作給lazyfree_objects加1,以備info命令查看有多少對象待後台線程刪除
bioCreateBackgroundJob(BIO_LAZY_FREE ,val,NULL,NULL);
//此時真正把對象val丟到後台線程的任務隊列中
dictSetVal(db->dict,de,NULL);
//把條目裏的val指針設置為NULL,防止刪除數據庫字典條目時重複刪除val對象
}
}
if (de) {
dictFreeUnlinkedEntry(db->dict,de);
//刪除數據庫字典條目,釋放資源
return 1;
} else {
return 0;
}
}
以上便是異步刪除的邏輯,首先會清除過期時間,然後調用dictUnlink把要刪除的對象從數據庫字典摘除,再判斷下對象的大小(太小就沒必要後台刪除),如果足夠大就丟給後台線程,最後清理下數據庫字典的條目信息。
由以上的邏輯可以看出,當unlink一個體積較大的鍵時,實際的刪除是交給後台線程完成的,所以並不會阻塞redis。
2. FLUSHALL、FLUSHDB命令
4.0給flush類命令新加了option——async,當flush類命令後麵跟上async選項時,就會進入後台刪除邏輯,代碼如下:
/* FLUSHDB [ASYNC]
*
* Flushes the currently SELECTed Redis DB. */
void flushdbCommand(client *c) {
int flags;
if (getFlushCommandFlags(c,&flags) == C_ERR) return;
signalFlushedDb(c->db->id);
server.dirty += emptyDb(c->db->id,flags,NULL);
addReply(c,shared.ok);
sds client = catClientInfoString(sdsempty(),c);
serverLog(LL_NOTICE, "flushdb called by client %s", client);
sdsfree(client);
}
/* FLUSHALL [ASYNC]
*
* Flushes the whole server data set. */
void flushallCommand(client *c) {
int flags;
if (getFlushCommandFlags(c,&flags) == C_ERR) return;
signalFlushedDb(-1);
server.dirty += emptyDb(-1,flags,NULL);
addReply(c,shared.ok);
...
}
flushdb和flushall邏輯基本一致,都是先調用getFlushCommandFlags來獲取flags(其用來標識是否采用異步刪除),然後調用emptyDb來清空數據庫,第一個參數為-1時說明要清空所有數據庫。
long long emptyDb(int dbnum, int flags, void(callback)(void*)) {
int j, async = (flags & EMPTYDB_ASYNC);
long long removed = 0;
if (dbnum < -1 || dbnum >= server.dbnum) {
errno = EINVAL;
return -1;
}
for (j = 0; j < server.dbnum; j++) {
if (dbnum != -1 && dbnum != j) continue;
removed += dictSize(server.db[j].dict);
if (async) {
emptyDbAsync(&server.db[j]);
} else {
dictEmpty(server.db[j].dict,callback);
dictEmpty(server.db[j].expires,callback);
}
}
return removed;
}
進入emptyDb後首先是一些校驗步驟,校驗通過後開始執行清空數據庫,同步刪除就是調用dictEmpty循環遍曆數據庫的所有對象並刪除(這時就容易阻塞redis),今天的核心在異步刪除emptyDbAsync函數。
/* Empty a Redis DB asynchronously. What the function does actually is to
* create a new empty set of hash tables and scheduling the old ones for
* lazy freeing. */
void emptyDbAsync(redisDb *db) {
dict *oldht1 = db->dict, *oldht2 = db->expires;
db->dict = dictCreate(&dbDictType,NULL);
db->expires = dictCreate(&keyptrDictType,NULL);
atomicIncr(lazyfree_objects,dictSize(oldht1));
bioCreateBackgroundJob(BIO_LAZY_FREE,NULL,oldht1,oldht2);
}
這裏直接把db->dict和db->expires指向了新創建的兩個空字典,然後把原來兩個字典丟到後台線程的任務隊列就好了,簡單高效,再也不怕阻塞redis了。
lazyfree線程
接下來介紹下真正幹活的lazyfree線程。
首先要澄清一個誤區,很多人提到redis時都會講這是一個單線程的內存數據庫,其實不然。雖然redis把處理網絡收發和執行命令這些操作都放在了主工作線程,但是除此之外還有許多bio後台線程也在兢兢業業的工作著,比如用來處理關閉文件和刷盤這些比較重的IO操作,這次bio家族又加入了新的小夥伴——lazyfree線程。
void *bioProcessBackgroundJobs(void *arg) {
...
if (type == BIO_LAZY_FREE) {
/* What we free changes depending on what arguments are set:
* arg1 -> free the object at pointer.
* arg2 & arg3 -> free two dictionaries (a Redis DB).
* only arg3 -> free the skiplist. */
if (job->arg1)
lazyfreeFreeObjectFromBioThread(job->arg1);
else if (job->arg2 && job->arg3)
lazyfreeFreeDatabaseFromBioThread(job->arg2, job->arg3);
else if (job->arg3)
lazyfreeFreeSlotsMapFromBioThread(job->arg3);
}
...
}
redis給新加入的lazyfree線程起了個名字叫BIO_LAZY_FREE,後台線程根據type判斷出自己是lazyfree線程,然後再根據bio_job裏的參數情況去執行相對應的函數。
-
後台刪除對象,調用decrRefCount來減少對象的引用計數,引用計數為0時會真正的釋放資源。
void lazyfreeFreeObjectFromBioThread(robj *o) { decrRefCount(o); atomicDecr(lazyfree_objects,1); }
-
後台清空數據庫字典,調用dictRelease循環遍曆數據庫字典刪除所有對象。
void lazyfreeFreeDatabaseFromBioThread(dict *ht1, dict *ht2) { size_t numkeys = dictSize(ht1); dictRelease(ht1); dictRelease(ht2); atomicDecr(lazyfree_objects,numkeys); }
-
後台刪除key-slots映射表,原生redis如果運行在集群模式下會用,雲redis使用的自研集群模式這一函數目前並不會調用。
void lazyfreeFreeSlotsMapFromBioThread(rax *rt) { size_t len = rt->numele; raxFree(rt); atomicDecr(lazyfree_objects,len); }
過期與逐出
redis的過期與逐出策略可以參考《ApsaraDB for Redis之內存去哪兒了(一)數據過期與逐出策略》,而在此期間的刪除動作也可能會阻塞redis。
所以redis 4.0這次除了顯示增加unlink、flushdb async、flushall async命令之外,還增加了4個後台刪除配置項,分別為:
- slave-lazy-flush:slave接收完RDB文件後清空數據選項
- lazyfree-lazy-eviction:內存滿逐出選項
- lazyfree-lazy-expire:過期key刪除選項
- lazyfree-lazy-server-del:內部刪除選項,比如rename oldkey newkey時,如果newkey存在需要刪除newkey
以上4個選項默認為同步刪除,可以通過config set [parameter] yes打開後台刪除功能。
後台刪除的功能無甚修改,隻是在原先同步刪除的地方根據以上4個配置項來選擇是否調用dbAsyncDelete或者emptyDbAsync進行異步刪除,具體代碼可見:
-
slave-lazy-flush
void readSyncBulkPayload(aeEventLoop *el, int fd, void *privdata, int mask) { ... if (eof_reached) { ... emptyDb( -1, server.repl_slave_lazy_flush ? EMPTYDB_ASYNC : EMPTYDB_NO_FLAGS, replicationEmptyDbCallback); ... } ... }
-
lazyfree-lazy-eviction
int freeMemoryIfNeeded(long long timelimit) { ... /* Finally remove the selected key. */ if (bestkey) { ... propagateExpire(db,keyobj,server.lazyfree_lazy_eviction); if (server.lazyfree_lazy_eviction) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); ... } ... }
-
lazyfree-lazy-expire
int activeExpireCycleTryExpire(redisDb *db, struct dictEntry *de, long long now) { ... if (now > t) { ... propagateExpire(db,keyobj,server.lazyfree_lazy_expire); if (server.lazyfree_lazy_expire) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj); ... } ... }
-
lazyfree-lazy-server-del
int dbDelete(redisDb *db, robj *key) { return server.lazyfree_lazy_server_del ? dbAsyncDelete(db,key) : dbSyncDelete(db,key); }
此外雲redis對過期和逐出做了一點微小的改進。
expire優化
redis在空閑時會進入activeExpireCycle循環刪除過期key,每次循環都會率先計算一個執行時間,在循環中並不會遍曆整個數據庫,而是隨機挑選一部分key查看是否到期,所以有時時間不會被耗盡(采取異步刪除時更會加快清理過期key),剩餘的時間就可以交給freeMemoryIfNeeded來執行。
void activeExpireCycle(int type) {
...
afterexpire:
if (!g_redis_c_timelimit_exit &&
server.maxmemory > 0 &&
zmalloc_used_memory() > server.maxmemory)
{
long long time_canbe_used = timelimit - (ustime() - start);
if (time_canbe_used > 0) freeMemoryIfNeeded(time_canbe_used);
}
}
evict優化
如前所述,如果evict未能根據逐出策略釋放足夠多的內存空間,就可以查看BIO_LAZY_FREE後台線程的任務隊列,嚐試等待後台線程來釋放空間。如果後台線程釋放了足夠的內存就返回C_OK,如果超時或是後台線程執行完畢仍不能釋放足夠多的內存空間,那就隻能返回C_ERR了。
int freeMemoryIfNeeded(long long timelimit) {
...
wait_bio_free:
while(bioPendingJobsOfType(BIO_LAZY_FREE )) {
if (timelimit > 0 && (ustime()-start) > timelimit) {
g_redis_c_timelimit_exit = 1;
break;
}
delta = (long long) zmalloc_used_memory();
usleep(1000);
delta -= (long long) zmalloc_used_memory();
mem_freed += delta;
if (mem_freed >= mem_tofree)
return C_OK;
}
return C_ERR;
}
結束
基於4.0的雲redis正在籌備之中,敬請期待。
最後更新:2017-09-13 14:33:36