分库分表是最容易被提前消费的方案。它解决的问题很具体——写入吞吐、单表体量、连接数、备份窗口触顶;引入的问题很宽泛——跨分片事务、跨分片 join、全局排序、唯一约束全要另建机制。
正确的顺序不是"要不要分片",而是先证明瓶颈不在更便宜的层面。
1. 先排除四种假瓶颈
| 假瓶颈 | 识别方法 | 典型证据 |
|---|---|---|
| 慢查询 | 慢日志、digest 聚合 | 单条 SQL 慢,但 QPS 不高 |
| 缺索引 | EXPLAIN 看 type/rows | type=ALL,扫描行数远超返回行数 |
| 锁竞争 | 锁等待视图、死锁日志 | 大量 LOCK WAIT,CPU 不饱和 |
| 连接数打满 | 连接数与线程状态 | Threads_running 接近上限,取连接超时 |
-- 按总耗时排序的语句(MySQL 8,performance_schema)
SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1e12 AS total_s
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
SELECT * FROM sys.innodb_lock_waits; -- 谁在等谁
SHOW STATUS LIKE 'Threads_%';
EXPLAIN SELECT id, amount FROM orders
WHERE user_id = 42 AND status = 'PAID' ORDER BY created_at DESC LIMIT 20;
-- key 为空或 rows 到百万级:先建 (user_id, status, created_at) 复合索引
四种假瓶颈的共同点是修复成本小时级,而分片是月级。跳过这一步,等于用架构改造掩盖一条缺失的索引。
2. 四种手段的前提与代价
| 手段 | 适用前提 | 主要代价 |
|---|---|---|
| 垂直拆分 | 业务边界清晰、表间 join 少 | 跨库 join 消失,应用层拼装 |
| 读写分离 | 读多写少、可容忍秒级延迟 | 复制延迟、读己之写 |
| 分库分表 | 写入吞吐或单表体量触顶 | 事务、join、排序、唯一约束全要重建 |
| 换引擎/换库 | 瓶颈在存储模型本身 | 迁移风险、团队经验、生态兼容 |
垂直拆分与读写分离可逆,分库分表基本不可逆。这个可逆性差异比性能收益更该影响决策。
3. 真正需要分片的信号
| 信号 | 量级判据(按实际压测校准) | 可否替代 |
|---|---|---|
| 写入吞吐 | 接近单库 fsync 上限,主从延迟持续扩大 | 归档冷数据、异步化 |
| 单表体量 | 千万行级以上,索引不再全部常驻内存 | 分区表、冷热分离 |
| 连接数 | 活跃连接接近 max_connections,加机器无用 |
连接池、代理 |
| 备份窗口 | 全量备份+恢复超过可接受停机窗口 | 分表备份、物理备份 |
判断链(每层都要实测证据,不接受"预计会涨")
1. 单条 SQL 是否已优化到索引覆盖? 否 -> 优化
2. 冷数据可否归档到历史库/对象存储? 否 -> 归档
3. 读压力可否用从库分担? 否 -> 读写分离
4. 写入是否已到单机上限? 是 -> 分片
第 4 步的上限必须来自压测或生产曲线。写入触顶常表现为 commit 延迟上升 + 从库延迟扩大 + IO util 饱和三者同时出现。
4. 分片键的选择
| 方式 | 优点 | 代价 | 适用 |
|---|---|---|---|
| 业务维度(user_id/tenant_id) | 同主体数据聚合,查询自然收敛 | 大客户热点 | 多数 SaaS 业务 |
| 哈希(取模/一致性哈希) | 分布均匀、扩容可控 | 范围查询变广播 | 无主体维度 |
| 范围(时间/ID 段) | 范围查询高效、易归档 | 新数据集中写热点 | 日志、流水、时序 |
选键三问
1. 最高频查询是否都带这个键? 否 -> 该查询变成跨分片
2. 是否存在超级大客户/大租户? 是 -> 二级拆分或独立库
3. 未来是否需要范围扫描? 是 -> 范围分片 + 索引表
分片键一旦选定,查询边界就被它固定。业务后来多一个"按手机号查订单"的需求,就得额外维护映射表。选键要把未来两年的查询形态列出来,而不是只看当下 SQL。
5. 分片后必然失去的能力
| 能力 | 失去原因 | 替代方案 |
|---|---|---|
| 跨分片事务 | 单机事务边界消失 | 本地事务+消息、TCC、Saga |
| 跨分片 join | 数据不在同一实例 | 冗余字段、宽表、应用层拼装 |
| 全局排序 | 各分片各自排序 | 归并排序、离线宽表 |
| 全局唯一约束 | 唯一索引只在单分片生效 | 全局 ID、独立去重表 |
| 跨分片聚合 | count/sum 需汇总 | 预聚合表、近似计数 |
-- 跨分片分页:各分片取前 N,应用侧归并,不能直接 offset
SELECT id, amount, created_at FROM orders
WHERE user_id IN (...) ORDER BY created_at DESC LIMIT 20; -- 在 shard-0..3 各执行
这些能力不是"性能变差",而是语义变了:一条 SQL 的事务保证现在要靠消息与补偿,一个 COUNT(*) 现在要汇总四个数。改造清单必须逐项写明替代方案,否则上线才发现报表算不出来。
6. 扩容时的数据迁移
| 方式 | 一致性 | 回滚难度 | 适用 |
|---|---|---|---|
| 双写 | 应用保证,易漏 | 低(关开关) | 新老结构可并行 |
| 影子表 + CDC/触发器 | 数据库保证 | 中 | 不能改应用写入逻辑 |
| 在线 DDL | 表级,需双倍空间 | 中 | 单表结构变更 |
| 停机迁移 | 强一致 | 低 | 可接受维护窗口 |
-- 一致性校验:按分片键取模分组比对聚合值,差异桶再下钻到主键级
SELECT MOD(user_id, 16) AS b, COUNT(*) AS c, SUM(amount) AS s
FROM orders WHERE created_at < '2026-01-01' GROUP BY b;
难点不是搬数据,而是搬完之后新旧还在同时写:双写要处理失败重试与顺序(同一主键后写覆盖前写),校验要能按桶定位差异,而不是只报总数不符。
7. 分布式 ID 与分页的坑
| 问题 | 表现 | 处理 |
|---|---|---|
| 自增 ID 冲突 | 多分片各自从 1 开始 | 雪花/号段/中心化自增 |
| 深分页 | LIMIT 100000, 20 越翻越慢 |
游标(keyset)分页 |
| 跨分片 count | 需汇总,慢且易超时 | 预聚合、近似计数、限范围 |
| ID 有序性 | 随机 ID 让插入离散 | 趋势递增(时间前缀) |
-- 游标分页:把 offset 换成上一页最后一条的排序键
SELECT id, created_at FROM orders
WHERE user_id = 42 AND created_at < :last_created_at
ORDER BY created_at DESC LIMIT 20;
雪花依赖时钟回拨保护;号段从库取一段缓存,重启会浪费一段;UUID 索引局部性差,不适合做聚簇主键。深分页的根因是数据库要扫描并丢弃前面的行,分片后每片都要丢弃,所以分片与游标分页几乎必须一起上。
8. 读写分离的延迟与读己之写
| 场景 | 风险 | 策略 |
|---|---|---|
| 下单后立刻查订单 | 从库未同步 | 写后短窗口走主库 |
| 列表页展示刚改的数据 | 看到旧值 | 会话粘滞主库一段时间 |
| 报表/导出 | 可容忍延迟 | 走从库并标注数据截止时间 |
| 强一致读 | 不能容忍 | 直连主库或改用共识复制 |
// 读己之写:写操作后把该用户的读请求钉在主库一段时间
if (justWrote(userId)) return master.query(sql); // 窗口秒级
return replica.query(sql);
延迟是复制模型的固有属性。消除它只有两条路:读主库(放弃分离收益)或用共识复制(放弃异步性能)。"既要分离又要强一致"在异步复制下不成立。
9. NewSQL 与中间件的取舍
| 方案 | 改造量 | 运维复杂度 | 适用 |
|---|---|---|---|
| 应用层分片(JDBC SDK) | 中,改配置与 SQL 习惯 | 低,无额外组件 | 团队能控 SQL |
| 代理层(Proxy) | 低,应用透明 | 中,多一跳与单点 | 多语言、少改应用 |
| 分布式数据库(NewSQL) | 低,取决于 SQL 兼容度 | 高,新体系与新故障模式 | 强一致+水平扩展刚需 |
| 维持单库 + 归档 | 最低 | 最低 | 增长可预期且慢 |
NewSQL 的诱惑是"不用改应用",代价是把复杂度搬到运维。选型前问三句:跨分片事务是真支持还是看起来支持?扩容重分布要不要停机?故障时谁能定位到具体分片?没有分布式存储排障经验,一次 region 热点就可能烧掉自建分片的全周期预算。
10. 回滚预案
| 阶段 | 可回滚性 | 预案 |
|---|---|---|
| 影子表/双写 | 高 | 关开关,回单库读 |
| 读切换 | 高 | 路由配置回退 |
| 写切换 | 中 | 反向双写 + 差异修复 |
| 停止单库写入 | 低 | 需保留单库只读窗口与回灌脚本 |
回滚检查单
1. 单库是否仍在同步(binlog 消费是否存活)?
2. 反向双写的字段映射是否覆盖新加列?
3. 差异数据能否按主键枚举,而不只是报总数?
4. 回滚后 ID 生成是否会产生重复?
最容易犯的错是切换完成后立刻停掉单库同步,那一步之后回滚就从"改配置"变成"数据救援"。稳妥做法是保持双向同步至少一个完整业务周期(含月末、结算峰值)再评估下线。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 分片后查询变慢 | 查询未带分片键,变广播 | 补键或建映射表 |
| 某分片 CPU 独高 | 分片键倾斜、大客户 | 二级拆分或独立库 |
| 跨分片事务偶发不一致 | 消息重试非幂等 | 加去重表/幂等键 |
| 迁移后 count 对不上 | 软删除条件不一致 | 统一过滤条件后重算 |
| 从库延迟持续扩大 | 大事务、无主键表、并行复制不足 | 拆大事务、补主键 |
12. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 慢查询/缺索引 | 加索引、改 SQL | 小时级修复,零架构风险 |
| 读多写少 | 加从库 + 缓存 | 可逆,成本线性 |
| 冷数据占多数 | 归档 + 分区表 | 热数据量降下来问题自然消失 |
| 单表千万级但查询简单 | 分区表 + 覆盖索引 | 避免跨分片复杂度 |
| 强一致 + 水平扩展刚需 | NewSQL | 应用改造量最小 |
| 增长缓慢可预估 | 单库 + 容量规划 | 分片收益覆盖不了复杂度 |
一句话:能靠索引和缓存解决就别分片,能加从库就别分库,能归档就别分区。分片把"一个数据库的问题"变成"一个分布式系统的问题"。
13. 自测
- 四种假瓶颈各自的识别证据是什么?为什么必须先排除它们?
- 四种扩容手段里哪一种基本不可逆?这个性质如何影响决策顺序?
- 判断"写入已到单机上限"时,通常有哪三个信号同时出现?
- 分片键选定后,查询边界被怎样固定?选键时要预先列出什么?
- 分片后跨分片分页为什么必须改游标?深分页在分片下为何更严重?
- 切换完成后立刻停掉单库同步,会把回滚变成什么?