PolarDB 數據庫性能大賽 Java 分享

1 前言

排名

國際慣例,先報成績,熬了無數個夜晚,最後依舊被絕殺出了第一頁,最終排名第 21 名。前十名的成績分佈爲 413.69~416.94,我最終的耗時是 422.43。成績雖然不是特別亮眼,但與衆多參賽選手使用 C++ 做爲參賽語言不一樣,我使用的是 Java,一方面是我 C++ 的能力早已荒廢,另外一方面是我想驗證一下使用 Java 編寫存儲引擎是否與 C++ 差距巨大(固然,主要仍是前者 QAQ)。因此在本文中,我除了介紹總體的架構以外,還會着重筆墨來探討 Java 編寫存儲類型應用的一些最佳實踐,文末會給出 github 的開源地址。java

2 賽題概覽

比賽整體分紅了初賽和複賽兩個階段,總體要求實現一個簡化、高效的 kv 存儲引擎node

初賽要求支持 Write、Read 接口。git

public abstract void write(byte[] key, byte[] value);
public abstract byte[] read(byte[] key);
複製代碼

複賽在初賽題目基礎上,還須要額外實現一個 Range 接口。github

public abstract void range(byte[] lower, byte[] upper, AbstractVisitor visitor);
複製代碼

程序評測邏輯 分爲2個階段: 1)Recover 正確性評測: 此階段評測程序會併發寫入特定數據(key 8B、value 4KB)同時進行任意次 kill -9 來模擬進程意外退出(參賽引擎須要保證進程意外退出時數據持久化不丟失),接着從新打開 DB,調用 Read、Range 接口來進行正確性校驗shell

2)性能評測數據庫

  • 隨機寫入:64 個線程併發隨機寫入,每一個線程使用 Write 各寫 100 萬次隨機數據(key 8B、value 4KB)
  • 隨機讀取:64 個線程併發隨機讀取,每一個線程各使用 Read 讀取 100 萬次隨機數據
  • 順序讀取:64 個線程併發順序讀取,每一個線程各使用 Range 有序(增序)遍歷全量數據 2 次 注: 2.2 階段會對全部讀取的 kv 校驗是否匹配,如不經過則終止,評測失敗; 2.3 階段除了對迭代出來每條的 kv校 驗是否匹配外,還會額外校驗是否嚴格字典序遞增,如不經過則終止,評測失敗。

語言限定:C++ & JAVA,一塊兒排名apache

3 賽題剖析

關於文件 IO 操做的一些基本常識,我已經在專題文章中進行了介紹,若是你沒有瀏覽那篇文章,建議先行瀏覽一下:文件IO操做的一些最佳實踐。再回歸賽題,先對賽題中的幾個關鍵詞來進行解讀。數組

3.1 key 8B, value 4kb

key 爲固定的 8 字節,所以可以使用 long 來表示。緩存

value 爲 4kb,這節省了咱們很大的工做量,由於 4kb 的整數倍落盤是很是磁盤 IO 友好的。bash

value 爲 4kb 的另外一個好處是咱們再內存作索引時,可使用 int 而不是 long,來記錄數據的邏輯偏移量:LogicOffset = PhysicalOffset / 4096,能夠將 offset 的內存佔用量減小一半。

3.2 kill -9 數據不丟失

首先賽題明確表示會進行 kill -9 並驗證數據的一致性,這加大了咱們在內存中作 write buffer 的難度。但它並無要求斷電不丟失,這間接地闡釋了一點:咱們可使用 pageCache 來作寫入緩存,在具體代碼中我使用了 PageCache 來充當數據和索引的寫入緩衝(二者策略不一樣)。同時這點也限制了參賽選手,不能使用 AIO 這樣的異步落盤方式。

3.3 分階段測評

