sql.DB本身不区分读写语义,读写分离本质是SQL执行路径的路由决策问题,需通过自定义sql.Driver在连接获取时按DSN前缀(rw://或ro://)路由至主库或从库,并确保事务全程绑定同一主库连接。

为什么直接用 sql.Open 无法实现多读一写
因为 sql.DB 本身不区分读写语义,它只是连接池抽象。你调用 db.Query 或 db.Exec 时,底层都可能路由到任意一个已建连的节点——除非你显式控制连接来源。常见错误是:把多个 sql.DB 实例(分别连主库、从库)简单暴露出去,却没隔离事务上下文,导致读操作意外落到主库,或事务内混用读库连接而报错。
关键点在于:读写分离不是连接池配置问题,而是 SQL 执行路径的路由决策问题。
- 事务内所有操作必须走同一数据库实例(通常是主库),否则会破坏一致性
- 非事务性读请求才可调度到从库,且需考虑从库延迟、是否只读、连接健康状态
- 不能靠 DNS 或负载均衡器做读写分离——它们无法感知 SQL 类型或事务状态
用 sql.Driver 封装读写路由逻辑最轻量
Go 标准库允许注册自定义 sql.Driver,你可以在这个层面拦截 Open 和 Conn 获取行为,根据当前上下文返回主库或从库连接。比中间件层(如 ORM 插件)更底层、更可控,也避免侵入业务代码。
示例核心逻辑:
立即学习“go语言免费学习笔记(深入)”;
// 实现 sql.Driver 接口
func (d *RouterDriver) Open(dsn string) (driver.Conn, error) {
// dsn 形如 "rw://..." 或 "ro://...",由上层调用约定
if strings.HasPrefix(dsn, "rw://") {
return d.master.Open(dsn[5:])
}
if strings.HasPrefix(dsn, "ro://") {
return d.slaves[rand.Intn(len(d.slaves))].Open(dsn[5:])
}
return nil, fmt.Errorf("unknown scheme in DSN: %s", dsn)
}
- 业务代码中仍用
sql.Open("router", "..."),但 DSN 带语义前缀 - 事务必须用
rw://开头的 DSN 初始化,确保后续Begin()拿到主库连接 - 普通查询用
ro://,自动轮询从库;若某从库不可用,应在Open中降级或 panic,而非静默失败
事务上下文里误用从库连接会直接 panic
Go 的 sql.Tx 绑定的是单个 driver.Conn,如果事务开始后执行了 tx.Query 却被路由到从库连接,会导致 driver.ErrBadConn 或更隐蔽的“connection closed”错误——因为从库连接不支持事务命令(如 BEGIN、COMMIT)。
- 务必确保
sql.Open("router", "rw://...")创建的*sql.DB专用于事务和写操作 - 不要在事务对象上调用
QueryRowContext以外的方法(如QueryContext)再手动指定从库,这违反事务原子性 - 如果业务需要“事务内查缓存+写主库”,应先完成所有读操作(用只读 DB 实例),再开启事务,而不是在事务中混合读写源
从库延迟导致读不到刚写入的数据
这是最终一致性场景下的固有问题,不是代码 bug。比如用户注册后立即跳转到个人页,页面读从库可能查不到刚插入的记录。
- 对强一致性读(如注册后立刻展示),应强制走主库,可用
context.WithValue(ctx, ctxKeyRoute, "master")+ 自定义QueryContext路由钩子 - 监控从库复制延迟(如 MySQL 的
SHOW SLAVE STATUS中Seconds_Behind_Master),延迟超阈值时自动剔除该从库 - 不要依赖
READ UNCOMMITTED隔离级别来规避——它解决不了主从延迟,反而引入脏读
实际部署时,路由策略、连接健康检查、延迟感知这些逻辑容易堆在一起变成“大杂烩”。建议把路由判定、连接池管理、延迟探测拆成独立小模块,每个只做一件事。否则改一个超时参数,可能让整个读写分离失效。


















