Go 中的 database/sql
database/sql 提供连接池与查询抽象;驱动实现协议。覆盖 Open、Query/Exec、Scan、事务、context 与资源关闭。
[!info] Related notes
Go 中的 database/sql
这个概念为什么会出现
访问数据库需要:连接管理、语句执行、结果扫描、事务、超时取消。若每个驱动 API 各异,业务代码无法移植。database/sql 提供统一门面,具体协议由 driver 实现(如 pgx 的 stdlib 适配、go-sql-driver/mysql)。
[!abstract] 一句话理解
sql.DB是连接池抽象:用Query/Exec/Tx访问数据,用Scan填入变量;务必CloseRows,并用 context 控制超时。
最小可运行示例
需要可访问的 PostgreSQL、已初始化的 Go module 和 pgx 驱动:go get github.com/jackc/pgx/v5/stdlib。通过环境变量 DATABASE_URL 提供所选环境的 DSN;TLS 参数由部署环境决定。
package main
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // 示例驱动,按实际替换
)
func run() error {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return fmt.Errorf("DATABASE_URL is required")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
defer db.Close()
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return err
}
var n int
if err := db.QueryRowContext(ctx, `SELECT 1`).Scan(&n); err != nil {
return err
}
fmt.Println(n)
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err) // run 已返回,其 defer 已执行
}
}
运行 go run .,成功输出 1。这里 Ping 与查询共享 3 秒总预算。
驱动 import、DSN 与占位符随数据库变化;下面使用 PostgreSQL 的
$1。users表需事先存在,示例不创建 schema。
func listUsers(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `SELECT id, email FROM users WHERE active = $1`, true)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var email string
if err := rows.Scan(&id, &email); err != nil {
return err
}
fmt.Println(id, email)
}
return rows.Err()
}
func createUser(ctx context.Context, db *sql.DB, email string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // 保证未提交路径尝试回滚;成功 Commit 后返回 sql.ErrTxDone
if _, err := tx.ExecContext(ctx, `INSERT INTO users(email) VALUES ($1)`, email); err != nil {
return err
}
return tx.Commit()
}
关注点:
Open不保证连通——用PingContext。Rows必须 Close,并检查rows.Err()。- 事务路径上失败要 Rollback,成功 Commit。
核心概念与准确模型
sql.DB
- 不是单连接,而是池
- 通常每个数据库目标/连接配置共享一个
*sql.DB,可供多个 goroutine 并发使用 - 配置:
SetMaxOpenConns/MaxIdle/ConnMaxLifetime/ConnMaxIdleTime
文档:database/sql、教程:Accessing databases。
查询 API
| API | 用途 |
|---|---|
ExecContext | 不消费返回行;如不带 RETURNING 的 INSERT/UPDATE/DELETE |
QueryContext | 多行 |
QueryRowContext | Scan 取第一行,丢弃其余行;不会验证查询结果唯一性,错误延迟到 Scan |
PrepareContext | 创建可复用 Stmt;此 ctx 仅用于准备,执行时另传 ctx |
Scan 与 NULL
列顺序和目标数量需匹配,转换失败由 Scan 返回。NULL 可用 sql.NullString / sql.Null[T](Go 1.22+),或 var email *string 后调用 Scan(&email);SQL NULL 得到 nil,空字符串得到指向 "" 的非 nil 指针。直接把 NULL 扫入 string 会报错。database/sql 不会根据 struct tags 自动映射列。
事务
BeginTx 返回错误时应直接返回,不能调用可能为 nil 的 tx.Rollback()。成功后立即注册 defer tx.Rollback(),在同一 tx 上执行 SQL,最后检查 Commit 的错误;上面的 createUser 展示了完整顺序。
Tx 占用单个连接。事务期间误用 db.ExecContext 会在事务之外执行,池上限为 1 时还可能等待自己占用的连接。隔离级别是否支持取决于驱动/数据库。Commit 报错时不能把之前的结果当成已成功提交,也不应无条件重试;网络故障可能使提交结果不确定,需要幂等键或查询确认。
资源与循环 defer
在 for 里 defer rows.Close() 会堆积——包一层函数或显式 Close。
context
优先传递请求 context(如 r.Context()),它可限制等待连接等过程;执行中的 I/O 能否及时取消仍取决于驱动/数据库。传给 BeginTx 的 context 覆盖事务生命周期,取消后 database/sql 会回滚,后续 Commit 返回错误。取消不等于证明某条写入从未发生。
哨兵错误
sql.ErrNoRows:QueryRow 无数据时 Scan 返回。用 errors.Is。
设计动机
标准库稳住“如何访问”,驱动竞争“访问谁”。ORM(GORM)建在这层之上,但 SQL、池、事务语义仍必须理解。
边界情况与反直觉行为
1. 占位符方言
? vs $1 vs @p1 因驱动而异——用驱动文档,不要假设可移植字符串。
2. 连接池耗尽
表现为请求阻塞/超时,而非立刻报错。查 MaxOpenConns 与是否泄漏 Rows。
3. Prepare 与池
DB.PrepareContext 得到的 Stmt 可并发复用,换到底层新连接时由池自动准备;Tx / Conn 上创建的 Stmt 则固定在那条连接上,其所属事务/连接结束后不可再用。调用方应关闭 Stmt。是否提高性能需结合驱动和负载测量。
4. 取消已发出的查询
依赖驱动与数据库是否响应取消;仍应传 ctx。
常见误区
[!warning] 常见误区:忽略
Rows.Close
提前退出遍历而不关闭会占用连接;完整遍历到末尾且没有后续结果集时会自动关闭。成功查询后仍推荐立即 defer Close,并检查 rows.Err()。
[!warning] 常见误区:错误路径忘记
Rollback
事务悬挂或占连接。
[!warning] 常见误区:字符串拼接 SQL
值使用参数绑定;表名、列名、排序方向等通常不能用值占位符,应从明确的允许列表映射,不能直接拼用户输入。
[!warning] 常见误区:每次请求
sql.Open/Close
失去池化,开销巨大。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| 裸驱动(pgx 原生) | 功能更强/性能路径不同;API 不统一 |
| ORM | 生产力高;仍可能生成低效 SQL |
| 缓存 | 减少读库;一致性独立问题 |
| Repository 层 | 把 Scan/SQL 关在数据访问边界 |
工程实践
- 按数据库目标共享
*sql.DB,在 main 注入 repository,服务结束时统一关闭。 - 仓储封装错误翻译(唯一约束 → 领域
ErrConflict)。 - 迁移用独立工具(golang-migrate 等),勿靠启动时隐式 DDL 混用。
- 测试:集成测试 + 事务回滚或 testcontainers。
- 指标:池等待、慢查询、错误率。
- 只读副本:可第二
*sql.DB,在 repo 分流。
可验证实验
2026-10-07:Go 1.26.8 / pgx v5.10.0 下示例编译通过;用受控 database/sql/driver 测试驱动验证事务开始/执行/提交失败、提交后 Rollback、QueryRow 首行/无行、NULL 扫描和池等待,go test -race 通过。这不替代真实 PostgreSQL 的连接、SQL 方言、隔离与取消集成验证;下列实验仍需在测试数据库执行。
实验 1:不 Close Rows
并发查询后观察连接数/超时。
实验 2:短 timeout
使用支持取消的驱动,验证 WithTimeout 是否及时终止慢查询,并确认服务端查询/事务状态。
实验 3:ErrNoRows
QueryRow 无数据,errors.Is(err, sql.ErrNoRows)。
实验 4:事务回滚
插入后 Rollback,再查应不存在。
本节总结
- 本质:
database/sql= 池 + 查询/事务抽象。 - 关键规则:context、Close、参数化、事务边界、共享 DB。
- 最易错:泄漏 Rows、拼接 SQL、Open 当连通保证。
- 下一步:仓储分层;缓存 保护读路径。
自测题
概念题
sql.Open是否保证网络已连通?- 为何优先
QueryContext而非Query? QueryRow无行时错误是什么?
代码推理题
在 for rows.Next() 循环内 defer rows.Close() 处理嵌套查询——有何风险?
工程思考题
如何把 sql.ErrNoRows 转成 API 的 404 而不在 handler 依赖 database/sql?
参考答案
展开
- 否,通常惰性;用 Ping。
- 传递取消/超时,限制池等待;执行中查询的及时取消取决于驱动/数据库。
sql.ErrNoRows(在 Scan 时)。
代码题:defer 延迟到函数返回,连接/语句长期占用;应立即 Close 或抽函数。
工程题:repository 映射为领域ErrNotFound,handler 只认领域错误。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| database/sql | 标准库 | API |
| Accessing databases | 官方教程 | 池、查询、事务 |
| Go Blog — database | 官方 | 历史文章检索 |
| go-context · go-resource-cleanup · go-error-handling | 本库 | 取消、关闭、错误 |