☰
分布式数据库代理架构实践:SQL解析、分片路由与读写分离
2026/10/15 5:15:25 网站建设 项目流程

1. 从一个真实瓶颈说起:为什么单库最先扛不住

1.1 单机数据库的死因往往不是容量,而是并发

我做的这个项目,标题就是“分布式数据库代理”,最早不是某个人拍脑袋起的名字,而是被一次事故逼出来的。当时某业务线刚做完一轮大促,应用层扩容得很顺利,但从监控页面上看到数据库主库的CPU已经长时间挂在95%以上,连接数也贴着上限跑。开发们第一反应是“加机器”,可真把数据库当后端服务那样横向扩容,你会发现它根本不是一台无状态节点——你加只读从库,写流量还是压在主库上;你加内存加大磁盘,慢查询照样把IOPS打穿。

单库单表的本质瓶颈是并发,不是存储。一个MySQL实例能同时维护的连接数、能支撑的每秒事务数、能把Buffer Pool命中率维持在健康水位的能力,都是有物理上限的。我当时数过,某社区电商项目上线初期,应用实例20个,每个实例连接池初始10个连接,总共200个连接挂在数据库上。业务一冲高,连接全部被占住,后面进来的请求开始排队,数据库端的线程调度也跟着恶化。最典型的现象是:应用没有报错,但接口RT从50ms缓慢爬到800ms,最终雪崩。慢查询日志里大量“Waiting for table metadata lock”“Waiting for handler commit”这类锁等待,其实都是并发打满后的连锁反应。

这时候你面临两条路。第一条是把业务数据从单库拆出去——分库分表,但代价是应用代码里所有SQL都要改,查询条件必须带上分片键,事务边界要重画,开发周期和风险都难以接受。第二条就是做一个中间层,把数据库路由、分片、合并、读写分离这些逻辑收拢到一个代理服务里,让应用层继续用标准MySQL协议访问,底层怎么拆对上层透明。“分布式数据库代理”这个项目,核心就是在做第二条路。

1.2 代理模式与客户端SDK模式的取舍

分布式数据访问方案里,除了代理模式,还有一个经常被拿来对比的方案:客户端SDK模式,也就是把路由逻辑写进一个依赖库,应用通过这个库来访问底层分片数据库。两种模式各有优劣,我简单列一个对比表,这也是当时团队反复开会讨论的核心。

对比维度代理模式客户端SDK模式
接入成本应用只改数据库地址,业务代码零侵入应用需引入SDK,改造数据访问层
升级能力代理独立部署,升级对业务透明SDK升级要推动所有业务重新发版
额外损耗多一跳网络,但可用本地协议优化网络路径短,延迟略低
管控能力集中审计、限流、脱敏、灰度都在一处管控逻辑分散在各应用进程内
运维复杂性需要维护一个独立中间件集群依赖每个应用的SDK版本规范

我当时在项目里是坚定站代理模式的,主要原因很实际:业务线太多,不同团队用的框架版本不统一,指望所有人统一升级SDK,在一个大公司里基本是奢望。代理模式可以把“数据库运维”和“业务开发”彻底解耦,数据库团队自己维护代理集群,业务方只看到一份连接配置。当然代价也有,就是代理层本身会成为新的故障点和高性能瓶颈点,所以后面所有设计都围绕“如何让代理层稳、快、可扩展”来展开。

2. 架构拆解:一个代理层该怎么划分模块

2.1 代理层在网络拓扑里的位置

先看整体拓扑。应用服务不再直连数据库,而是连接代理层暴露的端口,比如默认3307。代理层根据SQL里的分片条件,把请求转发到后端的某个物理库或分表上,数据返回时再做合并。代理层本身设计成无状态,可以横向部署多个实例,前面挂负载均衡设备,所有元数据都放在配置中心里统一管理。

