作者: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將檔案事件和時間事件進行抽象,時間輪訓器會監聽I/O事件表,一旦有檔案事件就緒,Redis就會優先處理檔案事件,接著處理時間事件,在上述所有事件處理上,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內核也會有較大改動,性能也不一定有明顯提升,

如上圖紅色部分,就是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 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實作對慢操作的優化,著實值得期待,
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2021最新版)
2.別在再滿屏的 if/ else 了,試試策略模式,真香!!
3.臥槽!Java 中的 xx ≠ null 是什么新語法?
4.Spring Boot 2.5 重磅發布,黑暗模式太炸了!
5.《Java開發手冊(嵩山版)》最新發布,速速下載!
覺得不錯,別忘了隨手點贊+轉發哦!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/300842.html
標籤:Java
