彩虹橋架構演進之路-效能篇|得物技術

陶然陶然發表於2023-11-06


來源:得物技術

目錄

一、前言

二、改造前的架構

    1. Proxy 三層模組

    2. BIO 模式下的問題

三、改造後的架構

    1. 自研 NIO 資料庫驅動

    2. 自研 NIO 資料庫連線池

    3. 跳過不必要的編解碼

    4. 全鏈路非同步化

    5. 相容性

四、效能表現

五、總結

前言

一年前的《彩虹橋架構演進之路》側重探討了穩定性和功能性兩個方向。在過去一年中,儘管業務需求不斷增長且流量激增了數倍,彩虹橋仍保持著零故障的一個狀態,算是不錯的階段性成果。而這次的架構演進,主要分享一下近期針對效能層面做的一些架構調整和最佳化。其中最大的調整就是 Proxy-DB 層的執行緒模式從 BIO 改造成了效能更好的 NIO。下面會詳細介紹一下具體的改造細節以及做了哪些最佳化。

閱讀本文預計需要 20~30 分鐘,整體內容會有些枯燥難懂,建議閱讀前先看一下上一篇彩虹橋架構演進的文章(彩虹橋架構演進之路)以及 MySQL 協議相關基礎知識。

改造前的架構

先來複習一下彩虹橋的全景架構圖:

彩虹橋架構演進之路-效能篇|得物技術

Proxy三層模組

針對 Proxy 這一層,可以大致分成 Frontend、Core、Backend 三層:

  • Frontend-服務暴露層:使用 Netty 作為伺服器,按照 MySQL 協議對接收&返回的資料進行編解碼。

  • Core-功能&核心層:透過解析、改寫、路由等核心能力實現資料分片、讀寫分離、影子庫路由等核心功能。

  • Backend-底層DB互動層:透過 JDBC 實現與資料庫互動、對結果集改列、歸併等操作。

BIO模式下的問題

這裡 Core 層為純計算操作,而 Frontend、Backend 都涉及 IO 操作,Frontend 層使用 Netty 暴露服務為 NIO 模式,但是 Backend 使用了資料庫廠商提供的傳統 JDBC 驅動,為 BIO 模式。所以 Proxy 的整體架構還是 BIO 模式。在 BIO 模型中,每個連線都需要一個獨立的執行緒來處理。這種模型有一些明顯的缺點:

  • 高資源消耗:每個請求建立獨立執行緒,伴隨大量執行緒開銷。執行緒切換與排程額外消耗 CPU。

  • 擴充套件性受限:受系統執行緒上限影響,處理大量併發連線時,效能急劇下降。

  • I/O阻塞:BIO 模型中,讀/寫操作均為阻塞型,導致執行緒無法執行其他任務,造成資源浪費。

  • 複雜的執行緒管理:執行緒管理和同步問題增加開發和維護難度。

我們看最簡單的一個場景:在 JDBC 在發起請求後,當前執行緒會一直阻塞直到資料庫返回資料,當出現大量慢查或者資料庫出現故障時,會導致大量執行緒阻塞,最終雪崩。在上一篇彩虹橋架構演進文章中,我們做了一些改進來避免了 BIO 模型下的一些問題,比如使用執行緒池隔離來解決單庫阻塞導致全域性雪崩的問題。

彩虹橋架構演進之路-效能篇|得物技術

但是隨著邏輯庫數量的增多,最終導致 Proxy 的執行緒數膨脹。系統的可伸縮性和吞吐量都受到了挑戰。因此有必要將現有的基於 JDBC 驅動的阻塞式連線升級為採用 NIO(非阻塞 I/O)方式連線資料庫。

改造後的架構

  • BIO->NIO

想把 Proxy 整體架構從 BIO->NIO,最簡單的方式就是把傳統的 BIO 資料庫驅動 JDBC 換成 NIO 的資料庫驅動,但是在調研過後發現開源的 NIO 驅動並不多,而且基本上沒有什麼最佳實踐。最後在參考 ShardingSphere 社群之前做的調研後(),決定使用 Vertx 來替換 JDBC。最開始使用 Vert.x 的原因,第一是 Vertx 的非同步編碼方式更友好,編碼複雜度相對較低,第二是因為它實現了主流資料庫的驅動。但最終的結果不盡人意,由於 Vertx 相關抽象化的架構,導致鏈路較長時,整個呼叫棧深非常誇張。最終壓測出來的吞吐量提升只有 5% 不到,而且存在很多相容性問題。於是推倒重來,決定自研資料庫驅動和連線池。

  • 跳過不必要的編解碼階段