賽題分爲了隨機寫,隨機讀,順序讀三個階段,每一個階段都會從新 open,且不會發生隨機寫到一半校驗隨機讀這樣的行爲,因此咱們在隨機寫階段不須要在內存維護索引,而是直接落盤。隨機讀和順序讀階段,磁盤均存在數據,open 階段須要恢復索引,可使用多線程併發恢復。

同時,賽題還有存在一些隱性的測評細節沒有披露給你們,但經過測試,咱們能夠得知這些信息。

3.4 清空 PageCache 的耗時

雖然咱們可使用 PageCache,但評測程序在每一個階段以後都使用腳本清空了 PageCache,而且將這部分時間也算進了最終的成績之中,因此有人感到奇怪:三個階段的耗時相加比輸出出來的成績要差,其實那幾秒即是清空 PageCache 的耗時。

#清理 pagecache (頁緩存)
sysctl -w vm.drop_caches=1
#清理 dentries(目錄緩存)和 inodes
sysctl -w vm.drop_caches=2
#清理pagecache、dentries和inodes
sysctl -w vm.drop_caches=3
複製代碼

這一點啓發咱們,不能毫無節制的使用 PageCache,也正是由於這一點,必定程度上使得 Direct IO 這一操做成了本次競賽的銀彈。

3.5 key 的分佈

這一個隱性條件可謂是本次比賽的關鍵,由於它涉及到 Range 部分的架構設計。本次比賽的 key 共計 6400w,可是他們的分佈都是均勻的,在《文件IO操做的一些最佳實踐》 一文中咱們已經提到了數據分區的好處,能夠大大減小順序讀寫的鎖衝突,而 key 的分佈均勻這一特性,啓發咱們在作數據分區時,能夠按照 key 的搞 n 位來作 hash,從而確保 key 兩個分區之間總體有序(分區內部無序)。實際我嘗試了將數據分紅 102四、2048 個分區,效果最佳。

3.6 Range 的緩存設計

賽題要求 64 個線程 Range 兩次全量的數據,限時 1h,這也啓發了咱們,若是不對數據進行緩存,想要在 1h 內完成比賽是不可能的,因此,咱們的架構設計應該儘可能以 Range 爲核心,兼顧隨機寫和隨機讀。Range 部分也是最容易拉開差距的一個環節。

4 架構詳解

首先須要明確的是,隨機寫指的是 key 的寫入是隨機的,但咱們能夠根據 key hash,將隨機寫轉換爲對應分區文件的順序寫。

/** * using high ten bit of the given key to determine which file it hits. */
public class HighTenPartitioner implements Partitionable {
    @Override
    public int getPartition(byte[] key) {
        return ((key[0] & 0xff) << 2) | ((key[1] & 0xff) >> 6);
    }
}
複製代碼

明確了高位分區的前提再來看總體的架構就變得明朗了

全局視角

全局視角

分區視角

分區視角

內存視角

內存中僅僅維護有序的 key[1024][625000] 數組和 offset[1024][625000] 數組。

上述兩張圖對總體的架構進行了一個很好的詮釋,利用數據分佈均勻的特性,能夠將全局數據 hash 成 1024 個分區,在每一個分區中存放兩類文件:索引文件和數據文件。在隨機寫入階段,根據 key 得到該數據對應分區位置,並按照時序,順序追加到文件末尾,將全局隨機寫轉換爲局部順序寫。利用索引和數據一一對應的特性,咱們也不須要將 data 的邏輯偏移量落盤,在 recover 階段能夠按照恢復 key 的次序,反推出 value 的邏輯偏移量。

在 range 階段,因爲咱們事先按照 key 的高 10 爲作了分區,因此咱們能夠認定一個事實,patition(N) 中的任何一個數據必定大於 partition(N-1) 中的任何一個數據,因而咱們能夠採用大塊讀,將一個 partition 總體讀進內存,供 64 個 visit 線程消費。到這兒便奠基了總體的基調:讀盤線程負責按分區讀盤進入內存,64 個 visit 線程負責消費內存,按照 key 的次序隨機訪問內存,進行 Visitor 的回調。