一条完整请求在代理层内部的流转链路是这样的:客户端建连 → 代理完成MySQL握手协议 → 收到SQL字符串 → 语法解析生成AST → 根据元数据路由到目标节点 → 从后端连接池取出连接下发SQL → 如果涉及多分片则并行执行并归并结果集 → 按MySQL协议把结果写回客户端。整个链路看起来不复杂,但每个环节都有大量细节,比如LOB字段的协议编码、COM_STMT_PREPARE预处理语句的处理、结果集的列信息透传,这些都需要协议层严格对齐MySQL行为,否则客户端驱动会直接报错。

当时我最担心的是代理成为单点,所以从第一版就定了两条硬性原则:第一,代理层进程内不保存任何分片规则之外的有状态业务数据,所有真实表节点信息都从配置中心拉取并缓存;第二,代理实例可以随时摘除和添加,前端应用只要访问虚拟IP地址,后端实例变化对业务无感。这个设计在后来做机房迁移的时候帮了大忙,代理集群整体搬迁,应用层一行代码都没动。

2.2 六个核心模块的职责边界

一个可用的分布式数据库代理,我按职责把它拆成六个模块,每个模块的边界必须清晰,否则后期维护会很痛苦。

协议网关:负责MySQL协议解析和报文编码。前置握手、认证、SQL文本或二进制协议解析都在这一层完成。它对外暴露的是一个标准的MySQL服务端,所以任何支持MySQL协议的语言和框架都能直接连接。

元数据模块:维护逻辑库表到物理库表的映射关系。比如逻辑表 order_t 对应 db0.order_t_0 到 db3.order_t_15 共64张分表。这部分还包括分片算法、全局表标记、只读节点列表、脱敏规则。元数据变更要支持热加载,不能重启代理。

SQL解析引擎:把SQL文本解析成语法树,识别查询类型、涉及的逻辑表、WHERE条件里的分片键、需要改写的字段和排序分页信息。这是整个代理的核心,也是工作量最大的部分。

路由执行引擎:根据解析结果和元数据决定把SQL发到哪些节点。核心逻辑就是“定位分片”,而不是把SQL原封不动丢到所有节点。执行引擎还要负责SQL改写和下推,比如分页参数改写、聚合函数改写。

连接池管理:代理与后端数据库之间维护一组常驻连接。这个模块极其关键,很多连接风暴事故都是因为这里没做好。后文我会详细说参数配置。

治理组件:包括限流熔断、慢SQL审计、数据脱敏、主从延迟控制、灰度路由开关。它让代理从“转发工具”变成“数据库治理入口”,这也是代理模式相比SDK模式最值钱的部分。

六个模块里,SQL解析和路由是最难的,也是最容易出隐蔽Bug的地方。我见过不少代理在常规SQL上跑得很好,遇到子查询、多表JOIN、UNION就直接卡住或者返回错误数据。所以我一直强调:不要把代理当成万能网关,它更擅长处理那些有明确分片键的单表操作,复杂的跨分片查询必须提前在业务侧做约束。

3. 核心实现要点:SQL解析、分片路由与读写分离

3.1 SQL解析与改写:分页、聚合、排序的翻车现场

SQL解析说白了就是把字符串变成一棵AST语法树,然后从树上摘出我们关心的节点。第一步是判断SQL类型,SELECT、INSERT、UPDATE、DELETE走不同分支。第二步是定位涉及哪些逻辑表,拿到逻辑表的元数据。第三步是提取WHERE条件中分片键的等值条件或IN条件,没有等值条件时还要判断该表是单表、全局表还是分片表。

我实际踩过最深的坑是分页改写。假设逻辑表 order_t 按 user_id 分片到16张分表,业务执行一个订单列表查询:

SELECT * FROM order_t WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10 OFFSET 20;

很多初版实现会把这个SQL原封不动发给目标分片,然后在代理层把每个分片返回的前10条直接拼起来。这看起来没错,但实际上是错的。因为LIMIT 10 OFFSET 20的意思是全局第21到30条,而每个分片上的第21到30条并不等于全局的第21到30条——排序是按用户维度分的,一个用户的数据只会落在某一个分片上,如果分片键是user_id,那么这个查询其实只路由到一个分片,问题不大。真正的翻车场景是分片键和查询条件不一致时。比如按 user_id 分片,但业务按商品ID来查所有用户最近的10条评论,又没有把商品ID作为分片键,代理层只能全分片路由。此时每个分片执行 LIMIT 30 OFFSET 0,把所有分片前30条取回,在代理层按 create_time 做全局排序,再截取第21到30条返回。如果每个分片只取10条,全局排序后就会丢掉跨分片数据。这个“取offset+size而不是只取size”的逻辑,是所有手写代理最容易漏掉的地方。

