一、Redis單線程
在Redis6.0版本之前,Redis可以被認為是單線程服務(除少量的后台定時任務、文件異步操作、惰性刪除外),它的處理過程主要包括:
1接收命令。通過TCP或者UDP接收到命令
2解析命令。將命令取出來
3執行命令。到對應的地方將value讀出來
4返回結果。將結果返回給客戶端
Redis在啟動后會產生一個死循環aeMain,在這個循環里通過IO多路復用(linux系統采用epoll方式)等待事件發生。
事件分為IO事件和timer事件,timer事件即開頭提到的后台定時任務,如expire key等。IO事件即來自客戶端的命令轉化的事件,由epoll處理。
epoll多路復用的處理結構如下圖所示
即多個網絡連接復用一個IO線程。由於單個線程可以記錄每個socket的狀態來同時管理多個IO流,所以能夠提高服務的吞吐能力。
二、單線程IO的瓶頸
前面簡單的介紹了單線程IO的處理過程以及高性能的原因,官方壓測的結果是10萬QPS,我們現網使用經驗在3~4萬QPS。
雖然這個性能指標已經挺高了,但是單線程IO模型有幾個明顯的缺陷:
1、只能使用一個CPU核(忽略后台線程和子線程)
2、如果涉及到大key,Redis的QPS會下降的厲害
3、由於解析和返回的限制,QPS難以進一步提高
針對以上的缺陷,又為了避免多線程執行命令帶來的控制key、lua腳本、事務等並發復雜問題,Redis作者選擇了多線程IO模型,即執行命令仍采用單線程,解析和返回等步驟采用多線程。
三、多線程IO
多線程IO的設計思路大體如下圖
整體的讀流程包括:
1、主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列
2、主線程處理完讀事件之后,通過輪詢將這些連接分配給這些 IO 線程,然后主線程忙等待(spinlock 的效果)狀態
3、IO 線程將請求數據讀取並解析完成(這里只是讀數據和解析並不執行)
4、主線程執行所有命令並清空整個請求等待讀處理隊列(執行部分串行)
readQueryFromClient
函數:
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) { /* Check if we want to read from the client later when exiting from * the event loop. This is the case if threaded I/O is enabled. */ if (postponeClientRead(c)) return; ... }
readQueryFromClient
之前的實現是負責讀取和解析請求並執行命令,加入多線程 IO 之后加入了postponeClientRead
函數,它的實現邏輯如下:
int postponeClientRead(client *c) { if (io_threads_active && // 多線程IO是否在開啟狀態,在待處理請求較少時會停止IO多線程 server.io_threads_do_reads && // 讀是否開啟多線程 IO !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) // 主從庫復制請求不使用多線程 IO { // 連接標識為 CLIENT_PENDING_READ 來控制不會反復被加隊列, // 這個標識作用在后面會再次提到 c->flags |= CLIENT_PENDING_READ; // 連接加入到等待讀處理隊列 listAddNodeHead(server.clients_pending_read,c); return 1; } else { return 0; } }
postponeClientRead
判斷如果開啟多線程 IO 且不是主從復制連接的話就放到隊列然后返回 1,在
readQueryFromClient
函數會直接返回不進行命令解析和執行。
int handleClientsWithPendingReadsUsingThreads(void) { ... // 將等待處理隊列的連接按照 RR 的方式分配給多個 IO 線程 listRewind(server.clients_pending_read,&li); int item_id = 0; 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++; } ... // 一直忙等待直到所有的連接請求都被 IO 線程處理完 while(1) { unsigned long pending = 0; for (int j = 0; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; }
代碼里面的 io_threads_list
用來存儲每個 IO 線程對應需要處理的連接,然后主線程將這些連接分配給這些 IO 線程后進入忙等待狀態(相當於主線程 blocking 住)。
IO 處理線程入口是 IOThreadMain
函數:
void *IOThreadMain(void *myid) { while(1) { // 遍歷線程 id 獲取線程對應的待處理連接列表 listRewind(io_threads_list[id],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); // 通過 io_threads_op 控制線程要處理的是讀還是寫請求 if (io_threads_op == IO_THREADS_OP_WRITE) { writeToClient(c->fd,c,0); } else if (io_threads_op == IO_THREADS_OP_READ) { readQueryFromClient(NULL,c->fd,c,0); } else { serverPanic("io_threads_op value is unknown"); } } listEmpty(io_threads_list[id]); io_threads_pending[id] = 0; } }
IO 線程處理根據全局 io_threads_op
狀態來控制當前 IO 線程應該處理讀還是寫事件,這也是上面提到的全部 IO 線程同一時刻只會執行讀或者寫。另外,已經加到等待處理隊列的連接會被設置 CLIENT_PENDING_READ
標識。postponeClientRead
函數不會把連接再次加到隊列,readQueryFromClient
會繼續執行讀取和解析請求。readQueryFromClient
函數讀取請求數據並調用 processInputBuffer
函數進行解析命令,processInputBuffer
會判斷當前連接是否來自 IO 線程,如果是的話就只解析不執行命令。IOThreadMain
線程是沒有任何 sleep 機制,在空閑狀態也會導致每個線程的 CPU 跑到 100%,但簡單 sleep 則會導致讀寫處理不及時而導致性能更差。Redis 當前的解決方式是通過在等待處理連接比較少的時候關閉這些 IO 線程來避免長期CPU跑滿。
四、性能對比
本次性能壓測采用了控制單變量法多次測試,變量包括線程數、request請求數、客戶端連接數。一共測試了set、get、incr、hset4種命令。每組參數測試5次取平均值為最終測試結果。詳細測試數據見附件Excel
由於壓測結果顯示QPS與客戶端連接數關系不大。故作圖數據采用get命令,50個客戶端為例,繪制了QPS與線程數及請求數的關系。
結論:
1、隨着IO線程數增多,每個IO線程可支持約4.5萬QPS,呈線性關系,4IO線程時,可達到20萬QPS。但是從4線程IO之后,線程數對QPS的提升效果逐漸降低。
2、隨着IO線程數增多,QPS隨着request的增加有一定的增加,但是差距不明顯。
五、建議
由於Redis6支持綁定IO線程、持久化子線程及后台線程,對於QPS高的業務線,可通過Redis配置文件綁定多核開啟多線程IO提高CPU利用率來提高QPS。
相關配置參數如下
io-threads 4 # Setting io-threads to 1 will just use the main thread as usually. # When I/O threads are enabled, we only use threads for writes, that is # to thread the write(2) syscall and transfer the client buffers to the # socket. However it is also possible to enable threading of reads and # protocol parsing using the following configuration directive, by setting # it to yes: io-threads-do-reads yes
# Jemalloc background thread for purging will be enabled by default jemalloc-bg-thread yes # It is possible to pin different threads and processes of Redis to specific # CPUs in your system, in order to maximize the performances of the server. # This is useful both in order to pin different Redis threads in different # CPUs, but also in order to make sure that multiple Redis instances running # in the same host will be pinned to different CPUs. # # Normally you can do this using the "taskset" command, however it is also # possible to this via Redis configuration directly, both in Linux and FreeBSD. # # You can pin the server/IO threads, bio threads, aof rewrite child process, and # the bgsave child process. The syntax to specify the cpu list is the same as # the taskset command: # # Set redis server/io threads to cpu affinity 0,2,4,6: server_cpulist 7-15 # # Set bio threads to cpu affinity 1,3: # bio_cpulist 1,3 # # Set aof rewrite child process to cpu affinity 8,9,10,11: # aof_rewrite_cpulist 8-11 # # Set bgsave child process to cpu affinity 1,10,11 # bgsave_cpulist 1,10-11