經典面試題:Redis為什么這么快?
Redis有多快
根據官方基準測試,在具有平均硬件的Linux機器上運行的單個Redis實例通常可以為簡單命令(O(N)或O(log(N)))實現8w+的QPS,使用流水線批處理可以達到100w。
從性能角度來看,Redis可以稱為高性能的緩存解決方案。
Redis為什么這么快
面試時經常被問到Redis高性能的原因,典型回答是下面這些:
- C語言實現,雖然C語言有助于Redis的性能,但語言并不是核心因素。
- 基于內存實現:僅內存I/O,相對于其他基于磁盤的數據庫(MySQL等),Redis具有純內存操作的自然性能優勢。
- I/O復用模型,基于epoll/select/kqueue等I/O多路復用技術實現高吞吐量網絡I/O。
- 單線程模型,單線程無法充分利用多核,但另一方面,它避免了多線程的頻繁上下文切換以及鎖等同步機制的開銷。
為什么Redis選擇單線程?
上面回答了是單線程的,接著會問為啥采用單線程模型。
Redis的CPU通常不會成為性能瓶頸,因為通常情況下Redis要么受到內存限制,要么受到網絡限制。例如,使用流水線技術,在平均Linux系統上運行的Redis甚至可以每秒處理100萬個請求,因此,如果應用程序主要使用O(N)或O(log(N))命令,它幾乎不會使用太多CPU。
這基本上意味著CPU通常不是數據庫的瓶頸,因為大多數請求不會占用太多CPU資源,而是占用I/O資源。特別是對于Redis來說,如果不考慮像RDB/AOF這樣的持久性方案,Redis是完全的內存操作,非??焖?。Redis的真正性能瓶頸是網絡I/O,即客戶端和服務器之間的網絡傳輸延遲,因此Redis選擇了單線程的I/O多路復用來實現其核心網絡模型。
「實際上選擇單線程的更具體原因可以總結如下:」
- 避免過多的上下文切換開銷:在多線程調度過程中,需要在CPU之間切換線程上下文,并且上下文切換涉及一系列寄存器替換、程序堆棧重置,甚至包括程序計數器、堆棧指針和程序狀態字等快速表項的退休。因為單個進程內的多個線程共享進程地址空間,線程上下文要比進程上下文小得多,在跨進程調度的情況下,需要切換整個進程地址空間。
- 避免同步機制的開銷:如果Redis選擇多線程模型,因為Redis是一個數據庫,不可避免地涉及底層數據同步問題,這必然會引入一些同步機制,如鎖。我們知道Redis不僅提供簡單的鍵值數據結構,還提供列表、集合、哈希等豐富的數據結構。不同的數據結構對于同步訪問的鎖定具有不同的粒度,這可能會在數據操作期間引入大量的鎖定和解鎖開銷,增加了程序的復雜性并降低了性能。
- 簡單和可維護性:Redis的作者Salvatore Sanfilippo(化名antirez)在Redis的設計和代碼中有一種近乎偏執的簡單哲學,當您閱讀Redis源代碼或向Redis提交PR時,您可以感受到這種偏執。因此,簡單且可維護的代碼必然是Redis在早期的核心準則之一,引入多線程不可避免地導致了代碼復雜性的增加和可維護性的降低。
Redis真的是單線程的嗎?
在回答這個問題之前,我們需要澄清“單線程”概念的范圍:它是否涵蓋了核心網絡模型或整個Redis?如果是前者,答案是肯定的。Redis的網絡模型在v6.0之前一直是單線程的;如果是后者,答案是不。Redis早在v4.0版本中就引入了多線程。
- Redis v4.0(引入多線程進行異步任務)
- Redis v6.0(正式在網絡模型中實現I/O多線程)
單線程網絡模型
從Redis v1.0到v6.0,Redis的核心網絡模型一直是典型的單Reactor模型:使用epoll/select/kqueue等多路復用技術來處理事件(客戶端請求)在單線程事件循環中,最后將響應數據寫回客戶端。
redis-io模型
在這里有幾個核心概念需要了解。
- 客戶端:客戶端對象,Redis是典型的CS架構(客戶端<-->服務器),客戶端通過套接字與服務器建立網絡通道,然后發送請求的命令,服務器執行請求的命令并回復。Redis使用client結構來存儲與客戶端相關的所有信息,包括但不限于包裝套接字連接 -- *conn,當前選擇的數據庫指針--*db,讀緩沖區--querybuf,寫緩沖區--buf,寫數據鏈接列表--reply等。
- aeApiPoll:I/O多路復用API,基于epoll_wait/select/kevent等系統調用封裝,監聽讀寫事件以觸發,然后進行處理,這是事件循環(Event Loop)中的核心函數,是事件驅動器運行的基礎。
- acceptTcpHandler:連接響應處理器,底層使用系統調用accept接受來自客戶端的新連接,并將新連接注冊綁定命令讀取處理器以進行后續的新客戶端TCP連接處理;除了此處理器外,還有相應的acceptUnixHandler用于處理Unix域套接字和acceptTLSHandler用于處理TLS加密連接。
- readQueryFromClient:命令讀取處理器,用于解析并執行客戶端請求的命令。
- beforeSleep:在事件循環進入aeApiPoll并等待事件到達之前執行的函數。它包含一些常規任務,如將來自client->buf或client->reply的響應寫回客戶端、將AOF緩沖區中的數據持久化到磁盤等。還有一個afterSleep函數,在aeApiPoll之后執行。
- sendReplyToClient:命令回復處理器,當事件循環后仍然在寫緩沖區中有數據時,將注冊并綁定到相應連接的sendReplyToClient命令,當連接觸發寫就緒事件時,將剩余的寫緩沖區中的數據寫回客戶端。
Redis內部實現了一個高性能事件庫AE,基于epoll/select/kqueue/evport,用于為Linux/MacOS/FreeBSD/Solaris實現高性能事件循環模型。Redis的核心網絡模型正式構建在AE之上,包括I/O多路復用和各種處理器綁定的注冊,所有這些都是基于它實現的。
redis-io多路復用
到這里,我們可以描述一個客戶端從Redis請求命令的工作方式。
- Redis服務器啟動,打開主線程事件循環,將acceptTcpHandler連接響應處理器注冊到用戶配置的監聽端口的文件描述符上,等待新連接的到來。
- 客戶端與服務器之間建立網絡連接。
- 調用acceptTcpHandler,主線程使用AE的API將readQueryFromClient命令讀取處理器綁定到新連接的文件描述符上,并初始化一個client以綁定此客戶端連接。
- 客戶端發送請求命令,觸發讀就緒事件,主線程調用readQueryFromClient將客戶端通過套接字發送的命令讀入客戶端->querybuf讀緩沖區。
- 接下來調用processInputBuffer,在其中使用processInlineBuffer或processMultibulkBuffer來根據Redis協議解析命令,最后調用processCommand來執行命令。
- 根據請求命令的類型(SET、GET、DEL、EXEC等),分配適當的命令執行器來執行,最后調用addReply系列函數中的一系列函數將響應數據寫入到相應客戶端的寫緩沖區中:client->buf或client->reply,client->buf是首選的寫出緩沖區,具有固定大小的16KB,通??梢跃彌_足夠的響應數據,但如果客戶端在時間窗口內需要非常大的響應,則它將自動切換到client->reply鏈接列表,理論上可以容納無限數量的數據(受機器物理內存限制)最后,將client添加到LIFO隊列clients_pending_write。
- 在事件循環中,主線程執行beforeSleep -> handleClientsWithPendingWrites,遍歷clients_pending_write隊列,并調用writeToClient將客戶端寫緩沖區中的數據返回給客戶端,如果寫緩沖區中仍然有剩余數據,則注冊sendReplyToClient命令到連接的回復處理器,等待客戶端寫入后繼續在事件循環中寫回剩余的響應數據。
對于那些希望利用多核性能的人來說,官方的Redis解決方案簡單而直接:在同一臺機器上運行更多的Redis實例。事實上,為了保證高可用性,一個在線業務不太可能以獨立運行的方式存在。更常見的是使用Redis分布式集群,具有多個節點和數據分片,以提高性能和確保高可用性。
多線程異步任務
如前所述,Redis在v4.0版本中引入了多線程來執行一些異步操作,主要用于非常耗時的命令。通過將這些命令的執行設置為異步,可以避免阻塞單線程事件循環。
我們知道Redis的DEL命令用于刪除一個或多個鍵的存儲值,它是一個阻塞命令。在大多數情況下,要刪除的鍵不會存儲太多值,最多幾十個或幾百個對象,因此可以快速執行。但如果要刪除具有數百萬個對象的非常大的鍵值對,則此命令可能會阻塞至少幾秒鐘,由于事件循環是單線程的,它會阻塞隨后的其他事件,從而降低吞吐量。
Redis的作者antirez對解決這個問題進行了深思熟慮。起初,他提出了一個漸進式的解決方案:使用定時器和數據游標,他將逐步刪除少量數據,例如1000個對象,最終清除所有數據。但這個解決方案存在一個致命的缺陷:如果其他客戶端繼續寫入正在逐步刪除的鍵,而且刪除速度跟不上寫入的數據,那么內存將無休止地被消耗,這個問題通過一個巧妙的解決方案得以解決,但這個實現使Redis更加復雜。多線程似乎是一個牢不可破的解決方案:簡單且容易理解。因此,最終,antirez選擇引入多線程來執行這類非阻塞命令。antirez在他的博客中更多地思考了這個問題:懶惰的Redis是更好的Redis。
因此,在Redis v4.0之后,已添加了一些非阻塞命令,如UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC等,它們會在后臺線程中執行,不會阻塞主線程事件循環。這使得Redis可以更好地應對一些特定情況下的命令處理。
多線程異步任務的主要特點:
- 后臺線程:這些異步任務由一個或多個后臺線程負責執行,不影響主線程的事件循環,因此主線程可以繼續處理其他請求。
- 非阻塞:異步任務是非阻塞的,因此它們不會阻止其他命令的執行,即使它們可能需要很長時間才能完成。
- 高可用性:通過將某些耗時操作轉移到后臺線程,Redis可以更好地保持高可用性。
總結 Redis的網絡模型是單線程的,這意味著它使用單個事件循環來處理所有客戶端請求。這個設計的優點是簡單性和可維護性,但需要謹慎處理一些可能導致事件循環阻塞的命令。
為了處理一些非常耗時的命令,Redis v4.0引入了多線程異步任務。這些異步任務在后臺線程中執行,不會阻塞主線程的事件循環,從而提高了Redis的吞吐量和可用性。
總而言之,Redis的單線程事件循環和多線程異步任務的設計是為了在性能和簡單性之間取得平衡,以滿足各種不同用例的需求。理解Redis的這些基本原理對于使用Redis進行高性能數據存儲和緩存非常重要。
多線程網絡模型
正如前面提到的,Redis最初選擇了單線程的網絡模型,原因是CPU通常不是性能瓶頸,瓶頸往往是內存和網絡,因此單線程足夠了。那么為什么Redis現在引入了多線程呢?簡單的事實是Redis的網絡I/O瓶頸變得越來越明顯。
隨著互聯網的快速增長,互聯網業務系統處理越來越多的在線流量,而Redis的單線程模式導致系統在網絡I/O上消耗了大量CPU時間,從而降低了吞吐量。提高Redis性能有兩種方式:
- 優化網絡I/O模塊
- 提高機器內存讀寫速度
后者依賴于硬件的發展,目前尚無法解決。因此,我們只能從前者入手,網絡I/O的優化可以分為兩個方向:
- 零拷貝技術或DPDK技術
- 利用多核
零拷貝技術存在局限性,無法完全適應像Redis這樣的復雜網絡I/O場景。DPDK技術通過繞過內核棧來繞過NIC I/O,過于復雜,需要內核甚至硬件的支持。
因此,充分利用多個核心是優化網絡I/O最具成本效益的方式。
在6.0版本之后,Redis正式將多線程引入核心網絡模型中,也稱為I/O線程,現在Redis具有真正的多線程模型。在前面的部分中,我們了解了Redis 6.0之前的單線程事件循環模型,實際上是一個非常經典的反應器模型。
圖片
反應器模式在Linux平臺上的大多數主流高性能網絡庫/框架中都有應用,比如netty、libevent、libuv、POE(Perl)、Twisted(Python)等。
反應器模式實際上是指使用I/O多路復用(I/O multiplexing)+非阻塞I/O(non-blocking I/O)模式。
Redis的核心網絡模型,直到6.0版本,都是單一的反應器模型:所有事件都在單一線程中處理,盡管在4.0版本中引入了多線程,但更多是用于特定場景的補?。▌h除超大鍵值等),不能被視為核心網絡模型的多線程。
一般來說,單一反應器模型,在引入多線程后,會演變為多反應器模型,具有以下基本工作模型。
圖片
與單一線程事件循環不同,這種模式有多個線程(子反應器),每個線程維護一個獨立的事件循環,主反應器接收新連接并將其分發給子反應器進行獨立處理,而子反應器則將響應寫回客戶端。
多反應器模式通??梢缘韧贛aster-Workers模式,比如Nginx和Memcached使用這種多線程模型,盡管項目之間的實現細節略有不同,但總體模式基本一致。
Redis多線程網絡模型設計
Redis也實現了多線程,但不是標準的多反應器/主工作模式。讓我們先看一下Redis多線程網絡模型的一般設計。
圖片
- Redis服務器啟動,打開主線程事件循環,將acceptTcpHandler連接答復處理器注冊到與用戶配置的監聽端口對應的文件描述符,并等待新連接的到來。
- 客戶端與服務器之間建立網絡連接。
- 調用acceptTcpHandler,主線程使用AE的API將readQueryFromClient命令讀取處理器綁定到與新連接對應的文件描述符上,并初始化一個客戶端以綁定這個客戶端連接。
- 客戶端發送一個請求命令,觸發一個讀就緒事件。但不是通過套接字讀取客戶端的請求命令,而是服務器的主線程首先將客戶端放入LIFO隊列clients_pending_read中。
- 在事件循環中,主線程執行beforeSleep –> handleClientsWithPendingReadsUsingThreads,使用輪詢的負載均衡策略將clients_pending_read隊列中的連接均勻地分配給I/O線程。I/O線程通過套接字讀取客戶端的請求命令,將其存儲在client->querybuf中并解析第一個命令,但不執行它,同時主線程忙于輪詢并等待所有I/O線程完成讀取任務。
- 當主線程和所有I/O線程都完成讀取時,主線程結束忙碌的輪詢,遍歷clients_pending_read隊列,執行所有已連接客戶端的請求命令,首先調用processCommandResetClient來執行已解析的第一個命令,然后調用processInputBuffer來解析和執行客戶端連接的所有命令,使用processInlineBuffer或processMultibulkBuffer根據Redis協議解析命令,最后調用processCommand來執行命令。
- 根據所請求命令的類型(SET、GET、DEL、EXEC等),分配相應的命令執行器來執行它,最后調用addReply系列函數中的一系列函數將響應數據寫入相應的客戶端寫出緩沖區:client->buf或client->reply,client->buf是首選的寫出緩沖區,大小固定為16KB,通常足夠緩沖足夠的響應數據,但如果客戶端需要在時間窗口內響應大量數據,則會自動切換到client->reply鏈表,理論上可以容納無限量的數據(受到機器物理內存的限制),最后將客戶端添加到LIFO隊列clients_pending_write中。
- 在事件循環中,主線程執行beforeSleep –> handleClientsWithPendingWritesUsingThreads,使用輪詢的負載均衡策略將clients_pending_write隊列中的連接均勻地分配給I/O線程和主線程本身。I/O線程通過調用writeToClient將客戶端寫緩沖區中的數據寫回客戶端,而主線程則忙于輪詢,等待所有I/O線程完成寫入任務。
- 當主線程和所有I/O線程都完成寫入時,主線程結束忙碌的輪詢,遍歷clients_pending_write隊列。如果客戶端寫緩沖區中還有數據,它將注冊sendReplyToClient以等待連接的寫準備就緒事件,并等待客戶端寫入,然后繼續在事件循環中寫回剩余的響應數據。
大部分邏輯與之前的單線程模型相同,唯一的改變是將讀取客戶端請求和寫回響應數據的邏輯異步化到I/O線程中。這里需要特別注意的是,I/O線程只負責讀取和解析客戶端命令,實際的命令執行最終是在主線程上完成的。
總結
當面試官再問Redis為啥這么快時別傻傻再回答Redis是單線程了,否則只能回去等通知了。
Redis的多線程網絡模型通過將讀取和寫回數據的任務異步化,以及更好地利用多核CPU,從而提高了Redis在處理大量在線流量時的性能表現。
1.「多線程設計」:
- Redis多線程模型包括一個主線程(Main Reactor)和多個I/O線程(Sub Reactors)。
- 主線程負責接受新的連接,并將其分發到I/O線程進行獨立處理。
- I/O線程負責讀取客戶端的請求命令,但不執行它們。
- 主線程負責執行客戶端的請求命令,包括解析和執行。
- 響應數據由I/O線程寫回客戶端。
2.「異步讀寫」:Redis的多線程模型異步化了讀取客戶端請求和寫回響應數據的過程??蛻舳苏埱笫紫缺环湃氪x取隊列,然后由I/O線程讀取。執行命令仍然在主線程上進行,但這種異步化提高了系統的并發性和吞吐量。