由於 JDBC 驅動會自動把 MySQL 的位元組資料編解碼成 Java 物件,然後 Proxy 再把這些結果集經過一些加工(元資訊修正、結果集歸併)後再進行編碼返回給上游。如果自研驅動的話,就可以把編解碼流程控制的更細緻一些,把 Proxy 不需要加工的資料直接轉發給上游,跳過無意義的編解碼。後面會介紹一下哪些場景是不需要 Proxy 對結果集進行加工的。

自研NIO資料庫驅動

資料庫驅動主要是封裝了與 DB 層互動協議,封裝成高階 API。下面 2 張圖是 java.sql 包中的 Connection 和 Statement 的一些核心介面。

彩虹橋架構演進之路-效能篇|得物技術彩虹橋架構演進之路-效能篇|得物技術

所以首先我們需要了解一下,如何與資料庫進行資料互動,以 MySQL 為例,使用 Netty 連線 MySQL,簡單的互動流程如下。

彩虹橋架構演進之路-效能篇|得物技術

使用 Netty 與 MySQL 連線建立後,我們要做的就是按照 MySQL 協議規定的資料格式,先鑑權後再傳送具體的命令包即可。下面是 MySQL 官方文件中鑑權流程和命令執行流程:

  • 鑑權流程:https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html

  • 執行命令流程:https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_command_phase.html

下面就是按照 MySQL 的文件,去實現編解碼 Handle,我們簡單看一下實現的程式碼。

彩虹橋架構演進之路-效能篇|得物技術

  • decode 解碼

就是針對 MySQL 返回的資料包解碼,根據長度解析出 Palyload 封裝成 MySQLPacketPayload 傳給對應的 Handle 處理。

  • encode 編碼

把具體的命令類轉換成具體的 MySQL 資料包,這裡的 MySQLPacket 有多個實現類,跟 MySQL的Command 型別一一對應。

現在還需要一個類似 java.sql.Connection 的實現類,來組裝 MySQLPacket 並寫入到 Netty 通道中,並且解析編碼後的 MySQLPacketPayload 轉換成 ResultSet。

彩虹橋架構演進之路-效能篇|得物技術

彩虹橋架構演進之路-效能篇|得物技術

看起來比較簡單,互動流程和傳統的 JDBC 幾乎一樣,但是由於現在是非同步化流程,所有的 Response 都是透過回撥返回,所以這裡有 2 個難點:

  • 由於 MySQL 在上一條命令沒結束前無法接受新的命令,所以如何控制單個連線的命令序列化?

  • 如何將 MySQL 返回的資料包和發起命令的 Request 一一繫結?

首先 NettyDbConnection 引入了一個無鎖化非阻塞佇列 ConcurrentLinkedQueue。

彩虹橋架構演進之路-效能篇|得物技術

在傳送 Command 時,如何沒有正在進行中的 Command,則直接傳送,如果有正在進行中的 Command,直接扔到佇列中,等待上一條 Command 處理完成後推動下一條命令的執行。保證了單個連線命令序列化。

其次,NettyDbConnection 在執行命令時,傳入一個 Promise,在 MySQL 資料包全部返回後,這個 Promise 將會被設定完成,即可於發起命令的 Request 一一繫結。

彩虹橋架構演進之路-效能篇|得物技術

自研NIO資料庫連線池

前面介紹了 NettyDbConnection 這個類,實現了與 MySQL 的互動,並且提供了執行 SQL 的高階 API,但實際使用過程中,不可能每次都建立一個連線執行完 SQL 就關閉。所以需要對 NettyDbConnection 進行池化,統一管理連線的生命週期。其功能類似於傳統連線池 HikariCP,在完成基本能力的基礎上,做了很多效能最佳化。

  • 連線生命週期管控

  • 連線池動態伸縮

  • 完善的監控

  • 連線非同步保活

  • 超時控制

  • EventLoop 親和性

這裡除了 EventLoop 親和性,其他幾個功能只要用過傳統的資料庫連線池應該都比較熟悉,這裡不做過多展開。這裡主要針對 EventLoop 親和性展開介紹一下。

