引言

订单表到 8000 万行那天,DBA 在群里说了一句话:"单库快扛不住了。" 那个平时 100ms 的查询接口开始偶发 3 秒超时,写入高峰期的连接池被挤爆,Thread pool is full 的告警一条接一条。老板问得直接:"加机器能解决吗?"

能,但不是你想的那种加机器。给单机数据库换一块更大的硬盘、更大的内存是垂直扩展,它有天花板;而把一张大表拆成 N 张、分到 N 个库上,让每台机器只扛总量的 1/N,这才是水平扩展——也就是本文要讲的分库分表。

分库分表是架构里代价最大、也最容易做错的优化之一。它不像加索引那样"加完就快",而是把简单的单机事务模型,换成了一个处处要你手动兜底的分布式系统。本文不堆概念,只讲四件事:什么时候必须分、怎么选分片策略、会踩哪些坑、上线后怎么监控。


一、什么时候该分库分表

先说结论:分库分表是最后的招,不是第一招。绝大多数性能问题,轮不到分库分表出场。

1.1 先问这三个问题

在决定拆分之前,按顺序排除更便宜的方案:

code
1. 是查询慢吗?   → 先加索引、改慢 SQL、上缓存(Redis)、读写分离
2. 是表太大吗?   → 先归档冷数据、分区表(Partition)
3. 是连接数不够? → 先加连接池、优化事务时长、上只读副本

这三样都做完还不行,才进入分库分表的话题。过早拆分等于用分布式复杂度,去解决一个本可以用索引解决的问题。

1.2 触发拆分的硬指标

经验阈值不是绝对标准,但可以作为决策参考:

维度 参考阈值 说明
单表行数 > 2000 万行 MySQL InnoDB 的常见经验线,超过后索引维护、DDL 都明显变慢
单表体积 > 20 GB 备份、迁移、DDL 的时间成本急剧上升
单库 QPS > 数千写 写入受单机磁盘/CPU 上限约束
连接数 频繁打满 连接是有上限的,无法靠横向加连接解决
单机 IO 磁盘 IOPS 打满 读写分离也救不了写瓶颈时

核心认知:分库分表解决的是"单机物理上限"问题,不是"SQL 写得烂"的问题。行数超阈值但查询都走索引、写入也远没到瓶颈,那就不该拆。

1.3 拆分的两个动作:分表 + 分库

很多人把"分库分表"混为一谈,其实是两个独立动作:

只分表不分库,能解决"单表太大"但解决不了"单库连接数/IO 打满";只分库不分表,能分摊连接和 IO,但单表还是可能大到拖慢 DDL 和备份。实际工程里,两者通常一起上。


二、分库分表 vs 垂直扩展 vs 分区

做决策前,先把这三个容易混的词分清楚。

2.1 垂直扩展(Scale Up)

给单机数据库加配置:CPU 核数、内存、换 NVMe 盘。

code
优点:零代码改动,事务模型不变,运维简单
缺点:物理上限明显(单机内存/CPU 有顶),且越往上性价比越低
适用:业务早期、数据量在单机可承载范围内

一台 64 核 512G 的机器跑 MySQL,和把它拆成 8 台 8 核 64G,后者虽然总量一样,但每台单点故障影响面小、写入吞吐线性扩展、成本也更灵活。这就是水平扩展的核心吸引力。

2.2 分区表(Partition)

同一张表,数据库内部按规则把数据物理存到不同分区,但对应用仍然是一张表,SQL 不用改。

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');
code
优点:对应用透明,SQL 不用改,方便按时间归档冷数据
缺点:仍是单库单实例,分区多了一样有元数据开销;解决不了连接数和 IO 上限

2.3 三者的关系

方案 解决的瓶颈 对应用是否透明 复杂度
垂直扩展 单机性能 透明 低
分区表 单表过大、冷热分离 透明(单库内) 中
分库分表 单机物理上限(连接/IO/容量) 不透明,需改造 高

一句话记忆:分区是"一张表内部分堆",分库分表是"拆成多张表多个库,应用自己路由"。前者数据库帮你做,后者你得自己做。


三、分片策略:范围分片 vs 哈希分片

选定要拆分后,第一个硬决策是按什么规则把数据分到哪个分片。两种主流策略:范围分片和哈希分片。

3.1 范围分片(Range)

按某个连续字段的区间划分,比如按 created_at 或 user_id 区间。

