Redis新版本引入多執行緒,有哪些利弊?
原文連結:https://mp.weixin.qq.com/s/-8yWv5w4x7-QDqFJ3DJCzg
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將檔案事件和時間事件進行抽象,時間輪訓器會監聽I/O事件表,一旦有檔案事件就緒,Redis就會優先處理檔案事件,接著處理時間事件。在上述所有事件處理上,Redis都是以單執行緒
此外,如下圖,Redis基於Reactor模式開發了自己的I/O事件處理器,也就是檔案事件處理器,Redis在I/O事件處理上,採用了I/O多路複用技術,同時監聽多個套接字,併為套接字關聯不同的事件處理函式,通過一個執行緒實現了多客戶端併發處理。
多路複用件
正因為這樣的設計,在資料處理上避免了加鎖操作,既使得實現上足夠簡潔,也保證了其高效能。當然,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核心也會有較大改動,效能也不一定有明顯提升。
多執行緒IO實現
如上圖紅色部分,就是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
之間採用無鎖佇列和管道進行資料交換,整體效能會更好。
Tair多執行緒實現
小結
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實現對慢操作的優化,著實值得期待。