天池中間件大賽百萬隊列存儲設計總結【複賽】

維持了 20 天的複賽終於告一段落了,國際慣例先說結果,複賽結果不太理想,一度從第 10 名掉到了最後的第 36 名,主要是寫入的優化卡了 5 天,一直沒有進展,最終排名也是定格在了排行榜的第二頁。痛定思痛,這篇文章將本身複賽中學習的知識,成功的優化,未成功的優化都羅列一下。java

最終排名

賽題介紹

題面描述很簡單:使用 Java 或者 C++ 實現一個進程內的隊列引擎,單機可支持 100 萬隊列以上。linux

public abstract class QueueStore {
    abstract void put(String queueName, byte[] message);
    abstract Collection<byte[]> get(String queueName, long offset, long num);
}
複製代碼

編寫如上接口的實現。git

put 方法將一條消息寫入一個隊列,這個接口須要是線程安全的,評測程序會併發調用該接口進行 put,每一個queue 中的內容按發送順序存儲消息(能夠理解爲 Java 中的 List),同時每一個消息會有一個索引,索引從 0 開始,不一樣 queue 中的內容,相互獨立,互不影響,queueName 表明隊列的名稱,message 表明消息的內容,評測時內容會隨機產生,大部分長度在 58 字節左右,會有少許消息在 1k 左右。算法

get 方法從一個隊列中讀出一批消息,讀出的消息要按照發送順序來,這個接口須要是線程安全的,也即評測程序會併發調用該接口進行 get,返回的 Collection 會被併發讀,但不涉及寫,所以只須要是線程讀安全就能夠了,queueName 表明隊列的名字,offset 表明消息的在這個隊列中的起始索引,num 表明讀取的消息的條數,若是消息足夠,則返回 num 條,不然只返回已有的消息便可,若消息不足,則返回一個空的集合。apache

評測程序介紹數組

  1. 發送階段:消息大小在 58 字節左右,消息條數在 20 億條左右,即發送總數據在 100G 左右,總隊列數 100w
  2. 索引校驗階段:會對全部隊列的索引進行隨機校驗;平均每一個隊列會校驗1~2次;(隨機消費)
  3. 順序消費階段:挑選 20% 的隊列進行所有讀取和校驗; (順序消費)
  4. 發送階段最大耗時不能超過 1800s;索引校驗階段和順序消費階段加在一塊兒,最大耗時也不能超過 1800s;超時會被判斷爲評測失敗。
  5. 各個階段線程數在 20~30 左右

測試環境爲 4c8g 的 ECS,限定使用的最大 JVM 大小爲 4GB(-Xmx 4g)。帶一塊 300G 左右大小的 SSD 磁盤。對於 Java 選手而言,可以使用的內存能夠理解爲:堆外 4g 堆內 4g。緩存

賽題剖析

首先解析題面,接口描述是很是簡單的,只有一個 put 和一個 get 方法。須要注意特別注意下評測程序,發送階段須要對 100w 隊列,每一次發送的量只有 58 字節,最後總數據量是 100g;索引校驗和順序消費階段都是調用的 get 接口,不一樣之處在於前者索引校驗是隨機消費,後者是對 20% 的隊列從 0 號索引開始進行全量的順序消費,評測程序的特性對最終存儲設計的影響是相當重要的。安全

複賽題目的難點之一在於單機百萬隊列的設計,據查閱的資料顯示微信

  • Kafka 單機超過 64 個隊列/分區,Kafka 分區數不宜過多
  • RocketMQ 單機支持最高 5 萬個隊列

至於百萬隊列的使用場景,只能想到 IOT 場景有這樣的需求。相較於初賽,複賽的設計更加地具備不肯定性,排名靠前的選手可能會選擇截然不同的設計方案。數據結構

複賽的考察點主要有如下幾個方面:磁盤塊讀寫,讀寫緩衝,順序讀寫與隨機讀寫,pageCache,稀疏索引,隊列存儲設計等。

