直接用 database/sql + 多个 *sql.db 会出问题,因缺乏主从路由能力,导致事务混用从库报错、读写错发、强一致性逻辑散乱;核心在于连接获取时机与上下文感知能力需在 query/exec 时动态决策。

为什么直接用 database/sql + 多个 *sql.DB 会出问题
Go 原生 database/sql 不提供内置的主从路由能力,硬编码两个 *sql.DB(一个主库、一个从库)再手动分发 SQL,看似简单,实则极易踩坑:事务中混用从库连接会报 sql: Transaction has already been committed or rolled back;读操作误发到主库浪费资源;更麻烦的是,一旦某次查询需要强一致性(比如刚写完立刻查),你得主动“降级”到主库——这种逻辑散落在业务代码里,后期维护成本极高。
关键点在于:主从分离不是“多配几个 DB 句柄”就能解决的,核心是**连接获取时机**和**上下文感知能力**。你需要在 db.Query 或 db.Exec 那一刻,才决定走哪个物理连接,且这个决策必须能被事务、中间件、甚至 context 取消信号影响。
用 sqlmock 测试时如何避免主从逻辑被绕过
很多团队在单元测试里用 sqlmock 模拟单个 *sql.DB,结果测试通过,上线后主从路由失效——因为 sqlmock 默认只 mock 一个驱动,而你的主从路由层可能依赖多个 sql.Open 实例或自定义 sql.Driver。真正可靠的测试方式是:把路由逻辑抽成独立接口(如 type DBRouter interface { GetMaster() *sql.DB; GetSlave() *sql.DB }),然后在测试中注入 mock 实现;或者用 sqlmock 分别注册两个不同 driverName(如 "mysql-master" 和 "mysql-slave"),并在路由层显式调用 sql.Open("mysql-master", ...)。
- 别在测试里直接 new 一个
*sql.DB然后塞进业务结构体——这等于跳过了路由层 - 确保
sqlmock.New()后,调用mock.ExpectQuery的 SQL 模式能匹配你实际发出的语句(注意空格、换行、参数占位符是否一致) - 事务测试必须用
mock.ExpectBegin()+mock.ExpectCommit(),否则tx.Commit()会 panic
基于 context.Context 控制读写分离的最小可行方案
最轻量又可控的方式,是改造你的 DAO 层,让每个数据库操作函数接收 context.Context,并在其中 embed 一个 key 来标记意图:
type routeKey struct{}
func WithMaster(ctx context.Context) context.Context {
return context.WithValue(ctx, routeKey{}, "master")
}
func WithSlave(ctx context.Context) context.Context {
return context.WithValue(ctx, routeKey{}, "slave")
}
func getDB(ctx context.Context) *sql.DB {
if v := ctx.Value(routeKey{}); v == "master" {
return masterDB
}
return slaveDB // 默认走从库
}
这样业务代码可以自然表达语义:
Go语言(Golang)1.26.0版本提供 Go 官方 Windows amd64 MSI 安装包下载入口,版本号 1.26.0,可用于旧项目维护、兼容性测试和指定版本开发环境配置。
// 强一致性读
row := getDB(WithMaster(ctx)).QueryRow("SELECT ...")
// 普通读
row := getDB(ctx).QueryRow("SELECT ...")
// 写操作
_, _ = getDB(WithMaster(ctx)).Exec("INSERT ...")
注意:context.WithValue 不适合传大量数据,但只传字符串标识完全没问题;更重要的是,这个方案不侵入 database/sql 底层,也不依赖第三方 ORM,所有逻辑都在你可控的 DAO 封装层内。
事务中强制使用主库的隐含约束
很多人以为“开启事务后自动走主库”是默认行为,其实不然:sql.Tx 是对单个 *sql.DB 的封装,它本身不关心主从——如果你的 Begin() 是从从库 *sql.DB 调用的,那整个事务就锁死在从库上,后续任何 tx.Query 都无法切回主库,且多数 MySQL 从库默认禁用写操作,直接报错 ERROR 1792 (HY000): Cannot execute statement in a READ ONLY transaction。
所以必须保证:事务的 Begin() 必须从主库 *sql.DB 发起。这意味着不能在 DAO 函数里写 db.Begin() 然后靠 context 判断——因为此时 db 已经是某个具体实例。正确做法是暴露两个方法:
-
BeginTx(ctx context.Context) (*sql.Tx, error)—— 内部调用masterDB.BeginTx -
QueryContext(ctx context.Context, query string, args ...any)—— 根据 ctx 决定用 masterDB 还是 slaveDB 的QueryContext
事务一旦开始,后续所有该 *sql.Tx 上的操作都绑定到那个物理连接,这点没法绕开,只能靠设计约束住入口。
主从分离真正的复杂点不在“怎么连”,而在“什么时候该连哪个”——这个判断必须紧贴业务语义,而不是靠 SQL 自动识别(比如 SELECT 就走从库)。哪怕是最简单的场景,也要守住事务起点和强一致性读这两个锚点,其余才能稳住。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










