分库分表:什么时候做、怎么做、代价是什么
本文目录
分库分表是数据库架构里代价最高的手术:做完之后,SQL 的表达能力直接退化到"单分片",跨片聚合、跨片事务、扩容迁移全都变成新问题。它解决的是"单实例容量和写入性能到顶"这一件事,代价是此后所有查询都要绕着这个约束设计。这篇文章按决策顺序讲:先确认该不该做,再讲怎么选键、怎么路由、怎么出全局 ID,最后讲跨片查询和扩容这两个最大的坑。
先问该不该做:多数"撑不住"不是分片问题
真实排查里,号称"数据太大要分库分表"的案例,多数死因是别的。按成本从低到高逐级排除:
| 手段 | 解决的问题 | 成本 | 前提 |
|---|---|---|---|
| 索引与查询优化 | 慢查询、CPU 高 | 低,可回退 | 先看 EXPLAIN,多数"数据库慢"是缺索引或烂 SQL |
| 读写分离 | 读压力大(读多写少) | 中,引入复制延迟问题 | 写入单点仍未解决 |
| 冷热归档 | 表大但活跃数据小(订单类) | 中,业务侵入小 | 活跃窗口明确(如近 3 个月) |
| 硬件升级 | 整体资源到顶 | 中,钱能解决 | 只推迟问题,不消除 |
| 分库分表 | 单实例写入/容量真到顶 | 高,不可逆 | 以上全部无效后再上 |
判断"真到顶"可以看硬指标:单表行数只是参考(InnoDB 单表几千万行在索引合理时完全可能健康),写入 TPS 逼近单实例上限、主从延迟无法收敛、单表数据文件已经大到备份和 DDL 都超出窗口,这三条同时出现才值得谈分片。反之,QPS 500、表 2000 万行就喊分库分表,几乎一定是索引没做对。
先垂直拆,再水平拆
垂直拆是水平拆之前的低风险动作,两件事方向不同:
垂直分库:按业务域把一个大库拆成多个独立库。用户、订单、文章各归其库,库与库之间不共享实例(或至少不共享库)。这一步通常伴随服务拆分,收益是故障域隔离——文章库的慢查询不再拖死订单库的连接池。
垂直分表:把一张宽表按列拆开。典型是把 TEXT/BLOB 大字段(文章正文、附件内容)从主表挪到扩展表,主表只留元数据。因为 InnoDB 按页存储,大字段会把行撑得跨页,扫主表的任何查询都被拖慢:
-- 主表:轻量列,列表页只查它
CREATE TABLE article (
id BIGINT UNSIGNED PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_user_created (user_id, created_at)
);
-- 扩展表:大字段独享,正文改动不影响主表查询
CREATE TABLE article_content (
article_id BIGINT UNSIGNED PRIMARY KEY,
content MEDIUMTEXT NOT NULL
);
垂直拆的顺序意义在于:先拆库能显著推迟水平拆的时间点,而且垂直拆不动 SQL 语义(表还是完整的表),水平拆才真正改变应用层的一切。
水平拆分键的选择:一个字段决定整个架构
分片键(sharding key)决定了每行数据落在哪个分片,也决定了每条查询能不能只打一个分片。选键的三个标准:
| 标准 | 含义 | 反例的后果 |
|---|---|---|
| 查询覆盖度 | 高频查询必须带上分片键,路由到单分片 | 高频接口全变成广播查询,分片越多越慢 |
| 分布均匀 | 键的取值不能天然倾斜 | 一个大客户占 60% 数据,某个分片永远是热点 |
| 稳定不变 | 键选完不能改,改键等于全量重分布 | 后期被迫二次迁移 |
电商订单的经典权衡:用 user_id 做分片键,用户侧查询(查我的订单)全单分片,但商家侧查询(查店铺订单)全广播;用 order_id 编码商家位则反过来。没有两全的键,只能按更高频、更核心的那一侧选,另一侧用下面的"异构索引"解决。
时间维度要单独说:按时间分片(月表、日表)对"可删除旧数据"极其友好——删一个月表比 DELETE 千万行快几个量级,但跨时间范围查询和热点集中(最新分片承载全部写入)是它的固定代价。日志、流水类 append-only 数据适合时间分片;交易主数据适合实体 ID 哈希分片。
-- 验证候选键的倾斜度:top 用户的数据占比(在拆分前跑)
SELECT user_id, COUNT(*) AS cnt,
ROUND(COUNT(*) / (SELECT COUNT(*) FROM orders), 2) AS ratio
FROM orders GROUP BY user_id ORDER BY cnt DESC LIMIT 10;
候选键定下来之前,把"最高频的 10 条查询"列在表格里逐条标注是否带键,这一小时的评审能避免一整年的广播查询。
路由方案:客户端、代理与现状
路由指"这条 SQL 该发往哪个分片实例",三种实现位置:
| 方案 | 形态 | 优点 | 缺点 |
|---|---|---|---|
| 客户端分片 | SDK 内计算分片直连各库 | 少一跳,延迟最低,故障面小 | 语言绑定,配置升级要所有应用跟着发 |
| 代理模式 | 中间件代理 SQL,对应用透明 | 语言无关,运维收敛在代理层 | 多一跳延迟,代理自身是新的单点/瓶颈 |
| 数据库原生 | MySQL 自带分区表/Cluster 系 | 标准化,无中间件 | 能力有限:分区不解决跨实例容量 |
现状的基本盘:新项目客户端分片为主,配合自建路由配置中心;代理层(MyCat、ShardingSphere-Proxy 一类)适合存量应用无法改造、或异构语言多的场景。无论哪种,共同的前提是路由规则要有灰度能力和配置版本化——路由规则 bug 等于全量数据错位,这是分片体系里最高危的变更类别。
// 客户端路由的骨架:规则集中在配置,禁止散落在业务代码
const SHARD_COUNT = 16;
export function shardOf(userId: number): number {
// 哈希取模:简单直接,扩容要迁移(见"扩容迁移"节)
return hash32(String(userId)) % SHARD_COUNT;
}
export async function getDb(userId: number): Promise<Pool> {
return pools[shardOf(userId)]; // 每个分片一个连接池,容量分别规划
}
分片算法:range、hash 与一致性哈希
| 算法 | 规则 | 优点 | 缺点 |
|---|---|---|---|
| range 按区间 | 按键值/时间区间路由 | 范围查询高效、扩容只加新区间 | 写入集中在最新区间,热点必然 |
| hash 取模 | hash(key) % N | 分布均匀 | 扩容 N 变化导致全量重分布 |
| 一致性哈希 | 哈希环 + 顺时针就近 | 扩缩容只迁移相邻一小段数据 | 实现复杂,需处理虚拟节点与倾斜 |
三者不是并列选择,而是按业务类型分流:流水、日志类数据时间 range(旧分片可直接归档下线);交易主数据哈希类(均匀优先)。哈希取模的 N 要一开始就规划好(如 16、32),并配合"分库数与分表数分离"——先 4 库 16 表,扩容时优先增加每库的表数或分 64 表,推迟跨库迁移。
一致性哈希解决的是"扩容时取模全错位":把哈希空间组成环,节点只接管环上一小段,扩容迁移量约 1/(N+1)。代价是实现复杂度(虚拟节点解决数据倾斜)和调试成本,建议优先选成熟实现而不是自己手写哈希环,自研的一致性哈希出过最多的事故是"节点摘除后没人接管的区间"。
-- range 分片的建表骨架:按月,旧月分片可整体归档
CREATE TABLE orders_2026_10 (
id BIGINT UNSIGNED NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id, created_at) -- InnoDB 分区键必须进主键
) ;
全局 ID:自增步进、号段与雪花
拆分后各分片的 AUTO_INCREMENT 必然冲突,全局 ID 有三条主流路线:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 自增步进 | 每个分片 AUTO_INCREMENT 起点不同、步长=分片数 | 数据库原生,零改造成本 | 分片数定死;加片要重新规划;趋势可猜(有业务风险) |
| 号段模式 | 发号服务一次从 DB 取一段(如 1000 个)缓存发 | 数据库压力极小,DB 短暂不可用还能撑 | 发号服务可用性要求高;段间不严格递增 |
| 雪花算法 | 41 位时间戳 + 机器 ID + 序列拼 64 位 | 本地生成无依赖,趋势递增 | 依赖时钟;机器 ID 分配要管理 |
步进方案一行配置就能跑,适合分片数确定且不打算频繁扩的小体系;号段与雪花是规模上去之后的主流。雪花的核心风险是时钟回拨:机器 NTP 校时回退后,同一毫秒同一序列可能生成重复 ID。处理策略必须在实现里写死:检测到回拨在阈值内(如 10ms)等待追平,超过阈值直接报错拒发,绝不静默继续:
// 雪花 ID 的骨架实现:时钟回拨必须显式处理
class Snowflake {
private lastTs = 0n;
private seq = 0n;
next(machineId: bigint): bigint {
let ts = BigInt(Date.now());
if (ts < this.lastTs) {
const drift = this.lastTs - ts;
if (drift <= 10n) {
// 小回拨:等待到上次时间戳之后,避免重复
const waitUntil = Number(this.lastTs + 1n);
while (BigInt(Date.now()) < waitUntil) {}
ts = BigInt(Date.now());
} else {
throw new Error('clock moved backwards, refusing to issue id');
}
}
if (ts === this.lastTs) {
this.seq = (this.seq + 1n) & 0xfffn; // 12 位序列
if (this.seq === 0n) { /* 本毫秒用尽,等到下一毫秒 */ }
} else {
this.seq = 0n;
}
this.lastTs = ts;
return (ts - 0n) << 22n | (machineId << 12n) | this.seq;
}
}
工程上还要补两件事:machineId 的分配要注册化(数据库分配/环境元数据),两台机器配同一个 machineId 就是静默的重复 ID 事故;ID 类型对外用字符串承接(超过 JS Number.MAX_SAFE_INTEGER 后精度丢失是前端的经典翻车点)。
跨分片查询:聚合、冗余与异构索引
分片键没覆盖的查询只有三条出路。
出路一:两阶段聚合。先把查询下推到每个分片局部聚合,再在应用层归并。以"查用户订单总额"为例(假设分片键是别的维度):
-- 阶段一:并发发往所有分片,每个分片只做本地聚合
SELECT user_id, SUM(amount) AS s, COUNT(*) AS c
FROM orders WHERE created_at >= '2026-10-01'
GROUP BY user_id ORDER BY s DESC LIMIT 20;
// 阶段二:应用层归并。SUM/COUNT/MAX 可以直接二次聚合
// 但 AVG 要用 SUM 和 COUNT 重算,LIMIT 不能在每个分片直接拿全局值
const locals = await Promise.all(shards.map(runStage1));
const merged = new Map<string, { sum: number; cnt: number }>();
for (const rows of locals) for (const r of rows) {
const m = merged.get(r.user_id) ?? { sum: 0, cnt: 0 };
m.sum += Number(r.s); m.cnt += Number(r.c);
merged.set(r.user_id, m);
}
// 排序取全局 top:每个分片取 LIMIT 20 保证归并正确的前提是
// 局部排序键与全局排序键一致,且每片返回量 >= 全局需要量
两阶段聚合的隐性雷区:分页的 OFFSET 在归并语义下不复存在(全局第 21 条可能落在任何分片的第 1 条里,正确做法是每片取 offset+limit 再全局归并截断);AVG、DISTINCT 都要拆成可归并的等价形式。
出路二:宽表冗余。高频但带不上分片键的查询,把需要的字段冗余到一张按查询维度分片的表里。订单表按 user_id 分片,同时把订单摘要冗余到按 shop_id 分片的商家宽表——商家查"今日订单"就单分片搞定。冗余的代价是双写一致:主表与宽表必须有补偿机制(对账任务兜底),接受最终一致。
出路三:异构索引表。只存"键到键"的映射(user_id → order_id 列表按 shop_id 分片),查到 ID 列表再回主分片取详情。比宽表轻,但一次业务查询变成两跳,要评估总延迟。
JOIN 在分片体系里的归宿基本是"消失":跨分片 JOIN 的正确姿势就是把上面三条出路之一做成物化结果,架构上明确"禁止跨分片 JOIN"并写进评审清单,比事后治理一堆 3 秒的查询有效得多。
分布式事务:能回避就回避
跨分片的强一致事务(XA 协议类)在 MySQL 上可用但性能代价大:锁定周期跨网络往返,吞吐常常掉一个数量级,还带来悬挂事务的运维复杂度。实践共识是绝大多数业务不需要跨分片强一致,回避手法比引入分布式事务框架更优先:
- 让事务留在单分片:分片键设计时保证同一用户的订单、明细落同一分片,订单创建是本地事务,无任何跨片协调。
- 本地事务 + 事件最终一致:跨片操作改成"主分片本地落库 + 发事件 + 其他分片异步消费",配合幂等消费与重试。事件投递可用事务型 Outbox:在主分片同一事务里写业务数据和待发送事件,再由投递器异步入队;消息确认和重试边界见消息队列基础。
- TCC/补偿:真正需要跨片原子性的少数场景(资金划拨),用显式的 try-confirm-cancel 业务状态机,复杂度留在业务层,每一步都可观测可补偿。
判断标准:问业务方"这两步之间允许几百毫秒的不一致吗",绝大多数答案是可以;回答"绝对不行"的场景先数清楚是不是真的跨分片(很多强一致诉求其实能靠分片键设计回到单分片)。
扩容迁移:双写、校验、切读切写
分片数不够要扩容,这是分库分表体系里风险最高的操作,标准路径是"双写灰度"四步:
- 新集群就位:按新分片规则(如 32 片)部署新库,旧集群照常服务。
- 双写开启:应用写旧集群的同时异步写新集群,以旧为准。存量数据用迁移工具按新规则回填,回填要限速(避免打满复制/影响线上),并记录位点支持断点续传。
- 数据校验:全量分块比对行数与抽样内容的校验和,差异块重追。校验通过是切流量的唯一门禁,不要用"跑了一周没报错"替代校验。
- 切换:先灰度切读(按用户位或百分比),读侧稳定后切写,旧集群降级只读保留一个回滚窗口,最后下线。
-- 校验 SQL 的形态:按块比对主键区间,两边分片各跑一份再对账
SELECT COUNT(*) AS rows, SUM(CRC32(CONCAT_WS(':', id, user_id, amount))) AS chk
FROM orders WHERE id > 0 AND id <= 1000000;
两个纪律:每一步都要有回滚路径(切读可瞬间切回,双写期补写逻辑要幂等);迁移期间冻结 DDL——双写过程中表结构变更会让校验与补写全部失效,任何 alter 等迁移完成后排队。
什么时候不要分库分表
最后把反向清单写全,因为"做错方向的手术"没法撤销:
- 还没做 EXPLAIN 和索引治理——多数慢是烂查询,分片会把烂查询广播到 N 个分片,更慢。
- 读多写少想扩读能力——读写分离加从库是小时级操作,分片是季度级项目。
- 单表行数在千万级但活跃数据只有几百万——按时间归档冷数据,表立刻"变小"。
- 团队没有明确的分片键共识——键选错的分片体系撑不过第一次业务转向。
- 业务还在快速试错、表结构每周变——分片的迁移成本会把迭代速度拖垮,先垂直分库等业务定型。
分库分表是"终点方案":它把数据库的弹性约束换成架构的静态约束。做之前问一句"三年后这个分片键还成立吗",答案不确定,就先回到本节第一行。
注意事项与边界
- 分片数与连接数是乘法关系:N 个分片 × 每应用实例池大小,数据库侧连接预算要重新整体核算,别让分片把连接池规划放大到打爆
max_connections。 - 自增 ID 对外暴露的业务风险(遍历、爬取)在分片体系里会被步进/雪花 ID 缓解,但别把它当安全机制,接口鉴权照做。
- 本文示例以 MySQL/InnoDB 为背景,分区表有明确限制(如分区键必须进主键/唯一键、不支持外键),动手前以官方文档为准;其他数据库的分片思路同源但细节不同。
- 双写与异构冗余的一致性窗口是客观存在的:对账任务、差异修复脚本要作为体系标配交付,不是"以后再补"。
- 跨分片查询的"能跑"不等于"能用":每条广播查询的 P99 要进监控,分片数量增长时广播查询成本线性上升,别等事故时才发现。
- 分片键一旦选定不可更改是铁律:改键 = 全量数据按新规则重分布,等于一次没有灰度的迁移。
总结
分库分表的决策链:先穷尽索引、读写分离、归档、硬件,确认单实例真到顶;先垂直分库分表推迟水平拆;水平拆的一切围绕分片键(查询覆盖度、均匀性、不可变)展开;路由客户端为主、代理为辅,算法按数据形态选 range 或 hash;全局 ID 用号段或雪花并显式处理时钟回拨;跨片查询靠两阶段聚合、宽表冗余、异构索引三板斧,跨片事务用本地事务加最终一致回避;扩容走双写、校验、切读切写的四步纪律。这套体系最贵的不是搭建,是它不可逆——所以第一问永远是"真的必须吗"。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。