閱讀725 返回首頁    go 阿裏雲 go 技術社區[雲棲]


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裏的參數情況去執行相對應的函數。

  1. 後台刪除對象,調用decrRefCount來減少對象的引用計數,引用計數為0時會真正的釋放資源。

    void lazyfreeFreeObjectFromBioThread(robj *o) {
        decrRefCount(o);
        atomicDecr(lazyfree_objects,1);
    }
    
  2. 後台清空數據庫字典,調用dictRelease循環遍曆數據庫字典刪除所有對象。

    void lazyfreeFreeDatabaseFromBioThread(dict *ht1, dict *ht2) {
        size_t numkeys = dictSize(ht1);
        dictRelease(ht1);
        dictRelease(ht2);
        atomicDecr(lazyfree_objects,numkeys);
    }
    
  3. 後台刪除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進行異步刪除,具體代碼可見:

  1. 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);
            ...
        }
        ...
    }
    
  2. 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);
                    ...
                }
        ...
    }         
    
  3. 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);
            ...
        }
        ...
    }
    
  4. 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

  上一篇:go  HybridDB for PostgreSQL排序鍵使用
  下一篇:go  ? 阿裏雲前端工程化工具 Dawn 正式開源!