直接用 database/sql + 驱动足够,无需 ORM;应封装薄层(50 行内),显式管理连接池、时间解析、事务与错误处理,暴露清晰底层语义,避免自动映射和隐式事务。

直接用 database/sql + 驱动就够了,别急着上 ORM
绝大多数内部服务、脚手架或中小项目,根本不需要 gorm 或 xorm。它们带来的抽象层在简单 CRUD 场景下反而增加心智负担和调试成本——比如字段名映射错误、预编译语句失效、事务嵌套失控。用原生 database/sql 封装一层薄包装,50 行内就能搞定增删改查+连接池管理,且完全可控。
关键不是“封装得多”,而是“暴露得少”。只导出 Query、Exec、BeginTx 这类底层语义清晰的操作,不隐藏 SQL,不自动拼接 WHERE。
-
sql.Open后必须调用db.Ping(),否则连接可能延迟到第一次查询才暴露失败 - 连接字符串里加
&parseTime=true&loc=Local(MySQL)或&_loc=Local(SQLite),否则时间字段解析会出错 -
db.SetMaxOpenConns(20)和db.SetMaxIdleConns(10)必须显式设,否则默认 0(无限制),容易耗尽数据库连接
QueryRow 和 Query 别混用,返回值类型要对齐
这是新手最常踩的坑:QueryRow 返回单行,Scan 失败会直接报错;Query 返回多行,必须手动 rows.Next() + rows.Scan(),漏掉 rows.Close() 会导致连接泄漏。
封装时建议统一用 QueryRow 处理单条记录(如根据 ID 查详情),用 Query 处理列表(如分页查询)。不要试图用一个函数兼容两者——类型擦除后容易 panic。
立即学习“go语言免费学习笔记(深入)”;
- 用
QueryRow时,即使 SQL 可能返回空结果,也要用err == sql.ErrNoRows显式判断,不能忽略 err - 用
Query时,defer rows.Close()必须写在rows, err := db.Query(...)之后立即执行,哪怕后面还有 error check - 如果结构体字段名和数据库列名不一致(比如
user_name→UserName),老老实实用AS别名:"SELECT user_name AS user_name FROM users",别依赖反射自动映射
事务别藏在封装里,显式传参比隐式上下文更可靠
很多封装把 Begin/Commit/Rollback 包进一个函数里,还塞进 context 或 middleware。这会让调用方失去对事务边界的掌控——比如想在一个事务里执行多次不同操作,但封装强制你每次调用都新开事务。
正确做法是暴露 BeginTx 和 *sql.Tx 类型,让业务代码自己决定何时开始、提交或回滚。封装层只负责提供 tx.Query、tx.Exec 这些透传方法。
- 不要写
WithTx(func() error { ... })这种闭包封装,它掩盖了事务失败时的 rollback 路径 - 如果真要用闭包,至少确保内部 panic 也能触发 rollback,且 error 不被吞掉
-
tx.Commit()和tx.Rollback()都可能返回 error,必须检查——尤其是Commit失败时,不代表数据没写入
结构体字段私有化 + 方法接收指针,避免意外复制
封装模块的主结构体(比如叫 DB 或 Storage)字段必须小写私有,所有操作通过方法暴露。更重要的是:所有方法接收者必须是 *DB,不是 DB。Go 的 struct 是值类型,传值会复制整个结构体,包括里面的 *sql.DB 指针——虽然指针本身复制没问题,但容易误导使用者以为它是线程安全的,而实际上 sql.DB 本身是并发安全的,但封装层若带缓存或状态就未必。
- 别写
func (d DB) Query(...),必须是func (d *DB) Query(...) - 初始化函数返回
*DB,而不是DB,避免调用方误用值接收者 - 如果封装里需要维护连接状态(比如重试计数、慢查询日志),这些字段一定要小写,且只在方法内修改
真正难的不是写封装,是克制——不加自动迁移、不加软删除标记、不加全局 hook。每加一行“便利”代码,就多一分不可控。把 SQL 留给业务层写,把连接池和错误处理留给自己管,边界清晰了,后期替换驱动或接入中间件才不会翻车。


















