開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

悸動

32 歲,碼農的倒數第二個本命年,平淡無奇的生活總覺得缺少了點什麼。

想要去創業,卻害怕家庭承受不住再次失敗的挫折,想要生二胎,帶娃的壓力讓我想著還不如去創業;所以我只好在生活中尋找一些小感動,去看一些老掉牙的電影,然後把自己感動得稀里嘩啦,去翻一些泛黃的書籍,在回憶裡尋找一絲絲曾經的深情滿滿;去學習一些冷門的知識,最後把自己搞得暈頭轉向,去參加一些有意思的比賽,撿起那 10 年走來,早已被刻在基因裡的悸動。

那是去年夏末的一個傍晚,我和同事正閒聊著西湖的美好,他們說看到了阿里雲釋出雲原生程式設計挑戰賽,問我要不要試試。我說我只有九成的把握,另外一成得找我媳婦兒要;那一天,我們繞著西湖走了好久,最後終於達成一致,Ninety Percent 戰隊應運而生,雲原生 MQ 的賽道上,又多了一個艱難卻堅強的選手。

人到中年,仍然會做出一些衝動的決定,那種屁股決定腦袋的做法,像極了領導們的睿智和 18 歲時我朝三暮四的日子;夏季的 ADB 比賽,已經讓我和女兒有些疏遠,讓老婆對我有些成見;此次參賽,必然是要暗度陳倉,臥薪嚐膽,不到關鍵時刻,不能讓家裡人知道我又在賣肝。

開工

你還別說,或許是人類的本性使然,這種揹著老婆偷偷幹壞事情的感覺還真不錯,從上路到上分,一路順風順水,極速狂奔;斷斷續續花了大概兩天的時間,成功地在 A 榜拿下了 first blood;再一次把第一名和最後一名同時納入囊中;快男總是不會讓大家失望了,800 秒的成績,成為了比賽的 base line。

第一個版本並沒有做什麼設計,基本上就是拍腦門的方案,目的就是把流程跑通,儘快出分,然後在保證正確性的前提下,逐步去最佳化方案,避免一開始就過度設計,導致遲遲不能出分,影響士氣。

整體設計

先回顧下賽題:Apache RocketMQ 作為一款分散式的訊息中介軟體,歷年雙十一承載了萬億級的訊息流轉,其中,實時讀取寫入資料和讀取歷史資料都是業務常見的儲存訪問場景,針對這個混合讀寫場景進行最佳化,可以極大的提升儲存系統的穩定性。

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

基本思路是:當 append 方法被呼叫時,會將傳入的相關引數包裝成一個 Request 物件,put 到請求佇列中,然後當前執行緒進入等待狀態。

聚合執行緒會迴圈從請求佇列裡面消費 Request 物件,放入一個列表中,當列表長度到達一定數量時,就將該列表放入到聚合佇列中。這樣在後續的刷盤執行緒中,列表中的多個請求,就能進行一次性刷盤了,增大刷盤的資料塊的大小,提升刷盤速度;當刷盤執行緒處理完一個請求列表的持久化邏輯之後,會依次對列表中個各個請求進行喚醒操作,使等待的測評執行緒進行返回。

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

記憶體級別的元資料結構設計

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

<![endif]–> 首先用一個二維陣列來儲存各個 topicId+queueId 對應的 DataMeta 物件,DataMeta 物件裡面有一個 MetaItem 的列表,每一個 MetaItem 代表的一條訊息,裡面包含了訊息所在的檔案下標、檔案位置、資料長度、以及快取位置。

SSD 上資料的儲存結構

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

總共使用了 15 個 byte 來儲存訊息的元資料,訊息的實際資料和元資料放在一起,這種混合儲存的方式雖然看起來不太優雅,但比起獨立儲存,可以減少一半的 force 操作。

資料恢復

依次遍歷讀取各個資料檔案,按照上述的資料儲存協議生成記憶體級別的元資料資訊,供後續查詢時使用。

資料消費

資料消費時,透過 topic+queueId 從二維陣列中定位到對應的 DataMeta 物件,然後根據 offset 和 fetchNum,從 MetaItem 列表中找到對應的 MetaItem 物件,透過 MetaItem 中所記錄的檔案儲存資訊,進行檔案載入。

總的來說,第一個版本在大方向上沒有太大的問題,使用 queue 進行非同步聚合和刷盤,讓整個程式更加靈活,為後續的一些功能擴充套件打下了很好的基礎。

快取