在文章開頭我們說到 Proxy 的三層模組,Frontend、Core、Backend,如果現在我們把 Backend 層於資料庫互動的元件換成了我們自研的驅動,那麼 Proxy 就即是Netty Server,也是Netty Client,所以 Frontend 和 Backend 可以共用一個 EventLoopGroup。為了降低執行緒上下文切換,在單個請求從 Frontend 接收、經過 Core 層計算後轉發到 MySQL ,再到接收 MySQL 服務響應,以及最終的回寫給 Client 端,這一些列操作儘量放在一個 EventLoop 執行緒中處理。

彩虹橋架構演進之路-效能篇|得物技術

具體的做法就是 Backend 在選擇與資料庫連線時,優先選擇與當前 EventLoop 繫結的連線。也就是前面提到的 EventLoop 親和性,這樣就能保證大部分場景下一次請求從頭到尾都由同一個 EventLoop 處理,下面我們看一下具體的程式碼實現。

在 NettyDbConnectionPool 類中使用一個 Map 儲存連線池中的空閒連線,Key 為 EventLoop,Value 為當前 EventLoop 繫結的空閒連線佇列。

彩虹橋架構演進之路-效能篇|得物技術

在獲取時,優先獲取當前 EventLoop 繫結的連線,如果當前 EventLoop 未繫結連線,則會借用其他 EventLoop 的連線。

彩虹橋架構演進之路-效能篇|得物技術

為了提高 EventLoop 命中率,需要注意幾點配置:

  • EventLoop 執行緒數量儘量與 CPU 核心數保持一致。

  • 連線池最大連線數超過 EventLoop 執行緒數越多,EventLoop 命中率越高。

下面放一張壓測環境(8C16G、連線池最大連線數 10~30)的命中率監控,大部分保持在 75% 左右。

彩虹橋架構演進之路-效能篇|得物技術

跳過不必要的編解碼

前面說到,有部分 SQL 的結果集是不需要 Proxy 進行加工的,也就是可以直接把 MySQL 返回的資料流原封不動轉發給上游,直接省去編解碼操作。那什麼 SQL 是不需要 Proxy 進行加工的呢,我們舉個例子說明一下。

假設邏輯庫 A 裡面有一張表 User 做了分庫,分了 2 個庫 DB1 和 DB2,分片演算法是 user_id%2。

  • SQL 1

SELECT id, name FROM user WHERE user_id in (1, 2)
  • SQL 2

SELECT id, name FROM user WHERE user_id in (1)

很顯然 SQL 1由於有 2 個分片 Value,最終匹配到了 2 個節點,SQL 2 只會匹配到 1 個節點。

彩虹橋架構演進之路-效能篇|得物技術

SQL 1 由於需要對結果集進行歸併,所以無法跳過編解碼,SQL 2 不需要對結果集歸併,只需要把結果集中的列定義資料做修正後,真正的 Row 資料無需處理,這種情況就可以把 Row 資料直接轉發至上游。

全鏈路非同步化

Backend 層用自研連線池+驅動替換原先的 HikariCP+JDBC 後,從 Frontend-Core-Backend 全鏈路涉及到阻塞的操作需要全部替換成非同步化編碼,也就是透過 Netty 的 Promise 和 Future 來實現。

彩虹橋架構演進之路-效能篇|得物技術

由於部分場景拿到 Future 時,可能當前 Future 已經完成了,如果每次都是無腦的加 Listener 會讓呼叫棧加長,所以我們定義了一個通用的工具類來處理 Future,即 future.isDone() 時直接執行,反之才會 addListener,最大化降低整個呼叫棧的深度。

彩虹橋架構演進之路-效能篇|得物技術

相容性

除了以上基本程式碼的改造外,還需要做大量的相容工作:

  • 特殊資料庫欄位型別處理

  • JDBC URL 引數相容

  • ThreadLocal 相關資料全部需要遷移至 ChannelHandlerContext 中

  • 日誌 MDC、TraceContext 相關資料傳遞

  • ……

效能表現

經過幾輪效能壓測後,NIO架構相較於BIO架構效能有較大提升:

  • 整體最大吞吐量提升 67%

  • LOAD 下降 37% 左右

  • 高負載情況下 BIO 多次出現程式夯住現象,NIO 相對較穩定

  • 執行緒數減少 98% 左右

總結

NIO 架構的改造工作量相當巨大,中間也經歷了一些曲折,但是最終的結果令人滿意。得益於 ShardingShpere 本身核心層面的高效能加上本次 NIO 改造後,彩虹橋在 DAL 中介軟體效能層面基本上可以算是第一梯隊了。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027824/viewspace-2992785/,如需轉載,請註明出處,否則將追究法律責任。