先交代一个真实项目里的场景。当时我负责的订单中心,上线还不到一年,订单表已经超过9000万行,磁盘占用接近180GB。每天晚高峰,数据库CPU冲上90%,慢查询一个接一个。MySQL性能优化最基本的索引、SQL改写都做了,撑了几个月又顶不住,最后只能走分库分表和水平扩展。这篇文章就把我这一路踩过的坑、选过的型、落过地的方案,完整写一遍。如果你是后端开发或DBA,正被单表数据量折磨,或者面试前想系统搞懂分库分表,这篇可以直接当操作手册看。
1. 项目背景:单库单表的瓶颈到底卡在哪
1.1 先排查资源瓶颈:CPU、磁盘IO、连接数谁先报警
先说结论:分库分表不是第一步,拆库拆表之前,你得先搞清楚单库到底是被什么资源卡死的。我习惯的做法是盯四个指标:慢查询数量、活跃连接数、磁盘IO使用率、InnoDB锁等待。如果慢查询数量随着数据量线性增长,而且优化完SQL仍然缓解不了,才需要考虑单表规模本身的问题。
以那张订单表为例,起初慢查询集中在两类:一类是按用户查最近订单,另一类是后台按时间范围拉订单。前者加(uid, create_time)联合索引后,大部分查询能走索引,但订单量继续涨到千万级后,辅助索引加上聚簇索引,每次回表都可能有随机IO,数据库的IO队列开始堆积;后者因为要扫全表或大范围数据,加了索引也不治本。这个时候,单表的体量已经成为性能上限了。
1.2 数据量上去之后,索引和锁的问题是怎么放大的
单表数据量过亿后,InnoDB的B+树层级通常在3到4层,单纯看查询理论并不慢。真正变慢的原因是索引缓冲命中率下降、页分裂变频繁、写锁粒度扩大。一个数据页16KB,一行订单数据假设1KB,一个页大约16行,1亿行就是600多万个页。Buffer Pool再大,总有大量页需要从磁盘换进换出,热点数据一散,随机读请求就会把磁盘IO打满。
更麻烦的是锁。比如后台按状态批量更新订单,可能在间隙锁上相互等待;某个商户做活动,大量同一用户或同一商家的写操作涌过来,行锁竞争会把事务响应时间拉长。mysql锁表现在几百上千个,活跃连接全部卡在processlist里。这个时候你再去加索引、改SQL,边际收益已经很小。分库分表解决的是“单机资源上限”和“单表数据规模”两个问题,不是所有慢查询都该用这招。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分库分表方案选型:不是所有拆分都叫水平扩展
2.1 垂直拆分:优先按业务线拆库,按字段冷热拆表
很多人一上来就问取模还是范围,其实在此之前,垂直拆分应该先做。垂直拆分分两步:一是按业务模块把库拆开,订单库、用户库、商品库分到不同实例或集群;二是把单表中的大字段、不常用字段拆到附属表,比如把订单扩展JSON和核心字段拆开,减少每行数据占用的页空间,让同页能容纳更多核心记录。
垂直拆分见效快,但解决不了核心问题:单表行数还在线性增长。所以它通常作为水平拆分的前置动作。我见过不少团队把订单表按扩展字段拆了,主表还是几亿行,照样慢。记住:垂直拆分是给表瘦身,水平拆分才是真正做扩展。
2.2 水平拆分:分片键怎么选,取模和范围如何取舍
水平拆分最关键的不是怎么分表,而是选分片键。分片键决定每次SQL能不能落到一个可用分片上。拿订单表举例,如果按order_id取模,那么按用户查订单的请求会广播到全部分片,虽然中间件会归并,但性能明显变差;按user_id取模,单个用户查询只访问一个分片,但需要按order_id精确查询的场景就要走映射或用全局索引。所以,分片键要从业务访问模式里找最高频的查询维度。
算法上最常见的有两种:取模和范围。取模实现简单,数据分布均匀,但扩容时数据迁移量巨大;范围分表,比如按月,扩容只需加新表,天然适合时间序列数据,但容易有热点,近一个月数据全在最新分片上。我的建议是:C端核心表用取模,日志和流水表用范围;如果两者都想兼顾,考虑二级映射或业务分组字段。
这里列一下我判断分片键的标准:
- 均匀性:分片键的值要足够分散,不要出现某个值独占大量数据。
- 查询亲和性:尽量让高频查询只落到一个或少数分片,减少广播。
- 稳定性:分片键本身最好不要被业务修改,比如手机号、user_id这类稳定字段。
- 简单性:类型越简单越好,避免复杂函数计算,否则路由和排查都很痛苦。
2.3 中间件选型:ShardingSphere、MyCat还是应用层自研
分库分表有两种落地方式:一种是使用成熟中间件,另一种是业务代码里自己维护路由。自研适合团队能力和精力都够的情况,能完全控制分片算法,但事务、join、分页这些都要自己补,很容易半途烂尾。我更推荐先看ShardingSphere和MyCat这两类方案。
ShardingSphere-JDBC是客户端模式,应用直接连接多个MySQL实例,分片逻辑在Java应用内完成,性能损耗低,适合Java技术栈。ShardingSphere-Proxy是独立服务端,模拟一个MySQL服务,对应用透明,适合跨语言或不想改业务的场景。MyCat部署类似服务端模式,但新特性迭代慢了,遇到复杂子查询和特殊SQL容易出问题。我最终选的是ShardingSphere-JDBC,原因是团队是Java后端,改造可控,配置也够灵活。
测试环境建议直接用Docker拉几个MySQL实例来模拟分库。比如用mysql:8.0镜像,起三个容器就是三个分片,开发阶段就能把路由、迁移、故障切换先跑一遍。这也省去了手动安装几个MySQL实例的麻烦,配置起来很快。
3. 水平扩展实操:容量评估、数据迁移与配置落地
3.1 容量评估:先算要拆多少库、多少表
开工之前先把账算清楚,否则拆一半发现分片数不够,很尴尬。我一般用两个公式:表数量 = ceil(预估三年总行数 / 单表可承受行数),库数量 = max(ceil(总QPS / 单库能扛QPS), ceil(表数量 / 每库合理表数))。比如订单数据预计三年增长到1亿行,单表控制在500万行,表数量就是20;线上高峰期总读写在2万QPS,单库扛到5000,至少4个库,每库放5张表。
这里单表500万并不是绝对标准。如果表字段少、访问模式简单,2000万也能跑;如果字段多、写放大严重,我甚至会压到300万以内,主要还是结合Buffer Pool大小和业务SQL复杂度。拆分完还要预留buffer,实际我会多拆出至少一倍的余量,避免下一次扩容来得太急。
3.2 数据迁移:“双写+binlog回放”无损切换
如果业务能接受短时间停机,直接停写、迁移、校验、切流最省事。但订单类业务显然不能接受。我用的双写加binlog回放,详细过程是:先把历史全量数据导到新分片,应用改造成新旧库双写;新库写入失败只记录日志,不能影响主流程;然后用canal或者自研binlog消费逻辑,把主库上新增和变更的binlog回放到新库;回放一段时间后,两边做行数和checksum校验;校验通过,把读流量灰度切到新分片,确认无异常再切写流量;观察一两天后关闭旧库写入,只保留只读备份。
有两点必须注意。第一,历史数据导入时要保证幂等,配合唯一键去重,否则binlog回放可能和双写产生重复数据。第二,双写期间新库的SQL要兼容分片路由,如果某条写操作没带分片键,中间件会广播,可能造成性能抖动,需要在入口就拦截。
3.3 ShardingSphere配置与验证过程
我以ShardingSphere-JDBC 5.x为例,配置一个订单表分成4片的场景。依赖用maven或者gradle引入shardingsphere-jdbc-core-spring-boot-starter,版本选5.2.x之后的。然后配置ds0、ds1两个数据源,每个库里建t_order_0和t_order_1两张分表。核心配置如下:
yaml复制dataSources:
ds0:
url: jdbc:mysql://10.0.0.1:3306/order_db_0
username: root
password: "******"
ds1:
url: jdbc:mysql://10.0.0.2:3306/order_db_1
username: root
password: "******"
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds${0..1}.t_order_${0..1}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db_inline
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: table_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
db_inline:
type: INLINE
props:
algorithm-expression: ds${user_id % 2}
table_inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id % 2}
keyGenerators:
snowflake:
type: SNOWFLAKE
这个配置里,order_id用雪花算法生成分布式主键,user_id作为分片键,先对2取模决定库,再对2取模决定表,最终落到4张物理表里。查单用户订单时会直接路由到对应分片;查用户加订单详情能精准定位一张表;但按order_id查就需要广播到4个表,所以业务上还要维护order_id到user_id的映射关系,这是常见取舍。
配置完之后,第一步先用SQL日志看actual table,确认路由落到预期物理表。第二步打接口压测,观察各分片负载分布。我当时就发现分片键没生效导致全表扫描的SQL,一查日志,原来有些历史接口根本没传user_id,直接走广播路由,把四个分片一起打爆。所以后来把路由查询日志单独采集,专门监控每个分片的访问分布。
4. 分库分表后的典型问题与排查技巧
4.1 跨分片分页、排序和聚合为什么慢
分库分表后,最典型的性能坑就是跨分片order by和limit。中间件会让每个分片各自取offset+limit条,然后到内存做归并排序。如果用户翻到第100页,offset=990,每个分片都要取上限100条,再丢弃前990条,数据量大了内存和计算都扛不住,这也是mysql排序在分布式环境下最容易翻车的地方。
解法我推荐滚动分页:前端不再传页码,而是传上一页最后一条记录的ID或时间,SQL写成where create_time < ? order by create_time limit 20。这种模式天然回避了深分页,所有分片只要返回20条,然后归并一次取前20。如果确实是后台报表要跨全部数据做聚合,建议把明细同步到分析型数据库或搜索引擎,不要让在线分片扛这种查询。
4.2 分布式事务怎么降级
拆分之后,原来一个事务里更新订单和用户账户的操作,现在可能跨两个库。直接用本地事务肯定不行,ShardingSphere虽然支持XA,但强一致事务在跨库场景下性能和复杂度都会放大。我的经验是,优先从业务设计上规避:比如支付后更新订单状态和写财务流水,可以改成先发MQ,让下游各自消费更新,通过回调确认最终一致。
如果对强一致要求非常高,再考虑XA或者TCC,但一定要做补偿和人工干预入口。大部分互联网核心链路其实都接受最终一致。面试聊到mysql事务和分布式事务时,能说清楚为什么不用强一致、最终一致怎么保证,比背两阶段提交的流程有用得多。
4.3 取模分片扩容时,数据迁移怎么做到平滑
取模分片最大的痛点是扩容后取模基数变了。例如从4个分片扩到8个,原来user_id % 4结果落到0的用户,改成%8后可能落到0或4,数据需要迁移接近一半。很多团队在扩容时会选择停机重分片,但业务不允许。
我建议两种思路。第一种是双写再切换:新建8个分片,开启双写和binlog回放,迁移完成前先把旧4分片和新8分片保持同步,最后灰度切换,这在前面3.2已经说过。第二种更高阶的办法是逻辑桶:设计分片时不直接用user_id % 4,而是先用user_id % 1024分成1024个逻辑桶,再把桶映射到4个物理节点;扩容到8个节点时,只需要迁移部分桶的映射关系,比如把原来分片3上的某些桶挪到新分片5上,数据迁移量少一个数量级。
这个逻辑桶方案是我的长期推荐,虽然初始化配置复杂一点,但后续扩容和动态调整都会舒服很多。注意桶的数量要足够大,一般取1000以上,否则可扩展空间不够。
4.4 线上故障速查表:从症状到对策
分库分表之后的很多问题,其实都有固定的排查路径。我把最常见的几类故障整理成一张表,方便直接对照:
| 症状 | 可能原因 | 排查思路 | 处理建议 |
|---|---|---|---|
| 某个分片CPU拉满 | 分片键分布不均,热点用户或商户集中 | 查各分片监控QPS,统计分片键top值 | 热点行再拆分或加缓存;换更均匀的分片键 |
| 连接数打满 | 分片后连接池配置过大或短连接频繁 | 查活跃连接数和processlist | 限制每个应用池连接,增加分片数,启用连接复用 |
| 查询结果少了或多了 | 双写迁移丢数据,或路由配置不一致 | 按分片逐表查行数,对比迁移时间点 | 用binlog回放补数据,确保唯一键幂等 |
| 分页接口超时 | 深分页跨分片归并排序 | 抓慢SQL,看actual table路由 | 改滚动分页,或限制最大页码 |
| 写事务报错 | 事务跨库,中间件只支持部分场景 | 看异常堆栈,确认是否跨库 | 业务拆分事务,或引入分布式事务方案 |
这张表覆盖了分库分表初期的多数故障。核心思路是先看路由是否命中,再看数据迁移是否完整,最后才怀疑性能。很多问题不是分片本身的问题,而是入口SQL没带分片键导致广播。
5. 监控、容量规划与实操总结
5.1 扩容之后要盯哪些指标
拆分完成后,监控才是真正的长期工作。我至少会盯三类指标。第一类是基础资源:每个分片的CPU、内存、磁盘IO、网络带宽;第二类是数据库指标:活跃连接数、慢查询数、主从延迟、Buffer Pool命中率;第三类是业务路由指标:各分片QPS分布、分片键缺失率、广播查询占比。特别是广播查询占比,如果某段时间明显上升,多半又有接口没传分片键。
告警阈值建议按分片设置,不要只看整体均值。比如4个分片整体平均CPU 30%,但其中一个分片已经80%,整体看完全没事,实际已经有热点。监控面板最好按实例维度展示,用prometheus加grafana这类工具把多个分片叠在一张图上对比,热点一眼就能看出来。
5.2 选型和踩坑后的几点个人体会
最后聊几句体会。第一,分库分表是性能优化的终局手段,不是第一手段。如果还没做索引优化和SQL改写,不要急着拆。第二,分片键一定要选业务天然自带主键维度的,后面想改分片键,成本会大到怀疑人生。第三,预留容量比精确容量更重要,数据增长往往比估算快得多。
还有一个小技巧,每次分片改动上线后,我都会用一个脚本把所有分片的表行数、最大ID、数据分布查出来,和上一版本做对比。任何一次数据不一致,都能在一分钟内定位到分片。这个习惯救过我很多次。