因爲複賽成績並非很理想,優化 put 接口的失敗是致使失利的罪魁禍首,最終成績是 126w TPS,而第一梯隊的 TPS 則是到達了 200 w+ 的 TPS。鑑於此,不太想像初賽總結那樣,按照優化歷程羅列,而是將本身作的方案預研,以及設計思路分享給你們,對文件 IO 不甚瞭解的讀者也能夠將此文當作一篇科普向的文章來閱讀。

思路詳解

肯定文件讀寫方式

做爲忠實的 Java 粉絲,天然選擇使用 Java 來做爲參賽語言,雖然最終的排名是被 Cpp 大佬所壟斷,但着實無奈,畢業後就把 Cpp 丟到一邊去了。Java 中的文件讀寫接口大體能夠分爲三類:

  1. 標準 IO 讀寫,位於 java.io 包下,相關類:FileInputStream,FileOuputStream
  2. NIO 讀寫,位於 java.nio 包下,相關類:FileChannel,ByteBuffer
  3. Mmap 內存映射,位於 java.nio 包下,相關類:FileChannel,MappedByteBuffer

標準 IO 讀寫不具有調研價值,直接 pass,因此 NIO 和 Mmap 的抉擇,成了第一步調研對象。

第一階段調研了 Mmap。搜索一圈下來發現,幾乎全部的文章都一致認爲:Mmap 這樣的內存映射技術是最快的。不少沒有接觸過內存映射技術的人可能還不太清楚這是一種什麼樣的技術,簡而言之,Mmap 可以將文件直接映射到用戶態的內存地址,使得對文件的操做再也不是 write/read,而轉化爲直接對內存地址的操做。

