Database·2026年6月17日·12 分鐘閱讀

資料庫連線池調校:我踩過的連線數地雷

L
Louis Wu

Louis Wu,台灣資深後端工程師,主力 Go,做過交易所撮合、金流與高併發系統。

從一次搶購事故說起

那是一個很普通的週四晚上八點。我們在做一檔限量商品的搶購活動,行銷估計大概會有幾萬人同時湧進來。後端服務早就壓測過了,DB 也升級到更高規格,我心想應該穩。

結果開賣後不到三十秒,監控就開始噴錯。不是 DB 掛掉,也不是 CPU 燒滿,而是大量的請求卡在一個我當下沒立刻反應過來的地方:等待資料庫連線。應用程式的 log 裡塞滿了「connection pool exhausted」「context deadline exceeded」,使用者那端則是轉圈圈轉到逾時,下單失敗。

最諷刺的是,那台 PostgreSQL 的 CPU 使用率只有百分之三十幾,IO 也很閒。資料庫根本沒在忙,忙的是我們的連線池——它被打爆了,後面排隊的請求全都拿不到連線,活活餓死。

那次事故讓我重新認真看待連線池這件事。它平常安安靜靜的,你幾乎不會想到它的存在,但它就是那種「平常不出事,一出事就是大事」的元件。這篇文章我想把這幾年踩過的連線數地雷整理出來,包含為什麼需要連線池、為什麼連線不是越多越好、池大小到底怎麼抓、連線的生命週期管理、洩漏怎麼揪,以及在金流和高併發場景下我學到的取捨。內容會以 Go 的 database/sql 為主,但觀念在哪個語言都通。

為什麼一定要連線池

要談調校,得先理解連線池在解決什麼問題。

建立一條資料庫連線從來都不便宜。以 PostgreSQL 為例,每一條新連線在伺服器端都是一個獨立的 process(不是 thread,是 process),fork 出來、做 TCP 握手、如果有 TLS 還要做加密交握、然後做身分驗證、初始化 session 狀態。這整套流程跑下來,動輒幾毫秒到幾十毫秒。對一個要扛每秒上萬請求的服務來說,如果每個請求都現開一條連線、用完就關,光是建連線的成本就會把你壓垮。

連線池的核心想法很簡單:連線建好之後不要關,放進一個池子裡重複利用。請求來了就跟池子借一條,用完還回去,下一個請求再借同一條。這樣建連線的成本只在啟動或擴容時付一次,後面都是攤平的。

在 Go 裡面,database/sql 的 sql.DB 物件本身就是一個連線池,這點很多人一開始會誤會。sql.DB 不是「一條連線」,它是「一個連線池的把手」。所以你應該在程式啟動時開一個 sql.DB,整個服務共用,而不是每次查詢都 sql.Open 一個。我看過有人把 sql.Open 包在 handler 裡面每次呼叫,結果池子完全沒發揮作用,這是很常見的新手陷阱。

連線不是越多越好

這是整篇文章最重要、也最反直覺的一點:連線池並不是開越大越好。

很多人的第一直覺是,既然請求卡在等連線,那我把連線數開大一點不就解決了嗎?把 100 改成 500,再不行改成 1000。我自己早年也是這樣想的,直到被現實教訓。

問題出在 DB 端。每一條連線對資料庫來說都是有成本的,而且這個成本不是線性的。連線一多,會發生幾件事:

記憶體被吃光。 PostgreSQL 每條連線的 process 都要吃 work_mem、維護自己的快取、保留各種緩衝區。幾百條連線下去,光是連線本身就能吃掉好幾 GB 的記憶體。如果再加上複雜查詢需要的排序、雜湊空間,記憶體很快就見底,接著就是 OOM 或是 swap,整台機器開始抖。

鎖跟資源的競爭變激烈。 連線越多,同時想動同一筆資料、同一張表、同一個索引的機率就越高。鎖等待、buffer 競爭、WAL 的寫入競爭全部都會放大。你以為開更多連線能做更多事,實際上大家都在互相卡。

context switch 的開銷。 資料庫所在的機器 CPU 核心數是固定的。假設你只有 16 核,卻同時有 500 條連線都想執行查詢,作業系統就得不斷在這些 process 之間切換。context switch 本身就有成本,切換太頻繁的時候,CPU 真正花在做事上的時間反而變少,吞吐量不升反降。

