引言
订单表到 8000 万行那天,DBA 在群里说了一句话:"单库快扛不住了。" 那个平时 100ms 的查询接口开始偶发 3 秒超时,写入高峰期的连接池被挤爆,Thread pool is full 的告警一条接一条。老板问得直接:"加机器能解决吗?"
能,但不是你想的那种加机器。给单机数据库换一块更大的硬盘、更大的内存是垂直扩展,它有天花板;而把一张大表拆成 N 张、分到 N 个库上,让每台机器只扛总量的 1/N,这才是水平扩展——也就是本文要讲的分库分表。
分库分表是架构里代价最大、也最容易做错的优化之一。它不像加索引那样"加完就快",而是把简单的单机事务模型,换成了一个处处要你手动兜底的分布式系统。本文不堆概念,只讲四件事:什么时候必须分、怎么选分片策略、会踩哪些坑、上线后怎么监控。
一、什么时候该分库分表
先说结论:分库分表是最后的招,不是第一招。绝大多数性能问题,轮不到分库分表出场。
1.1 先问这三个问题
在决定拆分之前,按顺序排除更便宜的方案:
1. 是查询慢吗? → 先加索引、改慢 SQL、上缓存(Redis)、读写分离
2. 是表太大吗? → 先归档冷数据、分区表(Partition)
3. 是连接数不够? → 先加连接池、优化事务时长、上只读副本这三样都做完还不行,才进入分库分表的话题。过早拆分等于用分布式复杂度,去解决一个本可以用索引解决的问题。
1.2 触发拆分的硬指标
经验阈值不是绝对标准,但可以作为决策参考:
| 维度 | 参考阈值 | 说明 |
|---|---|---|
| 单表行数 | > 2000 万行 | MySQL InnoDB 的常见经验线,超过后索引维护、DDL 都明显变慢 |
| 单表体积 | > 20 GB | 备份、迁移、DDL 的时间成本急剧上升 |
| 单库 QPS | > 数千写 | 写入受单机磁盘/CPU 上限约束 |
| 连接数 | 频繁打满 | 连接是有上限的,无法靠横向加连接解决 |
| 单机 IO | 磁盘 IOPS 打满 | 读写分离也救不了写瓶颈时 |
核心认知:分库分表解决的是"单机物理上限"问题,不是"SQL 写得烂"的问题。行数超阈值但查询都走索引、写入也远没到瓶颈,那就不该拆。
1.3 拆分的两个动作:分表 + 分库
很多人把"分库分表"混为一谈,其实是两个独立动作:
- 分表(水平切分):把一张大表按某种规则拆成多张结构相同的小表,可以都在同一个库里(
orders_0、orders_1…)。 - 分库(垂直/水平切分到多库):把表分散到多个数据库实例,分摊连接数和 IO。
只分表不分库,能解决"单表太大"但解决不了"单库连接数/IO 打满";只分库不分表,能分摊连接和 IO,但单表还是可能大到拖慢 DDL 和备份。实际工程里,两者通常一起上。
二、分库分表 vs 垂直扩展 vs 分区
做决策前,先把这三个容易混的词分清楚。
2.1 垂直扩展(Scale Up)
给单机数据库加配置:CPU 核数、内存、换 NVMe 盘。
优点:零代码改动,事务模型不变,运维简单
缺点:物理上限明显(单机内存/CPU 有顶),且越往上性价比越低
适用:业务早期、数据量在单机可承载范围内一台 64 核 512G 的机器跑 MySQL,和把它拆成 8 台 8 核 64G,后者虽然总量一样,但每台单点故障影响面小、写入吞吐线性扩展、成本也更灵活。这就是水平扩展的核心吸引力。
2.2 分区表(Partition)
同一张表,数据库内部按规则把数据物理存到不同分区,但对应用仍然是一张表,SQL 不用改。
-- PostgreSQL 按时间范围分区
CREATE TABLE orders (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2026_08 PARTITION OF orders
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');优点:对应用透明,SQL 不用改,方便按时间归档冷数据
缺点:仍是单库单实例,分区多了一样有元数据开销;解决不了连接数和 IO 上限2.3 三者的关系
| 方案 | 解决的瓶颈 | 对应用是否透明 | 复杂度 |
|---|---|---|---|
| 垂直扩展 | 单机性能 | 透明 | 低 |
| 分区表 | 单表过大、冷热分离 | 透明(单库内) | 中 |
| 分库分表 | 单机物理上限(连接/IO/容量) | 不透明,需改造 | 高 |
一句话记忆:分区是"一张表内部分堆",分库分表是"拆成多张表多个库,应用自己路由"。前者数据库帮你做,后者你得自己做。
三、分片策略:范围分片 vs 哈希分片
选定要拆分后,第一个硬决策是按什么规则把数据分到哪个分片。两种主流策略:范围分片和哈希分片。
3.1 范围分片(Range)
按某个连续字段的区间划分,比如按 created_at 或 user_id 区间。
分片 0:user_id ∈ [0, 1000万)
分片 1:user_id ∈ [1000万, 2000万)
分片 2:user_id ∈ [2000万, 3000万)-- 应用层路由伪代码
def route(user_id):
shard = user_id // 10_000_000
return shard优点:
- 实现简单,路由逻辑一眼看懂
- 天然支持范围查询(查 user_id 在某区间直接定位分片)
- 扩容时新分片只接新区间,老数据不用搬
缺点:
- 写入热点:新数据永远落在最新分片,老分片越来越闲
- 数据倾斜:业务增长不均时,某些区间可能被刷爆3.2 哈希分片(Hash)
对分片键做哈希取模,让数据均匀打散。
shard = hash(user_id) % N优点:
- 数据分布均匀,基本无热点
- 每个分片压力均衡
缺点:
- 范围查询要扫所有分片再聚合(如按时间查一段订单)
- 取模扩容是灾难:N 从 4 变 5,几乎所有数据都要重算并迁移3.3 一致性哈希:解决取模扩容问题
取模分片 hash(key) % N 的痛点在于 N 一变,映射全乱。一致性哈希把分片节点也映射到同一个哈希环上,数据落在环上顺时针遇到的第一个节点:
哈希环 [0, 2^32)
┌─────────────────┐
│ node_A ... │
│ ↗ │
│ key ─┘ node_C│
│ │
│ node_B ... │
└─────────────────┘
加一个节点 node_D:
只有 node_D 负责的弧段上的 key 需要迁移,其余 key 不动优点:扩容时只迁移一小部分数据(理论上是 1/N),其余分片不受影响
代价:引入虚拟节点(virtual node)来避免节点少时分布不均,实现复杂现实里,多数中小规模系统用哈希取模 + 翻倍扩容就够了:分片数从 4 → 8 → 16,取模结果天然稳定一半,配合一致性哈希或虚拟分片做迁移。别一上来就上完整的一致性哈希轮子,除非分片数频繁变化。
3.4 复合策略
生产上常见的是先哈希分片、片内按时间排序:用 user_id 做哈希分片保证均匀,created_at 只用于片内查询和归档,不做分片键。
分片键:hash(user_id) % 16 ← 保证均匀 + 用户维度的查询命中单分片
片内:created_at 建索引/分区 ← 时间维度查询走索引,不进跨分片聚合四、分片键怎么选
分片键是整个方案的地基,选错了一开始就要返工。三个判断标准:
4.1 高区分度
分片键取值要足够分散。用 status(只有 4 个取值)当分片键,数据只会落进 4 个分片,其余分片空转,等于没分。
4.2 覆盖核心查询
绝大多数查询都带这个字段,才能让请求命中单分片。电商订单用 user_id 或 buyer_id,是因为"查我的订单"是最高频场景;如果用 order_id 分片,用户查订单列表就得扫全部分片。
4.3 避免热点
新数据、热门数据不能被某个分片独吞。用自增 ID 做范围分片,最新数据永远挤在一个分片;用时间做哈希键,同一秒的写入也会挤在一起。理想情况是"读多写多"都分散。
坏例子:用自增主键 id 做哈希分片 → 均匀,但订单查询场景不带 id,查用户订单要扫全片
坏例子:用 status 做分片键 → 区分度低,4 个分片浪费 12 个
好例子:用 user_id 哈希分片 → 高区分度 + 用户维度查询全命中单分片五、分库分表的常见陷阱
这是全文最重要的部分。拆分的收益有多大,这些坑就有多深。
5.1 跨分片 JOIN
单库时代随手一个 JOIN 就能拿到的数据,拆分后可能横跨十几个分片。
-- 拆之前:订单 join 商品,一条 SQL 搞定
SELECT o.*, p.name
FROM orders o JOIN products p ON o.product_id = p.id
WHERE o.user_id = 42;
-- 拆之后:orders 按 user_id 分了 16 片,products 可能没分
-- 要么把商品信息冗余到订单里,要么分片内各自查、应用层拼装解法优先级:
- 字段冗余:把高频关联的字段(商品名、用户名)冗余进订单表,牺牲一致性换查询简单。
- 应用层拼装:各分片并行查,应用层 merge。
- 建全局表:小表(配置、类目)每个分片复制一份。
- 换存储:实在要跨分片聚合的场景,推到 ES / 数仓去做。
5.2 分布式事务
拆分前 BEGIN ... COMMIT 一个事务搞定;拆分后一个订单扣库存、写流水,可能落在三个分片。跨分片事务没有免费的强一致性。
方案 一致性 性能 适用
2PC/XA 强一致 差 金融对账等强一致场景
TCC 最终一致 中 需要幂等补偿的扣减类
本地消息表 最终一致 好 可接受短暂不一致核心认知:分库分表后,绝大多数业务要接受最终一致性,靠幂等 + 补偿 + 对账兜底。试图在拆分的系统里强上 XA 强一致,性能和可用性都会反噬。
5.3 全局唯一 ID
分库分表后,每个分片的自增 ID 会从 1 开始各自增长,跨分片 ID 会撞车。需要一个全局唯一的 ID 生成方案:
雪花算法(Snowflake)—— 64 位长整型:
┌─1位符号─┬─41位时间戳─┬─10位机器ID─┬─12位序列号─┐
0 毫秒级时间 worker/机房 同毫秒内序号优点:趋势递增(利于索引)、无中心依赖、高并发
注意:机器时钟回拨会导致 ID 重复,需要时钟回拨保护其他选择:数据库号段(每次取一段 ID)、Redis INCR、UUID(无序,会打散索引局部性,慎用于聚簇索引)。
5.4 数据倾斜与热点
即使哈希分片,也可能出现"逻辑热点":某个 user_id 是超级大 V,它一个人产生了总数据量的 20%,落在哪个分片,哪个分片就被打爆。
解法:
- 热点账号单独路由到独立分片/独立表
- 热点数据单独缓存,绕过数据库
- 对热点 key 加随机后缀打散(牺牲按 user 查询的便利)5.5 扩容与数据迁移
分库分表不是一次性的,数据涨到分片扛不住时还得再拆。迁移才是真正的重头戏:
双写方案(生产常用):
1. 老分片继续服务读,新老分片同时双写
2. 后台 job 按主键分片把老数据全量搬到新分片
3. 校验数据一致性(行数、抽样比对)
4. 读流量切到新分片,观察无误后停掉老分片写入关键点:全程可回滚。任何一步都要保留切回去的路径,双写期间用版本号或时间戳处理边界数据。
5.6 跨分片排序与分页
"按时间倒序展示全站订单列表"这种查询,拆分后要扫全部分片再归并,深分页(LIMIT 10000, 20)尤其痛苦。
解法:
- 全局列表推到 ES 或数仓,数据库只做单分片精确查询
- 用游标/游标分页替代 offset 分页
- 业务上限制深分页(只给前 N 页)六、真实案例:三个典型场景
6.1 电商订单库
订单表按 buyer_id 哈希分 16 库、每库 64 表。高频的"查我的订单"命中单分片,写入均匀分散到 16 个实例。订单详情里的商品名、卖家昵称直接冗余进订单表,避免跨分片 JOIN。跨分片的"平台运营查询"全部走数仓和 ES,数据库不承担聚合。
6.2 微博/社交的 feed 与关注
用户表按 user_id 分片,保证"查某个用户"命中单分片;但 feed 流是"我关注的人发了什么",天然跨分片。解法是写时扩散 + 读时拉取结合:活跃用户推送到粉丝的 feed 缓存,长尾用户实时拉取,数据库只存权威数据,热点全在缓存层消化。
6.3 支付流水
流水表对强一致性要求高,通常先分区不急着分库:按 created_at 分区做冷热分离,热数据进缓存。真正要分片时,按 交易单号 哈希,保证单笔交易的所有操作落在同一分片,避免跨分片事务。对账、审计等聚合场景走异步离线链路。
共同点:这三个案例的分片键都选在"最高频查询的维度"上,跨分片需求一律推到缓存、ES、数仓这些为聚合而生的系统,而不是让数据库硬扛。
七、监控与运维
分库分表上线只是开始,真正的考验是长期可观测。
7.1 必须盯的指标
分片粒度监控(每个分片单独出指标,不是整体平均):
- QPS / TPS、慢查询数
- 连接数、连接池使用率
- 磁盘使用率、单表行数
- 数据倾斜度:max(分片行数) / avg(分片行数)
- 跨分片查询占比(越接近 0 越好)
- 分布式事务失败率、补偿成功率关键:看分片粒度而非集群平均值。整体 QPS 很健康,但某个分片被热点打爆的情况,平均数是看不出来的。监控必须能下钻到单个分片。
7.2 常见告警项
- 单分片 QPS 超过阈值(提前预警热点)
- 单分片磁盘使用率 > 70%(预留迁移时间)
- 数据倾斜度 > 1.5 倍(某个分片数据量异常)
- 慢查询 / 跨分片查询数量突增
- 分布式事务补偿失败率上升7.3 迁移与回滚 checklist
每次扩容或迁移,对照这张清单走:
[ ] 迁移前全量备份,确认恢复路径
[ ] 双写开关可一键切换
[ ] 数据校验:总行数 + 抽样字段比对一致
[ ] 灰度切读,观察错误率和延迟
[ ] 保留回滚到老分片的能力,观察至少一个完整业务周期
[ ] 停止老分片写入前,二次确认没有遗漏的双写数据结语
分库分表没有银弹,它是一笔交易:用分布式复杂度,换单机承载不了的规模。
把三句话带进下一次架构评审:
- 先问能不能不分——索引、缓存、归档、分区这些便宜方案都试过再说。
- 分片键选在最高频查询的维度上——它决定了你以后是扫一片还是扫全部。
- 跨分片的需求,推到缓存、ES、数仓,别让数据库硬扛——数据库只做它能做好的单分片精确读写。
最后记住监控那句话:平均数是谎言,分片粒度才是真相。一个被热点打爆的分片,藏在整个集群漂亮的平均 QPS 曲线下面,等你发现的时候,通常已经是事故了。