Redis單線(xiàn)程已經(jīng)很快了,為什么6.0要引入多線(xiàn)程?帶來(lái)什么優(yōu)勢(shì)?

只能使用CPU一個(gè)核; 如果刪除的鍵過(guò)大(比如Set類(lèi)型中有上百萬(wàn)個(gè)對(duì)象),會(huì)導(dǎo)致服務(wù)端阻塞好幾秒; QPS難再提高。
針對(duì)上面問(wèn)題,Redis在4.0版本以及6.0版本分別引入了Lazy Free以及多線(xiàn)程IO,逐步向多線(xiàn)程過(guò)渡,下面將會(huì)做詳細(xì)介紹。
單線(xiàn)程原理
都說(shuō)Redis是單線(xiàn)程的,那么單線(xiàn)程是如何體現(xiàn)的?如何支持客戶(hù)端并發(fā)請(qǐng)求的?為了搞清這些問(wèn)題,首先來(lái)了解下Redis是如何工作的。
Redis服務(wù)器是一個(gè)事件驅(qū)動(dòng)程序,服務(wù)器需要處理以下兩類(lèi)事件:
文件事件:Redis服務(wù)器通過(guò)套接字與客戶(hù)端(或者其他Redis服務(wù)器)進(jìn)行連接,而文件事件就是服務(wù)器對(duì)套接字操作的抽象;服務(wù)器與客戶(hù)端的通信會(huì)產(chǎn)生相應(yīng)的文件事件,而服務(wù)器則通過(guò)監(jiān)聽(tīng)并處理這些事件來(lái)完成一系列網(wǎng)絡(luò)通信操作,比如連接accept,read,write,close等;時(shí)間事件:Redis服務(wù)器中的一些操作(比如serverCron函數(shù))需要在給定的時(shí)間點(diǎn)執(zhí)行,而時(shí)間事件就是服務(wù)器對(duì)這類(lèi)定時(shí)操作的抽象,比如過(guò)期鍵清理,服務(wù)狀態(tài)統(tǒng)計(jì)等。
如上圖,Redis將文件事件和時(shí)間事件進(jìn)行抽象,時(shí)間輪訓(xùn)器會(huì)監(jiān)聽(tīng)I(yíng)/O事件表,一旦有文件事件就緒,Redis就會(huì)優(yōu)先處理文件事件,接著處理時(shí)間事件。在上述所有事件處理上,Redis都是以單線(xiàn)程形式處理,所以說(shuō)Redis是單線(xiàn)程的。此外,如下圖,Redis基于Reactor模式開(kāi)發(fā)了自己的I/O事件處理器,也就是文件事件處理器,Redis在I/O事件處理上,采用了I/O多路復(fù)用技術(shù),同時(shí)監(jiān)聽(tīng)多個(gè)套接字,并為套接字關(guān)聯(lián)不同的事件處理函數(shù),通過(guò)一個(gè)線(xiàn)程實(shí)現(xiàn)了多客戶(hù)端并發(fā)處理。

