天天看點

面試官:Redis 單線程已經很快,為何 6.0要引入多線程?有啥優勢?

作者:Java鬥帝之路

連結:

https://www.jianshu.com/p/ba2f082ff668

Redis作為一個基于記憶體的緩存系統,一直以高性能著稱,因沒有上下文切換以及無鎖操作,即使在單線程處理情況下,讀速度仍可達到11萬次/s,寫速度達到8.1萬次/s。但是,單線程的設計也給Redis帶來一些問題:

隻能使用CPU一個核;

如果删除的鍵過大(比如Set類型中有上百萬個對象),會導緻服務端阻塞好幾秒;

QPS難再提高。

針對上面問題,Redis在4.0版本以及6.0版本分别引入了Lazy Free以及多線程IO,逐漸向多線程過渡,下面将會做詳細介紹。

單線程原理

都說Redis是單線程的,那麼單線程是如何展現的?如何支援用戶端并發請求的?為了搞清這些問題,首先來了解下Redis是如何工作的。

Redis伺服器是一個事件驅動程式,伺服器需要處理以下兩類事件:

檔案事件:

Redis伺服器通過套接字與用戶端(或者其他Redis伺服器)進行連接配接,而檔案事件就是伺服器對套接字操作的抽象;伺服器與用戶端的通信會産生相應的檔案事件,而伺服器則通過監聽并處理這些事件來完成一系列網絡通信操作,比如連接配接accept,read,write,close等;

時間事件:

Redis伺服器中的一些操作(比如serverCron函數)需要在給定的時間點執行,而時間事件就是伺服器對這類定時操作的抽象,比如過期鍵清理,服務狀态統計等。

面試官:Redis 單線程已經很快,為何 6.0要引入多線程?有啥優勢?

如上圖,Redis将檔案事件和時間事件進行抽象,時間輪訓器會監聽I/O事件表,一旦有檔案事件就緒,Redis就會優先處理檔案事件,接着處理時間事件。在上述所有事件處理上,Redis都是以單線程形式處理,是以說Redis是單線程的。

此外,如下圖,Redis基于Reactor模式開發了自己的I/O事件處理器,也就是檔案事件處理器,Redis在I/O事件處理上,采用了I/O多路複用技術,同時監聽多個套接字,并為套接字關聯不同的事件處理函數,通過一個線程實作了多用戶端并發處理。

面試官:Redis 單線程已經很快,為何 6.0要引入多線程?有啥優勢?

正因為這樣的設計,在資料處理上避免了加鎖操作,既使得實作上足夠簡潔,也保證了其高性能。當然,Redis單線程隻是指其在事件處理上,實際上,Redis也并不是單線程的,比如生成RDB檔案,就會fork一個子程序來實作,當然,這不是本文要讨論的内容。

Lazy Free機制

如上所知,Redis在處理用戶端指令時是以單線程形式運作,而且處理速度很快,期間不會響應其他用戶端請求,但若用戶端向Redis發送一條耗時較長的指令,比如删除一個含有上百萬對象的Set鍵,或者執行flushdb,flushall操作,Redis伺服器需要回收大量的記憶體空間,導緻伺服器卡住好幾秒,對負載較高的緩存系統而言将會是個災難。為了解決這個問題,在Redis 4.0版本引入了Lazy Free,将慢操作異步化,這也是在事件處理上向多線程邁進了一步。

如作者在其部落格中所述,要解決慢操作,可以采用漸進式處理,即增加一個時間事件,比如在删除一個具有上百萬個對象的Set鍵時,每次隻删除大鍵中的一部分資料,最終實作大鍵的删除。但是,該方案可能會導緻回收速度趕不上建立速度,最終導緻記憶體耗盡。

是以,Redis最終實作上是将大鍵的删除操作異步化,采用非阻塞删除(對應指令UNLINK),大鍵的空間回收交由單獨線程實作,主線程隻做關系解除,可以快速傳回,繼續處理其他事件,避免伺服器長時間阻塞。

以删除(DEL指令)為例,看看Redis是如何實作的,下面就是删除函數的入口,其中,lazyfree_lazy_user_del是是否修改DEL指令的預設行為,一旦開啟,執行DEL時将會以UNLINK形式執行。

void delCommand(client *c) {
    delGenericCommand(c,server.lazyfree_lazy_user_del);
}

