10.1 database/sql:連線池、查詢結果的讀取與交易
這篇整理 Go 標準庫 database/sql 存取關聯式資料庫的方式,範例在 Go 1.25 與 PostgreSQL 17 上執行,驅動是 pgx v5.11.0 提供的 database/sql 相容版本。在 1.17 應用程式存取資料庫的工具分層 的分類裡,database/sql 屬於驅動與標準介面:程式自己寫 SQL、逐欄讀取結果,連線與交易由它管理。sqlx 與 GORM 直接建立在這一層上;sqlc 依設定產生 database/sql 或 pgx 原生介面的程式碼;pgx 的原生介面不經過它。四者處理的仍是同一組問題:連線從哪裡來、查詢結果怎麼讀、NULL 放到哪裡、交易怎麼收尾。
範例資料表
本模組各篇都用同一組表:一家書店的顧客與訂單。orders.note 允許 NULL,用來示範 NULL 的讀取;customers.email 有唯一約束,用來示範約束違反的錯誤。
1CREATE TABLE customers (
2 id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
3 name text NOT NULL,
4 email text NOT NULL,
5 created_at timestamptz NOT NULL DEFAULT now(),
6 CONSTRAINT customers_email_key UNIQUE (email)
7);
8
9CREATE TABLE orders (
10 id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
11 customer_id bigint NOT NULL REFERENCES customers (id),
12 amount integer NOT NULL,
13 note text,
14 ordered_at timestamptz NOT NULL DEFAULT now()
15);
16
17INSERT INTO customers (id, name, email) VALUES
18 (1, '佳穎', 'jiaying@example.com'), (2, '宗翰', 'zonghan@example.com'), (3, '雅文', 'yawen@example.com');
19INSERT INTO orders (id, customer_id, amount, note) VALUES
20 (101, 1, 300, NULL), (102, 1, 500, '禮物包裝'), (103, 2, 250, NULL);
21
22-- 明確指定 id 寫入 identity 欄位不會推進它的序列;不補這兩行,下一個由資料庫配號的 INSERT 會撞主鍵
23SELECT setval(pg_get_serial_sequence('customers', 'id'), (SELECT max(id) FROM customers));
24SELECT setval(pg_get_serial_sequence('orders', 'id'), (SELECT max(id) FROM orders));雅文沒有訂單,佳穎的兩張訂單只有一張有備註。範例程式的連線字串是 postgres://postgres:pw@localhost:55432/bookstore?sslmode=disable,帳號、密碼、port 與資料庫名稱換成手上那一台的值。各篇的輸出都以剛執行完上面這段 SQL 的資料為起點;前一篇的示範改過資料時(例如本篇〈交易〉那一節會改金額),先 DROP TABLE orders, customers 再重跑這段 SQL。
範例程式的 Go 模組要先取得驅動(後面各篇另外取得各自的套件):
1go mod init example.com/bookstore # 模組名稱自訂
2go get github.com/jackc/pgx/v5@v5.11.0驅動與 sql.Open
database/sql 只定義介面,實際和資料庫通訊的是資料庫驅動程式(database driver,以下簡稱驅動)。驅動在自己的 init 函式裡向 database/sql 註冊一個名字,程式用空白匯入(blank import)觸發註冊,之後用那個名字開啟:
1import (
2 "database/sql"
3
4 _ "github.com/jackc/pgx/v5/stdlib" // 註冊名為 "pgx" 的驅動
5)
6
7db, err := sql.Open("pgx", dsn)sql.Open 只建立 *sql.DB 這個物件並檢查參數格式,不連線。密碼錯了,sql.Open 照樣回傳 nil 錯誤,要到第一次需要連線時才失敗。所以服務啟動時接著呼叫 PingContext,讓設定錯誤在啟動那一刻就浮現,而不是在第一個請求進來時:
1open with wrong password, err = <nil>
2ping: failed to connect to `user=postgres database=bookstore`: ... failed SASL auth: FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01)*sql.DB 與連線池
*sql.DB 代表的是一個連線池(connection pool),不是一條連線。每次查詢時它從池裡取一條閒置的連線,沒有閒置的就新開一條,查詢結束後放回池裡。它可以被多個 goroutine 同時使用,所以一個程序(process)建立一個、在整個服務裡共用;每個請求各開一個 *sql.DB 會讓每個請求都重新建立連線,也讓池的上限失去作用。
池的大小由四個設定決定:
| 方法 | 管什麼 | 預設 |
|---|---|---|
SetMaxOpenConns(n) | 同時開著的連線上限(使用中加閒置) | 0,代表不設上限 |
SetMaxIdleConns(n) | 閒置時保留幾條,不關掉 | 2 |
SetConnMaxLifetime(d) | 一條連線最多用多久就關掉重開 | 不限 |
SetConnMaxIdleTime(d) | 一條連線閒置多久就關掉 | 不限 |
MaxOpenConns 預設不設上限,意思是流量尖峰時 Go 會一直開新連線,直到資料庫的 max_connections 拒絕為止;多個服務實例共用同一個資料庫時,全部實例的上限加起來要小於資料庫的 max_connections 扣掉維運保留的連線(各語言執行模型下的估算見 1.17 應用程式存取資料庫的工具分層〈各語言執行模型下的連線模型〉)。ConnMaxLifetime 設成比中間的負載平衡器或防火牆的閒置逾時短,連線就不會在池裡被對方悄悄斷掉。
db.Stats() 回傳池的即時狀態,〈查詢結果讀取物件(*sql.Rows)的關閉與連線洩漏〉那一節會用到其中三個欄位:InUse(使用中的連線數)、Idle(閒置的連線數)、WaitCount(因為池滿而排隊等連線的累計次數)。
查詢:QueryContext、QueryRowContext 與 ExecContext
三個方法依「預期回幾列」分工:
| 方法 | 用途 | 回傳 |
|---|---|---|
QueryContext | 可能回多列的查詢 | *sql.Rows,要逐列讀、讀完要關閉 |
QueryRowContext | 預期最多回一列的查詢 | *sql.Row,呼叫 Scan 時才回報錯誤 |
ExecContext | 不回資料列的語句(INSERT、UPDATE、DELETE) | sql.Result,可讀影響的列數 |
查詢的參數用佔位符(placeholder)傳,不要把值拼進 SQL 字串。佔位符的寫法由驅動決定:PostgreSQL 的驅動用 $1、$2,MySQL 的驅動用 ?。值拼進字串會讓使用者輸入的內容被當成 SQL 的一部分執行,也就是 SQL 注入(SQL injection);佔位符把值交給驅動,pgx 預設把值和 SQL 分開送給資料庫,值只會被當成值。
1var name string
2err := db.QueryRowContext(ctx, "SELECT name FROM customers WHERE id = $1", 999).Scan(&name)
3if errors.Is(err, sql.ErrNoRows) {
4 // 沒有這個顧客:這是業務上的「找不到」,不是系統錯誤
5}QueryRowContext 查不到資料時,Scan 回傳 sql.ErrNoRows。repository 通常在這裡把它轉成自己的「找不到」錯誤,讓上層不必認得 database/sql 的型別(轉換的位置見 Repository Adapter 實作)。
每個方法都有不帶 Context 的版本(Query、QueryRow、Exec),服務程式一律用帶 Context 的版本:請求被取消或逾時時,驅動會通知資料庫中止那一段查詢;pgx 接著關閉那條連線,database/sql 把它從池裡丟掉,空出的名額由下一個請求開新連線補上。不帶 Context 的版本等同傳入 context.Background(),查詢會一直跑到資料庫回應為止。
Scan 與 NULL
Scan 把一列的欄位依序寫進傳入的指標。欄位值是 NULL 而目標是 string 時,Scan 回傳錯誤,因為 string 沒有一個值可以代表「沒有值」。以 SELECT note FROM orders WHERE id = $1 讀訂單 101 的備註進 var note string:
1scan NULL into string: sql: Scan error on column index 0, name "note": converting NULL to string is unsupported可以是 NULL 的欄位有兩種接法:
1var note sql.NullString // NULL 時 note.Valid == false,非 NULL 時值在 note.String
2var notePtr *string // NULL 時 notePtr == nil兩種都能分辨「NULL」與「空字串」。sql.NullString 這類型別(還有 NullInt64、NullTime,以及泛型的 sql.Null[T])是 database/sql 自己的型別,放進 domain 的 struct 會讓上層也依賴資料庫套件;指標是語言本身的寫法,轉成 JSON 時 nil 直接變成 null。多數專案在 repository 的 row struct 用其中一種,轉成 domain 型別時再決定 NULL 代表什麼。
查詢結果讀取物件(*sql.Rows)的關閉與連線洩漏
QueryContext 回傳的 *sql.Rows 是查詢結果的讀取物件(其他中文資料常寫作游標,英文是 cursor,譯名的分辨見 Cursor)。它在讀取期間一直佔著池裡的一條連線,直到被關閉才放回去。關閉有兩種途徑:Next 讀到最後一列之後回傳 false 時自動關閉,或者程式呼叫 Close。
讀到一半就離開、又沒有呼叫 Close,那條連線就一直被佔著。把池的上限設成 1 就能直接看到:
1db.SetMaxOpenConns(1)
2rows, _ := db.QueryContext(ctx, "SELECT id FROM orders")
3rows.Next() // 只讀一列就離開,沒有 Close
4
5ctx2, cancel := context.WithTimeout(ctx, 2*time.Second)
6defer cancel()
7err := db.QueryRowContext(ctx2, "SELECT 1").Scan(new(int))1stats after leaking rows: InUse=1 Idle=0
2second query: context deadline exceeded WaitCount = 1
3after Close: InUse=0 Idle=1第二個查詢排隊等那唯一的一條連線,等了兩秒仍然等不到。正式環境的池上限比 1 大,同樣的連線洩漏(connection leak)要累積到把池佔滿才浮現,症狀是請求慢慢變慢、最後全部逾時,而資料庫本身的負載並不高。db.Stats() 的 InUse 一直接近上限、WaitCount 持續增加,是這個狀況的訊號。
所以讀多列的標準寫法固定是這個形狀:
1rows, err := db.QueryContext(ctx, "SELECT id, amount FROM orders ORDER BY id")
2if err != nil {
3 return err
4}
5defer rows.Close() // 中途 return 也會關閉
6
7for rows.Next() {
8 var id int64
9 var amount int
10 if err := rows.Scan(&id, &amount); err != nil {
11 return err
12 }
13 // 使用 id、amount
14}
15return rows.Err() // 讀取途中斷線之類的錯誤在迴圈結束後才回報rows.Err() 不能省:Next 回傳 false 有兩種原因,一種是正常讀完,一種是讀取途中出錯,只有 rows.Err() 分得出來。少了它,讀到一半斷線的結果會被當成完整的結果交給呼叫端。
交易:BeginTx、defer Rollback 與 Commit
交易(transaction)綁在一條連線上:BeginTx 從池裡取一條連線、送出 BEGIN,回傳的 *sql.Tx 之後的每個查詢都走這條連線,直到 Commit 或 Rollback 才把連線放回池裡。
1// 把訂單 101 的 50 元折抵改記到同一位顧客的訂單 102:只成功一半的話,顧客會多拿到或少拿到 50 元
2func moveDiscount(ctx context.Context, db *sql.DB) error {
3 tx, err := db.BeginTx(ctx, nil)
4 if err != nil {
5 return err
6 }
7 defer tx.Rollback() // Commit 成功之後再呼叫只會回 ErrTxDone,不會撤銷
8
9 if _, err := tx.ExecContext(ctx, "UPDATE orders SET amount = amount - 50 WHERE id = $1", 101); err != nil {
10 return err
11 }
12 if _, err := tx.ExecContext(ctx, "UPDATE orders SET amount = amount + 50 WHERE id = $1", 102); err != nil {
13 return err
14 }
15 return tx.Commit()
16}defer tx.Rollback() 讓每一條提早 return 的錯誤路徑都會撤銷交易並放回連線。Commit 成功之後,那個 Rollback 仍然會執行,只是回傳錯誤、什麼都不做。下面這一行的最後一個值是 errors.Is(err, sql.ErrTxDone):
1rollback after commit: sql: transaction has already been committed or rolled back true交易裡的查詢一定要用 tx 的方法,不能用 db 的。db.ExecContext 會從池裡另取一條連線,那個查詢就跑在交易之外:交易撤銷時它不會跟著撤銷,而且交易裡尚未提交的修改它也看不到。池上限是 1 的時候,這個錯誤直接變成卡住,因為唯一的連線被交易佔著。
隔離等級由 BeginTx 的第二個參數 &sql.TxOptions{Isolation: sql.LevelSerializable} 指定,nil 代表用資料庫的預設值(PostgreSQL 是 read committed)。等級之間的差別見 Isolation Level,交易範圍要劃在哪裡見 Transaction Boundary;交易要跨好幾個 repository 共用時,交易物件怎麼傳進各個 repository(以及傳錯時的徵兆,見 Transaction Propagation),見 1.4 Repository Adapter 實作〈Transaction 傳遞〉。
本模組其餘工具各自接手哪一段
1.17 應用程式存取資料庫的工具分層 把存取資料庫的工作分成寫出 SQL、取得連線並執行(連線池、交易)、把查詢結果對映成程式的值。用 database/sql 時,連線池由它管理,程式仍然要寫 SQL 字串、逐欄 Scan 進變數,並負責讀取物件與交易的收尾。本模組其餘的工具各自接手其中幾件:
| 工具 | 接手的工作 | 仍由程式負責的 |
|---|---|---|
| pgx 原生介面 | 依欄名把整列對映進 struct;PostgreSQL 專有的型別、COPY 大量寫入 | SQL 字串;只能用在 PostgreSQL |
| sqlx | 依 db tag 把整列對映進 struct、具名參數 | SQL 字串,寫錯要到執行時才發現 |
| sqlc | 從 SQL 產生呼叫函式、參數與結果的 struct,產生時檢查欄位 | SQL 寫在 .sql 檔;動態組合的條件 |
| GORM | 從方法呼叫組出 SQL、對映、寫入時的交易 | 理解它實際送出了什麼 SQL |
sqlx、pgx 原生介面與 sqlc 接手之後,送出的 SQL 仍然寫在程式碼或 .sql 檔裡看得見;GORM 接手之後,SQL 也從程式碼上消失,要靠它的 SQL 記錄才看得到。怎麼在這些工具之間選,見 10.7 Go 專案的資料庫存取工具選型:GORM、sqlc 與 pgx 的組合。