分库分表:分片键、扩容与跨片查询 — editorial cover photo

分库分表:分片键、扩容与跨片查询

单表千万行后的破局之道,但分片键选错、扩容困难会让人后悔莫及。

2025年7月5日·返回文章列表
文章大纲

MySQL 单表的数据量是有物理极限的。经验值大概是单表过千万行、单库过百 GB 之后,索引深度增加、写入页分裂频繁、DDL 锁表风险升高,性能开始肉眼可见地往下掉。这时候把索引优化、读写分离、冷数据归档这些手段都用尽之后,剩下的路就只有水平拆分,把一张大表按某种规则拆成多张小表,甚至多个库。

分库分表是一条单行道。一旦分了,跨库 JOIN 没了、跨库事务没了、全局聚合变难了,想退回去几乎不可能。所以这个决策必须慎重,能不分就不分,穷尽了垂直优化再分。我自己见过一个团队,单表才几百万行就急着分库分表,结果分片键没选好,所有查询都退化成全分片广播,性能还不如没分之前。分库分表不是银弹,是把一类问题换成另一类问题的交易。

先垂直再水平

水平拆分之前先做垂直拆分。垂直拆分按字段或业务边界拆,把一个大而全的库拆成订单库、用户库、商品库,每个库职责单一,业务耦合度低。垂直拆分相对容易,不涉及数据路由的复杂性,主要是业务边界梳理。垂直拆分做完,如果某个业务库里的单张表还是太大,再考虑水平拆分这张表。

水平拆分按行拆,把一张表的数据按某个字段的值分散到多张物理表甚至多个物理库里。复杂度从这里开始陡增,因为查询不再能直接定位数据在哪。

分片键,最重要的决策

PlantUML
正在加载图表…
PlantUML diagram

图:带分片键的查询直达单分片飞快,不带分片键的查询退化成 64 分片全广播——选分片键就是选 80% 高频查询条件字段

水平拆分用什么字段做分片键,决定了后续所有查询的命运。这是整个分库分表设计里最关键的一步,选错了几乎无法回头。

假设订单表按 userId 做分片键,hash(userId) 对 64 取模决定数据落在哪个分片。那么所有带 userId 条件的查询,"查某个用户的所有订单",都能直接定位到单个分片,高效。但反过来,"查某个 orderId 的订单详情",orderId 里没有分片信息,系统不知道这条数据在哪个分片,只能把查询广播到所有 64 个分片,等所有分片返回后合并结果。64 个分片的广播查询,延迟和资源开销是单分片查询的几十倍。

这就是分片键的核心约束,带分片键的查询命中单分片飞快,不带分片键的查询退化成全分片扫描。选分片键的方法是分析查询模式,找出 80% 以上的高频查询都以哪个字段为条件,就用它。对订单表,绝大多数查询都带 userId,所以 userId 是合理的分片键。

几种分片策略

定了分片键,还要决定怎么把分片键的值映射到物理分片。常见的有四种。

哈希分片,hash(userId) % 64,数据分布均匀,没有热点,是最常用的策略。代价是扩容时取模数变了,几乎所有数据都要重新分布。

范围分片,按 ID 或时间区间切分,0 到 10000 在表 1,10001 到 20000 在表 2。实现简单,扩容只需加新区间不用动老数据。致命弱点是热点,新数据永远集中在最新的一张表,那张表压力远超其他。

一致性哈希,节点增删时只影响相邻区段的数据,扩容迁移量小。细节单独成篇讲,这里只要知道它解决了哈希分片扩容迁移量大的问题。

基因法,专门解决双维度查询,下面单独讲。

基因法,双维度查询的解法

PlantUML
正在加载图表…
PlantUML diagram

图:基因法在 orderId 末位嵌入 userId 的低位基因,两个查询维度对分片数取模落到同一分片——电商订单表经典解法

订单场景有个典型矛盾。订单既要按 userId 查("我的订单"列表),又要按 orderId 查(订单详情页)。如果只按 userId 分片,orderId 查询就得广播。如果只按 orderId 分片,userId 聚合查询又得广播。无论选哪个,另一个维度的查询都受害。

基因法的思路是在 orderId 里嵌入 userId 的分片信息。具体做法是取 userId 的低若干位作为"基因",比如低 4 位,生成 orderId 时把这个基因拼进去。这样 orderId 的结构变成时间戳加机器位加序列号加 userId 基因。查询订单详情时,即使只给 orderId,也能从 orderId 末尾提取出那几位基因,对分片数取模,直接算出数据在哪个分片,和按 userId 查询定位到的是同一个分片。

