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