《Go 语言编程入门》14.1 database/sql 与驱动

TaskAPI 不能再把任务放在内存里了。本节讲清 database/sql 的双层设计——标准库提供接口、驱动提供实现,sql.DB 为何是连接池而非连接、连接池参数怎么调、Exec/QueryRow/Query 三种执行方式与预处理语句的区别。为便于本机实测,还给出一份纯标准库实现的内存驱动,把整条链路真正跑通。

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.ExecContextsql.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 增删改查与事务 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练