读写分离的核心问题:流量在哪一层分流?
在 Go 项目里实现数据库读写分离,绕不开的一个问题是:分流逻辑到底放在哪里?在 Go 生态里,主流做法就两种,一种是把路由逻辑做进应用里的中间件模式,另一种是单独部署一层数据库代理的 proxy 模式。这篇文章就围绕这两种模式,聊聊它们各自的取舍以及常见的坑。

中间件模式:把路由逻辑写进 Go 应用
中间件模式不是指 HTTP 中间件,而是指以库或框架形式集成到 Go 应用的数据库中间件。大多数 Go 项目会直接用 GORM,它自带了一个插件 dbresolver,可以比较方便地实现主从路由。
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/plugin/dbresolver"
)
func main() {
db, _ := gorm.Open(mysql.Open("master:3306"), &gorm.Config{})
err := db.Use(dbresolver.Register(dbresolver.Config{
Sources: []gorm.Dialector{mysql.Open("master:3306")},
Replicas: []gorm.Dialector{mysql.Open("slave1:3306"), mysql.Open("slave2:3306")},
Policy: dbresolver.RandomPolicy{},
}))
_ = err
}
上面的配置注册了一个主库和两个从库,并选择了随机策略。当 GORM 收到写操作或者事务时,它会强制走主库;普通查询则会根据策略分到从库。对于已经使用 GORM 的项目,这几乎是成本最低的实现方式。
即便你不使用 GORM,也可以基于 database/sql 自己封装一个路由层:解析 SQL 前缀,区分 SELECT、INSERT、UPDATE 等,然后从连接池里选择对应实例。核心逻辑并没有多难,真正麻烦的是事务、延迟、从库故障这些边界。
中间件模式的优点很明显:没有额外节点,部署简单;路由逻辑在应用进程里,可以方便地和 context 结合;从库连接可以和主库连接复用同一个连接池,便于精细控制。比如对某个需要强一致性的读请求,你可以通过 context 标记强制走主库。
缺点同样现实:路由能力完全取决于你用的库或者你的实现。GORM 的 dbresolver 只识别通过 ORM 发出的 SQL,如果你的项目里有大量原生 SQL,某些特殊语句可能被错误路由。从库扩容时要改代码并重新发布。更重要的是,主库故障切换时,中间件模式通常需要应用感知新拓扑,一般要配套一个配置中心才能做到半自动。
Proxy 模式:把复杂性下沉到独立代理
proxy 模式则是把读写分离这件事从应用里挪出来,单独部署一个代理服务。应用不再直连数据库实例,而是连接代理层,由代理负责识别 SQL 类型、把请求分发到主库或从库。常见的开源方案有 ProxySQL、MaxScale、Atlas,更重的则有 Vitess 这类分布式数据库中间件。
ProxySQL 是很多团队的首选,它本身支持复杂的规则系统。例如可以配置主库和一个从库组,然后用 query rules 将非事务内的 SELECT 路由到从库,其余都走主库。配置思路大致如下:
mysql_servers:
hostgroup 10: master
hostgroup 20: slave1, slave2
mysql_query_rules:
rule 1: SELECT 语句且非事务内 -> 路由到 hostgroup 20
rule 2: 其他 SQL -> 路由到 hostgroup 10
代理模式带来的第一优点是透明。业务代码不用关心后端有多少个从库,只需要修改数据库连接地址到代理即可。第二是集中管理,如果多个团队、多个服务都要共用同一套读写路由策略,代理是天然的集中控制点。第三是连接复用,代理可以维护到数据库的连接池,应用连接代理时相当于拿了一个逻辑连接,底层复用物理连接,这样可以有效控制数据库的连接数。
代价也很明确。第一是增加了一层网络跳转,虽然延迟通常只有毫秒级,但在高并发、对延迟敏感的系统里,不能忽略不计。第二是代理本身需要高可用。如果你只部署一个代理节点,它反而会成为单点;一旦挂了,所有服务都会失去数据库连接。第三是 SQL 解析成本。代理要在内存中解析每一句 SQL 再做路由,复杂 SQL 或特殊语法可能解析失败。另外,ProxySQL 这类代理在应对大规模分库分表时能力有限,更重的方案又需要投入很高的维护成本。
中间件模式与 Proxy 模式的关键差异
如果把两种模式放到一张表里,它们在各维度上的区别会更直观:
| 维度 | 中间件模式 | Proxy 模式 |
|---|---|---|
| 部署位置 | 应用进程内 | 独立服务 |
| 实现难度 | 低,库或框架自带 | 较高,需要额外部署和高可用 |
| 性能开销 | 低,无额外网络跳转 | 有代理层延迟和 SQL 解析开销 |
| 路由控制 | 代码内硬编码,可精细 | 规则配置,集中且灵活 |
| 多语言支持 | 每种语言各自实现 | 跨语言统一 |
| 故障切换 | 依赖应用重连逻辑 | 代理层可自动处理 |
| 适用规模 | 中小团队、单应用 | 中大型系统、多团队共享 |
这张表里没有绝对的好坏,关键看你的系统规模和团队能承受的运维复杂度。很多项目一开始用中间件模式,业务增长后切换到代理,完全是个正常的演进过程。
如果你还不太确定当前阶段该不该上代理,可以对照下面几个信号:
- 出现多个语言或服务,无法用一套 Go 中间件统一管理路由策略。
- 应用代码里开始大量出现强制走主库的逻辑,路由判断散落在各处。
- 数据库连接数成为瓶颈,希望通过代理层的连接复用降低后端负载。
- 团队有专职 DBA 或中间件团队,能承接代理的运维和监控。
Go 项目里到底怎么选?我的建议
没有银弹。如果你的团队规模不大、技术栈以 Go 为主、数据库实例数量有限,优先选择中间件模式。它最快、最直接,也最容易在代码里调整路由策略。尤其当你的数据库压力还没有到必须精细管理每条 SQL 的时候,中间件模式足够。
如果你已经走到了多服务、多语言、数据库实例较多的阶段,或者你们有专职的 DBA 队伍,可以考虑引入 proxy 模式。它带来的统一治理能力远远超过额外那一跳的延迟成本。但注意,不要把代理模式理解成一次性方案,选型前一定要想清楚运维能力和成本预算。
还有一种比较稳的演进路径:先上中间件模式,通过代码实现读写分离,把业务跑顺,同时预留数据库代理的抽象层,比如使用标准 SQL,避免数据库私有语法,为后续迁移代理层留好空间。当压力真的来临时,再切换到 proxy 模式,业务代码改动会很小。
不管选哪种,这几个坑你会反复踩
先说主从延迟。读写分离后,你写入一条数据后立刻去查询,很可能从库还没同步到,导致读到旧值。解决办法要么是让关键查询强制走主库,要么引入基于版本号或者时间戳的延迟机制。GORM dbresolver 提供一个 Use 方法,可以针对某个 session 强制用主库:
db.Clauses(dbresolver.Use("master")).Find(&user)
再说事务。如果你在一个显式事务里有读有写,中间件模式通常会识别出当前处于事务中,自动把所有请求都发到主库。但如果是自己实现的 route 层,很容易忽略这个细节。在 proxy 模式中,同样要保证事务内的语句不经过读写分离规则,否则可能造成数据不一致。
最后是从库故障。从库挂掉时,请求继续打到从库就会报错。好一点的中间件能从连接池中剔除故障节点,但很多库在运行中的动态剔除并不完善。ProxySQL 这类代理自带健康检查,能自动把故障从库摘掉,但这需要你在代理层投入更多维护工作。
读写分离从来不是一锤子买卖,它和主从同步、连接管理、服务治理结合在一起。中间件模式和 proxy 模式的区别,本质上是把复杂性放在应用内还是应用外。对大多数 Go 中小团队来说,先以中间件模式快速实现,能解决绝大部分问题;当系统规模到了中间件模式难以支撑的阶段,再平滑迁移到 proxy 模式,算是条比较务实的路径。希望这篇能帮你少踩几个坑。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/808/