5 隨機寫流程

介紹完了總體架構,咱們分階段來看一下各個階段的一些細節優化點,有一些優化在各個環節都會出現,未避免重複,第二次出現的同一優化點我就不贅述了,僅一句帶過。

使用 pageCache 實現寫入緩衝區

主要看數據落盤,後討論索引落盤。磁盤 IO 類型的比賽,第一步即是測量磁盤的 IOPS 以及多少個線程一次讀寫多大的緩存可以打滿 IO,在固定 64 線程寫入的前提下,16kb,64kb 都可以達到最理想 IOPS,因此理所固然的想到,能夠爲每個分區分配一個寫入緩存,湊齊 4 個 value 落盤。可是這次比賽,要作到 kill -9 不丟失數據,不能簡單地在內存中分配一個 ByteBuffer.allocate(4096 * 4);, 而是能夠考慮使用 mmap 內存映射出一片寫入緩衝,湊齊 4 個刷盤,這樣在 kill -9 以後,PageCache 不會丟失。實測 16kb 落盤比 4kb 落盤要快 6s 左右。

索引文件的落盤則沒有太大的爭議,因爲 key 的數據量爲固定的 8B,因此 mmap 能夠發揮出它寫小數據的優點,將 pageCache 利用起來,實測 mmap 相比 filechannel 寫索引要快 3s 左右,相信若是把 polardb 這塊盤換作其餘普通的 ssd,這個數值還要增長。

寫入時不維護內存索引,不寫入數據偏移

一開始審題不清,在隨機寫以後誤覺得會馬上隨機讀,實際上每一個階段都是獨立的,因此不須要在寫入時維護內存索引;其次,以前的架構圖中也已經說起,不須要寫入連帶 key+offset 一塊兒寫入文件,recover 階段能夠按照恢復索引的順序,反推出 data 的邏輯偏移,由於咱們的 key 和 data 在同一個分區內的位置是一一對應的。

6 恢復流程

recover 階段的邏輯實際上包含在程序的 open 接口之中,咱們須要再數據庫引擎啓動時,將索引從數據文件恢復到內存之中,在這之中也存在一些細節優化點。

因爲 1024 個分區的存在,咱們可使用 64 個線程 (經驗值) 併發地恢復索引,使用快速排序對 key[1024][625000] 數組和 offset[1024][625000] 進行 sort,以後再 compact,對 key 進行去重。須要注意的一點是,不要使用結構體,將 key 和 offset 封裝在一塊兒,這會使得排序和以後的二分效率很是低,這之中涉及到 CPU 緩存行的知識點,不瞭解的讀者能夠翻閱我以前的博客: 《CPU Cache 與緩存行》

// wrong
public class KeyOffset {
    long key;
    int offset;
}
複製代碼

整個 recover 階段耗時爲 1s,跟 cpp 選手交流後發現恢復流程比之慢了 600ms,這中間讓我以爲比較詭異,加載索引和排序不該該這麼慢纔對,最終也沒有優化成功。

7 隨機讀流程

隨機讀流程沒有太大的優化點,優化空間實在有限,實現思路即是先根據 key 定位到分區,以後在有序的 key 數據中二分查找到 key/offset,拿到 data 的邏輯偏移和分區編號,即可以愉快的隨機讀了,隨機讀階段沒有太大的優化點,但仍然比 cpp 選手慢了 2-3s,多是語言沒法越過的差距。

8 順序讀流程

Range 環節是整個比賽的大頭,也是拉開差距的分水嶺。前面咱們已經大概提到了 Range 的總體思路是一個生產者消費者模型,n 個生成者負責從磁盤讀數據進入內存(n 做爲變量,經過 benchmark 來肯定多少合適,最終實測 n 爲 4 時效果最佳),64 個消費者負責調用 visit 回調,來驗證數據,visit 過程就是隨機讀內存的過程。在 Range 階段,剩餘的內存還有大概 1G 左右,因此我分配了 4 個堆外緩衝,一個 256M,從而能夠緩存 4 個分區的數據,而且,我爲每個分區分配了一個讀盤線程,負責 load 數據進入緩存,供 64 個消費者消費。

