分布式数据库代理:分库分表后的路由、连接池与读写分离实战

发布时间:2026/9/30 3:42:13
分布式数据库代理:分库分表后的路由、连接池与读写分离实战
接手过一个日订单量百万级的数据平台之后你多半会被一个问题反复折磨单库的连接数已经打满读写全挤在同一个实例上事务越来越慢扩容一次要停机半小时。这时候大多数人会想到分库分表可真把表拆完你又发现应用里到处都是改不完的数据源切换逻辑。分布式数据库代理这层中间件就是在你被这种“拆了表却管不住连接”的痛打醒之后最值得花时间认真研究的东西。它本质上是一个放在应用和真实数据库节点之间的调度层负责把一条SQL按规则送到该去的节点把连接池管起来把主从延迟、故障切换这类细节挡在应用外面。这篇文章不聊教科书理论只讲我在真实环境里拆解这个中间件时的设计思路、核心参数和踩坑记录适合正在规划分库分表、又不想把底层细节全塞给业务代码的架构师和资深后端。1. 为什么需要分布式数据库代理从单库瓶颈到中间件层的定位1.1 单库的容量与连接瓶颈绝大多数业务起步时就是一台数据库数据量在百万以内时单实例完全够用。但数据量一旦涨到千万甚至亿级单库的问题就不是“慢”这么简单了它会同时卡在三个地方存储空间、CPU/IO、连接数。存储空间相对最直观你可以在一个实例上堆很多张表但单机的磁盘和内存终究有限。更棘手的是CPU和IO一个高峰期的订单查询如果没建对索引全表扫描直接吃掉整台机器的内存带宽。而连接数这个问题最容易被忽视MySQL默认的max_connections通常只有一两百但你的应用可能有好几个微服务每个服务又起了50个连接几个服务一叠连接池直接爆掉。数据库不是不让你连是连了之后每条SQL都要经过解析、优化、锁竞争连接一多单库的吞吐量反而断崖式下降。还有个隐藏瓶颈是读多写少。电商、内容、社交类系统读请求往往是写请求的十倍以上。单库只能同时扛这么多查询你要扩读能力常规操作是搞一主多从但应用直连多个从库的话谁来选哪台从库响应最快主库挂了你让应用怎么切换这些问题落到代码层面就是一堆脏活。1.2 应用直连多节点的混乱分库分表之后最直接的问题来了原来一个订单表现在拆成8张甚至64张应用层代码怎么写你可以自己封装一个路由工具在DAO层硬编码比如“order_id % 8 0就查order_0等于1就查order_1”。这样写是可以但每多一个分片、每改一次分片规则都要发一次完整的应用版本同事之间互相覆盖代码的现场惨不忍睹。更麻烦的是连接管理。如果8个分片每个都配一主一从那就是16个数据源。应用要维护16个连接池还要自己处理主从切换。主库挂掉的那一刻你以为是数据库的事但实际上数据库早就把角色切好了只是你的应用还在死死的连那个旧IP。这时候你要么改配置重启要么在代码里做高可用切换两样都够折腾。还有数据聚合的问题。查询订单列表按买家ID分片那没问题但后台运营要按日期、按状态汇总分片之后你得先把所有分片的数据捞出来再在内存或临时表里做合并。没有一层帮你做“合并查找”这些脏活就全落在业务层里。你说“我全部联表都只在库里做”现实是分片之后你连union all都要自己拼SQL。1.3 代理层在架构中的位置与职责边界分布式数据库代理就是从这个痛点里长出来的产物。它竖在应用和数据节点中间应用只连代理代理再去连背后那一堆真实的数据库实例。应用发一条SQL过来代理先解析一下判断这条SQL要操作哪个逻辑表再根据分片规则算出应该去物理分片几然后把SQL改写成带着真实表名和库名的形态发到对应的节点等结果拿回来再返回给应用。这个位置决定它的职责很清晰做路由、做连接治理、做故障屏蔽但绝不负责算数据和存数据。你对它做水平扩容是很容易的多部署几个代理节点前面挂个负载均衡即可。代价就是多了一层网络跳转单条SQL的响应时间会增加几百微秒到一毫秒左右在普通业务里完全可以接受但如果你是那种单次查询就要跑10秒的重分析任务代理层的转发开销反而可以忽略不计。代理层的边界感很重要我见过不少团队想往代理里加自定义SQL函数、加存储过程最后全都变成了一个巨大的“翻译官”越搞越复杂。记住一条原则代理只做你不愿意在每个应用里重复做的那些事其他能力尽量交给数据库本身。2. 分布式数据库代理的核心技术拆解2.1 数据分片与路由算法分片是代理层的地基路由算法决定数据往哪跑。要设计一套合理分片第一件事是选分片键。分片键必须满足两个条件基数高、查询频率均匀。比如用户ID、订单ID天然均匀分布适合做分片键但如果你按省份分片人口大省那一片的存储和查询量就会比别人高一个量级这就是数据倾斜。常见的路由算法有三类取模、范围、一致性哈希。取模最简单比如order_id % 8规则清晰扩容时要迁移数据是它的硬伤。范围分片按日期或者ID区间分好处是范围扫描方便坏处一样是热点比如双十一那天的订单都在同一个区间其他分片闲着。一致性哈希对节点增减友好但会引入虚拟节点和迁移盘的复杂度适合节点数量经常变的场景。真正落到代理层实现核心是“路由映射表”。你配置里写了一个逻辑表order对应order_0..order_7八个物理表代理会把分片键值通过算法转成分片编号再查映射表得到实际库和表名。这里我特别提醒一点分片键必须出现在WHERE条件里否则代理只能把SQL广播到所有分片再逐分片执行合并结果那性能就是灾难后面常见问题里我会专门说。2.2 读写分离与主从切换读写分离的实际操作是代理在解析SQL时先判断它是写操作还是读操作。INSERT、UPDATE、DELETE这类一律送主库SELECT类默认送从库。但这里有个容易踩坑的细节有些读操作其实是“读最新写入的数据”比如用户刚下单立刻回查订单详情如果读延迟把这次查询甩给了还没同步完的从库用户就会看到刚创建的订单消失了。解决这类问题有几个通用策略。一是代理支持“强制读主”你在SQL里加个注释或者用事务包裹代理就把它路由到主库。二是代理记录“最近写入的键”在短时间窗口内对同一个键的读请求也走主库。我自己的项目里更常用第一种简单可控。从库的负载均衡代理一般提供轮询、随机、加权三种实际部署中建议按从库的真实性能配权重别用默认轮询否则一台老机器会把延迟拉高。主从切换这块代理必须有心跳检测。每几秒对物理节点发一个轻量查询比如SELECT 1连续失败三次就认为节点不可用从可用节点列表里剔除。切换有两种方式一种是被动的代理只管路由主从角色由数据库高可用组件负责切换代理只感知节点好坏另一种是代理自己发指令把从库提升为主库。我建议让专业的数据库高可用组件去做角色切换代理只做“感知和剔除”职责分离坑少很多。2.3 连接池与线程模型代理本身是个巨大连接池容器。它要维护两套连接池靠近应用的前端连接池靠近数据库的后端连接池。前端连接池负责接收大量应用连接但它们很便宜可以建的稍微多一点后端连接池每个连接都对应数据库的一个真实连接受数据库max_connections的限制必须精打细算。这里核心参数是三个最大连接数、最小空闲数、等待超时。如果你的应用服务有40个实例每个实例配置最大连接池50那总数是2000个连接可能同时打过来。代理的前端连接池最好大于这个峰值比如设2500避免连接直接拒掉。而后端连接池要按数据库实例的额度来算如果每台MySQL的max_connections是200你一个分片只有一主一从那后端连接池最大也别超过150留出50的余量给日常运维和慢查询。线程模型上老一代代理往往一连接一线程连接一多线程数爆炸。现代代理基本都是IO多路复用用少量线程处理大量网络事件。选型时候一定要问一句这个代理底层是NIO还是BIO千万别拿到一个几十年前BIO做出来的东西顶着高并发硬扛。当然自己的代理项目用Java的Netty、Go的goroutine都是很好的选择。2.4 分布式事务的边界与补偿很多同学对分布式数据库代理有个误解以为用了它跨分片事务就能自动一致。不是这样的。代理能做的是把单条SQL路由正确但如果你在一个事务里更新了两个不同分片的数据代理本身并没有能力去协调这两个库的原子性。跨分片事务常见的解决方案有三类两阶段提交(2PC)、三阶段提交(3PC)、以及柔性事务(TCC、本地消息表、最终一致性)。代理层在这中间能扮演的角色是“事务上下文传递者”它需要给事务生成全局ID在路由到的每个分片上把全局ID带上这样后续如果要做补偿能够定位到具体是哪个事务改过哪些节点。经验之谈2PC在数据库代理里做得好不好取决于代理和数据库两端的XA支持度一旦协调者挂掉资源锁会长期持有对业务影响很大。我实际项目里更倾向于用本地消息表加定期对账来保证最终一致性代理层只需要做好“记录路由轨迹”也就是访问日志里保存那些跨分片事务的完整路径这样出问题排查会舒服很多。3. 实操自己搭一个轻量分布式数据库代理层3.1 物理部署规划这一节我拿一个典型方案来做说明分四个分片每个分片是异步复制的“一主一从”代理层部署两个节点前面再挂一个负载均衡器。业务库逻辑上叫order_sharding对应物理库order_0、order_1、order_2、order_3。物理节点规划如下逻辑角色物理节点说明分片0-主192.168.1.10:3306存储 order_0分片0-从192.168.1.11:3306复制自分片0-主分片1-主192.168.1.12:3306存储 order_1分片1-从192.168.1.13:3306复制自分片1-主分片2-主192.168.1.14:3306存储 order_2分片2-从192.168.1.15:3306复制自分片2-主分片3-主192.168.1.16:3306存储 order_3分片3-从192.168.1.17:3306复制自分片3-主代理节点建议和数据库节点分开部署别混在同一个物理机上因为代理本身要消耗CPU做SQL解析跟数据库抢资源会影响两边。两个代理节点之间不做状态同步保持无状态这样任何一个挂掉另一个都能接管再加上前面的负载均衡做健康检查整体高可用才有保障。3.2 配置核心路由规则下面是一份简洁的配置示例包含了逻辑库映射、数据源分组和分片算法。我用通用格式写你可以对照自己的中间件调整字段名。schemaName: order_sharding dataSources: ds0_main: # 分片0主库 url: jdbc:mysql://192.168.1.10:3306/order_db username: proxy_user password: xxxx ds0_replica: url: jdbc:mysql://192.168.1.11:3306/order_db username: proxy_user password: xxxx # 分片1、2、3按同样格式省略 dataSourceGroups: - name: group0 write: ds0_main read: [ds0_replica] - name: group1 write: ds1_main read: [ds1_replica] shardingRules: - table: t_order actualDataNodes: group0.t_order_0, group1.t_order_1, group2.t_order_2, group3.t_order_3 shardingColumn: buyer_id shardingAlgorithm: buyer_id % 4注意几个关键点shardingColumn是分片键这里选buyer_id算法是取模4。actualDataNodes里我故意写成了groupX.t_order_X意思是在每个分片的库里物理表名就叫t_order_0到t_order_3。这样代理在改写SQL时会把你发来的t_order自动替换成t_order_2之类的真实表名。配置里最重要的一个陷阱是分片算法要和物理节点数量强绑定。你现在是4个分片取模4将来要扩到8个分片那线上存量数据全部得重新分布这就是常说的“取模分片加水难”。所以如果预算允许可以考虑在分片键本身加一个“年份前缀”之类的区间概念让扩容变简单。3.3 连接池和超时参数设置光有分片规则不够连接池参数才是决定代理稳不稳的关键。下面是我实际压过的一套参数你可以作为起点调优connectionPool: frontend: maxSize: 2500 minIdle: 200 idleTimeout: 60s waitTimeout: 30s backend: maxSize: 120 minIdle: 20 idleTimeout: 300s waitTimeout: 3s前端最大连接数设2500因为你的应用总连接数可能达2000留些余量。但这不代表2000个连接都在并发执行SQL大部分时间它们都是空闲挂着的。所以前端池的空闲回收时间不要设太短60秒比较合适否则应用那边频繁建连接端口和线程调度压力都大。后端池每台数据库实例最多只能接受200左右的连接你要做的是给主从各留余量。我是把后端池的最大值设在120因为我们还有定时任务、管理命令、备份任务之类的连接需要占用数据库额度全部加满150-160就很容易触顶。后端waitTimeout设3秒意思是如果分片连接都占满代理最多等3秒等不到就快速失败避免把雪球滚大。有一点很容易踩后端池的最小空闲数不要设太高。我见过有人设50到了夜里业务低谷时一堆空闲连接占着数据库内存还白白产生SELECT 1的心跳开销。空闲连接设20就够了。3.4 验证分片与故障切换的步骤搭完之后不要直接上流量先做三轮验证。第一轮验证分片路由是否正确。你可以用客户端连代理执行INSERT INTO t_order (order_id, buyer_id, amount) VALUES (1001, 88, 199.00);然后到四个分片的主库上分别查看t_order_0到t_order_3。因为88 % 4 0这条数据应该只会落在分片0的t_order_0表里。如果出现多张表都有数据说明你的物理表名或者路由算法有问题。第二轮验证读写分离。在代理上执行SELECT * FROM t_order WHERE buyer_id 88;然后看分片0的从库日志确认读请求的确打到了从库。再配合一个“强制读主”的指令比如SQL注释里加标签验证它是否绕过从库直接读主库。第三轮验证故障切换。手动把分片1主库的数据库进程杀掉然后持续发写入请求。理想情况是代理检测到主库不可用从节点列表里剔除然后在跟随的一两秒窗口期返回明确的错误码而不是让请求无限卡死。如果你希望自动把从库提升为主库得依赖你内部的高可用组件去和代理联动这块我测试时一般会模拟主库恢复和再次故障反复确认代理的状态机是健康的。整个验证过程一定要记录日志观察代理是否在超时内完成了节点变更这对上线后的自信心提升比读一百篇文档都管用。4. 常见故障与排查技巧实录4.1 连接风暴所有请求卡在获取连接这个现象通常是突然之间代理的响应从几毫秒涨到几十秒应用侧大量报连接超时。先看代理的前端连接池监控会发现活跃数打满等待队列长度一直在涨。再看到数据库侧很可能是某些慢SQL占满了后端连接比如一个ORDER BY没有索引导致的全表排序卡了几个大分片。排查顺序我一般是先打开代理的慢SQL日志找出执行时间超过1秒的语句和耗时分布再查数据库的information_schema.processlist看哪些SQL长时间处于Sending data或者Waiting for table metadata lock状态。定位到源头后第一件事是kill掉那些长时间挂起的会话让连接释放出来。然后再回到代理上确认前端连接池的等待超时没有设成无限否则很容易演变成集群级雪崩。这里有个很隐蔽的坑代理后端池的waitTimeout如果设得比数据库锁等待时间还短业务SQL可能还没拿到锁连接就变成了“先到先得”的争抢结果全局连接全部被锁等待占住。建议后端池的等待超时和数据库innodb_lock_wait_timeout保持一致或者略大略小会制造更多人祸。4.2 路由到了空节点分片键参数没进SQL上线一段时间后运营反馈某条查询特别慢一查日志发现代理做了“全分片广播”。这种问题的本质是开发者写的SQL里没有带分片键。比如分片键是buyer_id但运营只按order_status去查代理不知道去哪找只能把SQL复制到所有4个分片执行再把结果合并。数据量小的时候还挺快数据量一大就是灾难。解决思路不是取消广播而是规范查询业务查询必须带分片键运营后台的跨分片查询走独立的数据分析平台而不是让OLTP链路上跑这种高危SQL。代理层可以配置“强制路由校验”也就是如果遇到没有分片键的SQL直接拒绝或者发警告我强烈建议至少开启警告模式把这类SQL记录下来看看是哪些慢查询漏网了。另外一个小技巧给分片键起一个好记的别名比如订单表里顾客叫customer_id数据库列名就叫customer_id应用层DTO也保持一致。名字一旦不统一开发者自己都会忘记传哪个字段最容易触发广播。4.3 主从延迟引发的读旧数据最常见的是用户下单后立刻刷新订单列表结果看到的数据里没有刚下的单。这是因为写操作走了主库读操作走了从库而异步复制还没把刚才那一条binlog同步过来。排查时第一件事是确认复制延迟到底多少秒。主库执行SHOW MASTER STATUS从库执行SHOW SLAVE STATUS比较Seconds_Behind_Master。如果延迟在1秒以内问题不大如果延迟到了几十秒那说明从库有慢查询在拖同步得先去优化从库的写或者对从库做并行复制。代理层能做的规避策略我前面提过给关键SQL强制走主库。实际操作里可以在SQL前面加一条路由注释比如/*ROUTE_MASTER*/SELECT * FROM t_order WHERE buyer_id88代理看到注释就直接送主库。也可以用短事务包裹代理默认一个事务里的所有读操作都跟随事务第一条写操作所在的节点这种“事务内读主”的策略对一致性要求高的场景非常适用。不过要注意无脑全走主库会彻底废掉读写分离所以使用场景要收敛只有那些“写后立刻读”的接口才需要用。4.4 分布式事务回滚不完整跨两个分片更新数据分片0成功了分片1失败了但业务侧看到的最终结果并不是两个都失败而是“半成功半失败”。这就是典型的缺少统一事务协调。排查的时候先在代理日志里找到这个全局事务ID再看它路由到了哪些分片节点对照每个分片的binlog确认实际提交情况然后决定补偿逻辑是重试还是反向冲正。我自己的实践是代理层不做全局回滚只做“路由轨迹记录”。业务方要实现一个本地消息表把需要跨分片的两步操作先写成本地事务里的待发消息再靠一个分布式任务去消费消息调用对端接口完成第二步。这样把强一致转成最终一致除了查不到“瞬时一致”外业务都能接受。这个方案对代理的依赖最小出了故障也很好人工介入简直是我用过的几种分布式事务方案里最省心的一种。给一个排查速查表方便你遇到问题时快速定位故障现象第一步排查第二层可能原因推荐处置连接全部超时看代理连接池监控是否打满数据库慢SQL占满后端连接清理慢SQL调整后端池等待时间某条查询巨慢看代理日志是否广播到全分片SQL缺少分片键补分片键跨分片查询走数仓刚写的数据查不到看主从延迟数值从库复制延迟对该SQL强制读主跨分片数据半成功查事务日志确认节点路径缺少协调器使用本地消息表或TCC补偿最后再分享一个身份验证的心得代理层上线之前一定要把监控做全。连接池水位、路由耗时、节点存活状态、慢SQL明细这四张监控图缺一不可。我见过太多只盯着业务接口成功率等代理节点挂了半天才反应过来最后一看监控面板空荡荡的。分布式数据库代理不是装完就完事的组件它处于所有数据流量的必经之路上你给它配一套像样的监控等于给整个数据平台上了份保险。用过一段时间之后你会越来越觉得真正让系统稳住的往往不是代理本身的魔法而是你踩坑之后对每一条路由规则和连接参数的敬畏。