原文地址:https://blog.csdn.net/luoyu_/article/details/83090576
1. 問題簡述
前幾天接收到報警,同時Redis團隊監(jiān)控到redis集群發(fā)生了主從切換;
最終分析原因是,刪除大key,導(dǎo)致redis主服務(wù)器阻塞,sentinel哨兵認(rèn)為主服務(wù)器宕機,進行了故障轉(zhuǎn)移;如下圖所示:
在Redis集群中,應(yīng)用程序盡量避免使用大鍵;直接影響容易導(dǎo)致集群的容量和請求出現(xiàn)”傾斜問題“,同時在刪除大鍵或者打鍵過期時,容易出現(xiàn)故障切換和應(yīng)用程序雪崩的故障;
查詢線上有一個集合鍵,集合oea_set_star_ol_2017元素個數(shù)達到4300萬;當(dāng)刪除這個鍵,或者鍵過期時,會阻塞redis主進程,從而發(fā)生了主從切換;(集合中的每個元素對象都要釋放內(nèi)存空間,時間復(fù)雜度比較高)
2. 解決方案
眾所周知,Redis是單進程執(zhí)行命令請求的;集合已經(jīng)有4000多萬元素了,想要刪除這個集合,肯定不能直接刪除,否則必會阻塞主進程;
我們可以一點一點刪除集合中的元素;
Redis 2.8以上版本提供了這么一個命令:SCAN 命令,其相關(guān)的 SSCAN 命令、 HSCAN 命令和 ZSCAN 命令;
它們每次執(zhí)行都只會返回少量元素;(而不會出現(xiàn)像 KEYS命令、 SMEMBERS 命令帶來問題 —— 當(dāng) KEYS 命令被用于處理一個大的數(shù)據(jù)庫時, 又或者 SMEMBERS 命令被用于處理一個大的集合鍵時, 它們可能會阻塞服務(wù)器達數(shù)秒之久。)
我們可以這樣做:通過HSCAN,每次獲取500個字段,再用HDEL命令,每次刪除1個字段;
這樣雖然刪除過程時間復(fù)雜度也很高(提高客戶端復(fù)雜度,需要多次獲取key,批量執(zhí)行刪除命令),但是至少不會阻塞redis服務(wù)器。
3. 更好的解決方案
redis也發(fā)現(xiàn)了這個問題:直接使用del命令刪除大key會導(dǎo)致Redis主進程阻塞;分批次刪除,客戶端復(fù)雜度又比較高;
因此在Redis 4.0 的時候,提出了惰性刪除lazyfree:當(dāng)用戶刪除集key時,或者集合key過期需要刪除時,檢測如果集合元素大于64個,則使用惰性刪除,只解除集合對象與數(shù)據(jù)庫字典的關(guān)系,將集合對象放入待刪除隊列中,后臺現(xiàn)成依次獲取隊列中的對象,并真正的刪除;
redis 4.0 引入了lazyfree的機制,它可以將刪除鍵或數(shù)據(jù)庫的操作放在后臺線程里執(zhí)行, 從而盡可能地避免服務(wù)器阻塞。
lazyfree的原理不難想象,就是在刪除對象時只是進行邏輯刪除,然后把對象丟給后臺,讓后臺線程去執(zhí)行真正的destruct,避免由于對象體積過大而造成阻塞
下面我們深入redis源碼,分析redis惰性刪除策略;我們分析兩個方面:客戶端使用命令刪除大key,大key過期刪除;
3.1 客戶端使用命令刪除大key
redis 4.0刪除元素有兩個命令,del和unlink;del和之前版本一樣,直接刪除對象,可能會阻塞主進程,unlink就是惰性刪除;
下面看看del和unlink命令的代碼邏輯:
{"unlink",unlinkCommand,-2,"wF",0,NULL,1,-1,1,0,0}
{"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0},
void delCommand(client *c) {
? ? delGenericCommand(c,0);
}
void unlinkCommand(client *c) {
? ? delGenericCommand(c,1);
}
delGenericCommand函數(shù)第二個參數(shù)是lazy標(biāo)志;0同步刪除,1惰性/異步刪除,先解除對象數(shù)據(jù)庫字典關(guān)聯(lián)關(guān)系,再調(diào)用后臺線程釋放對象空間;
//lazy表示是否懶刪除
void delGenericCommand(client *c, int lazy) {
? ? int numdel = 0, j;
? ? for (j = 1; j < c->argc; j++) {
? ? ? ? expireIfNeeded(c->db,c->argv[j]); //校驗對象是否過期(順便說一下,redis數(shù)據(jù)庫有兩個字典:對象字典 存儲鍵值對,過期時間字典 存儲鍵和過期時間)
? ? ? ? int deleted? = lazy ? dbAsyncDelete(c->db,c->argv[j]) : //根據(jù)lazy表示執(zhí)行同步/異步刪除操作
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? dbSyncDelete(c->db,c->argv[j]);
? ? ? ? if (deleted) {
? ? ? ? ? ? signalModifiedKey(c->db,c->argv[j]);
? ? ? ? ? ? notifyKeyspaceEvent(NOTIFY_GENERIC,
? ? ? ? ? ? ? ? "del",c->argv[j],c->db->id);
? ? ? ? ? ? server.dirty++;
? ? ? ? ? ? numdel++;
? ? ? ? }
? ? }
? ? addReplyLongLong(c,numdel);
}
刪除命令之前如果檢測到這個key已過期,則執(zhí)行過期刪除操作;
int expireIfNeeded(redisDb *db, robj *key) {
? ? mstime_t when = getExpire(db,key);
? ? mstime_t now;
? ? if (when < 0) return 0; //key沒有配置過期時間
? ? //正在加載db,直接返回
? ? if (server.loading) return 0;
? ? //slave機器,不處理
? ? if (server.masterhost != NULL) return now > when;
? ? //沒有到期
? ? if (now <= when) return 0;
? ? //刪除
? ? server.stat_expiredkeys++;
? ? propagateExpire(db,key,server.lazyfree_lazy_expire); //傳播到期刪除命令給aof和slaves
? ? notifyKeyspaceEvent(NOTIFY_EXPIRED,
? ? ? ? "expired",key,db->id);
? ? return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : //根據(jù)過期刪除策略決定同步/異步刪除(用戶可配置)
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? dbSyncDelete(db,key);
}
惰性刪除時,會執(zhí)行異步刪除函數(shù)
//異步刪除函數(shù):
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
? ? //刪除過期字典
? ? if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
? ? //從字典刪除鍵值對,并返回
? ? dictEntry *de = dictUnlink(db->dict,key->ptr);
? ? if (de) {
? ? ? ? robj *val = dictGetVal(de);
? ? ? ? size_t free_effort = lazyfreeGetFreeEffort(val); //獲得當(dāng)前對象長度(列表元素數(shù)目,hash對象鍵值對數(shù)目。。。)
? ? ? ? //當(dāng)對象元素超過64個,且對象引用計數(shù)為1,才會懶刪除;
? ? ? ? //開啟bio后臺線程刪除
? ? ? ? if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
? ? ? ? ? ? atomicIncr(lazyfree_objects,1);
? ? ? ? ? ? bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);? //子線程刪除
? ? ? ? ? ? dictSetVal(db->dict,de,NULL);
? ? ? ? }
? ? }
? ? //釋放鍵值對(假如懶釋放,這里只釋放鍵對象)
? ? if (de) {
? ? ? ? dictFreeUnlinkedEntry(db->dict,de);
? ? ? ? if (server.cluster_enabled) slotToKeyDel(key);
? ? ? ? return 1;
? ? } else {
? ? ? ? return 0;
? ? }
}
//同步刪除函數(shù),直接刪除
int dbSyncDelete(redisDb *db, robj *key) {
? ? /* Deleting an entry from the expires dict will not free the sds of
? ? * the key, because it is shared with the main dictionary. */
? ? if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
? ? if (dictDelete(db->dict,key->ptr) == DICT_OK) {
? ? ? ? if (server.cluster_enabled) slotToKeyDel(key);
? ? ? ? return 1;
? ? } else {
? ? ? ? return 0;
? ? }
}
3.2 過期刪除
對于過期鍵有三種檢測策略:
1.添加定時器:設(shè)置key過期時間時,添加定時器,定時執(zhí)行過期刪除(沒有這么做)
2.周期性檢測:周期性檢測若干key過期時間,過期則刪除;
3.訪問這個key時,如果已經(jīng)過期,則刪除
redis結(jié)合2和3兩種策略,實現(xiàn)過期鍵的檢測;
過期鍵刪除函數(shù)如下所示:
//過期鍵刪除函數(shù)
int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
? ? long long t = dictGetSignedIntegerVal(de);
? ? if (now > t) {
? ? ? ? sds key = dictGetKey(de);
? ? ? ? robj *keyobj = createStringObject(key,sdslen(key)); //數(shù)據(jù)庫字典key存儲的是字符串對象;過期字典key存儲的是sds
? ? ? ? //代碼基本與刪除key代碼相同;
? ? ? ? propagateExpire(db,keyobj,server.lazyfree_lazy_expire);
? ? ? ? if (server.lazyfree_lazy_expire)? ? //過期刪除時,是否執(zhí)行異步刪除操作,由用戶配置,server.lazyfree_lazy_expire
? ? ? ? ? ? dbAsyncDelete(db,keyobj);
? ? ? ? else
? ? ? ? ? ? dbSyncDelete(db,keyobj);
? ? ? ? notifyKeyspaceEvent(NOTIFY_EXPIRED,
? ? ? ? ? ? "expired",keyobj,db->id);
? ? ? ? decrRefCount(keyobj);
? ? ? ? server.stat_expiredkeys++;
? ? ? ? return 1;
? ? } else {
? ? ? ? return 0;
? ? }
}
4.總結(jié)
對于大key刪除,上面提出了兩種方案
對于低版本redis 2.8以上 4.0以下:使用scan命令分批次獲得大key中的元素,分批次刪除,直到刪除大key中的所有元素;
客戶端刪除大key時,使用unlink命令,其會執(zhí)行惰性刪除策略,只是邏輯刪除大key,真正的刪除是在后臺線程進行的;而對于過期刪除,則需要用戶配置server.lazyfree_lazy_expir,這樣redis在刪除過期鍵時,才會執(zhí)行惰性刪除策略。