Redis緩存IO模型的演進(jìn)教程示例精講
前言
redis作為應(yīng)用最廣泛的nosql數(shù)據(jù)庫之一,大大小小也經(jīng)歷過很多次升級。在4.0版本之前,單線程+IO多路復(fù)用使得redis的性能已經(jīng)達(dá)到一個非常高的高度了。作者也說過,之所以設(shè)計(jì)成單線程是因?yàn)閞edis的瓶頸不在cpu上,而且單線程也不需要考慮多線程帶來的鎖開銷問題。
然而隨著時間的推移,單線程越來越不滿足一些應(yīng)用場景了,比如針對大key刪除會造成主線程阻塞的問題,redis4.0出了一個異步線程。
針對單線程由于無法利用多核cpu的特性而導(dǎo)致無法滿足更高的并發(fā),redis6.0也推出了多線程模式。所以說redis是單線程越來越不準(zhǔn)確了。
事件模型
redis本身是個事件驅(qū)動程序,通過監(jiān)聽文件事件和時間事件來完成相應(yīng)的功能。其中文件事件其實(shí)就是對socket的抽象,把一個個socket事件抽象成文件事件,redis基于Reactor模式開發(fā)了自己的網(wǎng)絡(luò)事件處理器。那么Reactor模式是什么?
通信
思考一個問題,我們的服務(wù)器是如何收到我們的數(shù)據(jù)的?首先雙方先要建立TCP連接,連接建立以后,就可以收發(fā)數(shù)據(jù)了。發(fā)送方向socket的緩沖區(qū)發(fā)送數(shù)據(jù),等待系統(tǒng)從緩沖區(qū)把數(shù)據(jù)取走,然后通過網(wǎng)卡把數(shù)據(jù)發(fā)出去,接收方的網(wǎng)卡在收到數(shù)據(jù)之后,會把數(shù)據(jù)copy到socket的緩沖區(qū),然后等待應(yīng)用程序來取,這是大概的發(fā)收數(shù)據(jù)流程。
copy數(shù)據(jù)的開銷
因?yàn)樯婕暗较到y(tǒng)調(diào)用,整個過程可以發(fā)現(xiàn)一份數(shù)據(jù)需要先從用戶態(tài)拷貝到內(nèi)核態(tài)的socket,然后又要從內(nèi)核態(tài)的socket拷貝到用戶態(tài)的進(jìn)程中去,這就是數(shù)據(jù)拷貝的開銷。
數(shù)據(jù)怎么知道發(fā)給哪個socket
內(nèi)核維護(hù)的socket那么多,網(wǎng)卡過來的數(shù)據(jù)怎么知道投遞給哪個socket?
答案是端口,socket是一個四元組:
ip(client)+ port(client)+ip(server)+port(server)
注意千萬不要說一臺機(jī)器的理論最大并發(fā)是65535個,除了端口,還有ip,應(yīng)該是端口數(shù)*ip數(shù)
這也是為什么一臺電腦可以同時打開多個軟件的原因。
socket的數(shù)據(jù)怎么通知程序來取
當(dāng)數(shù)據(jù)已經(jīng)從網(wǎng)卡copy到了對應(yīng)的socket緩沖區(qū)中,怎么通知程序來?。考偃鐂ocket數(shù)據(jù)還沒到達(dá),這時程序在干嘛?這里其實(shí)涉及到cpu對進(jìn)程的調(diào)度的問題。從cpu的角度來看,進(jìn)程存在運(yùn)行態(tài)、就緒態(tài)、阻塞態(tài)。
- 就緒態(tài):進(jìn)程等待被執(zhí)行,資源都已經(jīng)準(zhǔn)備好了,剩下的就等待cpu的調(diào)度了。
- 運(yùn)行態(tài):正在運(yùn)行的進(jìn)程,cpu正在調(diào)度的進(jìn)程。
- 阻塞態(tài):因?yàn)槟承┣闆r導(dǎo)致阻塞,不占有cpu,正在等待某些事件的完成。
當(dāng)存在多個運(yùn)行態(tài)的進(jìn)程時,由于cpu的時間片技術(shù),運(yùn)行態(tài)的進(jìn)程都會被cpu執(zhí)行一段時間,看著好似同時運(yùn)行一樣,這就是所謂的并發(fā)。當(dāng)我們創(chuàng)建一個socket連接時,它大概會這樣:
sockfd = socket(AF_INET, SOCK_STREAM, 0) connect(sockfd, ....) recv(sockfd, ...) doSometing()
操作系統(tǒng)會為每個socket建立一個fd句柄,這個fd就指向我們創(chuàng)建的socket對象,這個對象包含緩沖區(qū)、進(jìn)程的等待隊(duì)列...。對于一個創(chuàng)建socket的進(jìn)程來說,如果數(shù)據(jù)沒到達(dá),那么他會卡在recv處,這個進(jìn)程會掛在socket對象的等待隊(duì)列中,對cpu來說,這個進(jìn)程就是阻塞的,它其實(shí)不占有cpu,它在等待數(shù)據(jù)的到來。
當(dāng)數(shù)據(jù)到來時,網(wǎng)卡會告訴cpu,cpu執(zhí)行中斷程序,把網(wǎng)卡的數(shù)據(jù)copy到對應(yīng)的socket的緩沖區(qū)中,然后喚醒等待隊(duì)列中的進(jìn)程,把這個進(jìn)程重新放回運(yùn)行隊(duì)列中,當(dāng)這個進(jìn)程被cpu運(yùn)行的時候,它就可以執(zhí)行最后的讀取操作了。這種模式有兩個問題:
recv只能接收一個fd,如果要recv多個fd怎么辦?
通過while循環(huán)效率稍低。
進(jìn)程除了讀取數(shù)據(jù),還要處理接下里的邏輯,在數(shù)據(jù)沒到達(dá)時,進(jìn)程處于阻塞態(tài),即使用了while循環(huán)來監(jiān)聽多個fd,其它的socket是不是因?yàn)槠渲幸粋€recv阻塞,而導(dǎo)致整個進(jìn)程的阻塞。
針對上述問題,于是Reactor模式和IO多路復(fù)用技術(shù)出現(xiàn)了。
Reactor
Reactor是一種高性能處理IO的模式,Reactor模式下主程序只負(fù)責(zé)監(jiān)聽文件描述符上是否有事件發(fā)生,這一點(diǎn)很重要,主程序并不處理文件描述符的讀寫。那么文件描述符的可讀可寫誰來做?答案是其他的工作程序,當(dāng)某個socket發(fā)生可讀可寫的事件后,主程序會通知工作程序,真正從socket里面讀取數(shù)據(jù)和寫入數(shù)據(jù)的是工作程序。這種模式的好處就是就是主程序可以扛并發(fā),不阻塞,主程序非常的輕便。事件可以通過隊(duì)列的方式等待被工作程序執(zhí)行。通過Reactor模式,我們只需要把事件和事件對應(yīng)的handler(callback func),注冊到Reactor中就行了,比如:
type Reactor interface{ RegisterHandler(WriteCallback func(), "writeEvent"); RegisterHandler(ReadCallback func(), "readEvent"); }
當(dāng)一個客戶端向redis發(fā)起set key value的命令,這時候會向socket緩沖區(qū)寫入這樣的命令請求,當(dāng)Reactor監(jiān)聽到對應(yīng)的socket緩沖區(qū)有數(shù)據(jù)了,那么此時的socket是可讀的,Reactor就會觸發(fā)讀事件,通過事先注入的ReadCallback回調(diào)函數(shù)來完成命令的解析、命令的執(zhí)行。當(dāng)socket的緩沖區(qū)有足夠的空間可以被寫,那么對應(yīng)的Reactor就會產(chǎn)生可寫事件,此時就會執(zhí)行事先注入的WriteCallback回調(diào)函數(shù)。當(dāng)發(fā)起的set key value執(zhí)行完畢后,此時工作程序會向socket緩沖區(qū)中寫入OK,最后客戶端會從socket緩沖區(qū)中取走寫入的OK。在redis中不管是ReadCallback,還是WriteCallback,它們都是一個線程完成的,如果它們同時到達(dá)那么也得排隊(duì),這就是redis6.0之前的默認(rèn)模式,也是最廣為流傳的單線程redis。
整個流程下來可以發(fā)現(xiàn)Reactor主程序非???,因?yàn)樗恍枰獔?zhí)行真正的讀寫,剩下的都是工作程序干的事:IO的讀寫、命令的解析、命令的執(zhí)行、結(jié)果的返回..,這一點(diǎn)很重要。
IO多路復(fù)用器
通過上面我們知道Reactor它是一個抽象的理論,是一個模式,如何實(shí)現(xiàn)它?如何監(jiān)聽socket事件的到來?。最簡單的辦法就是輪詢,我們既然不知道socket事件什么時候到達(dá),那么我們就一直來問內(nèi)核,假設(shè)現(xiàn)在有1w個socket連接,那么我們就得循環(huán)問內(nèi)核1w次,這個開銷明顯很大。
用戶態(tài)到內(nèi)核態(tài)的切換,涉及到上下文的切換(context),cpu需要保護(hù)現(xiàn)場,在進(jìn)入內(nèi)核前需要保存寄存器的狀態(tài),在內(nèi)核返回后還需要從寄存器里恢復(fù)狀態(tài),這是個不小的開銷。
由于傳統(tǒng)的輪詢方法開銷過大,于是IO多路復(fù)用復(fù)用器出現(xiàn)了,IO多路復(fù)用器有select、poll、evport、kqueue、epoll。Redis在I/O多路復(fù)用程序的實(shí)現(xiàn)源碼中用#include宏定義了相應(yīng)的規(guī)則,程序會在編譯時自動選擇系統(tǒng)中性能最高的I/O多路復(fù)用函數(shù)庫來作為Redis的I/O多路復(fù)用程序的底層實(shí)現(xiàn):
// 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
我們這里主要介紹兩種非常經(jīng)典的復(fù)用器select和epoll,select是IO多路復(fù)用器的初代,select是如何解決不停地從用戶態(tài)到內(nèi)核態(tài)的輪詢問題的?
select
既然每次輪詢很麻煩,那么select就把一批socket的fds集合一次性交給內(nèi)核,然后內(nèi)核自己遍歷fds,然后判斷每個fd的可讀可寫狀態(tài),當(dāng)某個fd的狀態(tài)滿足時,由用戶自己判斷去獲取。
fds = []int{fd1,fd2,...} for { select (fds) for i:= 0; i < len(fds); i++{ if isReady(fds[i]) { read() } } }
select的缺點(diǎn):當(dāng)一個進(jìn)程監(jiān)聽多個socket的時候,通過select會把內(nèi)核中所有的socket的等待隊(duì)列都加上本進(jìn)程(多對一),這樣當(dāng)其中一個socket有數(shù)據(jù)的時候,它就會把告訴cpu,同時把這個進(jìn)程從阻塞態(tài)喚醒,等待被cpu的調(diào)度,同時會把進(jìn)程從所有的socket的等待隊(duì)列中移除,當(dāng)cpu運(yùn)行這個進(jìn)程的時候,進(jìn)程因?yàn)楸旧韨鬟M(jìn)去了一批fds集合,我們并不知道哪個fd來數(shù)據(jù)了,所以只能都遍歷一次,這樣對于沒有數(shù)據(jù)到來的fd來說,就白白浪費(fèi)了。由于每次select要遍歷socket集合,那么這個socket集合的數(shù)量過大就會影響整體效率,這原因也是select為什么支持最大1024個并發(fā)的。
epoll
如果有一種方法使得不用遍歷所有的socket,當(dāng)某個socket的消息到來時,只需要觸發(fā)對應(yīng)的socket fd,而不用盲目的輪詢,那效率是不是會更高。epoll的出現(xiàn)就是為了解決這個問題:
epfd = epoll_create() epoll_ctl(epfd, fd1, fd2...) for { epoll_wait() for fd := range fds { doSomething() } }
- 首先通過epoll_create創(chuàng)建一個epoll對象,它會返回一個fd句柄,和socket的句柄一樣,也是管理在fds集合下。
- 通過epoll_ctl,把需要監(jiān)聽的socket fd和epoll對象綁定。
- 通過epoll_wait來獲取有數(shù)據(jù)的socket fd,當(dāng)沒有一個socket有數(shù)據(jù)的時候,那么此處會阻塞,有數(shù)據(jù)的話,那么就會返回有數(shù)據(jù)的fds集合。
epoll是怎么做到的?
首先內(nèi)核的socket不在和用戶的進(jìn)程綁定了,而是和epoll綁定,這樣當(dāng)socket的數(shù)據(jù)到來時,中斷程序就會給epoll的一個就緒對列添加對應(yīng)socket fd,這個隊(duì)列里都是有數(shù)據(jù)的socket,然后和epoll關(guān)聯(lián)的進(jìn)程也會被喚醒,當(dāng)cpu運(yùn)行進(jìn)程的時候,就可以直接從epoll的就緒隊(duì)列中獲取有事件的socket,執(zhí)行接下來的讀。整個流程下來,可以發(fā)現(xiàn)用戶程序不用無腦遍歷,內(nèi)核也不用遍歷,通過中斷做到"誰有數(shù)據(jù)處理誰"的高效表現(xiàn)。
單線程到多線程的演進(jìn)
單線程
結(jié)合Reactor的思想加上高性能epoll IO模式,redis開發(fā)出一套高性能的網(wǎng)絡(luò)IO架構(gòu):單線程的IO多路復(fù)用,IO多路復(fù)用器負(fù)責(zé)接受網(wǎng)絡(luò)IO事件,事件最終以隊(duì)列的方式排隊(duì)等待被處理,這是最原始的單線程模型,為什么使用單線程?因?yàn)閱尉€程的redis已經(jīng)可以達(dá)到10w qps的負(fù)載(如果做一些復(fù)雜的集合操作,會降低),滿足絕大部分應(yīng)用場景了,同時單線程不用考慮多線程帶來的鎖的問題,如果還沒達(dá)到你的要求,那么你也可以配置分片模式,讓不同的節(jié)點(diǎn)處理不同的sharding key,這樣你的redis server的負(fù)載能力就能隨著節(jié)點(diǎn)的增長而進(jìn)一步線性增長。
異步線程
在單線程模式下有這樣一個問題,當(dāng)執(zhí)行刪除某個很大的集合或者h(yuǎn)ash的時候會很耗時(不是連續(xù)內(nèi)存),那么單線程的表現(xiàn)就是其他還在排隊(duì)的命令就得等待。當(dāng)?shù)却拿钤絹碓蕉啵敲床缓玫氖虑榫蜁l(fā)生。于是redis4.0針對大key刪除的情況,出了個異步線程。用unlink代替del去執(zhí)行刪除,這樣當(dāng)我們unlink的時候,redis會檢測當(dāng)刪除的key是否需要放到異步線程去執(zhí)行(比如集合的數(shù)量超過64個...),如果value足夠大,那么就會放到異步線程里去處理,不會影響主線程。同樣的還有flushall、flushdb都支持異步模式。此外redis還支持某些場景下是否需要異步線程來處理的模式(默認(rèn)是關(guān)閉的):
lazyfree-lazy-eviction no lazyfree-lazy-expire no lazyfree-lazy-server-del no replica-lazy-flush no
lazyfree-lazy-eviction
:針對redis有設(shè)置內(nèi)存達(dá)到maxmemory的淘汰策略時,這時候會啟動異步刪除,此場景異步刪除的缺點(diǎn)就是如果刪除不及時,內(nèi)存不能得到及時釋放。
lazyfree-lazy-expire
:對于有ttl的key,在被redis清理的時候,不執(zhí)行同步刪除,加入異步線程來刪除。
replica-lazy-flush
:在slave節(jié)點(diǎn)加入進(jìn)來的時候,會執(zhí)行flush清空自己的數(shù)據(jù),如果flush耗時較久,那么復(fù)制緩沖區(qū)堆積的數(shù)據(jù)就越多,后面slave同步數(shù)據(jù)較相對慢,開啟replica-lazy-flush后,slave的flush可以交由異步現(xiàn)成來處理,從而提高同步的速度。
lazyfree-lazy-server-del
:這個選項(xiàng)是針對一些指令,比如rename一個字段的時候執(zhí)行RENAME key newkey, 如果這時newkey是b存在的,對于rename來說它就要刪除這個newkey原來的老值,如果這個老值很大,那么就會造成阻塞,當(dāng)開啟了這個選項(xiàng)時也會交給異步線程來操作,這樣就不會阻塞主線程了。
多線程
redis單線程+異步線程+分片已經(jīng)能滿足了絕大部分應(yīng)用,然后沒有最好只有更好,redis在6.0還是推出了多線程模式。默認(rèn)情況下,多線程模式是關(guān)閉的。
# io-threads 4 # work線程數(shù) # io-threads-do-reads no # 是否開啟
多線程的作用點(diǎn)?
通過上文我們知道當(dāng)我們從一個socket中讀取數(shù)據(jù)的時候,需要從內(nèi)核copy到用戶空間,當(dāng)我們往socket中寫數(shù)據(jù)的時候,需要從用戶空間copy到內(nèi)核。redis本身的計(jì)算還是很快的,慢的地方那么主要就是socket IO相關(guān)操作了。當(dāng)我們的qps非常大的時候,單線程的redis無法發(fā)揮多核cpu的好處,那么通過開啟多個線程利用多核cpu來分擔(dān)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核的機(jī)器來說,開2-3個IO線程,如果有8核,那么開6個IO線程即可。
多線程的原理
需要注意的是redis的多線程僅僅只是處理socket IO讀寫是多個線程,真正去運(yùn)行指令還是一個線程去執(zhí)行的。
- redis server通過EventLoop來監(jiān)聽客戶端的請求,當(dāng)一個請求到來時,主線程并不會立馬解析執(zhí)行,而是把它放到全局讀隊(duì)列clients_pending_read中,并給每個client打上CLIENT_PENDING_READ標(biāo)識。
- 然后主線程通過RR(Round-robin)策略把所有任務(wù)分配給I/O線程和主線程自己。
- 每個線程(包括主線程和子線程)根據(jù)分配到的任務(wù),通過client的CLIENT_PENDING_READ標(biāo)識只做請求參數(shù)的讀取和解析(這里并不執(zhí)行命令)。
- 主線程會忙輪詢等待所有的IO線程執(zhí)行完,每個IO線程都會維護(hù)一個本地的隊(duì)列io_threads_list和本地的原子計(jì)數(shù)器io_threads_pending,線程之間的任務(wù)是隔離的,不會重疊,當(dāng)IO線程完成任務(wù)之后,io_threads_pending[index] = 0,當(dāng)所有的io_threads_pending都是0的時候,就是任務(wù)執(zhí)行完畢之時。
- 當(dāng)所有read執(zhí)行完畢之后,主線程通過遍歷clients_pending_read隊(duì)列,來執(zhí)行真正的exec動作。
- 在完成命令的讀取、解析、執(zhí)行之后,就要把結(jié)果響應(yīng)給客戶端了。主線程會把需要響應(yīng)的client加入到全局的clients_pending_write隊(duì)列中。
- 主線程遍歷clients_pending_write隊(duì)列,再通過RR(Round-robin)策略把所有任務(wù)分給I/O線程和主線程,讓它們將數(shù)據(jù)回寫給客戶端。
多線程模式下,每個IO線程負(fù)責(zé)處理自己的隊(duì)列,不會互相干擾,IO線程要么同時在讀,要么同時在寫,不會同時讀或?qū)?。主線程也只會在所有的子線程的任務(wù)處理完畢之后,才會嘗試再次分配任務(wù)。同時最終的命令執(zhí)行還是由主線程自己來完成,整個過程不涉及到鎖。
以上就是Redis線程IO模型的演進(jìn)教程示例精講的詳細(xì)內(nèi)容,更多關(guān)于Redis線程IO模型的演進(jìn)的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
基于Redis實(shí)現(xiàn)分布式鎖的方法(lua腳本版)
這篇文章主要介紹了基于Redis實(shí)現(xiàn)分布式鎖的方法(lua腳本版),本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-05-05深入理解redis_memcached失效原理(小結(jié))
這篇文章主要介紹了深入理解redis_memcached失效原理(小結(jié)),小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-08-08Redis高并發(fā)情況下并發(fā)扣減庫存項(xiàng)目實(shí)戰(zhàn)
本文主要介紹了Redis高并發(fā)情況下并發(fā)扣減庫存項(xiàng)目實(shí)戰(zhàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-04-04