数据库
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(),此时启动直接失败。
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`)
扫描的类型转换是固定的:
- NULL 进指针字段即置 nil;进
sql.Null*或任何自定义sql.Scanner则交给该类型自己解码。 - 时间列是
time.Time就用它;是字符串则依次按 RFC3339Nano、2006-01-02 15:04:05.999999999-07:00、2006-01-02 15:04:05、2006-01-02解析。 - 数值与布尔支持跨驱动的宽松转换(如
[]byte→int64), 溢出即报错,不静默截断。 []byte是唯一支持的切片字段类型。
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 // 事务里能读到本事务自己的写
})
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
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,…}
}
便携写法的几条硬规矩
- 主键用
VARCHAR(n),别用TEXT——MySQL 不能索引裸 TEXT 主键。 - 别依赖
AUTO_INCREMENT/SERIAL/IDENTITY;ID 由应用生成。 - 时间戳在 Go 里格式化后写入(
time.Now().UTC().Format(time.RFC3339)), 不要now()/CURRENT_TIMESTAMP。 - upsert 三方言不同(
ON CONFLICT/ON DUPLICATE KEY/INSERT OR REPLACE):跨库就先UPDATE,RowsAffected()==0再INSERT。 RETURNING只有 postgres / sqlite 有;MySQL 用LastInsertId或再查一次。
迁移:按文件名序,只追加
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 自己的属性,事务里的语句沿用池的阈值。
相关示例
-
examples/database:三驱动、
?占位符、泛型扫描、WithTx、DAO 进出事务。 - 缓存模式:把回源包在
cache.GetOrLoad里。 - 离线测试:
dbtest.Start(t)起一个已迁移的库。 - 包参考:
pkg/core/db的导出面。