聚合改写同样有讲究。COUNT(*)可以下发到每个分片执行,最后在代理层把所有分片计数相加;但MAX、MIN、SUM这类函数,如果查询带有GROUP BY,分组键和分片键不一致,就必须把所有分片的分组结果先取回,再在代理层重新做一次分组聚合。至于ORDER BY,如果排序键也是分片键,那么天然可以在分片内排序后直接拼接;否则同样需要全量取回再排序。所以解析阶段不仅要识别“是什么分片键”,还要识别“排序字段和分片键的关系”。

3.2 分片键与路由算法:Hash取模不是万能的

路由算法直接决定数据分布是否均匀、后续扩容是否痛苦。项目里常用的分片策略有三类。

第一类是Hash取模,表达式是 shard_index = hash(shard_key) % shard_count。实现简单,数据分布通常比较均匀,但一旦分片数固定,扩容就需要双倍迁移数据。我们项目早期用户表按 user_id % 16,后来要扩到32,就必须把旧数据按新规则重新分布,这个过程使用迁移工具做了整整一个周末,还因为切流窗口太短出过几次校验不一致。

第二类是Range范围分片,按 user_id 的区间划段,比如1到1000万放分片0,1000万到2000万放分片1。好处是范围查询天然友好,扩容时按需增加新区段即可;坏处是可能数据倾斜严重,高活跃用户扎堆在一个区间会把分片打爆。我们后续在做冷热数据分层时,才真正发挥了Range策略的优势。

第三类是一致性哈希,解决的是“分片数量变化时大量数据迁移”的问题。数据节点在哈希环上离散分布,新增节点时只需要迁移部分数据,听起来很完美,但它有两个问题:一是范围查询完全不可用,二是分布也可能不均匀,需要引入虚拟节点技术来平滑。我们最终是把一致性哈希用在缓存节点上,数据库分片还是用Hash加Range混合。

分片键的选择比算法本身更重要。我总结了两条硬性规则:第一,分片键必须是业务查询中最稳定的等值条件,我们选了 user_id,那所有核心查询必须带 user_id;第二,分片键要分布均匀,如果业务有少数超大客户,必须考虑单独为大客户扩容分片或者加盐拆分。记住一句话:分片键选错了,后面所有路由、事务、查询优化都是白搭。

3.3 读写分离、连接管理与分布式事务

读写分离是代理层的标准功能,核心难点不在“把SELECT转到从库”,而在“怎么保证一致性”。解析出SQL类型后,SELECT默认走从库,INSERT/UPDATE/DELETE走主库,但还要考虑三种特殊情况:事务内所有SQL必须强制主库,因为从库可能还没同步到事务提交的数据;带FOR UPDATE的SELECT必须走主库;刚从写接口提交完立刻发起的读请求,要小心主从延迟。我们当时实现了一个简单策略:应用可以在连接上下文里标记“forceMaster”,或者代理根据事务状态自动路由。

连接管理方面,代理和后端数据库之间必须用连接池复用,否则前端1万个连接进来,代理给每个连接单独建后端连接,数据库瞬间被打爆。我当时是从一次事故里彻底理解这个问题的:某次压测配置没调好,代理层对每个前端连接都新建后端连接,导致MySQL端连接数直接涨到几千,最后DBA在半夜把连接数上限改了才救回来。一个健康的后端连接池参数大概是这样的:minimumIdle 8,maximumPoolSize 50,connectionTimeout 1000ms,idleTimeout 600000ms,maxLifetime 1800000ms。注意maximumPoolSize不是越大越好,数据库服务线程数是有限资源,池子越大只会在高并发下把数据库线程耗尽。

