GORM v2 默认 DB 实例基于单个 sql.DB 连接池,不区分读写语义,所有操作均从同一池取连接,无法自动路由;读写分离必须显式构造主从两个独立 gorm.DB 实例,分别配置、调优并按语义调用,否则会因混用导致只读库写入失败或数据不一致。

为什么 GORM 的 DB.ConnPool 不支持直接读写分离
GORM v2 默认的 DB 实例底层用的是单个 sql.DB 连接池,它本身不区分读/写语义。你调用 db.Create() 或 db.First(),GORM 都只是往同一个连接池里取连接——不会自动切到从库。所谓“读写分离”,必须由上层显式控制路由逻辑。
常见误区是以为配置多个 DSN 就能自动分流,其实 GORM 官方没提供开箱即用的读写分离中间件。得自己搭骨架,再把读/写操作分别导向不同 *gorm.DB 实例。
怎么手动构造主库/从库两个 *gorm.DB 实例
最轻量的做法:用 gorm.Open() 分别连主库和从库,得到两个独立的 *gorm.DB。注意它们共享同一套模型定义(db.AutoMigrate() 只需在主库调一次),但事务、连接池、日志等完全隔离。
- 主库实例只用于
Create/Save/Delete/Transaction等写操作 - 从库实例只用于
First/Find/Count/Raw(只读 SQL)等读操作 - 避免混用:比如用从库实例调
Save(),虽然语法不报错,但会触发写操作失败(如果从库设为只读)或主从不一致 - 连接字符串里建议显式加
readTimeout=3s&writeTimeout=10s,防止从库慢查询拖垮主库连接池
示例:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
立即学习“go语言免费学习笔记(深入)”;
// 主库(可写)
master, err := gorm.Open(mysql.Open("user:pass@tcp(10.0.0.1:3306)/db?charset=utf8mb4"), &gorm.Config{})
// 从库(只读)
slave, err := gorm.Open(mysql.Open("user:pass@tcp(10.0.0.2:3306)/db?charset=utf8mb4&readOnly=1"), &gorm.Config{})
怎么在业务代码里安全地选库,避免手滑写错
硬编码 master.First() 或 slave.Save() 很容易出错。推荐封装一层路由逻辑,用函数名或上下文标记意图:
- 定义两个包级变量:
var Master *gorm.DB和var Slave *gorm.DB,初始化后全局只读 - 写操作统一走
Master.Transaction()或Master.Create();读操作统一走Slave.First()—— 命名即契约 - 如果要用
db.Scopes()或自定义Callbacks,确保它们不跨库调用(比如在BeforeCreate里又去查从库,会破坏事务一致性) - 特别注意
Preload:默认走当前db实例,如果用Slave.Preload("User"),关联查询也发往从库;但若主键在主库刚插入,从库可能还没同步,导致 Preload 返回空
从库延迟高时,哪些读操作必须强制走主库
MySQL 主从延迟是常态。不是所有读都能扔给从库——一旦涉及“刚写完立刻要读”的场景,必须降级到主库,否则数据对不上。
- 用户注册后立即查自己的资料:
Master.First(&user, "email = ?", email) - 支付成功后查订单状态:
Master.Where("order_id = ? AND status = 'paid'", id).First(&order) - 任何在同一个 HTTP 请求里先
Save再Find的链路,只要中间没加 sleep 或重试,一律走主库 - 可以加个简单 wrapper 判断是否需要强一致:
if isStrongConsistencyRequired(ctx) { return Master } else { return Slave },把判断逻辑收口,别散落在各处
主从切换不是银弹。延迟低时靠从库分担压力,延迟高时得敢切回主库——这个边界得由业务语义决定,没法全交给框架。

















