Redis緩存IO模型的演進教程示例精講

前言

redis作為應用最廣泛的nosql數據庫之一,大大小小也經歷過很多次升級。在4.0版本之前,單線程+IO多路復用使得redis的性能已經達到一個非常高的高度瞭。作者也說過,之所以設計成單線程是因為redis的瓶頸不在cpu上,而且單線程也不需要考慮多線程帶來的鎖開銷問題。然而隨著時間的推移,單線程越來越不滿足一些應用場景瞭,比如針對大key刪除會造成主線程阻塞的問題,redis4.0出瞭一個異步線程。針對單線程由於無法利用多核cpu的特性而導致無法滿足更高的並發,redis6.0也推出瞭多線程模式。所以說redis是單線程越來越不準確瞭。

事件模型

redis本身是個事件驅動程序,通過監聽文件事件和時間事件來完成相應的功能。其中文件事件其實就是對socket的抽象,把一個個socket事件抽象成文件事件,redis基於Reactor模式開發瞭自己的網絡事件處理器。那麼Reactor模式是什麼?

通信

思考一個問題,我們的服務器是如何收到我們的數據的?首先雙方先要建立TCP連接,連接建立以後,就可以收發數據瞭。發送方向socket的緩沖區發送數據,等待系統從緩沖區把數據取走,然後通過網卡把數據發出去,接收方的網卡在收到數據之後,會把數據copy到socket的緩沖區,然後等待應用程序來取,這是大概的發收數據流程。

copy數據的開銷

因為涉及到系統調用,整個過程可以發現一份數據需要先從用戶態拷貝到內核態的socket,然後又要從內核態的socket拷貝到用戶態的進程中去,這就是數據拷貝的開銷。

數據怎麼知道發給哪個socket

內核維護的socket那麼多,網卡過來的數據怎麼知道投遞給哪個socket?

答案是端口,socket是一個四元組:

ip(client)+ port(client)+ip(server)+port(server)

註意千萬不要說一臺機器的理論最大並發是65535個,除瞭端口,還有ip,應該是端口數*ip數

這也是為什麼一臺電腦可以同時打開多個軟件的原因。

socket的數據怎麼通知程序來取

當數據已經從網卡copy到瞭對應的socket緩沖區中,怎麼通知程序來取?假如socket數據還沒到達,這時程序在幹嘛?這裡其實涉及到cpu對進程的調度的問題。從cpu的角度來看,進程存在運行態、就緒態、阻塞態。

  • 就緒態:進程等待被執行,資源都已經準備好瞭,剩下的就等待cpu的調度瞭。
  • 運行態:正在運行的進程,cpu正在調度的進程。
  • 阻塞態:因為某些情況導致阻塞,不占有cpu,正在等待某些事件的完成。

當存在多個運行態的進程時,由於cpu的時間片技術,運行態的進程都會被cpu執行一段時間,看著好似同時運行一樣,這就是所謂的並發。當我們創建一個socket連接時,它大概會這樣:

sockfd = socket(AF_INET, SOCK_STREAM, 0)
connect(sockfd, ....)
recv(sockfd, ...)
doSometing()

操作系統會為每個socket建立一個fd句柄,這個fd就指向我們創建的socket對象,這個對象包含緩沖區、進程的等待隊列…。對於一個創建socket的進程來說,如果數據沒到達,那麼他會卡在recv處,這個進程會掛在socket對象的等待隊列中,對cpu來說,這個進程就是阻塞的,它其實不占有cpu,它在等待數據的到來。

當數據到來時,網卡會告訴cpu,cpu執行中斷程序,把網卡的數據copy到對應的socket的緩沖區中,然後喚醒等待隊列中的進程,把這個進程重新放回運行隊列中,當這個進程被cpu運行的時候,它就可以執行最後的讀取操作瞭。這種模式有兩個問題:

recv隻能接收一個fd,如果要recv多個fd怎麼辦?

通過while循環效率稍低。

