go中读写分离需手动管理masterdb和slavedb两个sql.db实例,写与事务强制走主库,读默认走从库但需健康检查与延迟感知,orm需禁用自动路由并显式传递主从意图。

Go 里用 sql.DB 做读写分离,本质是管理多个连接池
Go 标准库的 sql.DB 本身不支持自动读写分离,它只是一个连接池抽象,不区分主从语义。你要做的不是“配置一个带读写分离的 DB”,而是手动控制:写操作走 masterDB *sql.DB,读操作走 slaveDB *sql.DB。关键在调用层路由,不在驱动层。
常见错误是试图用单个 sql.DB 实例加中间件或钩子拦截 SQL 类型——这不可靠:SQL 解析复杂(比如 SELECT ... FOR UPDATE 是读还是写?),且无法覆盖事务内混合操作。
- 所有写操作(
INSERT、UPDATE、DELETE、CREATE等)必须显式使用masterDB - 事务必须全程绑定到
masterDB,不能跨主从开启或提交 - 读操作默认走
slaveDB,但需预留降级开关(如从库延迟高时切回主库读)
如何安全地在事务中处理读写混合逻辑
事务天然要求强一致性,所以即使你只想在事务里查点数据再更新,也必须全部走主库。否则可能读到旧快照,或因从库复制延迟导致业务逻辑错乱(比如先查余额,再扣款,结果查的是 1 秒前的值)。
典型陷阱是封装了一个“读方法”并默认用 slaveDB,但在事务上下文中误调用:
tx, _ := masterDB.Begin()
tx.QueryRow("SELECT balance FROM accounts WHERE id = $1", 123) // ✅ 正确:显式用 tx(来自 masterDB)
// ❌ 错误:下面这行会走 slaveDB,破坏事务隔离
// slaveDB.QueryRow("SELECT balance FROM accounts WHERE id = $1", 123)
tx.Commit()
- 事务对象
*sql.Tx只能从创建它的*sql.DB获取,不能混用 - 不要为
*sql.Tx包装“通用查询方法”,容易隐式泄漏主从边界 - 如果真需要事务内只读校验,仍用
tx.Query*,哪怕只是 SELECT
从库负载不均?别只靠随机选节点,得看复制延迟和健康状态
简单轮询或随机选一个 slaveDB 很容易把流量打到延迟高、甚至已断连的从库上。Go 里没有现成的“带健康检查的从库负载均衡器”,得自己补一层。
实际做法是维护一个可变的从库列表,每个节点附带:lastReplicationLag(秒级)、isHealthy(心跳检测结果)、weight(静态权重)。每次读请求前做轻量筛选:
- 过滤掉
!isHealthy或lastReplicationLag > 10的节点 - 剩余节点按
weight加权随机选,而非纯随机 - 定期异步更新各节点状态(例如每 5 秒发一次
SHOW SLAVE STATUS或pg_replication_slots查询)
这个逻辑不能放在每次 Query 调用里同步执行,否则拖慢读性能;要提前缓存+后台刷新。
ORM(如 GORM)里怎么避免被自动路由带偏
GORM v2 默认不支持主从分离,v2.2+ 提供了 Resolver 接口,但它的行为依赖你注册的 Resolve 函数是否准确识别读写意图。问题在于:GORM 把 First()、Find() 当作读,但 Save()、Create() 是写——这看似合理,却漏掉边界情况:
-
db.Where("id = ?", 1).Updates(&user)是写,但若字段为空,GORM 可能生成SELECT先查再更新,此时第一次查询走了从库就错了 -
db.Raw("SELECT * FROM orders WHERE status = 'paid'").Rows()明确是读,但如果你没配好Resolver,可能误走主库
更稳妥的做法是:关闭 GORM 自动路由,改用 db.WithContext(context.WithValue(ctx, ctxKeyDBType, "master")) 手动透传意图,再在 Resolver 里取值判断。这样控制权始终在业务代码手里,而不是赌 ORM 的 SQL 分类逻辑。
真正难的不是连上两个库,而是让每个 SQL 在该走的地方走,且不因 ORM 封装、框架扩展、日志中间件等引入意外跳转。这点在灰度发布或分库分表叠加时会更明显。











