不能靠单个 sql.DB 实例加 SQL 解析做读写分离,因为 database/sql 和驱动均不解析 SQL 语义,无法识别 SELECT ... FOR UPDATE 等写语义查询;正则匹配易误判,ORM 生成语句复杂多变;事务内混用主从库破坏 ACID 隔离性;主从库负载特征不同,需独立连接池与调优;GORM 应使用 dbresolver 插件并显式声明读写意图。

为什么不能靠单个 sql.DB 实例加 SQL 解析做读写分离
因为 database/sql 层根本不解析 SQL 语义——它只负责把字符串原样发给驱动,而 MySQL/PostgreSQL 驱动也不做路由判断。你写的 "SELECT * FROM users WHERE id = ? FOR UPDATE" 在驱动眼里只是普通字节流,不会触发“这是写锁,得走主库”的逻辑。
常见错误是用正则匹配 "SELECT" 就发到从库,结果遇到 SELECT ... FOR UPDATE 或 WITH RECURSIVE 带写语义的查询,直接在从库报错:ERROR 1290 (HY000): The MySQL server is running with the --read-only option 或 PostgreSQL 的 cannot execute SELECT FOR UPDATE in a read-only transaction。
- ORM(如 GORM)生成的语句可能以
SELECT开头但实际执行写锁,无法靠前缀识别 - 注释、大小写、子查询嵌套会让字符串匹配完全失效
- 事务内哪怕只调一次
QueryRow,如果发到从库,就破坏了 ACID 中的隔离性
必须用两个独立的 *sql.DB 实例并分开调优连接池
主库和从库不是“同一个 DB 换个地址”,而是负载特征完全不同的服务端点:主库写多、延迟敏感、连接数少;从库读多、可容忍延迟、连接复用率高。共用连接池会导致监控失真、熔断失效,甚至主库被从库慢查询拖垮。
初始化时必须各自调用 sql.Open,再单独设置参数:
- 主库:
db.SetMaxOpenConns(20)、db.SetConnMaxLifetime(5 * time.Minute)、db.SetWriteTimeout(3 * time.Second) - 从库:
db.SetMaxOpenConns(100)、db.SetConnMaxLifetime(10 * time.Minute)、db.SetReadTimeout(10 * time.Second) - 每个实例都必须显式
db.Ping()校验连通性,失败立即返回 error,别等第一次 query 才暴露问题
事务内所有操作必须绑定同一个 *sql.DB 实例
一旦调用 masterDB.Begin(),得到的 *sql.Tx 只能执行 tx.Query、tx.Exec 等方法——这些调用底层仍走主库连接。如果你在事务里误调 slaveDB.QueryRow(...),那就等于开了两个独立会话,刚 INSERT 的数据在从库查不到,业务逻辑直接错乱。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
最容易踩的坑是封装了一个全局 ReadDB() 函数,然后在事务函数里无意识调用它。正确做法是让事务上下文携带数据库意图:
- 用
context.WithValue(ctx, dbKey, masterDB)显式透传主库实例 - 业务层从 ctx 取
*sql.DB,而不是查全局变量或调封装函数 - 禁止给
*sql.Tx包装任何“通用查询方法”,它的存在本身就意味着主库绑定
GORM 用户请务必用 dbresolver 插件而非手写路由
GORM v2 的 dbresolver 是少数真正可靠的支持读写分离的方案,但它不是自动 SQL 分析器——它靠的是你在调用时显式声明意图。默认规则简单明确:Create/Update/Delete/Transaction 走 sources(主库),Find/First/Where 走 replicas(从库)。
但注意两个硬约束:
-
Raw("INSERT INTO ...")默认走从库,会直接报错,必须加.Clauses(dbresolver.Write) - 强一致读(比如刚
Create完立刻Find)不能依赖默认行为,得用db.Clauses(dbresolver.Write).Find()或db.Session(&gorm.Session{NewDB: true}).Find() - 从库延迟不是插件能解决的,GORM 不重试、不等待、不校验复制位点——这得由业务层兜底,比如关键读操作后 sleep 100ms 再查,或用 binlog 位点对齐
最常被忽略的是:db.Transaction 回调里的所有链式调用(包括 Where().Select().Scan())都自动走主库,但如果你在回调里又调了另一个封装函数,而那个函数内部用了 ReadDB(),就彻底绕过了 resolver 的控制。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










