第一屆PolarDB資料庫效能大賽Java選手分享
天池大賽-第一屆PolarDB資料庫效能大賽,比賽以NVME Optane SSD為背景,在此之上開發單機儲存引擎比拼效能,支援C++和Java語言。內部賽小試牛刀後,汲取了一些經驗,作為這麼多年的資深JAVAer,還是想繼續挑戰一把,這次參加外部賽,成績是Java語言排名第一,總排名20(隊伍名稱:neoremind),與C++第一差距在2.1%(<9s)。眾所周知,類似的系統如果想榨乾硬體,那麼越貼近底層越好,Java存在一些天然的劣勢,跑出這樣的成績也是盡力了,雖然不是前十的C++高手,但是思想架構是通用的,丟擲我的解法和程式碼,供學習交流。
1. 賽題介紹
2. 實現前的思考和最終成績
413.69s(Write 116s + Read 103s + Range 193s)
Write throughput:2.21G/s,Read throughput:2.49G/s,Range throughput:2.65G/s
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的MpmcArrayQueue,disruptor的無鎖佇列也可以嘗試,比賽中都有實驗,其實無鎖就足夠了,瓶頸在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相關的技術,學習和積累經驗的目標已經達到。希望未來自己還能有精力和活力參加比賽,向高手們學習,切磋進步。技術的路很長,每一步的積累都是為了明天更好的自己,與正在讀此文的你共勉。
相關文章
- PolarDB資料庫效能大賽Java選手分享資料庫Java
- 第一屆天池 PolarDB 資料庫效能大賽資料庫
- PolarDB 資料庫效能大賽 Java 分享資料庫Java
- 巔峰對決,第四屆全球資料庫大賽—PolarDB效能挑戰賽圓滿收官資料庫
- 第二屆資料安全大賽“數信杯”資料安全大賽 WP
- 大資料競賽技術分享大資料
- 資料競賽:第四屆工業大資料競賽-虛擬測量大資料
- 第一屆國家智慧教育平臺資料創新應用活動參賽經驗分享
- 《中介軟體效能挑戰賽--分散式統計和過濾的鏈路追蹤》java 選手分享分散式Java
- Oracle資料庫遷移至PolarDb(阿里雲資料庫)Oracle資料庫阿里
- 2014第六屆華為程式設計大賽初賽第一輪程式設計
- PolarDB資料庫LVM管理配置資料庫LVM
- 產學研協同育人,第二屆OceanBase資料庫大賽圓滿收官資料庫
- 第一屆SQL大賽第一期優秀解題思路彙總SQL
- 第一屆C語言比賽答案C語言
- Polardb-O資料庫歸檔配置資料庫
- 安全管理:polardb資料庫審計功能資料庫
- 資料分享 | 第十三屆GOPS 全球運維大會Go運維
- 首屆傑出資料庫工程師評選文集資料庫工程師
- 第一屆BMZCTF公開賽-WEB-WriteupWeb
- 百度搜尋首屆技術創新挑戰賽資料分享
- SQL資料庫程式設計大賽隨感SQL資料庫程式設計
- SQL資料庫程式設計大賽開幕SQL資料庫程式設計
- 快速建立POLARDB for PostgreSQL資料庫叢集教程SQL資料庫
- 開源資料庫大會技術分享資料庫
- 大資料系列分享第一期:《Hello Bigdata》大資料
- “盛拓傳媒杯”SQL資料庫程式設計大賽第一期程式碼SQL資料庫程式設計
- CUSGA第一屆中國大學生遊戲開發創作大賽複賽名單公佈!遊戲開發
- 資料庫週刊34丨首屆達夢資料庫精英挑戰賽啟動;2020(上)最受歡迎資料庫文章…資料庫
- Polardb資料庫掛庫後,如何恢復主備關係資料庫
- 大咖說|網易數帆論道 PolarDB 資料庫開源 & 儲存生態資料庫
- 巨杉資料庫攜手廣發證券入選2023大資料“星河”案例資料庫大資料
- 三體PCC大賽題目-facebook微博like場景資料庫設計與效能壓測資料庫
- 老白Oracle資料庫效能優化實務-視訊分享Oracle資料庫優化
- 第一部分資料庫效能基礎資料庫
- 全國首屆大模型創新創意應用大賽開啟,等你來賽!大模型
- 阿里天池大資料競賽阿里大資料
- 建立高效能的資料庫——效能調整手冊和參考資料庫