這裡有個我很喜歡引用的觀察,來自 PostgreSQL 社群的經驗法則:在很多情況下,一個連線數較少、但每條連線都能跑滿的池子,整體吞吐量會比一個連線數很多、但每條都在互相等待的池子還要高。也就是說,把連線數從 500 降到 50,延遲跟吞吐量反而都改善了。這聽起來很違反直覺,但背後的道理就是上面講的:資料庫能真正並行處理的工作量,是被 CPU 跟磁碟這些實體資源限制住的,連線開再多也變不出更多核心。

我自己的心法是:連線池的大小應該對應到資料庫能有效並行處理的工作量,而不是對應到你期望的請求量。請求量靠排隊去吸收,不是靠無限開連線去硬扛。

池大小到底怎麼抓

那實際上池子要開多大?這沒有一個放諸四海皆準的數字,但有幾個維度要一起考慮。

第一,DB 的最大連線數是硬上限。 PostgreSQL 有個 max_connections 參數,超過這個數,新連線直接被拒。這個數字必須在你所有應用實例的連線總和之上,而且要留餘裕給維運用途——你總要留幾條給監控、給備份工具、給你自己半夜上去救火用的 psql。我看過有人把應用的連線池總數設到剛好等於 max_connections,結果某天連線稍微多一點,連 DBA 想連進去看狀況都連不上,只能重啟,場面非常難看。

第二,要從應用實例數回推。 這是最容易出包的地方。假設你的服務跑了 10 個 pod,每個 pod 的連線池設 maxOpen 為 50,那對 DB 來說,最壞情況是 10 乘以 50 等於 500 條連線。你在單一 pod 看連線池設定覺得很合理,但乘上實例數之後,DB 端看到的是完全不同的數字。每次擴容(autoscaling 把 pod 從 10 變成 30),連線總數也跟著三倍跳,這是搶購時很容易爆掉的隱藏因素——流量上來觸發自動擴容,連線數瞬間暴增,反而先把 DB 打死。

第三,CPU 核心數是參考基準。 業界有個流傳很廣的起手式公式,把連線數抓在「核心數的兩到四倍」附近,對純粹被 CPU 限制的工作負載大致成立。但這只是起點,不是真理。如果你的查詢大量等 IO(等磁碟、等網路),那連線可以比核心數多一些,因為 IO 等待的時候 CPU 是閒的,可以讓別的連線插隊用。重點是你要實測,看延遲跟吞吐量在哪個連線數開始反轉。

我實際在抓的時候,流程大概是這樣:先估算單一資料庫實例上,所有應用實例加起來的連線總數不能超過 max_connections 的七成左右(留餘裕)。然後在這個天花板下,根據實例數平均分配給每個 pod。再用壓測去微調——慢慢往上加連線,看到吞吐量不再上升、延遲開始惡化,那個轉折點往前一點就是合理區間。不要設在轉折點上,要留一點 buffer。

舉個金流場景的具體數字感。我們有個處理交易入帳的服務,DB 是單一主庫加幾個讀庫,主庫 max_connections 設 200。寫入的服務有 6 個實例,我給每個實例的 maxOpen 設 20,總共最多 120 條打到主庫,留 80 條給讀寫分離以外的雜項跟維運。實測下來,這個服務的瓶頸根本不在連線數,而在交易本身的鎖競爭——所以再開大連線池只會讓鎖等待更嚴重,完全沒有意義。這也呼應前面講的:先搞清楚你的瓶頸在哪,再決定要不要動連線數。

最大連線數 vs 最小空閒連線

Go 的 database/sql 提供幾個關鍵旋鈕,我一個一個講我的理解跟踩過的坑。

SetMaxOpenConns,最大開啟連線數。 這是池子能同時持有的連線數上限,包含正在用的跟閒置的。這是最重要的一個參數,前面講的所有抓法都是在抓這個值。如果不設,預設是無限制——這非常危險,等於你把 DB 的死活完全交給流量決定,搶購一來連線無限增長直接打爆 DB。我的原則是這個值一定要明確設定,絕對不要用預設的無限。

SetMaxIdleConns,最大空閒連線數。 這是池子裡可以保留多少條閒置不關的連線。設太小會有個隱性問題:流量有起伏的時候,連線一閒下來就被關掉,等下一波流量來又要重開,你會付出反覆建連線的成本,而且這個成本藏在尾延遲裡很難察覺。一般的建議是把 MaxIdleConns 設成跟 MaxOpenConns 一樣或接近,讓池子在低流量時也願意保留連線,避免無謂的開關。注意這個值如果大於 MaxOpenConns,Go 會自動把它壓到跟 MaxOpenConns 一樣,不會幫你開超過。

