做数据库中间件这些年,我最常听到的一句话是:“我们分库了,但比不分库更累。”数据拆出去了,SQL却不知道该往哪个节点发;业务代码里堆满了“先定位分片、再拼库名、最后重试”的胶水逻辑;应用实例一扩容,数据库连接数直接顶到上限。走到这个阶段的团队,缺的往往不是另一个存储引擎,而是一个能把数据访问流量统一管起来的分布式数据库代理。
分布式数据库代理,简单说就是介于应用和数据库集群之间的一层服务。它假装自己是一套数据库,把客户端的连接、SQL、事务请求接过来,再根据路由规则转发到真正的存储节点,最后把结果整理回来。听起来像“中间人”,实际做的是数据访问的调度中枢。这篇文章就围绕这个主题,讲讲我的实现思路、方案选型、核心机制拆解,以及落地过程中踩过的坑,给正准备搞代理层的团队一点参考。
1. 先聊清楚:分布式数据库代理解决的是什么问题
1.1 没有代理的时候,分库分表为什么这么痛苦
很多团队走上分库分表这条路,是因为单实例数据库确实扛不住了。订单量涨、写入并发高、单表数据破亿,于是决定拆。拆完以后你会发现,难度并没有下降,只是从“数据库扛不住”变成了“应用层更乱”。
最典型的问题是路由逻辑散落一地。业务代码里到处都是类似这样的东西:
shardNo = hashCode(userId) % 8 dataSource = getDataSource("order_ds_" + shardNo) jdbcTemplate.execute(dataSource, sql, params)这段逻辑看似简单,可一旦分片规则调整、数据迁移、加节点,所有用到这个规则的服务都要跟着改。而且不同团队可能各写一套,A团队用哈希取模,B团队用日期范围,维护成本成倍上升。
第二个问题是连接数爆炸。每多一个应用实例,每个应用实例里的连接池又给每个分库维护一批连接。假设有8个分库,应用实例50个,分摊下来每个实例每个库2个连接就是800条连接,数据库端连接池压力非常大。我曾经见过某系统的库端连接数冲到1800,DBA每天被连接报警骚扰,只能靠临时调大后端连接上限续命。
第三个问题则是故障处理不优雅。某个分片节点抖动、磁盘慢、主从切换,应用不知道,继续往坏节点上发请求,结果就是接口超时、错误率飙升。没有统一的调度层,故障就被放大成了全链路故障。
分布式数据库代理要解决的,就是这三件事:路由集中化、连接标准化、故障透明化。
1.2 代理层到底接管了什么
代理层本质上是一层数据访问中间服务。它暴露给应用的是标准数据库连接入口,应用侧的配置只需要指向代理地址,不必再感知后面有几个分片、每个分片在哪台机器上。
数据流向大致是这样:
应用服务 -> 数据库代理 -> 后端存储节点(分片1/分片2/分片3...)代理承担的工作包括:
- 连接接入:监听客户端连接,完成身份认证、会话管理。
- SQL解析:识别语句类型,提取表名、分片键、查询条件。
- 路由决策:根据分片规则决定发往哪个或哪几个存储节点。
- 读写分离:区分读写语句,将读请求分发到只读节点,写请求固定到主节点。
- 结果聚合:跨分片查询时,将多个分片返回的结果合并、排序、分页后回给客户端。
- 连接治理:为每个后端节点维护连接池,避免应用连接数直接冲击数据库。
- 故障转移:后端节点异常时快速剔除,恢复后自动加回。
打一个生活化的比方:你去一家大的连锁餐厅吃饭,不再需要自己跑到后厨找每个厨师。你只需要坐下,跟服务员点菜;服务员负责把订单拆给凉菜间、热炒间、主食间,最后再统一把菜端到你面前。代理层就是那个服务员。
1.3 什么团队真正需要代理
不是所有项目都应该上代理。一个只有两三个应用、单库压力不大的系统,引入代理等于多了一层网络跳转,纯属增加延迟和运维负担。
当下面这些信号集中出现时,才应该认真考虑:
- 分库分表已经落地,但路由规则还在业务代码里维护。
- 应用实例数量多,数据库连接数成为瓶颈。
- 团队希望把数据访问治理集中到基础架构层,而不是让每个业务开发自己写路由。
- DBA和运维希望有统一的口子做SQL审计、慢查询治理、账号权限管理。
- 业务需要在多个存储节点之间实现读写分离、故障自动切换。
我当时接手某内部订单系统改造时,这三个现象全占。应用实例40多个,分库从2个涨到6个,每次加库都要发版本改配置,非常痛苦。把代理层做出来之后,应用侧只改了一次连接配置,后面所有扩容操作都在代理配置里完成,业务代码一行没动。
2. 方案选型:到底该怎么做这把“代理”
2.1 两种主流路线:独立代理与SDK式嵌入式路由
实现“代理”效果的技术路线大致分两类。一类是独立部署的代理服务,对应用来说它就是一套数据库,应用通过标准连接串访问,如写“host=proxy.node, port=6530”;另一类是嵌入式SDK,把路由能力封装成客户端组件,应用在代码里调用这个组件的API来访问数据层。
这两条路线的差异非常明显。
| 对比维度 | 独立代理层 | 嵌入式SDK路由 |
|---|---|---|
| 应用侵入性 | 低,改连接配置即可 | 高,依赖SDK版本、API重构 |
| 支持语言 | 多语言通用 | 每种语言都要维护一套SDK |
| 运维位置 | 独立部署,可统一监控 | 随应用进程走,升级靠发版本 |
| 故障隔离 | 代理故障影响面集中,但可做多实例 | SDK故障影响宿主应用 |
| 性能开销 | 多一跳网络转发 | 进程内调用,延迟更低 |
| 功能扩展 | 集中升级,发布一次所有应用生效 | 必须推动所有应用配合升级 |
2.2 我为什么选择独立代理
在这个项目里我选的是独立代理层,核心原因有两个。
第一是团队技术栈不统一。订单系统是Java写的,商品系统有Go,用户中心又有一部分Python维护。如果做嵌入式SDK,等于要为三种语言维护三套路由实现,不仅有功能对齐问题,还会因为某个语言的SDK版本老旧导致整个路由规则迟迟推不下去。独立代理只跟数据库协议打交道,对上游语言完全无感,接入成本最低。
第二是运维可控性。独立代理部署在中间数据链路,出了问题可以单独排查、单独升级,不牵扯业务发布窗口。DBA也能通过代理暴露的管理端口直接看路由统计、连接状态和慢查询明细,这是嵌入式SDK很难做到的。
代价也很明显:一次额外的网络转发会增加单个SQL的往返时间。实测下来,内网环境下这个延迟增量通常在0.1到0.3毫秒,对绝大多数业务来说可以接受。但如果业务是超高频、超低延迟、每个请求只有几十微秒预算的极限场景,独立代理不一定是第一选择。
2.3 选型时严查的几个关键能力
不管你是买商业方案还是自研,评估一个分布式数据库代理项目时,我都会列一张检查清单。
第一看协议兼容程度。代理是用标准连接协议与应用交互,还要用后端协议与存储节点交互。这里的坑在于各个数据库版本的协议细节并不完全一样,尤其是预处理语句、多结果集返回、事务状态切换这些环节。慌手慌脚上线了,开发同学反馈“存储过程跑不了”“预处理语句报错”,基本上就是协议兼容性没做够。
第二看自己有没有办法做高可用。代理一旦成为数据访问的唯一入口,它自己就不能是单点。至少要支持多实例部署、无状态会话调度、后端节点状态同步。常见做法是前面挂一个负载均衡层,后面部署两个以上的代理实例。
第三看路由能力上限。是只支持简单的哈希取模,还是能做一致性哈希、范围路由、自定义脚本路由?分片键提取是依赖用户SQL里必带查询条件,还是能解析复杂表达式?这决定了未来业务变通时的自由度。
第四看SQL管理功能。有没有慢查询日志、结果集限制、SQL黑名单、敏感字段脱敏。这些能力在代理层做,比在数据库侧做要顺手得多。
第五看社区和可维护性。如果是自研,得确认自己的团队能长期投入维护;如果是开源方案,要看社区活跃度、发布节奏、高危漏洞响应速度。技术选型最忌讳“看着能跑”就上,后面没人管,出了安全问题只能自己扛。
3. 核心机制拆解:路由、连接与事务
3.1 分片路由:最关键的“下一步去哪”
分片路由是整个代理最核心的决策环节。客户端发来一条SQL,代理必须知道这条SQL该去哪个物理节点执行。这个“知道”是由分片规则决定的。
常见路由策略有三种:
- 哈希取模:对分片键做哈希,再除以分片数取余数。优点是实现简单,数据分布均匀;缺点是增加或减少分片时,绝大多数数据都需要迁移。
- 一致性哈希:把分片键映射到一个环形哈希空间,每个节点负责一段区间。增减节点时只影响相邻节点,数据迁移量小;缺点是范围查询和跨节点统计更难做。
- 范围路由:按分片键的数值区间划分节点,如用户ID 1到10万在节点A,10万到20万在节点B。范围查询方便,但容易产生热点,某个区间的数据暴涨就会把对应节点打满。
以某个订单系统为例,我当时用的是复合策略:订单表的主分片键取order_id,哈希算法选用一致性哈希,虚拟节点设置为64。为什么不用简单取模?因为当时已经预料到半年后可能要扩分片,取模按3做除法,扩到5个节点时数据要全量重排,根本没有平滑扩缩容的可能。
分片键的选择比算法更需要谨慎。经验法则是“查询尽量带上分片键”。如果选了user_id作为订单表的分片键,用户查看“我的订单列表”会非常爽,一次路由就定位到用户的订单所在分片。但如果后台要按订单号查询某笔订单,那就麻烦了,只能把请求广播到所有分片。反过来选order_id做分片键,单笔订单查询很高效,用户订单列表却得跨分片聚合。最终我们结合业务特点,把订单表按user_id分区,同时要求所有订单相关的写入和查询都带上user_id,个别必须按order_id查的场景走全局索引表。
这里还有一个容易被忽视的细节:路由提取不能只看SQL文本里的字面值,还要处理预处理语句的占位符。代理解析SQL后,参数和模板是分开传输的。如果代理不拿到实际参数值,就做不了精确路由。所以我们内部对预处理语句做了参数值提取和二次校验,确保路由决策使用的是最终执行值。
3.2 连接管理:从“连接风暴”到“连接复用”
代理层有一项不太显眼但极其重要的功能:连接复用。数据库端能承载的连接数是有限的,应用实例越多,直连模式下连接数越爆炸。有了代理之后,应用连接的是代理,代理再向后端数据库节点复用有限的连接。
这里的模型是这样的:
应用实例的每个请求线程 -> 代理客户端连接池 -> 代理后端连接池 -> 真实数据节点代理既要管理“面向应用”的连接池,也要维护“面向后端”的连接池。通常我会控制后端连接池的大小,原则是“后端连接数量级固定,不再随应用实例数量增长”。比如某个分片主库,后端连接池上限设为100条,那么无论你有100个还是200个应用实例,真正压到数据库主库的连接就那么100条。
连接复用的另一个动作是预处理语句共享。同一个业务SQL,每次执行都可能新建一条预处理语句,数据库端就要反复解析和优化。代理可以按模板维度和后端节点维度缓存预处理语句,让后续执行直接复用句柄,CPU消耗能降下来不少。
连接治理的关键参数也值得单独强调。典型的配置包括:
- 最小空闲连接和最大连接数。
- 连接存活时间。
- 空闲回收周期。
- 获取连接时的最大等待时间。
这些参数不是越大越好。后端连接池配得过大,相当于没减负;配得过小,高峰流量下代理反而会排队等待连接,拖慢响应。我们当时是先压测,把并发打到峰值的1.5倍,再观察后端连接池的等待曲线,找到“够用但不冗余”的点,基本落在池大小的60%到80%利用率区间。
3.3 读写分离的判定逻辑
分布式数据层最常见的优化手段就是读写分离。代理层做读写分离,比在应用层做要自然得多。应用不需要关心哪条SQL走主库、哪条走从库,代理解析SQL之后自己决定。
判定规则看似简单,但有几个容易踩雷的地方。
第一,不是所有SELECT都适合走从库。比如事务内部的读操作,如果读和写是同一份逻辑事务,就必须全部固定在主库,否则会出现“写完还没提交,读操作连到从库去了”的脏读问题。所以代理会维护一个会话上下文:一旦某个连接进入了事务状态,这个事务内的所有语句全部锁定主库,直到事务结束。
第二,特定函数和系统变量可能导致结果不一致。例如SELECT NOW(),从库执行也是返回从库的系统时间;如果主从时钟有偏差,应用拿到的结果就会有出入。为了严谨,我把这类可能依赖节点状态的语句也统一路由到主库。
第三,显式强制路由的手段要保留。业务有时知道某次读操作必须读最新数据,普通读写分离策略并不能识别这种意图。比较通用的做法是通过SQL注释来传递路由倾向,比如在SQL前面加一个特殊标记,代理解析时发现这个标记就走主库。这个机制后来帮了大忙,尤其是财务对账类查询,数据一致性要求极高。
读写分离真正的难点在于主从延迟感知。代理负责转发读请求到只读节点,但如果主库写入量大,从库复制延迟明显,不少读请求会拿到旧数据。我们当时的兜底方案是给只读节点维护一个“延迟水位”指标,延迟超过阈值时,该节点的读请求自动切回主库;延迟恢复正常后再切回来。
3.4 分布式事务:必须想清楚的取舍
分布式数据库代理最头痛的部分,是分布式事务。事务在单体数据库上是ACID的,一旦数据分布到多个分片,一个问题就冒出来了:一个事务写了分片A,又写了分片B,提交时A成功了,B失败了,怎么让A回滚掉?
业界常见做法有三种:
第一种是XA两阶段提交。代理作为协调者,先让所有参与节点prepare,全部成功后,再逐一commit。这套方案保证的是强一致性,但性能影响非常大,prepare阶段要锁资源,协调者要记录事务状态,网络抖动时会卡住。我把它限定在低并发、强一致性要求极高的场景,比如库存预占、账户扣款,并且严格控制参与分片的数量,一般不超过两个。
第二种是最终一致性方案。代理把跨分片的一致性需求转化为本地事务加异步消息,比如先写订单流水表,再发MQ消息给下游账户服务做金额变更。这个方案响应快、扩展性好,但业务系统要接受“短时间数据不一致”的现实。我们内部把它用在积分变动、�notification推送这类非关键路径场景。
第三种是避免分布式事务。其实很多跨分片事务是因为数据建模没做好,或者分片键选得不对。如果业务上能通过合理设计,让一个事务的所有操作都落在同一个分片内,就根本不存在跨分片事务。我们的策略就是“优先账业务,再设计分析,万不得已才上XA”。
我在项目里定了一条原则:代理层不要试图把所有分布式事务问题都透明化解掉。因为透明化的代价是极度复杂的协调逻辑和高昂的性能损耗,最终往往变成虽然能跑,但不如不改。正确的做法是:能避免就避免,不能避免就按幂等设计补最终一致,最后才考虑同步强一致。
3.5 保护机制:限制和隔离要在入口做
代理层处于流量的入口位置,天然适合做保护策略。我在这个项目里落地了几类保护机制。
第一类是连接数保护。限制每个代理实例最多接收的连接数,超过阈值直接拒绝新连接并返回可辨识的错误码。这样做能防止连接风暴把后端数据库彻底打死。
第二类是慢SQL熔断。代理设置执行超时时间,超过阈值就直接终止,不再等后端。以前没有这层保护时,一条慢SQL能把数据库连接池占满,后面所有正常请求都被堵住。加了这个熔断之后,单条慢SQL的影响范围被限制在一个连接上,造成的损害小很多。
第三类是结果集限制。有些开发同学会不小心写出全表扫面的查询,返回百万行。代理可以设置单个查询返回行数的上限,超过后截断,或强制要求带WHERE条件再放行。这个功能被某些同事说有“家长式管理”的嫌疑,但真出事的时候能救命的。
第四类是SQL黑名单和审计。对批量删除、无WHERE条件的更新、超级大IN列表这些高危SQL,可以通过代理配置白名单放行或强制人工审批。同时完整记录SQL执行日志。
这些保护机制都有一个共同原则:在入口处拦截,而不是等请求到达数据库后再处理。代理层做得越细,数据库后端就越安全。
4. 实操记录:一个最小可用的代理层如何落地
4.1 定义业务场景与参数规模
我负责的某内部订单系统,上线时业务体量大约是:日均订单120万,读写比例约7比3,分片数计划3个,后续可平滑扩容到6个。数据库集群形态是每个分片一个主节点加一个只读副本。业务查询模式以订单维度为主,必须带user_id的SQL超过90%。
基于这个场景,我得出的核心设计是:
- 分片键:user_id。
- 路由算法:一致性哈希,虚拟节点64。
- 读写分离:同一事务内强制主库,事务外SELECT默认读从库。
- 后端连接池:每个分片主库80条连接,每个分片只读副本60条连接。
- 客户端连接上限:代理实例最大客户端连接500。
4.2 最小配置清单
整个代理层在业务接入前,需要准备一份可执行的路由和连接配置。我这里整理一个最简版本的参考结构。
proxy: cluster_name: order_proxy_cluster listen: host: 0.0.0.0 port: 6530 max_clients: 500 backend: password_encrypted: true connection_timeout_ms: 2000 socket_timeout_ms: 3000 shard_groups: - group: order algorithm: consistent_hash shard_key: user_id virtual_shards: 64 nodes: - id: shard0 address: db-node0.internal role: primary max_pool_size: 80 - id: shard0-replica address: db-node0-ro.internal role: replica max_pool_size: 60 - id: shard1 address: db-node1.internal role: primary max_pool_size: 80 ... read_write_splitting: enabled: true transaction_force_primary: true select_replica: true protection: query_timeout_ms: 1500 max_result_rows: 50000 sql_blacklist: - "delete from order_info where 1=1"这里顺便解释几个关键参数为什么这么配。
connection_timeout_ms配的是2000毫秒,意思是代理向后端获取连接时,最多等2秒。如果后端连接池已经满了,代理不会无限等下去,而是快速失败,把这个超时错误返回给应用。这样可以避免应用端的请求线程全部挂在等待上。
socket_timeout_ms配3000毫秒,指的是代理和后端之间一次SQL执行最大的网络读写等待时间。后端执行超过这个时间,代理主动断开并返回超时错误,应用侧可以拿到明确的状态码。
max_pool_size是我们压测之后定的。80条连接听起来不多,但如果每一条连接都保持复用,后端CPU利用率能稳定在70%左右,RPS也能满足业务峰值需求。
virtual_shards配64,是把每个物理节点虚拟成64个哈希环上的节点,让键分布更均匀。这样即使某个物理节点下线,它负责的流量扩散到其他节点的范围也被控制住了。
4.3 灰度上线与一键回滚
代理层上线不能一步到位,我见过不少团队一次性切全量流量,结果路由配置不对,数据库直接被错误SQL打挂。比较稳妥的做法是灰度切换。
操作步骤大致是这样:
- 代理层先在测试环境跑一周,跑通所有核心SQL和定时任务。
- 生产环境部署代理节点,配置只读模式,只接监控流量,不转真实业务请求。
- 选取一个低峰时段,切1%的读流量到代理,观察延迟、错误率、连接数。
- 确认无异常后,逐步调大路由比例,每步观察20分钟。
- 写流量单独灰度,先切部分写请求,验证事务路由是否正常。
- 全部流量切完后,把应用侧原来的直连配置下线。
回滚预案要提前准备。我当时设计了“流量切回”开关,只要代理层出现异常,负载均衡上直接把代理节点摘掉,应用连接串改回原数据库地址,整个过程控制在分钟级。
有一点特别值得提醒:回滚时千万不要只盯代理的报错,还要关注数据库端的连接数瞬间变化。因为切回直连后,所有应用重新建立数据库连接,会产生一波连接峰值。如果数据库端没有预留余量,回滚操作可能会造成新的故障。
5. 常见问题与排查心得
5.1 应用连接数不降反升,问题出在哪
有一次上线后,DBA反馈数据库连接数不仅没降,反而比直连时还高了。排查下来发现,代理端的客户端连接超时时间配置太长,应用侧连接池出现了“持有不释放”的现象。应用以为连接还活着,一直没有关闭,代理端的旧连接也没被回收,新的应用连接又不断建立,连接数自然越积越多。
解决办法分两步:第一步是在代理层调低空闲连接回收时间;第二步是推动应用侧连接池增加空闲检测,及时发现并重建失效连接。
另一个类似问题来自代理本身的后端连接池。同一个分片的主备节点都配了连接池,但主备切换后,旧连接没有及时清理,导致部分连接指向已经降级的节点,执行SQL时出现异常。后来我们在后端连接池里增加了节点状态探测,切换后主动踢掉旧连接,问题才稳定下来。
5.2 慢SQL被路由到了错误的读库
另一件让我印象很深的问题是慢SQL排查。某个统计报表查询经常超时,代理日志显示这条SQL被路由到了只读副本,而只读副本的执行计划走了全表扫描,延迟自然高。
后来一查,问题不在路由本身,而在于SQL里带了FOR UPDATE子句。FOR UPDATE本质上是写锁操作,不能路由到只读节点,但我们的SQL分类器当时只判断了语句前缀“SELECT”,没有检查FOR UPDATE。调整解析逻辑后,凡是带FOR UPDATE的SELECT统一定向主库执行。
这个案例告诉我们:读写的判定不能只看SQL开头是什么,还要解析查询语句内部的锁语义、特殊函数、临时表操作。简单分类器在早期够用,业务复杂之后就容易漏。
5.3 热点分片导致数据倾斜
有段时间某个分片的负载明显高于其他分片,CPU使用率长期在90%以上。原因是一个高频商家批量生成订单,按user_id做哈希后,该商家的所有订单集中到了同一个分片。
排查思路是先确认热点到底集中在哪些访问模式上,再看数据分布是否均匀。通过代理上的路由统计,我发现某个user_id前缀的查询每天超过百万次,对应的分片自然成了热点。
针对数据倾斜,我们做了两个调整:第一,给热点账号单独配置了独立的逻辑分片标识,绕开默认哈希规则;第二,把部分读流量转发到只读副本上分摊压力。实际上,如果热点问题是长期持续的,更彻底的方案是重新设计分片键,把维度从“单一用户”改成“用户+商家”的复合分片策略,但这需要业务侧配合改造查询条件,我只能渐进式推进。
5.4 代理节点自身成了瓶颈点
代理上线稳定运行一段时间后,出现了一个新问题:应用侧延迟整体升高,代理节点CPU使用率接近100%。
一开始我怀疑是SQL解析太耗时,后来做了火焰图分析,发现大头在连接协议解析和线程上下文切换。代理每接收一个连接都要进行握手、鉴权、维护会话状态;连接数一多,线程切换开销就上来了。
优化方向有三个:一是把会话管理做得更轻,减少不必要的心跳包处理;二是把线程模型从“一连接一线程”改为“事件驱动+队列”,降低上下文切换;三是对代理节点做横向扩容,前面加负载均衡,让流量分配到多个代理实例。
最终我们用两节点部署替代单节点,代理负载降下来一大截。经验就是:代理层必须预留足够的横向扩展能力,而且要持续监控代理自身的CPU、连接线程数和GC情况。代理出问题,影响面可比单台数据库出问题还大。
6. 运行维护的一些个人经验
代理层上线并不意味着工作结束,反而是长期维护的开始。我个人的体会是,数据库代理跟业务服务不太一样,它一旦成为数据访问入口,稳定性优先级就要提到最高。
日常维护一定要关注几个核心指标:代理节点的CPU和内存、后端连接池的排队等待时间、路由错误率、慢查询数量、客户端连接的增长曲线。这些指标最好统一上报到监控系统,配置好告警阈值。不要等DBA半夜打电话给你,才意识到代理已经挂了。
还有一个小技巧值得分享:在代理层设计一个轻量的“SQL指令协议”,通过特殊注释跟业务系统约定路由意图。例如某条查询必须走主库时,开发人员在SQL里加一个约定好的标记,代理解析时读取标记并强制执行对应路由。这样既保留代理的集中控制能力,也给业务留了一条显式的出口,遇到特殊情况不用改配置重发。
如果你正在筹备一个分布式数据库代理项目,我的建议从一开始就明确边界:它是数据访问的调度和管理层,不是数据库本身的替代品。不要试图把所有问题都在代理这一层解决,尤其是分布式事务和数据一致性,该留给业务去设计最终一致方案就留给业务。边界清楚,代理才能真正稳定。