正因?yàn)檫@樣的設(shè)計(jì),在數(shù)據(jù)處理上避免了加鎖操作,既使得實(shí)現(xiàn)上足夠簡(jiǎn)潔,也保證了其高性能。當(dāng)然,Redis單線(xiàn)程只是指其在事件處理上,實(shí)際上,Redis也并不是單線(xiàn)程的,比如生成RDB文件,就會(huì)fork一個(gè)子進(jìn)程來(lái)實(shí)現(xiàn),當(dāng)然,這不是本文要討論的內(nèi)容。
Lazy Free機(jī)制
如上所知,Redis在處理客戶(hù)端命令時(shí)是以單線(xiàn)程形式運(yùn)行,而且處理速度很快,期間不會(huì)響應(yīng)其他客戶(hù)端請(qǐng)求,但若客戶(hù)端向Redis發(fā)送一條耗時(shí)較長(zhǎng)的命令,比如刪除一個(gè)含有上百萬(wàn)對(duì)象的Set鍵,或者執(zhí)行flushdb,flushall操作,Redis服務(wù)器需要回收大量的內(nèi)存空間,導(dǎo)致服務(wù)器卡住好幾秒,對(duì)負(fù)載較高的緩存系統(tǒng)而言將會(huì)是個(gè)災(zāi)難。為了解決這個(gè)問(wèn)題,在Redis 4.0版本引入了Lazy Free,將慢操作異步化,這也是在事件處理上向多線(xiàn)程邁進(jìn)了一步。
如作者在其博客中所述,要解決慢操作,可以采用漸進(jìn)式處理,即增加一個(gè)時(shí)間事件,比如在刪除一個(gè)具有上百萬(wàn)個(gè)對(duì)象的Set鍵時(shí),每次只刪除大鍵中的一部分?jǐn)?shù)據(jù),最終實(shí)現(xiàn)大鍵的刪除。但是,該方案可能會(huì)導(dǎo)致回收速度趕不上創(chuàng)建速度,最終導(dǎo)致內(nèi)存耗盡。因此,Redis最終實(shí)現(xiàn)上是將大鍵的刪除操作異步化,采用非阻塞刪除(對(duì)應(yīng)命令UNLINK),大鍵的空間回收交由單獨(dú)線(xiàn)程實(shí)現(xiàn),主線(xiàn)程只做關(guān)系解除,可以快速返回,繼續(xù)處理其他事件,避免服務(wù)器長(zhǎng)時(shí)間阻塞。
以刪除(DEL命令)為例,看看Redis是如何實(shí)現(xiàn)的,下面就是刪除函數(shù)的入口,其中,lazyfree_lazy_user_del是是否修改DEL命令的默認(rèn)行為,一旦開(kāi)啟,執(zhí)行DEL時(shí)將會(huì)以UNLINK形式執(zhí)行。
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]);
// 根據(jù)配置確定DEL在執(zhí)行時(shí)是否以lazy形式執(zhí)行
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);
}`
同步刪除很簡(jiǎn)單,只要把key和value刪除,如果有內(nèi)層引用,則進(jìn)行遞歸刪除,這里不做介紹。下面看下異步刪除,Redis在回收對(duì)象時(shí),會(huì)先計(jì)算回收收益,只有回收收益在超過(guò)一定值時(shí),采用封裝成Job加入到異步處理隊(duì)列中,否則直接同步回收,這樣效率更高。回收收益計(jì)算也很簡(jiǎn)單,比如String類(lèi)型,回收收益值就是1,而Set類(lèi)型,回收收益就是集合中元素個(gè)數(shù)。
/* 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);
// 計(jì)算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(). */
// 只有回收收益超過(guò)一定值,才會(huì)執(zhí)行異步刪除,否則還是會(huì)退化到同步刪除
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;
}
}`
通過(guò)引入a threaded lazy free,Redis實(shí)現(xiàn)了對(duì)于Slow Operation的Lazy操作,避免了在大鍵刪除,FLUSHALL,FLUSHDB時(shí)導(dǎo)致服務(wù)器阻塞。當(dāng)然,在實(shí)現(xiàn)該功能時(shí),不僅引入了lazy free線(xiàn)程,也對(duì)Redis聚合類(lèi)型在存儲(chǔ)結(jié)構(gòu)上進(jìn)行改進(jìn)。因?yàn)镽edis內(nèi)部使用了很多共享對(duì)象,比如客戶(hù)端輸出緩存。當(dāng)然,Redis并未使用加鎖來(lái)避免線(xiàn)程沖突,鎖競(jìng)爭(zhēng)會(huì)導(dǎo)致性能下降,而是去掉了共享對(duì)象,直接采用數(shù)據(jù)拷貝,如下,在3.x和6.x中ZSet節(jié)點(diǎn)value的不同實(shí)現(xiàn)。
// 3.2.5版本ZSet節(jié)點(diǎn)實(shí)現(xiàn),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節(jié)點(diǎn)實(shí)現(xiàn),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;`
去掉共享對(duì)象,不但實(shí)現(xiàn)了lazy free功能,也為Redis向多線(xiàn)程跨進(jìn)帶來(lái)了可能,正如作者所述:
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.
多線(xiàn)程I/O及其局限性
Redis在4.0版本引入了Lazy Free,自此Redis有了一個(gè)Lazy Free線(xiàn)程專(zhuān)門(mén)用于大鍵的回收,同時(shí),也去掉了聚合類(lèi)型的共享對(duì)象,這為多線(xiàn)程帶來(lái)可能,Redis也不負(fù)眾望,在6.0版本實(shí)現(xiàn)了多線(xiàn)程I/O。
實(shí)現(xiàn)原理
正如官方以前的回復(fù),Redis的性能瓶頸并不在CPU上,而是在內(nèi)存和網(wǎng)絡(luò)上。因此6.0發(fā)布的多線(xiàn)程并未將事件處理改成多線(xiàn)程,而是在I/O上,此外,如果把事件處理改成多線(xiàn)程,不但會(huì)導(dǎo)致鎖競(jìng)爭(zhēng),而且會(huì)有頻繁的上下文切換,即使用分段鎖來(lái)減少競(jìng)爭(zhēng),對(duì)Redis內(nèi)核也會(huì)有較大改動(dòng),性能也不一定有明顯提升。