public void test1() throws Exception {
    String dir = "/Users/kirito/data/";
    ensureDirOK(dir);
    RandomAccessFile memoryMappedFile;
    int size = 1 * 1024 * 1024;
    try {
        memoryMappedFile = new RandomAccessFile(dir + "testMmap.txt", "rw");
        MappedByteBuffer mappedByteBuffer = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, size);
        for (int i = 0; i < 100000; i++) {
            mappedByteBuffer.position(i * 4);
            mappedByteBuffer.putInt(i);
        }
        memoryMappedFile.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製代碼

如上的代碼呈現了一個最簡單的 Mmap 使用方式,速度也是沒話說,一個字:快!我懷着將信將疑的態度去找了更多的佐證,優秀的源碼老是第一參考對象,觀察下 RocketMQ 的設計,能夠發現 NIO 和 Mmap 都出如今了源碼中,但更多的讀寫操做彷佛更加青睞 Mmap。RocketMQ 源碼 org.apache.rocketmq.store.MappedFile 中兩種寫方法同時存在,請教 @匠心零度 後大概得出結論:RocketMQ 主要的寫是經過 Mmap 來完成。

兩種寫入方式

可是在實際使用 Mmap 來做爲寫方案時遇到了兩大難題,單純從使用角度來看,暴露出了 Mmap 的侷限性:

  1. Mmap 在 Java 中一次只能映射 1.5~2G 的文件內存,但實際上咱們的數據文件大於 100g,這帶來了第一個問題:要麼須要對文件作物理拆分,切分紅多文件;要麼須要對文件映射作邏輯拆分,大文件分段映射。RocketMQ 中限制了單文件大小來避免這個問題。

文件作物理拆分

  1. Mmap 之因此快,是由於藉助了內存來加速,mappedByteBuffer 的 put 行爲實際是對內存進行的操做,實際的刷盤行爲依賴於操做系統的定時刷盤或者手動調用 mappedByteBuffer.force() 接口來刷盤,不然將會致使機器卡死(實測後的結論)。因爲複賽的環境下內存十分有限,因此使用 Mmap 存在較難的控制問題。

rocketmq存在定時force線程

通過這麼一折騰,再加上資料的蒐集,最終肯定,Mmap 在內存較爲富足而且數據量小的場景下存在優點(大多數文章的結論認爲 Mmap 適合大文件的讀寫,私覺得是不嚴謹的結論)。

第二階段調研 Nio 的 FileChannel,這也是我最終肯定的讀寫方案。

因爲每一個消息只有 58 字節左右,直接經過 FileChannel 寫入必定會遇到瓶頸,事實上,若是你這麼作,複賽連成績估計都跑不出來。另外一個說法是 ssd 最小的寫入單位是 4k,若是一次寫入低於 4k,實際上耗時和 4k 同樣。這裏涉及到了賽題的一個重要考點:塊讀寫。

雲盤ssd寫入性能

根據阿里雲的 ssd 雲盤介紹,只有一次寫入 16kb ~ 64kb 才能得到理想的 IOPS。文件系統塊存儲的特性,啓發咱們須要設置一個內存的寫入緩衝區,單個消息寫入內存緩衝區,緩衝區滿,使用 FileChannel 進行刷盤。通過實踐,使用 FileChannel 搭配緩衝區發揮的寫入性能和內存充足狀況下的 Mmap 並沒有區別,而且 FileChannel 對文件大小並沒有限制,控制也相對簡單,因此最終肯定使用 FileChannel 進行讀寫。

肯定存儲結構和索引結構

因爲賽題的背景是消息隊列,評測 2 階段的隨機檢測以及 3 階段的順序消費一次會讀取多條連續的消息,而且 3 階段的順序消費是從隊列的 0 號索引一直消費到最後一條消息,這些因素都啓發咱們:應當將同一個隊列的消息儘量的存到一塊兒。前面一節提到了寫緩衝區,便和這裏的設計很是契合,例如咱們能夠一個隊列設置一個寫緩衝區(比賽中 Java 擁有 4g 的堆外內存,100w 隊列,一個隊列使用 DirectByteBuffer 分配 4k 堆外內存 ,能夠保證緩衝區不會爆內存),這樣同一個緩衝區的消息一塊兒落盤,就保證了塊內消息的順序性,即作到了」同一個隊列的消息儘量的存到一塊兒「。按塊存取消息目前看來有兩個優點:

  1. 按條讀取消息=>按塊讀取消息,發揮塊讀的優點,減小了 IO 次數
  2. 全量索引=>稀疏索引。塊內數據是連續的,因此只須要記錄塊的物理文件偏移量+塊內消息數便可計算出某一條消息的物理位置。這樣大大下降了索引的數量,稍微計算一下能夠發現,徹底可使用一個 Map 數據結構,Key 爲 queueName,Value 爲 List 在內存維護隊列塊的索引。若是按照傳統的設計方案:一個 queue 一個索引文件,百萬文件必然會超過默認的系統文件句柄上限。索引存儲在內存中既規避了文件句柄數的問題,速度也沒必要多數,文件 IO 和 內存 IO 不是一個量級。

因爲賽題規定消息體是非定長的,大多數消息 58 字節,少許消息 1k 字節的數據特性,因此存儲消息體時使用 short+byte[] 的結構便可,short 記錄消息的實際長度,byte[] 記錄完整的消息體。short 比 int 少了 2 個字節,2*20億消息,能夠減小 4g 的數據量。

稠密索引

稠密索引是對全量的消息進行索引,適用於無序消息,索引量大,數據能夠按條存取。

稀疏索引

稀疏索引適用於按塊存儲的消息,塊內有序,適用於有序消息,索引量小,數據按照塊進行存取。

因爲消息隊列順序存儲,順序消費的特性,加上 ssd 雲盤最小存取單位爲 4k(遠大於單條消息)的限制,因此稀疏索引很是適用於這種場景。至於數據文件,能夠作成參數,根據實際測試來判斷究竟是多文件效果好,仍是單文件,此方案支持 100g 的單文件。

內存讀寫緩衝區

在稀疏索引的設計中,咱們提到了寫入緩衝區的概念,根據計算能夠發現,100w 隊列若是一個隊列分配一個寫入緩衝區,最多隻能分配 4k,這剛好是最小的 ssd 寫入塊大小(但根據以前 ssd 雲盤給出的數據來看,一次寫入 64k 才能打滿 io)。

一次寫入 4k,這致使物理文件中的塊大小是 4k,在讀取時一次一樣讀取出 4k。

// 寫緩衝區
private ByteBuffer writeBuffer = ByteBuffer.allocateDirect(4 * 1024);
// 用 short 記錄消息長度
private final static int SINGLE_MESSAGE_SIZE = 2;

public void put(String queueName,byte[] message){
    // 緩衝區滿,先落盤
    if (SINGLE_MESSAGE_SIZE + message.length  > writeBuffer.remaining()) {
        // 落盤
        flush();
    }
    writeBuffer.putInt(SINGLE_MESSAGE_SIZE);
    writeBuffer.put(message);
    this.blockLength++;
}
複製代碼

不足 4k 的部分能夠選擇補 0,也能夠跳過。評測程序保證了在 queue 級別的寫入是同步的,因此對於同一個隊列,咱們沒法擔憂同步問題。寫入搞定以後,一樣的邏輯搞定讀取,因爲 get 操做是併發的,2階段和3階段會有 10~30 個線程併發消費同一個隊列,因此 get 操做的讀緩衝區能夠設計成 ThreadLocal<ByteBuffer> ,每次使用時 clear 便可,保證了緩衝區每次讀取時都是嶄新的,同時減小了讀緩衝區的建立,不然會致使頻繁的 full gc。讀取的僞代碼暫時不貼,由於這樣的 get 方案不是最終方案。

到這裏總體的設計架構已經出來了,寫入流程和讀取流程的主要邏輯以下:

寫入流程:

put流程

讀取流程:

讀取流程

內存讀緩存優化

方案設計通過好幾回的推翻重來,纔算是肯定了上述的架構,這樣的架構優點在於很是簡單明瞭,實際上個人初版設計方案的代碼量是上述方案代碼量的 2~3 倍,但實際效果卻不理想。上述架構的跑分紅績大概能夠達到 70~80w TPS,只能算做是第三梯隊的成績,在此基礎上,進行了讀取緩存的優化才達到了 126w 的 TPS。在介紹讀取緩存優化以前,先容我介紹下 PageCache 的概念。

PageCache

Linux 內核會將它最近訪問過的文件頁面緩存在內存中一段時間,這個文件緩存被稱爲 PageCache。如上圖所示。通常的 read() 操做發生在應用程序提供的緩衝區與 PageCache 之間。而預讀算法則負責填充這個PageCache。應用程序的讀緩存通常都比較小,好比文件拷貝命令 cp 的讀寫粒度就是 4KB;內核的預讀算法則會以它認爲更合適的大小進行預讀  I/O,好比 16-128KB。

因此通常狀況下咱們認爲順序讀比隨機讀是要快的,PageCache 即是最大的功臣。

回到題目,這簡直 nice 啊,由於在磁盤中同一個隊列的數據是部分連續(同一個塊則連續),實際上一個 4KB 塊中大概能夠存儲 70 多個數據,而在順序消費階段,一次的 offset 通常爲 10,有了 PageCache 的預讀機制,7 次文件 IO 能夠減小爲 1 次!這但是不得了的優化,可是上述的架構僅僅只有 70~80w 的 TPS,這讓我產生了疑惑,通過多番查找資料,最終在 @江學磊 的提醒下,才定位到了問題。

linux io

兩種可能致使比賽中沒法使用 pageCache 來作緩存

  1. 因爲我使用 FIleChannel 進行讀寫,NIO 的讀寫可能走的正是 Direct IO,因此根本不會通過 PageCache 層。
  2. 測評環境中內存有限,在 IO 密集的狀況下 PageCache 效果微乎其微。

雖說不肯定究竟是何種緣由致使 PageCache 沒法使用,可是個人存儲方案仍然知足順序讀取的特性,徹底能夠本身使用堆外內存本身模擬一個「PageCache」,這樣在 3 階段順序消費時,TPS 會有很是高的提高。

一個隊列一個讀緩衝區用於順序讀,又要使得 get 階段不存在併發問題,因此我選擇了複用讀緩衝區,而且給 get 操做加上了隊列級別的鎖,這算是一個小的犧牲,由於 2 階段不會發生衝突,3 階段衝突機率也並不大。改造後的讀取緩存方案以下:

讀取流程-優化

通過緩存改造以後,使用 Direct IO 也能夠實現相似於 PageCache 的優化,而且會更加的可控,不至於形成頻繁的缺頁中斷。通過這個優化,加上一些 gc 的優化,能夠達到 126w TPS。總體方案算是介紹完畢。

其餘優化

還有一些優化對總體流程影響不大,拎出來單獨介紹。

2 階段的隨機索引檢測和 3 階段的順序消費能夠採起不一樣的策略,2 階段能夠直接讀取所須要的數據,而不須要進行緩存(由於是隨機檢測,因此讀緩存確定不會命中)。

將文件數作成參數,調整參數來判斷究竟是多文件 TPS 高仍是單文件,實際上測試後發現,差距並非很大,單文件效果略好,因爲是 ssd 雲盤,又不存在磁頭,因此真的不太懂原理。

gc 優化,能用數組的地方不要用 List。儘可能減小小對象的出現,能夠用數組管理基本數據類型,小對象對 gc 很是不友好,不管是初賽仍是複賽,Java 比 Cpp 始終差距一個垃圾回收機制。必須保證全程不出現 full gc。

失敗的優化與反思

本次比賽算是留下了不小的遺憾,由於寫入的優化一直沒有作好,讀取緩存作好以後我 2 階段和 3階段的總耗時相加是 400+s,算是不錯的成績,可是寫入耗時在 1300+s。我上述的方案採用的是多線程同步刷盤,但也嘗試過以下的寫入方案:

  1. 異步提交寫緩衝區,單線程直接刷盤
  2. 異步提交寫緩衝區,設置二級緩衝區 64k~64M,單線程使用二級緩衝區刷盤
  3. 同步將寫緩衝區的數據拷貝至一個 LockFreeQueue,單線程平滑消費,以打滿 IOPS
  4. 每 16 個隊列共享一個寫入緩衝區,這樣控制寫入緩衝區能夠達到 64k,在刷盤時進行排序,將同一個 queue 的數據放置在一塊兒。

但都以失敗了結,沒有 get 到寫入優化的要領,算是本次比賽最大的遺憾了。

還有一個失誤在於,評測環境使用的雲盤 ssd 和個人本地 Mac 下的 ssd 存儲結構差距太大,加上 mac os 和 Linux 的一些差距,致使本地成功的優化在線上徹底體現不出來,仍是租個阿里雲環境比較靠譜。

另外一方面的反思,則是對存儲和 MQ 架構設計的不熟悉,對於 Kafka 和 RocketMQ 所作的一些優化也都是現學現用,不太肯定用的對不對,致使走了一些彎路,而比賽中認識的一個 96 年的小夥子王亞普,相比之下對中間件知識理解的深度和廣度實在令我欽佩,實在還有不少知識須要學習。

參賽感悟

第一感覺是累,第二感覺是爽。相信不少選手和我同樣是工做黨,白天工做,只能騰出晚上的時間去搞比賽,對於966 的我真是太不友好了,初賽時間延長了一次還算給緩了一口氣,複賽一眨眼就過去了,想翻盤都沒機會,實在是遺憾。爽在於此次比賽真的是汗快淋漓地實踐了很多中間件相關的技術,初賽的 Netty,複賽的存儲設計,都是難以忘懷的回憶,比賽中也認識了很多朋友,有學生黨,有工做黨,感謝大家不厭其煩的教導與發人深省的討論,從不一樣的人身上是真的能夠學到不少本身缺失的知識。

據消息說,阿里中間件大賽頗有多是最後一屆,不管是由於什麼緣由,做爲參賽者,我都感到深深的可惜,但願還能有機會參加下一屆的中間件大賽,也期待能看到更多的相同類型的賽事被各大互聯網公司舉辦,和大佬們同臺競技,一邊認識更多新朋友的感受真棒。

雖然最終無緣決賽,但仍是期待進入決賽的 11 位選手能帶來一場精彩的答辯,也好解答我始終優化失敗的寫入方案。後續會考慮吸取下前幾名 JAVA 的優化思路,整理成最終完善的方案。 目前方案的 git 地址,倉庫已公開:code.aliyun.com/250577914/q…

歡迎關注個人微信公衆號:「Kirito的技術分享」,關於文章的任何疑問都會獲得回覆,帶來更多 Java 相關的技術分享。

關注微信公衆號
相關文章
相關標籤/搜索