去年年中大促,我们线上的订单库先炸了。监控面板上MySQL的threads_running一路往上蹿,600、700、1000,然后应用层开始疯狂抛Too many connections,整个下单链路直接半瘫。事后排查,原因并不复杂:十几个微服务直连各自的数据库分片,连接数完全不受控,稍微一个慢查询拖住连接,雪崩就来了。那次事故之后我们才下决心做了一件事——在应用和数据库之间加一层统一的分布式数据库代理。
这门课很值得拿出来复盘。当然,网上讲分库分表、讲MyCat、讲ShardingSphere的资料很多,但大多是按功能点罗列,真正上手时会遇到的路由冲突、事务补偿、连接池参数、压测翻车这些细节,很少有人串起来讲。我尽量把这次的改造过程、踩坑记录和关键思路一次性说清楚,无论你是准备调研方案、还是已经在改造路上,应该都能用上。
1. 分布式数据库代理到底解决了什么
1.1 分库分表之后,客户端直连的代价
很多团队第一阶段的分布式数据库改造,其实是"客户端直连分片库"——业务代码里配多个数据源,自己写一个工具类选库选表,订单表按用户ID取模,库存表按SKU哈希,规则直接写在Java代码里。这种方式能跑,但代价是结构性的。
首先是路由规则和业务代码强耦合。分片键一变、分片数量一扩容,你要发版本、要改配置、要处理存量数据迁移,每一次都战战兢兢。其次是连接数压力,假设你有20个分片、每分片最大连接数1000,理论上总容量是2万,但应用侧往往不止一套服务,每个服务、每个实例都建一堆连接,实际消耗远超预期,而且你根本没法统一回收和复用。第三是跨库查询和跨库事务基本得靠手工,下单要写订单库、扣库存要写库存库,业务代码里自己拼凑聚合逻辑,出了数据不一致问题都没法定位。
1.2 代理层做了什么
分布式数据库代理,本质上是数据库访问的一个中间网关。业务端不再直接连真实的MySQL分片,而是连一台代理,代理再去连接后端真实库。它接管的工作主要有这四块:
- SQL解析与路由:解析出SQL要操作的表、字段、条件,根据分片规则把请求发给正确的分片库。
- 结果集归并:一条
order by或count可能落到多个分片,代理要先在各分片执行再汇总排序、聚合,返回给客户端一个合并后的结果。 - 读写分离与负载均衡:把读流量分发给多个从库,把写流量集中到主库,故障时自动摘除不可用节点。
- 连接管理与复用:代理和业务之间、代理和真实数据库之间都做连接池,大幅降低直连的连接开销。
类比一下,代理层就像小区门口的中转站:业主不需要知道哪封信该投给哪栋楼的哪户,只要把信放到中转站,中转站按门牌号分发、按回执汇总,出了问题业主也不用自己去找各楼协商。
1.3 驱动内嵌和独立代理两种形态怎么选
现在主流的方案大致分两类。一类是像ShardingSphere-JDBC这样,以驱动形式嵌入应用进程,不走独立服务;另一类是ShardingSphere-Proxy、MyCat、ProxySQL、Vitess这类独立部署的Proxy进程,应用通过MySQL协议直接连它。
两类我都用过,说下直观感受,整理成表格更清楚:
| 维度 | 驱动内嵌式(ShardingSphere-JDBC) | 独立代理式(Proxy、MyCat等) |
|---|---|---|
| 部署方式 | 打进应用,不额外占机器 | 独立集群,应用改连接地址即可 |
| SQL兼容性 | 直接在JVM内做解析,兼容性较好 | 多一层协议翻译,部分SQL有损耗或限制 |
| 运维影响 | 升级驱动要重新发版 | 代理集群独立扩缩容 |
| 性能开销 | 单次调用极低损耗 | 增加一次网络往返和代理转发开销 |
| 适合场景 | 对性能敏感、改造可控的单一团队 | 需要统一管控、多种语言的场景 |
如果团队是Java技术栈、规模不大、变动频繁,内嵌式上手更快;如果公司有多个技术栈、需要统一DBA管控SQL,独立代理式更符合预期。我们没有走最"省事"的路,特意选了独立代理模式,因为要同时服务订单、库存、用户等多个域,之后还有一个由DBA统一收拢权限和监控的规划,客户端驱动式的控制力不够,所以痛一次痛到底。
2. 一个具体的改造:订单库和库存库接入代理层
2.1 改之前的痛点
我们的电商场景里有订单和库存两个核心域,订单库有20个分片,库存库有10个分片。订单服务、库存服务各自连各自的库,本来也算正常运行着,直到几个需求撞到一起:下单要同时写订单、预扣库存;库存要支持跨仓调拨;后台还要按订单维度查商品、查仓储信息。
结果就是应用里到处是分布式事务的"手写补丁":先写订单表,再调库存服务扣减,如果失败就发补偿消息。逻辑分散在至少三个服务里,经常出现库存扣了但订单失败、或者订单成功但库存没扣干净的情况。更别说临时要查一张跨分片的统计报表,直接在库里跑SQL差点把从库拖死。
2.2 接入代理后的目标架构
我们最终落地是这样的:
应用服务 -> 分布式数据库代理集群 -> MySQL分片 |-> 读从库应用侧只需要改一个数据源地址,指向代理集群的VIP。代理这边配置逻辑库shop,逻辑表t_order、t_stock,映射到后端的物理分片shop_0、shop_1……代理负责把t_order按user_id取模路由到订单分片,把t_stock按sku_hash路由到库存分片。业务代码不再维护任何分片规则,写出来的SQL还是标准的逻辑SQL,代理自动转换成实际SQL。
这种架构最大的好处是架构演进变快了。后续加新分片不用动应用,DBA在代理配置里增加数据源和分片规则即可;权限控制、SQL拦截、慢日志都可以集中做,不用挨个服务推进。
2.3 分片键不一致,是绕不过的坎
这里有个非常重要的设计决策:订单表和库存表的分片键是不一致的。订单表按user_id分,库存表按sku_hash分。这就意味着一次下单操作,订单写入的分片和库存扣减的分片完全可能不是同一个物理节点。
如果你天真地想直接做一条跨表update或join,代理要么拒绝执行,要么做比较复杂的多节点协同。我们在下单链路里彻底放弃了跨节点SQL,把订单写入和库存预扣拆成两个独立子事务,中间用消息驱动。后面第四章我会专门讲这个补偿方案,因为这是整个改造中踩坑最多的地方。
2.4 改造工作量的重新评估
接入代理前,团队估的工期是一周半。实际前后用了一个多月才敢完整切换,主要时间不是花在"连上代理",而是花在梳理存量SQL的兼容性上。那些项目里长期没人维护的、写了自定义函数的、带hint的、甚至嵌套子查询的SQL,一跑就是异常或者全路由。建议你如果有类似项目,第一步先做SQL资产盘点,把存量SQL导出来在测试环境批量回归,这一件事能帮你提前暴露80%的兼容性问题。
3. 一条SQL在代理层是怎么走完全程的
3.1 解析、路由、改写三步
代理处理一条SQL,大致分三步。先说解析,代理内置的SQL解析器把文本SQL变成一棵AST语法树,识别出SELECT/INSERT/UPDATE/DELETE、涉及的表、查询条件里的字段。然后是路由,路由器拿到语法树,结合分片规则,决定这条SQL要发往哪些分片:单值等值条件命中单分片,范围条件往往命中多个分片,不带分片键的查询就是广播全库。
最后是改写。代理不会原样转发逻辑SQL,而是把逻辑表名替换成物理表名,把条件里的分片键换成具体的库表后缀。我在压测环境用ShardingSphere-Proxy的日志印证过,一条逻辑SQL最终会变成若干条实际执行的SQL,每条前面都有实际的库表名。一个典型的配置长这样:
rules: - !SHARDING tables: t_order: actualDataNodes: ds_${0..19}.t_order_${0..9} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_user_hash t_stock: actualDataNodes: ds_stock_${0..9}.t_stock_${0..9} tableStrategy: standard: shardingColumn: sku_hash shardingAlgorithmName: stock_sku_hasht_order拆到20个库、每个库再10张表,代理看到where user_id = 123456,算出它的库下标和表下标,只访问那一个实际表。
3.2 结果集归并:最容易想错的地方
很多人以为分库分表之后,order by和limit就是代理把所有数据拉回来再排一遍,这个理解既对也不对。对的是代理确实要归并,不对的是它不是闷头拉全量。
以select * from t_order where user_id in (…) order by create_time desc limit 0,20为例。代理会改写SQL,让每个分片都执行order by create_time desc limit 0,20,然后各个分片返回各自的Top 20,代理在内存里做一次多路归并排序,再取全局Top20。如果写成limit 10000,20这种深分页,代理就需要下推limit 10020,每个分片都要返回10020条,性能非常难看。这点要提醒队伍里写分页的同学,深分页在分片环境下是最典型的性能陷阱,生产环境建议改成"游标式翻页",用上次查询的最后一条create_time加limit 20的方式往前走。
聚合函数同理。count(*)各分片先统计再汇总;sum先把分片结果求和,如果是求均值就不能直接对各分片均值取平均,必须各分片返回count和sum两个值再重算。这也是归并引擎的隐藏细节,写业务统计SQL时要留意数值对不对。
3.3 代理日志里的门道
排查问题一定学会看代理的慢日志或SQL日志。ShardingSphere-Proxy的日志里会同时输出Logic SQL和Actual SQL,前者是应用发来的原始语句,后者是代理改写后真实在分片上跑的表名和条件。我踩过一次这样的坑:某SQL在代理层看起来正常,但压测时其中几个分片的CPU爆红,打开日志发现where order_status=…里没有分片键,代理把它广播到了全部分片,20个库全部全表扫描。这就是典型的"逻辑SQL看似规范,实际路由胚子已经歪了",不看Actual SQL根本发现不了。
4. 分布式事务、锁和一致性:代理层最费脑子的部分
4.1 为什么"先写订单、再扣库存"不能这么干
下单链路天然是跨库的:订单在t_order(按用户ID分),库存扣减在t_stock(按SKU哈希分),这两张表几乎不可能落在同一个物理节点。因此"一个本地事务全搞定"是不可能的。
一个常见的错误做法是:业务代码里先提交订单事务,再调用库存服务扣减。这中间只要库存接口超时、宕机、或者网络抖动,订单已经落库,库存却可能没扣掉,超卖就出现了。反过来先扣库存再写订单,又会出现"库存扣了但订单状态失败"的幽灵库存。那时候我们复盘事故,大多数线上库存对不上,根因都是这种不一致。
4.2 XA两阶段提交的教训
严格一致性有一种现成方案——XA两阶段提交(2PC),由代理协调多个分片的预提交和正式提交。我们最初在压测环境试过,结论是:能用,但不敢往核心链路放。
2PC在prepare阶段要锁住所有涉及分片的资源,事务锁持有时间取决于最慢的那个分片。压测数据很能说明问题:单库本地事务TPS能做到一万左右,同样场景下走分布式情况下的XA,TPS直接掉到本地事务的一个零头,而且随着分片数增加还会继续下降。锁时间拉长,意味着别的普通查询都被堵住,系统吞吐和响应时间双双恶化。两个分片都没问题,但一旦其中一个分片prepare成功了、另一个prepare失败,协调者要回滚所有分片,流程异常复杂。生产环境的数据库节点偶尔会进程崩溃重启,事务表状态不一致的例子非常多。
所以XA适合低频、小事务量、强一致要求极高的场景,比如账户余额变更的个别对账修正流程。高频的订单扣库存核心链路,我们果断放弃,走了下面的最终一致性方案。
4.3 用本地消息表落地最终一致性
最终方案是经典的"本地消息表+可靠消息投递",流程是这样的:
- 订单服务在本地事务里同时写入
t_order和一张order_msg本地消息表。 - 后台定时任务扫描
status=0的消息,把"库存预扣"消息投递到库存服务的MQ队列。 - 库存服务消费消息执行扣减,成功后回写消息确认;失败则记录错误并触发补偿。
- 消息消费端要做幂等校验,防止同一消息被重复消费导致库存扣两次。
本地消息表长这样:
CREATE TABLE `order_msg` ( `id` bigint NOT NULL COMMENT '主键', `order_id` bigint NOT NULL COMMENT '关联订单ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待发送 1已发送 2成功 3失败', `retry_times` int NOT NULL DEFAULT '0' COMMENT '已重试次数', `next_retry_time` datetime NOT NULL COMMENT '下次重试时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_time` (`status`,`next_retry_time`) ) ENGINE=InnoDB COMMENT='订单消息表,与订单同库同事务';核心设计意图是:订单数据和待发消息在同一个本地事务里,做到"要么都成功、要么都失败"。这比"先发MQ再写订单"安全得多,因为发出去的消息一旦丢失,重试机制还能补偿,但订单如果先入了库、消息却没发出去,系统根本不知道该补偿谁。
这套方案上线后,消息偶尔还是会因为网络抖动投递失败,但重试表能把最终一致性兜住。我认为这才是分布式数据库代理真正要配合业务层做的事情:代理负责数据访问的标准化路由,业务层负责把跨分片的事务边缘,通过可靠的异步消息缝合起来。
4.4 分布式锁放在哪一层
热词里经常看到"分布式锁"和"Redis分布式锁",这和数据库代理有关联但不是一个层级。代理层诞生了很多数据访问相关的锁需求,比如批量更新广播表(所有分片都要更新的配置表)时,两个并发事务同一个会话去更新不同分片,部分成功部分失败会造成配置不一致,这类场景我建议用分布式锁把并发更新串行化。
但真实的业务互斥锁,比如同一个用户不能并发下单、同一件商品不能并发扣库存,锁应该放在业务资源层,而不是代理层。我们用的还是Redis分布式锁,抢到锁的业务实例再走代理访问数据库,锁粒度按用户ID和SKU设计。代理层不要试图去管业务锁,它管不了业务语义,反而会因为锁范围过大拖垮所有分片。
5. 代理层的连接管理、性能压测与容量规划
5.1 代理后面的连接池才是关键
很多人以为代理只是"转发一把",连接数压力自动消失,实际上只是从"业务直连库"转移成了"代理连库"。如果代理到后端库的连接池配得过大,后端一样会被打爆;配得过小,代理会成为新的瓶颈。
我最终在ShardingSphere-Proxy的配置里按这个思路调:代理与后端的物理连接不需要和前端逻辑连接一一对应,精确用池化复用。前端一个连接进来,代理从后端连接池里取一个空闲物理连接,执行完放回池子。关键参数是max-connections、min-idle、max-connections-per-query。实际压测后我们给出的数值是:单台4核8G代理,后端连接池上限控制在500左右,单查询连接数上限50,空闲连接最低50。再大就开始碰MySQL自身连接数天花板,收益不高,风险翻倍。
5.2 一台代理能扛多少流量
这个问题是每次汇报都会被问到的。我没法给一个放之四海而皆准的数,因为差异太大:点查和多分片聚合查询的负载完全是两个量级。我们压测的结果就一个快照:
| 场景 | 单代理QPS表现 | 备注 |
|---|---|---|
| 按分片键等值点查 | 2.5万~4万QPS | 取决于SQL长度和结果行数 |
| 分页+排序(无深分页) | 0.8万~1.5万QPS | 多分片归并开销明显 |
| 深分页(offset 1万+) | 不到3000 QPS | 并发一高就明显变慢 |
| 跨分片聚合(count/group by) | 3000~6000 QPS | 分片数量越多越低 |
压测还暴露了一个规律:单条SQL的处理延迟只要从0.2ms涨到0.5ms,同样的连接池规模下能支撑的并发就近乎腰斩。所以代理层对慢SQL的容忍度极低,任何一条缺乏分片键的扫描式查询,都会把聚合连接迅速耗尽。这个锅代理不背,是SQL本身的锅。
5.3 慢查询的定位思路
代理层启用慢SQL日志是标配。我在定位问题时通常用三步:第一步看代理慢日志里Actual SQL的总执行时间,确认是路由问题还是分片本身慢;第二步看后端MySQL的慢日志,确认是某分片数据倾斜、索引失效还是锁等待;第三步用explain打原始SQL,在每个分片分别跑一遍看执行计划。
压测中出现过最经典的问题:某条统计SQL因为条件里没带分片键,被代理广播到了全部30个分片,每个分片各自全表扫描,耗时从正常的20ms变成500ms。把字段补上分片键、或者改成先查索引表再精确路由,耗时立刻回到20ms。这类问题靠加索引解决不了,必须从路由源头改。
6. 上线后踩过的坑与真实调整
6.1 代理单点故障,所有读写瞬间全挂
代理把连接集中了,好处是统一,坏处也明显:一旦代理集群出问题,影响范围是全局的。我们第一版直接上了一个孤零零的代理实例,结果某天凌晨那个节点磁盘满,所有服务瞬间连库超时,比之前直连数据库宕机的爆炸半径还大。
后来做的高可用方案是两件事:一是代理实例改成双节点,前置VIP并配合健康检查,主节点故障秒级切换到备节点;二是客户端连接池层面加了连接失效快速重连,不能等到超时。Keepalived的配置不复杂,但它解决的是故障切换时间,真正要紧的是备节点要能随时接管流量,所以数据流上的配置要完全一致,且压测时故意杀主节点验证过切换时间。这次演练很值得做,真到大促才发现没演练过,根本不敢主动重启。
6.2 高价值用户把分片干成了热点
订单表按user_id取模,规则本身没错,但现实数据永远是不均匀的。我们有个KA客户,订单量是普通用户的几百倍,它所在的那个分片,磁盘容量、QPS、buffer pool全部被单独顶高,而同批其他分片却几乎闲着。这是哈希分片解决不了的数据倾斜问题。
处理思路是给大客户单独开分片空间,把user_id映射表独立维护,代理路由时先查映射,再决定走普通分片还是大客户专有分片。这类"分片键选择"的教训是:分片规则不能只按技术均匀来,还要结合业务模型做热点预判。预判不了就留好迁移通道,话题很大,但必须提前想。
6.3 雪花算法生成全局ID,时钟回拨差点导致重复主键
分布式数据库代理环境下,全局ID不能在库内自增,我们一开始用雪花算法。雪花算法依赖机器时钟,某一台机器做了时钟同步后突然回拨,生成的ID就有极小概率和之前重复,在主键插入时冲突。这事在测试环境遇到过一回,线上虽然没爆,但已经足够吓人。
现在的做法是在应用层封装ID生成器:改用Redis生成号段缓存,每次取一批ID,用完再取。这样既不依赖时钟,也可以让代理层的多条写请求使用不同模式的ID而不会碰撞。分布式环境的全局ID,优先级一定是"绝对不重复"远超"绝对有序",很多人舍本逐末去追求趋势递增,没必要。
6.4 压测里发现的参数也要留到上线前再检查一遍
大促压测那几天,我们连续调过几个代理参数,值得记录:一是max-connections从默认的1000降到500,反而减少了后端MySQL的连接打满风险;二是开启了代理的sql-parse日志,方便逐条追踪大促核心链路SQL;三是给代理所在的JVM堆内存核到8G,避免一次大结果集归并直接触发Full GC。每次调整都伴随一次半小时的混合压测,不能只调不改,改完必须重新测。
回到开头的那个事故,现在想想,"数据库连接数打爆"只是表象,底层是流量进入无序、数据访问没有统一闸口。分布式数据库代理就是那个闸口:SQL经过它,路由清晰、连接可控、规则统一;事务和一致性经过它,也有了比"业务各自为战"更明确的分层处理方式。
如果让我给一个最实用的建议,那就是不要把它当成一个"装上去就完事"的组件。代理层真正推行的其实是分层治理的思维:路由归路由、事务归事务、锁归锁、连接归连接,各管各的边界。把这条线理清了,后面再换什么组件、扩多少分片,心里都有底。