/* 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]);
        // 根據配置确定DEL在執行時是否以lazy形式執行
        int deleted  = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
                              dbSyncDelete(c->db,c->argv[j]);
        if (deleted) {
            signalModifiedKey(c,c->db,c->argv[j]);
            notifyKeyspaceEvent(NOTIFY_GENERIC,
                "del",c->argv[j],c->db->id);
            server.dirty++;
            numdel++;
        }
    }
    addReplyLongLong(c,numdel);
}      

同步删除很簡單,隻要把key和value删除,如果有内層引用,則進行遞歸删除,這裡不做介紹。下面看下異步删除,Redis在回收對象時,會先計算回收收益,隻有回收收益在超過一定值時,采用封裝成Job加入到異步處理隊列中,否則直接同步回收,這樣效率更高。回收收益計算也很簡單,比如String類型,回收收益值就是1,而Set類型,回收收益就是集合中元素個數。

/* Delete a key, value, and associated expiration entry if any, from the DB.
 * If there are enough allocations to free the value object may be put into
 * a lazy free list instead of being freed synchronously. The lazy free list
 * will be reclaimed in a different bio.c thread. */
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(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 the value is composed of a few allocations, to free in a lazy way
     * is actually just slower... So under a certain limit we just free
     * the object synchronously. */
    dictEntry *de = dictUnlink(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        // 計算value的回收收益
        size_t free_effort = lazyfreeGetFreeEffort(val);

        /* If releasing the object is too much work, do it in the background
         * by adding the object to the lazy free list.
         * Note that if the object is shared, to reclaim it now it is not
         * possible. This rarely happens, however sometimes the implementation
         * of parts of the Redis core may call incrRefCount() to protect
         * objects, and then call dbDelete(). In this case we'll fall
         * through and reach the dictFreeUnlinkedEntry() call, that will be
         * equivalent to just calling decrRefCount(). */
        // 隻有回收收益超過一定值,才會執行異步删除,否則還是會退化到同步删除
        if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
            atomicIncr(lazyfree_objects,1);
            bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
            dictSetVal(db->dict,de,NULL);
        }
    }

    /* Release the key-val pair, or just the key if we set the val
     * field to NULL in order to lazy free it later. */
    if (de) {
        dictFreeUnlinkedEntry(db->dict,de);
        if (server.cluster_enabled) slotToKeyDel(key->ptr);
        return 1;
    } else {
        return 0;
    }
}      

通過引入a threaded lazy free,Redis實作了對于Slow Operation的Lazy操作,避免了在大鍵删除,FLUSHALL,FLUSHDB時導緻伺服器阻塞。當然,在實作該功能時,不僅引入了lazy free線程,也對Redis聚合類型在存儲結構上進行改進。

因為Redis内部使用了很多共享對象,比如用戶端輸出緩存。當然,Redis并未使用加鎖來避免線程沖突,鎖競争會導緻性能下降,而是去掉了共享對象,直接采用資料拷貝,如下,在3.x和6.x中ZSet節點value的不同實作。

// 3.2.5版本ZSet節點實作,value定義robj *obj
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

// 6.0.10版本ZSet節點實作,value定義為sds ele
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;      

去掉共享對象,不但實作了lazy free功能,也為Redis向多線程跨進帶來了可能,正如作者所述:

Now that values of aggregated data types are fully unshared, and client output buffers don’t contain shared objects as well, there is a lot to exploit. For example it is finally possible to implement threaded I/O in Redis, so that different clients are served by different threads. This means that we’ll have a global lock only when accessing the database, but the clients read/write syscalls and even the parsing of the command the client is sending, can happen in different threads.

多線程I/O及其局限性

Redis在4.0版本引入了Lazy Free,自此Redis有了一個Lazy Free線程專門用于大鍵的回收,同時,也去掉了聚合類型的共享對象,這為多線程帶來可能,Redis也不負衆望,在6.0版本實作了多線程I/O。

實作原理

正如官方以前的回複,Redis的性能瓶頸并不在CPU上,而是在記憶體和網絡上。是以6.0釋出的多線程并未将事件處理改成多線程,而是在I/O上,此外,如果把事件處理改成多線程,不但會導緻鎖競争,而且會有頻繁的上下文切換,即使用分段鎖來減少競争,對Redis核心也會有較大改動,性能也不一定有明顯提升。

面試官:Redis 單線程已經很快,為何 6.0要引入多線程?有啥優勢?

