ShardingSphere 的分佈式數據庫負載均衡架構
Apache ShardingSphere 是一個分佈式數據庫生態系統, 可將任何數據庫轉換爲分佈式數據庫, 併爲其提供數據分片、彈性擴展、加密等功能。在本文中, 我演示瞭如何基於 ShardingSphere 構建分佈式數據庫負載均衡架構, 並探討了引入負載均衡的影響。
架構
ShardingSphere 分佈式數據庫負載均衡架構由兩個產品組成: ShardingSphere-JDBC 和 ShardingSphere-Proxy, 它們可以獨立部署或採用混合架構。以下是混合部署架構:
ShardingSphere-JDBC 負載均衡解決方案
ShardingSphere-JDBC 是一個輕量級 Java 框架, 在 JDBC 層提供額外的服務。ShardingSphere-JDBC 在應用程序執行數據庫操作之前添加計算操作。應用程序進程仍然通過數據庫驅動程序直接連接到數據庫。
因此, 使用 ShardingSphere-JDBC, 用戶不必擔心負載均衡問題, 而是可以專注於如何對應用程序進行負載均衡。
ShardingSphere-Proxy 負載均衡解決方案
ShardingSphere-Proxy 是一個透明的數據庫代理, 通過數據庫協議爲客戶端提供服務。以下是 ShardingSphere-Proxy 作爲獨立部署進程, 並在其上進行負載均衡:
(Wu Weijie, CC BY-SA 4.0)
負載均衡解決方案的關鍵點
ShardingSphere-Proxy 集羣負載均衡的關鍵點在於, 數據庫協議本身被設計爲有狀態的 (連接認證狀態、事務狀態、預備語句等)。
如果在 ShardingSphere-Proxy 之上的負載均衡無法理解數據庫協議, 您唯一的選擇是選擇一個四層負載均衡代理 ShardingSphere-Proxy 集羣。在這種情況下, 特定的代理實例維護客戶端與 ShardingSphere-Proxy 之間的數據庫連接狀態。
由於代理實例維護連接狀態, 四層負載均衡只能實現連接級別的負載均衡。同一數據庫連接的多個請求無法輪詢到多個代理實例。無法實現請求級別的負載均衡。
本文不涉及四層和七層負載均衡的細節。
應用層建議
理論上, 客戶端直接連接到單個 ShardingSphere-Proxy 或通過負載均衡門戶連接到 ShardingSphere-Proxy 集羣之間沒有功能上的差異。但在技術實現和不同負載均衡器的配置方面存在一些差異。
例如, 在直接連接 ShardingSphere-Proxy 且沒有限制數據庫連接會話的總保持時間的情況下, 某些彈性負載均衡 (ELB) 產品在第 4 層有 60 分鐘的最大會話保持時間。如果負載均衡超時導致空閒數據庫連接被關閉, 但客戶端沒有意識到被動 TCP 連接關閉, 應用程序可能會報告錯誤。
因此, 除了在負載均衡層面的考慮外, 您可能還需要考慮客戶端措施, 以避免引入負載均衡的影響。
按需創建連接
如果連接實例是持續創建和使用的, 當執行一小時間隔的定時任務且執行時間較短時, 數據庫連接大部分時間都處於空閒狀態。當客戶端本身不知道連接狀態的變化時, 長時間的空閒會增加連接狀態的不確定性。對於執行間隔較長的場景, 可以考慮按需創建連接並在使用後釋放。
連接池
一般的數據庫連接池具有維護有效連接、拒絕失敗連接等能力。通過連接池管理數據庫連接可以降低自行維護連接的成本。
啓用 TCP KeepAlive
客戶端通常支持 TCP KeepAlive 配置:
-
MySQL Connector/J 支持
autoReconnect
或tcpKeepAlive
, 默認未啓用。 -
PostgreSQL JDBC 驅動支持
tcpKeepAlive
, 默認未啓用。
但是, 啓用 TCP KeepAlive 也有一些侷限性:
-
客戶端可能不支持配置 TCP KeepAlive 或自動重連。
-
客戶端無意進行代碼或配置調整。
-
TCP KeepAlive 依賴於操作系統的實現和配置。
用戶案例
最近, ShardingSphere 社區成員反饋, 他們的 ShardingSphere-Proxy 集羣正在爲公衆提供服務, 並使用上層負載均衡。在此過程中, 他們發現應用程序與 ShardingSphere-Proxy 之間的連接穩定性存在問題。
問題描述
假設用戶的生產環境使用三節點 ShardingSphere-Proxy 集羣, 通過雲供應商的 ELB 爲應用程序提供服務。
(Wu Weijie, CC BY-SA 4.0)
其中一個應用程序是一個常駐進程, 執行定時任務, 每小時執行一次, 任務邏輯中包含數據庫操作。用戶反饋每次定時任務觸發時, 應用程序日誌中都會報告錯誤:
send of 115 bytes failed with errno=104 Connection reset by peer
檢查ShardingSphere-Proxy日誌,沒有發現異常信息。
該問題只發生在每小時執行的定時任務中, 其他應用程序正常訪問 ShardingSphere-Proxy。由於任務邏輯有重試機制, 每次重試後任務都能成功執行, 不影響原有業務。
問題分析
應用程序顯示錯誤的原因很明確 - 客戶端向已關閉的 TCP 連接發送數據。故障排查的目標是確定 TCP 連接被關閉的原因。
如果遇到以下三種情況之一, 我建議在問題發生前後的幾分鐘內, 對應用程序和 ShardingSphere-Proxy 端進行網絡數據包捕獲:
-
問題會按小時重複出現。
-
問題與網絡相關。
-
問題不影響用戶的實時操作。
數據包捕獲現象 1
ShardingSphere-Proxy 每 15 秒接收到來自客戶端的 TCP 連接建立請求。但客戶端在與代理完成三次握手後立即向代理發送 RST。客戶端在收到服務器問候語之後或甚至在代理發送服務器問候語之前, 就向代理發送 RST, 而不做任何響應。
(Wu Weijie, CC BY-SA 4.0)
但是, 在應用程序端的數據包捕獲結果中, 沒有發現與上述行爲相匹配的流量。
經諮詢社區成員的 ELB 文檔, 發現上述網絡交互是該 ELB 實現四層健康檢查機制的方式。因此, 這種現象與本案例的問題無關。
(Wu Weijie, CC BY-SA 4.0)
數據包捕獲現象 2
MySQL 連接在客戶端和 ShardingSphere-Proxy 之間建立, 在 TCP 連接斷開階段, 客戶端向代理發送 RST。
(Wu Weijie, CC BY-SA 4.0)
上述數據包捕獲結果顯示, 客戶端首先向 ShardingSphere-Proxy 發送了 COM_QUIT
命令。客戶端基於以下可能的場景主動斷開了 MySQL 連接:
-
應用程序使用完 MySQL 連接後, 正常關閉了數據庫連接。
-
應用程序的數據庫連接由連接池管理, 連接池執行了對空閒連接的超時或最大生命週期的釋放操作。由於連接是在應用程序端主動關閉的, 理論上不會影響其他業務操作, 除非應用程序邏輯存在問題。
經過多輪數據包分析, 在問題出現前後的幾分鐘內, ShardingSphere-Proxy 都沒有向客戶端發送過 RST。
根據現有信息, 可能是客戶端與 ShardingSphere-Proxy 之間的連接在更早前就斷開了, 但數據包捕獲時間有限, 沒有捕獲到斷開連接的時刻。
由於 ShardingSphere-Proxy 本身沒有主動斷開客戶端連接的邏輯, 因此問題正在客戶端和 ELB 層面進行排查。
客戶端應用程序和 ELB 配置檢查
用戶反饋中包含以下補充信息: 以下是對您提供的內容的中文翻譯:
-
該應用程序的定時任務每小時執行一次, 該應用程序沒有使用數據庫連接池, 而是手動維護和提供數據庫連接供定時任務持續使用。
-
ELB 配置有四個級別的會話保持和 40 分鐘的會話空閒超時。
考慮到定時任務的執行頻率, 我建議用戶將 ELB 會話空閒超時修改爲大於定時任務執行間隔。在用戶將 ELB 超時改爲 66 分鐘後, 連接重置問題就不再發生。
如果用戶在故障排查過程中繼續進行數據包捕獲, 很可能會發現 ELB 在每小時的第 40 分鐘斷開 TCP 連接的流量。
問題結論
客戶端報告了一個 "Connection reset by peer" 的錯誤。
ELB 空閒超時小於定時任務執行間隔。客戶端在 ELB 會話保持超時時間內保持空閒, 導致客戶端與 ShardingSphere-Proxy 之間的連接被 ELB 超時斷開。
客戶端向已被 ELB 關閉的 TCP 連接發送數據, 導致 "Connection reset by peer" 錯誤。
超時模擬實驗
我決定進行一個簡單的實驗來驗證客戶端在負載均衡會話超時後的性能。我在實驗過程中進行了數據包捕獲, 分析網絡流量並觀察負載均衡的行爲。
構建基於 Nginx 的負載均衡 ShardingSphere-Proxy 集羣環境
理論上, 這篇文章可以涵蓋任何四層負載均衡的實現。我選擇了 Nginx。
我將 TCP 會話空閒超時設置爲 1 分鐘, 如下所示:
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
stream {
upstream shardingsphere {
hash $remote_addr consistent;
server proxy0:3307;
server proxy1:3307;
}
server {
listen 3306;
proxy_timeout 1m;
proxy_pass shardingsphere;
}
}
構建 Docker Compose 文件
以下是一個 Docker Compose 文件:
version: "3.9"
services:
nginx:
image: nginx:1.22.0
ports:
- 3306:3306
volumes:
- /path/to/nginx.conf:/etc/nginx/nginx.conf
proxy0:
image: apache/shardingsphere-proxy:5.3.0
hostname: proxy0
ports:
- 3307
proxy1:
image: apache/shardingsphere-proxy:5.3.0
hostname: proxy1
ports:
- 3307
啓動環境
啓動容器:
$ docker compose up -d
[+] Running 4/4
⠿ Network lb_default Created 0.0s
⠿ Container lb-proxy1-1 Started 0.5s
⠿ Container lb-proxy0-1 Started 0.6s
⠿ Container lb-nginx-1 Started
客戶端同一連接的定時任務模擬
首先, 構建一個客戶端延遲 SQL 執行。這裏, 通過 Java 訪問 ShardingSphere-Proxy, 並使用 MySQL Connector/J。
邏輯:
-
建立與 ShardingSphere-Proxy 的連接, 並執行一個查詢。
-
等待 55 秒, 然後執行另一個查詢。
-
等待 65 秒, 然後執行另一個查詢。
public static void main(String[] args) {
try (Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306?useSSL=false", "root", "root"); Statement statement = connection.createStatement()) {
log.info(getProxyVersion(statement));
TimeUnit.SECONDS.sleep(55);
log.info(getProxyVersion(statement));
TimeUnit.SECONDS.sleep(65);
log.info(getProxyVersion(statement));
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
private static String getProxyVersion(Statement statement) throws SQLException {
try (ResultSet resultSet = statement.executeQuery("select version()")) {
if (resultSet.next()) {
return resultSet.getString(1);
}
}
throw new UnsupportedOperationException();
}
預期和客戶端運行結果:
-
客戶端連接到 ShardingSphere-Proxy, 第一個查詢成功。
-
客戶端的第二個查詢成功。
-
客戶端的第三個查詢由於 TCP 連接中斷而出錯, 因爲 Nginx 的空閒超時設置爲一分鐘。
執行結果符合預期。由於編程語言和數據庫驅動程序的差異, 錯誤消息的行爲有所不同, 但根本原因是相同的: 兩個 TCP 連接都已斷開。
日誌如下所示:
15:29:12.734 [main] INFO icu.wwj.hello.jdbc.ConnectToLBProxy - 5.7.22-ShardingSphere-Proxy 5.1.1
15:30:07.745 [main] INFO icu.wwj.hello.jdbc.ConnectToLBProxy - 5.7.22-ShardingSphere-Proxy 5.1.1
15:31:12.764 [main] ERROR icu.wwj.hello.jdbc.ConnectToLBProxy - Communications link failure
The last packet successfully received from the server was 65,016 milliseconds ago. The last packet sent successfully to the server was 65,024 milliseconds ago.
at com.mysql.cj.jdbc.exceptions.SQLError.createCommunicationsException(SQLError.java:174)
at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:64)
at com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1201)
at icu.wwj.hello.jdbc.ConnectToLBProxy.getProxyVersion(ConnectToLBProxy.java:28)
at icu.wwj.hello.jdbc.ConnectToLBProxy.main(ConnectToLBProxy.java:21)
Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure
The last packet successfully received from the server was 65,016 milliseconds ago. The last packet sent successfully to the server was 65,024 milliseconds ago.
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:61)
at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:105)
at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:151)
at com.mysql.cj.exceptions.ExceptionFactory.createCommunicationsException(ExceptionFactory.java:167)
at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:581)
at com.mysql.cj.protocol.a.NativeProtocol.checkErrorMessage(NativeProtocol.java:761)
at com.mysql.cj.protocol.a.NativeProtocol.sendCommand(NativeProtocol.java:700)
at com.mysql.cj.protocol.a.NativeProtocol.sendQueryPacket(NativeProtocol.java:1051)
at com.mysql.cj.protocol.a.NativeProtocol.sendQueryString(NativeProtocol.java:997)
at com.mysql.cj.NativeSession.execSQL(NativeSession.java:663)
at com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1169)
... 2 common frames omitted
Caused by: java.io.EOFException: Can not read response from server. Expected to read 4 bytes, read 0 bytes before connection was unexpectedly lost.
at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:67)
at com.mysql.cj.protocol.a.SimplePacketReader.readHeaderLocal(SimplePacketReader.java:81)
at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:63)
at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:45)
at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:52)
at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:41)
at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:54)
at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:44)
at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:575)
... 8 common frames omitted
數據包捕獲結果分析
數據包捕獲結果顯示, 在連接空閒超時後, Nginx 同時斷開了與客戶端和代理的 TCP 連接。但是, 客戶端並不知道這一點, 所以 Nginx 在發送命令後返回了 RST。
在 Nginx 連接空閒超時後, 與代理的 TCP 斷開連接過程正常完成。代理不知道客戶端使用已斷開的連接發送後續請求。
分析以下數據包捕獲結果:
-
1-44 號數據包是客戶端與 ShardingSphere-Proxy 建立 MySQL 連接的交互過程。
-
45-50 號數據包是客戶端執行的第一個查詢。
-
55-60 號數據包是客戶端在執行第一個查詢 55 秒後執行的第二個查詢。
-
73-77 號數據包是 Nginx 在會話超時後啓動的與客戶端和 ShardingSphere-Proxy 之間的 TCP 連接斷開過程。
-
78-79 號數據包是客戶端在執行第二個查詢 65 秒後執行的第三個查詢, 包括連接重置。
總結
排查斷開連接問題需要檢查 ShardingSphere-Proxy 設置和雲服務提供商的 ELB 強制執行的配置。捕獲數據包有助於瞭解特定事件 (特別是 DST 消息) 發生的時間與空閒時間和超時設置的關係。
上述實現和故障排查場景基於特定的 ShardingSphere-Proxy 部署。有關雲端選項的討論, 請參閱我的後續文章。ShardingSphere on Cloud 爲各種雲服務提供商環境提供了額外的管理選項和配置。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vplNjC1UrW4RMJZZujWBqg