code
分片 0:user_id ∈ [0, 1000万)
分片 1:user_id ∈ [1000万, 2000万)
分片 2:user_id ∈ [2000万, 3000万)
sql
-- 应用层路由伪代码
def route(user_id):
    shard = user_id // 10_000_000
    return shard
code
优点:
  - 实现简单,路由逻辑一眼看懂
  - 天然支持范围查询(查 user_id 在某区间直接定位分片)
  - 扩容时新分片只接新区间,老数据不用搬

缺点:
  - 写入热点:新数据永远落在最新分片,老分片越来越闲
  - 数据倾斜:业务增长不均时,某些区间可能被刷爆

3.2 哈希分片(Hash)

对分片键做哈希取模,让数据均匀打散。

code
shard = hash(user_id) % N
code
优点:
  - 数据分布均匀,基本无热点
  - 每个分片压力均衡

缺点:
  - 范围查询要扫所有分片再聚合(如按时间查一段订单)
  - 取模扩容是灾难:N 从 4 变 5,几乎所有数据都要重算并迁移

3.3 一致性哈希:解决取模扩容问题

取模分片 hash(key) % N 的痛点在于 N 一变,映射全乱。一致性哈希把分片节点也映射到同一个哈希环上,数据落在环上顺时针遇到的第一个节点:

code
         哈希环 [0, 2^32)
   ┌─────────────────┐
   │   node_A   ...  │
   │        ↗        │
   │   key ─┘  node_C│
   │                 │
   │   node_B  ...   │
   └─────────────────┘

加一个节点 node_D:
  只有 node_D 负责的弧段上的 key 需要迁移,其余 key 不动
code
优点:扩容时只迁移一小部分数据(理论上是 1/N),其余分片不受影响
代价:引入虚拟节点(virtual node)来避免节点少时分布不均,实现复杂

现实里,多数中小规模系统用哈希取模 + 翻倍扩容就够了:分片数从 4 → 8 → 16,取模结果天然稳定一半,配合一致性哈希或虚拟分片做迁移。别一上来就上完整的一致性哈希轮子,除非分片数频繁变化。

3.4 复合策略

生产上常见的是先哈希分片、片内按时间排序:用 user_id 做哈希分片保证均匀,created_at 只用于片内查询和归档,不做分片键。

code
分片键:hash(user_id) % 16        ← 保证均匀 + 用户维度的查询命中单分片
片内:created_at 建索引/分区       ← 时间维度查询走索引,不进跨分片聚合

四、分片键怎么选

分片键是整个方案的地基,选错了一开始就要返工。三个判断标准:

4.1 高区分度

分片键取值要足够分散。用 status(只有 4 个取值)当分片键,数据只会落进 4 个分片,其余分片空转,等于没分。

4.2 覆盖核心查询

绝大多数查询都带这个字段,才能让请求命中单分片。电商订单用 user_id 或 buyer_id,是因为"查我的订单"是最高频场景;如果用 order_id 分片,用户查订单列表就得扫全部分片。

4.3 避免热点

新数据、热门数据不能被某个分片独吞。用自增 ID 做范围分片,最新数据永远挤在一个分片;用时间做哈希键,同一秒的写入也会挤在一起。理想情况是"读多写多"都分散。

code
坏例子:用自增主键 id 做哈希分片 → 均匀,但订单查询场景不带 id,查用户订单要扫全片
坏例子:用 status 做分片键 → 区分度低,4 个分片浪费 12 个
好例子:用 user_id 哈希分片 → 高区分度 + 用户维度查询全命中单分片

五、分库分表的常见陷阱

这是全文最重要的部分。拆分的收益有多大,这些坑就有多深。

5.1 跨分片 JOIN

单库时代随手一个 JOIN 就能拿到的数据,拆分后可能横跨十几个分片。

sql
-- 拆之前:订单 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 可能没分
-- 要么把商品信息冗余到订单里,要么分片内各自查、应用层拼装

解法优先级:

  1. 字段冗余:把高频关联的字段(商品名、用户名)冗余进订单表,牺牲一致性换查询简单。
  2. 应用层拼装:各分片并行查,应用层 merge。
  3. 建全局表:小表(配置、类目)每个分片复制一份。
  4. 换存储:实在要跨分片聚合的场景,推到 ES / 数仓去做。

5.2 分布式事务

拆分前 BEGIN ... COMMIT 一个事务搞定;拆分后一个订单扣库存、写流水,可能落在三个分片。跨分片事务没有免费的强一致性。

