database/sql 不支持读写分离是因为它只提供统一 db 抽象,无内置主从路由逻辑;需手动维护主从两个 *sql.db 实例,封装 routerdb 收口路由,并确保事务全程使用 masterdb。

为什么 database/sql 本身不支持读写分离
Go 标准库的 database/sql 包只提供统一的 DB 抽象,它没有内置“主库写、从库读”的路由逻辑。所有 db.Query、db.Exec 调用都走同一个连接池,你没法靠改配置就自动分流。想实现读写分离,必须自己控制连接来源——也就是在调用前决定该用哪个 *sql.DB 实例。
手动维护主从两个 *sql.DB 实例最直接
这是最可控、最易调试的方式:显式初始化主库和从库连接池,再按需选择使用哪一个。
常见做法:
- 用两个独立的
sql.Open创建*sql.DB,分别连主库(带写权限)和从库(只读) - 主库实例用于
Exec、QueryRow(写操作)、或强一致性读(如刚写完立刻查) - 从库实例用于普通
Query,尤其是列表页、详情页等非关键读场景 - 注意调用
SetMaxOpenConns和SetConnMaxLifetime分别配置,避免从库连接池被主库参数误影响
示例片段:
masterDB, _ := sql.Open("mysql", "user:pass@tcp(10.0.1.10:3306)/mydb")
slaveDB, _ := sql.Open("mysql", "user:pass@tcp(10.0.2.20:3306)/mydb")
// 写
_, _ = masterDB.Exec("INSERT INTO users(name) VALUES(?)", "alice")
// 普通读
rows, _ := slaveDB.Query("SELECT id, name FROM users LIMIT 20")
封装一个 RouterDB 类型来简化调用
手动判断用主还是从,容易漏、难维护。可以封装一层,把路由逻辑收口。
要点:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 定义结构体包含
master *sql.DB和slaves []*sql.DB(支持多从) - 提供
Exec、Query、QueryRow方法,内部根据方法名或上下文决定走哪边 - 读操作可轮询
slaves,用简单取模或随机选一个,避免单点压力过大 - 不要在
Query里自动 fallback 到主库——这会掩盖从库故障,应单独加健康检查机制
例如:
func (r *RouterDB) Query(query string, args ...interface{}) (*sql.Rows, error) {
db := r.slaves[len(r.slaves)-1] // 简单取最后一个,实际可用 rand.Intn(len(r.slaves))
return db.Query(query, args...)
}
事务中必须强制走主库,且不能跨库
这是最容易出错的地方:事务只能绑定到单一数据库连接。如果在事务里混用 slaveDB.Query,会 panic 或返回空结果,因为从库连接根本不在事务上下文中。
正确做法:
- 事务一律从
masterDB.Begin()启动 - 整个
*sql.Tx生命周期内,所有Query、Exec都调用tx.Query/tx.Exec - 不要试图“事务中读从库”——MySQL 的 binlog 复制有延迟,从库读不到刚写入的数据是常态
- 如果业务真需要“写后立即一致读”,要么读主库,要么加应用层重试 + 版本号校验
错误示例(会失败):
tx, _ := masterDB.Begin()
tx.Exec("INSERT ...") // OK
slaveDB.Query("SELECT ...") // ❌ 不在事务中,且连接不同
读写分离不是开个开关就能用的功能,核心在于连接源头的隔离和事务边界的严格把控。最容易被忽略的是从库连接池的独立调优,以及事务中对从库的误调用——这两处一出问题,轻则数据不一致,重则服务卡死。










