分库分表(一):拆分策略与核心难题
分库分表(一):拆分策略与核心难题
导语:分库分表是数据库面试的"分水岭题"——能背出"垂直水平"只是入门,能把分片键选择、路由算法、跨库 JOIN、跨片分页、全局 ID、分布式事务这六个核心难题讲清才算过关。本篇覆盖完整链路并补上原文缺失的重点。共 13 题。
一、为什么要拆、怎么拆
1. 为什么要分库分表?什么场景需要?
答: 当单库/单表触到瓶颈时,三类压力分别对应不同方案:
| 瓶颈 | 表现 | 对应方案 |
|---|---|---|
| 单表数据量过大 | 索引树变高、查询变慢、DDL 与备份困难 | 分表 |
| 单机磁盘/存储不足 | 备份迁移耗时、磁盘报警 | 分库 |
| 写并发过高 | CPU/IO 打满、响应抖动 | 分库 |
量化的临界参考值(面试时给出具体数字会加分):
| 指标 | 临界值 | 典型症状 |
|---|---|---|
| 单表数据量 | 500 万 ~ 2000 万行 | B+Tree 树高超过 3 层,查询明显变慢 |
| 单库总数据量 | 约 1TB | 备份/迁移耗时数小时 |
| 单库 QPS/TPS | 约 5000 | CPU 持续 80% 以上 |
| 磁盘 IOPS | 接近硬件上限 | 写延迟明显上升 |
关键前提:必须在"优化索引 + 加缓存 + 读写分离 + 归档历史数据"都用尽后才考虑分库分表(详见第二篇第 5 题)。因为它是成本最高、最不可逆的改造——一旦分了,跨库 JOIN、分布式事务、运维复杂度都会成为长期负担。
一句话:分表解决"单表数据量/查询慢",分库解决"存储容量/写并发"。
2. 分库分表有哪些拆分方式?
答: 两个维度、四种组合:
| 维度 | 方式 | 含义 | 解决的问题 |
|---|---|---|---|
| 垂直 | 垂直分库 | 按业务把不同表拆到不同库(用户库、订单库、商品库) | 单库表过多、业务耦合、便于独立扩容 |
| 垂直分表 | 把宽表按列拆成主表 + 扩展表(大字段、低频字段拆出去) | 单行过大、热点列与冷列的 IO 浪费 | |
| 水平 | 水平分库 | 同一张表按行分散到多个库 | 写并发、单机存储 |
| 水平分表 | 同一张表按行分散到多个表(同一库内) | 单表数据量 |
实践中的组合顺序:通常是「先垂直分库(业务解耦)→ 再水平分表(单表瘦身)→ 最后水平分库(写扩展)」。
垂直分表的一个注意点:拆出去的字段如果经常和主表一起查询,就会引入 JOIN 或多次查询——所以垂直分表适合拆"大字段(TEXT/BLOB)+ 低频访问字段"。
补充概念:垂直分库之后,那些"数据量小、变更少、所有业务都要用"的表(如字典表、地区表)会成为问题——它们在每个库都要用。解决方式是把它做成全局表(广播表),在每个分片都存一份(见第 8 题)。
3. 分库分表和分区表(PARTITION)有什么区别?
答: 这是高频辨析题,很多人把两者混为一谈:
| 维度 | 分区表(PARTITION) | 分库分表 |
|---|---|---|
| 本质 | 单库单表内部的逻辑划分,对 MySQL 而言仍是一张表 | 多个物理库/表 |
| 实现层面 | MySQL 原生支持(RANGE/LIST/HASH/KEY) | 需要中间件或应用层改造 |
| 对应用透明性 | 完全透明(SQL 基本不用改) | 需要改造(路由、跨片查询、事务) |
| 能否突破单机瓶颈 | 不能——仍共用同一实例的 CPU、IO、连接数 | 能——真正分散到多台机器 |
| 分区裁剪 | 支持(只扫描相关分区) | 中间件负责路由(只查相关分片) |
| 运维成本 | 低 | 高 |
| 适用场景 | 数据量中等;需要按时间快速清理历史数据 | 单机已到瓶颈(数据量/并发/磁盘) |
分区表的最大亮点:ALTER TABLE t DROP PARTITION p2025; 可以秒级删除整个分区的数据——比 DELETE 高效几个数量级,是"按时间归档"的经典手段。
分区表的主要局限:
- 仍是单表,无法分散写并发与存储;
- 唯一索引必须包含分区键,否则无法创建(这是最常见的踩坑点);
- 分区键改动需要重组分区,代价高。
结论:分区表是"单机内的优化",分库分表是"跨机的扩展"。数据量没到单机瓶颈时,分区表 + 归档能以极低成本解决问题。
二、分片策略(核心)
4. 分片键(Sharding Key)如何选择?
答: 分片键的选择几乎决定了分库分表方案的成败,因为它是不可逆的(改分片键意味着全量数据迁移)。
四条选择原则:
| 原则 | 说明 |
|---|---|
| 1. 离散度高 | 值分布均匀,避免热点。如 user_id、order_id |
| 2. 高频查询条件 | 让最核心的查询能定位到单个分片(避免跨片)。若 80% 查询都带 user_id,就按 user_id 分 |
| 3. 业务相关性 | 结合业务特征,如 B 端系统可"按商家分片",商家维度的报表就高效 |
| 4. 不频繁变更 | 分片键变更意味着数据要跨片搬迁 |
三类禁忌(面试常考):
| 禁忌 | 原因 |
|---|---|
| 单调递增字段(时间戳、自增 ID) | 所有新写入都集中在最后一个分片,形成写入热点,其他分片空闲 |
| 枚举值(状态、性别、地区) | 取值少且分布不均,导致数据倾斜(如 90% 订单都是"已完成") |
| 业务上不会用于查询的字段 | 分片后每次查询都要扫全部分片,得不偿失 |
进阶技巧:
- 多级分片:先按
user_id分库,再按order_id分表,兼顾两个维度的查询; - 基因法:让"副维度的键"内嵌"主分片键的基因",从而两个维度都能精准路由(见第 13 题);
- 绑定表(ER 分片):让强关联的两张表(订单 + 订单明细)用同一个分片键,从而 JOIN 变成同片操作。
灵魂拷问题:"你们为什么用
user_id而不是order_id做分片键?"——因为用户维度查询(我的订单列表)是高频操作,而order_id查询可以靠"订单号内嵌 user_id 基因"或"根据订单号反查 user_id"来补齐。
5. 常见的分片(路由)算法有哪些?
答:
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 范围分片 | 按 ID/时间区间分段(id < 1000 → 分片 0) | 范围查询高效、扩容简单(加一段) | 易热点(新数据集中在最后一段) |
| 取模(Hash)分片 | hash(key) % N | 分布最均匀 | 扩容困难——N 一变几乎全量数据要迁移 |
| 一致性哈希 | 哈希环 + 虚拟节点 | 扩容只迁移相邻的一部分数据 | 实现复杂;虚拟节点少时仍可能有倾斜 |
| 预分片(逻辑分片) | 一开始就切好足够多的逻辑分片(如 1024 个逻辑表),再把逻辑分片映射到少量物理库 | 扩容极简单(迁移整片,无需重算哈希) | 需初期规划;逻辑分片多则单表数据少 |
| 查表路由 | 维护一张 key → 分片 的映射表 | 最灵活,支持人工干预(大客户独立分片) | 多一次查询(需缓存);映射表本身是高可用点 |
| 基因法 / ID 内嵌 | 用 ID 的特定几位直接定位分片 | 无需路由表、无中心依赖 | 依赖 ID 生成规则 |
推荐方案:预分片 + 取模
初期:1024 个逻辑表 → 映射到 4 个物理库(每库 256 张逻辑表)
扩容:1024 个逻辑表 → 映射到 8 个物理库(每库 128 张逻辑表)
只需把 4 个库里的部分逻辑表整片搬到新库,路由规则(逻辑分片号)完全不变为什么取 2 的幂:取模结果天然均匀,且扩容时"翻倍"(4 → 8 → 16)最方便——可以按二进制高位判断,或用 hash & (N-1) 替代 %(与 Java HashMap 同理)。
一致性哈希的两个坑:① 虚拟节点数量要足够(通常 100~1000 个/物理节点)才能保证均衡;② 热点 key(如某个爆款商品)无论怎么哈希都会落在同一分片——哈希只解决"整体均匀",解决不了"单 key 热点"。
6. 什么是数据倾斜(热点问题)?如何解决?
答: 数据倾斜指各分片的数据量或请求量严重不均,导致部分分片成为瓶颈——分库分表"把压力分散"的初衷失效。
两类成因:
- 分片键分布不均:如按"地区"分片但业务集中在某几个省;按"商家"分片但存在超大商家;
- 超级热点 key:如秒杀商品被百万请求同时打到同一个分片。
解决方案:
| 方案 | 说明 | 代价 |
|---|---|---|
| 加盐(Salt)打散 | shardKey = userId + "_" + random(100),把热点分散到多个分片 | 查询时要聚合多个分片;且读写路径都要用同一套盐规则 |
| 大客户单独分片 | 用路由表把大客户/大商家指向独立分片 | 需维护路由表 |
| 多级缓存 | 本地缓存(Caffeine)+ Redis,把热点请求挡在 DB 之前 | 缓存一致性成本 |
| 请求合并 | 把短时间内对同一 key 的请求合并成一次DB 操作(如秒杀扣减) | 增加延迟,需权衡 |
| 热点探测 + 动态打散 | 监控发现热点后动态调整路由 | 实现复杂 |
| 读写分离 | 热点数据的读压力由从库分担 | 只解决读,不解决写 |
监控手段:定期统计各分片的数据量差异率与 QPS 差异率,超过均值 20% 就告警。
本质认知:哈希分片只能保证"键的分布均匀",不能保证"访问量均匀"。真实业务里访问量服从幂律分布(少数 key 占大部分请求),所以热点治理是长期持续的工作,不是一次性设计能解决的。
7. 分库分表后如何生成全局唯一 ID?
答: 单表的 AUTO_INCREMENT 在分片后失效(各分片自增会冲突),需要全局唯一 ID。主流方案对比:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Snowflake(雪花) | 64 位 = 1 符号位 + 41 位时间戳 + 10 位机器 ID + 12 位序列号 | 本地生成无网络开销、趋势递增、高性能(单机 400 万/秒) | 时钟回拨会导致 ID 重复或阻塞;机器 ID 需分配 |
| 号段模式(Leaf-Segment) | 向 DB 一次申请一段 ID(如 1000 个),在内存中自增分配 | DB 压力极小、高 QPS | 依赖 DB 可用性;ID 不连续(有跳跃) |
Redis INCR | 原子自增 | 简单 | 依赖 Redis 可用性;AOF 未落盘时重启可能丢号 |
| DB 多实例步长 | 各实例设不同起始值与相同步长(1,3,5 / 2,4,6) | 实现简单 | 扩容困难;依赖 DB |
| UUID | 随机生成 | 无协调、本地生成 | 完全无序——会导致聚簇索引频繁页分裂、索引体积大,不推荐做主键 |
| 美团 Leaf / 百度 UidGenerator | 号段 / 雪花改进版 | 生产验证过、有高可用方案 | 需额外部署组件 |
四个核心考量:
- 趋势递增(最重要):InnoDB 的聚簇索引要求主键有序,无序 ID 会引发页分裂(见《MySQL(一)》第 8 题);
- 高可用、无单点;
- 性能(本地生成 > 远程调用);
- 是否需要有序、是否可暴露(如对外单号不希望被猜到业务量)。
结论:优先雪花(趋势递增 + 本地生成)或号段模式;避免用 UUID 直接做 InnoDB 主键(如必须用,则存为 BINARY(16) 并用 UUID_TO_BIN(uuid, 1) 做时间有序化)。
时钟回拨怎么处理(雪花的高频追问):① 小幅回拨(毫秒级)→ 等待追平后再生成;② 大幅回拨 → 直接报错/拒绝服务并由监控告警;③ 用
Leaf的"号段 + 雪花双 buffer"方案兜底,或改用不依赖时钟的号段模式。
三、分片后的六大难题
8. 分库分表后如何做跨库 JOIN?
答: 分片后两张表可能不在同一个库,无法直接 JOIN。五种解决方案:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 字段冗余(最常用) | 把需要 JOIN 的字段冗余到主表(如订单表冗余 seller_name、product_name) | 关联表数据相对静态(名称、快照) |
| 全局表(广播表) | 数据量小、变更少的表(字典、地区、配置)在每个分片都存一份 | 字典/配置表 |
| 应用层组装 | 分别查各分片,在应用内存中拼装(一次查订单,再一次批量查用户) | 关联数据量小、可批量查 |
| 绑定表(ER 分片) | 让强关联的两张表用同一个分片键(订单 + 订单明细都按 order_id 分片),JOIN 天然落在同一分片 | 最优雅——订单与其明细、主表与扩展表 |
| 异构存储 | 把关联后的宽表写入 ES / 数仓,复杂查询交给它们 | 面向搜索、报表、大数据量检索 |
决策顺序:
- 优先"绑定表" —— 如果两张表本来就是同一业务实体(订单 + 明细),直接让它们同分片,JOIN 问题从根上消失;
- 其次是字段冗余 —— 用"空间 + 一致性成本"换查询性能(注意冗余字段的更新传播,通常用 MQ 异步更新,接受最终一致);
- 再次是全局表(仅适合小表);
- 最后才是应用层组装与异构存储。
重要:分片后外键约束无法使用(跨库外键不生效),唯一索引也只能保证分片内唯一——这两点必须在设计阶段就接受,改由应用层或"带分片键的唯一索引"来保证。
9. 分库分表后如何做跨库分页、排序与聚合?
答: 这是分片后最难的问题。以 LIMIT 100000, 10 为例:每个分片都必须返回 100010 行,中间件归并排序后再取 10 行——代价随 offset 线性增长,深分页会直接拖垮系统。
方案一:二次查询法(业务折中,能返回正确结果)
设分片数 n = 4,要查全局 LIMIT 100000, 10:
- 第一轮:每个分片查
LIMIT 25000, 10(即100000/4),得到 4 组数据,取其中最小的排序值min; - 第二轮:每个分片查
WHERE sort_col > min ORDER BY sort_col LIMIT 10; - 把第二轮结果归并排序,取前 10 条即为全局正确的第 100001~100010 条。
原理:全局第 100000 名之后的数据,其排序值必然不小于各分片第 25000 名中的最小值——所以以 min 为界再查一轮即可补齐。
方案二:游标分页(最实用)
-- 各分片都执行(lastSortCol 是上一页最后一条的排序值)
SELECT * FROM t WHERE sort_col > #{lastSortCol} ORDER BY sort_col LIMIT 10;
-- 中间件把 4 个分片各返回的 10 条归并,取全局前 10- 优点:每个分片只取 10 条,代价恒定;适合"无限滚动";
- 缺点:不能跳页。
方案三:限制最大页数 / 禁止深翻页
产品层面直接限制(如"最多翻到 100 页"),引导用户用筛选条件缩小范围。这是很多大厂的真实做法。
方案四:把排序字段作为分片键
让排序在单分片内完成(如按 create_time 分片后按时间排序就是同片排序)——但会牺牲其他维度的查询(其他条件都要跨片)。
方案五:异构存储
深分页、多条件排序、模糊检索这类需求交给 ES(用 search_after 做深分页),DB 只负责按主键取数据。
聚合(COUNT / SUM / GROUP BY)的处理:
| 聚合 | 处理方式 |
|---|---|
COUNT / SUM / MAX / MIN | 各分片分别计算,中间件二次聚合(求和 / 取最值) |
AVG | 不能直接平均!要各分片返回 SUM 与 COUNT,中间件算 总SUM / 总COUNT |
GROUP BY | 需要把所有分组的键值拉到中间件归并(跨片分组代价极高);若分组键就是分片键,则可下推到单分片 |
ORDER BY + LIMIT | 见上面的分页方案 |
DISTINCT | 各分片去重后再全局去重 |
设计建议:尽量让"分片键"同时出现在"高频查询条件"与"分组/排序字段"中,这样绝大多数聚合与排序都能下推到单个分片。这是分片键设计时要一并考虑的事。
10. 分库分表后分布式事务如何解决?
答: 单个业务操作跨多个分片时,本地事务失效,需要分布式事务方案:
| 方案 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|
| XA / 2PC | 强一致 | 差(同步阻塞、协调者单点、锁持有时间长) | 金融核心、跨库操作极少的场景 |
| TCC(Try-Confirm-Cancel) | 最终一致(业务补偿) | 好 | 高并发订单、资金(需业务改造,写三个方法) |
| Saga | 最终一致(长事务补偿) | 好 | 长流程业务(物流、旅游订单) |
| 本地消息表 / 事务消息 | 最终一致 | 好 | 大多数互联网业务(MQ 可靠投递) |
| Seata AT 模式 | 最终一致(自动生成反向 SQL) | 较好 | 快速落地(对业务代码侵入最小) |
核心建议:优先"避免分布式事务"而不是"解决它"
| 手段 | 说明 |
|---|---|
| 1. 让相关数据落同一分片 | 把订单、订单明细、支付记录都用 order_id 分片 → 本地事务即可(这是最推荐的做法) |
| 2. 字段冗余 + 异步补偿 | 用 MQ 异步更新冗余数据,接受最终一致 |
| 3. 拆分业务边界 | 把跨库操作拆成"多个单库操作 + 消息驱动",用本地消息表保证最终一致 |
面试话术:被问"分库分表后事务怎么办",先答"我们优先通过分片键设计避免跨片事务",再展开具体方案(Seata AT / 本地消息表)的选型依据。不要一上来就说 XA——它的性能代价在生产上几乎不可接受。
11. 分库分表会带来哪些代价(问题清单)?
答: 完整的"代价清单",面试时能列全说明你真做过:
| 类别 | 具体问题 |
|---|---|
| SQL 能力受限 | 跨库 JOIN 无法直接做;GROUP BY/ORDER BY/COUNT 需跨片归并;部分函数与子查询受限 |
| 分页困难 | 深分页代价高(见第 9 题) |
| 事务 | 跨片写需要分布式事务 |
| 主键 | 自增主键失效,需要全局唯一 ID |
| 约束弱化 | 外键无法使用;唯一索引只能在分片内唯一(跨片唯一需应用层保证) |
| 分片键不可逆 | 分片键选错要全量迁移;分片键更新意味着数据跨片搬迁 |
| 扩容复杂 | 分片数变化涉及数据迁移与双写 |
| 运维成本 | 实例数成倍增加;DDL/备份/监控/慢日志都要在多实例上批量执行 |
| 数据一致性 | 多实例间的对账与校验成为常态化工作 |
| 开发规范 | 团队要遵守新的 SQL 规范(禁止跨片 JOIN、必须带分片键等),有学习与改造成本 |
经验法则:分库分表把"数据库的问题"变成了"应用与运维的问题"。收益是容量与并发,代价是复杂度——所以它必须是最后一张牌。
12. 分片数量如何确定?
答: 有可套用的估算方法:
公式:
分片数 = 预估峰值数据量 / 单表容量上限,再预留 50% 余量,向上取 2 的幂示例:预估 3 亿行,单表上限 2000 万行 → 3亿 / 2000万 = 15 → 预留 50% 后取 16(2 的幂)。
三点设计建议:
| 建议 | 原因 |
|---|---|
| 取 2 的幂(8/16/32/64) | 取模分布更均匀;扩容时"翻倍"即可(hash & (N-1) 的位运算优化);一致性哈希/预分片也更友好 |
| 单表控制在 500 万~2000 万行 | 经验值,超过后 B+Tree 树高与维护成本明显上升;具体取决于行宽与查询模式(行宽大则更早到达瓶颈) |
| 不要分得太细 | 分片过多会:提升跨片查询概率、连接数膨胀(每个分片都要连接)、DDL/备份的批量操作成本剧增、单表数据过少反而浪费 |
"一开始分多少"的经典权衡:
- 分太少(如 4 个)→ 后续扩容代价大(要迁移大量数据);
- 分太多(如 1024 个物理表)→ 一开始就有跨片问题,且每表数据量少、浪费;
最佳实践:预分片。逻辑上先切成足够多的分片(如 1024 个逻辑分片,一层映射表),但物理上只部署少量库;扩容时只搬逻辑分片,路由规则不变。这样既避免了初期过度分片,又让扩容变成纯粹的"搬运"。
13. 什么是"基因法"?如何保证同一用户的数据落在同一分片?
答: 基因法要解决的核心矛盾是:分片键只能是一个维度,但业务需要多个维度都能精准路由。
问题场景:订单表按 user_id 分片(为了"我的订单列表"高效),但如果要按 order_id 查单条订单,就无法定位分片,只能扫全部分片。
基因法的做法:让 order_id 内嵌 user_id 的"基因"。
分片数 = 16(需要 4 个二进制位)
1. 生成 order_id 时,计算 user_id 的哈希值,取其低 4 位作为"基因"
2. 把基因写入 order_id 的低 4 位(雪花 ID 的序列号部分可以承载)
3. 路由时:
- 用 user_id 哈希 → 取低 4 位 → 得到分片号
- 用 order_id 的低 4 位 → 得到**同一个分片号**效果:两个维度都能精准定位分片,无需反查、无需广播。
基因法的要点与限制:
| 要点 | 说明 |
|---|---|
| 分片数必须是 2 的幂 | 基因占的位数 = log2(分片数),位运算才能直接映射 |
| 需要 ID 生成器支持"自定义低位" | 雪花 ID 的序列号部分可承载基因;纯 UUID 不适合 |
| 扩容会破坏基因位数 | 分片数从 16 扩到 32 时,基因位数变化 → 需要重新生成 ID 规则并处理历史数据(所以更要预分片,一次性把位数定好) |
| 基因位不宜过多 | 会挤占 ID 的可用位数(如雪花只剩 4 位序列号,单毫秒只能生成 16 个 ID,成为瓶颈) |
同类思路的其它应用:
- 订单号内嵌 user_id → 支持"按订单号查"和"按用户查订单"两个维度;
- 绑定表同分片:订单与订单明细都用
order_id分片 → JOIN 变同片操作; - 冗余分片键:在子表的记录里冗余主表的分片键,便于直接路由。
