iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

「藥瓶握在手裡不算用過,連線借走不還,整座井就乾了。」
——《阿帕契開源審計錄》¹ 卷二·借還篇

幕間
終局三人對峙:七號、二號與獵人。
二號先聲奪人,字句精雕細琢宛如 AI 潤飾:自恃銀水身分,反咬獵人跳身分太遲。
七號沒有動怒反駁,只是靜靜將三十天的羊皮紙在長桌上鋪展開來。

城堡的鐘敲了六下,法官宣布:「昨夜平安夜,無人死亡。」

長桌邊有人鬆了口氣,也有人皺眉——平安夜代表女巫動了解藥,那瓶救命的藥用掉了,場上唯一的容錯額度又少了一格。過去每一世,七號都會追問「妳把解藥給了誰」。這一次她沒有。她攤開羊皮紙,炭筆停在半空,問了一個沒有人問過的問題:「妳昨晚,為什麼沒有順手毒掉最可疑的那個人?」

全場愣住。女巫張了張嘴,說不出話。

七號在紙上寫下一行字:一瓶毒藥握在手裡整整一夜,沒有用出去,也沒有還回場上——它就這樣憑空消失了,卻沒有換到任何一條情報。坐在我斜對面的二號慢慢點頭:「七號說得有道理,那瓶毒藥確實……」他從不第一個說出該殺誰,總是等別人開口,再補上半句附議。

我盯著自己的手牌。女巫的兩瓶藥,是這局唯一的緩衝。握著不用,跟用錯了,在屠邊規則下其實一樣糟——因為那份資源在你手上閒置的每一分鐘,全場都在替你承擔風險。這個道理,我在資料庫的連線池上見過一模一樣的版本。


連線池:那幾瓶藥的數位版

開一條資料庫連線很貴:TCP 三次握手、TLS 協商、帳號認證,來回好幾趟。所以沒有人每次查詢都新開一條,而是預先建立一小池連線,用「借出、使用、歸還」的方式輪流分配。借走的人用完必須還,否則池子見底,後面所有請求都卡在等待佇列裡——這就是連線洩漏(connection leak),跟女巫把毒藥攥在手裡一夜不放,是同一種病。

四語言的驅動與池,旋鈕名字不同,概念完全一致:

  • Go:標準庫 database/sql 本身就是一個池,靠 SetMaxOpenConns、SetMaxIdleConns、SetConnMaxLifetime、SetConnMaxIdleTime 調參;每次 Query / Exec / Begin 借一條,忘了 rows.Close() 就洩漏。
  • Java:HikariCP 是事實標準,maximumPoolSize、connectionTimeout、maxLifetime、leakDetectionThreshold;用 try-with-resources 自動歸還。
  • Python:psycopg_pool 或 SQLAlchemy 的 QueuePool,pool_size、max_overflow、pool_recycle、pool_pre_ping;用 context manager 歸還。
  • Rust:sqlx::Pool、deadpool、r2d2,max_connections、acquire_timeout、max_lifetime;連線離開作用域時由 Drop 歸還。

關鍵是「有上限」。池子太大,資料庫端的連線數先爆;池子太小,應用端排隊。而 maxLifetime 這種「活太久就回收」的設定,是為了趕在資料庫或中間的負載平衡器主動斷線之前,自己先換一條新的,避免拿到一條已經死掉的連線。

池子被借光的那一刻會發生什麼,是整個設計裡最關鍵的一個選擇。多數池的預設行為是「排隊等待,直到 acquire timeout 才丟錯」,你也可以設成「立刻失敗」。這個選擇直接決定一個服務的尾端延遲:排隊會讓 p99 在流量尖峰時暴衝,因為那些請求不是在做事,是在等一條連線;立刻失敗則把壓力用錯誤的形式往上游推,讓呼叫端自己決定要重試還是降級。沒有哪個一定對,但你必須知道自己選了哪個——很多線上事故的根因,就是工程師「以為會快速失敗、實際上在靜默排隊」。


逾時、重試與死鎖防禦

有限資源的第二堂課是:永遠不要無限等待。取連線要有 acquire timeout,執行查詢要有 statement timeout,整個操作要掛一個 context deadline。任何一層少了時限,一個慢查詢就能拖垮整池。

