天池中介軟體大賽百萬佇列儲存設計總結【複賽】

Kirito的技術分享發表於2019-01-22

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

最終排名

賽題介紹

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

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

編寫如上介面的實現。

put 方法將一條訊息寫入一個佇列,這個介面需要是執行緒安全的,評測程式會併發呼叫該介面進行 put,每個queue 中的內容按傳送順序儲存訊息(可以理解為 Java 中的 List),同時每個訊息會有一個索引,索引從 0 開始,不同 queue 中的內容,相互獨立,互不影響,queueName 代表佇列的名稱,message 代表訊息的內容,評測時內容會隨機產生,大部分長度在 58 位元組左右,會有少量訊息在 1k 左右。

get 方法從一個佇列中讀出一批訊息,讀出的訊息要按照傳送順序來,這個介面需要是執行緒安全的,也即評測程式會併發呼叫該介面進行 get,返回的 Collection 會被併發讀,但不涉及寫,因此只需要是執行緒讀安全就可以了,queueName 代表佇列的名字,offset 代表訊息的在這個佇列中的起始索引,num 代表讀取的訊息的條數,如果訊息足夠,則返回 num 條,否則只返回已有的訊息即可,若訊息不足,則返回一個空的集合。

評測程式介紹

  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 相關的技術分享。

關注微信公眾號

相關文章