再讲分布式事务。代理层如果允许多个分片参与一个事务,有两种处理方式。一种是本地事务,前提是业务的所有相关表都在同一分片上,代理只路由到该分片,事务就是普通的数据库事务,性能最好。另一种是跨分片事务,意味着代理要协调多个物理库,常见方案是XA两阶段提交,但XA的同步阻塞和协调者单点问题在高并发场景下很难看。我们项目实际上的做法是尽量从业务建模层面消灭跨库事务,把强相关数据放到同一分片;实在无法消灭的,采用最终一致性方案,比如用本地消息表把“扣库存”和“更新订单状态”拆成两步异步执行。分布式数据库代理解决的是路由问题,不是磁悬浮式的分布式事务银弹,这个预期管理一定要做在前面。

4. 实操过程:从零搭一个最小可用的代理层

4.1 环境与配置:先定体量再定分片规则

实操部分我用一个模拟项目X来演示,只含订单表 order_t。假设当前数据量预估一年增长5000万条,单库MySQL到3000万条时性能明显下滑,所以我们提前定4个物理库、每库4张分表,总16个分片,分片键 user_id。整体配置放在一个配置文件里,核心内容如下。

{ "proxy": { "port": 3307, "auth": { "username": "proxy_user", "password": "encrypted-text" }, "registry": "configCenter://db-proxy/node-list" }, "logicTable": { "order_t": { "shardKey": "user_id", "shardCount": 16, "shardStrategy": "hash", "actualDataNodes": [ "db0.order_t_0", "db0.order_t_1", "db0.order_t_2", "db0.order_t_3", "db1.order_t_4", "db1.order_t_5", "db1.order_t_6", "db1.order_t_7", "db2.order_t_8", "db2.order_t_9", "db2.order_t_10", "db2.order_t_11", "db3.order_t_12", "db3.order_t_13", "db3.order_t_14", "db3.order_t_15" ] } }, "readWriteSplit": { "enabled": true, "primary": "db0-0.db0", "replicas": ["db0-0.replica0", "db0-0.replica1"] }, "connectionPool": { "maximumPoolSize": 50, "minimumIdle": 8, "connectionTimeout": 1000, "idleTimeout": 600000, "maxLifetime": 1800000 } }

这里有个容易忽略的点:分片序号和物理库的对应关系一定要在文档里写清楚。我们通常按“库序号 × 每库分表数 + 表后缀”来命名,比如 db1.order_t_7,说明是第1个物理库里的第7号分片表。如果命名混乱,排查问题时找表都要找半天。

配置完成后,验证路由是否正确的第一步是做一个“分片定位自检”。写一个测试SQL,比如 user_id = 5,手算一下期望分片号:假设 user_id = 5,128,3 分别对应分片 5 % 16 = 5,128 % 16 = 0,3 % 16 = 3。然后查看代理日志里实际路由到的节点名,如果和手算一致,说明基础路由没问题。这个自检脚本我建议保留下来,后续每次改分片算法都跑一遍。

4.2 核心流程的代码级串联

代理核心入口主要是处理一条SQL的生命周期。我用偏Go风格的伪代码描述这个流程,方便理解每个环节的职责。

