Go 基础体系 · 第 32/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go database/sql 基础:连接池、查询与事务边界

本文以 Go 1.26.4 为基准。database/sql 定义统一调用模型,协议、占位符、错误码和类型由 driver 实现。*sql.DB 不是一条连接,Rows 不是结果切片,事务也不只是两个函数:它们持有有限连接,并受 context、池配置和数据库语义约束。

本文聚焦标准库契约。SQL 建模、索引、迁移、ORM 和特定数据库锁属于相邻主题;工程中仍要按所选数据库验证。

1. database/sql 与 driver 怎样分工

应用通过 database/sql 使用 DBConnTxStmtRowsRow。driver 包负责把这些调用翻译成 PostgreSQL、MySQL、SQLite 等具体协议。标准库不自带可连接生产数据库的 driver,仅空导入 driver 包才会完成注册:

import (
	"database/sql"
	_ "example.com/vendor/driver"
)

func openDatabase(dsn string) (*sql.DB, error) {
	return sql.Open("driver-name", dsn)
}

driver 名、DSN、占位符、隔离级别和取消能力都不是标准库统一保证的。更换 driver 时要重新验证类型映射、错误分类和事务行为。

2. DB 是池句柄,不是一条已建立的连接

sql.Open 通常只创建池句柄,err == nil 不代表数据库可达。启动依赖数据库时,用有期限的 PingContext 验证:

db, err := sql.Open(driverName, dsn)
if err != nil {
	return fmt.Errorf("open database: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
	db.Close()
	return fmt.Errorf("ping database: %w", err)
}

DB 可并发使用,应长期复用并在退出时关闭。每请求调用 sql.Open 会创建独立池,绕过总连接预算。

3. 连接池配置是一道容量方程

SetMaxOpenConns 限制打开和正在打开的连接总数;达到上限后新操作等待归还连接。SetMaxIdleConns 限制池中空闲连接,过小会频繁建连,过大则占用数据库资源。SetConnMaxLifetime 限制连接总寿命,SetConnMaxIdleTime 限制空闲寿命。

db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

池上限要满足“每实例上限 × 实例数 + 管理余量 ≤ 数据库容量”。上限过大只会把更多并发压到数据库,过小则使请求在应用池中排队。

db.Stats() 观察 OpenConnectionsInUseIdleWaitCountWaitDuration。池等待可能源于池太小、查询慢、事务长或 Rows 泄漏,不能直接扩池。

4. Context 限制等待和执行,但取消能力依赖 driver

所有请求路径优先使用 QueryContextQueryRowContextExecContextBeginTxPingContext。context deadline 可能覆盖等待池连接和 driver 执行阶段。请求取消后,标准库会尝试通知 driver;driver 和数据库协议是否能立即取消服务端工作,需要实测。

ctx, cancel := context.WithTimeout(parent, 800*time.Millisecond)
defer cancel()

rows, err := db.QueryContext(ctx, query, accountID)

不要在进入数据库层时用 context.Background() 丢掉上游取消,也不要为每条语句机械分配完整上游预算。事务中的多条语句、锁等待和提交共享端到端预算。context 已取消时 Commit 可能失败并回滚,但网络中断仍可能留下“提交结果未知”:数据库可能已经提交,只是确认响应未到达。外部副作用需要幂等键或状态核对,不能把超时等同于回滚成功。

5. 参数只能代替值,不能代替 SQL 结构

用户值必须通过参数传递,driver 负责编码和转义:

row := db.QueryRowContext(ctx,
	`SELECT title FROM article WHERE article_id = ?`, id)

不同数据库的占位符可能是 ?$1 或命名参数;使用所选 driver 的语法。占位符通常不能代表表名、列名、排序方向或任意 SQL 片段。动态排序应从白名单映射:

orderBy := map[string]string{
	"newest": "created_at DESC",
	"title":  "title ASC",
}[sortKey]
if orderBy == "" {
	return errors.New("unsupported sort order")
}
query := `SELECT id, title FROM article ORDER BY ` + orderBy

字符串拼接在这里安全的前提是拼入的内容完全来自程序常量,而不是“清理过”的用户输入。列值仍使用参数。还要为分页大小设上限,避免合法 SQL 被用成资源消耗攻击。

6. Query、QueryRow 与 Exec 的返回契约

ExecContext 用于不返回结果集的语句,结果可查询 RowsAffected 或某些 driver 支持的 LastInsertId;并非所有数据库都支持后者。QueryContext 返回多行 *RowsQueryRowContext 返回占位 *Row,真正的查询或扫描错误由 Scan 返回,因此不能省略 Scan。

var title string
err := db.QueryRowContext(ctx,
	`SELECT title FROM article WHERE article_id = ?`, id,
).Scan(&title)
switch {
case errors.Is(err, sql.ErrNoRows):
	return ErrNotFound
case err != nil:
	return fmt.Errorf("select article %q: %w", id, err)
default:
	return nil
}

sql.ErrNoRows 是正常的“零行”结果,通常应映射为领域层 not found。不要把所有错误都转成它;权限、超时、连接失败和解码错误需要保留原因。错误包装使用 %w,上层再用 errors.Is/As 分类。

7. Rows 持有连接,必须关闭并检查 Err

多行查询的固定模板是:检查 Query 错误,立即 defer rows.Close(),循环 Next,每行 Scan,最后检查 rows.Err()

rows, err := db.QueryContext(ctx, `SELECT id, title FROM article WHERE state = ?`, state)
if err != nil {
	return nil, fmt.Errorf("query articles: %w", err)
}
defer rows.Close()

var result []Article
for rows.Next() {
	var item Article
	if err := rows.Scan(&item.ID, &item.Title); err != nil {
		return nil, fmt.Errorf("scan article: %w", err)
	}
	result = append(result, item)
}
if err := rows.Err(); err != nil {
	return nil, fmt.Errorf("iterate articles: %w", err)
}
return result, nil

循环中途返回时 defer 会关闭 Rows;自然读到结尾通常也会释放连接,但显式 Close 表达所有权并覆盖未读完路径。忘记关闭或在循环中做慢速外部调用,会让连接长期处于 InUse,最终所有请求堵在池上。

8. Scan 的类型、字节寿命与 NULL

Scan 把 driver 值转换到目标指针。目标数量必须与列数一致。常见目标包括 *string、整数指针、*bool*time.Time*[]byte*any 和实现 sql.Scanner 的类型。转换范围不合法会返回错误,不应忽略。

SQL NULL 不能扫描到普通 stringtime.Time。可使用 sql.NullStringsql.NullTime 等:

var subtitle sql.NullString
if err := row.Scan(&subtitle); err != nil {
	return err
}
if subtitle.Valid {
	fmt.Println(subtitle.String)
}

NullString 适合持久层,领域层通常应转换成明确的可选值模型,避免数据库细节扩散。使用 COALESCE 把 NULL 强行变成空字符串会丢失“未知”和“明确为空”的差异。

driver 返回的 []byte 可能只在下一次 Scan 前有效;需要保存时复制。sql.RawBytes 更明确地表示借用内存,生命周期仅到下一次 Rows.NextScanClose,绝不能直接存入结果切片。

9. 事务固定在一条连接上

BeginTx 从池中占用一条连接,直到 Commit 或 Rollback 才归还。事务中的所有语句必须通过 tx 调用;若误用 db.ExecContext,那条语句可能跑在另一连接、事务之外。

tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
	return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()

if _, err := tx.ExecContext(ctx, debitSQL, fromID, amount); err != nil {
	return fmt.Errorf("debit: %w", err)
}
if _, err := tx.ExecContext(ctx, creditSQL, toID, amount); err != nil {
	return fmt.Errorf("credit: %w", err)
}
if err := tx.Commit(); err != nil {
	return fmt.Errorf("commit transfer: %w", err)
}
return nil

defer tx.Rollback() 是安全兜底:成功 Commit 后 Rollback 返回 sql.ErrTxDone,通常忽略即可。事务应尽量短,不在其中调用 HTTP、等待用户输入或执行无界计算,否则会延长锁与连接占用。

10. 隔离级别、冲突与重试不能抽象掉

标准隔离级别可能不受 driver/数据库支持。脏读、不可重复读、幻读、写偏斜与锁等待必须按实际 SQL 验证。

死锁或序列化失败应重试整个事务,而不是最后一条语句。事务内操作必须可重放;数据库回滚无法撤销邮件或支付等外部动作。

重试要识别结构化错误码,限制次数和总 deadline,并加入退避。唯一冲突可能是业务结果;重试所有错误只会放大压力。

11. Prepared Statement 与 Conn 的适用边界

db.PrepareContext 返回的 *Stmt 可并发使用,标准库会在所需底层连接上准备语句。收益取决于数据库、代理和 driver;动态语句过多还会消耗服务端资源。

tx.PrepareContext 的 Stmt 只属于该事务。先写正确的参数查询,只有指标证明准备成本显著时再优化。

db.Conn(ctx) 取得独占连接。用后必须 Close,归还前恢复会话状态;普通查询不应固定 Conn。

12. 错误模式与诊断方法

池等待持续升高。 同时看 InUse、SQL 延迟、事务时长和 goroutine profile;根因可能是慢查询或 Rows 未关闭。

数据库 CPU 高且扩池后更差。 连接池本质是并发闸门。先用数据库慢查询、执行计划、锁等待定位,再按容量降低或调整并发,而不是继续加连接。

超时但数据库仍在执行。 验证 driver 是否实现协议取消,检查服务端 statement timeout;应用 context 和数据库端超时应协同,且数据库端应略小于上游预算以留出错误返回时间。

扫描偶发失败。 核对列名、目标 Go 类型、schema 演进、NULL、数值范围和时区。避免 SELECT *,且不记录敏感参数。

提交返回网络错误。 把结果视为未知,不自动声称失败。使用业务唯一键查询最终状态,或让命令本身具备幂等标识。

生产应联合观察 DB.Stats()、查询耗时、错误类别和数据库会话指标,不记录完整 DSN、SQL 参数或用户数据。

13. 可运行综合示例:无外部依赖的最小 driver

下面程序注册内存 driver,演示 Ping、参数查询、NULL、事务更新与回滚。它只解释接口,不是数据库模拟器;生产测试仍要覆盖真实隔离和错误码。

package main

import (
	"context"
	"database/sql"
	"database/sql/driver"
	"errors"
	"fmt"
	"io"
	"sync"
)

var store = struct {
	sync.Mutex
	balances map[int64]int64
}{balances: map[int64]int64{1: 100, 2: 50}}

type memoryDriver struct{}
type memoryConn struct{ tx *memoryTx }
type memoryTx struct { conn *memoryConn; pending map[int64]int64 }

func (memoryDriver) Open(string) (driver.Conn, error) { return &memoryConn{}, nil }
func (*memoryConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("prepare unsupported") }
func (*memoryConn) Close() error                        { return nil }
func (*memoryConn) Begin() (driver.Tx, error)           { return nil, errors.New("use BeginTx") }
func (*memoryConn) Ping(context.Context) error          { return nil }
func (c *memoryConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
	tx := &memoryTx{conn: c, pending: make(map[int64]int64)}
	c.tx = tx
	return tx, nil
}
func (c *memoryConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
	if err := ctx.Err(); err != nil { return nil, err }
	if c.tx == nil || query != "add" || len(args) != 2 { return nil, errors.New("unsupported update") }
	id, idOK := args[0].Value.(int64)
	delta, deltaOK := args[1].Value.(int64)
	if !idOK || !deltaOK { return nil, errors.New("id and delta must be int64") }
	c.tx.pending[id] += delta
	return driver.RowsAffected(1), nil
}
func (*memoryConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
	if err := ctx.Err(); err != nil { return nil, err }
	if query != "balance" || len(args) != 1 { return nil, errors.New("unsupported query") }
	id, ok := args[0].Value.(int64)
	if !ok { return nil, errors.New("id must be int64") }
	store.Lock()
	balance, found := store.balances[id]
	store.Unlock()
	if !found { return &memoryRows{}, nil }
	var note any
	if id == 1 { note = "primary" }
	return &memoryRows{values: [][]driver.Value{{id, balance, note}}}, nil
}

type memoryRows struct { values [][]driver.Value; index int }
func (*memoryRows) Columns() []string { return []string{"id", "balance", "note"} }
func (*memoryRows) Close() error      { return nil }
func (r *memoryRows) Next(dest []driver.Value) error {
	if r.index == len(r.values) { return io.EOF }
	copy(dest, r.values[r.index]); r.index++; return nil
}
func (tx *memoryTx) Commit() error {
	store.Lock(); defer store.Unlock()
	for id, delta := range tx.pending { store.balances[id] += delta }
	tx.conn.tx = nil
	return nil
}
func (tx *memoryTx) Rollback() error { tx.conn.tx = nil; return nil }

func main() {
	sql.Register("article-memory", memoryDriver{})
	db, err := sql.Open("article-memory", "")
	if err != nil { panic(err) }
	defer db.Close()
	db.SetMaxOpenConns(2)
	ctx := context.Background()
	if err := db.PingContext(ctx); err != nil { panic(err) }

	printAccount(ctx, db, 1)
	tx, err := db.BeginTx(ctx, nil)
	if err != nil { panic(err) }
	if _, err := tx.ExecContext(ctx, "add", int64(1), int64(-20)); err != nil { panic(err) }
	if _, err := tx.ExecContext(ctx, "add", int64(2), int64(20)); err != nil { panic(err) }
	if err := tx.Commit(); err != nil { panic(err) }
	printAccount(ctx, db, 1)
	tx, err = db.BeginTx(ctx, nil)
	if err != nil { panic(err) }
	if _, err := tx.ExecContext(ctx, "add", int64(1), int64(-99)); err != nil { panic(err) }
	if err := tx.Rollback(); err != nil { panic(err) }
	printAccount(ctx, db, 1)
	fmt.Printf("pool open=%d in_use=%d wait=%d\n", db.Stats().OpenConnections, db.Stats().InUse, db.Stats().WaitCount)
}

func printAccount(ctx context.Context, db *sql.DB, id int64) {
	var gotID, balance int64
	var note sql.NullString
	err := db.QueryRowContext(ctx, "balance", id).Scan(&gotID, &balance, &note)
	if err != nil { panic(err) }
	fmt.Printf("id=%d balance=%d note_valid=%v note=%q\n", gotID, balance, note.Valid, note.String)
}

driver 只识别 balanceadd,展示值经过 Rows/Scan、NULL 进入 NullString,以及 Commit/Rollback 决定暂存更新是否生效。生产代码应使用真实参数 SQL。

14. 工程检查清单

  • 一个进程通常长期复用一个按用途划分的 DB 池,启动依赖用有期限的 Ping 验证;
  • 按数据库总容量和实例数配置池,监控等待、占用、寿命淘汰与 SQL 延迟;
  • 所有请求查询贯穿 context,同时配置数据库端语句超时并验证 driver 取消;
  • 值使用参数,动态标识符和排序只从程序白名单映射;
  • QueryRow 必须 Scan,多行 Rows 必须 Close、逐行检查 Scan,并在循环后检查 Err;
  • 明确 NULL、数值范围、时间和字节生命周期,持久层类型不要无意泄漏到领域层;
  • 事务内只使用 Tx,缩短事务,不把不可回滚外部副作用混入其中;
  • 隔离、死锁和重试按具体数据库设计,提交网络错误视为结果未知;
  • 只有确需会话状态才取得 Conn,并确保恢复状态和归还连接;
  • 用真实数据库集成测试验证协议差异,内存 fake 只能验证有限调用契约。

掌握 database/sql 的核心,是把连接看作受限资源,把 Rows 和 Tx 看作连接租约,把 context 看作预算,把 SQL 与错误交给具体数据库验证。池、查询和事务应一起设计,避免负载上来后全部排队。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。