Gin 系列教程 (02):Go 里的数据库访问层该怎么选

在 Go 的 Web 项目里,数据库访问层的选型争论从来没停过:database/sql 原生接口、sqlx、GORM,还是更轻量的 sqlc?每个选项背后都是不同的工程权衡,和”哪个更好用”关系不大,和”你愿意把多少控制权交给库”关系很大。

这篇文章从 database/sql 出发,讲清楚它的设计模型,然后说清楚什么情况下往上加一层抽象是合理的。


database/sql 的设计模型

Go 标准库的 database/sql 是一个通用的数据库接口层,驱动和接口分离。MySQL 驱动本身不在标准库里,需要单独引入:

1
go get github.com/go-sql-driver/mysql
1
2
3
4
5
6
7
8
9
10
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() 才触发第一次连接验证:

1
2
3
if err := db.Ping(); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}

db 对象是并发安全的连接池,整个进程生命周期持有一个实例即可,不要在每次请求里 Open/Close。


连接池是第一个要调的参数

database/sql 内置连接池,默认参数在生产环境通常不够用:

1
2
3
4
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() 观测:

1
2
stats := db.Stats()
// stats.OpenConnections, stats.InUse, stats.Idle, stats.WaitCount

查询的基本模式

database/sql 的查询分三类,接口设计反映了底层行为差异:

单行查询

1
2
3
4
5
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 需要显式处理,它不是”错误”,是”无结果”。

多行查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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() 是常见的遗漏点——迭代过程中的网络错误会在这里体现,循环正常结束不代表没有错误。

写操作

1
2
3
4
5
6
7
8
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.Execdb.Query 的占位符参数即可,驱动内部同样会做参数化处理,防注入的效果是一样的。Prepared Statement 真正有价值的场景是同一语句的高频批量执行。


事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()

BeginTxBegin 多一个 context.Context 参数,可以把请求的取消信号传进来,避免长事务在请求超时后还在占用数据库连接。


什么时候考虑加一层抽象

database/sql 的问题不在于功能,在于模板代码太多:每次 Scan 都要手写字段列表,结构体变化时容易遗漏,大型项目维护成本高。

几个常见的上层选项:

sqlxdatabase/sql 的轻量封装,最大价值是 StructScanNamedExec,直接把查询结果映射到 struct,不需要手写 Scan 参数列表。不引入 ORM 的抽象,SQL 还是自己写,迁移成本接近零。

sqlc:从 SQL 文件生成类型安全的 Go 代码。SQL 是”源码”,Go 代码是生成物。适合 SQL 比较稳定、对类型安全要求高的团队,代价是多了一个代码生成步骤。

GORM:功能最全,代价是最厚的抽象层。适合快速原型或者 CRUD 密集型的后台系统,但一旦需要复杂查询、性能调优,抽象层的成本就会显现。

选哪个不取决于项目大小,取决于团队对 SQL 的掌控意愿。如果愿意自己写 SQL,sqlx 或 sqlc 通常是更稳的选择。


下一篇会讲 Gin 里的参数绑定与校验——这是框架抽象价值最集中的地方,也是实际项目里最容易踩坑的环节。


Gin 系列教程 (02):Go 里的数据库访问层该怎么选
https://www.krli.org/2018/04/03/Gin-系列教程-02-使用MySQL数据库/
作者
李科燃
发布于
2018年4月3日
许可协议