资讯动态

分库分表实战:路由组件如何破解单库慢查询与扩容难题

发布时间:2026/10/21 15:28:58 来源:尧图企业网站定制
今天是我这个实战系列推进到第十天的节点。前面九天我只让一个模拟订单系统老老实实地跑在单库单表上把下单、退款、库存、账务、消息推送这些主链路全部打通。也正因为如此当昨天压测脚本跑到第三轮慢查询日志里冒出一批超过三秒的订单查询时我知道自己必须引入分库分表路由组件了。我会把 DAY 10 前后的完整踩坑过程复盘一遍路由组件为什么要在这个阶段加、它内部到底做了什么、配置和迁移要注意哪些坑以及压测数据如何倒逼我调整扩容方案。如果你也在单库单表里撑了几个月正犹豫要不要动手拆这篇应该能给你一些参考。1. 为什么非得到第九天才动手单库单表的“舒适区陷阱”1.1 前九天我到底在做什么数据被撑到了什么程度模拟项目X 的业务基线很简单一张主订单表 app_order一张订单明细表 app_order_item一张库存表一张账户流水表。前九天我基本没碰表结构所有流量都打到 app_order 单表上。到 DAY 9 晚上这张表已经有了 1892 万行历史数据表空间约 22 GB而我们给数据库实例分配的 buffer pool 只有 8 GB。单表索引其实建得并不差user_id、create_time、order_no 都有二级索引但索引本身在 1892 万行上也要付出可观的扫描成本。慢查询问题集中体现在两个场景。第一个是前台“我的订单”列表按 user_id 查最近 20 条订单order by create_time desc limit 20平均响应时间 480ms最差跑到 1.2s。第二个更吓人后台运营需要按月份聚合订单金额一条 group by month 的统计 SQL 要跑 2.9 秒直接把凌晨的数据跑批拖到了天亮。一开始我怀疑是索引失效explain 之后发现扫描行数还是八位数索引回表次数太多单表已经顶不太住了。我在这里想说明白一个判断标准不是“超过 1000 万就必须拆”而是“数据量和访问模式共同决定的成本已经失控”。我当时观察了三个信号它们是同时出现的单表行数超过 1500 万、慢查询占整体查询比例超过 5%、高峰时段 CPU 和 IO 长时间徘徊在 80% 以上。任何一个单独出现都可以再观察三个同时出现基本就可以启动拆分准备了。1.2 既然迟早要拆为什么一开始不直接分库分表这是很多同行会问的问题。我前九天故意不拆不是懒而是有更现实的理由业务模型和查询模式还没稳定。新的优惠规则、售后状态机、客服后台的筛选条件都在频繁调整如果一开始就上分片每改一次需求都要同步评估路由规则是否受影响开发成本会成倍放大。过早优化在大规模系统里是一种灾难它让你把有限的精力浪费在“未来可能出现的瓶颈”上而不是当前确定的业务问题上。另外一个原因是分库分表本身会带来查询约束。单表时代随手就能写的 join、子查询、任意字段排序在分片之后全都变成高成本操作。团队如果还没想清楚哪些查询是高频必须带分片键的、哪些可以走全路由广播贸然拆完就会发现一堆功能“查不到数据了”。还有一个隐蔽的成本在事务层面一个下单事务原本只涉及一张库表拆完可能横跨多个数据节点本地事务失去意义分布式事务的复杂度是成倍上升的。所以我更愿意把“要不要拆”画成一道判断题当业务增长曲线明确、查询模型基本固定、单库明显成为瓶颈时再动手。我给的内部红线是两条单表行数超过 1500 万且慢查询占比连续一周大于 5%或者单表容量超过 buffer pool 的 1.5 倍。出现任意一条就开始走拆分立项。DAY 9 的慢查询预警正好两条都踩中了。2. 路由组件加进来之后到底替我干了哪三件事2.1 第一件事SQL 改写一条变多条很多第一次接触分库分表的同学以为路由组件就是“把一条 SQL 换成另一张表名”实际并没有这么简单。路由组件接到一条 SQL先做语法解析识别出查询条件里有没有分片键再根据分片键计算目标库和表最后把逻辑表替换成物理表必要时还要改写聚合、分页和排序语句。我举个最简单的例子。业务原本执行的是select * from app_order where user_id 1001 and create_time 2025-01-01 order by create_time desc limit 10;user_id 是分片键1001 通过我们定义的规则计算后落在 ds1 的 app_order_2 表。路由组件会把它改写成select * from app_order_2 where user_id 1001 and create_time 2025-01-01 order by create_time desc limit 10;然后路由到 ds1 执行。如果查询条件里没有 user_id组件就只能把 SQL 广播到 4 个库、16 张表上每一份结果都回来再做归并。这种“全路由查询”在分片环境里最危险它会让单条慢查询的代价放大到原来的十几倍。这里有一个非常关键的工程原则路由组件不是数据库优化器它不会帮你想清楚“该扫哪张表”它只会按你配置的规则机械地执行。所以业务开发必须遵守一条铁律能带分片键的查询一律带上带不上的必须走专门设计的映射表或者索引表。2.2 第二件事分片键与路由算法取模不是唯一解分片键选得好不好直接决定这个方案能不能活下来。我当时的候选有 user_id、order_no、order_time。最后选了 user_id原因很朴素用户查自己的订单是这个系统最核心、最高频的查询按 user_id 分片可以保证一次查询只打一个节点user_id 是一个长整型数字分布天然均匀而 order_time 做分片键会导致新数据永远写到同一个热点库一个月后旧库冷、新库热完全不能接受。order_no 是一个看起来不错、实际很坑的选择。用户不一定记得订单号大量查询都是按 user_id 进来的而且客服后台按订单号查询必须跨分片你还需要额外维护一张“order_no → user_id”的映射表。除非产品形态要求“按订单号入口优先”否则我建议不要用订单号做主分片键。路由算法我最终选了最简单的取模。规则定义如下库索引 user_id % 4表索引 (user_id / 4) % 4逻辑表 app_order 映射到 4 个库 × 4 张表共 16 张表举个例子user_id库索引物理表0ds0app_order_01ds1app_order_04ds0app_order_11001ds1app_order_29999ds3app_order_3取模的好处是规则简单、谁都能看懂、测试用例一写就完事。坏处是扩容时数据迁移量大。我也考虑过一致性哈希它能减少扩缩容的数据迁移代价是路由计算复杂、虚拟节点不好排查、规则不透明。在我们这种业务模型稳定的项目里取模反而更实用因为你只需要在扩容前做好“从 16 片到 32 片”的迁移脚本就够了不需要在运行期去猜数据到底落在哪个虚拟节点上。2.3 第三件事结果归并排序分页不是把数据拼起来如果每条查询都带分片键路由组件的工作就结束了。但实际系统总有一些查询必须不带分片键比如后台运营要统计全局订单量、财务要对账、风控要扫描异常订单。这时候组件会让 16 张表各自执行同一份 SQL然后把多份结果集合并成一份返回这个合并动作就叫结果归并。归并没有听起来那么简单。排序归并需要多路归并算法每个节点返回一个有序游标应用层不断取下一条最小的记录分页归并更坑全局 limit offset 不能直接在每个节点上各取 offsetlimit 再拼因为谁也不能保证全局第 100 万条记录恰好分布在第 100 万条的位置你必须先把每个节点的前 offsetlimit 条汇集起来再做一次全局偏移聚合归并则需要区分 count、sum、avg、group by、distinct 各自的语义。我后来统计过结果归并阶段的内存峰值往往会出现在 order by 和 group by 同时出现的查询上。路由组件虽然会做流式归并但某些算子必须全量落内存。所以我在 DAY 10 的代码评审里加了一条硬性要求全路由查询必须允许 3 秒以上的响应时间且查询结果集行数上限不超过一万行。超过这个量级的统计需求应该走离线的宽表或者汇总表而不是在分片层直接硬扛。3. 集成落地全记录依赖、配置、迁移、自检3.1 依赖选型版本稳定比功能新更重要到了具体接路由组件的时候我先面对的是版本选择。社区里的路由内核更新非常快每半年就有大版本变更但我最后还是选了 2.4.0 这个稳定分支而不是最新的大版本。原因无非三个第一团队原有的连接池、ORM 框架都和 2.4.x 做过兼容性测试第二最新版本引入了异步分片 API 和容器化调度能力这些功能对我们的业务量级短期内没有价值第三新版本往往在分片算法扩展接口上有破坏性变更一旦升级就要重新适配没必要在 DAY 10 这个时间点引入不确定性。我没采用社区版的默认依赖而是把路由内核封装成了一个内部二方包对外只暴露最少的配置项数据源、分片规则、分布式 ID 生成器、SQL 打印开关。这样应用层看到的东西很干净以后就算要替换内核改动也被隔离在一个包里面。我强烈建议你也这么做不要直接在业务代码里散落一堆路由内核的 API那会让后续升级和排查变得非常痛苦。配套能力上我一次性加了三样分布式 ID 生成器订单号不再依赖数据库自增、SQL 打印日志用于观察实际改写后的物理 SQL、全路由查询告警发现有 SQL 没带分片键立刻通知值班。这三样东西看着不起眼后面排查问题全靠它们。3.2 分片配置样例用一份可以被抄的 yaml 说话这里给一份简化后的配置字段名我已经做了打码处理核心结构可以直接复用。数据源有四个命名为 ds0 到 ds3每个库里四张表dataSources: ds0: url: jdbc:xxx://node0:3306/business_db ds1: url: jdbc:xxx://node1:3306/business_db ds2: url: jdbc:xxx://node2:3306/business_db ds3: url: jdbc:xxx://node3:3306/business_db rules: sharding: tables: app_order: actualDataNodes: ds${0..3}.app_order_${0..3} databaseStrategy: shardingColumn: user_id algorithm: user_id % 4 tableStrategy: shardingColumn: user_id algorithm: (user_id / 4) % 4 app_order_item: actualDataNodes: ds${0..3}.app_order_item_${0..3} databaseStrategy: shardingColumn: user_id algorithm: user_id % 4 tableStrategy: shardingColumn: user_id algorithm: (user_id / 4) % 4 bindingTables: - app_order, app_order_item defaultDataSource: ds0注意两个细节。第一app_order 和 app_order_item 的分片规则必须完全一致并且要声明到 bindingTables 里路由组件才会保证这俩表在 join 时落到同一个节点。第二我故意没给 app_order 配置默认路由就是为了逼着所有查询显式带 user_id而不是让组件悄无声息地全路由。分布式 ID 生成器也在这个文件里一起开了生成的字段是 order_id。这一步如果不做后面两个库同时插入数据订单号撞车就是迟早的事。3.3 存量数据迁移自增主键给了我一个下马威配置写完只是第一步真正的硬骨头是迁移。我的方案比较保守双写一段时间再切换读流量最后切换写流量。下单接口里加一个双写监听器同一份订单同时写入旧的 app_order 单表和新的分片表落库后异步比对两边的行数和关键字段。迁移脚本跑起来之后第一个坑立刻出现自增主键在分片环境里完全不成立。旧表 order_id 是数据库自增从 1 到 1892 万连续排列分片之后如果还靠数据库自增每个库的每张表都会从 1 开始这样全局 order_id 必然冲突。我当时的处理方式有两步。第一步分片表的 order_id 全部改成由分布式 ID 生成器预生成不再走数据库自增。第二步历史数据导入时给每行额外保留一个 old_order_id 字段用来挂接旧的业务单据号同时建了一张“旧 ID 到新 ID”的映射表保证客服后台拿旧单号还能查到新单号。映射表会越来越大所以只留最近六个月的更早的单据按用户分片索引就能定位。双写阶段的核对任务也很重要。我每天跑一遍比对任务比总数、比 min/max 订单号、比 create_time 分布发现不一致立刻告警绝对不靠“感觉差不多”。DAY 12 那天核对任务就抓到一个问题旧表里有一批历史状态为“已取消”的订单由于状态机在前端做过非法修改导入脚本把它们漏掉了。这种脏数据在双写期发现修复成本很低要等到切完流量再发现就是事故了。3.4 启动自检清单别以为日志不报错就完事了我见过不少人配置完路由组件启动日志刷了几行成功就宣告上线然后被线上问题教做人。所以 DAY 10 我给自己列了一份启动自检清单强烈建议你也照着做一遍。第一项路由边界用例测试。user_id 取 0、1、4、1001、9999 这几个边界值分别调用一次查询人工核对日志里实际访问的库名和表名是否和规则算出来的一致。第二项开启 SQL 打印随便跑三个典型查询确认打印出来的物理 SQL 是改写后的表名而不是原逻辑表名。第三项查全路由告警日志里统计一天内执行了全路由广播的 SQL 条数如果超过业务允许范围就得回头查哪条查询漏了分片键。第四项验证分布式 ID 生成器连续生成 10 万个 ID确认没有碰撞同时检查时间回拨保护有没有触发。第五项全链路业务回归下单、支付回调、发货、退款、按用户查订单、按订单号查订单每一条都要真的跑到。这一套清单做下来大概需要大半天但能挡住 90% 的低级事故。我在自检时还真抓到一个问题有个统计接口通过 ORM 框架生成了 select count(*) from app_order where status 1没带 user_id也没有绑定表路由组件直接把它广播到了 16 张表。虽然返回结果是对的但接口耗时从原来的 200ms 变成了 1.6s。这种问题如果不提前暴露上线后就会被运营部门精准投诉。4. 压测前三天踩出来的三个真问题4.1 跨分片 JOIN订单明细表差点查不出来分片之前app_order 和 app_order_item 用 order_id join 是一件很自然的事。分片之后如果两张表的分片键不一致join 时路由组件根本没法把它们拉到同一个节点通常只能报错或者退化成应用层多次查询。我当时先踩了一个更隐蔽的坑app_order_item 也按 user_id 分片但我们忘了在绑定表里声明主表和明细表的关系导致某次订单详情查询被路由到不同节点直接查不出来。解决方式有两层。第一层是配置层把 app_order 和 app_order_item 的分片规则配置成完全一致并且在 bindingTables 里声明绑定关系这样所有涉及两张表的 join 都会被路由到同一数据节点执行。第二层是架构层订单明细本身行数不大我最后在前端接口里直接改成“先查订单再按 order_id 批量查明细最后在应用内存里拼装”响应比 join 更快还省掉了跨节点的风险。我后来把这总结成一条规则能用应用层拼装解决的关联就不要硬着头皮用 join。特别是订单这种“主表一行、明细几行”的结构一次网络 RPC 就能把明细全捞回来比数据库跨节点 join 优雅得多。4.2 分页的“假象”limit 偏移在分片环境下不靠谱压测第三天我复现了一个非常经典的翻页问题。前台页面翻到第 100 页之后订单列表开始出现“少数据”的现象有时候一页只有两三条甚至空页。根因是分页语义在分片环境下被放大了对于 limit 1000000, 10如果组件把这条 SQL 原样下发给每个分片每个分片各自跳过自己前 1000000 条再取 10 条合并后的结果一定对不上全局的第 100 万条因为全局前 100 万条数据在 16 个分片里的分布并不是均匀的。更常见的错误配置是每个分片只取 limit 10然后归并这种写法深翻页必然丢数据。正确做法要么是每个分片都取 offsetlimit再在归并层做全局偏移要么用游标式分页把“第几页”的语义改成“时间游标”。我最后选了游标因为我们的场景天然是“按时间倒序看订单”第一页用当前时间做上界下一页取上一页最后一条订单的 create_time 作为下界where user_id? and create_time ? order by create_time desc limit 20。这样无论数据分布多不均匀每个分片都只处理一个小范围的排序性能非常稳。当然产品可能不接受“没有页码跳转”的交互。那就只能在后台维护快照表或物化视图在离线环境先算好分页数据查询时直接读快照代价由后台承担。4.3 分布式事务下单和扣库存不在一棵树上单库时代下单和扣库存写在同一个事务方法里事务注解一包就完事了。分片之后订单在 ds0库存可能在 ds3本地事务已经没有办法保证跨节点的原子性。一开始我想用 XA 两阶段协议但研究了一圈后放弃了协调器本身有单点风险锁持有时间会拉长业务一旦复杂起来两阶段的回滚处理非常痛苦。我采用的务实方案是“同域同库 最终一致”。具体来说把“创建订单”和“扣减库存”这两个高频操作尽量放到同一个数据源里执行保证它们在绝大多数场景下还是本地事务确实无法避免跨库的部分比如某些异构调用就用本地消息表加异步补偿。下单时在同一个本地事务里写入订单和一条待处理消息事务提交后由后台任务消费消息去调用扣减接口扣减失败就重试超过次数就标记异常单由人工或补偿脚本处理。这个方案的哲学很简单不要把分布式事务当成默认选项。能用业务分层规避的用分层规避能用消息最终一致解决的就完全不需要加锁等待同步事务。我们的业务场景里订单创建后用户有足够时间去支付状态可以异步流转所以最终一致完全够用。如果你的场景要求强一致那我会建议先重新审视业务设计再看是不是必须引入成熟的分布式事务框架。5. 压测结果与容量决策16 张表够用多久5.1 从平均 450ms 到 P99 85ms压测数据复盘路由组件跑通后我做了一轮对比压测。压测条件保持和 DAY 9 一致100 并发读写比例 7:3模拟用户 ID 均匀分布持续一小时。带分片键的“我的订单”查询P95 从 450ms 降到 32msP99 从 850ms 降到 85ms单次查询只访问一个物理分片明显是质的改善。但全路由查询的表现让我警醒后台管理页里那些不带 user_id 的查询P95 620msP99 980ms比单库单表时代还要慢。造成这个结果的原因也很简单一条全路由 SQL 要拆成 16 条子 SQL各自查询完还要归并排序内存和 IO 都翻倍。压测中全路由查询占比约 12%这 12% 几乎抵消了分片带来的红利。我当时的处理是把高频后台查询改造成按 user_id 分段查询或者走 order_no → user_id 映射表剩下的低频统计类全路由查询限制返回行数并允许延迟。这个结果告诉我们一个道理分库分表不是把表拆了就万事大吉它只是把瓶颈从数据库转移到了路由层和应用层。如果业务查询大量不带分片键你会得到一个“拆完更慢”的系统。5.2 容量评估单表 2000 万行之前必须扩容很多人以为拆到 16 张表就一劳永逸了实际是要算账的。以我们的业务节奏假设日新增订单 60 万到 80 万行一年下来就是 2.2 亿到 2.9 亿行分摊到 16 张表每张表一年就有约 1400 万到 1800 万行。如果再跑一年单表会到 3600 万这个量级即使分片了也不舒服。所以我决定在下一个版本把“4 库 × 4 表”升级成“8 库 × 4 表”也就是 32 个分片单表年增量回落到 900 万行以下。库索引改成 user_id % 8表索引改成 (user_id / 8) % 4。扩容流程也在这个阶段定下来先加新库并把新规则发布到灰度环境启动历史数据搬迁任务按新规则重新分布搬迁过程中持续比对旧表和分片的行数与内容比对通过后再放量切换读写流量旧库保留只读一周作为兜底。这个流程我验证了两遍第一遍在一个模拟环境里跑第二遍才是真实数据。第一遍就翻车了搬迁任务按 user_id 范围切割但切割边界没对齐结果有一小部分 userId 同时被两个任务搬了两份造成主键冲突。后来改成按“分片编号”来切割任务每个任务负责一个目标分片的所有原数据才彻底解决。5.3 往后几天我会怎么治理这头“分片野兽”路由组件上了之后治理工作比部署更细碎。我会在 DAY 11 到 DAY 14 持续做几件事一是把“全路由 SQL 次数”和“跨分片事务数”加进监控大盘每天看一眼趋势二是给 SQL 审核流程加一条硬规则凡是查询没带分片键且没有绑定表声明的不通过审批三是定期抽查分布式 ID 生成器的时钟状态防止节点时钟回拨造成 ID 冲突。最想提醒大家的是数据倾斜问题。我们模拟业务里 user_id 分布很均匀取模规则工作得很好。一旦未来出现头部用户订单量特别大的场景按 user_id 取模会导致热点用户所在分片比其他分片忙得多。提前想好的预案是热点用户单独拆分到专属分片配合缓存和异步化降低对单片的压力。这个预案现在用不上但需要写在文档里等真的遇到时不要临时拍脑袋。我自己的体会是DAY 10 最大的收获不是“把表拆开了”而是逼迫整个团队用数据位置重新思考每一条 SQL它从哪里来要落到哪个节点能不能避开广播。分库分表是一个系统性的查询语义重构不是一次配置变更。如果你也正在这条路上希望这份记录能帮你少踩几个我踩过的坑。

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

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

免费获取报价 →
↑