直接用 sql.DB 做读写分离会出问题,因其不区分读写语义,手动分库导致事务失效、上下文丢失、连接泄漏及读取过期数据。

为什么直接用 sql.DB 做读写分离会出问题
Go 标准库的 sql.DB 本身不区分读/写语义,所有 Exec、Query、QueryRow 都走同一连接池。你手动切到两个不同 sql.DB 实例(一个连主库、一个连从库)后,事务、上下文传播、连接生命周期管理就全乱了——比如在事务中调用从库 Query,实际执行时可能根本不在事务上下文里,或者读到过期数据。
常见错误现象:sql: Tx.Commit: database is closed、从库查不到刚写入的数据、事务跨库失效、连接泄漏。
- 事务必须全程绑定单一
*sql.Tx,不能中途切库 -
Query和Exec的语义由 SQL 内容决定,不是由调用方决定;但 Go 层无法自动解析 SQL 判断读写意图 - 多数 ORM(如 GORM)的
Session或WithContext不天然支持动态路由,需显式指定ReadReplica
用 GORM v2 实现带事务感知的读写分离
GORM v2 提供了 Resolver 接口和 replica 配置,能根据当前是否在事务中自动路由:事务内全部走主库,非事务查询默认走从库。这是目前最稳妥的开箱方案。
关键配置点:
- 主库地址填入
gorm.Open(mysql.Open(dsnMaster), ...)的第一个参数 - 从库列表通过
Config.Resolver设置,例如&gorm.Config{Resolver: &resolver.DefaultResolver{Replicas: []gorm.Dialector{mysql.Open(dsnSlave1), mysql.Open(dsnSlave2)}}} - 必须启用
PrepareStmt: true,否则某些预处理场景下路由失效 - 手动开启事务时,GORM 自动将后续所有操作锁定到主库,无需额外标记
示例片段:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
db, _ := gorm.Open(mysql.Open(dsnMaster), &gorm.Config{
Resolver: &resolver.DefaultResolver{
Replicas: []gorm.Dialector{mysql.Open(dsnSlave)},
},
PrepareStmt: true,
})
// 这个 Query 走从库
db.Where("status = ?", "active").Find(&users)
// 这个事务内所有操作都走主库
db.Transaction(func(tx *gorm.DB) error {
tx.Create(&user)
tx.Model(&user).Update("name", "new")
return nil
})
手写中间层做 SQL 类型判断的风险点
有人尝试封装一层 DBRouter,用正则匹配 SQL 前缀(如 ^SELECT)来决定走哪边。这在简单场景看似可行,但极易翻车:
-
SELECT COUNT(*) FROM (UPDATE ... RETURNING id)是合法 SQL,但本质是写操作 - PostgreSQL 的
WITH RECURSIVE或 MySQL 的INSERT ... SELECT混合读写,正则无法识别 - ORM 生成的 SQL 常带注释、换行、空格,正则易误判
- PREPARE / EXECUTE 协议下,SQL 文本在 prepare 阶段已解析,运行时只传参数,无 SQL 可匹配
更糟的是:一旦路由错误,写请求发到从库,MySQL 直接报错 ERROR 1792 (HY000): Cannot execute statement in a READ ONLY transaction,而 PostgreSQL 是 ERROR: cannot execute INSERT in a read-only transaction —— 这类错误往往在压测或上线后才暴露。
什么时候该放弃读写分离
不是所有场景都适合读写分离。如果你的应用满足以下任意一条,先别急着加从库:
- 写多读少(比如日志归档、实时计数更新),加从库反而因主从延迟导致读取不一致,还增加同步压力
- 用的是 SQLite、TiDB Serverless 或 Cloud SQL 的高可用实例,底层已做透明负载分发
- QPS
- 业务强依赖“写后立即读”,又无法接受
SELECT ... FOR UPDATE或显式db.WithContext(context.WithValue(ctx, "force_master", true))控制
真正需要读写分离的,通常是读 QPS 突增、主库 CPU 长期 > 70%、且能容忍秒级延迟的报表或列表页场景。这时候,GORM 的 Resolver 是起点,但要注意:它不解决跨库 JOIN、全局唯一 ID、从库慢查询监控等衍生问题。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