func handleQuery(sql string, session *Session) (*ResultSet, error) { // 1. 解析SQL为语法树 stmt, err := parser.Parse(sql) if err != nil { return nil, fmt.Errorf("parse sql error: %w", err) } // 2. 判断是否包含分片键 shardKey := extractShardKey(stmt.WhereCondition) if stmt.Table.IsSharded() && shardKey == "" { if stmt.Table.IsGlobal() { // 全局表广播到所有分片,取第一个成功的 return routeGlobal(stmt, session) } // 没有分片键的分片表查询直接拒绝,避免全路由 return nil, errors.New("shard key is required for sharded table") } // 3. 根据分片值和算法计算目标节点 targetNodes := make([]string, 0) if stmt.Table.IsSharded() { targetNodes = routeByHash(shardKey, stmt.ShardValues(), 16) } else { targetNodes = []string{stmt.Table.PhysicalNode()} } // 4. 改写SQL:分页参数、聚合函数等 rewrittenSQL := rewriteSQL(stmt, targetNodes) // 5. 并行下发到目标分片 results := make([]*ResultSet, 0, len(targetNodes)) for _, node := range targetNodes { conn := pool.Get(node) results = append(results, conn.Query(rewrittenSQL)) pool.Put(node, conn) } // 6. 合并结果集并返回 return mergeResults(stmt, results) }

实际工程里,第1步解析会占用相当多的CPU,所以一定要做解析缓存。我们当时做了一个基于SQL文本加粗命名的LRU缓存,命中率大概在80%以上,代理层的CPU使用率瞬间降了30%。第5步的并行下发一定要控制并发度,不能所有分片同时全打,新增一个全局并发上限,比如10个并行任务,多的排队等待,否则数据库端会出现瞬时压力尖峰。第6步合并结果集要做流的处理,不能把所有分片结果都加载进内存再排序,数据量一大就会OOM,最好是用迭代器逐行归并。

4.3 灰度发布:先让一条业务线走代理

代理层上线最忌讳“全部流量一次性切过去”。我们当时的策略是:先拿一条流量规模最小但业务足够复杂的业务线做试点,用配置开关控制只有该业务线的数据源指向代理,其他业务线继续直连旧库。同时在代理层记录完整请求日志和响应时间,和旧链路做对比。

灰度分四步走:第一步10%流量,主要是验证协议兼容性,观察应用端有没有报错;第二步加到30%,开始看数据库节点上的SQL分布是否符合预期,有没有流量异常集中;第三步加到50%,重点盯代理自身的CPU、内存和连接数,以及后端数据库的连接池水位;第四步全量。整个过程期间,应用端的切换只需改数据库连接地址,代码零改动。一旦出现严重问题,回滚就是把连接地址改回去,代理层设计成无状态让摘除没有任何负担。

我还建议在灰度过程中做一个“影子流量对比”:复制部分真实请求同时打到代理层链路和设备直连链路,对比返回结果是否一致。这个方式能提前发现代理层SQL改写错误和结果集合并问题。我们就是通过这种对比,抓到一个子查询改写后排序顺序不一致的Bug,修复了才敢继续加流量。

5. 常见问题与排查技巧实录

5.1 问题速查表:一条条对号入座

代理层上线后,踩坑是常态。我整理了这段时间最常遇到的问题,做成一张速查表,很实用。

现象可能原因定位手段解决思路
后端数据库连接数暴涨代理未启用连接池或池参数过大看数据库的 processlist,统计连接来源IP开启后端连接池,限制maximumPoolSize
刚插入的数据读不到主从延迟,SELECT走了从库查看从库延迟时间事务内强制主库;写后读场景加路由hint
分页翻页时有重复和漏数据分页改写没有取offset+size对比改写前后SQL和返回结果修正rewriteSQL分页逻辑
某分片数据量远超其他分片分片键选择不当导致倾斜统计各分片表行数重新选分片键或大客户独立分片
代理层CPU高但数据库CPU不高大量复杂SQL全路由或解析性能低开启解析缓存命中率监控强制SQL带分片键;优化解析缓存
跨分片事务经常超时回滚锁等待或XA协调问题查看代理层事务日志业务拆分到单分片,尽量用最终一致

这表里的前三条我都真实遇到过,每条背后都有一段加班排查的经历。所以下面单独讲三个印象最深的事故。

5.2 事故一:连接“假释”把数据库连接池打爆

第一次压测的时候,我发现数据库端连接数随着并发上升一路飙升,完全停不下来。查了一圈,发现代理层虽然号称做了连接池,但实现里有个低级错误:每个前端连接第一次请求时从池里借连接,归还逻辑只在连接关闭时触发,正常情况下连接空闲10分钟后才归还。也就是说,一个前端连接如果只发了一条SQL就挂着不动,后端连接就会被白白占用。压测里大量前端连接建立后保持空闲状态,后端连接池里的连接全被“假释”占用,数据库端连接数自然爆了。

定位方式很简单:在数据库端执行连接查询,把所有状态为Sleep、持续超过30秒的连接标记出来,再关联到代理节点和前端客户端IP,马上就能看出是哪些连接没归还。修复方案是重写连接池的借还逻辑,保证每次查询完成后都归还连接,同时给空闲连接增加idleTimeout回收,并加了一个监控面板实时显示“池中空闲连接数”和“借出连接数”。这个教训让我后来对连接池的所有借出归还路径都做了单元测试,包括异常分支。

5.3 事故二:深度分页把一个分片拖死

上线第二周,运营同学反馈一个管理后台的订单列表翻到第50页左右时页面要等4秒多。查慢查询日志,定位到一个针对 order_t 的查询:LIMIT 98000, 20。这个SQL的offset相当大,代理层把它改写后下发给分片,数据库需要扫描并排序9980行,同时还要在代理层合并16个分片各返回的20行,翻页越深开销越大,最终把一个分片节点CPU拉满。

我们最后给出的限制方案有两条:管理后台的列表查询限制最大offset为10000,超过阈值会提示使用“上一页/下一页”式游标,不允许随机跳页;同时为导出功能单独建了汇总统计表,按天异步推进,避免直接跑深度分页。对于普通C端用户,也统一改成基于游标的分页方式,用上一页最后一条记录的时间戳作为过滤条件。从那以后,深度分页相关的慢查询从排行榜上彻底消失了。

5.4 事故三:跨分片事务回滚后造成重复补偿

还有一次是下单场景。业务里订单表在分片3,库存表在分片7,两个分片不在同一个物理库,代理层自动开启了XA事务。平时流量低相安无事,但大促时XA协调超时,事务回滚,应用层捕获异常后走了重试逻辑,结果重试时前一个事务的补偿消息还没完全处理掉,库存被扣了两次。这个事故本质上不是代理的Bug,而是业务边界设计问题,但代理层把跨分片事务包装得像本地事务一样透明,给了业务错觉,让大家忽略了跨库事务的脆弱性。

解法还是我们前面反复强调的:调整分片策略,让订单表和库存表按相同user_id落到同一分片,事务退化为本地事务;不能落在同一分片的,明确拆成异步库存扣减加消息队列对账的最终一致方案。同时把代理层对XA的支持降级为默认关闭,只有显式开启的少量场景才允许跨分片事务。代理这个角色,该透明的地方要透明,不该透明的地方反而要“刺眼”一点,让开发同学清楚地知道哪些操作有风险。

6. 几点不吐不快的体会

这个项目做下来,我最深的感受是:分布式数据库代理不是一个“写个代理转发SQL”的纯技术工具,它更像是一面映射了业务建模水平的镜子。很多问题表面上出在代理层,根子都在业务一开始没想清楚分片键、事务边界和查询模式。如果你手里的业务早期数据量不大,切不要急着上代理,先梳理核心表之间的关系,能单库解决的问题用单库解决,等真到了单库天花板,再引入代理把复杂规则沉淀成平台能力。

其次,监控比功能更重要。代理层上线前,至少要把连接数、路由命中率、SQL改写错误数、后端连接池水位、各分片延迟这几个指标先埋好,否则你会在问题爆发后才后知后觉。我们后来甚至给路由命中率加了报警阈值,因为正常业务下分片SQL命中率应该无限接近100%,一旦低于98%,多半是有团队在滥用全路由查询,这时候靠代理强制约束比事后排查更高效。

最后说一个实用小技巧:代理层故障演练一定要做。我们每个月都会随机摘掉一台代理实例,验证前端连接自动重连是否正常、后端连接池是否能快速补齐,同时做一次分片节点宕机的模拟切换。演练过程会暴露出很多平时根本不会触发的边界问题,比如某些驱动缓存了旧连接导致10秒超时、代理实例摘除时未排空存量请求。把这些边界都磨平之后,代理集群才算真正从“能跑”走到了“敢扛”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询