天天看點

Redis單線程已經很快了6.0引入多線程

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實作對慢操作的優化,着實值得期待。

繼續閱讀