60 個 G的 AEP,我垂涎已久,國慶七天,沒有出遠門的計劃,一定要好好捲一捲 llpl。下載了 llpl 的原始碼,一頓看,發現比我想象的要簡單得多,本質上和用 unsafe 訪問普通記憶體是一模一樣的。卷完 llpl,快取設計方案呼之欲出。

快取分級

快取的寫入用了佇列進行非同步化,避免對主執行緒造成阻塞(到比賽後期才發現雲 SSD 的奧秘,就算同步寫也不會影響整體的速度,後面我會講原因);程式可以用作快取的儲存介質有 AEP 和 Dram,兩者在訪問速度上有一定的差異,賽題所描述的場景中,會有大量的熱讀,因此我對快取進行了分級,分為了 AEP 快取和 Dram 快取,Dram 快取又分為了堆內快取、堆外快取、MMAP 快取(後期加入),在申請快取時,優先使用 Dram 快取,提升高效能快取的使用頻度。

Dram 快取最後申請了 7G,AEP 申請了 61G,Dram 的容量佔比為 10%;本次比賽總共會讀取(61+7)/2+50=84G 的資料,根據日誌統計,整個測評過程中,有 30G 的資料使用了 Dram 快取,佔比 35%;因為前 75G 的資料不會有讀取操作,沒有快取釋放與複用動作,所以嚴格意義上來講,在寫入與查詢混合操作階段,總共使用了 50G 的快取,其中滾動使用了 30-7/2=26。5G 的 Dram 快取,佔比 53%。10%的容量佔比,卻滾動提供了 53%的快取服務,說明熱讀現象非常嚴重,說明快取分級非常有必要。

但是,現實總是殘酷的,這些看似無懈可擊的最佳化點在測評中作用並不大,畢竟這種最佳化只能提升查詢速度,在讀寫混合階段,讀快取總耗時是 10 秒或者是 20 秒,對最後的成績其實沒有任何影響!很神奇吧,後面我會講原因。

快取結構

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

當獲取到一個快取請求後,會根據 topic+queueId 從二維陣列中獲取到對應的快取上下文物件;該物件中維護了一個快取塊列表、以及最後一個快取塊的寫入指標位置;如果最後一個快取塊的餘量足夠放下當前的資料,則直接將資料寫入快取塊;如果放不下,則申請一個新的快取塊,放在快取塊列表的最後,同時將寫不下的資料放到新快取塊中;若申請不到新的快取塊,則直接按快取寫入失敗進行處理。

在寫完快取後,需要將快取的位置資訊回寫到記憶體中的Meta中;比如本條資料是從第三個快取塊中的 123B 開始寫入的,則回寫的快取位置為:(3-1)*每個快取塊的大小+123。在讀取快取資料時,按照 meta 資料中的快取位置新,定位到對應的快取塊、以及塊內位置,進行資料讀取(需要考慮跨塊的邏輯)。

由於快取的寫入是單執行緒完成的,對於一個 queueId,前面的快取塊的訊息一定早於後面的快取塊,所以當讀取完快取資料後,就可以將當前快取塊之前的所有快取都釋放掉(放入快取資源池),這樣 75G 中被跳過的那 37。5G 的資料也能快速地被釋放掉。

快取功能加上去後,成績來到了 520 秒左右,程式的主體結構也基本完成了,接下來就是精裝了。

最佳化

快取准入策略

一個 32k 的快取塊,是放 2 個 16k 的資料合適,還是放 16 個 2k 的資料合適?毫無疑問是後者,將小資料塊儘量都放到快取中,可以使得最後只有較大的塊才會查 ssd,減少查詢時 ssd 的 io 次數。

那麼閾值為多少時,可以保證小於該閾值的資料塊放入快取,能夠使得快取剛好被填滿呢?(若不填滿,快取利用率就低了,若放不下,就會有小塊的資料無法放快取,讀取時必須走 ssd,io 次數就上去了)。

一般來說,透過多次引數調整和測評嘗試,就能找到這個閾值,但是這種方式不具備通用性,如果總的可用的快取大小出現變化,就又需要進行嘗試了,不具備生產價值。

這個時候,中學時代的數學知識就派上用途了,如下圖:

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

由於訊息的大小實際是以 100B 開始的,為了簡化,直接按照從 0B 進行了計算,這樣會導致算出來的閾值偏大,也就是最後會出現快取存不下從而小塊走 ssd 查詢的情況,所以我在算出來的閾值上減去了 100B*0。75(由於影響不大,基本是憑直覺拍腦門的)。如果要嚴格計算真正準確的閾值,需要將上圖中的三角形面積問題,轉換成梯形面積問題,但是感覺意義不大,因為 100B 本來就只有 17K 的 1/170,比例非常小,所以影響也非常的小。

