第一屆PolarDB資料庫效能大賽Java選手分享

neoremind發表於2018-12-18

天池大賽-第一屆PolarDB資料庫效能大賽,比賽以NVME Optane SSD為背景,在此之上開發單機儲存引擎比拼效能,支援C++和Java語言。內部賽小試牛刀後,汲取了一些經驗,作為這麼多年的資深JAVAer,還是想繼續挑戰一把,這次參加外部賽,成績是Java語言排名第一,總排名20(隊伍名稱:neoremind),與C++第一差距在2.1%(<9s)。眾所周知,類似的系統如果想榨乾硬體,那麼越貼近底層越好,Java存在一些天然的劣勢,跑出這樣的成績也是盡力了,雖然不是前十的C++高手,但是思想架構是通用的,丟擲我的解法和程式碼,供學習交流。

本文是解題報告,原始碼地址https://github.com/neoremind/2018-polar-race

1. 賽題介紹

評測程式分為2個階段:
 
1. 正確性評測
此階段評測程式會併發寫入特定資料(key 8B、value 4KB)同時進行任意次kill -9來模擬程式意外退出(參賽引擎需要保證程式意外退出時資料持久化不丟失),接著重新開啟DB,呼叫Read、Range介面來進行正確性校驗。
 
2. 效能評測
2.1 隨機寫入:64個執行緒併發隨機寫入,每個執行緒使用Write各寫100萬次隨機資料(key 8B、value 4KB)。
2.2 隨機讀取:64個執行緒併發隨機讀取,每個執行緒各使用Read讀取100萬次隨機資料。
2.3 順序讀取:64個執行緒併發順序讀取,每個執行緒使用Range全域性順序迭代DB資料2次。
 
補充下,
1)每個階段結束後都會清page cache,清理時間也算在總時長裡。
2)Read、Range會驗證key、value是否match,Range驗證是否保序。
 

2. 實現前的思考和最終成績

題目要求開發一個單機KV引擎,保證高吞吐寫,低延遲點查,範圍查詢,同時保證crash consistency。第一個蹦出來的想法就是WAL+LSM-tree實現的leveldb和rocksdb,但是這兩個單機引擎都是針對通用的場景,眾所周知,LSM-tree的架構把random write轉成sequential write,多層的compaction和lookup,存在寫放大和讀放大,在HDD時代,這個雖然是劣勢,但是比起磁碟隨機寫比順序寫高1000x的代價,也是值了,在SDD時代,這個代價並沒有那麼高,更何況SDD的併發讀寫效能優秀,所以在比賽中直接用LSM-tree不可取。
 
迴歸題目,抓重點,1)定長kv,2)大value 4K,3)64併發查詢。
 
順著LSM-tree的思路,聯想到一篇論文“WiscKey: Separating Keys from Values in SSD-Conscious Storage”,這是Wisconsin大學在2016年發表的論文,主要講了如何在SSD上優化leveldb,包括減少讀寫放大,最大化利用bandwidth,充分利用SSD的一些特點,包括順序IO高吞吐、隨機併發效能出色等。這種在SSD上優化的kv分離儲存結構的思想,很契合題目: 大Value和併發查詢,我結合這個論文的見解和思想,整合出瞭解題的儲存設計和引擎實現。而剩下的定長kv要求,則進一步簡化了實現難度。
 
比賽的目標是比耗時最短,所以就變成如何榨乾IO,達到最大的吞吐。
 
先計算下,隨機寫總量,(4k+8byte)*64*100w=256G,隨機讀256G,Range兩次掃512G。根據Intel Optane SSD官方給出的一些資料,順序寫2G/s,順序讀2.4G/s,隨機讀 55w IOPS,隨機寫50w IOPS,讀寫延遲10us。比賽結果順序寫和隨機讀的吞吐和IOPS都比官方值略高,實測順序寫2.2G/s,隨機讀2.5G/s,順序讀還會更快一些。理論計算極限大概410s左右。
 
第一名C++選手的完賽成績:
413.69s(Write 116s + Read 103s + Range 193s)
 
Write throughput:2.21G/s,Read throughput:2.49G/s,Range throughput:2.65G/s
 基本已經榨乾磁碟。
 
我是用Java實現的第一名,完賽成績:
422.31s(Write 116s + Read 109s + Range 196s)
 
Write throughput:2.21G/s,Read throughput:2.35G/s,Range throughput:2.61G/s

3. 儲存設計

不採用LSM-tree模型,利用WisckKey論文的思想,做key、value分離。如下圖所示。

wal(Write Ahead Log)存key和value在vlog中的offset,vlog是順序寫入的value,wal和vlog都是append-only的定長寫入,所以wal只用存vlog的sequence,vlog seq用4byte Int表示儲存,大尾端/小尾端程式自己定,然後乘以4096就是在vlog檔案中的偏移量。關於vlog的gc問題題目不要求刪除故不考慮。