這兩個參數的關係我這樣理解:MaxOpen 是天花板,決定你最多能借出去幾條;MaxIdle 是地板的暖機區,決定你願意養著幾條隨時待命。MaxOpen 控制的是對 DB 的壓力上限,MaxIdle 控制的是面對流量波動的反應速度。兩個一起設才完整,只設一個常常會在某種流量型態下出問題。

連線的生命週期:maxLifetime 為什麼重要

這是一個很多人會忽略、但在生產環境會反咬你一口的設定。

連線不是建好就永遠健康。一條連線在池子裡放久了,可能會在你不知情的狀況下變成死連線。常見的原因有幾個:

DB 端主動關閉。 資料庫可能設了 idle session timeout,閒置太久的連線被伺服器端砍掉。你的池子卻還以為這條連線活著,下次借出去一用,才發現對面早就斷了。

中間件偷偷斷線。 雲環境裡,應用跟 DB 之間常常隔著 NLB、雲的網路閘道、或是 PgBouncer 這種代理。這些中間件很多都有自己的 idle timeout,連線閒太久就被它默默回收。你的應用端完全沒收到通知,連線在池子裡看起來好端端的,實際上已經是條死魚。

故障切換之後的殘留連線。 主從切換、DB 重啟、做了某些變更之後,舊的連線可能還掛在池子裡,但它指向的已經是一個不對的狀態。

這些死連線的可怕之處在於,它在池子裡看起來是正常的,直到某個倒楣的請求借到它,才會收到一個莫名其妙的錯誤——connection reset、broken pipe、EOF 之類的。在金流場景這特別致命,因為你不知道那筆交易到底有沒有送到 DB、有沒有執行成功,對帳的時候會非常頭痛。

解法就是 SetConnMaxLifetime,設定一條連線的最長存活時間。超過這個時間的連線,池子會主動淘汰掉、重新建立,而不是繼續用。這等於是強制定期換血,把那些可能已經死掉或快死掉的連線在它害人之前先清掉。我通常會把 maxLifetime 設得比所有中間件的 idle timeout 都短,這樣連線總是在被外部砍掉之前就自己先退休了。

Go 後來還加了 SetConnMaxIdleTime,針對「閒置」多久就回收,跟 maxLifetime 互補。maxLifetime 是不管你用不用,活到時間就退;maxIdleTime 是針對閒太久的特別處理。兩個搭配可以讓池子在高峰時保有足夠連線,離峰時又能優雅地縮回去。

要提醒一點,maxLifetime 不要設太短。如果設得太激進,連線一直在建了又關、關了又建,反而把你想用連線池避免的建連線成本又給請回來了。我的習慣是抓在幾分鐘到十幾分鐘這個量級,具體看中間件的 timeout 怎麼設,原則是比它短就好,不需要短到誇張。

連線洩漏:借了沒還的慢性失血

連線洩漏是另一種我很怕的問題,因為它不是「砰」一聲爆掉,而是慢慢失血,等你發現的時候通常已經很嚴重了。

所謂洩漏,就是從池子借了連線出來,但因為某些原因沒有還回去。在 Go 裡,最典型的兇手是查詢結果集 rows 沒有關。當你做一個會回傳多筆資料的查詢,Go 會從池子借一條連線給這個結果集用,這條連線會一直被佔著,直到你把結果集讀完或是明確關掉。如果你中途因為某個錯誤提早 return,卻忘了關結果集,那條連線就回不來了——它被那個再也不會被讀取的結果集綁架,永遠掛在那。

每洩漏一次,池子裡可用的連線就少一條。一開始你完全沒感覺,因為池子還很寬裕。但這是會累積的,跑著跑著,可用連線越來越少,直到某一刻池子裡的連線全被洩漏掉的請求佔光,新的請求一個都借不到,整個服務就僵在等連線。最陰險的是,這種問題往往要跑好幾個小時甚至幾天才會浮現,重啟之後又好了,讓人摸不著頭緒。

在 Go 裡防洩漏的關鍵習慣,是每次拿到結果集就立刻安排關閉,用 defer 把關閉動作綁在當前函數結束時執行,這樣不管是正常結束還是中途出錯 return,連線都會被歸還。另外,檢查迭代過程中有沒有發生錯誤也很重要,因為迭代被錯誤中斷的時候同樣需要正確收尾。對於只回傳單筆結果的查詢,Go 的 API 設計上會自動處理連線歸還,這類就不太會洩漏,要特別小心的主要是會回傳多筆的查詢。