梯形面積和三角形面積的比為:(17K+100)(17K-100)/(17k17K)=0。999965,完全在資料波動的範圍之內。

在程式執行時,根據動態計算出來的閾值,大於該閾值的就直接跳過快取的寫入邏輯,最後不管快取配置為多大,都能保證小於該閾值的資料塊全部寫入了快取,且快取最後的利用率達到 99。5%以上。

共享快取

在剛開始的時候,按照算出來的閾值進行快取規劃,仍然會出現快取容量不足的情況,實際用到的快取的大小總是比總快取塊的大小小一些,透過各種排查,才恍然大悟,每個 queueId 所擁有的最後一個快取塊大機率是不會被寫滿的,宏觀上來說,平均只會被寫一半。一個快取塊是32k,queueId 的數量大概是 20w,那麼就會有 20w*32k/2=3G 的快取沒有被用到;3G/2=1。5G(前 75G 之後隨機讀一半,所以要除以 2),就算是順序讀大塊,1。5G 也會帶來 5 秒左右的耗時,更別說隨機讀了,所以不管有多複雜,這部分快取一定要用起來。

既然自己用不完,那就共享出來吧,整體方案如下:

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

在快取塊用盡時,對所有的 queueId 的最後一個快取塊進行自增編號,然後放入到一個一維陣列中,快取塊的編號,即為該塊在以為數字中的下標;然後根據快取塊的餘量大小,放到對應的餘量集合中,餘量大於等於 2k 小於 3k 的快取塊,放到 2k 的集合中,以此類推,餘量大於最大訊息體大小(賽題中為 17K)的塊,統一放在 maxLen 的集合中。

當某一次快取請求獲取不到私有的快取塊時,將根據當前訊息體的大小,從共享快取集合中獲取共享快取進行寫入。比如當前訊息體大小為 3。5K,將會從 4K 的集合中獲取快取塊,若獲取不到,則繼續從 5k 的集合中獲取,依次類推,直到獲取到共享快取塊,或者沒有滿足任何滿足條件的快取塊為止。

往共享快取塊寫入快取資料後,該快取塊的餘量將發生變化,需要將該快取塊從之前的集合中移除,然後放入新的餘量集合中(若餘量級別未發生變化,則不需要執行該動作)。

訪問共享快取時,會根據Meta中記錄的共享快取編號,從索引陣列中獲取到對應的共享塊,進行資料的讀取。