wal和vlog都是順序IO寫入,不存在LSM-tree模型的寫放大問題。

由於kv分離,寫入必須lock,有鎖就會限制效能,由於是隨機寫入,所以按照分治的思路,減少衝突即可,資料要分片sharding。我的策略是按照key的字典序分成1024個分片。把key的第一個位元組8byte+第二個位元組的前2個bit取出,轉成int,經過分割槽函式就可以路由到正確的分片上。

4. 實現分析-Write

1024分片,單分片加鎖寫,流程如下

synchronized(lock) {
  write vlog 4k;
  write wal 8byte + 4byte vlog seq;
  vlog seq++;
}

可選擇的IO方式有buffer io和direct io,而buffer io又可以考慮vfs read/write和mmap兩種方式。

wal採用mmap方式寫入,好處在於,第一,減少了一次記憶體拷貝,Java的FileChannel寫入會經過byte[]->offheap direct memory->kernel page cache->disk的通路,而mmap則直接byte[]->記憶體對映地址(也算page cache)->disk,mmap直接是把檔案對映到user space可訪問的記憶體,讀寫都直接操作記憶體,不write through disk,僅需要一次mmap系統呼叫即可,close db的時候truncate掉後面的無用bytes;第二,crash consistency的保證,由於題目僅進行kill -9,不進行掉電,利用Page Cache,不同步刷盤保證資料一致性,而第二次啟動由於vlog seq都是遞增的,所以讀wal遇到0x00000000,則可以丟棄後面的資料。

總的wal檔案大小=(8byte key+4byte vlog seq)*64併發*100w=768MB。由於評測程式足夠隨機,每個wal大小=768MB/1024=750KB,所以每個分片可以直接通過mmap,對映為12byte<<16大小的檔案,通過ensure capacity來做re-mmap,這樣可以相容正確性測試中的檔案大小不平均,正確性程式的寫入集中在5個分片中。

單個vlog檔案大小=4k*6400w/1024=256MB。

vlog如果採用Java的FileChannel的,則相比C++多一次從heap到offheap direct memory的拷貝,走Page Cache後續echo 3 >/proc/sys/vm/drop_caches也算時間,故採用direct io。由於JDK並不提供direct io的API,可選擇的有直接用JNA類庫,通過JNI方式呼叫,或者採用封裝了JNA的jaydio。我這裡直接用了JNA的API。

順序IO的問題解決了,那麼上大塊IO才能真正打滿頻寬,採用direct io的同時為了保證crash consistency,需要有個mmap在前面頂著,這裡採用攢齊4個value,即16k value再寫盤的策略,不斷的擦寫mmap同一塊記憶體區域,正常關閉則刪除掉mmap的臨時檔案,否則下次初始化的時候需要append這個mmap的檔案內容到wal做recover。

全程young gc 4次,無full gc。Write on-CPU火焰圖,86% IO + 4% 鎖消耗 + 其他。基本達到目標。 

5. 實現分析-Read

初始化資料庫,在上面的寫入分析中已經說明了部分,wal和vlog都需要做一些工作保證crash consistency。下一步就是如何建立索引,支援point lookup。

分析來看,總的wal檔案大小768MB,索引完全可以放到記憶體,每個分片建索引流程如下:

1)load wal形成key 8byte + vlog seq 4byte

2)排序這12個byte,先按照key字典序排序,再按照vlog seq取最大的,來應對duplicate key情況。

3)把排序好的資料放到記憶體中。

這個過程每個分片是獨立的,可以並行化。這個環節是Java做的不夠好的地方,64併發load共耗時1.5-2.5s,有抖動,而且不及C++的300ms,慢了不少。排序的話採用位元組一個個比較,或者把key轉成unsigned long再比較差距不大,分析來看應該是由於cache line的原因。由於後一個階段還需要Range,為了避免這個過程再重複,所以可以選擇性的把已排序、去重好的key+vlog seq持久化到磁碟,做一個wal.sort檔案。

由於key 8byte + vlog seq 4byte定長,所以在記憶體裡用二分查詢即可。我的實現採用了offheap記憶體,通過JDK的Unsafe來malloc和free記憶體,避免放到heap中的old region,存在GC overhead。記憶體二分查詢開銷非常低,佔總耗時1%左右。

一次point lookup需要一次記憶體二分查詢+一次磁碟IO。從磁碟讀4k value只需要把vlog seq * 4096還原成實際的檔案offset即可,通過offset從vlog讀4k資料,也需要考慮buffer io還是direct io,在集團內部賽的時候,評測程式讀和寫key的順序是一致的,有區域性性效應,採用buffer io,走Page Cache,作業系統的預讀read ahead會起作用,會快不少。而外部賽,修正了這個問題,所以buffer io,read ahead反而是糟糕的,預讀了很多無用的資料到Page Cache浪費了頻寬。

