很多 Go 项目在起步阶段,数据库操作会直接选择 GORM 或者 sqlx。前者用起来快,但黑盒感重,后者更接近原生 SQL,却依然要靠手写字段映射和字符串拼接。真正让团队痛苦的,往往不是 SQL 本身,而是这一类代码在长期维护中逐渐失控——字段名改了编译器不会报错,条件拼接出现逻辑漏洞,查询性能在数据量上来后才发现问题。

为了在 Go 里既能写 SQL,又能享受类型安全,sqlc 和 pgx 的组合逐渐被更多项目采用。sqlc 在编译期根据你写的 SQL 生成 Go 代码,pgx 作为 PostgreSQL 的原生驱动,在连接管理和性能上都有明显优势。这篇文章会结合实际工程场景,聊聊这套方案为什么值得考虑,以及落地时会遇到哪些坑和取舍。
为什么需要类型安全的 SQL
大多数团队对 ORM 的抱怨集中在三点:生成的 SQL 不可控,复杂查询性能差,以及隐式行为太多。比如 GORM 的自动迁移,在多人协作时很容易把表结构改得面目全非。而直接用 sqlx 或 database/sql 写 SQL,虽然完全可控,但字段扫描和类型转换的错误只有到运行时才能发现——如果你重构时把 user_name 改成了 username,代码编译通过,一上线就炸。
sqlc 的做法很直接:你定义好 SQL 查询文件,sqlc 解析这些 SQL 和数据库 schema,然后生成类型安全的 Go 代码。如果 SQL 里引用了不存在的列,或者参数类型不匹配,代码根本无法生成。这就把本该在测试阶段暴露的问题,提前到了编译期甚至更早。
sqlc 的核心工作方式
sqlc 不是 ORM,它只是一个代码生成器。你需要准备三样东西:
- 数据库 schema(DDL 文件)
- 查询文件(.sql)
- sqlc 配置文件(sqlc.yaml)
然后运行 sqlc generate,它就会生成 Go 代码,里面包含一个 Queries 结构体,每个方法都对应一条你定义的 SQL。这些方法接收的参数和返回的 struct 都是精确类型,绝不会出现 interface{}。下面是一个简单例子:
-- schema.sql
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL,
bio text
);
-- query.sql
-- name: GetAuthor :one
SELECT id, name, bio FROM authors
WHERE id = $1;
生成的代码大概长这样:
type GetAuthorRow struct {
ID int64
Name string
Bio sql.NullString
}
func (q *Queries) GetAuthor(ctx context.Context, id int64) (GetAuthorRow, error) {
// ...
}
注意返回的 GetAuthorRow 是 sqlc 根据查询结果自动推导的,字段名和类型完全匹配。如果你在 SQL 里选了一个不存在的列,sqlc generate 会直接报错,根本不会产出代码。这种体验,对于习惯了手写 SQL 又害怕运行时出错的开发者来说,非常友好。
pgx:不只是驱动
很多人以为 pgx 只是 database/sql 的一个 PostgreSQL 驱动替代,实际上它远不止于此。pgx 提供了原生的连接池、更快的编码解码、以及对 PostgreSQL 协议的直接支持。sqlc 默认支持 pgx 的 pgx/v5 库,这意味着生成的代码可以直接使用 pgx 的 pgxpool 连接池,而不走 database/sql 的抽象层。
使用 pgx 的原生连接池,可以避免很多传统驱动中的性能损耗,比如数据类型转换的额外开销。而且 pgx 对 COPY 协议、批量操作、Listen/Notify 等高级特性支持更好,这些在需要高性能数据导入或实时通知的场景里非常关键。
sqlc + pgx 的组合优势
当 sqlc 生成代码直接依赖 pgx 时,整个数据访问层就变得非常紧凑:没有多余的 ORM 层,没有反射,没有动态 SQL 构建。你可以像写普通 SQL 一样写查询,但得到的是编译期类型检查。这种组合在以下场景中优势尤其明显:
- 项目初期就需要稳定、可维护的数据访问层,且团队有一定 SQL 能力
- 对查询性能敏感,希望避免 ORM 生成的复杂 SQL 或不必要的字段加载
- 需要利用 PostgreSQL 特有功能(如 JSONB、全文检索、窗口函数)且不想被 ORM 屏蔽
但也要注意,sqlc 极度依赖 SQL 本身,如果你的查询逻辑需要在运行时动态拼接大量条件,那么 sqlc 的原生支持会比较弱,需要额外处理。
三种方案对比:sqlc vs GORM vs sqlx
| 维度 | sqlc + pgx | GORM | sqlx |
|---|---|---|---|
| 类型安全 | 编译期生成代码,SQL 错误在生成时暴露 | 运行时映射,字段名变更有风险 | 运行时扫描,字段变更需手动检查 |
| SQL 控制力 | 完全手写 SQL,可审查 | 大部分自动生成,难以优化 | 手写 SQL,完全控制 |
| 性能 | 接近原生 pgx,无反射 | 反射开销,复杂查询可能慢 | 接近原生,但映射有开销 |
| 动态查询 | 需额外处理(如手动拼接条件) | 条件构建方便 | 条件构建方便 |
| 学习成本 | 需要理解 SQL 和生成流程 | 低,但高级用法曲线陡 | 中等,SQL 能力要求高 |
这个对比不是要分出绝对好坏,而是帮助你在不同约束下做选择。对于一个 3 人后端团队,业务复杂度中等,且不希望被 ORM 绑架,sqlc 的组合往往能减少大量排错时间。
落地时最容易踩的坑
很多团队第一次用 sqlc 时,会卡在动态查询上。比如有一个管理后台,需要根据多个可选筛选条件查询用户列表,条件可能是 name、email、status 的任意组合。sqlc 很直白:它只认你写好的 SQL。如果你为每个条件组合都写一条 SQL,文件会爆炸。现实做法是:
- 把核心查询写成 sqlc 查询,那些固定且常执行的查询依然由 sqlc 管理
- 对于动态部分,在代码层用 pgx 的原生查询构建器(如
pgx.Query)配合条件拼接,但要严格检查参数 - 或者使用像
squirrel这样的查询构建库,只生成 WHERE 部分,仍用 sqlc 处理结果映射
另一个常见误区是认为 sqlc 可以替代数据库迁移工具。sqlc 只负责生成查询代码,它不管理 schema 的版本变更。你仍然需要 golang-migrate 或 atlas 这样的工具来管理 DDL 迁移。把 schema 文件和迁移脚本放在一起,确保 sqlc 读取的 schema 与当前数据库版本一致,这一点非常重要。
还有一个容易忽略的点:sqlc 默认生成的代码会使用 context.Context,所以所有查询方法都要求传入 context。这符合 Go 的最佳实践,但在一些老代码迁移时,你会需要把多处 db.Query 调用改成带 context 的版本,这个过程可能比想象中繁琐。
从零开始接入 sqlc + pgx 的建议路径
假设你启动一个新项目,或者想在一个中型项目中引入这套方案,可以参考以下步骤:
- 先用
golang-migrate管理数据库迁移,把所有 DDL 文件放在一个目录下。 - 在项目根目录创建
sqlc.yaml,指定 schema 路径、查询路径和代码生成目标(推荐使用 pgx/v5)。 - 编写第一个查询文件,从最简单的 SELECT 开始,跑通
sqlc generate。 - 在 main 函数中初始化 pgxpool,把连接池传给生成的
Queries实例。 - 随着业务发展,将动态查询部分抽象出来,保持 sqlc 只处理结构固定的查询。
如果你已经有一个基于 GORM 的项目,不适合全量重写。可以先把新功能的数据访问层用 sqlc 实现,旧的部分保持不变。通过接口隔离,让两种方式共存,逐步替换掉 ORM 代码。
在什么场景下 sqlc 可能不是最优解
虽然 sqlc 很优秀,但有些场景下它的优势会被削弱。比如你的应用需要频繁地跨多种数据库(MySQL、PostgreSQL、SQLite),sqlc 需要为每个数据库引擎维护不同的查询文件,因为 SQL 方言不同。这时 sqlx 或 ORM 的跨数据库抽象可能更有价值。
另外,如果团队内部 SQL 能力参差不齐,或者业务逻辑非常复杂,需要大量动态查询、多重嵌套条件,sqlc 的静态特性反而会成为负担。这种情况下,配合一个轻量级的查询构建器,或者退回到 sqlx 会更灵活。
最后,如果你对 PostgreSQL 的高级特性用得少,只是在做简单的 CRUD,那么 sqlc 带来的额外生成步骤可能抵消不了它带来的好处。但一旦你开始使用窗口函数、CTE、JSONB 操作,手写 SQL 的愉悦感配合类型安全,会让你觉得这一切都值得。
保证类型安全不是为了限制,而是让 SQL 在 Go 代码中成为一个可信赖的组件,而不是一个潜在的 bug 来源。
总的来说,sqlc + pgx 并不是一个“银弹”,但它确实能解决很多 Go 项目中长期存在的 SQL 维护问题。如果你正打算开始一个新项目,或者对现有 ORM 的不满已经积累到一定程度,不妨给这套组合一个机会。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/381/