go微服务中读写分离需手动控制sql路由,database/sql不感知主从;必须封装dbrouter按操作类型选择主从实例,并注意for update和事务内所有语句走主库。

读写分离不是加个 Slave 就能自动生效
Go 微服务里配了主从库,但所有请求还是打到主库——这是最常见的误判。根本原因在于,database/sql 本身不感知主从,也无路由逻辑;你得自己控制 *sql.DB 实例的流向。
典型做法是封装一个 DBRouter,根据操作类型(SELECT vs INSERT/UPDATE/DELETE)返回不同实例。但要注意:SELECT 不等于读操作——带 FOR UPDATE 的查询必须走主库,否则会报 ERROR 1205 (HY000): Deadlock found 或数据不一致。
- 事务内所有语句默认走主库,哪怕第一条是
SELECT - 显式指定从库时,需禁用事务上下文(
tx := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})不够,要确保没开Begin()) - 用
context.WithValue透传路由偏好(如ctx = context.WithValue(ctx, dbRouteKey, "slave")),比解析 SQL 更可靠且可控
sharding 必须在 DAO 层拦截,ORM 不会帮你分表
很多团队试图靠 gorm 的 TableName 回调或第三方插件实现分表,结果发现关联查询、事务、预编译都出问题。本质是:分库分表是数据访问层契约,不是 ORM 配置项。
真正可行的方式是在 DAO 接口层面做抽象,例如定义 func (r *UserRepo) GetByID(ctx context.Context, id uint64) (*User, error),内部根据 id % 4 算出物理表名和对应 *sql.DB 实例,再拼接查询。
- 分片键(shard key)必须是查询条件的一部分,否则无法路由;
WHERE name = ?无法分片,WHERE user_id = ?才可以 - 避免跨分片
JOIN:要么改用应用层合并(适合小结果集),要么把关联字段冗余进分片表 - 自增主键失效,改用
shard_id + timestamp + seq组合生成全局唯一 ID,github.com/sony/sonyflake是轻量选择
连接池配置不当会让读写分离和分片形同虚设
一个 *sql.DB 实例配了 SetMaxOpenConns(100),但后端有 4 个分片库——实际每库只承受 25 连接,而主库可能被写请求打满,从库却空闲。更糟的是,如果所有分片共用同一连接池参数,某个热点分片会耗尽连接,导致 sql.ErrConnDone 频发。
必须为每个物理库单独管理连接池:
- 主库连接池:偏高
MaxOpen(如 200)、较低MaxIdle(如 20),容忍短时写高峰 - 从库连接池:按读负载比例分配,比如读流量 80%,4 个从库各配
MaxOpen=150 - 分片库连接池:每个分片独立初始化
sql.Open,不要复用同一个*sql.DB - 务必设置
SetConnMaxLifetime(建议 1h),避免 MySQL 的wait_timeout导致连接静默失效
分库分表后,ORDER BY 和 LIMIT 要重写逻辑
假设按 user_id 分 4 表,查“最新 10 条订单”,直接在每个分片查 LIMIT 10 再合并,结果可能漏掉第 9–10 名的真实数据。这不是 bug,是分布式排序的本质限制。
解决方案取决于场景精度要求:
- 近似分页(如 Feed 流):各分片取
LIMIT 20,内存合并后取 top 10,加缓存防重复计算 - 强一致性分页(如后台管理):先查各分片满足条件的总条数,再用
OFFSET拆解到各分片,最后归并;代价高,慎用 - 避免
ORDER BY create_time DESC LIMIT 10 OFFSET 1000这类深分页,改用游标(WHERE create_time )
分库分表没有银弹,最易被忽略的是时间窗口一致性——比如跨分片转账,主库已提交,但从库延迟 200ms,此时读从库会看到旧余额。这种场景必须强制走主库,或者引入 binlog + 消息队列 做最终一致补偿。