讀採用direct io,Java通過JNA使用direct io,需要先通過int posix_memalign(void **memptr, size_t alignment, size_t size)函式對齊記憶體(記憶體暫叫做MemPointer),然後通過pread(fd, MemPointer, 4096, offset)系統呼叫來讀取到MemPointer地址,然後拷貝到user space的heap中,這個過程是需要加鎖的,如果無鎖化就需要池化MemPointer,實測兩種方案差距不大。

這裡有一個小點可以避免頻繁的Young GC,64個執行緒通過ThreadLocal讀4k value,避免頻繁的分配記憶體(感謝@島風同學的提醒才想到這點,島風是另外一位Java選手,他的分享見連結)。

這部分是所有3個環節中和C++差距最大的,吞吐2.35G/s,小於C++的2.49G/s,這140MB/s的差距可以歸結為建索引慢,通過JNA走JNI的direct io不如直接使用系統呼叫。

全程young gc 4-5次,無full gc。 

6. 實現分析-Range

這是本次比賽拉開差距的環節。題目64併發Range2次,128次full scan,基本不可能,一般有兩種思路解決,第一搭車模型,64併發wait,非同步執行緒visit回撥;第二64執行緒齊頭並進visit。我這裡採用了前者,利用java.util.concurrent併發程式設計庫實現了一個AccumulativeRunner的工具類。

基本思想就是併發的Range請求達到,然後都submit一個Range Task並且wait阻塞,後臺有兩個觸發條件,滿足一定Range Task數量,或者超時了例如5s,就trigger一次Range進行full scan,然後回撥所有Range請求的visitor,scan完統一的notify Range請求執行緒解除阻塞。再進行第二次range。時序圖如下,

接下來,考量如何高效的進行一次full scan,一開始採用“滑動視窗+併發隨機IO查詢”的思想,想利用SSD的併發隨機IO特性,結果不夠理想,第一,走buffer io,利用Page Cache讀取資料不如一次性大塊IO的load到記憶體裡吞吐高,第二,Java的FileChannel內部有把position的鎖,制約效能發揮。

之後放棄了這種模型,轉為“滑動視窗+併發記憶體查詢”的思想,效果不錯,接近打滿頻寬。由於key按照字典序分了1024個分片,基本思想就是順序遍歷1024個分片,每個分片放到記憶體中訪問,無縫的銜接每個分片,走完即可。每個分片的訪問都分為3個步驟。

1)prefetch預讀:wal排序好建立索引,vlog load到記憶體。

2)Range讀取:iterate排序好的wal,針對每個key和vlog seq找value,就變成了記憶體訪問,也就是“併發記憶體查詢”的精髓。

3)評測程式visit:評測程式需要驗證有序、值正確等,也有一定消耗。 

為了無縫銜接1024個分片做上述3個步驟,使用滑動視窗,如下圖,滑動視窗分為5類:

  • 已訪問結束的
  • 正在Range讀取的和visit的
  • prefetch預讀結束,準備被讀取的
  • prefetching中
  • 未訪問的

滑動視窗最大容量是3個分片,佔用記憶體最大=vlog(256MB*3)+wal索引(750KB*3)=770MB,這樣可以保證不會打破cgroup的記憶體限制。

prefetch預讀,Range讀取,評測程式visit三者的瓶頸最終應該在prefetch上,才會打滿頻寬。所以對於後兩個環節,benchmark everything實測確實可能拖後腿,上老辦法,序列轉並行,做一個多級流水線的架構。

prefetch預讀每個分片,就是wal排序好建立索引和load vlog並快取的過程,這兩個過程可以並行。

建立索引,load wal或者wal.sort檔案到記憶體,和read階段一樣,排序好的key和vlog seq放到堆外記憶體做索引,為Range讀取使用。

load vlog並快取,這個過程可以加併發,單分片vlog大小是256MB,採用8併發*32M大塊IO讀的方式並行load。load vlog可以選擇mmap/file channel/direct io load,實測direct io load和file channel差距不大,最終load vlog檔案採用direct io。快取可以採用offheap DirectBuffer/Unsafe手工分配的記憶體/heap中,offheap direct memory/Unsafe差距不大,如果使用DirectBuffer,那麼後續記憶體查詢get操作非執行緒安全,所以需要轉換為address,通過Unsafe的copyMemory API來訪問,進行無鎖化;由於load vlog採用direct io,所以這裡池化MemPointer,預先在記憶體中分配出3視窗*8併發共24個32MB的MemPointer,之後read就可以併發無鎖的讀記憶體地址了。

Range讀取,批量讀取256個kv,2個並行即可,然後把結果放到一個無鎖佇列中。評測程式visit函式在單獨的執行緒中完成,poll無鎖佇列,針對讀取到的kv資料,4個並行的完成64個visitor的回撥。這樣這兩個步驟不至於拖後腿。

