背景介紹
Cobar簡介
Cobar 是阿里開源的一款數據庫中間件產品。
在業務高速增長的情況下,數據庫往往成為整個業務系統的瓶頸,數據庫中間件的出現就是為了解決數據庫瓶頸而產生的一種中間層產品。
在軟件工程中,沒有什么問題是加一層中間層解決不了的,如果有,再加一層。
一款proxy類型(本文不討論client SDK類型的數據庫中間件)的數據庫中間件具備以下能力:
- 支持數據庫的透明代理,做到用戶無感知
- 能夠水平、垂直拆分數據庫和表,橫向擴展數據庫的容量和性能
- 讀和寫的分離,降低主庫壓力
- 復用數據庫連接,降低數據庫的連接消耗
- 能夠檢測數據庫集群的各種故障,做到快速failover
- 足夠穩定可靠,性能足夠好
而本文的主角Cobar除了讀寫分離外其他特性都支持的很好,而且基於Cobar開發讀寫分離的特性並不是一件很難的事。
SQL審計
筆者有幸也曾在公司內的Cobar上做過定制開發,開發的功能是SQL審計。
從數據庫產品的運營角度看,統計分析執行過的SQL是一個必要的功能;從安全角度看,信息泄露、異常SQL也需要被審計。
SQl審計需要審計哪些信息?通過調研,大致確定要采集執行的SQL、執行時間、來源host、返回行數等幾個維度。
SQL審計的需求很簡單,但就算是一個很簡單的需求放在數據庫中間件的高並發、低延遲,單機QPS可達幾萬到十幾萬的場景下都需要謹慎考慮,嚴格測試。
舉個例子,獲取操作系統時間,在Java中直接調用 System.currentTimeMillis();
就可以,但在Cobar中如果這么獲取時間,就會導致性能損耗非常嚴重(怎么解決?去Cobar的github倉庫上看看代碼吧)。
技術方案
大方向
經調研,SQL審計實現的方向大致有兩種
- 一種是比較容易想到的直接修改Cobar代碼,在需要收集信息的地方埋點
- 另一種是阿里雲數據庫提供的方案,通過抓取數據庫的通信流量進行分析。
考慮到技術的復雜度,我們選擇了較為簡單的第一種實現方式。
SQL審計在Cobar中屬於“錦上添花”的需求,不能因為這個功能導致Cobar性能下降,更不能導致Cobar不可用,所以必須遵循以下兩點:
- 性能盡可能接近無SQL審計版本
- 無論如何不能造成Cobar不可用
對於性能的損耗,沒有度量就沒法優化,於是使用sysbench
(一種數據庫基准測試工具)來對現在版本的Cobar進行壓測。
Cobar部署在4C8G的機器上,mysql部署在性能足夠好的物理機上,壓出了5.5w/s
的基准,后續的版本都和這個數值進行比對。
由於采取了侵入Cobar代碼的方式,想對Cobar造成影響最小,就需要保持代碼最小的修改,於是采取了agent的方案。
這樣可以保持代碼的最小修改,只需要打點采集並傳輸給agent,向遠端傳輸審計信息的邏輯就只需要在agent中處理即可,向遠端傳輸信息幾乎在一開始就確定了用kafka,這樣也能保持Cobar不引入新的第三方依賴,保持代碼的干凈(要知道Cobar的第三方依賴只有log4j),讓kafka和Cobar保持在兩個JVM中,更是一種隔離。於是有了下圖的架構初稿
通過上圖梳理出了兩個關鍵技術點:線程通信和進程通信。
進程通信容易理解,為什么這里還涉及線程通信?
首先Cobar的execute線程是執行SQL的主線程,如果在這個線程中去進行進程通信,那性能肯定被消耗的體無完膚。於是只能丟給審計線程去做,這樣對Cobar的性能影響最小。
進程間通信
先說進程間的通信,這塊稍微簡單點,我們只需要羅列出可用的進程間通信方式,然后對比優缺點,選擇一個合適的使用即可
首先Cobar是Java編寫,於是我們框定了范圍:TCP、UDP、UnixDomainSocket、文件。
經過調研,UnixDomainSocket與平台相關性太強,且沒有官方的實現,只有第三方的實現(如junixsocket),測試下來,不同linux的版本支持都不一致,所以這里直接排除。
寫文件會導致高IO,甚至有寫滿磁盤的風險,畢竟在如此高的並發之下,遂排除。
最終在TCP和UDP中選擇,考慮性能UDP比TCP好,且TCP還得自己解決粘包
問題,於是我們選擇了UDP。其實想想,SQL審計需求類似日志收集、metric上報,許多日志收集、metric上報都是采取UDP的方式。
線程間通信
如果說進程間通信拍拍腦袋
就能決定,是因為他並不直接影響Cobar,他是審計線程與agent進程間的通信。然而線程間的通信則直接決定了對Cobar的性能影響,必須謹慎。
線程間通信必須通過一個中間的緩沖buffer
來中轉,我們對這個buffer有如下要求
- 有界,無界就可能會導致內存溢出
- 投遞不能阻塞,阻塞會導致夯住主線程,極大影響Cobar性能
- 可以無序,為了保證Cobar可用性,甚至可以在極端情況下丟失一些數據
- 線程安全,高並發下如果線程不安全,數據就會錯亂
- 高性能
Java內置隊列
Java中內置的隊列可以充當這個buffer
有界的只有ArrayBlockingQueue和LinkedBlockingQueue,然而他們都是加鎖的,直覺告訴我,他的性能不會太好。
想到Java中CurrentHashMap和LongAdder都是通過分段來解決鎖沖突的,於是打算使用多個ArrayBlockingQueue來構造這個buffer
實測下來,只達到了4.7w/s,性能損失約10%
Disruptor
Java內置的隊列屬於有鎖隊列,那么有沒有不加鎖且有界的隊列呢?搜索后發現了一款開源的無鎖隊列實現Disruptor
,大量的產品如Log4j2等都使用了Disruptor。它是一種環形的數據結構,使用了Java中的CAS
代替了鎖,且有許多細節上的性能優化,導致他的性能非常強悍。
但很可惜的是,在測試時發現當Disruptor的buffer寫滿之后,再寫就會阻塞,這和我們的需求不符合,如果主線程發生阻塞將是災難性的,於是放棄。
SkyWalking的RingBuffer
剛好當時組內同學在研究SkyWalking
,SkyWalking是一款開源的應用性能監控系統,包括指標監控,分布式追蹤,分布式系統性能診斷。
他的原理是利用Java的字節碼修改技術在調用處插入埋點,采集信息上報。和Cobar的采集上報過程類似。
那么他的RingBuffer是如何實現的呢?其實非常簡單,緩沖區就是一個數組,每次投遞時獲取一個沒有寫入數據的數組下標即可,在多線程下只要保證獲取的下標不會被兩個線程同時獲取即可。數據的寫入速度快慢就看這個下標獲取是否高效即可,如下圖:
獲取數組下標和Disruptor類似也是使用了CAS,但他實現非常簡單,甚至有點粗糙,但他可以在寫滿時選擇是阻塞、覆蓋或是忽略,我們選擇覆蓋這個策略,在極端情況下丟掉老數據來換取Cobar的可用性。我們測試了一下使用多個SkyWalking的RingBuffer的場景,結果只有3w/s
,損失45%性能。
於是我們對這個Ringbuffer進行了一些優化
這個優化主要是將CAS換成incrementAndGet,這樣就能利用到JDK8對incrementAndGet的優化,在JDK8之前,incrementAndGet底層也是CAS,但在JDK8之后,incrementAndGet使用了fetch-and-add
(CPU指令),性能要強勁很多。這塊具體的介紹和代碼可以參考《一種極致性能的緩沖隊列》。
除了這個主要的優化外,還參考Disruptor進行對SkyWalking進行了緩存行填充
優化,最后達到了5.4w/s
,性能損失僅僅1.8%,非常給力,於是使用了這個版本的Ringbuffer作為Cobar SQL審計的緩存區。
優化后的Ringbuffer也回饋給了SkyWalking社區,SkyWalking作者贊賞這是一個“intersting contribution”。
總結
Cobar的SQL審計在上線后穩定支撐了公司所有Cobar集群,是承載最高QPS的系統之一。
回頭來看對性能的極致追求可能或許過於"偏執",創造的收益在旁人眼里看來並沒有那么大,加一台機器就能搞定的事情非要搞這么復雜。但這份“偏執”卻是我們對技術最初的追求,生活不止眼前的苟且,還有詩和遠方。
關於作者:專注后端的中間件開發,公眾號"捉蟲大師"作者,關注我,給你最純粹的技術干貨