MySQL 分库分表
分库分表不是“SQL 慢了就上”的万能药。它是当单库、单表、单机已经接近容量、写入、连接、IO 或运维边界时,用更复杂的架构换更大容量和吞吐的治理手段。
先记住一句话:
分库分表的本质是:把原来集中在一个库或一张表里的数据,按规则拆到多个物理库或物理表里;收益是容量和吞吐提升,代价是查询、事务、分页、唯一约束、扩容和运维都会变复杂。
学习目标
学完本章你要能回答:
- 为什么分库分表不是普通慢 SQL 的第一选择。
- 垂直拆分、水平拆分、分库、分表分别解决什么问题。
- 分片键为什么决定后续查询和扩容成本。
- 按用户、按订单、按时间、按租户分片分别有什么取舍。
- 分库分表后为什么 Join、事务、唯一约束、分页排序会变难。
- 全局 ID、路由、广播表、绑定表、读写分离、数据迁移怎么设计。
- 商业订单系统和医疗数据采集平台应该如何落地。
- 面试中如何把“为什么这样做,不这样会怎样”讲清楚。
什么时候才考虑分库分表
很多慢查询并不需要分库分表。先判断问题属于哪一类。
| 问题 | 优先方案 | 为什么不是先分库分表 |
|---|---|---|
| SQL 没有合适索引 | 先设计联合索引、改 SQL | 分表后每个分片仍然会慢 |
| 深分页慢 | 改游标分页、缩小查询范围 | 分片后还要跨分片归并,可能更慢 |
| 历史数据太多 | 冷热归档、分区、历史库 | 老数据很少访问,没必要增加全链路复杂度 |
| 读多写少 | 加缓存、读写分离、优化报表 | 分库会让读路径和一致性更复杂 |
| 单表写入极高 | 评估水平分表 | 写入热点可能需要拆散 |
| 单库连接、CPU、IO 到瓶颈 | 评估分库 | 单实例资源不够,需要横向扩展 |
| 单机容量不够 | 分库分表、归档或分布式数据库 | 需要明确长期容量模型 |
判断顺序:
flowchart TD
A["发现 MySQL 压力大"] --> B{"是单条 SQL 慢吗"}
B -- "是" --> C["先看 EXPLAIN、索引、扫描行数"]
B -- "否" --> D{"是历史数据太多吗"}
D -- "是" --> E["先做归档、分区、冷热分离"]
D -- "否" --> F{"单库资源是否到瓶颈"}
F -- "否" --> G["继续优化 SQL、缓存、读写分离"]
F -- "是" --> H{"是否有明确分片键和迁移方案"}
H -- "否" --> I["先补齐分片键、ID、路由、迁移设计"]
H -- "是" --> J["评估分库分表"]如果没有明确分片键就分库分表,后续大概率会出现:高频查询跨分片、分页无法承受、事务补偿复杂、扩容要重做路由。
分库、分表、垂直拆分、水平拆分
这几个词经常混在一起,先分清。
flowchart TD
A["拆分方式"] --> B["垂直拆分"]
A --> C["水平拆分"]
B --> D["按业务模块拆库"]
B --> E["按字段冷热拆表"]
C --> F["按行拆到多表"]
C --> G["按行拆到多库"]| 类型 | 拆什么 | 例子 | 解决什么 |
|---|---|---|---|
| 垂直分库 | 按业务域拆库 | 用户库、订单库、支付库 | 降低模块耦合,减少单库对象和连接压力 |
| 垂直分表 | 按字段冷热拆表 | user 与 user_profile | 减少宽表,降低高频查询读页成本 |
| 水平分表 | 同库多表按行拆 | orders_00 到 orders_15 | 降低单表数据量、索引高度和写热点 |
| 水平分库 | 多库按行拆 | order_db_00.orders_00 | 分散连接、CPU、IO 和存储压力 |
垂直拆分
业务域拆分:
user_db 用户、登录、地址
order_db 订单、订单明细
pay_db 支付单、支付流水
inventory_db 库存、库存流水字段冷热拆分:
product
- id
- sku_no
- title
- status
- price
- updated_at
product_detail
- product_id
- description
- rich_text
- images
- long_attributes为什么要垂直分表?因为宽字段会让每页能放的行变少。高频列表页只需要标题、价格、状态,却被大文本、图片 JSON、扩展字段拖累,Buffer Pool 会被低频大字段占用。
水平拆分
水平拆分是把同一种数据按行分散:
order_db_00.orders_00
order_db_00.orders_01
order_db_01.orders_00
order_db_01.orders_01适合数据持续增长、写入高、单表索引和单实例资源都接近瓶颈的场景。
分片键是最核心的设计
分片键决定数据落在哪个分片,也决定查询能不能精准路由。
订单表常见分片键:
| 分片键 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
user_id | 用户订单列表单分片查询 | 按 order_no 查需要路由映射 | C 端用户订单列表高频 |
order_id | 写入较均匀,订单号直查方便 | 用户订单列表可能跨分片 | 内部系统按订单号处理更多 |
tenant_id | 租户隔离清晰 | 大租户可能成为热点 | SaaS 多租户系统 |
created_at | 归档和冷热分离方便 | 当前时间段热点明显 | 日志、流水、历史归档 |
hospital_id | 医疗机构数据隔离自然 | 大医院热点,跨院统计复杂 | 医疗平台按机构运营 |
分片键选择原则:
- 高频查询必须尽量带分片键。
- 分片键的取值要尽量分散,避免热点。
- 分片键不能频繁变化。
- 分片键要能支撑扩容迁移。
- 强事务边界最好落在同一分片内。
反例:
订单按 created_at 分表,但用户订单列表按 user_id 查询。这样每次查用户订单都要扫多个时间分片,再归并排序。短期看能跑,长期数据增长后会很难维护。
常见分片算法
取模分片
public final class OrderSharding {
private static final int DB_COUNT = 4;
private static final int TABLE_COUNT_PER_DB = 16;
public static Shard routeByUserId(long userId) {
int totalTableCount = DB_COUNT * TABLE_COUNT_PER_DB;
long shard = Math.floorMod(userId, totalTableCount);
int dbIndex = (int) (shard / TABLE_COUNT_PER_DB);
int tableIndex = (int) (shard % TABLE_COUNT_PER_DB);
return new Shard("order_db_" + String.format("%02d", dbIndex),
"orders_" + String.format("%02d", tableIndex));
}
public record Shard(String database, String table) {
}
}优点:
- 简单直观。
- 数据通常比较均匀。
- 路由计算快。
缺点:
- 从 64 片扩到 128 片时,大量数据需要迁移。
- 老路由和新路由不兼容。
- 扩容期间要处理双写、回查和校验。
一致性哈希
一致性哈希常用于节点扩缩容时降低迁移量,但在数据库分片中也不是银弹。原因是数据库分片不仅要路由,还要考虑范围查询、事务、迁移校验、连接池、DDL 和运维。
适合:节点扩容频繁、Key 访问比较独立的场景。
不适合:强依赖范围查询、跨分片统计、严格事务的核心订单表。
范围分片
按月份分表:
collect_result_202601
collect_result_202602
collect_result_202603适合:
- 日志、流水、采集结果。
- 数据天然按时间归档。
- 查询大多带时间范围。
问题:
- 当前月写入会集中,形成热点。
- 跨月查询要查多张表。
- 修改时间范围查询很容易遗漏分片。
字典映射分片
用路由表记录业务 ID 到分片的映射:
create table order_route (
order_no varchar(64) primary key,
user_id bigint not null,
db_index int not null,
table_index int not null,
created_at datetime not null,
unique key uk_user_order (user_id, order_no)
) engine = InnoDB default charset = utf8mb4;适合:
- 查询入口既有
user_id,又有order_no。 - 订单号本身无法直接计算分片。
- 需要兼容历史路由和迁移路由。
代价:
- 多一次路由表查询。
- 路由表也要高可用和缓存。
- 创建订单时要保证路由记录和订单记录一致。
分库分表后的 SQL 怎么执行
分库分表前:
select *
from orders
where user_id = 1001
order by created_at desc
limit 20;如果按 user_id 分片,且 SQL 带 user_id,可以精准路由:
flowchart TD
A["SQL 带 user_id"] --> B["路由算法计算分片"]
B --> C["order_db_02.orders_09"]
C --> D["单分片执行 SQL"]
D --> E["返回结果"]如果 SQL 不带分片键:
select *
from orders
where status = 'PAID'
order by created_at desc
limit 20;就可能变成广播查询:
flowchart TD
A["SQL 不带分片键"] --> B["发送到所有分片"]
B --> C["每个分片各查 TopN"]
C --> D["应用或中间件归并排序"]
D --> E["取全局 Top20"]广播查询的问题:
- 分片越多,查询放大越严重。
- 每个分片都消耗连接和 CPU。
- 全局排序分页需要在应用层归并。
- 深分页会非常昂贵。
因此分库分表后,接口设计要尽量要求带分片键。例如用户订单列表必须带 user_id,租户资产查询必须带 tenant_id 或 hospital_id。
全局唯一 ID
单库单表可以用自增主键。分库分表后,如果每张表都从 1 自增,会出现全局重复。
常见方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 简单,不依赖中心服务 | 太长、无序,索引和存储成本高 |
| 数据库号段 | 趋势递增,性能好 | 需要号段服务高可用 |
| 雪花算法 | 本地生成,趋势递增 | 依赖时钟,机器号管理复杂 |
| Redis 自增 | 简单 | Redis 可用性和持久化要考虑 |
| 业务编码 + 序列 | 可读性好 | 规则复杂,可能泄露业务信息 |
雪花 ID 简化结构:
flowchart TD
A["64 位 ID"] --> B["时间戳"]
A --> C["机房/机器号"]
A --> D["序列号"]Java 简化 Demo:
public class SnowflakeId {
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeId(long workerId) {
if (workerId < 0 || workerId > 1023) {
throw new IllegalArgumentException("workerId must be 0..1023");
}
this.workerId = workerId;
}
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastTimestamp) {
throw new IllegalStateException("clock moved backwards");
}
if (now == lastTimestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
while (now <= lastTimestamp) {
now = System.currentTimeMillis();
}
}
} else {
sequence = 0;
}
lastTimestamp = now;
return ((now - 1700000000000L) << 22) | (workerId << 12) | sequence;
}
}生产上要补齐:
- workerId 分配和冲突检测。
- 时钟回拨处理。
- ID 服务监控。
- 压测单毫秒序列耗尽行为。
跨分片 Join 怎么处理
分库分表前:
select o.order_no, u.username
from orders o
join users u on u.id = o.user_id
where o.user_id = 1001;分库分表后,如果 orders 和 users 不在同一个分片,数据库层 Join 就很难直接做。
常见处理方式:
| 方式 | 说明 | 适合 |
|---|---|---|
| 冗余字段 | 订单表冗余用户名、手机号摘要 | 读多写少、字段变更不频繁 |
| 绑定表 | 同一分片键的表放同一分片 | 订单和订单明细 |
| 应用层 Join | 先查订单,再批量查用户 | 结果集小 |
| 数据同步到搜索视图 | 同步到 ES 或宽表 | 复杂检索和列表 |
| 禁止跨分片强 Join | 接口设计要求带分片键 | 核心交易链路 |
订单和订单明细适合绑定表:
orders 按 order_id 分片
order_items 也按 order_id 分片这样一个订单和它的明细在同一分片,查询订单详情、修改订单明细可以落在同一个库表里。
跨分片事务怎么处理
单库事务:
更新订单 + 写订单流水 + 写支付状态如果这些表都在一个库里,数据库本地事务能保证原子性。
分库后,如果订单在 order_db,支付在 pay_db,就变成分布式事务问题。
常见方案:
| 方案 | 适合 | 代价 |
|---|---|---|
| 避免跨库事务 | 通过同分片、业务拆分减少跨库写 | 需要建模提前设计 |
| 最终一致 | 本地事务 + 消息/补偿 | 要有重试、幂等、对账 |
| TCC | 强业务一致且可拆 Try/Confirm/Cancel | 代码复杂 |
| Saga | 长流程、多步骤业务 | 补偿逻辑复杂 |
| Seata AT | 常见 Java 微服务场景 | 对 SQL、性能、运维有要求 |
| XA/2PC | 强一致要求 | 阻塞、性能和可用性成本高 |
核心原则:
分库分表设计时,要尽量让一个事务内必须强一致的数据落在同一个分片。跨分片强事务越多,系统越难稳定。
更多看:分布式事务。
分页排序为什么变难
单表分页:
select *
from orders
where status = 'PAID'
order by created_at desc
limit 20 offset 1000;多分片全局分页:
flowchart TD
A["查询所有分片"] --> B["每个分片查局部结果"]
B --> C["应用层归并排序"]
C --> D["跳过 offset"]
D --> E["取 limit"]问题:
- 每个分片都要查更多数据。
- 全局 offset 越大,归并成本越高。
- 结果可能因为数据变化出现重复或漏数。
- 中间件和应用内存压力增加。
优化方向:
| 需求 | 方案 |
|---|---|
| 用户自己的订单 | 按 user_id 精准路由,单分片分页 |
| 后台全局订单搜索 | 同步到 ES 或搜索宽表 |
| 时间流水翻页 | Seek 分页,带上上一页最后时间和 ID |
| 报表统计 | 离线数仓或预聚合 |
Seek 分页示例:
select order_no, user_id, status, created_at
from orders_09
where user_id = 1001
and (created_at < '2026-07-06 10:00:00'
or (created_at = '2026-07-06 10:00:00' and id < 90001))
order by created_at desc, id desc
limit 20;广播表、绑定表和影子表
广播表
每个分片都复制一份的小表,例如字典、地区、状态码。
order_db_00.dict_status
order_db_01.dict_status
order_db_02.dict_status适合:
- 数据量小。
- 更新少。
- 很多业务 SQL 需要 Join。
风险:
- 多分片数据要保持一致。
- 更新要广播到所有库。
- 更新失败要补偿。
绑定表
使用同一个分片键的表。例如订单主表和订单明细都按 order_id 分片:
orders_03
order_items_03好处是同一个订单相关数据落在同一分片,Join 和事务成本低。
影子表
用于迁移、压测、灰度:
orders 线上表
orders_new 新分片规则或新结构表迁移期间可以双写老表和新表,逐步校验一致性,再切读流量。
中间件和实现方式
常见方式:
| 方式 | 说明 | 适合 |
|---|---|---|
| 应用代码封装路由 | 自己计算库表名 | 简单场景、强控制 |
| ShardingSphere-JDBC | Java 应用内分片中间件 | Java 项目常见 |
| ShardingSphere-Proxy | 独立代理,应用像连 MySQL | 多语言、集中治理 |
| MyCAT 等代理 | 代理层分库分表 | 老项目或特定生态 |
| 云厂商分布式数据库 | 云服务托管 | 团队不想自建复杂中间件 |
ShardingSphere 不只是配置库表取模,它会负责 SQL 解析、路由、改写、执行和归并,还涉及绑定表、广播表、读写分离、事务、分布式 ID、迁移与 Proxy 运维。完整内容见 Apache ShardingSphere 原理与实战。
自己拼接表名要非常谨慎:
String tableName = route.tableName();
String sql = "select id, order_no from " + tableName + " where user_id = ?";表名不能用 ? 参数化,所以必须保证 tableName 只能来自白名单路由结果,不能来自用户输入。
商业场景一:订单系统按用户分片
业务特点:
- 用户查自己的订单高频。
- 订单详情按订单号查询也高频。
- 支付回调按支付单或订单号处理。
- 后台全局查询和报表相对低频。
设计:
orders 按 user_id 分片
order_items 按 user_id 或 order_id 绑定分片
order_route 保存 order_no -> user_id -> 分片
后台搜索同步 ES建表示例:
create table orders_00 (
id bigint primary key,
order_no varchar(64) not null,
user_id bigint not null,
status varchar(20) not null,
total_amount decimal(12, 2) not null,
created_at datetime not null,
updated_at datetime not null,
unique key uk_order_no (order_no),
key idx_user_status_created (user_id, status, created_at)
) engine = InnoDB default charset = utf8mb4;创建订单流程:
flowchart TD
A["生成全局订单 ID"] --> B["根据 user_id 计算分片"]
B --> C["写 orders_xx"]
C --> D["写 order_route"]
D --> E["提交本地事务"]
E --> F["发订单创建事件"]
F --> G["同步搜索视图或报表"]按订单号查询:
flowchart TD
A["输入 order_no"] --> B["查 order_route"]
B --> C["得到 user_id 和分片"]
C --> D["路由到具体 orders_xx"]
D --> E["查询订单详情"]如果没有 order_route,按 order_no 查可能要广播所有分片。
商业场景二:医疗采集结果按医院和时间治理
医疗采集平台常见特点:
- 采集结果写入量大。
- 查询通常带医院、任务、时间范围。
- 历史结果用于追溯,但在线高频看最近状态。
- 复杂检索可能同步到 ES。
一种设计:
asset_current_status 当前状态表,不分或按 hospital_id 分
collect_result_202607 按月分表保存采集结果
collect_error_202607 按月分表保存异常明细
collect_result_archive 历史归档库为什么不直接所有表按医院取模?
- 采集结果天然按时间增长,历史归档很重要。
- 大医院可能成为写入热点。
- 查询“最近失败”通常带时间范围。
- 当前状态和历史流水的访问模式不同,应该分开设计。
当前状态查询:
select asset_no, collect_status, last_collect_time, last_error_type
from asset_current_status
where hospital_id = 1001
and collect_status = 'FAILED'
order by last_collect_time desc
limit 20;历史结果查询:
select task_id, asset_no, collect_time, result_status
from collect_result_202607
where hospital_id = 1001
and collect_time >= '2026-07-01'
and collect_time < '2026-07-07'
order by collect_time desc
limit 100;这里体现一个原则:不是所有表都用同一种分片规则,应该根据表的访问模式设计。
扩容迁移怎么做
分库分表最怕“上线后才发现分片数不够”。扩容通常比首次拆分更难。
扩容流程:
flowchart TD
A["确定新分片规则"] --> B["创建新库表"]
B --> C["历史数据迁移"]
C --> D["双写老分片和新分片"]
D --> E["增量数据同步"]
E --> F["数据校验"]
F --> G{"校验通过吗"}
G -- "否" --> H["修复差异继续校验"]
G -- "是" --> I["灰度切读"]
I --> J["全量切换"]
J --> K["保留回滚窗口"]迁移必须回答:
- 老数据怎么搬?
- 迁移期间新增数据怎么同步?
- 怎么校验行数、金额、状态、哈希?
- 出错怎么回滚?
- 切换期间如何避免重复写、漏写?
- 业务如何识别老路由和新路由?
校验示例:
select count(*) as cnt,
sum(total_amount) as amount_sum,
min(created_at) as min_time,
max(created_at) as max_time
from orders_00
where created_at >= '2026-07-01'
and created_at < '2026-07-02';还可以按主键范围计算摘要,避免只看总数掩盖数据错位。
生产排查
| 现象 | 可能原因 | 排查方向 | 处理 |
|---|---|---|---|
| 某个分片特别慢 | 分片键倾斜、大租户热点 | 看各分片 QPS、数据量、慢 SQL | 大租户单独拆、热点隔离 |
| 广播查询拖垮系统 | SQL 不带分片键 | 看 SQL 路由日志 | 接口强制分片键,搜索同步 ES |
| 跨分片分页慢 | 全局排序归并 | 看每个分片扫描量 | 改精准路由、Seek 分页、搜索视图 |
| 写入失败不一致 | 跨库写部分成功 | 看本地事务、消息、补偿表 | 幂等、重试、对账、分布式事务 |
| 扩容数据不一致 | 迁移漏数据或双写失败 | 对比行数、金额、哈希、binlog 位点 | 暂停切流,补偿差异 |
| 某表索引膨胀 | 分片仍然太大或冷热混合 | 看单表大小、索引大小、Buffer 命中 | 归档、分区、再拆分 |
| 连接池耗尽 | 分片多导致连接倍增 | 看每库连接数 | 控制分片数、连接池隔离、代理层 |
排查流程:
flowchart TD
A["分库分表系统异常"] --> B{"是单分片问题吗"}
B -- "是" --> C["查该分片数据量、慢 SQL、锁、资源"]
B -- "否" --> D{"是否广播或跨分片查询"}
D -- "是" --> E["查路由日志和 SQL 是否带分片键"]
D -- "否" --> F{"是否迁移或双写期间"}
F -- "是" --> G["查数据校验、消息补偿、路由版本"]
F -- "否" --> H["查连接池、代理、中间件、整体资源"]常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 为了慢 SQL 直接分表 | 复杂度上升,慢 SQL 仍慢 | 先 EXPLAIN、索引、归档、分页优化 |
| 分片键不在高频查询里 | 大量广播查询 | 按核心访问路径选分片键 |
| 分片数拍脑袋 | 很快再次扩容 | 做 3 到 5 年容量估算 |
| 忽略全局 ID | 主键冲突或排序混乱 | 提前设计雪花、号段或路由 ID |
| 跨库事务太多 | 一致性和补偿复杂 | 同事务数据尽量同分片 |
| 后台查询直接扫分片 | 拖垮在线库 | 搜索视图、数仓、报表库 |
| 扩容无回滚 | 切换失败难恢复 | 双写、校验、灰度、回滚窗口 |
| 表名来自用户输入 | SQL 注入风险 | 表名只来自白名单路由 |
面试标准回答
分库分表是什么?
分库分表是把一个库或一张表的数据,按业务或分片规则拆到多个库表中,用复杂度换容量、吞吐和隔离能力。它不是普通慢 SQL 的第一选择,应该先优化索引、SQL、分页、归档、缓存和读写分离;当单表数据量、单库资源或写入吞吐接近瓶颈,并且有明确分片键和迁移方案时,再考虑分库分表。
分片键怎么选?
分片键要围绕最高频、最核心的查询和事务边界选择。订单系统如果用户订单列表最高频,可以按 user_id 分片;如果按订单号处理最高频,可以按 order_id 或建立订单路由表。好的分片键要分布均匀、查询常带、基本不变、能减少跨分片事务。不好的分片键会导致大量广播查询、跨分片分页和热点分片。
分库分表后为什么事务变难?
单库事务由数据库本地保证原子性;分库后,一个业务操作可能同时写多个库,本地事务无法覆盖所有分片,就变成分布式事务或最终一致问题。解决方式包括同事务数据尽量放同一分片、本地消息表、事务消息、TCC、Saga、Seata、XA 等。实际项目要根据一致性要求、性能和复杂度选型。
分库分表后分页排序为什么慢?
如果查询不能精准路由到单个分片,就要多个分片各自查一批数据,再在应用或中间件做全局归并排序。分片越多、offset 越大,中间结果越多,成本越高。优化方式是接口带分片键、使用 Seek 分页、同步搜索视图或使用报表/数仓承载全局查询。
分库分表扩容怎么做?
扩容要先确定新路由规则,创建新库表,迁移历史数据,再处理迁移期间增量数据。常见做法是双写、增量同步、数据校验、灰度切读、保留回滚窗口。不能只复制数据就切换,因为切换期间会有新增和修改,必须保证不漏、不重、可校验、可回滚。
关联知识点
| 知识点 | 说明 |
|---|---|
| 大表覆盖索引仍然慢 | 判断什么时候先归档、分页优化,什么时候才考虑拆分 |
| 主从复制 | 分库前常见的读扩展方案 |
| MySQL EXPLAIN | 分库前必须先证明 SQL 慢在哪里 |
| MySQL 事务 | 本地事务边界 |
| 分布式事务 | 跨库事务解决方案 |
| Apache ShardingSphere | JDBC/Proxy 选型、路由内核、配置、事务、迁移与排障 |
| 缓存一致性 | 分库分表后缓存和数据库一致性 |
| Elasticsearch 一致性 | 全局搜索视图同步和补偿 |
本章小结
分库分表的核心不是“把表名改成很多张”,而是重新设计数据路由、事务边界、查询方式和运维流程。它能解决容量和吞吐问题,但会带来跨分片查询、跨库事务、全局 ID、分页排序、扩容迁移和一致性补偿。真正能在商业项目里用好分库分表的人,必须先能证明为什么普通优化不够,再能说明分片键怎么选、数据怎么迁、出错怎么补、线上怎么查。
