導讀:
Redis可以輕鬆支撐100k+ QPS,離不開基於Reactor模型的I/O Multiplexing,In-memory操作,以及單線程執行命令避免靜態消耗。儘管性能已經能滿足大多數應用場景,但是如何繼續在迭代中繼續優化,以及在多核時代利用上多線程的優勢,也是大家關注的重點。我們知道性能優化在系統資源層面可以從I/O以及CPU上入手,對於Redis而言,其功能不過度依賴CPU計算能力,即不是CPU密集型的應用,而In-memory的操作也繞開了通常會拖慢性能的磁盤I/O,所以在Redis 6.0版本中,將從網絡I/O入手,引入Threaded I/O輔助讀寫,在一些場景下實現了大幅度的性能提升。本文將介紹Redis的事件模型,分析Threaded I/O是如何幫助提升性能,以及其實現的原理。
Introduction
Redis從6.0版本開始引入了Threaded I/O,目的是爲了提升執行命令前後的網絡I/O性能。本文會先從Redis的主流程開始分析,講解網絡I/O發生在哪裏,以及現有的網絡I/O模型,然後介紹Threaded I/O的新模型、實現以及生效場景,最後會進行場景測試,對比Threaded I/O關閉與開啓,以及啓用Threaded I/O與在單實例上搭建集羣的性能差異。如果你已經瞭解過Redis的循環流程,可以直接跳至浪Threaded I/O相關 的部分;如果你只關心新功能的實際提升,可以跳至 性能測試 部分查看。
Redis是如何運行的
事件循環
main
Redis的入口位於server.c下, main() 方法流程如圖所示。
在意main() 方法中Redis首先需要做的是 初始化各種庫以及服務配置 。具體舉例:
crc64_init() 會初始化一個crc校驗用的Lookup Table
getRandomBytes() 爲 hashseed 填充隨機元素作爲初始化值,用作哈希表的seed
...
initServerConfig() 中執行了大量對 server 對象屬性的初始化操作:
初始化 server.runid ,如 16e05f486b8d41e79593a35c8b96edaff101c194
獲取當前的時區信息,存放至 server.timezone 中
初始化 server.next_client_id 值,使得連接進來的客戶端id從1開始自增
...
ACLInit() 是對Redis 6.0新增的ACL系統的初始化操作,包括初始化用戶列表、ACL日誌、默認用戶等信息
通過 moduleInitModulesSystem() 和 tlsInit() 初始化模塊系統和SSL等
...
初始化結束後,開始 讀取用戶的啓動參數 ,和大多數配置加載過程類似,Redis也通過字符串匹配等分析用戶輸入的 argc 和 argv[] ,這個過程中可能會發生:
獲取到配置文件路徑,修改 server.configfile 的值,後續用於加載配置文件
獲取到啓動選項參數,如 loadmodule 和對應的Module文件路徑,保存至 options 變量中
解析完參數之後,執行 loadServerConfig() , 讀取配置文件並與命令行參數options的內容進行合併 ,組成一個 config 變量,並且逐個將name和value設置進configs列表中。對於每個config,有對應的switch-case的代碼,例如對於 loadmodule ,會執行 queueLoadModule() 方法,以完成真正的配置加載:
...
}elseif(!strcasecmp(argv[0],"logfile") && argc ==2) {
... }elseif(!strcasecmp(argv[0],"loadmodule") && argc >=2) {
queueLoadModule(argv[1],&argv[2],argc-2);
}elseif(!strcasecmp(argv[0],"sentinel")) {
...
回到 main 方法的流程,Redis會開始打印啓動的日誌,執行 initServer() 方法,服務根據配置項,繼續 爲 server 對象初始化內容 ,例如:
創建事件循環結構體 aeEventLoop (定義在ae.h),賦值給 server.el
根據配置的db數目,分配大小爲 sizeof(redisDb) * dbnum 的內存空間, server.db 保存這塊空間的地址指針
每個db都是一個redisDb結構,將這個結構中的保存key、保存過期時間等的字典初始化爲空dict
...
此後就是一些根據不同運行模式的初始化,例如常規模式運行時會記錄常規日誌、加載磁盤持久化的數據;而在sentinel模式運行時記錄哨兵日誌,不加載數據等。
在所有準備操作都完成後, Redis開始陷入 aeMain() 的事件循環,在這個循環中會不斷執行 aeProcessEvents() 處理髮生的各種事件,直到Redis結束退出 。
兩種事件
Redis中存在有兩種類型的事件: 時間事件 、 文件事件 。
時間事件也就是到了一定時間會發生的事件,在Redis中它們被記錄成一個鏈表,每次創建新的時間事件的時候,都會在鏈表頭部插入一個 aeTimeEvent 節點,其中保存了該事件會在何時發生,需要調用什麼樣的方法處理。遍歷整個鏈表我們可以知道離最近要發生的時間事件還有多久,因爲鏈表裏面的節點按照自增id順序排列,而在發生時間的維度上是亂序的。
文件事件可以看作I/O引起的事件,客戶端發送命令會讓服務端產生一個讀I/O,對應一個讀事件;同樣當客戶端等待服務端消息的時候需要變得可寫,讓服務端寫入內容,因此會對應一個寫事件。 AE_READABLE 事件會在客戶端建立連接、發送命令或其他連接變得可讀的時候發生,而 AE_WRITABLE 事件則會在客戶端連接變得可寫的時候發生。
文件事件的結構簡單很多, aeFileEvent 記錄了這是一個可讀事件還是可寫事件,對應的處理方法,以及用戶數據。
如果同時發生了兩種事件,Redis會優先處理 AE_READABLE 事件。
aeProcessEvents
aeProcessEvents() 方法處理已經發生和即將發生的各種事件 。
在 aeMain() 循環進入 aeProcessEvents() 後,Redis首先檢查下一次的時間事件會在什麼時候發生,在還沒有時間事件發生的這段時間內,可以調用多路複用的API aeApiPoll() 阻塞並等待文件事件的發生。如果沒有文件事件發生,那麼超時後返回0,否則返回已發生的文件事件數量 numevents 。
在有文件事件可處理的情況下,Redis會調用 AE_READABLE 事件的 rfileProc 方法以及 AE_WRITABLE 事件的 wfileProc 方法進行處理:
...
if(!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask); fired++; fe = &eventLoop->events[fd]; }if(fe->mask & mask & AE_WRITABLE) {
if(!fired || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop,fd,fe->clientData,mask); fired++; } }...
在完成前面的處理後,Redis會繼續調用 processTimeEvents() 處理時間事件。遍歷整個時間事件鏈表,如果此時已經過了一段時間(阻塞等待或處理文件事件耗時),有時間事件發生,那麼就調用對應時間事件的 timeProc 方法,將所有已經過時的時間事件處理掉:
...
if(te->when <= now) {
... retval = te->timeProc(eventLoop, id, te->clientData); ... processed++; ... }...
如果執行了文件事件之後還沒有到最近的時間事件發生點,那麼本次 aeMain() 循環中將沒有時間事件被執行,進入下一次循環。
命令執行前後發生了什麼
在客戶端連接上Redis的時候,通過執行 connSetReadHandler(conn, readQueryFromClient) ,設置了當讀事件發生時,使用 readQueryFromClient() 作爲讀事件的Handler。
在收到客戶端的命令請求時,Redis進行一些檢查和統計後,調用 read() 方法將連接中的數據讀取進 client.querybuf 消息緩衝區中:
voidreadQueryFromClient(connection *conn){
... nread = connRead(c->conn, c->querybuf+qblen, readlen); ...staticinlineintconnRead(connection *conn,void*buf,size_tbuf_len){
returnconn->type->read(conn, buf, buf_len);
}staticintconnSocketRead(connection *conn,void*buf,size_tbuf_len){
intret = read(conn->fd, buf, buf_len);
...}
然後進入 processInputBuffer(c) 開始讀取輸入緩衝區中的消息,最後進入 processCommand(c) 開始處理輸入的命令。
在命令執行得到結果後,首先會存放在 client.buf 中,並且調用調用 addReply(client *c, robj *obj) 方法,將這個 client 對象追加到 server.clients_pending_write 列表中。此時當次的命令,或者說 AE_READABLE 事件就已經基本處理完畢了,除了一些額外的統計數據、後處理以外,不會再進行發送響應消息的動作。
在當前 aeProcessEvents() 方法結束後,進入 下一次的循環 ,第二次循環調用I/O多路複用接口等待文件事件發生前,Redis會檢查 server.clients_pending_write 是否有客戶端需要進行回覆,若有,便利指向各個待回覆客戶端的 server.clients_pending_write 列表,逐個將客戶端從中刪除,並將待回覆的內容通過 writeToClient(c,0) 回覆出去
intwriteToClient(client *c,inthandler_installed){
... nwritten = connWrite(c->conn,c->buf+c->sentlen,c->bufpos-c->sentlen); ...staticinlineintconnWrite(connection *conn,constvoid*data,size_tdata_len){
returnconn->type->write(conn, data, data_len);
}staticintconnSocketWrite(connection *conn,constvoid*data,size_tdata_len){
intret = write(conn->fd, data, data_len);
...}
Threaded I/O模型
I/O問題與Threaded I/O的引入
如果要說Redis會有什麼性能問題,那麼從I/O角度,由於它沒有像其他Database一樣使用磁盤,所以不存在磁盤I/O的問題。在數據進入緩衝區前及從緩衝區寫至Socket時,存在一定的網絡I/O,特別是寫I/O對性能影響比較大。以往我們會考慮做管道化來減小網絡I/O的開銷,或者將Redis部署成Redis集羣來提升性能。
在Redis 6.0之後,由於Threaded I/O的引入,Redis開始支持對網絡讀寫的線程化,讓更多的線程參與進這部分動作中,同時保持命令的單線程執行。這樣的改動從某種程度上說可以既提升性能,但又避免將命令執行線程化而需要引入鎖或者其他方式解決並行執行的靜態問題。
Threaded I/O在做什麼
在老版本的實現中,Redis將不同client的命令執行結果保存在各自的 client.buf 中,然後把待回覆的 client 存放在一個列表裏,最後在事件循環中逐個將 buf 的內容寫至對應Socket。對應在新版本中,Redis使用多個線程完成這部分操作。
對讀操作,Redis同樣地爲 server 對象新增了一個 clients_pending_read 屬性,當讀事件來臨時,判斷是否滿足線程化讀的條件,如果滿足,那麼執行延遲讀操作,將這個 client 對象添加到 server.clients_pending_read 列表中。和寫操作一樣,留到下一次事件循環時使用多個線程完成讀操作。
Threaded I/O的實現與限制
Init階段
在Redis啓動時,如果滿足對應參數配置,會進行I/O線程初始化的操作。
voidinitThreadedIO(void){
server.io_threads_active =0;
if(server.io_threads_num ==1)return;
if(server.io_threads_num > IO_THREADS_MAX_NUM) {
serverLog(LL_WARNING,"Fatal: too many I/O threads configured. "
"The maximum number is %d.", IO_THREADS_MAX_NUM);
exit(1);
}...
Redis會進行一些常規檢查,配置數是否符合開啓多線程I/O的要求。
...
for(inti =0; i < server.io_threads_num; i++) {
io_threads_list[i] = listCreate();...
創建一個長度爲線程數的 io_threads_list 列表,列表的每個元素都是另一個列表L,L將會用來存放對應線程待處理的多個 client 對象。
...
if(i ==0)continue;
...
對於主線程,初始化操作到這裏就結束了。
...
pthread_ttid;
pthread_mutex_init(&io_threads_mutex[i],NULL);
io_threads_pending[i] =0;
pthread_mutex_lock(&io_threads_mutex[i]);/* Thread will be stopped. */
if(pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) !=0) {
serverLog(LL_WARNING,"Fatal: Can't initialize IO thread.");
exit(1);
} io_threads[i] = tid; }}...
io_threads_mutex 是一個互斥鎖列表, io_threads_mutex[i] 即第 i 個線程的鎖,用於後續阻塞I/O線程操作,初始化之後將其暫時鎖定。然後再對每個線程執行創建操作, tid 即其指針,保存至 io_threads 列表中。新的線程會一直執行 IOThreadMain 方法,我們將它放到最後講解。
Reads/Writes
多線程的讀寫主要在 handleClientsWithPendingReadsUsingThreads() 和 handleClientsWithPendingWritesUsingThreads() 中完成,因爲兩者幾乎是對稱的,所以這裏只對讀操作進行講解,有興趣的同學可以檢查一下寫操作有什麼不同的地方以及爲什麼。
inthandleClientsWithPendingReadsUsingThreads(void){
if(!server.io_threads_active || !server.io_threads_do_reads)return0;
intprocessed = listLength(server.clients_pending_read);
if(processed ==0)return0;
if(tio_debug)printf("%d TOTAL READ pending clients\n", processed);
...
同樣,Redis會進行常規檢查,是否啓用線程化讀寫並且啓用線程化讀(只開啓前者則只有寫操作是線程化),以及是否有等待讀取的客戶端。
...
listIter li; listNode *ln; listRewind(server.clients_pending_read,&li);intitem_id =0;
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);inttarget_id = item_id % server.io_threads_num;
listAddNodeTail(io_threads_list[target_id],c); item_id++; }...
這裏將 server.clients_pending_read 的列表轉化爲方便遍歷的鏈表,然後將列表的每個節點( *client 對象)以類似Round-Robin的方式分配給各個線程,線程執行各個client的讀寫順序並不需要保證,命令抵達的先後順序已經由 server.clients_pending_read/write 列表記錄,後續也會按這個順序執行。
...
io_threads_op = IO_THREADS_OP_READ;
...
設置狀態標記,標識當前處於多線程讀的狀態。由於標記的存在,Redis的Threaded I/O瞬時只能處於讀或寫的狀態,不能部分線程讀,部分寫。
...
for(intj =1; j < server.io_threads_num; j++) {
intcount = listLength(io_threads_list[j]);
io_threads_pending[j] = count; }...
爲每個線程記錄下各自需要處理的客戶端數量。當不同線程讀取到自己的pending長度不爲0時,就會開始進行處理。注意 j 從1開始,意味着 0 的主線程的pending長度一直爲0,因爲主線程馬上要在這個方法中同步完成自己的任務,不需要知道等待的任務數。
...
listRewind(io_threads_list[0],&li);
while((ln = listNext(&li))) {
client *c= listNodeValue(ln);
readQueryFromClient(c->conn);
} listEmpty(io_threads_list[0]);
...
主線程此時將自己要處理的client處理完。
...
while(1) {
unsignedlongpending =0;
for(intj =1; j < server.io_threads_num; j++)
pending += io_threads_pending[j];if(pending ==0)break;
}if(tio_debug)printf("I/O READ All threads finshed\n");
...
陷入循環等待, pending 等於各個線程剩餘任務數之和,當所有線程都沒有任務的時候,本輪I/O處理結束。
...
while(listLength(server.clients_pending_read)) {
ln = listFirst(server.clients_pending_read); client *c= listNodeValue(ln);
c->flags &= ~CLIENT_PENDING_READ;
listDelNode(server.clients_pending_read,ln);if(c->flags &CLIENT_PENDING_COMMAND) {
c->flags &= ~CLIENT_PENDING_COMMAND;
if(processCommandAndResetClient(c) ==C_ERR) {
continue;
} } processInputBuffer(c);
}...
我們已經在各自線程中將 conn 中的內容讀取至對應client的 client.querybuf 輸入緩衝區中,所以可以遍歷 server.clients_pending_read 列表,串行地進行命令執行操作,同時將 client 從列表中移除。
...
server.stat_io_reads_processed += processed;returnprocessed;
}
處理完成,將處理的數量加到統計屬性上,然後返回。
IOThreadMain
前面還有每個線程具體的工作內容沒有解釋,它們會一直陷在 IOThreadMain 的循環中,等待執行讀寫的時機。
void*IOThreadMain(void*myid){
longid = (unsignedlong)myid;
charthdname[16];
snprintf(thdname,sizeof(thdname),"io_thd_%ld", id);
redis_set_thread_title(thdname); redisSetCpuAffinity(server.server_cpulist);...
照常執行一些初始化內容。
...
while(1) {
for(intj =0; j <1000000; j++) {
if(io_threads_pending[id] !=0)break;
}if(io_threads_pending[id] ==0) {
pthread_mutex_lock(&io_threads_mutex[id]);
pthread_mutex_unlock(&io_threads_mutex[id]);
continue;
} serverAssert(io_threads_pending[id] !=0);
if(tio_debug) printf("[%ld] %d to handle\n",id, (int)listLength(io_threads_list[id]));
...
線程會檢測自己的待處理的client列表長度,當等待隊列長度大於0時往下執行,否則會到死循環起點。
這裏利用互斥鎖,讓主線程有機會加鎖,使得I/O線程卡在執行 pthread_mutex_lock() ,達到讓I/O線程停止工作的效果。
...
listIter li; listNode *ln; listRewind(io_threads_list[id],&li);while((ln = listNext(&li))) {
client *c= listNodeValue(ln);
if(io_threads_op ==IO_THREADS_OP_WRITE) {
writeToClient(c,0);
}elseif(io_threads_op ==IO_THREADS_OP_READ) {
readQueryFromClient(c->conn);
}else{
serverPanic("io_threads_op value is unknown");
} }...
將 io_threads_list[i] 的客戶端列表轉化爲方便遍歷的鏈表,逐個遍歷,藉助 io_threads_op 標誌判斷當前是要執行多線程讀還是多線程寫,完成對自己要處理的客戶端的操作。
...
listEmpty(io_threads_list[id]);
io_threads_pending[id] =0;
if(tio_debug) printf("[%ld] Done\n",id);
}}
清空自己要處理的客戶端列表,並且將自己的待處理數量修改爲0,結束本輪操作。
Limitation
通過查看代碼,使用上Threaded I/O的啓用受以下條件影響:
配置項 io-threads 需要大於1,否則會繼續使用單線程操作讀寫I/O
配置項 io-threads-do-reads 控制讀I/O是否使用線程化
postponeClientRead() CLIENT_PENDING_READ client CLIENT_PENDING_READ
handleClientsWithPendingWritesUsingThreads() stopThreadedIOIfNeeded() server.clients_pending_write
initThreadedIO() server io_threads_active server.io_threads_active server.io_threads_active
性能測試
我們編譯了unstable版本的Redis進行性能測試,測試工具爲Redis自帶的redis-benchmark,統計輸出的RPS值作爲參考。
Server實例:AWS/m5.2xlarge/8vCPU/32GB
BenchmarkClient實例:AWS / m5.2xlarge / 8 vCPU / 32 GB
Command:redis-benchmark-h172.xx.xx.62-p6379-c100-d256-tget,set-n10000000--threads8
Threaded I/O off vs. Threaded I/O on
我們對比了原有的單線程I/O以及開啓2線程/4線程的Threaded I/O時的表現,結果如圖所示。
在開啓 io-threads-do-reads 選項的情況下,Threaded I/O作用於讀操作,也能讓性能有進一步提升,但是沒有將寫I/O線程化提升明顯。另外我們還嘗試使用了大體積Payload( -d 8192 )進行測試,得出結果的提升百分比並沒有太大差異。
Threaded I/O vs. Redis Cluster
以往開發者會通過在單臺實例上部署Redis Cluster來嘗試讓Redis使用上更多的CPU資源,我們也嘗試對比了一下這種情景下的表現。
在新版本中,redis-benchmark也得到了更新,開始支持對Redis Cluster的測試,通過開啓 --cluster 參數即可檢測集羣模式和配置。我們在這一組對比測試中看到單實例構建集羣的強大性能,在實際測試中,3個進程的CPU使用率均在80%-90%,說明仍有提升的空間。當改用測試參數 -c 512 時,集羣能夠跑出超過40萬RPS的成績。儘管測試與實際使用會有所區別,並且我們在構建集羣的時候選擇了不附帶Slave,但是仍然能看出來在幾種模型中,構建Cluster能真正使用上多線程進行網絡I/O、命令執行,對性能的提升也是最大的。
總結與思考
Redis 6.0引入的Threaded I/O,將Socket讀寫延遲和線程化,在網絡I/O的方向上給Redis帶來了一定的性能提升,並且使用門檻比較低,用戶無需做太多的變更,即可在不影響業務的情況下白佔空閒的線程資源。
另一方面,從測試結果上看,這部分的提升可能還難以讓處於Redis 5甚至Redis 3版本的用戶有足夠的動力進行升級,特別是考慮到很多業務場景中Redis的性能並沒有差到成爲瓶頸,而且新版本的福利也未經過大規模驗證,勢必會影響到企業級應用中更多用戶關注的服務穩定性。同時,TIO的提升對比集羣性能似乎還有一定的差距,這可能更加會讓原本就處於集羣架構的企業用戶忽略這個功能。
但無論如何,用戶肯定樂於見到更多的新功能、更多優化提升出現在Redis上。在保持一貫穩定性的前提下,本次的版本可以說是Redis從誕生至今最大的更新,不只有Threaded I/O,包括RESP3、ACLs和SSL,我們期待這些新Feature能夠在更多的應用場景下得到推廣、驗證和使用,也希望未來的版本能夠給用戶帶來更多的驚喜和更好的體驗。
Further Reading: Understanding Redis
作爲一位從來沒有使用過C/類C語言的開發者,Redis簡潔的代碼和詳盡的註釋爲我閱讀和理解其實現提供了極大的幫助。在文末我想要分享一下自己學習Reids的一些途徑、工具和方法。
README.md 應該是我們瞭解Redis的入口,而不是全局搜索 main() 方法。請關注 Redis internals 小結下的內容,這裏介紹了Redis的代碼結構,Redis每個文件都是一個“general idea”,其中 server.c 和 network.c 的部分邏輯和代碼在本文已經介紹過了,持久化相關的 aof.c 和 rdb.c 、數據庫相關的 db.c 、Redis對象相關的 object.c 、複製相關的 replication.c 等都值得留意。其他包括Redis的命令是以什麼樣的形式編碼的,也能在 README.md 中找到答案,這樣可以方便我們進一步閱讀代碼時快速定位。
Documentation主頁 [1] 和 redis-doc repo [2] 是Redis文檔的集合處,請注意後者的 topics 目錄下有非常多有趣的主題,我對“有趣”的定義是像這樣的文章:
Redis Cluster Specification [3]
Redis server-assisted client side caching [4]
作爲開發者,在深入學習的階段,這些內容能讓大家從“使用”變爲“瞭解”,然後發現Redis原來能做更多的事情。所以如果缺乏時間閱讀和調試源碼,將 topics 下的60多篇文檔看一遍,大概是瞭解Redis最快的方法。
最後,如果你能看到這裏,興許也會對Redis的源碼有那麼一點興趣。因爲本身並不瞭解C語言,所以我可能會選擇藉助一個IDE,在 main() 打上斷點,然後流程的起點開始看,實際上我也確實是這麼做的。另外幾個代碼的關鍵點,其實也在本文中出現過:
main() ,起點
initServer() ,初始化
aeMain() ,事件循環
readQueryFromClient() ,讀事件的Handler
processInputBuffer() ,命令處理的入口
如果像本文一樣想了解Network的內容,可以在 aeMain() 處打斷點,然後關注中 network.c 中的方法;如果想關注具體命令相關的內容,可以在 processInputBuffer() 處打斷點,然後關注 $command.c 或者類似文件中的方法, README.md 文件裏也已經介紹過命令方法的命名格式,定位非常容易。其餘經常出現的其他動作,例如持久化、複製等,大概會出現在命令執行的前後,或者時間時間內,也可能在 beforeSleep() 中。 server.h 中定義的 redisServer 和 client 是Redis中兩個非常重要的結構,在業務上很多內容都是轉化爲對它們的屬性的相關操作,要特別留意。