code
方案          一致性      性能     适用
2PC/XA       强一致      差      金融对账等强一致场景
TCC          最终一致    中      需要幂等补偿的扣减类
本地消息表    最终一致    好      可接受短暂不一致

核心认知:分库分表后,绝大多数业务要接受最终一致性,靠幂等 + 补偿 + 对账兜底。试图在拆分的系统里强上 XA 强一致,性能和可用性都会反噬。

5.3 全局唯一 ID

分库分表后,每个分片的自增 ID 会从 1 开始各自增长,跨分片 ID 会撞车。需要一个全局唯一的 ID 生成方案:

code
雪花算法(Snowflake)—— 64 位长整型:
┌─1位符号─┬─41位时间戳─┬─10位机器ID─┬─12位序列号─┐
  0         毫秒级时间     worker/机房   同毫秒内序号
code
优点:趋势递增(利于索引)、无中心依赖、高并发
注意:机器时钟回拨会导致 ID 重复,需要时钟回拨保护

其他选择:数据库号段(每次取一段 ID)、Redis INCR、UUID(无序,会打散索引局部性,慎用于聚簇索引)。

5.4 数据倾斜与热点

即使哈希分片,也可能出现"逻辑热点":某个 user_id 是超级大 V,它一个人产生了总数据量的 20%,落在哪个分片,哪个分片就被打爆。

code
解法:
  - 热点账号单独路由到独立分片/独立表
  - 热点数据单独缓存,绕过数据库
  - 对热点 key 加随机后缀打散(牺牲按 user 查询的便利)

5.5 扩容与数据迁移

分库分表不是一次性的,数据涨到分片扛不住时还得再拆。迁移才是真正的重头戏:

code
双写方案(生产常用):
1. 老分片继续服务读,新老分片同时双写
2. 后台 job 按主键分片把老数据全量搬到新分片
3. 校验数据一致性(行数、抽样比对)
4. 读流量切到新分片,观察无误后停掉老分片写入

关键点:全程可回滚。任何一步都要保留切回去的路径,双写期间用版本号或时间戳处理边界数据。

5.6 跨分片排序与分页

"按时间倒序展示全站订单列表"这种查询,拆分后要扫全部分片再归并,深分页(LIMIT 10000, 20)尤其痛苦。

code
解法:
  - 全局列表推到 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 必须盯的指标

code
分片粒度监控(每个分片单独出指标,不是整体平均):
  - QPS / TPS、慢查询数
  - 连接数、连接池使用率
  - 磁盘使用率、单表行数
  - 数据倾斜度:max(分片行数) / avg(分片行数)
  - 跨分片查询占比(越接近 0 越好)
  - 分布式事务失败率、补偿成功率

关键:看分片粒度而非集群平均值。整体 QPS 很健康,但某个分片被热点打爆的情况,平均数是看不出来的。监控必须能下钻到单个分片。

7.2 常见告警项

code
- 单分片 QPS 超过阈值(提前预警热点)
- 单分片磁盘使用率 > 70%(预留迁移时间)
- 数据倾斜度 > 1.5 倍(某个分片数据量异常)
- 慢查询 / 跨分片查询数量突增
- 分布式事务补偿失败率上升

7.3 迁移与回滚 checklist

每次扩容或迁移,对照这张清单走:

code
[ ] 迁移前全量备份,确认恢复路径
[ ] 双写开关可一键切换
[ ] 数据校验:总行数 + 抽样字段比对一致
[ ] 灰度切读,观察错误率和延迟
[ ] 保留回滚到老分片的能力,观察至少一个完整业务周期
[ ] 停止老分片写入前,二次确认没有遗漏的双写数据

结语

分库分表没有银弹,它是一笔交易:用分布式复杂度,换单机承载不了的规模。

把三句话带进下一次架构评审:

  1. 先问能不能不分——索引、缓存、归档、分区这些便宜方案都试过再说。
  2. 分片键选在最高频查询的维度上——它决定了你以后是扫一片还是扫全部。
  3. 跨分片的需求,推到缓存、ES、数仓,别让数据库硬扛——数据库只做它能做好的单分片精确读写。

最后记住监控那句话:平均数是谎言,分片粒度才是真相。一个被热点打爆的分片,藏在整个集群漂亮的平均 QPS 曲线下面,等你发现的时候,通常已经是事故了。