数据库

pkg/core/db 在 database/sql 上做一层薄封装:SQL 还是 SQL, 一个方言跑三个驱动。业务代码只认 *db.DB / *db.Tx / db.Result,不 import 任何驱动库。

三个驱动,一份 SQL

驱动由 Driver 选择,空值默认 PostgreSQL。每个驱动对应一个 database/sql driver name:

driver 实现库 driver name DSN 示例
postgres(默认) jackc/pgx/v5/stdlib pgx postgres://user:pass@host:5432/db?sslmode=disable
mysql go-sql-driver/mysql mysql user:pass@tcp(host:3306)/db?parseTime=true
sqlite modernc.org/sqlite(纯 Go,无 cgo) sqlite file:matex.db 或 :memory:

MySQL 的 DSN 记得加 parseTime=true,否则时间列只能扫成字符串。

db:
  driver: postgres        # postgres | mysql | sqlite
  dsn: ${DB_DSN:postgres://matex:matex@localhost:5432/matex?sslmode=disable}
  max_open_conns: 16
  max_idle_conns: 2
  conn_max_lifetime: 30m
  conn_max_idle_time: 5m
  connect_timeout: 5s
  slow_threshold: 250ms

删掉 db: 段即禁用:env.DB() 返回 ok=false, 需要数据库的服务用 env.RequireDB(),此时启动直接失败。

SQLite 的连接池是 1 Open 在驱动为 SQLite 且未显式设 max_open_conns 时把池压到 一个连接:SQLite 只有一个写者,单连接池不改你的 SQL 就避开了 SQLITE_BUSY。

占位符:只写 ?

业务 SQL 一律写 ?。postgres 会在执行前改写成 $1..$n, MySQL 与 SQLite 原样使用。改写器会跳过引号串与注释,并用 ?? 转义一个字面 ?——postgres 的 JSON 操作符 ? 因此写成 data ?? 'key', 或直接改用 jsonb_exists(...)。

// 三种驱动共用同一份 SQL。
const insert = `INSERT INTO articles (slug, title, views, published, note, updated_at)
	VALUES (?, ?, ?, ?, ?, ?)`

_, err := d.Exec(ctx, insert, "hello-matex", "Hello matex", 0, false, nil, now)

保持一个占位符风格意味着 SQL 是便携的;只有驱动专属的写法(DDL 类型、upsert 语法、 RETURNING)需要单独注意。

泛型扫描:QueryAll / QueryOne / QueryScalar

结果行按 db tag 映射到结构体字段,缺省用字段名,大小写不敏感。 多余的列直接忽略,所以 SELECT * 在表加列后不会出错。

type Article struct {
	Slug      string    `db:"slug"       json:"slug"`
	Title     string    `db:"title"      json:"title"`
	Views     int64     `db:"views"      json:"views"`
	Published bool      `db:"published"  json:"published"`
	Note      *string   `db:"note"       json:"note"`       // NULL → nil
	UpdatedAt time.Time `db:"updated_at" json:"updated_at"` // RFC3339 字符串 → time.Time
}

a, err := d.QueryOne[Article](ctx, `SELECT * FROM articles WHERE slug = ?`, slug)
list, err := d.QueryAll[Article](ctx, `SELECT * FROM articles ORDER BY slug`)
n, err := d.QueryScalar[int64](ctx, `SELECT COUNT(*) FROM articles`)

扫描的类型转换是固定的:

查无此行是 db.ErrNoRow QueryOne / QueryScalar 无匹配行时返回哨兵错误 db.ErrNoRow,用 errors.Is(err, db.ErrNoRow) 判断, 业务层通常把它映射成 errs.NotFound。它不是 SQL 错误,不会进 500。

事务与密封的 Querier

WithTx 在 fn 返回 nil 时提交,返回 error 或 panic 时回滚。 *db.Tx 与 *db.DB 提供同一组方法。

err := d.WithTx(ctx, func(ctx context.Context, tx *db.Tx) error {
	if _, err := tx.Exec(ctx, `UPDATE accounts SET balance = balance - ? WHERE id = ?`, amt, from); err != nil {
		return err
	}
	if _, err := tx.Exec(ctx, `UPDATE accounts SET balance = balance + ? WHERE id = ?`, amt, to); err != nil {
		return err
	}
	return nil // nil 提交;返回 error(或 panic)自动回滚
})

让一段 DAO 代码在池上和事务里都能跑,靠的是 db.Querier: DAO 方法收 db.Querier 而不是 *db.DB。

type articleDAO struct{}

func (articleDAO) bySlug(ctx context.Context, q db.Querier, slug string) (*Article, error) {
	return db.QueryOne[Article](ctx, q, `SELECT * FROM articles WHERE slug = ?`, slug)
}

db, _ := env.RequireDB()
a, err := dao.bySlug(ctx, db, "hello-matex")           // 池上

err = db.WithTx(ctx, func(ctx context.Context, tx *db.Tx) error {
	_, err := dao.bySlug(ctx, tx, "hello-matex")       // 同一份代码,事务内
	return err                                          // 事务里能读到本事务自己的写
})
为什么 Querier 必须存在 接口的方法不能带类型参数——类型可以(Go 1.27 起),接口不行,编译器报 interface method must have no type parameters。所以 QueryOne[T] 只能是包级函数 db.QueryOne[T](ctx, q, …), 而 q 的类型是 Querier。接口是密封的(唯一方法是未导出的 conn()),所以实现只有 *DB 与 *Tx,业务代码长不出 第三条访问驱动的路。
flowchart TB
    H["handler"] --> D["articleDAO
方法签名收 db.Querier"] D -->|"直接调用
dao.bySlug(ctx, db, slug)"| POOL["*db.DB
连接池"] H -->|"db.WithTx(ctx, fn)"| TXU["WithTx
BeginTx → fn → Commit"] TXU -->|"回调收到 *db.Tx"| TX["*db.Tx
同一个 Querier"] TX -->|"dao.bySlug(ctx, tx, slug)"| D POOL --> SQL[("SQL")] TX --> SQL TXU -.->|"fn 返回 error / panic"| RB["Rollback"] classDef one fill:#fdf6f0,stroke:#c8641f,color:#2b2722 class D one
一个 DAO,两个调用点。事务内复用同一份 SQL 代码,不需要 bySlug + bySlugTx 的两个方法。

错误映射:约束冲突 → 409

业务代码看不到驱动错误。每次调用返回前,db 都会把一个驱动错误包装成 errs 的某个 Kind,HTTP 层据此选状态码:

来源错误 映射结果 HTTP
context.DeadlineExceeded errs.KindTimeout(50400) 504
context.Canceled errs.KindUnavailable(50300) 503
唯一约束冲突 errs.KindConflict(40901) 409
其它 errs.InternalWrap(50000) 500

识别冲突发生在包内一个叫 driverCode 的缝上:Postgres 的 23505 与 SQLite 的 2067(扩展码 SQLITE_CONSTRAINT_UNIQUE)通过 方法暴露,按结构匹配;MySQL 把 Number(1062)只暴露成 字段,按具体类型匹配。业务层因此不需要知道背后是哪种库,就能对重复写入返回 409。

// handler 不用判断是哪个库:插入重复 slug 直接就是 409。
_, err := dao.insert(ctx, db, a, now)
if err != nil {
	return nil, err // KindConflict → 409 {"code":40901,…}
}

便携写法的几条硬规矩

迁移:按文件名序,只追加

ApplyMigrations(ctx, dir) 遍历目录里所有 *.sql, 按文件名排序执行未应用的文件,状态记在 schema_migrations 表 (version + applied_at)。每个文件在一个事务里跑完: 语句执行成功后才写入记录,中途失败则整个文件回滚。

make migrate DSN=postgres://...                          # 默认 postgres
go run ./cmd/migrate -driver sqlite -dsn file:matex.db
go run ./cmd/migrate -driver mysql  -dsn 'user:pass@tcp(localhost:3306)/matex?parseTime=true'
追加新文件,不要改老文件 已执行的版本名记在表里,改了老文件的内容也不会重跑——改了等于没改,只有新环境才用得上。 迁移体按 ; 切分语句,所以别在一个文件里写 CREATE FUNCTION 这类 含分号的构造。

PostgreSQL 下整个迁移过程持一把 advisory lock(key 是常量 0x6d61746578,即 "matex"),并且锁与释放都发生在同一条 *sql.Conn 上:多个副本同时启动时,只有一个真正执行 DDL,其余等待。 MySQL 与 SQLite 不加锁(SQLite 是测试驱动;MySQL 需要别致的机制)——已应用状态的检查 加上容忍 IF NOT EXISTS 的 DDL 覆盖了常见情况。

慢查询日志

每次 Exec / Query 都计时。超过 slow_threshold (默认 250ms;Config 未设或小于 0 时回落到这个默认)就记一条 warn,带上操作类型、 耗时、被截断到 200 字符的 SQL 以及错误:

{"level":"WARN","msg":"db slow query","op":"query","dur_ms":412,"sql":"SELECT * FROM articles WHERE slug = ?","err":null}

阈值是每个 *DB 自己的属性,事务里的语句沿用池的阈值。

相关示例