這裡要分清楚兩種逾時,它們常被混為一談。連線逾時管的是「我等一條可用連線等多久」;查詢逾時管的是「一句 SQL 跑多久算太久」。只設前者,一個跑歪的查詢會霸佔連線直到天荒地老,把整池慢慢拖乾,而你的監控只會看到「取連線逾時」,指錯方向;只設後者,池子見底時你還是會無限卡住。兩個都要設,而且查詢逾時通常要比呼叫端的整體 deadline 更短一截,才來得及在上游放棄之前把連線回收回池子。

事務(transaction)給你 ACID 的保證,代價是它會持有鎖。守則有三條:事務要短;不要在事務裡做網路呼叫或等使用者輸入;所有程式路徑都用同一個順序去鎖同一批資料列——鎖定順序不一致,就是死鎖的溫床。

即使如此,高併發下死鎖還是會發生。資料庫會挑一個「受害者」交易把它中止(PostgreSQL 回 SQLSTATE 40P01,MySQL 回錯誤碼 1213),另一個交易得以繼續。正確的應對是:對這類短暫錯誤做「整段事務重試」,帶退避與抖動,重試次數設上限,而且只重試本身冪等的操作。序列化失敗(40001)也適用同一套。

隔離等級(isolation level)決定了「一個交易看得見別的交易做了多少」。多數 OLTP 專案用 Read Committed,需要嚴格一致性的地方才升到 Serializable,並接受它會提高重試率。這裡沒有免費午餐:你要嘛在應用層寫重試迴圈,要嘛在資料庫層承受更多鎖等待。女巫的兩瓶藥也是同樣的取捨——早用早見效,但少了後面翻盤的餘裕;晚用留彈性,但可能等不到那個時機。

四語言的事務慣用法

同一套 ACID,四種語法外衣。Go 用顯式的 db.BeginTx / tx.Commit / tx.Rollback,靠 context 傳遞取消與逾時,defer tx.Rollback() 是慣用的安全網。Java 在 Spring 生態用 @Transactional 讓 AOP 代理接管邊界,代價是你得清楚代理的邊界在哪。Rust 的 sqlx 回傳一個 Transaction 值,離開作用域沒 commit 就自動 rollback,把「忘記收尾」交給型別系統擋掉。Python 的連線物件配合 with conn: 在區塊結束時自動 commit 或 rollback。四者共通的原則只有一條:交易的開始與結束要在同一個函式裡看得見,不要跨越好幾層呼叫,否則沒有人知道鎖被持有了多久。


ORM 的迷思:厚重的代價

一線基礎設施專案——Kafka、Kubernetes、DataFusion——大多不是傳統的 CRUD 應用,但它們對資料存取的態度有個共同點:查詢要看得見、行為要可預測。厚重的 ORM 把這兩件事都藏起來了:N+1 查詢、跨越已關閉 session 的惰性載入、你沒下令卻自己開的事務、生成出來你沒辦法調校的 SQL。

ORM 本身不是原罪,失去控制權才是。所以這些專案偏好「薄」的映射層:jOOQ、sqlc、sqlx、或直接用 JDBC 與 database/sql。你自己寫 SQL,工具只負責把結果安全地映射成型別。當你需要對一句查詢做 EXPLAIN、加索引提示、或拆成批次時,薄的工具不會擋你的路。

具體的反對理由有兩條,都很實際。第一是隱形的 N+1 查詢:你在迴圈裡讀一個惰性載入的關聯欄位,ORM 就默默地每圈補發一句 SQL,一個列表頁變成上百次資料庫往返,而這件事在原始碼裡完全看不出來,要等到慢查詢日誌爆掉才發現。第二是你讀不到「即將執行的那句 SQL」:它被生成在好幾層抽象底下,等 DBA 拿著日誌來問「這句是哪裡發的」,你往往答不出來。薄映射層把 SQL 攤在你眼前,你寫什麼就跑什麼,任何調校都是直接改那段字串。

package store

import (
	"context"
	"database/sql"
	"errors"
	"math/rand"
	"time"

	"github.com/jackc/pgx/v5/pgconn"
	_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver with database/sql
)

