资讯动态

分布式数据库代理实战:从连接失控到高可用路由

发布时间:2026/10/15 23:55:53 来源:尧图企业网站定制
1. 一次连接数爆满事故让我决定在数据库前面加一层1.1 事故复盘连接数为什么会失控我之前维护过一个业务系统这里姑且叫某项目X。底层是多个MySQL实例为了扛业务压力做了分库分表。最开始那半年所有服务都是直连数据库每个应用服务自己在配置里写数据库地址自己管理连接。那时候没觉得有什么问题直到某天晚上业务流量冲上来核心库直接报告too many connections慢查询铺满监控面板数据库CPU一路飙到100%。晚上排查的时候我盯着连接状态看了很久发现失控的原因其实很杂。第一服务实例扩容的时候每个实例都会新建一批连接池连接总数成倍上涨数据库的连接上限很快就顶穿了。第二各个服务连接池的参数完全是各写各的有的空闲超时设了10分钟有的设了30秒而数据库侧的wait_timeout又比较短导致后端主动断开的空闲连接被应用端继续复用报错还特别随机。第三有个新上线的服务用了游标查询在一段长事务里长时间占着连接不放一个连接从上午一直握到下午。这些问题单独拎出来都不算难搞但架不住服务多、团队大、配置各有各的历史包袱。你不可能逼着所有服务都统一改配置也没法保证下一次扩容时不会再出一个新的连接黑洞。当晚救火还能重启应用、临时提高连接上限可只要流量再来一波同样的场景必然重现。1.2 为什么是代理而不是继续打补丁当时有人提过另一个方案做一个公共的数据库访问库把连接管理、路由、故障重试都塞进SDK里强制所有服务都用这个库。这个方案我思考了一阵子最后还是否掉了。原因很简单我们这边有Java服务也有Go和Python写的模块一套SDK想在所有语言里保持行为一致本身就是一个巨大的工程。而且SDK一旦发布出去升级就成了痛苦的流程你永远不知道哪个服务还在运行旧版本、踩着什么旧bug。代理的方案就清晰得多。它坐在应用和数据库之间本身不存数据只负责接收请求、解析SQL、做路由、管理连接。应用侧只需要连接一个逻辑地址后端有多少个分片、主库在哪儿全交给代理处理。这样做的好处是所有连接行为都被收敛到一个统一的入口后端连接数可控故障切换对应用透明分库分表的规则也不再散落在几十个服务的配置文件里。分布式数据库代理说白了就是这样一层基础设施。它本身不产生业务价值但能把数据库这块最复杂的连接和路由逻辑从业务侧剥离出去。如果你也碰到过类似连接数失控、分片规则混乱、故障切换全靠人工的事后面这些内容可以直接拿去参考。2. 代理层要管好的三件事协议、路由、连接2.1 协议解析先听得懂客户端在说什么很多人以为代理就是把TCP流量原样转发到后端这想法其实不太对。你要只是做个端口转发充其量只能做流量负载根本没法做分库分表路由因为你不知道客户端在请求里说了什么。MySQL客户端和服务器之间有一层基于二进制的通信协议。客户端发一个查询命令前面有包长度、包序号中间是命令类型后面才是SQL文本。代理要做的事情就是自己扮演一次“服务器角色”和客户端完成握手、认证拿到认证结果后再以“客户端角色”去连接后端数据库。两边连接都建立成功后中间这条通道才算打通。这个过程中最重要的就是解析命令类型。常见的COM_QUERY是普通SQLCOM_STMT_PREPARE是预编译语句COM_STMT_EXECUTE是执行预编译语句。你只有读懂了这些命令类型才能在真正转发数据之前截取SQL判断它该去哪个分片。这不是什么高深技术但很磨耐心。第一次做协议解析时我被各种奇怪的包序号问题折磨过。协议规定同一方向的包序号必须连续递增但两边都有独立序号空间一开始没处理好客户端和服务端就会互相等待表现为连接一直卡住不返回数据。2.2 路由转发把每条SQL送到该去的地方协议解析解决的是“听不听得懂”的问题路由解决的是“送到哪里去”的问题。代理拿到一条SQL首先要解析出它操作的是哪张表再从WHERE条件里提取分片键根据分片规则计算出目标分片。举个例子订单表按user_id做哈希分片客户端发来一条SELECT * FROM orders WHERE user_id 123代理就提取出user_id 123计算哈希取模得到分片编号然后把SQL原样转发到对应的后端实例。看起来不难但实际写起来会有很多边缘情况。比如SQL里的分片键不是直接等值比较而是IN (1, 2, 3)那你得把请求复制成多条分别发到不同分片再合并结果返回给客户端。再比如查询条件里根本没有分片键代理只能广播到所有分片最后聚合成一份结果。广播在分片少的时候还能接受分片一多延迟和资源消耗都够呛。所以路由策略在设计阶段就必须想清楚哪些SQL是允许广播的哪些SQL必须强制带上分片键哪些SQL干脆直接拒绝避免拖垮整个集群。我倾向于默认拒绝没有分片键的写操作只允许少数必要的读操作做广播。宁可让业务方报错也不能让一个全表扫描把整个代理层打挂。2.3 连接管理代理最容易被低估的一环如果说路由是代理的大脑连接管理就是代理的血管。最常见的问题是客户端连上代理之后代理是不是就给这个客户端分配一条独占的后端连接最省事的实现确实是这样前端连接和后端连接一一对应代码简单调试也直观。但这样的代理基本没有存在的意义因为它只解决了一个入口问题连接总数还是没降下来。应用侧扩容100个实例代理就得维护100条后端连接数据库该被打爆还是会被打爆。正确做法是让代理维护一个后端连接池前端多个客户端连接共享池里的连接。但这里又涉及一个关键问题事务。如果客户端只是执行一条普通的INSERT就提交那么这次请求用完连接后马上归还即可。可如果客户端发了BEGIN在未发送COMMIT之前所有SQL都必须固定在这条连接上执行否则事务上下文就断了。代理必须自动识别事务边界然后对这条后端连接做“绑定”等事务结束后再释放。这个问题处理不好就会出现各种莫名其妙的数据不一致有些查不到刚插入的数据有些事务提交报错。连接不合理的分配策略会导致池里的连接被某些长事务占满剩下的请求只能排队等待。这类问题往往比纯路由问题更难排查因为它的表现不是立刻报错而是整体越来越慢最后在某次流量高峰集中爆发。3. 路由规则设计选对分片键比写对代码更重要3.1 哈希分片和范围分片的取舍路由规则是代理的骨架选错了分片方式后面再怎么优化都很难补救。最常见的两种分片方式是哈希分片和范围分片。哈希分片的好处是数据分布均匀。比如按user_id % 64分成64个分片基础数据量相差不大每个分片压力基本均衡。它的短板是范围查询很难做比如“查最近一个月登录过的所有用户”这种SQL没办法直接在某个连续区间内定位只能广播到所有分片再合并性能和响应时间都会被打折扣。范围分片则相反比如按订单创建时间按月分片查询某个月的数据可以直接定位到对应分片范围扫描很舒服。但热点问题非常突出新一个月的数据永远集中在同一个分片里写压力和存储压力全砸在最新分片上其他分片空转。我在设计的时候最终还是选了哈希分片并且把分片键定为了user_id原因在于我们的业务读多写少绝大多数查询都带着用户维度范围查询的场景极少。但是我也保留了几个按时间分片的业务表单独配置了范围分片规则。代理需要支持不同表使用不同分片策略这才算灵活。如果代理的配置只支持一种全局分片算法那后患无穷。3.2 路由配置怎么组织才不会变成新的维护负担路由规则写在代码里是最蠢的做法改一次路由规则就要重新发布一次代理。我后来的做法是把路由配置外置化用一个JSON文件描述所有分片规则代理启动时加载到内存运行时只做读取。{ tables: { orders: { shard_key: user_id, type: hash, nodes: 64, node_mapping: { 0-7: db-shard-0, 8-15: db-shard-1 } }, trade_log: { shard_key: created_at, type: range, ranges: [ {from: 2024-01-01, to: 2024-01-31, node: db-hist-0} ] } } }配置本身不复杂但要特别注意分片键的选择。分片键必须是业务查询中最常见、最稳定的条件。user_id这类字段就比order_id更适合因为用户相关的查询量远超按订单号的单点查询。还要避免选择会变化的字段作为分片键一旦用户ID或订单状态被更新数据就得搬库这在分片体系里是大忌。这对维护来说是巨大进步。后来改分片节点时我只需要改配置中心里的一份JSON代理平滑加载不用再重新部署整个集群。但是配置的变更顺序也有讲究要先加新节点、迁移数据再把路由配置切到新节点不要反过来否则流量会先打到空节点上造成大面积查不到数据。4. 连接池与会话状态代理稳定性真正的分水岭4.1 两层连接池分别怎么设计连接池得做两层。第一层是前端连接池也就是应用和代理之间的连接这一层基本由应用侧自己控制代理被迫接收能做的只是设置超时和最大连接数。第二层是后端连接池也就是代理和数据库之间的连接这层才是代理能控制的关键。后端连接池我最后按每个分片分别管理每个分片维护一组空闲连接。核心参数有最小空闲数、最大连接数、空闲超时、最长存活时间。最小空闲数是为了避免突发流量瞬间建立过多连接MySQL的握手过程虽然快但高并发下同时建房很容易把数据库的线程调度打满。最大连接数要结合后端实例的CPU和内存量来定一个分片实例给32核64G内存后端连接池上限设在200到300之间比较合理太高了数据库处理不过来太低了又容易排队。空闲超时设置也得和后端数据库的参数配合好。我遇到过代理空闲连接池设成10分钟但数据库的wait_timeout只有3分钟的情况结果一堆空闲连接被数据库端断开代理第一次用就报连接失效。后来我在代理启动时就设置了主动探测定期向后端发送一个轻量查询保证连接是活的。也把空闲连接回收时间调到了比数据库wait_timeout短彻底解决这个问题。4.2 事务绑定和读写分离的正确姿势事务绑定是连接池内最微妙的部分。客户端执行BEGIN后后面所有的SQL都得走同一条后端连接。我的实现里有一个会话状态机连接空闲时状态是IDLE一旦解析到BEGIN就从连接池取一条连接并绑定到该前端会话状态变成IN_TX。之后这条连接不参与复用只有等到COMMIT或ROLLBACK才把状态切换回来把连接归还给空闲池。这里有个很容易踩的坑SET AUTOCOMMIT 0虽然没有建立显式事务块但也是开启了事务语义。有许多ORM框架在内部会这么做代理如果只认BEGIN关键字就会错过事务绑定导致一条连接上同时跑了多个会话的SQL后果极其严重。后来我在状态机里增加了WEIRD_TX状态看到SET AUTOCOMMIT 0同样进入事务绑定流程。读写分离也要结合事务状态来判断。普通的SELECT可以路由到只读从库减少主库压力。但如果是事务内的SELECT哪怕没有写操作也得在主库上执行否则会出现“先写后读”不一致。尤其注意SELECT ... FOR UPDATE这种加锁读取必须留在主库。有些代理只判断SQL首关键字是SELECT就去从库这属于非常危险的错误行为。我最初也差点踩进去后来加了一个白名单和黑名单FOR UPDATE、LOCK IN SHARE MODE、GET_LOCK这类查询全部强制走主库。5. 高可用与故障转移代理不能变成新的单点5.1 后端故障怎么被及时发现加了代理之后数据库的高可用逻辑就被集中到了一个地方这既好也危险。好处是切换动作可以做得细腻且统一坏处是代理如果判断错了整个业务都会受影响。后端健康检查我做了两个层面一是主动探测每隔几秒向最新主库执行一条简单查询确认延迟和连通性正常二是被动感知当前端请求到达代理后后端执行报错时记录错误码连续超过阈值就把节点标记为不健康。故障转移触发后代理需要更新自己的路由拓扑。新主库不能靠猜而是要拿到集群的权威信息。在MySQL场景下通常通过半同步或异步复制状态去判断哪台从库的数据最新。人工触发切换时我把指令放在管理接口里代理收到指令后把对应分片的写流量切到新主库同时把只读流量切到可用的从库。这里有一点容易被忽视代理本身不生产数据切换只能建立在后端数据库的数据一致性之上。如果从库落后主库太久强行切换会丢数据。所以我在切换流程里加了一个手工确认环节正常情况下必须有运维确认新主库的复制位点再调用切流。全自动切换看起来很过瘾但在数据一致性要求高的场景里它带来的损失往往超过收益。5.2 代理自身的高可用部署模式代理层自己不能单点部署。我用过两种模式第一种是多个代理节点并列前面挂一个负载均衡服务应用连接负载均衡的虚拟IP。这种模式部署简单单个代理挂掉后负载均衡会把新连接分发到其他节点已有连接会断但因为代理的会话状态应当尽可能轻量应用重连后还能继续工作。第二种模式是主备模式两个代理节点绑定同一个虚拟IP正常情况下只有主节点处理连接主节点故障后备用节点接管。这种模式对应用透明程度更高但存在虚拟IP切换的秒级中断。我在实际生产里选择了第一种模式四个代理节点统一由负载均衡分发同时在每个节点上都保持后端连接池和路由表同步。代理节点不要保存任何会话级的内存状态前端连接断了就断让应用连接池自己去重试。这样才能保证任意节点故障时新连接能立刻在其他节点上正常服务。代理节点扩容也不复杂新节点启动后从配置中心拉取最新路由配置建立后端连接池然后加入负载均衡节点组。整个过程不需要动数据库也不需要动应用。6. 实测压测数据代理到底引入了多少开销6.1 我把直连和代理放在一起做了对照很多团队不敢引入代理主要是担心性能损耗。我当时的做法是先在测试环境做了一轮完整的对照压测用了8核16G虚拟机跑代理后端数据库是独立的两台实例。压测工具分两路跑一路直接连接MySQL另一路走代理转发。压测结果是直连读场景QPS大约在2万左右P99延迟5毫秒走代理后QPS下降到1.5万左右P99延迟6.5毫秒。也就是说代理引入了大约1.5毫秒的额外延迟。这个损耗对于大多数业务场景是可以接受的但如果你有很多轻量级查询比如只查一条记录、返回几个字段那延迟比例就非常难看直连1毫秒和代理2.5毫秒差距很大。写场景的损耗反而比读场景小因为写请求本身耗时相对高代理额外增加的解析和转发成本占比较低。由此可见代理适合业务逻辑较重、单次查询耗时较长的情况。如果你的系统里全是平均执行时间不到1毫秒的短查询那引入代理前必须做好心理准备延迟开销会在比例上显得很明显。6.2 调优之后的变化和关键参数第一轮压测数据并不理想我针对明显瓶颈做了几轮调优最终把P99额外延迟从1.5毫秒压缩到了0.8毫秒左右。主要做了三件事。第一是启用了TCP_NODELAY关闭Nagle算法。MySQL协议本身是大量的短报文交互Nagle算法会把小包积攒后一次性发送这在局域网环境下非常影响P99打开后效果立竿见影。第二是调整了线程模型从每连接一线程改成了事件驱动模型。代理最大的瓶颈不在CPU而在大量空闲连接的线程上下文切换。事件驱动后空闲连接不占线程内存占用和调度开销都降了很多。第三是给后端连接池做了预热代理启动时就把常用分片的空闲连接提前建好避免前几个请求都去经历一次完整的MySQL握手认证。调优后的关键参数可以列成一张参考表格参数项直连代理优化前代理优化后备注读QPS200001450018000并发100P99延迟5ms6.5ms5.8ms纯网络开销压缩明显写QPS800072007800写路径损耗较小空闲连接数50200300后端池扩展后更稳经过调优后代理在压测环境下的表现已经基本可以接受。有一点要记住由于代理把原本分散在应用层的连接管理集中起来后端数据库的线程和连接使用更平稳了整体集群的可用性反而提高不少。只看单连接的损耗会觉得很亏站在全局看代理降低的是整个集群的故障概率。7. 我踩过的坑和给想搞代理的人的建议7.1 几个让我印象深刻的坑代理项目做完后我又在生产环境里擦了很长一段时间的地板。第一个经典坑是max_allowed_packet不一致。应用直接连数据库时能插入几MB的报文代理默认包大小却只有1MB导致大字段插入时报错。排查了很久最后发现是代理和后端MySQL在这项参数上配置完全不同步。后来我在代理启动时主动向后端查询该参数并动态调整自己的接收上限。第二个坑是预编译语句的路由。客户端先发COM_STMT_PREPARE代理解析出SQL并转发到后端得到一个语句ID再发COM_STMT_EXECUTE时用这个ID执行。问题在于不同后端分片各自维护自己的语句ID空间如果你的预编译语句没在一个会话内固定连接那么语句ID往往对不上。我的解决方案是带有分片键的预编译语句优先绑定到单个后端连接避免跨节点执行。不好处理的SQL则退化成文本查询放弃预编译优化。第三个坑是对字符集不敏感导致的路由错乱。比如分片键字段是varchar应用端连接字符集和服务端不一致时哈希结果可能不同同一业务会落到不同分片。为此我在代理解析SQL后统一按建表结构的真实字符集做分片键计算而不是直接使用客户端传来的字节串。另外建议分片键统一用数字类型能省掉很多麻烦。还有一个坑是关于长事务的。某服务发起事务后长时间不提交导致对应的后端连接被长时间占用。我一开始没有限制结果某个慢业务直接拖垮了池子。后来我给前端会话设置了事务空闲超时事务内数据超过一定时间没有任何SQL活动代理直接断开前端的这个会话算是用强制手段逼业务方修复问题。7.2 如果让我重来一次我会怎么规划给想接入分布式数据库代理的团队一句实在话不要在系统还在频繁改分片规则的时候上代理。先把分片键定死再考虑代理。否则你每天都会在路由配置上折腾代理反而成了新的维护负担。最开始可以把目标定得小一点先只做连接池和读写分离让代理充当一个智能网关把连接数比重压下来。等团队适应了这种拓扑再逐步开放分库分表路由最后再做跨分片聚合。一次不要做太多东西否则出了问题都不知道是路由错了还是连接池满了。另外代理层的日志和监控必须从第一天就做起来。我后来专门整理了代理日志记录每一条SQL的路由结果和目标分片以及后端耗时的分位值。没有这些数据遇到问题根本没法定位。后面几次复杂的线上事故都是靠日志回溯才把根因找出来的。我最后还想分享一个小技巧代理本身不要做任何业务逻辑不要尝试在代理里实现数据加解密、数据脱敏、字段转换这类功能。一旦代理需要理解业务数据它就会不可避免地被业务SQL卡住维护成本会成倍增长。代理就老老实实做路由、连接、转发把所有的复杂度都挡在外面这样才能真正稳定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