PolarDB 資料庫效能大賽 Java 分享

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

1 前言

排名

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

2 賽題概覽

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

初賽要求支援 Write、Read 介面。

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

複賽在初賽題目基礎上,還需要額外實現一個 Range 介面。

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

程式評測邏輯 分為2個階段:
1)Recover 正確性評測:
此階段評測程式會併發寫入特定資料(key 8B、value 4KB)同時進行任意次 kill -9 來模擬程式意外退出(參賽引擎需要保證程式意外退出時資料持久化不丟失),接著重新開啟 DB,呼叫 Read、Range 介面來進行正確性校驗

2)效能評測

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

語言限定:C++ & JAVA,一起排名

3 賽題剖析

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

3.1 key 8B, value 4kb

key 為固定的 8 位元組,因此可使用 long 來表示。

value 為 4kb,這節省了我們很大的工作量,因為 4kb 的整數倍落盤是非常磁碟 IO 友好的。

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 兩個分割槽之間整體有序(分割槽內部無序)。實際我嘗試了將資料分成 1024、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 相關的技術分享。

關注微信公眾號

相關文章