具體的順序讀架構能夠參見下圖:

range

大致來看,即是 4 個 fetch 線程負責讀盤,fetch thread n 負責 partitionNo % 4 == n 編號的分區,完成後通知 visit 消費。這中間充斥着比較多的互斥等待邏輯,並未在圖中體現出來,大致以下:

  1. fetch thread 1~4 加載磁盤數據進入緩存是併發的
  2. visit group 1~64 訪問同一個 buffer 是併發的
  3. visit group 1~64 訪問不一樣 partition 對應的 buffer 是按照次序來進行的(打到全局有序)
  4. 加載 partitonN 會阻塞 visit bufferN,visit bufferN 會阻塞加載 partitionN+4(至關於複用4塊緩存)

大塊的加載讀進緩存,最大程度複用,是 ReadSeq 部分的關鍵。順序讀兩輪的成績在 196~198s 左右,相比 C++ 又慢了 4s 左右。

9 魔鬼在細節中

這兒是個分水嶺,介紹完了總體架構和四個階段的細節實現,下面就是介紹下具體的優化點了。

10 Java 實現 Direct IO

因爲此次比賽將 drop cache 的時間算進了測評程序之中,因此在沒必要要的地方應當儘可能避免 pageCache,也就是說除了寫索引以外,其餘階段不該該出現 pageCache。這對於 Java 選手來講多是不小的障礙,由於 Java 原生沒有提供 Direct IO,須要本身封裝一套 JNA 接口,封裝這套接口借鑑了開源框架 jaydio 的思路,感謝@塵央的協助,你們能夠在文末的代碼中看到實現細節。這一點能夠說是攔住了一大票 Java 選手。

Direct IO 須要注意的兩個細節:

  1. 分配的內存須要對齊,對應 jna 方法:posix_memalign
  2. 寫入的數據須要對齊一般是 pageSize 的整數倍,實際使用了 pread 的 O_DIRECT

11 直接內存優於堆內內存

這一點在《文件IO操做的一些最佳實踐》中有所說起,堆外內存的兩大好處是減小了一分內存拷貝,而且對 gc 友好,在 Direct IO 的實現中,應該配備一套堆外內存的接口,才能發揮出最大的功效。尤爲在 Range 階段,一個緩存區的大小便對應一個 partition 數據分區的大小:256M,大塊的內存,更加適合用 DirectByteBuffer 裝載。

12 JVM 調優

-server -Xms2560m -Xmx2560m -XX:MaxDirectMemorySize=1024m -XX:NewRatio=4 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:-UseBiasedLocking
複製代碼

衆所周知 newRatio 控制的是 young 區和 old 區大小的比例,官方推薦參數爲 -XX:NewRatio=1,不少不注意的 Java 選手可能沒有意識去修改它,會在無形中被 gc 拖累。通過和@阿杜的討論,最終得出的結論:

  1. young 區過大,對象在年輕代待得過久,屢次拷貝
  2. old 區太小,會頻繁觸發 old 區的 cms gc

在比賽中這顯得尤其重要,-XX:NewRatio=4 放大老年代能夠有效的減小 cms gc 的次數,將 126 次 cms gc,降低到最終的 5 次。

13 池化對象

不管是 apache 的 ObjectPool 仍是 Netty 中的 Recycler,仍是 RingBuffer 中預先分配的對象,都在傳達一種思想,對於那些反覆須要 new 出來的東西,均可以池化,分配內存再回收,這也是一筆不小的開銷。在這次比賽的場景下,不必大費周章地動用對象池,直接一個 ThreadLocal 便可搞定,事實上我對 key/value 的寫入和讀取都進行了 ThreadLocal 的緩存,作到了永遠再也不循環中分配對象。

14 減小線程切換

