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 使用 DB、Conn、Tx、Stmt、Rows 和 Row。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() 观察 OpenConnections、InUse、Idle、WaitCount 和 WaitDuration。池等待可能源于池太小、查询慢、事务长或 Rows 泄漏,不能直接扩池。
4. Context 限制等待和执行,但取消能力依赖 driver
所有请求路径优先使用 QueryContext、QueryRowContext、ExecContext、BeginTx 和 PingContext。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 返回多行 *Rows。QueryRowContext 返回占位 *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 不能扫描到普通 string 或 time.Time。可使用 sql.NullString、sql.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.Next、Scan 或 Close,绝不能直接存入结果切片。
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, ¬e)
if err != nil { panic(err) }
fmt.Printf("id=%d balance=%d note_valid=%v note=%q\n", gotID, balance, note.Valid, note.String)
}
driver 只识别 balance 和 add,展示值经过 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 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go net/http 基础:Server、Handler、Middleware 与 Client 超时
- 下一篇:Go 测试体系:表驱动测试、子测试、Benchmark 与 Fuzz
- 延伸:Go context 完整指南:取消、超时、Deadline 与 Value
- 延伸:Go 错误处理:包装、errors.Is/As、panic 与 recover 边界
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论