先講一個幾乎每個後端工程師都做過的錯誤決策:
線上系統變慢,監控顯示大量請求卡在「等待資料庫連線」
→ 直覺反應:連線池不夠大,從 20 調到 100
→ 結果:更慢了,而且資料庫的 CPU 飆到 100%
這不是意外,是可以預測的結果。連線池的大小和系統吞吐量之間,不是「越大越好」的線性關係—— 它有一個最佳點,超過那個點之後,加得越多、跑得越慢。
這一章要處理的是後端最常見、卻最少被真正理解的一層資源管理:
maxLifetime 為什麼一定要設,以及那些只在半夜出現的 Communications link failure。max_connections——這三個數字算錯,系統會在你最不想要的地方崩掉。這一章會多次回頭用到前兩章:慢 SQL(ch01)和長事務(ch02)是「連線不夠用」最常見的兩個真正原因——而它們都不是靠調大池能解決的。
連線池存在的理由只有一個:建立連線太貴,貴到不能每次查詢都做一次。先看貴在哪。
它同時佔用三邊的資源:
先建好一批連線放著,用完不關、還回池裡,下次直接拿。把「幾十毫秒的建立成本」攤平成「幾乎為零的借還成本」。
直覺是這樣的:連線越多 → 能同時處理的請求越多 → 吞吐越高。這個直覺在超過某個點之後就完全錯了。
連線只是一條通道。資料庫實際能同時執行的工作量,受限於:
8 核的資料庫,100 個連線同時送查詢進來
→ 100 個 thread 搶 8 顆 CPU
→ 大量 context switch(每次切換都要換快取、換 TLB)
→ CPU 有相當比例花在「切換」而不是「幹活」
→ 鎖競爭同步放大:等鎖的人變多,持鎖時間也被拉長
結果:吞吐量下降,而且每個請求的延遲都變長連線數 = (CPU 核心數 × 2) + 有效磁碟數一台 8 核、用 SSD(視為 1)的資料庫,算出來是 17。這個數字通常讓人很不安——「才 17 個連線怎麼撐得住幾百 QPS?」
一個請求只在真正執行 SQL 的那幾毫秒持有連線,其餘時間(處理 HTTP、跑業務邏輯、序列化 JSON)都不需要連線。
排隊不是壞事。在池外面排 2ms,比擠進去讓所有人都慢 80ms 好得多——這是連線池設計的核心哲學。
Spring Boot 從 2.0 起預設用 HikariCP。它的參數不多,但每一個都對應一個具體問題。
spring.datasource.hikari.maximum-pool-size=10池裡最多幾個連線。這是整個系統真正的資料庫並行上限,不是 Tomcat 的執行緒數。預設 10——不要因為「看起來太小」就調大,先看下一張卡的算法。
閒置時至少留幾個連線不關掉,避免流量突然回來時要重新建立。HikariCP 官方建議不要設——不設的話它會等於 maximumPoolSize,變成固定大小的池。固定大小反而更好,因為「動態伸縮」在尖峰時剛好會讓你付出建立連線的成本。
spring.datasource.hikari.connection-timeout=3000 # 毫秒,預設 30000池滿了、所有連線都被借走時,新的請求要等多久才放棄並丟 SQLTransientConnectionException。
spring.datasource.hikari.max-lifetime=1500000 # 毫秒,預設 1800000(30 分)一個連線活過這麼久就會被主動汰換掉(在閒置時,不會打斷使用中的連線)。這個參數的重要性遠超它的名氣——下一張卡專門講它。
閒置多久就關掉多餘的連線。只有在 minimumIdle 小於 maximumPoolSize 時才有作用;照上面的建議不設 minimumIdle 的話,這個參數等於不存在。
這是連線池最經典的靈異現象,而且它的成因完全不靈異。
Communications link failure、Connection reset、The last packet successfully received from the server was N milliseconds ago連線閒置太久時,路徑上有三個角色可能主動把它切掉:
wait_timeout(預設 8 小時,但雲端服務常改成 5~30 分鐘)maxLifetime < min(資料庫 wait_timeout, 防火牆 idle timeout, LB idle timeout)wait_timeout 是 600 秒 → maxLifetime 設 570000(570 秒)SHOW VARIABLES LIKE 'wait_timeout',以及去問網路/雲端設定connectionTestQuery(例如 SELECT 1)會讓每次借用都多一次來回,而且現代 JDBC 驅動支援 Connection.isValid(),HikariCP 預設就會用。正確的做法是主動汰換,不是每次檢查。SQLTransientConnectionException: Connection is not available, request timed out after 30000ms// ❌ 例外路徑漏掉 close
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ps.execute(); // 這裡丟例外的話,下面兩行永遠不會執行
ps.close();
conn.close();
// ✅ try-with-resources
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.execute();
}用 Spring 的 @Transactional、JdbcTemplate、JPA Repository 幾乎不會洩漏——框架保證會還。會漏的幾乎都是手寫 dataSource.getConnection() 的地方。
spring.datasource.hikari.leak-detection-threshold=20000 # 毫秒連線被借走超過這個時間還沒還,HikariCP 會印出當初借走它的那段 stack trace——直接指到兇手那一行。正式環境可以長期開著(20~60 秒),成本很低。
@Transactional 裡呼叫第三方 API,交易多久連線就被佔多久| 觀察 | 真洩漏 | 還得太慢 |
|---|---|---|
| active 連線數 | 單調上升,永不回落 | 尖峰時衝高,離峰會回落 |
| 重啟後 | 從零開始再慢慢漲 | 流量一來馬上又滿 |
| leak detection | 會印出 stack trace | 可能也會印,但指向的是慢查詢 |
一個請求要走完全程,會依序經過三個有上限的關卡。大多數線上事故都是因為這三個數字沒有對齊。
HTTP 請求
↓
① Tomcat 執行緒池 server.tomcat.threads.max 預設 200
↓
② HikariCP 連線池 maximum-pool-size 預設 10
↓
③ 資料庫連線上限 max_connections MySQL 預設 151200 條 Tomcat 執行緒配 10 個連線,代表最多 190 條執行緒會同時在等連線。很多人看到這個數字會嚇到,覺得應該把池調大。
但這是正常且正確的設計。執行緒在等連線的時候幾乎不耗 CPU;讓它們排隊,總比全部擠進資料庫互相拖累好(回到第二張卡的推導)。
max_connections 是整個資料庫的總額,不是每個應用實例的額度。maximum-pool-size = 50,Kubernetes 上跑 4 個 Pod:max_connections 只有 151。Too many connections——而且擴容本來是為了救火,結果反而把資料庫打死。單一實例池大小 × 最大實例數 + 維運保留額 ≤ max_connections
例:max_connections = 151
保留 20 給 DBA 連線、監控、備份工具
最大實例數 = 6(含滾動更新時的新舊並存)
→ 單一實例池大小 ≤ (151 - 20) / 6 ≈ 21hikaricp_connections_active——正在被借走的數量hikaricp_connections_pending——正在排隊等連線的執行緒數,這個持續大於 0 才是真的要調整hikaricp_connections_acquire_seconds——拿到連線花的時間不要用「感覺」設池大小,也不要直接抄別人的設定。有兩個可以實際算的方法。
需要的連線數 = 目標 TPS × 每個請求持有連線的平均時間關鍵是第二項——不是整個請求的耗時,只有真正在跑 SQL 的那一段。
假設:目標 500 TPS,每個請求平均跑 3 句 SQL、
每句 5ms → 持有連線約 15ms
500 × 0.015 = 7.5 → 取 10(留一點餘裕)連線數 = (CPU 核心數 × 2) + 有效磁碟數兩個算法取比較小的那個——因為兩邊都是上限,較小的那個才是真正的瓶頸。
hikaricp_connections_pending如果同一個服務同時有「線上請求」和「後台批次」,應該用兩個 DataSource:
connectionTimeout 短,快速失敗否則一個跑十分鐘的報表就能把線上請求的連線全部吃光。
連線池是一個很好用的調節閥,但它只能分配資料庫的處理能力,不能增加它。以下四種情況,動池是在治標。
一句沒走索引的查詢跑 5 秒(ch01),連線就被佔 5 秒。此時加大池只是讓更多請求同時慢,還會把資料庫 CPU 推更高。先去 EXPLAIN,不要先調池。
@Transactional 包住第三方 API 呼叫(ch02),交易開多久連線就被佔多久。這種情況下池永遠不夠大——對方慢一點你就再爆一次。該修的是交易範圍。
Serverless、大量短生命週期的容器、每次擴容就多一組池——每個實例各自維護一個池的模型會直接壓垮資料庫。這時需要的是外部連線池:
該做的是讀寫分離(讀走 replica),配兩組 DataSource,而不是把單一池撐大。
如果你的系統在池滿的時候優雅地排隊、快速失敗、然後自動恢復,那個池的大小很可能是對的。池的作用本來就包含「保護資料庫不被打死」——它擋住請求不是故障,是它正在做它的工作。
| 參數 | 預設值 | 解決什麼問題 | 設錯的後果 |
|---|---|---|---|
| maximumPoolSize | 10 | 資料庫並行上限(真正的上限在這,不是 Tomcat 執行緒數) | 太大 → 資料庫 context switch 與鎖競爭暴增,吞吐反而下降 |
| minimumIdle | = maximumPoolSize | 閒置時保留多少連線待命 | 官方建議不要設;動態伸縮會讓你在尖峰時付出建立連線的成本 |
| connectionTimeout | 30000(30 秒) | 池滿了要等多久才放棄 | 預設太長:資料庫卡住時所有執行緒堆積,整個服務一起死。建議 2~5 秒 |
| maxLifetime | 1800000(30 分) | 主動汰換連線,避開資料庫/防火牆/LB 的 idle timeout | 沒設或設太長 → 半夜的 Communications link failure |
| leakDetectionThreshold | 0(關閉) | 連線借走太久時印出借走它的 stack trace | 不開就查不到洩漏源頭;建議正式環境長期開 20~60 秒 |
| 徵狀 | 最可能的原因 | 怎麼確認 |
|---|---|---|
| 半夜/低流量時偶發 Communications link failure | 連線被防火牆或資料庫靜默切斷,池不知道 | 比對 maxLifetime 與 wait_timeout/防火牆 idle timeout,看是不是 maxLifetime 比較長 |
| 跑幾天後所有請求卡住,重啟就好 | 連線洩漏(借了沒還) | 看 active 連線數是否單調上升不回落;開 leakDetectionThreshold 抓 stack trace |
| 尖峰時大量等待,離峰恢復正常 | 不是洩漏,是「還得太慢」——慢 SQL 或長事務 | 看慢查詢日誌、看 innodb_trx 的 trx_started;先修 SQL 與交易邊界 |
| 調大池之後更慢,資料庫 CPU 飆高 | 已超過資料庫最佳並行度,多出來的連線在互相拖累 | 看 TPS 有沒有跟著上升;沒上升但 P99 變長就是過頭了,退回去 |
| 擴容或滾動更新時 Too many connections | 池大小 × 實例數超過資料庫 max_connections | 算 池大小 × 更新期間峰值實例數 + 維運保留額,跟 max_connections 比 |
| 層級 | 參數 | 常見預設 | 算錯的後果 |
|---|---|---|---|
| ① Tomcat 執行緒池 | server.tomcat.threads.max | 200 | 太小 → 請求連排隊的機會都沒有;太大 → 記憶體浪費,但不影響資料庫 |
| ② 連線池 | maximum-pool-size | 10 | 這才是真正的資料庫並行上限;大量執行緒在此排隊是正常設計 |
| ③ 資料庫連線上限 | max_connections | MySQL 151 | 全域總額,要除以實例數;忘記算滾動更新的並存 Pod 是最常見的錯 |
點擊卡片翻面查看答案,共 14 張。