如上圖紅色部分,就是Redis實作的多線程部分,利用多核來分擔I/O讀寫負荷。在事件處理線程每次擷取到可讀事件時,會将所有就緒的讀事件配置設定給I/O線程,并進行等待,在所有I/O線程完成讀操作後,事件處理線程開始執行任務處理,在處理結束後,同樣将寫事件配置設定給I/O線程,等待所有I/O線程完成寫操作。

以讀事件處理為例,看下事件處理線程任務配置設定流程:

int handleClientsWithPendingReadsUsingThreads(void) {
    ...

    /* Distribute the clients across N different lists. */
    listIter li;
    listNode *ln;
    listRewind(server.clients_pending_read,&li);
    int item_id = 0;
    // 将等待處理的用戶端配置設定給I/O線程
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);
        int target_id = item_id % server.io_threads_num;
        listAddNodeTail(io_threads_list[target_id],c);
        item_id++;
    }

    ...

    /* Wait for all the other threads to end their work. */
    // 輪訓等待所有I/O線程處理完
    while(1) {
        unsigned long pending = 0;
        for (int j = 1; j < server.io_threads_num; j++)
            pending += io_threads_pending[j];
        if (pending == 0) break;
    }

    ...

    return processed;
}      

I/O線程處理流程:

void *IOThreadMain(void *myid) {
    ...

    while(1) {
        ...

        // I/O線程執行讀寫操作
        while((ln = listNext(&li))) {
            client *c = listNodeValue(ln);
            // io_threads_op判斷是讀還是寫事件
            if (io_threads_op == IO_THREADS_OP_WRITE) {
                writeToClient(c,0);
            } else if (io_threads_op == IO_THREADS_OP_READ) {
                readQueryFromClient(c->conn);
            } else {
                serverPanic("io_threads_op value is unknown");
            }
        }
        listEmpty(io_threads_list[id]);
        io_threads_pending[id] = 0;

        if (tio_debug) printf("[%ld] Done\n", id);
    }
}      

局限性

從上面實作上看,6.0版本的多線程并非徹底的多線程,I/O線程隻能同時執行讀或者同時執行寫操作,期間事件處理線程一直處于等待狀态,并非流水線模型,有很多輪訓等待開銷。

Tair多線程實作原理

相較于6.0版本的多線程,Tair的多線程實作更加優雅。如下圖,Tair的Main Thread負責用戶端連接配接建立等,IO Thread負責請求讀取、響應發送、指令解析等,Worker Thread線程專門用于事件處理。IO Thread讀取使用者的請求并進行解析,之後将解析結果以指令的形式放在隊列中發送給Worker Thread處理。Worker Thread将指令處理完成後生成響應,通過另一條隊列發送給IO Thread。為了提高線程的并行度,IO Thread和Worker Thread之間采用無鎖隊列 和管道 進行資料交換,整體性能會更好。

面試官:Redis 單線程已經很快,為何 6.0要引入多線程?有啥優勢?

小結

Redis 4.0引入Lazy Free線程,解決了諸如大鍵删除導緻伺服器阻塞問題,在6.0版本引入了I/O Thread線程,正式實作了多線程,但相較于Tair,并不太優雅,而且性能提升上并不多,壓測看,多線程版本性能是單線程版本的2倍,Tair多線程版本則是單線程版本的3倍。在作者看來,Redis多線程無非兩種思路,I/O threading和Slow commands threading,正如作者在其部落格中所說:

I/O threading is not going to happen in Redis AFAIK, because after much consideration I think it’s a lot of complexity without a good reason. Many Redis setups are network or memory bound actually. Additionally I really believe in a share-nothing setup, so the way I want to scale Redis is by improving the support for multiple Redis instances to be executed in the same host, especially via Redis Cluster.

What instead I really want a lot is slow operations threading, and with the Redis modules system we already are in the right direction. However in the future (not sure if in Redis 6 or 7) we’ll get key-level locking in the module system so that threads can completely acquire control of a key to process slow operations. Now modules can implement commands and can create a reply for the client in a completely separated way, but still to access the shared data set a global lock is needed: this will go away.

Redis作者更傾向于采用叢集方式來解決I/O threading,尤其是在6.0版本釋出的原生Redis Cluster Proxy背景下,使得叢集更加易用。

此外,作者更傾向于slow operations threading(比如4.0版本釋出的Lazy Free)來解決多線程問題。後續版本,是否會将IO Thread實作的更加完善,采用Module實作對慢操作的優化,着實值得期待。