userId = 10086, 取低4位基因 = 6
orderId 生成时把基因 6 编码进末位
按 orderId 查询时:
gene = orderId & 0xF // 提取基因 = 6
shard = gene % 64 // 和 hash(userId)%64 落在同一分片

这是双维度查询的经典解法,电商订单表几乎都在用。代价是 orderId 要遵循特定的生成规则,灵活性降低,基因位数也要和分片数匹配。

跨片查询怎么办

无论分片键选得多好,总有一些查询不带分片键,比如"按商家查订单"、"按商品查销量"。这些查询怎么处理,分两种情况。

高频的非分片键查询,靠异构索引。用 Canal 监听主库 binlog,把数据按新的查询维度同步到 Elasticsearch 或单独的反范式表里,查询走异构索引而不是扫原表。这是生产里最常见的做法,相当于用空间和一致性窗口换查询性能。我在风控项目里做过类似的事,TB 级发票数据用 ES 做辅助索引加 HBase 大宽表存储,按发票号、企业号等多个维度查询都走 ES,原库只承担写入。低频的非分片键查询,直接广播全分片合并结果,接受慢,不值得为偶发查询建一整套异构索引。

扩容,最痛的环节

PlantUML
正在加载图表…
PlantUML diagram

图:成倍扩容让每个分片一裂为二,双写灰度保证随时可回滚——分片数要按两到三年后数据量预留

数据量继续涨,分片不够用了要加。这一步是最痛苦的,尤其是哈希分片。从 64 个分片扩到 128 个,hash 取模从 % 64 变成 % 128,理论上每个 key 的归属都要重新计算,大约八成的数据需要迁移。一次性迁移这么多数据,风险极高,业务停不得。

生产里成熟的做法是成倍扩容加双写灰度。64 扩到 128,每个原有分片裂变成两个,因为 key % 64 的结果加上最高位区分就能得到 key % 128 的归属,迁移量从八成降到五成。然后双写新旧两套分片,全量同步历史数据,增量用 binlog 实时同步。读流量先灰度切一部分到新分片验证,没问题再逐步全切,最后下线老分片。整个过程可能持续几周,中间任何一环出问题都要能回滚。

策略扩容影响热点风险适用
哈希分片大,N→N+1 多数数据迁移通用
范围分片小,只加新区间高,集中在最新表时序数据
一致性哈希小,只影响相邻区段节点动态增删

扩容这么痛苦,所以一开始就要按两到三年后的数据量来规划分片数。宁可初始多分几个片留足余量,也不要过两年频繁扩容。中途扩容的工程复杂度和风险,远高于多分几个片带来的初期管理成本。

分库分表后的连锁反应

分了之后,原来单库能做的事很多都受限。跨库事务没了,要上前面讲过的分布式事务方案。跨库 JOIN 几乎不可能,要么靠业务层把数据分别查出来再拼,要么靠反范式把关联字段冗余进同一张表。聚合查询 COUNT、SUM 要把各分片的结果汇总,复杂且慢,全局分页更是噩梦,第二页的数据分散在各分片,要先把各分片的候选都拉出来再全局排序。

全局 ID 也要重新考虑,分片后 MySQL 自增主键失效,每个分片各自自增会撞,得用雪花算法或号段模式生成全局唯一 ID。

中间件选型上,Java 生态首选 ShardingSphere-JDBC,它是客户端分片,没有额外的代理层,性能损耗小。多语言环境用 ShardingSphere-Proxy,代理分片,对业务透明但多一跳网络。MyCat 是老牌方案,但社区活跃度下降,新项目不建议。

数据迁移是上线的最后一关。全量同步历史数据到新分片,增量用 Canal 监听 binlog 实时补齐,双写灰度切换,这套流程前面扩容已经讲过,分库分表上线就是一次大规模扩容。

分片键选错是灾难性的

分片键一旦定下来铺数据,就没有后悔药。我见过一个案例,订单表按时间分片,结果每天的订单全集中在一到两张表,那几张表磁盘和 CPU 天天报警,其他表空着吃灰。分片键的选择必须基于真实的查询模式分析,拿最近一个月的慢查询日志做样本,统计 top 字段,别拍脑袋。

分库分表是最后手段

把索引、读写分离、分区、缓存、冷数据归档全用尽之后再考虑分库分表。它是单行道,分之前必须想透三件事,分片键选什么、未来怎么扩容、跨片查询怎么办。这三个问题答不好就动刀,后患无穷。仓促分库分表换来的不是性能提升,而是更难维护的系统和躲不掉的扩容债。

Continue Reading

关联文档推荐

查看全部
容量评估:从 QPS 推导到资源规划的全流程 — editorial cover photo读写分离与主从延迟:DB 性能优化的基石 — vibrant cover for technical blog article架构演进:从单体到微服务的渐进之路 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。