進程除瞭讀取數據,還要處理接下裡的邏輯,在數據沒到達時,進程處於阻塞態,即使用瞭while循環來監聽多個fd,其它的socket是不是因為其中一個recv阻塞,而導致整個進程的阻塞。

針對上述問題,於是Reactor模式和IO多路復用技術出現瞭。

Reactor

Reactor是一種高性能處理IO的模式,Reactor模式下主程序隻負責監聽文件描述符上是否有事件發生,這一點很重要,主程序並不處理文件描述符的讀寫。那麼文件描述符的可讀可寫誰來做?答案是其他的工作程序,當某個socket發生可讀可寫的事件後,主程序會通知工作程序,真正從socket裡面讀取數據和寫入數據的是工作程序。這種模式的好處就是就是主程序可以扛並發,不阻塞,主程序非常的輕便。事件可以通過隊列的方式等待被工作程序執行。通過Reactor模式,我們隻需要把事件和事件對應的handler(callback func),註冊到Reactor中就行瞭,比如:

type Reactor interface{
   RegisterHandler(WriteCallback func(), "writeEvent");
   RegisterHandler(ReadCallback func(), "readEvent");
}

當一個客戶端向redis發起set key value的命令,這時候會向socket緩沖區寫入這樣的命令請求,當Reactor監聽到對應的socket緩沖區有數據瞭,那麼此時的socket是可讀的,Reactor就會觸發讀事件,通過事先註入的ReadCallback回調函數來完成命令的解析、命令的執行。當socket的緩沖區有足夠的空間可以被寫,那麼對應的Reactor就會產生可寫事件,此時就會執行事先註入的WriteCallback回調函數。當發起的set key value執行完畢後,此時工作程序會向socket緩沖區中寫入OK,最後客戶端會從socket緩沖區中取走寫入的OK。在redis中不管是ReadCallback,還是WriteCallback,它們都是一個線程完成的,如果它們同時到達那麼也得排隊,這就是redis6.0之前的默認模式,也是最廣為流傳的單線程redis。

整個流程下來可以發現Reactor主程序非常快,因為它不需要執行真正的讀寫,剩下的都是工作程序幹的事:IO的讀寫、命令的解析、命令的執行、結果的返回..,這一點很重要。

IO多路復用器

通過上面我們知道Reactor它是一個抽象的理論,是一個模式,如何實現它?如何監聽socket事件的到來?。最簡單的辦法就是輪詢,我們既然不知道socket事件什麼時候到達,那麼我們就一直來問內核,假設現在有1w個socket連接,那麼我們就得循環問內核1w次,這個開銷明顯很大。

用戶態到內核態的切換,涉及到上下文的切換(context),cpu需要保護現場,在進入內核前需要保存寄存器的狀態,在內核返回後還需要從寄存器裡恢復狀態,這是個不小的開銷。

由於傳統的輪詢方法開銷過大,於是IO多路復用復用器出現瞭,IO多路復用器有select、poll、evport、kqueue、epoll。Redis在I/O多路復用程序的實現源碼中用#include宏定義瞭相應的規則,程序會在編譯時自動選擇系統中性能最高的I/O多路復用函數庫來作為Redis的I/O多路復用程序的底層實現:

// Include the best multiplexing layer supported by this system. The following should be ordered by performances, descending.
# ifdef HAVE_EVPORT
# include "ae_evport.c"
# else
    # ifdef HAVE_EPOLL
    # include "ae_epoll.c"
    # else
        # ifdef HAVE_KQUEUE
        # include "ae_kqueue.c"
        # else
        # include "ae_select.c"
        # endif
    # endif
# endif
 

我們這裡主要介紹兩種非常經典的復用器select和epoll,select是IO多路復用器的初代,select是如何解決不停地從用戶態到內核態的輪詢問題的?

select

既然每次輪詢很麻煩,那麼select就把一批socket的fds集合一次性交給內核,然後內核自己遍歷fds,然後判斷每個fd的可讀可寫狀態,當某個fd的狀態滿足時,由用戶自己判斷去獲取。

