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

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

   一 前言

  一年前的《彩虹橋架構演進之路》側重探討了穩定性和功能性兩個方向。在過去一年中,儘管業務需求不斷增長且流量激增了數倍,彩虹橋仍保持著零故障的一個狀態,算是不錯的階段性成果。而這次的架構演進,主要分享一下近期針對效能層面做的一些架構調整和最佳化。其中最大的調整就是 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 中介軟體效能層面基本上可以算是第一梯隊了。

來自 “ 得物技術 ”, 原文作者:新一;原文連結:https://server.it168.com/a2023/1103/6827/000006827913.shtml,如有侵權,請聯絡管理員刪除。

相關文章