如上圖紅色部分,就是Redis實(shí)現(xiàn)的多線(xiàn)程部分,利用多核來(lái)分擔(dān)I/O讀寫(xiě)負(fù)荷。在事件處理線(xiàn)程每次獲取到可讀事件時(shí),會(huì)將所有就緒的讀事件分配給I/O線(xiàn)程,并進(jìn)行等待,在所有I/O線(xiàn)程完成讀操作后,事件處理線(xiàn)程開(kāi)始執(zhí)行任務(wù)處理,在處理結(jié)束后,同樣將寫(xiě)事件分配給I/O線(xiàn)程,等待所有I/O線(xiàn)程完成寫(xiě)操作。
以讀事件處理為例,看下事件處理線(xiàn)程任務(wù)分配流程:
int handleClientsWithPendingReadsUsingThreads(void) {
...
/* Distribute the clients across N different lists. */
listIter li;
listNode *ln;
listRewind(server.clients_pending_read,&li);
int item_id = 0;
// 將等待處理的客戶(hù)端分配給I/O線(xiàn)程
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. */
// 輪訓(xùn)等待所有I/O線(xiàn)程處理完
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線(xiàn)程處理流程:
void *IOThreadMain(void *myid) {
...
while(1) {
...
// I/O線(xiàn)程執(zhí)行讀寫(xiě)操作
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// io_threads_op判斷是讀還是寫(xiě)事件
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);
}
}`
局限性
從上面實(shí)現(xiàn)上看,6.0版本的多線(xiàn)程并非徹底的多線(xiàn)程,I/O線(xiàn)程只能同時(shí)執(zhí)行讀或者同時(shí)執(zhí)行寫(xiě)操作,期間事件處理線(xiàn)程一直處于等待狀態(tài),并非流水線(xiàn)模型,有很多輪訓(xùn)等待開(kāi)銷(xiāo)。
Tair多線(xiàn)程實(shí)現(xiàn)原理
相較于6.0版本的多線(xiàn)程,Tair的多線(xiàn)程實(shí)現(xiàn)更加優(yōu)雅。如下圖,Tair的Main Thread負(fù)責(zé)客戶(hù)端連接建立等,IO Thread負(fù)責(zé)請(qǐng)求讀取、響應(yīng)發(fā)送、命令解析等,Worker Thread線(xiàn)程專(zhuān)門(mén)用于事件處理。IO Thread讀取用戶(hù)的請(qǐng)求并進(jìn)行解析,之后將解析結(jié)果以命令的形式放在隊(duì)列中發(fā)送給Worker Thread處理。Worker Thread將命令處理完成后生成響應(yīng),通過(guò)另一條隊(duì)列發(fā)送給IO Thread。為了提高線(xiàn)程的并行度,IO Thread和Worker Thread之間采用無(wú)鎖隊(duì)列 和管道 進(jìn)行數(shù)據(jù)交換,整體性能會(huì)更好。

小結(jié)
Redis 4.0引入Lazy Free線(xiàn)程,解決了諸如大鍵刪除導(dǎo)致服務(wù)器阻塞問(wèn)題,在6.0版本引入了I/O Thread線(xiàn)程,正式實(shí)現(xiàn)了多線(xiàn)程,但相較于Tair,并不太優(yōu)雅,而且性能提升上并不多,壓測(cè)看,多線(xiàn)程版本性能是單線(xiàn)程版本的2倍,Tair多線(xiàn)程版本則是單線(xiàn)程版本的3倍。在作者看來(lái),Redis多線(xiàn)程無(wú)非兩種思路,I/O threading和Slow commands threading,正如作者在其博客中所說(shuō):
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作者更傾向于采用集群方式來(lái)解決I/O threading,尤其是在6.0版本發(fā)布的原生Redis Cluster Proxy背景下,使得集群更加易用。
此外,作者更傾向于slow operations threading(比如4.0版本發(fā)布的Lazy Free)來(lái)解決多線(xiàn)程問(wèn)題。后續(xù)版本,是否會(huì)將IO Thread實(shí)現(xiàn)的更加完善,采用Module實(shí)現(xiàn)對(duì)慢操作的優(yōu)化,著實(shí)值得期待。
- end -
用心分享面試知識(shí),做有溫度的攻城獅
每天記得對(duì)自己說(shuō):你是最棒的!
往期推薦:
拜托!不要用“ ! = null " 做判空了 IDEA這樣配置注釋模板,讓你高出一個(gè)逼格!! 翻車(chē)!在項(xiàng)目中用了Arrays.asList、ArrayList的subList,被公開(kāi)批評(píng) 每一個(gè)“好看”,都是對(duì)我們最大的