對於已經訪問結束的分片,需要釋放資源,包括wal索引和vlog快取,以及歸還MemPointer到池中。

7. Java實現的特殊性

Java相比C++在比賽中存在一定劣勢,這也是直接導致儘管Java排名第一,但是總排名20的原因,雖然和C++第一名的差距只有2.1%,不到9s,但是能做到這樣的成績自己也是比較滿意了。

JVM相比C++的劣勢包括:

1)不夠貼近底層,隔著一層JVM,靠JVM解釋位元組碼執行,雖然有JIT幫熱點程式碼優化為native code執行,但終究不夠直接。

2)有GC overhead,如果快取放heap,必然gc頻繁,影響吞吐;併發GC和使用者執行緒可以緩解,必須控制只young gc,不full gc,這個比賽是比IO,所以CPU理論都夠用,觀察看佔400%-600%的CPU是中位數。

3)使用作業系統API不夠方便,比如direct io原生JDK不支援,mmap釋放記憶體不方便,執行緒bind CPU core不方便等。

作為Java選手要克服上面的困難,必然要使出一些大殺器,下面依次總結下。

1、mmap

幫助寫入階段寫wal,保證crash consistency。JDK提供原生的API,但是釋放相對麻煩。

2、direct io

JNA封裝,或者使用jaydio,拼接小IO為大塊IO寫入。FileChannel在本次比賽都沒有使用,原因就是內部有個position lock並且走buffer io,在這個場景不適合,但是大多數Java涉及IO的場景,NIO的FileChannel都是首選。

3、堆外記憶體

offheap可以用DirectBuffer,或者Unsafe的malloc、free。

4、gc控制

比賽用引數如下,

-server -XX:-UseBiasedLocking -Xms2000m -Xmx2000m -XX:NewSize=1400m -XX:MaxMetaspaceSize=32m -XX:MaxDirectMemorySize=1G -XX:+UseG1GC

write和read階段young gc都很少,主要為range階段使用,由於使用了多級流水線架構,所以吃記憶體比較嚴重,young gc相對頻繁,但沒有full gc所以可接受。

5、池化技術

DirectMemory先分配好,然後池化,使用時候反覆擦寫,可以複用資源。read階段用ThreadLocal複用value避免頻繁young gc。

6、鎖控制

kv分離的寫入,必然加鎖。read階段的direct io load同一塊記憶體,然後返回給user space的過程也需要加鎖,儘量小的控制鎖粒度,分散鎖的衝突,就像ConcurrentHashMap思想一樣,就可以把鎖的消耗降到最低。

7、併發利器

java.util.concurrent要用好,Range階段的搭車模型,並行load vlog,滑動視窗都用到了執行緒池、lock、condition、mutex等。同時一些無鎖併發的類庫例如ConcurrentLinkedQueue,jctools的MpmcArrayQueuedisruptor的無鎖佇列也可以嘗試,比賽中都有實驗,其實無鎖就足夠了,瓶頸在IO,這些可以忽略。

8、減少上下文切換

由於比賽使用了Alijdk,而Alijdk有個Wisp API,可以做Java協程,在一些資源釋放無需等待的場景可以使用,親試後通過vmstat -w 1命令看cs列確實少了一些,但是對提高成績沒有很大幫忙。

8. 單機資料庫引擎思考

第一,個人認為影響一個資料庫效能的方面,儲存架構設計 大於 引擎實現質量 大於 語言選型,所以Java在這道題目和C++沒有質的區別。

第二,在平臺應用服務領域,Java優勢在於工程化,類庫豐富,設計模式等特性,所以空間廣闊;而分散式計算領域,對比HDD和SDD的差距從ms到us數量級的提升,分散式呼叫同機房ms級別、跨地域幾十ms的延遲,才是大問題,所以很多的開源大資料專案,例如spark、hadoop、flink等多采用Java這種工程化好和易維護的語言,也同樣滿足需求。

第三,儲存引擎實現語言的考量:

1)工程化,例如是否static type & 抽象設計

2)Tail latency控制

3)Runtime overhead

Java,2、3對比C++是java的弱項,比如GC和JVM的overhead,但是這兩點卻是一個DB的剛需。另一個角度,從系統分層角度出發,對2、3敏感的C++,Rust合適,如果是對工程化要求更高Java、Go是有其使用場景的。

9. 總結

第一次參加工程性質的比賽,有各路的高手,大家爭秒奪毫秒的比拼,非常刺激,也第一次系統性的實踐了一把Java IO相關的技術,學習和積累經驗的目標已經達到。希望未來自己還能有精力和活力參加比賽,向高手們學習,切磋進步。技術的路很長,每一步的積累都是為了明天更好的自己,與正在讀此文的你共勉。


相關文章