分库分表这事儿,我一直觉得是MySQL性能优化里最“重”的一招。很多人一听到性能瓶颈,脑子里第一反应就是分库分表,但真正动手的时候才发现,这玩意儿不是加几个表那么简单。选分片键、定分片算法、扩容迁移、跨分片查询,每一步都在挑战你对数据分布的理解。这篇文章我把这几年在项目里做分库分表踩过的坑、沉淀下来的方案、还有实际配置参数完整拆开来讲,希望能让你少走几步弯路。
1. 什么时候才需要分库分表:先搞清楚瓶颈在哪里
1.1 用数据说话:从QPS和存储量两个维度做预判
MySQL的瓶颈通常分成两种:一种是写并发太高,单库的连接数和锁竞争让你撑不住;另一种是数据量太大,单表几千万上亿行之后,即使走索引,B+树的层数变高,随机IO也明显变慢。很多人在表只有几百万行的时候就开始喊着要分库分表,这就属于过度设计。
我一般用两个数字做判断:
- 日增数据量 × 预计留存周期,如果单表行数超过5000万~1亿,就需要认真考虑水平拆分了。这个数字不是拍脑袋拍的,InnoDB聚簇索引的B+树在千万级以下基本还能保持三层以内,超过这个量级,索引维护代价和查询IO都会非线性恶化。
- 峰值写QPS超过5000~10000,单主库的binlog落盘、刷脏页、锁竞争都会报警,这时候需要的是分库,把写压力分散到多个实例上。
这个判断标准不是绝对的,但你至少要先拿监控数据看一眼,别凭感觉。如果单表才几百万行、QPS才几百,那要做的不是分库分表,而是先把慢查询日志拉出来看,是不是少建了索引、是不是有全表扫描、是不是没用上覆盖索引。真正合适的顺序是:索引优化 -> 缓存(Redis) -> 读写分离 -> 分库分表。分库分表是最后的手段,因为它的运维复杂度和业务侵入度都是最高的。
1.2 被忽视的前置优化:分库分表是最后的手段
我在很多项目里看到一个现象:表确实大了,但大部分慢查询是SQL本身写得有问题。比如查询条件里对索引列用了函数运算、在LIKE前面加了通配符、OR条件没有用UNION拆开、分页深翻页用了LIMIT 100000, 20这种写法。这些场景下,哪怕你把表分成128片,该慢还是慢。
所以我给团队定的规矩是:**先做一轮完整的SQL走查和索引优化,再看是否真的需要分库分表。**具体来说:
- 打开慢查询日志(
slow_query_log = ON,long_query_time = 1),采集一周的慢SQL。 - 用
EXPLAIN逐个分析执行计划,重点看type是否为ALL(全表扫描)、key是否使用了索引、rows扫描了多少行。 - 对高频查询做覆盖索引优化,尽量让索引包含所有SELECT的列,减少回表。
- 把分页改造成基于游标的方式,用
WHERE id > ? ORDER BY id LIMIT ?代替LIMIT offset。
这一套做下来,很多原本以为要分库分表的系统,硬生生把压力降了一半以上。只有当这些手段全部用尽、瓶颈依然存在时,才进入分库分表的方案评估阶段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分库分表方案怎么选:垂直拆、水平拆、中间件选型
2.1 垂直拆分:把大表按业务边界拆开
垂直拆分有两种理解。一种是按字段拆:一张表字段太多,比如有60个字段,其中只有20个是高频查询字段,另外40个是很少用到的大字段,那就把高频字段和低频字段拆成两张表,通过主键关联。这种做法的核心收益是让单行的存储变小,一个数据页能容纳更多行,查询时扫描的页更少,IO成本下降。
另一种是按业务库拆:原本所有表都在一个库里,现在按业务边界拆成用户库、订单库、商品库。这样做的好处是可以针对不同业务的负载特征做差异化配置,比如把订单库放在高性能SSD上,把日志库放到普通机械盘上;坏处是跨库的JOIN彻底没戏了,你需要在应用层做数据聚合,或者在冗余字段上做文章。
垂直拆分相对容易落地,因为它不会改变单表的数据分布逻辑,只是把表从一个库挪到另一个库。它解决的是**“单库压力大”**的问题,不是“单表数据量大”的问题。如果你的瓶颈是单库连接数满了但单表数据量不大,垂直拆分(也就是拆库)往往就够用了。
2.2 水平拆分:让核心表的数据分散出去
水平拆分才是真正意义上的“分表”。比如订单表从逻辑上还是一个 orders,物理上却变成 orders_0、orders_1、...、orders_15,一共16张表,分布在4个库上。每条订单根据分片键(通常是订单ID或用户ID)计算出一个哈希值,再取模落到对应的表里。
水平拆分的核心优势是:
- 单表数据量降下来了,索引层数变低,查询性能恢复。
- 写入并发分散到多库多表,锁竞争和磁盘竞争都降下来了。
- 可以线性扩展,当压力上来后,可以再增加分片数。
代价同样明显:所有操作都要带着分片键,否则就是全分片扫描;跨分片的聚合、排序、分页变得极其复杂;全局主键不能再依赖数据库自增。
所以在选型前你一定要想清楚:业务的主查询路径是不是可以稳定地通过一个字段(用户ID、商家ID)来定位数据的?如果能,水平拆分落地就容易很多;如果不能,你要么额外建立映射关系,要么考虑中间件提供的“广播表 + 分片表”方案,但复杂度会直线上升。
2.3 中间件选型:ShardingSphere、MyCat,还是自研
先说结论:中小团队建议直接用 Apache ShardingSphere,大团队且有中间件研发能力才考虑自研。
| 方案 | 代理模式(如MyCat) | 客户端模式(如ShardingSphere-JDBC) |
|---|---|---|
| 部署方式 | 独立中间件服务 | 以Jar包方式嵌入应用 |
| 性能损耗 | 多一跳网络,偏高 | 低,SQL解析后直连数据库 |
| 跨语言支持 | 好,任何语言都能连 | 仅支持Java(ShardingSphere-Proxy可弥补,但需额外部署) |
| 功能丰富度 | 较弱 | 分片、读写分离、分布式事务、数据加密等 |
我自己用ShardingSphere-JDBC比较多,因为它在应用层直接路由,不用多一次网络跳转,TPS损耗比代理模式低不少。它的核心工作流程是:应用发出一条SQL -> 解析引擎把SQL拆解成AST -> 路由引擎根据分片键和分片策略计算出目标库表 -> 改写引擎把SQL改写成多个分片的SQL -> 执行引擎并行执行 -> 归并引擎对多个结果集做排序、聚合、分页。这个过程用户基本无感知,但你得知道它内部的逻辑,才能把分片策略配对。
如果你不想引入中间件,自研路由逻辑通常就是应用层根据分片键计算 tableIndex = hash(key) % tableCount,然后用动态SQL拼接表名。这个方案适合分片规则极简单、且没有复杂聚合查询的团队。但我个人不建议自己造轮子,因为分库分表的坑不在“路由”本身,而在分布式事务、SQL归并、平滑扩容这些细节上,中间件把这些都封装好了。
3. 水平扩展落地:分片键、分片算法与扩容实战
3.1 分片键选不好,后面全是坑
选分片键是整套分库分表方案里最关键的决策,没有之一。分片键决定了数据分布的均匀性、查询是否能够路由到单个分片、以及后续扩容的复杂度。
我只推荐一种做法:选业务中最核心的、单条查询必然携带的字段作为分片键。
以电商订单系统为例,最自然的分片键是 user_id。用户查询“我的订单列表”,SQL条件必然是 WHERE user_id = ?。按 user_id 取模路由后,只需要访问一个分片,性能极好。代价是——商家侧的查询“某个商家的所有订单”会变成跨分片查询,需要并发请求所有分片再在应用层归并。这时候你就得做一个从“商家ID到用户ID”的反查索引表,或者干脆把订单冗余一份按照 seller_id 分片的数据出去。
还有一点要注意:分片键尽量选整数类型。字符串类型的哈希计算虽然也能做,但取模运算的性能略差,而且在路由配置上不如整数直观。如果业务主键是UUID,建议单独加一个数值型的业务分片键字段。
3.2 三种主流分片算法对比与适用场景
我实际操作中用过的分片算法主要有三种,各有利弊:
-
取模法:
tableIndex = value % tableCount。优点是实现简单,数据分布均匀;缺点是扩容时需要重新计算数据位置,迁移量巨大。比如从16张表扩到32张表,几乎每一条数据都要移动,因为value % 16和value % 32的结果完全不同。 -
范围法:按月、按日期范围分表,比如
orders_202401、orders_202402。优点是扩容极其简单,新月份来了直接建新表,旧数据完全不用动;缺点是容易数据倾斜,比如大促月份的订单量暴增,那个月对应的表就会成为热点。 -
一致性哈希法:把哈希值空间组织成一个环,每个分片节点负责环上的一段区间。优点是增减节点时只需要迁移相邻节点上的数据,迁移量小;缺点是路由计算和虚拟节点的配置相对复杂。
实际项目里我见到的组合方案是:**热数据用取模法保证均匀,冷数据按月归档到独立历史库,减轻主分片的存储压力。**比如在线订单只保留最近3个月的,取模分布在32张表上,三个月前的订单异步迁移到归档库。这样既拿到了取模分片的读写均衡,又不会让历史数据无限堆积拖垮性能。
3.3 一次典型的扩容实操:从单库单表到8库16表
这里我给你一个我最近一次扩容的实际配置和步骤,场景是一个订单系统:数据量从3000万涨到8000万,单表查询已经明显变慢,决定从单库单表扩到8库16表(每个库2张分片表)。
第一步:确定分片规则。
我选用户ID作为分片键,库路由和表路由分别取模:
dbIndex = user_id % 8tableIndex = (user_id / 8) % 2
这里为什么要两层取模而不是直接 tableIndex = user_id % 16?因为如果直接对16取模,意味着任意一个物理库里的表编号是跨库的,比如 orders_3 可能在库0也可能在库4,这样中间件没有“库内聚”的局部性。而 user_id % 8 先确定库、再在库内确定表,后续要扩到16库32表时,迁移逻辑可以从“先迁移库、再迁移表”两个维度去做,灵活度更高。
第二步:配置ShardingSphere-JDBC的分片规则。
yaml复制rules:
- !SHARDING
tables:
orders:
actualDataNodes: ds-$->{0..7}.orders_$->{0..1}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: orders_db_table_algorithm
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
orders_db_table_algorithm:
type: COMPLEX_INLINE
algorithm-expression: ds_${user_id % 8}.orders_${(user_id / 8) % 2}
这里我用的算法是 COMPLEX_INLINE,它和 INLINE 的区别是:INLINE 只能对一个分片键做路由,COMPLEX_INLINE 其实也是针对于多个分片键的组合场景,而在我这个场景下用 user_id 一个键做两次计算也够用。特别提醒:分片策略的类型必须跟分片键的查询方式匹配。STANDARD 类型支持精确查询和范围查询,INLINE 只支持精确查询,如果业务里有 WHERE user_id BETWEEN ? AND ? 这种范围查询,就要用 STANDARD + 自定义算法。
第三步:历史数据迁移。
这是整个扩容里最耗时的环节。我的做法是:
- 增量同步阶段:给原表创建一个触发器和一张中转日志表,原表上的新增、修改、删除都记录到日志表中,由同步程序不停地把日志表里的变更同步到新的分片表。
- 全量迁移阶段:把原表的存量数据按分片规则拆分,分批
SELECT ... WHERE id BETWEEN ? AND ?读到应用层,再P2多线程写入目标分片表。 - 一致性校验阶段:对比原表和分片表的行数,以及关键业务字段的CRC校验和。
- 切换阶段:在低峰期做一个短暂只读维护,确认增量日志追平后,把应用连接切到分片后的数据源,关闭同步程序。
这套流程核心要义是“业务无感、增量不丢”。最容易出错的地方是增量日志表的数据类型设计,如果你只是在表上加了AFTER INSERT/UPDATE/DELETE触发器,那需要把变更前和变更后的完整行都记录进去,否则重放的时候会丢上下文。
4. 分库分表之后的头号难题:全局主键与跨分片查询
4.1 全局主键不能再用自增:雪花算法怎么落地
分库分表后,主键如果还用MySQL的自增ID,那16张表都会各自从1开始自增,必然冲突。全局主键方案有很多,但我在生产环境里最推荐的还是雪花算法(Snowflake)。
雪花算法生成的ID是一个64位的Long,结构是这样的:最高位1位(0,符号位) + 41位时间戳(毫秒级) + 10位机器ID + 12位序列号。在同一毫秒内,通过机器ID和自增序列号保证不重复;跨毫秒时时间戳字段自然递增,所以生成的ID在整体上是有序的。
为什么要强调ID有序?因为InnoDB的聚簇索引是按照主键顺序排列的,如果主键是无序的UUID字符串,每次INSERT都可能在B+树中间插入,引发页分裂,写入性能断崖式下跌。用雪花算法生成的ID,趋近于顺序追加,写入性能接近自增ID。
ShardingSphere里内置了雪花算法,只需要配置:
yaml复制- !SHARDING
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
但要注意,worker-id 在集群多节点部署时必须唯一,否则高并发下会生成重复ID。如果你的应用是多节点部署,我的建议是把worker-id做成配置中心动态下发,而不是写死在配置文件里。
4.2 跨分片查询的降级方案:聚合层+冗余表
跨分片查询是整个分库分表里“反人类”的地方。比如订单分表后,商家要“查询我店铺最近一个月的所有订单”,如果按 user_id 分片,这个SQL就得路由到全部分片去执行,然后在应用层做归并排序。
ShardingSphere的归并引擎能做这个事,但它是有限制的。**LIMIT深分页场景下,性能会非常难看。**比如请求第1000页的数据,每页20条,中间件需要从每个分片取 (1000+1)*20 = 20020 条,然后在内存里排序,再取第1000页的数据。分片越多,中间层需要处理的冗余数据量就越大,最终可能直接把应用内存打爆。
我的经验是做三层降级:
- 冗余字段化:业务查询高频需要的商家维度信息,在订单表里直接冗余
seller_id、goods_name等字段,避免查询时去关联其他表。 - 冗余表反查:维护一张
seller_order_index表,按seller_id分片,字段包含seller_id、order_id、user_id、create_time。商家查订单时先按seller_id查到对应的order_id列表,再根据user_id路由到真正的订单分片表取详情。 - 聚合查询平台:如果商家后台有复杂报表需求,那就不应该直接压到在线数据库上,而是通过 Canal 订阅binlog,把数据同步到ClickHouse或Elasticsearch去做分析查询。
很多人到了这一步才反应过来:**分库分表解决的只是OLTP在线事务场景,OLAP分析场景得靠另外一套架构。**这个认知越早建立,后面就越不会设计出四不像的方案。
4.3 事务问题:从本地事务到分布式事务的取舍
单库单表时代,一个事务里更新 orders 表再更新 inventory 表,全靠数据库本地事务搞定。分库分表后,如果这两张表在同一库同一分片上,操作不受影响;一旦分片键路由导致两张表落在不同库,本地事务立刻失效,你需要引入分布式事务。
我见过不少团队一上来就用Seata AT模式做分布式事务,结果发现性能损耗大得惊人,一个简单的事务提交要经过全局事务管理器多轮协调,QPS直接砍半。所以我的实际建议是按业务场景区分:
- 允许短暂不一致的场景:比如订单创建后通知积分模块加分,这类场景建议用本地消息表 + 异步消息队列。把“订单创建”和“消息记录”放在同一个本地事务里,然后靠一个后台任务把消息表里的记录可靠地投递到MQ,下游消费者拉取后执行加分操作。这样既保证了核心数据的强一致,又不会牺牲性能。
- 强一致要求极高的场景:比如转账,资金账户之间的操作。这种场景用Seata AT模式或TCC模式,哪怕性能损耗大也必须保证全局一致性,因为金额对不上出的是事故,不是性能问题。
经验是:**先审查业务流程,把“必须同生共死”的操作限制在一个分片内,其他操作降级为最终一致。**很多业务经过梳理后会发现,真正需要跨分片强一致的操作少之又少,完全可以靠业务层补偿机制解决。
5. 常见问题与排查技巧实录
5.1 分片键查询很快,非分片键查询全表扫描
这是分库分表后最经典的问题:WHERE order_id = ? 能秒返回,WHERE status = 'PAID' AND create_time > ? 却要扫描全部分片。ShardingSphere对不带分片键的SQL会执行“全路由”,即把SQL广播到所有分片表上去执行,再归并结果。
排查思路是这样:
- 先用
EXPLAIN看中间件路由后的真实SQL,确认它确实广播到了所有分片。 - 检查业务是否真的需要这种非分片键的查询。如果只是低频管理后台在用,建议加一个“查询转异步任务”的机制,不直接打到在线库。
- 如果高频业务真的需要非分片键查询,那就必须建辅助索引表或反查表。比如用户想通过
order_no(业务单号)查订单,就维护一张order_no -> user_id的映射关系表,这条查询变成两次精确定位,性能完全可控。
这里要特别说一句:**分库分表后的“查询能力”是提前设计出来的,不是靠中间件自动魔法。**你必须在设计阶段就想清楚所有查询路径,然后通过反查表、索引表、冗余表来支撑。
5.2 数据倾斜导致单个分片成为瓶颈
取模法理论上数据是均匀的,但业务的真实分布往往不均匀。比如头部用户贡献了80%的订单量,按 user_id % 8 分片后,某个库可能比其他库多出一倍的数据量和QPS。
处理数据倾斜,我的三板斧:
- 冷热分离:把热数据的时效性做限制,比如在线订单表只保留3个月,三个月以上的归档到历史库。这个方案对倾斜的缓解最有效,因为大部分头部用户的流量也在衰减。
- 热点账号打散:给个别超大流量用户ID增加一个“子分区”维度,比如
user_id + 一个盐值,让该用户的数据平均分布到多个子分片,同时维护一张映射表,查询时先查映射表找到所有子分片位置再聚合。 - 行数分析:定期用
SELECT count(*) FROM orders_xx对比各分片行数,如果发现明显倾斜,对新增数据的分片路由加一个动态调整值。
说实话,第2种方案实现复杂度很高,我一般只用在极端场景。日常项目里优先用冷热分离,性价比最高。
5.3 扩容时不停机的数据迁移怎么做
从8库16表扩到16库32表,取模法的老毛病就来了:user_id % 8 和 user_id % 16 的路由结果绝大多数不一致,每条数据几乎都要搬家。
参考我前面3.3的扩容流程,这里补充几个最容易踩的坑:
- 迁移程序千万别用单线程。我第一版就是单线程跑全量迁移,8000万数据跑了快40小时,业务根本没法等。后来改成按ID范围分片并发迁移,每个线程处理一个区间,速度提升到5小时左右。
- 全量迁移期间,增量日志表的消费务必加幂等。因为全量同步和增量同步之间存在时间交叉,同一条数据可能被全量迁移写了一次、又被增量重放写了一次。我用的方案是在写入分片表时以
order_id判断是否已存在,存在则执行UPDATE而不是INSERT。 - 校验逻辑要包含修改时间字段。只比行数不够,因为行数一致但内容可能不一致。我对比了每张分片表的
max(update_time)和关键字段的CRC32聚合值,确保增量追平。
5.4 分库分表后归并排序导致的内存溢出
这个问题非常隐蔽,发生在ShardingSphere执行跨分片排序的时候。假设你发的SQL是这样的:
sql复制SELECT * FROM orders
WHERE user_id IN (1001, 1002, 1003)
ORDER BY create_time DESC
LIMIT 0, 20;
中间件会在每个分片上执行:
sql复制SELECT * FROM orders_xx
WHERE user_id IN (...)
ORDER BY create_time DESC
LIMIT 0, 20;
然后把每个分片返回的20条数据在应用层归并排序,取最终的前20条。这个场景没问题。
问题出在深分页:
sql复制SELECT * FROM orders
WHERE user_id IN (1001, 1002, 1003)
ORDER BY create_time DESC
LIMIT 10000, 20;
每个分片要执行 LIMIT 10000, 20,返回10020条数据到归并层,如果分片有16个,归并层就要处理16万行。归并层为了排序,拿到的是每行的行数据对象,塞在内存里,跑几次这种查询,JVM堆直接溢出。
我的解法是:
- 业务上限制深翻页,超过100页的查询强制走“导出任务”或者改为“基于游标翻页”。
- 排序字段上建好索引,让每个分片内部先排好序,减少归并层的排序压力。
- 把
LIMIT offset, size改写成WHERE create_time < ?的游标形式,让每个分片只返回需要在当前页显示的少量数据,从根上消除归并层的大量数据冗余。
结尾小记:分库分表不是银弹,是匠心活
分库分表这套东西,做出来是一套方案,做进去是一堆细节。我经历过从一台MySQL扛住一切,到8库16表还要继续扩容的完整过程,最大的体会是:不要为了“用技术而用技术”,分库分表往往是业务发展到一定阶段的必然选择,但它的代价是让系统复杂性陡增。
如果你是刚接触这个方向,建议先找一台测试实例,把ShardingSphere-JDBC搭起来,用测试数据把分片、归并、扩容跑一遍。数据量可以小,但流程必须完整。只有亲手动过一遍,你才会理解为什么分片键要选用户ID而不是订单号、为什么扩容时要设计双写而不是停服迁移、为什么跨分片查询要建索引表而不是指望中间件帮你魔法处理。
最后再分享一个小技巧:**分库分表的方案文档和分片映射规则一定要纳入版本管理,并同步到负责运维的同事。**很多事故不是技术不行,而是团队里只有一个人知道路由规则是怎么配的,一旦这个人不在,出了故障连从哪儿查起都不知道。把规则、配置、迁移记录都沉淀成文档,比什么都重要。
