分布式数据库代理架构实践:SQL解析、分片路由与读写分离

发布时间:2026/10/15 5:15:28
分布式数据库代理架构实践:SQL解析、分片路由与读写分离 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条全局排序后就会丢掉跨分片数据。这个“取offsetsize而不是只取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万放分片01000万到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 8maximumPoolSize 50connectionTimeout 1000msidleTimeout 600000msmaxLifetime 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 51283 分别对应分片 5 % 16 5128 % 16 03 % 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分页翻页时有重复和漏数据分页改写没有取offsetsize对比改写前后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秒超时、代理实例摘除时未排空存量请求。把这些边界都磨平之后代理集群才算真正从“能跑”走到了“敢扛”。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询