在快取的釋放邏輯裡,會直接忽略共享快取塊(理論上可以透過一個計數器來控制何時該釋放一個共享快取塊,但實現起來比較複雜,因為要考慮到有些訊息不會被消費的情況,且收益也不會太大(因為二階段快取是完全夠用的,所以就沒做嘗試)。

MMAP 快取

測評程式的 jvm 引數不允許選手自己控制,這是攔在選手面前的一道障礙,由於老年代和年輕代之間的比例為 2 比 1,那意味著如果我使用 3G 來作為堆內快取,加上記憶體中的 Meta 等物件,老年代基本要用 4G 左右,那就會有 2G 的新生代,這完全是浪費,因為該賽題對新生代要求並不高。

所以為了避免浪費,一定要減少老年代的大小,那也就意味著不能使用太多的堆內快取;由於堆外記憶體也被限定在了 2G,如果減小堆內的使用量,那空餘的快取就只能給系統做 pageCache,但賽題的背景下,pageCache 的命中率並不高,所以這條路也是走不通的。

有沒有什麼記憶體既不是堆內,申請時又不受堆外引數的限制?自然而然想到了 unsafe,當然也想到官方導師說的那句:用 unsafe 申請記憶體直接取消成績。。。這條路只好作罷。

花了一個下午的時間,通讀了 nio 相關的程式碼,意外發現 MappedByteBuffer 是不受堆外引數的限制的,這就意味著可以使用 MappedByteBuffer 來替代堆內快取;由於快取都會頻繁地被進行寫與讀,如果使用 Write_read 模式,會導致刷盤動作,就得不償失了,自然而然就想到了 PRIVATE 模式(copy on write),在該模式下,會在某個 4k 區首次寫入資料時,和 pageCache 解耦,生成一個獨享的記憶體副本;所以只要在程式初始化的時候,將 mmap 寫一遍,就能得到一塊獨享的,和磁碟無關的記憶體了。

所以我將堆內快取的大小配置成了 32M(因為該功能已經開發好了,所以還是要意思一下,用起來),堆外申請了 1700M(算上測評程式碼的 300M,差不多 2G)、mmap 申請了 5G;總共有 7G 的 Dram 作為了快取(不使用 mmap 的話,大概只能用到 5G),記憶體中的Meta大概有700M左右,所以堆內的記憶體差不多在 1G 左右,2G+5G+1G=8G,作業系統給 200M 左右基本就夠了,所以還剩 800M 沒用,這800M其實是可以用來作為 mmap 快取的,主要是考慮到大家都只能用 8G,超過 8G 容易被挑戰,所以最後最優成績裡面總的記憶體的使用量並沒有超過 8G。

基於末尾填補的 4K 對齊

由於 ssd 的寫入是以 4K 為最小單位的,但每次聚合的訊息的總大小又不是 4k 的整數倍,所以這會導致每次寫入都會有額外的開銷。

比較常規的方案是進行 4k 填補,當某一批資料不是 4k 對齊時,在末尾進行填充,保證寫入的資料的總大小是 4k 的整數倍。聽起來有些不可思議,額外寫入一些資料會導致整體效益更高?

是的,推導邏輯是這樣的:“如果不填補,下次寫入的時候,一定會寫這未滿的4k區,如果填補了,下次寫入的時候,只有 50%的機率會往後多寫一個 4k 區(因為前面填補,導致本次資料後移,尾部多垮了一個 4k 區)”,所以整體來說,填補後會賺 50%。或者換一個角度,填補對於當前的這次寫入是沒有副作用的(也就多 copy<4k 的資料),對於下一次寫入也是沒有副作用的,但是如果下一次寫入是這種情況,就會因為填補而少寫一個 4k。

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

基於末尾剪下的 4k 對齊

填補的方案確實能帶來不錯的提升,但是最後落盤的檔案大概有 128G 左右,比實際的資料量多了 3 個 G,如果能把這 3 個 G 用起來,又是一個不小的提升。

自然而然就想到了末尾剪下的方案,將尾部未 4k 對齊的資料剪下下來,放到下一批資料裡面,剪下下來的資料對應的請求,也在下一批資料刷盤的時候進行喚醒

方案如下:

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

填補與剪下共存

剪下的方案固然優秀,但在一些極端的情況下,會存在一些消極的影響;比如聚合的一批資料整體大小沒有操作 4k,那就需要扣留整批的請求了,在這一刻,這將變嚮導致刷盤執行緒大幅降低、請求執行緒大幅降低;對於這種情況,剪下對齊帶來的優勢,無法彌補扣留請求帶來的劣勢(基於直觀感受),因此需要直接使用填補的方式來保證 4k 對齊。

嚴格意義上來講,應該有一個扣留執行緒數代價、和填補代價的量化公式,以決定何種時候需要進行填補,何種時候需要進行剪下;但是其本質太過複雜,涉及到非同質因子的整合(要在磁碟吞吐、磁碟 io、測評執行緒耗時三個概念之間做轉換);做了一些嘗試,效果都不是很理想,沒能跑出最高分。

當然中間還有一些邊界處理,比如當 poll 上游資料超時的時候,需要將扣留的資料進行填充落盤,避免收尾階段,最後一批扣留的資料得不到處理。

SSD 的預寫

得此最佳化點者,得前 10,該最佳化點能大幅提升寫入速度(280m/s 到 320m/s),這個最佳化點很多同學在一些技術貼上看到過,或者自己意外發現過,但是大部分人應該對本質的原因不甚瞭解;接下來我便循序漸進,按照自己的理解進行 yy 了。

假設某塊磁碟上被寫滿了 1,然後檔案都被刪除了,這個時候磁碟上的物理狀態肯定都還是 1(因為刪除檔案並不會對檔案區域進行格式化)。然後你又新建了一個空白檔案,將檔案大小設定成了 1G(比如透過 RandomAccessFile。position(1G));這個時候這 1G 的區域對應的磁碟空間上仍然還是 1,因為在生產空白檔案的時候也並不會對對應的區域進行格式化。

但是,當我們此時對這個檔案進行訪問的時候,讀取到的會全是 0;這說明檔案系統裡面記載了,對於一個檔案,哪些地方是被寫過的,哪些地方是沒有被寫過的(以 4k 為單位),沒被寫過的地方會直接返回 0;這些資訊被記載在一個叫做 inode 的東西上,inode 當然也是需要落盤進行持久化的。

所以如果我們不預寫檔案,inode 會在檔案的某個 4k 區首次被寫入時發生性變更,這將造成額外的邏輯開銷以及磁碟開銷。因此,在構造方法裡面一頓 for 迴圈,按照預估的總檔案大小,先寫一遍資料,後續寫入時就能起飛了。

大訊息體的最佳化策略

由於磁碟的讀寫都是以 4k 為單位,這就意味著讀取一個 16k+2B 的資料,極端情況下會產生 16k+2*4k=24k 的磁碟 io,會多載入將近 8k 的資料。

顯然如果能夠在讀取的時候都按 4k 對齊進行讀取,且加載出來的資料都是有意義的(後續能夠被用到),就能解決而上述的問題;我依次做了以下最佳化(有些最佳化點在後面被廢棄掉了,因為它和一些其他更好的最佳化點衝突了)。

1、大塊置頂

<![endif]–> 由於每一批聚合的訊息都是 4k 對齊的落盤的(剪下扣留方案之前),所以我將每批資料中最大的那條訊息放在了頭部(基於快取規劃策略,大訊息大機率是不會進快取的,消費時會從 ssd 讀取),這樣這條訊息至少有一端是 4k 對齊的,讀取的時候能緩解 50%的對齊問題,該種方式在剪下扣留方案之前確實帶來了 3 秒左右的提升。

2、訊息順序重組

透過演算法,讓大塊資料儘量少地出現兩端不對齊的情況,減少讀取時額外的資料載入量;比如針對下面的例子:

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

在整理之前,載入三個大塊總共會涉及到 8 個 4k 區,整理之後,就變成了 6 個。

由於自己在演算法這一塊兒實在太弱了,加上這是一個 NP 問題,折騰了幾個小時,效果總是差強人意,最後只好放棄。

3、基於記憶體的 pageCache

在資料讀取階段,每次載入資料時,若載入的資料兩端不是 4k 對齊的,就主動向前後延伸打到 4k 對齊的地方;然後將首尾兩個 4k 區放到記憶體裡面,這樣當後續要訪問這些4k區的時候,就可以直接從記憶體裡面獲取了。

該方案最後的效果和預估的一樣差,一點驚喜都沒有。因為只會有少量的資料會走 ssd,首尾兩個 4k 裡面大機率都是那些不需要走ssd的訊息,所以被複用的機率極小。

4、部分快取

既然自己沒能力對訊息的儲存順序進行調整最佳化,那就把那些兩端不對齊的資料剪下來放到快取裡面吧:

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

某條訊息在落盤的時候,若某一端(也有可能是兩端)沒有 4k 對齊,且在未對齊的 4k 區的資料量很少,就將其剪下下來存放到快取裡,這樣查詢的時候,就不會因為這少量的資料,去讀取一個額外的 4k 區了。

剪下的閾值設定成了 1k,由於資料大小是隨機的,所以從宏觀上來看,剪下下來的資料片的平均大小為 0。5k,這意味著只需要使用 0。5k 的快取,就能減少 4k 的 io,是常規快取效益的 8 倍,加上快取部分的餘量分級策略,會導致有很多碎片化的小記憶體用不到,該方案剛好可以把這些碎片記憶體利用起來。

測評執行緒的聚合策略

每次聚合多少條訊息進行刷盤合適?是按訊息條數進行聚合,還是按照訊息的大小進行聚合

剛開始的時候並沒有想那麼多,透過日誌得知總共有 40 個執行緒,所以就寫死了一次聚合 10 條,然後四個執行緒進行刷盤;但這會帶來兩個問題,一個是若執行緒數發生變化,效能會大幅下降;第二是在收尾階段,會有一些跑得慢的執行緒還有不少資料未寫入的情況,導致收尾時間較長,特別是加入了尾部剪下與扣留邏輯後,該現象尤為嚴重。

為了解決收尾耗時長的問題,我嘗試了同步聚合的方案,在第一次寫入之後的 500ms,對寫入執行緒數進行統計,然後分組,後續就按組進行聚合;這種方式可以完美解決收尾的問題,因為同一個組裡面的所有執行緒都是同時完成寫入任務的,大概是因為每個執行緒的寫入次數是固定的吧;但是使用這種方式,尾部剪下+扣留的邏輯就非常難融合進來了;加上在程式一開始就固定執行緒數,看起來也有那麼一些不優雅;所以我就引入了“執行緒控制器”的概念。

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

聚合策略迭代-針對剪下扣的留方案的定向最佳化

假設當前動態計算出來的聚合數量是 10,對於聚合出來的 10 條訊息,如果本批次被扣留了 2 條,下次聚合時應該聚合多少條?

在之前的策略裡面,還是會聚合 10 條,這就意味著一旦出現了訊息扣留,聚合邏輯就會產生抖動,會出現某個執行緒聚合不到指定的訊息資料量的情況(這種情況會有 poll 超時方式進行兜底,但是整體速度就慢了)。

所以聚合引數不能是一個單純的、統一化的值,得針對不同的刷盤執行緒的扣留數,進行調整,假設聚合數為 n,某個刷盤執行緒的上批次扣留數量為 m,那針對這個刷盤執行緒的下批次的聚合數量就應該是 n-m。

那麼問題就來了,聚合執行緒(生產者)只有一個,刷盤執行緒(消費者)有好幾個,都是搶佔式地進行消費,沒辦法將聚合到的特定數量的訊息,給到指定的刷盤執行緒;所以聚合訊息佇列需要拆分,拆分成以刷盤執行緒為維度。

由於改動比較大,為了保留以前的邏輯,就引入了聚合數量的“嚴格模式”的概念,透過引數進行控制,如果是“嚴格模式”,就使用上述的邏輯,若不是,則使用之前的邏輯;

設計圖如下:

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

將聚合佇列換成了聚合佇列陣列,在非嚴格模式下,數組裡面的原始指向的是同一個佇列物件,這樣很多程式碼邏輯就能統一。

聚合執行緒需要先從扣留資訊佇列裡面獲取一個物件,然後根據扣留數和最新的聚合引數,決定要聚合多少條訊息,聚合好訊息後,放到扣留資訊所描述的佇列中。

完美的收尾策略,一行程式碼帶來 5s 的提升

引入了執行緒控制器後,收尾時間被降低到了 2 秒多,兩次收尾,也就是 5 秒左右(這些資訊來源於最後一個晚上對 A 榜時的日誌的分析),在賽點位置上,這 5 秒的重要性不言而喻。

比賽結束前的最後一晚,分數徘徊在了 423 秒左右,前面的大佬在很多天前就從 430 一次性最佳化到了 420,然後分數就沒有太大變化了;我當時抱著僥倖的態度,斷定應該是 hack 了,直到那天晚上在釘釘群裡和他聊了幾句,直覺告訴我,420 的成績是有效的。當時是有些慌的,畢竟比賽第二天早上 10 點就結束了。

我開始陷入深深的反思,我都捲到極致了,從 432 到 423 花費了大量的精力,為何大神能夠一擊致命?不對,一定是我忽略了什麼。

我開始回看歷史提交記錄,然後對照分析每次提交後的測評得分(由於歷史成績都有一定的抖動,所以這個工作非常的上頭);花費了大概兩個小時,總算髮現了一個異常點,在 432 秒附近的時候,我從同步聚合切換成了非同步聚合,然後融合了剪下扣留+4k 填補的方案,按理說這個最佳化能減少 3G 多的落盤資料量,成績應該是可以提升 10 秒左右的,但是當時成績只提升了 5 秒多,由於當時還有不少沒有落地的最佳化點,所以就沒有太在意。

扣留策略會會將尾部的請求扣留下來,尾部的請求本來就是慢一拍(對應的測評執行緒慢)的請求(佇列是順序消費),這一扣留,進度就更慢了!!!

聚合到一批訊息後,按照訊息對應的執行緒被扣留的次數,從大到小排個序,讓那些慢的、扣留多的執行緒,儘可能不被扣留,讓那些快的、扣留少的請求,儘可能被扣留;最後所有的執行緒幾乎都是同時完成(基於假想)。

趕緊提交程式碼、開始測評,抖了兩把就破 420 了,最好成績到達了 418,比最佳化前高出 5 秒左右,非常符合預期。

查詢最佳化

多執行緒讀 ssd

由於只有少量的資料會讀 ssd,這使得在讀寫混合階段,sdd 查詢的併發量並不大,所以在載入資料時進行了判斷,如果需要從 ssd 載入的數量大於一定量時,則進行多執行緒載入,充分利用 ssd 併發隨機讀的能力。

為什麼要大於一定的量才多執行緒載入,如果只需要載入兩條資料,用兩個執行緒來載入會有提升嗎?當儲存介質夠快、載入的資料量夠小時,多執行緒載入資料帶來的 io 時間的提升,還不足以彌補多執行緒執行本身帶來的程式開銷。

快取的批次 copy

若某次查詢時需要載入的資料,在快取上是連續的,則不需要一條一條從快取進行復制,可以以快取塊的大小為最小粒度,進行復制,提升快取讀取的效益。

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

上面的例子中,使用批次 copy 的方式,可以將 copy 的次數從 5 次降到 2 次。

這樣做的前提是:用於返回的各條訊息對應的 byteBuffer,在記憶體上需要是連續的(透過反射實現,給每個 byteBuffer 都注入同一個 bytes 物件);批次複製完畢後,根據各條訊息的大小,動態設定各自 byteBuffer 的 position 和 limit,以保證 retain 區域剛好指向自己所對應的記憶體區間。

該功能一直有偶現的 bug,本地又復現不了,A 榜的時候沒太在意,B 榜的時候又不能看日誌,一直沒得到解決;怕因為程式碼質量影響最後的程式碼分,所以後來就註釋掉了。

遺失的美好

在比賽開始的時候,看了金融通的賽題解析,裡面提到了一個對資料進行遷移的點;10 月中旬的時候進行了嘗試,在開始讀取資料時,陸續把那些快取中沒有的資料讀取到快取中(因為一旦開始讀取,就會有大量的快取被釋放出來,快取容量完全夠用),總共進行了兩個方案的嘗試:

1、基於順序讀的非同步遷移方案

在第一階段,當快取用盡時,記錄當前儲存檔案的位置,然後遷移的時候,從該位置開始進行順序讀取,將後續的所有資料都讀取到快取中;這樣做的好處是大幅降低查詢階段的隨機讀次數;但是也有不足,因為前 75G 資料中有一般的資料是不會被消費的,這意味著遷移到快取中的資料,有 50%都是沒有意義的,當時測下來該方案基本沒有提升(由於成績有一定的抖動,具體是有一部分提升、沒提升、還是負最佳化,也不得而知);後來引入了快取准入策略後,該方案就徹底被廢棄了,因為需要從 ssd 中讀取的資料會完全雜湊在儲存檔案中。

2、基於懶載入的非同步遷移方案

上面有講到,由於一階段的資料中有一半都不會被消費到,想要不做無用功,就必須要在保證遷移的資料都是會被消費的資料。

所以加了一個邏輯,當某個 queueId 第一次被消費的時候,就非同步將該 queueId 中不存在快取中的訊息,從 ssd 中載入到快取中;由於當時覺得就算是非同步遷移,也是要隨機讀的,讀的次數並不會減少,一段時間內磁碟的壓力也並不會減少;所以對該方案就沒怎麼重視,完全是抱著寫著玩的態度;並且在遷移的准入邏輯上加了一個判斷:“當本次查詢的訊息中包含有從磁碟中載入的資料時,才非同步對該 queueId 中剩下的 ssd 中的資料進行遷移”;至今我都沒相透當時自己為什麼要加上這個一個判斷。也就是因為這個判斷,導致遷移效果仍然不理想(會導致遷移不夠集中、並且很多 queueId 在某次查詢的時候讀了 ssd,後續就沒有需要從 ssd 上讀取的資料了),對成績沒有明顯的提升;在一次版本回退中,徹底將遷移的方案給抹掉了(相信打比賽的小夥伴對版本回退深有感觸,特別是對於這種有較大成績抖動的比賽)。

比賽結束後我在想,如果當時在遷移邏輯上沒有加上那個神奇的邏輯判斷,我的成績能到多少?或許能到 410,或許突破不了 420;正式因為錯過了那個大的最佳化點,才讓我在其他點上做到了極致;那些錯過的美好,會讓大家在未來的日子裡更加努力地奔跑。

接下來我們講一下為什麼非同步遷移會快。

ssd 的多執行緒隨機讀是很快的,但是我上面有講到,如果查詢的資料量比較小,多執行緒分批查詢效果並不一定就好,因為每一批的資料量實在太小了;所以想要在查詢階段開很多的執行緒來提升整體的查詢速度並不能取的很好的效果。非同步遷移能夠完美地解決這個問題,並且在 io 次數一定的情況下,集中進行 ssd 的隨機讀,比雜湊進行隨機讀,pageCache 命中率更高,且對寫入速度造成的整體影響更小(這個觀點純屬個人感悟,只保證 Ninety Percent 的正確率)。

SSD 雲盤的奧秘

我也是個小白,以下內容很多都是猜測,大家看一看就可以了。

1、雲 ssd 的運作機制

SSD 雲盤和傳統的 ssd 盤擁有著相同的特性,但是卻是不同的東西;可以理解成 SSD 雲盤,是傳統 ssd 盤的一個放大版。

開發者自述:我是如何設計針對冷熱讀寫場景的 RocketMQ 儲存系統

SSD 雲盤的底層儲存介質是多個普通的物理硬碟,這些物理硬碟就類似於傳統 ssd 中的儲存顆粒,在進行寫入或讀取的時候,會將任務分配到多個物理裝置上並行進行處理。同時,在雲 ssd 中,對資料的更新採用了 append 的方式,即在進行更新時,是順序追加寫一塊資料,然後將位置的引用從原有的資料塊指向新的資料塊(我們訪問的檔案的position和硬碟的物理地址之間有一層對映,所以就算硬碟上有很多的碎片,我們也仍然能獲取到一個“連續”的大檔案)。

阿里雲官網上有云 ssd 的 iops 和吞吐的計算公式:

iops = min{1800+50 容量, 50000}; 吞吐= min{120+0。5 容量, 350}

我們看到無論是 iops 和吞吐,都和容量呈正相關的關係,並且都有一個上限。這是因為,容量越大,底層的物理裝置就會越多,併發處理的能力就越強,所以速度就越快;但是當物理裝置多到一定的數量時,檔案系統的“總控“就會成為瓶頸;這個總控肯定也是需要儲存能力的(比如儲存位置對映、歷史資料的 compact 等等),所以當給總控配置不同效能的儲存介質時,就得到了 PL0、PL1 等不同效能的雲盤(當然,除此之外,網路頻寬、運算能力也是雲 ssd 速度的影響因子)。

2、雲 ssd 的 buffer 現象

在過程中發現了一個有趣的現象,就算是 force 落盤,在剛開始寫入時,速度也是遠大於 320m/s 的(能達到 400+),幾秒之後,會降下來,穩定在 320 左右(像極了不 force 時,pageCache 帶來的 buffer 現象)。

針對這種奇怪的現象,我進行了進一步的探索,每寫 2 秒的資料,就 sleep 2 秒,結果是:在寫入的這兩秒時間裡,速度能達到 400+,整體平均速度也遠超過了 160m/s;後來我又做了很多實驗,包括在每次寫完資料之後直接進行短暫的 sleep,但是這根本不會影響到 320m/s 的整體速度。測試程式碼中,雖然是 4 執行緒寫入,但是總會有那麼一些時刻,大部分甚至所有執行緒都處於 sleep 狀態,這必然會使得在這個時間點上,應用程式到硬碟的寫入速度是極低的;但是時間拉長了看,這個速度又是能恆定在 320m/s 的。這說明雲 ssd 上有一層 buffer,類似作業系統的 pageCache,只是這個“pageCache”是可靠儲存的,應用程式到這個 buffer 之間的速度是可以超過 320 的,320 的閾值,是下游所導致的(比如 buffer 到硬碟陣列)。

對於這個“pageCache”有幾種猜測:

1、物理裝置本身就有 buffer 效應,因為物理裝置的儲存狀態本質上是透過電刺激,改變儲存介質的化學狀態或者物理狀態的實現的,驅動這種變化的工業本質,產生了這種 buffer 現象‘;

2、雲 ssd 裡面有一塊較小的高效能存介質作為緩衝區,以提供更好的突擊寫的效能;

3、邏輯限速,哈哈,這個純屬開玩笑了。

由於有了這個 buffer 效應,程式層面就可以為所欲為了,比如寫快取的動作,整體會花費幾十秒,但是就算是在只有 4 個寫入執行緒的情況下,不管是非同步寫還是同步寫,都不會影響整體的落盤速度,因為在同步寫快取的時候,雲 ssd 能夠進行短暫的停歇,在接下來的寫入時,速度會短暫地超過 320m/s;查詢的時候也類似,非 io 以外的時間開銷,無論長短,都不會影響整體的速度,這也就是我之前提到的,批次複製快取,理論上有不小提升,但是實際上卻沒多大提升的原因。

當然,這個 buffer 現象其實是可以利用起來的,我們可以在寫資料的時候多花一些時間來做一些其他的事情,反正這樣的時間開銷並不會影響整體的速度;比如我之前提到的 NP 問題,可以 for 迴圈暴力破解

原文連結:http://click.aliyun.com/m/1000349416/

本文為阿里雲原創內容,未經允許不得轉載。