// NewPool configures a bounded connection pool. A pool is a shelf of scarce
// resources: every borrow must be returned, or the shelf runs dry.
func NewPool(dsn string) (*sql.DB, error) {
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(16)                   // hard ceiling on concurrent borrows
	db.SetMaxIdleConns(8)                    // keep some warm, close the rest
	db.SetConnMaxLifetime(30 * time.Minute)  // recycle before the server drops it
	db.SetConnMaxIdleTime(5 * time.Minute)
	return db, nil
}

// WithDeadlockRetry runs fn inside a serializable transaction, retrying only on
// transient serialization or deadlock errors, with capped attempts, exponential
// backoff plus jitter, and waits that respect ctx cancellation.
func WithDeadlockRetry(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
	const maxAttempts = 3
	for attempt := 1; ; attempt++ { // exits only through a return below
		tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
		if err != nil {
			return err
		}
		err = fn(tx)
		if err != nil {
			_ = tx.Rollback()
		} else if err = tx.Commit(); err == nil {
			return nil
		}
		// fn and Commit share one retry path: 40001 often surfaces at Commit.
		if !isTransient(err) || attempt == maxAttempts {
			return err
		}
		if waitErr := sleepWithJitter(ctx, attempt); waitErr != nil {
			return waitErr
		}
	}
}

// sleepWithJitter waits 20ms<<attempt plus up to 50% random jitter, so
// colliding transactions do not retry in lockstep. It returns early if ctx ends.
func sleepWithJitter(ctx context.Context, attempt int) error {
	base := 20 * time.Millisecond << attempt
	wait := base + time.Duration(rand.Int63n(int64(base/2)))
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(wait):
		return nil
	}
}

func isTransient(err error) bool {
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		// 40001 = serialization_failure, 40P01 = deadlock_detected
		return pgErr.Code == "40001" || pgErr.Code == "40P01"
	}
	return false
}

這段程式碼有兩個要看的地方。NewPool 裡每個旋鈕都是「上限」而不是「建議值」——SetMaxOpenConns(16) 是硬天花板,第 17 個借用一定要排隊。WithDeadlockRetry 則畫出重試的邊界:只有 isTransient 認得的 SQLSTATE 才重試,其他錯誤直接往上拋;重試次數寫死 3 次上限;退避是指數成長再加隨機抖動,讓撞在一起的交易不會同時重試;fn 失敗與 Commit 失敗走同一條重試路徑(40001 常常就是在提交那一刻才出現),等待期間也會回應 ctx 的取消或逾時,不會在呼叫端已經放棄之後還傻傻睡完。你絕對不會想要一個「什麼錯都重試、而且無限重試」的迴圈,那只會把一次故障放大成一場雪崩。

https://ithelp.ithome.com.tw/upload/images/20260924/20183684pU6QZ72lv9.png


這個答案在牌桌上的後果

七號寫下的那一行——「沒用出去、也沒還回場上的資源」——當晚就改變了她讀牌的方式。她不再只問「誰做了什麼」,開始問「誰握著能改變局面的東西卻按住不動」。一瓶整夜沒換到情報的毒藥,跟一條借出去卻不歸還的連線一樣,會悄悄地把每個人的容錯額度一起吃掉。至於二號那句永遠慢半拍的附議,她記下了,沒有多說——我們 2N1P 都清楚,一個細節還不是證據。

有件事我一直沒想通:我這一世記得的東西,跟上一世對不太起來,細節像被水洗過;可是七號那卷羊皮紙,不管這局重來過幾次,上面的字一個沒少、一個沒改。彷彿它根本不歸這座輪迴管。

讀完今天,你應該能做到:替任何服務設定「有界」的連線池(最大連線數、存活時間、取用逾時),寫出一個帶上限重試的事務函式來對付死鎖,並且在伸手抓 ORM 之前先問自己「我會不會失去對這句 SQL 的控制權」。接著對照 Go 官方 database/sql 套件文件 與 PostgreSQL 官方錯誤碼附錄 把池的每個旋鈕與死鎖/序列化失敗代碼實際核對一次。

參考資料與延伸閱讀


¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。


上一篇
Day 22|白天的發言與夜裡的暗號
系列文
狼人自爆的心路歷程:一個「AI人」的30天自學修煉 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言