怎麼抓洩漏?我的做法是盯著連線池的統計數據。database/sql 的 sql.DB 有一個 Stats 方法,會吐出當前的池子狀態:開了幾條、用了幾條、有幾條閒置、有多少請求正在等待、等待的累計次數跟累計時間。我會把這些指標定期吐到監控系統,畫成圖。

判斷洩漏的訊號很明確:如果 InUse(使用中的連線數)長期居高不下,即使在流量很低的離峰時段也降不下來,那八成就是洩漏。健康的服務,InUse 應該跟著流量起伏,流量低的時候閒置連線多、使用中連線少。如果你看到使用中連線只進不出、像棘輪一樣只增不減,那就是有地方借了沒還。WaitCount 跟 WaitDuration 持續上升也是危險訊號,代表常常有請求借不到連線在乾等。

我建議任何上線的服務都要把這幾個連線池指標接進監控,這是成本很低但回報很高的投資。那次搶購事故之後,我做的第一件事就是把連線池的等待數做成警報——只要排隊等連線的請求數超過某個閾值,立刻告警。這讓我們後來幾次在連線池真正爆掉之前就先收到預警。

等待逾時:別讓請求無止境地排隊

當連線池裡的連線全被借光,新來的請求會怎樣?答案是排隊等。等到有人還連線回來,或者等到逾時。

這裡有個很重要的設計選擇:請求該等多久?在 Go 裡,這件事是透過 context 來控制的。你執行查詢的時候帶一個有 timeout 或 deadline 的 context,如果在這個時間內借不到連線,或查詢本身跑太久,這個請求就會被取消,回傳一個逾時錯誤,而不是傻傻地等到天荒地老。

為什麼這很重要?因為在高併發下,無止境的等待會引發雪崩。想像連線池滿了,請求開始排隊,如果每個請求都願意無限等下去,那隊伍會越排越長,記憶體被這些 pending 請求吃光,上游的服務也因為等不到回應而開始堆積,最後整條鏈路一起垮。這就是所謂的串連故障。

設了合理的逾時之後,至少你的失敗是「快速失敗」。請求借不到連線,等個一兩秒就放棄,回一個明確的錯誤給上游,上游可以決定要不要重試、要不要降級。這比讓請求卡在那邊半分鐘、最後 context 才超時、使用者早就關掉頁面了要好得多。

那次搶購事故的一個關鍵問題,就是我們當時的逾時設得太寬鬆。請求借不到連線會在那邊等很久,導致大量請求堆積,而堆積本身又加重了系統負擔,形成惡性循環。事後檢討,我們把借連線的等待逾時收得更緊,並且讓借不到連線的請求快速失敗、直接回一個「系統忙碌中,請稍後再試」的明確訊息給前端,而不是讓使用者乾等。對搶購這種場景,快速失敗加上明確提示,使用者體驗反而比讓他轉圈圈轉到逾時要好。

還有一個搭配的觀念是上游的限流。連線池其實是一道天然的閘門——它的大小就限制了同時能打到 DB 的請求數。但光靠連線池擋在最後一關太被動了,比較好的做法是在更前面就做限流,在請求還沒走到 DB 之前就先擋掉一部分,讓真正能服務的請求順利完成,而不是讓所有請求都擠到連線池前面排隊。連線池是最後的安全網,不該是第一道防線。

高併發場景的真實取捨

回到開頭那次搶購事故,我想完整講一下我們後來怎麼處理,因為這裡面的取捨很有代表性。

事故當下的直接現象是連線池被打爆、請求大量逾時。第一時間的止血是降流量——前端先擋掉一部分請求,讓系統喘口氣。但這只是急救,根本問題還是要解。

事後我們做了幾件事。第一,重新校準連線池大小,並且把所有應用實例的連線總數跟 DB 的 max_connections 對齊算清楚,確保就算 autoscaling 把實例數拉滿,連線總數也不會超過 DB 能承受的範圍。這件事看起來基本,但很多團隊就是栽在「單一實例看起來很合理、乘上實例數就爆了」這個盲點上。

第二,我們意識到搶購這種瞬間尖峰,本質上不該讓所有流量都直接打到 DB。後來改成把搶購的請求先進一個佇列,用一個流速可控的 worker 去消化,DB 只承受佇列消化的速度,而不是承受瞬間湧入的全部流量。這等於是用佇列把尖峰削平,DB 看到的是一條平緩的線而不是一根尖刺。連線池在這個架構下就舒服多了,因為它要服務的併發量是可控的。

