本节目标:建立「什么时候才该分库分表」的判断力,掌握分片键、跨片查询、分布式主键、JPA 摩擦这几个必须提前想清的边界,并明确本节不做什么。
适用版本:Spring Boot 4.1.x(Java 21)
11.3 分库分表的接入边界
11.1 调好了连接池,11.2 用读写分离下放了读流量。当写入吞吐或单表数据量继续增长,最后一张牌是分库分表。但它是代价最高、最难回退的一种方案——一旦分了,数据迁移、跨片查询、分布式事务、扩容全部跟着来。本节不教你「怎么分」,而是讲清接入边界:什么时候不该分、该分时要想清楚哪些问题,以及为什么本文刻意不给出完整分片实现。
11.3.1 先别分:分区、归档、加索引
「单表数据量太大」的第一反应常常是分表,但正确的顺序是先穷尽成本更低的手段。这三件事都能在不改应用代码的前提下解决大部分问题:
| 手段 | 做什么 | 对应用透明 | 适用 |
|---|---|---|---|
| 加索引 | 补上缺失的联合索引、覆盖索引 | 是 | 慢查询扫描全表 |
| 表分区 | 按时间/范围把单表在物理上拆开 | 是 | 单表行数大但访问集中在近期 |
| 归档 | 历史数据移到冷表/冷存储 | 是 | 老数据访问极少 |
表分区(PostgreSQL 声明式分区、MySQL 8 的分区表)值得特别一提:它把一张逻辑表在物理上拆成多个分区,查询时数据库按分区键做分区裁剪(partition pruning),只扫相关分区;删除旧数据可以直接 drop 整个分区,比 delete 快几个数量级。对 book-loan 的 loan 表,按 borrowed_at 做范围分区就很自然——查询几乎总是带时间范围,裁剪效果明显。
归档是另一个常被忽略的选项:三年前的借阅记录,业务上几乎不再查询,把它们移到 loan_archive 表或冷存储,热表立刻瘦下来。归档不需要改表结构,也不需要改查询逻辑(只要确认没有查询会碰到老数据)。
分区在 PostgreSQL 里是声明式的,加一句 partition by range 就完成,应用代码完全不动:
-- 按借阅时间做范围分区,逻辑上仍是一张 loan 表
create table loan (
id bigint not null,
book_id bigint not null,
member_id bigint not null,
borrowed_at timestamptz not null,
due_at timestamptz not null,
status varchar(16) not null
) partition by range (borrowed_at);
create table loan_2026_q1 partition of loan
for values from ('2026-01-01') to ('2026-04-01');
create table loan_2026_q2 partition of loan
for values from ('2026-04-01') to ('2026-07-01');
查询 where borrowed_at >= '2026-05-01' 时,数据库只扫 loan_2026_q2 一个分区(分区裁剪);要清理历史,drop table loan_2026_q1 即可,不会像 delete 那样产生大量 WAL 和表膨胀。如果访问模式天然带时间范围,分区往往是分库分表之前最划算的一步。
判断标准很实际:如果单表几千万行、补上合适索引后关键查询仍在毫秒级,就不需要分。 分库分表解决的是「索引也救不了」的规模——通常是几亿行以上,且写入吞吐本身就超过单库上限。
11.3.2 什么时候才真的需要分
读写分离解决的是读,分库分表解决的是写和容量。触发条件通常是下面之一:
- 单表数据量:即便索引正确,单表行数已到 B+ 树层级过深、维护(DDL、备份、加索引)代价不可接受的规模。
- 写入吞吐:单库的写入 QPS 已经打满(写入无法像读那样靠加从库分摊,主库只有一个)。
- 单库存储:数据总量逼近单机磁盘或备份窗口的上限。
三条里最容易被误判的是第一条。行数多不等于需要分——分区加归档往往就够了。真正难以回避的是第二条:写入吞吐打满时,除了分片没有别的办法。
落地前可以用下面这张清单过一遍,任何一条为「否」就先别分:
| 问题 | 为「否」时的动作 |
|---|---|
| 索引已经补全、慢查询已排查? | 先做第 6 章的查询优化 |
| 表分区/归档已经试过且不够? | 先做分区与归档 |
| 读写分离已经做完、读不再是瓶颈? | 先做 11.2 |
| 分片键能覆盖主要查询? | 重新设计分片键或暂缓 |
| 有存量数据迁移与回滚方案? | 先写迁移方案再动手 |
| 跨片事务有处理办法? | 先明确事务策略(第 12 章) |
这张清单的意义是把分库分表变成「穷尽其他手段后的结论」,而不是「遇到容量问题时的第一反应」。
11.3.3 分片键的选择与热点
分片键(sharding key)决定一行数据落在哪个分片。它是整个方案里最重要、也最难改的决定——分片键一旦定了,后续所有查询要么带上它高效路由,要么跨全片扫描。
选择分片键的三条要求:
- 高基数:取值足够多,能均匀铺开。用
status(只有BORROWED/RETURNED几个值)做分片键是灾难,所有数据挤在少数片。 - 分布均匀:避免绝大多数行落在同一个值上。
- 查询常带:最频繁的查询条件应该包含分片键,否则每次都是全片扫描。
对 book-loan,如果绝大多数查询是「查某会员的借阅记录」,就用 member_id 做分片键;如果查询模式变成「查某本书的借阅历史」,那 member_id 分片就让这类查询退化成广播。分片键的选择本质上是在为「哪一类查询优化、哪一类查询牺牲」做取舍。
热点是分片键之外的另一类问题:即使分布均匀,某些分片键值(大客户、爆款商品、明星用户)承载的流量也可能远超平均。这类热点没法靠换分片键解决,只能单独处理——把热点键的数据再拆、或给热点读加缓存。book-loan 里如果某个机构会员借阅量是普通会员的几百倍,它的那片就会成为瓶颈。
还有一条经验:不要用自增 ID 做分片键。自增 ID 单调递增,写入会持续落在「当前最大」的那一片,形成写热点;而且自增 ID 在分片后不再全局唯一(见 11.3.5)。
分片算法本身也有几种,选择会直接影响路由行为:
| 算法 | 路由方式 | 特点 |
|---|---|---|
| 取模(mod) | hash(key) % 分片数 | 分布均匀,但改分片数要全量迁移 |
| 哈希(hash) | 对 key 取哈希再映射 | 类似取模,同样怕扩容 |
| 范围(range) | 按 key 的区间映射 | 便于按范围查询与归档,但易产生热点 |
| 一致性哈希 | 环形映射,扩容只迁移相邻区间 | 扩容代价小,实现更复杂 |
| 时间 | 按时间维度分片 | 便于归档,但写入集中在最新片 |
最常见的坑是取模 + 后续扩容:分片数从 4 改成 5,几乎所有数据的归属都会变,必须全量迁移。一致性哈希或「预分片」——一开始就分成很多逻辑片(如 64 片),再把逻辑片映射到少量物理库,扩容时只搬逻辑片——可以缓解这个问题。分片算法要在设计阶段就考虑扩容路径,而不是上线后再想。
11.3.4 跨片查询与聚合
分片最容易被低估的代价在查询侧。一旦数据分布在多个片,任何「不带分片键」的查询都要变成广播 + 归并(scatter-gather):把查询发到所有分片,收集结果再合并。延迟取决于最慢的那一片。
几个具体后果:
- 深度分页几乎不可行。
limit 10 offset 100000在单库上是「跳过 10 万行取 10 行」,在分片上却要每个分片都取 100010 行、汇总排序后再切前 10 行。片数越多,代价越夸张。分片后通常要改成基于游标/游标键的分页(where id > last_id order by id limit 10),而不是offset。 - 聚合要跨片重算。
count(*)、sum()、group by都不能靠单片的局部结果直接得出(avg尤其不能——局部平均的平均不是全局平均)。 - 跨片 join 基本不可行。 两个分片规则不同的表做 join,要么退化成全片笛卡尔积,要么需要额外的数据冗余设计。
结论很直接:分片键的选择,实际上决定了「哪些查询能高效,哪些查询会退化成全片扫描」。 在决定分片键之前,必须先把线上高频查询列出来,逐一确认它们是否带得上分片键。
11.3.5 分布式主键
分片后,各分片的自增 ID 会重复,必须换成全局唯一的生成方案。常见三类:
| 方案 | 机制 | 优点 | 代价 |
|---|---|---|---|
| 雪花算法 | 64 位:时间戳 + 机器 ID + 序列号 | 趋势递增,插入友好 | 依赖时钟,需处理时钟回拨 |
| 号段模式 | 中心服务批量发号,应用本地缓存一段 | 性能好,不依赖时钟 | 依赖发号服务可用性 |
| UUID | 随机全局唯一 | 实现最简单 | 随机主键导致 B+ 树页分裂,性能差 |
雪花算法的趋势递增很重要:主键递增意味着插入总是发生在 B+ 树最右侧,避免页分裂,这对写入吞吐影响很大。但它的代价是强依赖系统时钟——时钟回拨会导致生成重复 ID,生产实现必须显式处理(拒绝发号、等待追平、或换机器 ID)。
UUID 的问题不在于唯一性,而在于随机性:随机主键让每次插入都可能落在 B+ 树中间,触发页分裂和随机 IO,写入性能显著下降。除非主键不参与聚簇索引,否则不要用随机 UUID 做主键。
与 JPA 的配合有个硬约束:@GeneratedValue(strategy = GenerationType.IDENTITY) 在分片下不可用,因为它依赖数据库自增。要改用应用侧生成(@Id 自己赋值)或 GenerationType.SEQUENCE 配分片感知的序列。
11.3.6 与 JPA 的摩擦
JPA 的实体映射建立在「一张表、一个主键、数据库自增」这些假设上,分片会同时打破好几条:
GenerationType.IDENTITY不可用,主键必须应用侧生成(见上)。- JPQL 不知道分片的存在。 JPA 生成的 SQL 由分片中间件改写,复杂查询可能改写失败或不支持。
@OneToMany/@ManyToOne的关联如果跨分片,无法直接映射——JPA 会当成普通外键 join,而分片下 join 不可行。- 派生查询不会强制带上分片键,写出的查询可能全片扫描,而且不报错、只是慢。
这些摩擦意味着:分库分表不是「加一个中间件就完事」,它会渗透到实体设计和查询写法里。
11.3.7 ShardingSphere-JDBC 的定位与代价
分片中间件主流分两类:JDBC 层(应用内代理,如 ShardingSphere-JDBC)和 Proxy 层(独立服务,如 ShardingSphere-Proxy)。这里只谈 JDBC 层,因为它在 Spring Boot 应用里最常见。
ShardingSphere-JDBC 的定位是一个增强的 JDBC 驱动:应用照常使用标准 JDBC / JPA,它拦截 SQL、按配置改写并路由到分片。对应用代码基本透明,这是它最大的卖点。
# 示例配置(ShardingSphere-JDBC 5.x 风格,仅示意定位,未在本机运行)
spring:
shardingsphere:
datasource:
names: ds0,ds1
ds0:
driver-class-name: org.postgresql.Driver
jdbc-url: jdbc:postgresql://db0:5432/bookloan
ds1:
driver-class-name: org.postgresql.Driver
jdbc-url: jdbc:postgresql://db1:5432/bookloan
rules:
sharding:
tables:
loan:
actual-data-nodes: ds$->{0..1}.loan_$->{0..1}
table-strategy:
standard:
sharding-column: member_id
sharding-algorithm-name: loan-inline
但它用**「SQL 改写的复杂度」换「应用透明」**,代价体现在:
- SQL 兼容性有边界。 复杂子查询、部分窗口函数、
union、某些方言写法可能不支持或被拒绝。 - 行为要重新验证。 分页、聚合、
order by的结果语义在分片下和单库不同,原来通过的单测不能直接复用。 - 跨片查询性能不可控。 中间件能执行广播查询,但慢不慢取决于片数和数据量,应用层感知不到。
- 配置即契约。 分片算法、数据节点命名写进配置,改分片规则要动配置并可能伴随数据迁移。
先确认它不支持你的哪些 SQL,再决定是否引入——这比先引入再发现某条关键查询改写失败要省事得多。
11.3.8 迁移的代价:双写与校验
即使前面所有判断都指向「该分」,真正动手时的最大工程量也不在分片本身,而在把存量数据搬过去、且业务不停机。常见做法是双写迁移:
- 双写:应用同时写老库和新分片,以老库为准。
- 回填:把存量数据按分片规则迁移到新分片,边迁边补差异。
- 校验:比对老库与新分片的数据,确认一致。
- 切读:读流量逐步切到新分片,观察。
- 切写:确认无误后,写也切到新分片,老库降级为备份。
每一步都可能出问题:双写期间两边不一致怎么办、回填期间新写入的数据怎么补、校验发现差异怎么定位。迁移方案的工作量通常比「配置分片规则」大一个数量级,这也是分库分表最难回退的原因——切到一半出问题,回滚意味着要把已经写进新分片的数据再搬回去。
因此有一条务实的建议:迁移期间保留老库、以老库为准,直到新分片完全可信再切换。不要一上来就单向写入新分片,那等于放弃了回退路径。
11.3.9 边界声明:本节不展开完整分片实现
必须明确本文的边界:本节只讲「要不要分」的判断和「要分前必须想清的问题」,不给出完整的分片实现。 一套真正可上线的分库分表方案,至少还包含:
- 分片规则与路由算法的完整设计;
- 存量数据的迁移方案(停机迁移还是双写迁移);
- 迁移期间的双写一致性与校验;
- 扩容时的数据再平衡(rebalance);
- 跨片事务的处理(第 12 章会触及);
- 回滚预案。
这些每一项都是一个独立工程,草草写成半成品方案只会误导。本节的目的是让你判断「现在该不该做」,以及「如果要做,需要先准备什么」。 如果读完这一节你的结论是「我们还没到这一步」,那正是它想达到的效果。
11.3.10 常见坑
坑一:一上来就分库分表。 加索引、分区、归档就能解决大部分「单表太大」,这些都不改应用代码,风险远低于分片。
坑二:分片键选错。 低基数(如 status)、分布不均、查询不带——任何一个都会让分片退化成全片扫描或热点。
坑三:忽略跨片查询与深度分页。 offset 分页在分片下代价爆炸,跨片 join 基本不可行,这些必须在选分片键前评估。
坑四:用自增 ID 或随机 UUID 做主键。 自增 ID 制造写热点且不再唯一;随机 UUID 导致 B+ 树页分裂。
坑五:以为 ShardingSphere 透明解决一切。 复杂 SQL 可能改写失败,分页/聚合行为要重新验证。
坑六:没有扩容与回滚方案。 分片数一旦需要变化,数据再平衡和回滚预案缺一不可,否则分片就成了单行道。
坑七:跨片事务被忽略。 原来一个本地事务可能变成跨两片,@Transactional 不再保证原子性,这是从分片方案进入分布式事务的入口。
小结
- 分库分表是最后一张牌,先穷尽加索引、表分区、归档、缓存、连接池、读写分离这些成本更低的手段。
- 真正的触发条件是写入吞吐打满或单库存储见顶;单表行数多不等于需要分,分区加归档往往就够。
- 分片键要满足高基数、分布均匀、查询常带三条;选错会让查询退化成全片扫描,热点键值要单独处理,不要用自增 ID。
- 分片算法里取模最怕扩容,一致性哈希或预分片能降低迁移代价,扩容路径要在设计阶段想好。
- 跨片查询是广播加归并,深度
offset分页代价爆炸,跨片 join 基本不可行。 - 分布式主键可选雪花、号段、UUID;雪花趋势递增但对时钟敏感,随机 UUID 伤写入性能。
- JPA 与分片有多处摩擦:
IDENTITY不可用、JPQL 不知分片、跨片关联无法映射。 - ShardingSphere-JDBC 用 SQL 改写的复杂度换应用透明,引入前先确认关键 SQL 的兼容性。
- 迁移(双写、回填、校验、切流)的工程量远大于配置分片规则,且切一半难回滚,务必保留老库作为退路。
- 本节刻意不展开完整分片实现;它的目标是判断「该不该做」和「做之前准备什么」。
数据库工程这一章到这里结束:11.1 稳住连接、11.2 下放读流量、11.3 划清分片的边界。下一章进入事务与锁——12.1 先讲清楚 @Transactional 的边界到底该画在哪。
阅读导航:上一节:11.2 读写分离 · 下一节:12.1 事务边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。