不管是網絡 IO 仍是磁盤 IO,io worker 線程的時間片都顯得尤其的難得,在個人架構中,range 階段主要分爲了兩類線程:64 個 visit 線程併發隨機讀內存,4 個 io 線程併發讀磁盤。木桶效應,咱們很容易定位到瓶頸在於 4 個 io 線程,在 wait/notify 的模型中,爲了儘量的減小 io 線程的時間片流失,能夠考慮使用 while(true) 進行輪詢,而 visit 線程則能夠 sleep(1us) 避免 cpu 空轉帶來的總體性能降低,因爲評測機擁有 64 core,因此這樣的分配算是較爲合理的,爲此我實現了一個簡單粗暴的信號量。

public class LoopQuerySemaphore {

    private volatile boolean permit;

    public LoopQuerySemaphore(boolean permit) {
        this.permit = permit;
    }

    // for 64 visit thread
    public void acquire() throws InterruptedException {
        while (!permit) {
            Thread.sleep(0,1);
        }
        permit = false;
    }

    // for 4 fetch thread
    public void acquireNoSleep() throws InterruptedException {
        while (!permit) {
        }
        permit = false;
    }

    public void release() {
        permit = true;
    }

}
複製代碼

正確的在 IO 中 acquireNoSleep,在 Visit 中 acquire,可讓成績相比使用普通的阻塞 Semaphore 提高 6s 左右。

15 綁核

線上機器的抖動在所不免,避免 IO 線程的切換也並不只僅可以用依靠 while(true) 的輪詢,一個 CPU 級別的優化即是騰出 4 個核心專門給 IO 線程使用,徹底地避免 IO 線程的時間片爭用。在 Java 中這也不難實現,依賴萬能的 github,咱們能夠輕鬆地實現 Affinity。github 傳送門:github.com/OpenHFT/Jav…

使用方式:

try (final AffinityLock al2 = AffinityLock.acquireLock()) {
    // do fetch ...
}
複製代碼

這個方式可讓你的代碼快 1~2 s,而且保持測評的穩定性。

0 聊聊 FileChannel,MMAP,Direct IO,聊聊比賽

我在最終版本的代碼中,幾乎徹底拋棄了 FileChannel,事實上,在不 Drop Cache 的場景下,它已經能夠發揮出它利用 PageCache 的一些優點,而且優秀的 Java 存儲引擎都主要使用了 FileChannel 來進行讀寫,在少許的場景下,使用了 MMAP 做爲輔助,畢竟,MMAP 在寫小數據量文件時存在其價值。

另外須要注意的一點,在跟@96年的亞普長談的一個夜晚,發現 FileChannel 中出人意料的一個實現,在分配對內內存時,它仍然會拷貝一份堆外內存,這對於實際使用 FileChannel 的場景須要額外注意,這部分意料以外分配的內存很容易致使線上的問題(實際上已經遇到了,和 glibc 的 malloc 相關,當 buffer 大於 128k 時,會使用 mmap 分配一塊內存做爲緩存)

說回 FileChannel,MMAP,最容易想到的是 RocketMQ 之中對二者靈活的運用,不知道在其餘 Java 實現的存儲引擎之中,是否是能夠考慮使用 Direct IO 來提高存儲引擎的性能呢?咱們能夠設想一下,利用有限而且少許的 PageCache 來保證一致性,在主流程中使用 Direct IO 配合順序讀寫是否是一種能夠配套使用的方案,不只僅 PolarDB,算做是參加本次比賽給予個人一個啓發。

雖然無緣決賽,但使用 Java 取得這樣的成績還算不是特別難過,在 6400w 數據隨機寫,隨機讀,順序讀的場景下,Java 能夠作到僅僅相差 C++ 不到 10s 的 overhead,我卻是以爲徹底是能夠接受的,哈哈。還有一些小的優化點就不在此贅述了,歡迎留言與我交流優化點和比賽感悟。

github 地址:github.com/lexburner/k…

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

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