14.1 database/sql 与驱动
上一章结束时,TaskAPI 的所有任务都躺在内存 map 里,进程一重启就归零。要真正做服务,数据得落到数据库。Go 的数据库访问不像很多语言那样只有一套「官方 ORM」,而是把「通用接口」和「具体驱动」拆成了两层,这套设计值得花一节讲透。
本节把 TaskAPI 推进到:接上
database/sql,理解sql.DB作为连接池的真实语义,掌握 Exec/QueryRow/Query 与预处理语句。为能在本机实测,我们用纯标准库写一个内存驱动,把整条链路跑通;真实数据库驱动的接入方式也一并说明。
14.1.1 两层设计:标准库给接口,驱动给实现
database/sql 本身不含任何数据库的通信代码。它只定义接口:driver.Driver、driver.Conn、driver.Stmt、driver.Rows、driver.Result,以及面向使用者的 *sql.DB、*sql.Row、*sql.Rows。
真正的网络协议、SQL 方言由各家驱动实现,通过 sql.Register 注册一个名字。调用链是「你的代码 → database/sql(接口层)→ 驱动(实现层)→ 具体数据库」,换数据库时理论上只换驱动与 DSN。
这个分层的好处:标准库能统一处理连接池、重试、context 取消、预处理缓存,而不用每个驱动重复一遍。代价是驱动必须严格遵守接口契约,这也是为什么 Go 的数据库驱动大多由各数据库官方或社区长期维护。
14.1.2 驱动注册与 sql.Open 的惰性
驱动通常在 init() 里把自己注册进去:
func init() { sql.Register("memdb", memDriver{}) }
注意这里用的是空白导入的惯用法——import _ "github.com/lib/pq",只为触发它的 init,你自己并不直接调用驱动包。注册后就能用名字打开:
db, err := sql.Open("memdb", "")
sql.Open 不会建立连接。它只校验 DSN 格式、构造 *sql.DB 对象并立即返回;真正的连接推迟到第一次执行查询时(惰性)。所以想主动验证连通性,要调用 db.PingContext(ctx)——服务启动时 ping 一次,能把「数据库配错了」暴露在启动阶段,而不是等第一个用户请求打进来才报错。
14.1.3 sql.DB 不是连接,是连接池
这是最容易误解的一点:*sql.DB 不是一个数据库连接,而是一个连接池。它可以被多个 goroutine 并发安全地共享,内部按需创建、复用、回收连接。
var db *sql.DB // 全局一个 *sql.DB,各处共享
func main() {
db, _ = sql.Open("memdb", "")
defer db.Close() // 进程退出时关闭整个池,不是某条连接
}
defer db.Close() 关闭的是整个池。不要在每次请求里 sql.Open 再 Close——那等于每次都重建一个池,连接完全无法复用。正确做法是在 main 里建一次,注入给需要它的组件(第 15.2 节会讲这种手工装配)。
14.1.4 连接池参数与 Stats
池的行为通过四个方法调节:
| 方法 | 作用 | 建议 |
|---|---|---|
SetMaxOpenConns(n) | 池中最多打开的连接数 | 通常设成数据库 max_connections 的一部分 |
SetMaxIdleConns(n) | 空闲保留的连接数 | 建议等于 MaxOpenConns,避免频繁建连 |
SetConnMaxLifetime(d) | 连接最长存活时间 | 短于数据库/中间件的空闲断连时间 |
SetConnMaxIdleTime(d) | 连接最长空闲时间 | 配合上一条,防止拿到被服务端掐掉的连接 |
默认 MaxOpenConns 是 0,表示无限制——这在低流量时没问题,一旦并发上来,可能瞬间打爆数据库的连接上限。生产环境务必显式设置。
想看池的实时状态,用 db.Stats()(s := db.Stats(),读 s.OpenConnections、s.InUse、s.Idle、s.MaxOpenConnections、s.WaitCount 等字段)。实测输出(插入若干数据后):
stats: open=1 inUse=0 idle=1 maxOpen=4
字段含义:OpenConnections 是当前总连接数,InUse 是正被占用的,Idle 是空闲待复用的,WaitCount 是累计有多少次请求因为池满而等待。WaitCount 持续增长就是池太小的信号。
14.1.5 三种执行方式
database/sql 提供三个入口,按「期望的返回」区分:
| 方法 | 返回 | 用途 |
|---|---|---|
db.ExecContext | sql.Result(影响行数、自增 ID) | INSERT / UPDATE / DELETE |
db.QueryRowContext | *sql.Row(至多一行) | 按主键查一条 |
db.QueryContext | *sql.Rows(多行游标) | 列表查询 |
res, _ := db.ExecContext(ctx,
"INSERT INTO tasks(title, done) VALUES(?, ?)", "写 Go 书", false)
id, _ := res.LastInsertId()
aff, _ := res.RowsAffected()
var t Task
err := db.QueryRowContext(ctx,
"SELECT id, title, done FROM tasks WHERE id = ?", id).
Scan(&t.ID, &t.Title, &t.Done)
rows, err := db.QueryContext(ctx, "SELECT id, title, done FROM tasks ORDER BY id")
if err != nil {
return err
}
defer rows.Close() // 必须关,否则连接不归还
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
return err
}
// 处理 t
}
return rows.Err() // 别忘了检查迭代过程中的错误
Query 有两个必须记住的点:defer rows.Close() 不写会泄漏连接;循环结束后检查 rows.Err(),因为 Next() 返回 false 可能是正常结束,也可能是中途出错。参数占位符用 ?(部分驱动用 $1),绝不要用字符串拼接 SQL——那是 SQL 注入的入口,占位符会由驱动安全地转义。
14.1.6 预处理语句
db.PrepareContext 把 SQL 编译成 *sql.Stmt,之后可反复带不同参数执行:
stmt, _ := db.PrepareContext(ctx, "SELECT id, title, done FROM tasks WHERE id = ?")
defer stmt.Close()
var t Task
_ = stmt.QueryRowContext(ctx, id).Scan(&t.ID, &t.Title, &t.Done)
预处理的意义有两层:一是性能(SQL 只解析一次),二是安全(参数与语句结构分离,天然防注入)。注意 *sql.Stmt 也是并发安全的,但绑定在某条连接上;database/sql 内部会在连接被占用时自动在另一条连接上重新准备,你不用操心。
14.1.7 ErrNoRows:查询没结果的专用信号
QueryRow 查不到行时,Scan 返回一个哨兵错误 sql.ErrNoRows:
err := db.QueryRowContext(ctx, "SELECT id FROM tasks WHERE id = ?", 999).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
// 不是「出错了」,而是「没这条记录」
}
实测确认 errors.Is(err, sql.ErrNoRows) 为 true。在 TaskAPI 里,这个错误应该被翻译成领域层的 ErrNotFound(第 6 章的哨兵错误),再由第 15.3 节的错误映射回 404。不要把 sql.ErrNoRows 直接透传到 HTTP 层——那会让上层依赖数据库的具体实现。
14.1.8 本机实测:一个纯标准库的内存驱动
问题来了:本机没有可用的数据库服务,也没有任何第三方驱动缓存。为了让上面的代码真的跑起来,我们用一个「sqlmock 风格」的内存驱动——它完整实现了 driver.Driver 接口,让 database/sql 的连接池、预处理、事务语义都能真实运转:
package main
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"sort"
"strings"
"sync"
)
type memDriver struct{}
func (memDriver) Open(string) (driver.Conn, error) { return &memConn{}, nil }
type memDB struct {
mu sync.Mutex
nextID int64
rows map[int64]memRow
}
type memRow struct {
id int64
title string
done bool
}
var mem = &memDB{nextID: 1, rows: map[int64]memRow{}}
type memConn struct{}
func (*memConn) Prepare(q string) (driver.Stmt, error) { return &memStmt{q}, nil }
func (*memConn) Close() error { return nil }
func (*memConn) Begin() (driver.Tx, error) { return mem.begin() }
// 实现 driver.ConnBeginTx,让 BeginTx 的隔离级别/只读选项有处可去(驱动本身忽略它们)
func (*memConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
return mem.begin()
}
// 事务用快照实现:回滚时把 rows 恢复到 begin 时的状态
type memTx struct {
snap map[int64]memRow
nextID int64
}
func (m *memDB) begin() (*memTx, error) {
m.mu.Lock()
defer m.mu.Unlock()
snap := make(map[int64]memRow, len(m.rows))
for k, v := range m.rows {
snap[k] = v
}
return &memTx{snap: snap, nextID: m.nextID}, nil
}
func (*memTx) Commit() error { return nil }
func (t *memTx) Rollback() error {
mem.mu.Lock()
defer mem.mu.Unlock()
mem.rows, mem.nextID = t.snap, t.nextID
return nil
}
type memStmt struct{ q string }
func (*memStmt) Close() error { return nil }
func (*memStmt) NumInput() int { return -1 }
// driver.Stmt 接口要求这两个非 context 方法,内部转调 context 版本
func (s *memStmt) Exec(a []driver.Value) (driver.Result, error) { return s.exec(a) }
func (s *memStmt) Query(a []driver.Value) (driver.Rows, error) { return s.query(a) }
func (s *memStmt) ExecContext(_ context.Context, a []driver.NamedValue) (driver.Result, error) {
return s.exec(named(a))
}
func (s *memStmt) QueryContext(_ context.Context, a []driver.NamedValue) (driver.Rows, error) {
return s.query(named(a))
}
func named(ns []driver.NamedValue) []driver.Value {
vs := make([]driver.Value, len(ns))
for i, n := range ns {
vs[i] = n.Value
}
return vs
}
func (s *memStmt) exec(a []driver.Value) (driver.Result, error) {
q := strings.ToUpper(strings.Join(strings.Fields(s.q), " "))
mem.mu.Lock()
defer mem.mu.Unlock()
if strings.HasPrefix(q, "INSERT INTO TASKS") {
id := mem.nextID
mem.nextID++
mem.rows[id] = memRow{id, a[0].(string), a[1].(bool)}
return memResult{lastID: id, aff: 1}, nil
}
if strings.HasPrefix(q, "UPDATE TASKS") {
id := a[2].(int64)
r, ok := mem.rows[id]
if !ok {
return memResult{aff: 0}, nil
}
r.title, r.done = a[0].(string), a[1].(bool)
mem.rows[id] = r
return memResult{aff: 1}, nil
}
if strings.HasPrefix(q, "DELETE FROM TASKS") {
id := a[0].(int64)
if _, ok := mem.rows[id]; !ok {
return memResult{aff: 0}, nil
}
delete(mem.rows, id)
return memResult{aff: 1}, nil
}
return nil, fmt.Errorf("unsupported exec: %s", s.q)
}
func (s *memStmt) query(a []driver.Value) (driver.Rows, error) {
q := strings.ToUpper(strings.Join(strings.Fields(s.q), " "))
mem.mu.Lock()
defer mem.mu.Unlock()
if strings.Contains(q, "WHERE ID = ?") {
r, ok := mem.rows[a[0].(int64)]
if !ok {
return &memRows{cols: []string{"id", "title", "done"}}, nil
}
return &memRows{cols: []string{"id", "title", "done"},
data: [][]driver.Value{{r.id, r.title, r.done}}}, nil
}
ids := make([]int64, 0, len(mem.rows))
for id := range mem.rows {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var data [][]driver.Value
for _, id := range ids {
r := mem.rows[id]
data = append(data, []driver.Value{r.id, r.title, r.done})
}
return &memRows{cols: []string{"id", "title", "done"}, data: data}, nil
}
type memResult struct{ lastID, aff int64 }
func (r memResult) LastInsertId() (int64, error) { return r.lastID, nil }
func (r memResult) RowsAffected() (int64, error) { return r.aff, nil }
type memRows struct {
cols []string
data [][]driver.Value
pos int
}
func (r *memRows) Columns() []string { return r.cols }
func (r *memRows) Close() error { return nil }
func (r *memRows) Next(dest []driver.Value) error {
if r.pos >= len(r.data) {
return io.EOF
}
copy(dest, r.data[r.pos])
r.pos++
return nil
}
func init() { sql.Register("memdb", memDriver{}) }
把它和一段普通的 sql.DB 使用代码放在一起,实测输出如下:
insert id=1 affected=1
queryrow: {ID:1 Title:写 Go 书 Done:false} err=<nil>
missing is ErrNoRows: true
prepared: {ID:1 Title:写 Go 书 Done:false}
stats: open=1 inUse=0 idle=1 maxOpen=4
可以看到:LastInsertId 拿到了自增 ID、QueryRowContext 的 Scan 正常填充结构体、不存在的行返回 ErrNoRows、预处理语句复用了连接、Stats() 报告的连接数符合预期。这套 database/sql 的用法与真实数据库完全一致,只是背后的驱动换成了内存实现。
14.1.9 换成真实数据库
真实项目里,你只需要把驱动名和 DSN 换掉,其余代码几乎不动——空白导入第三方驱动(如 import _ "github.com/jackc/pgx/v5/stdlib")触发其 init 注册,再用 sql.Open("pgx", "postgres://user:pass@localhost:5432/taskapi?sslmode=disable") 打开即可。
需要说明的是:这些第三方驱动不在卷一范围内。本卷坚持只用标准库,所以全书示例都不会 go get 任何驱动;上面这段仅作为「接入真实数据库时的差异点」展示。真实驱动与内存驱动的接口契约相同,本节讲的所有 database/sql 行为——连接池、预处理、ErrNoRows、Stats——都原样适用。
14.1.10 小结
database/sql是接口层,驱动是实现层,两者通过sql.Register的名字解耦。sql.Open不连接,PingContext才连;启动时 ping 一次。*sql.DB是连接池,进程内共享一个,Close关整个池。- 四个池参数中
SetMaxOpenConns默认无限制,生产必设;用Stats()观察WaitCount。 - 按返回选入口:
Exec改数据、QueryRow查一条、Query查多条(记得Close与Err())。 - 预处理语句兼顾性能与防注入;参数一律用占位符,绝不拼字符串。
sql.ErrNoRows用errors.Is判别,翻译成领域错误再往上抛。
下一节,我们把这些原语组合成 TaskAPI 的完整仓储层:增删改查、事务、以及「事务里跨多张表」的原子性。
阅读导航:上一节:13.3 中间件与访问日志 · 下一节:14.2 SQL 增删改查与事务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。