fds = []int{fd1,fd2,...}
for {
 select (fds)
 for i:= 0; i < len(fds); i++{
  if isReady(fds[i]) {
      read()
     }
  }
}

select的缺點:當一個進程監聽多個socket的時候,通過select會把內核中所有的socket的等待隊列都加上本進程(多對一),這樣當其中一個socket有數據的時候,它就會把告訴cpu,同時把這個進程從阻塞態喚醒,等待被cpu的調度,同時會把進程從所有的socket的等待隊列中移除,當cpu運行這個進程的時候,進程因為本身傳進去瞭一批fds集合,我們並不知道哪個fd來數據瞭,所以隻能都遍歷一次,這樣對於沒有數據到來的fd來說,就白白浪費瞭。由於每次select要遍歷socket集合,那麼這個socket集合的數量過大就會影響整體效率,這原因也是select為什麼支持最大1024個並發的。

epoll

如果有一種方法使得不用遍歷所有的socket,當某個socket的消息到來時,隻需要觸發對應的socket fd,而不用盲目的輪詢,那效率是不是會更高。epoll的出現就是為瞭解決這個問題:

epfd = epoll_create()
epoll_ctl(epfd, fd1, fd2...)
for {
  epoll_wait()
  for fd := range fds {
    doSomething()
  }
}
  • 首先通過epoll_create創建一個epoll對象,它會返回一個fd句柄,和socket的句柄一樣,也是管理在fds集合下。
  • 通過epoll_ctl,把需要監聽的socket fd和epoll對象綁定。
  • 通過epoll_wait來獲取有數據的socket fd,當沒有一個socket有數據的時候,那麼此處會阻塞,有數據的話,那麼就會返回有數據的fds集合。

epoll是怎麼做到的?

首先內核的socket不在和用戶的進程綁定瞭,而是和epoll綁定,這樣當socket的數據到來時,中斷程序就會給epoll的一個就緒對列添加對應socket fd,這個隊列裡都是有數據的socket,然後和epoll關聯的進程也會被喚醒,當cpu運行進程的時候,就可以直接從epoll的就緒隊列中獲取有事件的socket,執行接下來的讀。整個流程下來,可以發現用戶程序不用無腦遍歷,內核也不用遍歷,通過中斷做到”誰有數據處理誰”的高效表現。

單線程到多線程的演進

單線程

結合Reactor的思想加上高性能epoll IO模式,redis開發出一套高性能的網絡IO架構:單線程的IO多路復用,IO多路復用器負責接受網絡IO事件,事件最終以隊列的方式排隊等待被處理,這是最原始的單線程模型,為什麼使用單線程?因為單線程的redis已經可以達到10w qps的負載(如果做一些復雜的集合操作,會降低),滿足絕大部分應用場景瞭,同時單線程不用考慮多線程帶來的鎖的問題,如果還沒達到你的要求,那麼你也可以配置分片模式,讓不同的節點處理不同的sharding key,這樣你的redis server的負載能力就能隨著節點的增長而進一步線性增長。

異步線程

在單線程模式下有這樣一個問題,當執行刪除某個很大的集合或者hash的時候會很耗時(不是連續內存),那麼單線程的表現就是其他還在排隊的命令就得等待。當等待的命令越來越多,那麼不好的事情就會發生。於是redis4.0針對大key刪除的情況,出瞭個異步線程。用unlink代替del去執行刪除,這樣當我們unlink的時候,redis會檢測當刪除的key是否需要放到異步線程去執行(比如集合的數量超過64個…),如果value足夠大,那麼就會放到異步線程裡去處理,不會影響主線程。同樣的還有flushall、flushdb都支持異步模式。此外redis還支持某些場景下是否需要異步線程來處理的模式(默認是關閉的):

lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
replica-lazy-flush no

lazyfree-lazy-eviction:針對redis有設置內存達到maxmemory的淘汰策略時,這時候會啟動異步刪除,此場景異步刪除的缺點就是如果刪除不及時,內存不能得到及時釋放。

