Gin 系列教程 (02):Go 里的数据库访问层该怎么选
在 Go 的 Web 项目里,数据库访问层的选型争论从来没停过:database/sql 原生接口、sqlx、GORM,还是更轻量的 sqlc?每个选项背后都是不同的工程权衡,和“哪个更好用”关系不大,和“你愿意把多少控制权交给库”关系很大。
这篇文章从 database/sql 出发,讲清楚它的设计模型,然后说清楚什么情况下往上加一层抽象是合理的。
database/sql 的设计模型
Go 标准库的 database/sql 是一个通用的数据库接口层,驱动和接口分离。MySQL 驱动本身不在标准库里,需要单独引入:
go get github.com/go-sql-driver/mysql
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/dbname?parseTime=true")
if err != nil {
return err
}
defer db.Close()
sql.Open 不建立实际连接,只是注册配置。db.Ping() 才触发第一次连接验证:
if err := db.Ping(); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}
db 对象是并发安全的连接池,整个进程生命周期持有一个实例即可,不要在每次请求里 Open/Close。
连接池是第一个要调的参数
database/sql 内置连接池,默认参数在生产环境通常不够用:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(10 * time.Minute)
几个判断依据:
MaxOpenConns:不是越大越好。MySQL 服务端有max_connections限制,多实例部署时需要算好总量。MaxIdleConns:空闲连接保留在池里复用,设得太低会导致频繁建连,太高浪费文件描述符。ConnMaxLifetime:连接存活上限,防止数据库侧主动断开导致的invalid connection错误。
连接池的实际效果需要结合 db.Stats() 观测:
stats := db.Stats()
// stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount
查询的基本模式
database/sql 的查询分三类,接口设计反映了底层行为差异:
单行查询
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = ?", id).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
QueryRow 不返回 error,错误延迟到 Scan 时暴露。sql.ErrNoRows 需要显式处理,它不是“错误”,是“无结果”。
多行查询
rows, err := db.Query("SELECT id, name FROM users WHERE status = ?", status)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
rows.Err() 是常见的遗漏点——迭代过程中的网络错误会在这里体现,循环正常结束不代表没有错误。
写操作
result, err := db.Exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
name, email,
)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
Prepared Statement 的实际意义
原始代码里 db.Prepare + Exec 的写法在连接池场景下有一个反直觉的细节:Stmt 是绑定在特定连接上的,当连接池切换连接时,驱动会在新连接上重新 Prepare。频繁的短连接场景里,Prepared Statement 的性能优势会被重新 Prepare 的开销抵消。
实际工程里,大多数场景直接用 db.Exec 和 db.Query 的占位符参数即可,驱动内部同样会做参数化处理,防注入的效果是一样的。Prepared Statement 真正有价值的场景是同一语句的高频批量执行。
事务
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p)
}
}()
if _, err := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID); err != nil {
tx.Rollback()
return err
}
if _, err := tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
BeginTx 比 Begin 多一个 context.Context 参数,可以把请求的取消信号传进来,避免长事务在请求超时后还在占用数据库连接。
什么时候考虑加一层抽象
database/sql 的问题不在于功能,在于模板代码太多:每次 Scan 都要手写字段列表,结构体变化时容易遗漏,大型项目维护成本高。
几个常见的上层选项:
sqlx:database/sql 的轻量封装,最大价值是 StructScan 和 NamedExec,直接把查询结果映射到 struct,不需要手写 Scan 参数列表。不引入 ORM 的抽象,SQL 还是自己写,迁移成本接近零。
sqlc:从 SQL 文件生成类型安全的 Go 代码。SQL 是“源码”,Go 代码是生成物。适合 SQL 比较稳定、对类型安全要求高的团队,代价是多了一个代码生成步骤。
GORM:功能最全,代价是最厚的抽象层。适合快速原型或者 CRUD 密集型的后台系统,但一旦需要复杂查询、性能调优,抽象层的成本就会显现。
选哪个不取决于项目大小,取决于团队对 SQL 的掌控意愿。如果愿意自己写 SQL,sqlx 或 sqlc 通常是更稳的选择。
下一篇会讲 Gin 里的参数绑定与校验——这是框架抽象价值最集中的地方,也是实际项目里最容易踩坑的环节。
← 所有文章