Gin 系列教程 (02):Go 里的数据库访问层该怎么选
在 Go 的 Web 项目里,数据库访问层的选型争论从来没停过:database/sql 原生接口、sqlx、GORM,还是更轻量的 sqlc?每个选项背后都是不同的工程权衡,和”哪个更好用”关系不大,和”你愿意把多少控制权交给库”关系很大。
这篇文章从 database/sql 出发,讲清楚它的设计模型,然后说清楚什么情况下往上加一层抽象是合理的。
database/sql 的设计模型
Go 标准库的 database/sql 是一个通用的数据库接口层,驱动和接口分离。MySQL 驱动本身不在标准库里,需要单独引入:
1 | |
1 | |
sql.Open 不建立实际连接,只是注册配置。db.Ping() 才触发第一次连接验证:
1 | |
db 对象是并发安全的连接池,整个进程生命周期持有一个实例即可,不要在每次请求里 Open/Close。
连接池是第一个要调的参数
database/sql 内置连接池,默认参数在生产环境通常不够用:
1 | |
几个判断依据:
MaxOpenConns:不是越大越好。MySQL 服务端有max_connections限制,多实例部署时需要算好总量。MaxIdleConns:空闲连接保留在池里复用,设得太低会导致频繁建连,太高浪费文件描述符。ConnMaxLifetime:连接存活上限,防止数据库侧主动断开导致的invalid connection错误。
连接池的实际效果需要结合 db.Stats() 观测:
1 | |
查询的基本模式
database/sql 的查询分三类,接口设计反映了底层行为差异:
单行查询
1 | |
QueryRow 不返回 error,错误延迟到 Scan 时暴露。sql.ErrNoRows 需要显式处理,它不是”错误”,是”无结果”。
多行查询
1 | |
rows.Err() 是常见的遗漏点——迭代过程中的网络错误会在这里体现,循环正常结束不代表没有错误。
写操作
1 | |
Prepared Statement 的实际意义
原始代码里 db.Prepare + Exec 的写法在连接池场景下有一个反直觉的细节:Stmt 是绑定在特定连接上的,当连接池切换连接时,驱动会在新连接上重新 Prepare。频繁的短连接场景里,Prepared Statement 的性能优势会被重新 Prepare 的开销抵消。
实际工程里,大多数场景直接用 db.Exec 和 db.Query 的占位符参数即可,驱动内部同样会做参数化处理,防注入的效果是一样的。Prepared Statement 真正有价值的场景是同一语句的高频批量执行。
事务
1 | |
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 里的参数绑定与校验——这是框架抽象价值最集中的地方,也是实际项目里最容易踩坑的环节。