第三,把該放快取的放快取。搶購頁面的商品資訊、庫存的讀取,這些高頻讀取沒必要每次都打 DB。庫存扣減這種需要強一致的操作才走 DB,其他能擋在快取的盡量擋。打到 DB 的請求少了,連線池的壓力自然就下來了。

這裡的核心取捨是:連線池調校能解決的是「在 DB 容量範圍內,怎麼把連線用得最有效率」,但它解決不了「流量本質上超過 DB 容量」這個問題。當你的瞬間流量遠超過 DB 能處理的量,再怎麼調連線池都只是讓你死得慢一點。真正的解法是在架構層面削峰、快取、限流,讓真正打到 DB 的負載落在它能舒服處理的範圍內。連線池調校是必要的,但它是配角,不是主角。

PgBouncer:連線池中介這一層

當你的應用實例越來越多,會撞到一個結構性的問題:每個實例都維護自己的連線池,這些池子彼此不知道對方的存在,加總起來很容易超過 DB 的負荷。前面講的「實例數乘以單實例連線數」就是這個困境。實例一多,你給每個實例分到的連線就被迫壓得很小,小到單一實例在自己流量尖峰時不夠用。

這時候 PgBouncer 這類連線池中介就派上用場了。它的概念是在應用跟 DB 中間加一層獨立的連線池代理。所有應用實例不直接連 DB,而是連到 PgBouncer,由 PgBouncer 維護一個對 DB 的小而精的連線池。應用端可以開很多連線到 PgBouncer(因為 PgBouncer 的連線很輕量),但 PgBouncer 對真正的 DB 只維持少量連線,把成千上萬的應用連線收斂成幾十條對 DB 的連線。

PgBouncer 有幾種池化模式,最常用、也最省連線的是 transaction pooling(交易層級池化)。在這個模式下,一條 DB 連線只在一個交易進行期間被某個 client 佔用,交易一結束就立刻釋放回去給別的 client 用。這樣連線的複用率非常高,少少幾條 DB 連線就能服務大量 client。但代價是有些依賴 session 狀態的功能會失效——像是 prepared statement、session 層級的變數設定、advisory lock 這些跨交易的東西,在 transaction pooling 下會出問題,因為你下一個交易很可能落在另一條 DB 連線上。用之前一定要確認你的應用沒有依賴這些 session 狀態,或是針對性地處理。

我自己的經驗是,當應用實例數成長到一定規模、或是你發現連線數怎麼分配都很彆扭的時候,引入 PgBouncer 是個很值得的投資。它讓連線管理從「每個實例各自為政」變成「集中收斂」,DB 端看到的連線數穩定可控,不會再隨著實例數線性暴增。金流系統因為對穩定性要求高、實例數又不少,我們最後是有上這一層的。當然它也增加了一個需要維運的元件、多一跳網路延遲,這些都是要權衡的成本。沒有銀彈,只有取捨。

收尾:我的連線池檢查清單

寫到這裡,把我這幾年沉澱下來的原則整理成一份清單,給要上線服務的你參考:

  • 一定要明確設定 MaxOpenConns,永遠不要用無限的預設值。 這是保護 DB 的第一道閘門。
  • 從 DB 的 max_connections 反推,算清楚所有實例連線總和,留足維運餘裕。 別忘了 autoscaling 會放大這個數字。
  • 連線數的瓶頸常常不在連線數本身,而在 DB 的實體資源跟鎖競爭。 先搞清楚瓶頸在哪,別盲目開大池子。
  • 設定 maxLifetime,而且要比所有中間件的 idle timeout 短, 避免拿到死連線。
  • 結果集一定要記得關,用 defer 綁好, 防止連線洩漏這種慢性失血。
  • 把連線池的 Stats 指標接進監控, 盯住使用中連線數跟等待數,洩漏跟打爆都看得出來。
  • 設合理的等待逾時,讓借不到連線的請求快速失敗, 別讓無止境排隊引發雪崩。
  • 連線池是最後的安全網,不是第一道防線。 真正的削峰要靠架構層的佇列、快取、限流。
  • 實例數大到一定程度,認真考慮 PgBouncer, 把連線收斂集中管理。

連線池這東西,調好的時候你完全感覺不到它的存在,調壞的時候它會在你最不希望的時刻——比如搶購的尖峰、月底的對帳高峰——給你致命一擊。它不是什麼高深的技術,但它需要你對 DB 的成本模型、對自己服務的流量型態有實際的理解。希望我這些用事故換來的經驗,能讓你少踩幾個坑。

#資料庫#連線池#PostgreSQL#Go#高併發#效能調校

相關文章