lazyfree-lazy-expire:對於有ttl的key,在被redis清理的時候,不執行同步刪除,加入異步線程來刪除。

replica-lazy-flush:在slave節點加入進來的時候,會執行flush清空自己的數據,如果flush耗時較久,那麼復制緩沖區堆積的數據就越多,後面slave同步數據較相對慢,開啟replica-lazy-flush後,slave的flush可以交由異步現成來處理,從而提高同步的速度。

lazyfree-lazy-server-del:這個選項是針對一些指令,比如rename一個字段的時候執行RENAME key newkey, 如果這時newkey是b存在的,對於rename來說它就要刪除這個newkey原來的老值,如果這個老值很大,那麼就會造成阻塞,當開啟瞭這個選項時也會交給異步線程來操作,這樣就不會阻塞主線程瞭。

多線程

redis單線程+異步線程+分片已經能滿足瞭絕大部分應用,然後沒有最好隻有更好,redis在6.0還是推出瞭多線程模式。默認情況下,多線程模式是關閉的。

# io-threads 4 # work線程數
# io-threads-do-reads no # 是否開啟

多線程的作用點?

通過上文我們知道當我們從一個socket中讀取數據的時候,需要從內核copy到用戶空間,當我們往socket中寫數據的時候,需要從用戶空間copy到內核。redis本身的計算還是很快的,慢的地方那麼主要就是socket IO相關操作瞭。當我們的qps非常大的時候,單線程的redis無法發揮多核cpu的好處,那麼通過開啟多個線程利用多核cpu來分擔IO操作是個不錯的選擇。

So for instance if you have a four cores boxes, try to use 2 or 3 I/O threads, if you have a 8 cores, try to use 6 threads.

開啟的話,官方建議對於一個4核的機器來說,開2-3個IO線程,如果有8核,那麼開6個IO線程即可。

多線程的原理

需要註意的是redis的多線程僅僅隻是處理socket IO讀寫是多個線程,真正去運行指令還是一個線程去執行的。

  1. redis server通過EventLoop來監聽客戶端的請求,當一個請求到來時,主線程並不會立馬解析執行,而是把它放到全局讀隊列clients_pending_read中,並給每個client打上CLIENT_PENDING_READ標識。
  2. 然後主線程通過RR(Round-robin)策略把所有任務分配給I/O線程和主線程自己。
  3. 每個線程(包括主線程和子線程)根據分配到的任務,通過client的CLIENT_PENDING_READ標識隻做請求參數的讀取和解析(這裡並不執行命令)。
  4. 主線程會忙輪詢等待所有的IO線程執行完,每個IO線程都會維護一個本地的隊列io_threads_list和本地的原子計數器io_threads_pending,線程之間的任務是隔離的,不會重疊,當IO線程完成任務之後,io_threads_pending[index] = 0,當所有的io_threads_pending都是0的時候,就是任務執行完畢之時。
  5. 當所有read執行完畢之後,主線程通過遍歷clients_pending_read隊列,來執行真正的exec動作。
  6. 在完成命令的讀取、解析、執行之後,就要把結果響應給客戶端瞭。主線程會把需要響應的client加入到全局的clients_pending_write隊列中。
  7. 主線程遍歷clients_pending_write隊列,再通過RR(Round-robin)策略把所有任務分給I/O線程和主線程,讓它們將數據回寫給客戶端。

多線程模式下,每個IO線程負責處理自己的隊列,不會互相幹擾,IO線程要麼同時在讀,要麼同時在寫,不會同時讀或寫。主線程也隻會在所有的子線程的任務處理完畢之後,才會嘗試再次分配任務。同時最終的命令執行還是由主線程自己來完成,整個過程不涉及到鎖。

以上就是Redis線程IO模型的演進教程示例精講的詳細內容,更多關於Redis線程IO模型的演進的資料請關註WalkonNet其它相關文章!

推薦閱讀: