一般的IO調(diào)用
首先來(lái)看一下一般的IO調(diào)用。在傳統(tǒng)的文件IO操作中,我們都是調(diào)用操作系統(tǒng)提供的底層標(biāo)準(zhǔn)IO系統(tǒng)調(diào)用函數(shù) read()、write() ,此時(shí)調(diào)用此函數(shù)的進(jìn)程(在JAVA中即java進(jìn)程)由當(dāng)前的用戶態(tài)切換到內(nèi)核態(tài),然后OS的內(nèi)核代碼負(fù)責(zé)將相應(yīng)的文件數(shù)據(jù)讀取到內(nèi)核的IO緩沖區(qū),然后再把數(shù)據(jù)從內(nèi)核IO緩沖區(qū)拷貝到進(jìn)程的私有地址空間中去,這樣便完成了一次IO操作。如下圖所示。

注意兩點(diǎn):
- OS的read函數(shù)會(huì)在內(nèi)核IO緩沖區(qū)中預(yù)讀取數(shù)據(jù),減少磁盤IO操作(Step2)
- Java的BufferedReader或BufferedInputStream的緩沖區(qū)的作用是減少系統(tǒng)調(diào)用(Step1)
Java的IO讀寫大致分為三種:
1、普通IO(java.io)
例如FileWriter、FileReader等,普通IO是傳統(tǒng)字節(jié)傳輸方式,讀寫慢阻塞,單向一個(gè)Read對(duì)應(yīng)一個(gè)Write 。
2、文件通道 FileChannel(java.nio)
FileChannel fileChannel = new RandomAccessFile(new File("data.txt"), "rw").getChannel()
全雙工通道,采用內(nèi)存緩沖區(qū)ByteBuffer且是線程安全的
使用FileChannel為什么會(huì)比普通IO快?
一般情況FileChannel在一次寫入4kb的整數(shù)倍數(shù)時(shí),才能發(fā)揮出實(shí)際的性能,益于FileChannel采用了ByteBuffer這樣的內(nèi)存緩沖區(qū)。這樣可以精準(zhǔn)控制寫入磁盤的大小,這是普通IO無(wú)法實(shí)現(xiàn)FileChannel是直接把ByteBuffer的數(shù)據(jù)直接寫入磁盤?
ByteBuffer 中的數(shù)據(jù)和磁盤中的數(shù)據(jù)還隔了一層,這一層便是 PageCache,是用戶內(nèi)存和磁盤之間的一層緩存。我們都知道磁盤 IO 和內(nèi)存 IO 的速度可是相差了好幾個(gè)數(shù)量級(jí)。我們可以認(rèn)為 filechannel.write 寫入 PageCache 便是完成了落盤操作,但實(shí)際上,操作系統(tǒng)最終幫我們完成了 PageCache 到磁盤的最終寫入,理解了這個(gè)概念,你就應(yīng)該能夠理解 FileChannel 為什么提供了一個(gè) force() 方法,用于通知操作系統(tǒng)進(jìn)行及時(shí)的刷盤,同理使用FileChannel時(shí)同樣經(jīng)歷磁盤->PageCache->用戶內(nèi)存三個(gè)階段
3、內(nèi)存映射MMAP(java.nio)
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, position, fileSize)

mmap 把文件映射到用戶空間里的虛擬內(nèi)存,省去了從內(nèi)核緩沖區(qū)復(fù)制到用戶空間的過(guò)程,文件中的位置在虛擬內(nèi)存中有了對(duì)應(yīng)的地址,可以像操作內(nèi)存一樣操作這個(gè)文件,相當(dāng)于已經(jīng)把整個(gè)文件放入內(nèi)存,但在真正使用到這些數(shù)據(jù)前卻不會(huì)消耗物理內(nèi)存,也不會(huì)有讀寫磁盤的操作,只有真正使用這些數(shù)據(jù)時(shí),也就是圖像準(zhǔn)備渲染在屏幕上時(shí),虛擬內(nèi)存管理系統(tǒng) VMS
MMAP 并非是文件 IO 的銀彈,它只有在一次寫入很小量數(shù)據(jù)的場(chǎng)景下才能表現(xiàn)出比 FileChannel 稍微優(yōu)異的性能。緊接著我還要告訴你一些令你沮喪的事,至少在 JAVA 中使用 MappedByteBuffer 是一件非常麻煩并且痛苦的事,主要表現(xiàn)為三點(diǎn):
MMAP 使用時(shí)必須實(shí)現(xiàn)指定好內(nèi)存映射的大小,并且一次 map 的大小限制在 1.5G 左右,重復(fù) map 又會(huì)帶來(lái)虛擬內(nèi)存的回收、重新分配的問(wèn)題,對(duì)于文件不確定大小的情形實(shí)在是太不友好了。
MMAP 使用的是虛擬內(nèi)存,和 PageCache 一樣是由操作系統(tǒng)來(lái)控制刷盤的,雖然可以通過(guò) force() 來(lái)手動(dòng)控制,但這個(gè)時(shí)間把握不好,在小內(nèi)存場(chǎng)景下會(huì)很令人頭疼。
MMAP 的回收問(wèn)題,當(dāng) MappedByteBuffer 不再需要時(shí),可以手動(dòng)釋放占用的虛擬內(nèi)存,但…方式非常的詭異

OS 的 PageCache機(jī)制
PageCache是OS對(duì)文件的緩存,用于加速對(duì)文件的讀寫。一般來(lái)說(shuō),程序?qū)ξ募M(jìn)行順序讀寫的速度幾乎接近于內(nèi)存的讀寫訪問(wèn),這里的主要原因就是在于OS使用PageCache機(jī)制對(duì)讀寫訪問(wèn)操作進(jìn)行了性能優(yōu)化,將一部分的內(nèi)存用作PageCache
1、對(duì)于數(shù)據(jù)文件的讀取
如果一次讀取文件時(shí)出現(xiàn)未命中(cache miss)PageCache的情況,OS從物理磁盤上訪問(wèn)讀取文件的同時(shí),會(huì)順序?qū)ζ渌噜弶K的數(shù)據(jù)文件進(jìn)行預(yù)讀取(ps:順序讀入緊隨其后的少數(shù)幾個(gè)頁(yè)面)。這樣,只要下次訪問(wèn)的文件已經(jīng)被加載至PageCache時(shí),讀取操作的速度基本等于訪問(wèn)內(nèi)存
1、對(duì)于數(shù)據(jù)文件的寫入
OS會(huì)先寫入至Cache內(nèi),隨后通過(guò)異步的方式由pdflush內(nèi)核線程將Cache內(nèi)的數(shù)據(jù)刷盤至物理磁盤上
對(duì)于文件的順序讀寫操作來(lái)說(shuō),讀和寫的區(qū)域都在OS的PageCache內(nèi),此時(shí)讀寫性能接近于內(nèi)存。RocketMQ的大致做法是,將數(shù)據(jù)文件映射到OS的虛擬內(nèi)存中(通過(guò)JDK NIO的MappedByteBuffer),寫消息的時(shí)候首先寫入PageCache,并通過(guò)異步刷盤的方式將消息批量的做持久化(同時(shí)也支持同步刷盤);訂閱消費(fèi)消息時(shí)(對(duì)CommitLog操作是隨機(jī)讀取),由于PageCache的局部性熱點(diǎn)原理且整體情況下還是從舊到新的有序讀,因此大部分情況下消息還是可以直接從Page Cache(cache hit)中讀取,不會(huì)產(chǎn)生太多的缺頁(yè)(Page Fault)中斷而從磁盤讀取:

PageCache機(jī)制也不是完全無(wú)缺點(diǎn)的,當(dāng)遇到OS進(jìn)行臟頁(yè)回寫,內(nèi)存回收,內(nèi)存swap等情況時(shí),就會(huì)引起較大的消息讀寫延遲。
對(duì)于這些情況,RocketMQ采用了多種優(yōu)化技術(shù),比如內(nèi)存預(yù)分配,文件預(yù)熱,mlock系統(tǒng)調(diào)用等,來(lái)保證在最大可能地發(fā)揮PageCache機(jī)制優(yōu)點(diǎn)的同時(shí),盡可能地減少其缺點(diǎn)帶來(lái)的消息讀寫延遲
RocketMQ存儲(chǔ)優(yōu)化技術(shù)
對(duì)于RocketMQ來(lái)說(shuō),它是把內(nèi)存映射文件串聯(lián)起來(lái),組成了鏈表;因?yàn)閮?nèi)存映射文件本身大小有限制,只能是2G(默認(rèn)1G);所以需要把多個(gè)內(nèi)存映射文件串聯(lián)成一個(gè)鏈表;這里介紹RocketMQ存儲(chǔ)層采用的幾項(xiàng)優(yōu)化技術(shù)方案在一定程度上可以減少PageCache的缺點(diǎn)帶來(lái)的影響,主要包括內(nèi)存預(yù)分配,文件預(yù)熱和mlock系統(tǒng)調(diào)用
1、預(yù)分配MappedFile
在消息寫入過(guò)程中(調(diào)用CommitLog的putMessage()方法),CommitLog會(huì)先從MappedFileQueue隊(duì)列中獲取一個(gè) MappedFile,如果沒有就新建一個(gè);這里,MappedFile的創(chuàng)建過(guò)程是將構(gòu)建好的一個(gè)AllocateRequest請(qǐng)求(具體做法是,將下一個(gè)文件的路徑、下下個(gè)文件的路徑、文件大小為參數(shù)封裝為AllocateRequest對(duì)象)添加至隊(duì)列中,后臺(tái)運(yùn)行的AllocateMappedFileService服務(wù)線程(在Broker啟動(dòng)時(shí),該線程就會(huì)創(chuàng)建并運(yùn)行),會(huì)不停地run,只要請(qǐng)求隊(duì)列里存在請(qǐng)求,就會(huì)去執(zhí)行MappedFile映射文件的創(chuàng)建和預(yù)分配工作,分配的時(shí)候有兩種策略,一種是使用Mmap的方式來(lái)構(gòu)建MappedFile實(shí)例,另外一種是從TransientStorePool堆外內(nèi)存池中獲取相應(yīng)的DirectByteBuffer來(lái)構(gòu)建MappedFile(ps:具體采用哪種策略,也與刷盤的方式有關(guān))。并且,在創(chuàng)建分配完下個(gè)MappedFile后,還會(huì)將下下個(gè)MappedFile預(yù)先創(chuàng)建并保存至請(qǐng)求隊(duì)列中等待下次獲取時(shí)直接返回。RocketMQ中預(yù)分配MappedFile的設(shè)計(jì)非常巧妙,下次獲取時(shí)候直接返回就可以不用等待MappedFile創(chuàng)建分配所產(chǎn)生的時(shí)間延遲

2 文件預(yù)熱 && mlock系統(tǒng)調(diào)用(TransientStorePool)
mlock系統(tǒng)調(diào)用
其可以將進(jìn)程使用的部分或者全部的地址空間鎖定在物理內(nèi)存中,防止其被交換到swap空間。對(duì)于RocketMQ這種的高吞吐量的分布式消息隊(duì)列來(lái)說(shuō),追求的是消息讀寫低延遲,那么肯定希望盡可能地多使用物理內(nèi)存,提高數(shù)據(jù)讀寫訪問(wèn)的操作效率。
文件預(yù)熱
預(yù)熱的目的主要有兩點(diǎn):
第一點(diǎn),由于僅分配內(nèi)存并進(jìn)行mlock系統(tǒng)調(diào)用后并不會(huì)為程序完全鎖定這些內(nèi)存,因?yàn)槠渲械姆猪?yè)可能是寫時(shí)復(fù)制的。因此,就有必要對(duì)每個(gè)內(nèi)存頁(yè)面中寫入一個(gè)假的值。其中,RocketMQ是在創(chuàng)建并分配MappedFile的過(guò)程中,預(yù)先寫入一些隨機(jī)值至Mmap映射出的內(nèi)存空間里。
第二,調(diào)用Mmap進(jìn)行內(nèi)存映射后,OS只是建立虛擬內(nèi)存地址至物理地址的映射表,而實(shí)際并沒有加載任何文件至內(nèi)存中。程序要訪問(wèn)數(shù)據(jù)時(shí)OS會(huì)檢查該部分的分頁(yè)是否已經(jīng)在內(nèi)存中,如果不在,則發(fā)出一次缺頁(yè)中斷。這里,可以想象下1G的CommitLog需要發(fā)生多少次缺頁(yè)中斷,才能使得對(duì)應(yīng)的數(shù)據(jù)才能完全加載至物理內(nèi)存中(ps:X86的Linux中一個(gè)標(biāo)準(zhǔn)頁(yè)面大小是4KB)?
RocketMQ的做法是:
在做Mmap內(nèi)存映射的同時(shí)進(jìn)行madvise系統(tǒng)調(diào)用,目的是使OS做一次內(nèi)存映射后對(duì)應(yīng)的文件數(shù)據(jù)盡可能多的預(yù)加載至內(nèi)存中,從而達(dá)到內(nèi)存預(yù)熱的效果。
參考資料
https://my.oschina.net/u/3180962/blog/3064148
https://blog.csdn.net/linxdcn/article/details/72903422
http://www.itdecent.cn/p/6d0c118c17de