《Spring Boot 实战》11.3 分库分表的接入边界

分库分表是数据库工程的最后一张牌,多数团队还没到需要它的那一步。本节给出接入边界:先分区、归档、加索引的判断依据,分片键选择与热点、跨片查询与深度分页的代价、分布式主键方案,以及 ShardingSphere-JDBC 与 JPA 的摩擦,并明确本文不展开完整分片实现。

本节目标:建立「什么时候才该分库分表」的判断力,掌握分片键、跨片查询、分布式主键、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)决定一行数据落在哪个分片。它是整个方案里最重要、也最难改的决定——分片键一旦定了,后续所有查询要么带上它高效路由,要么跨全片扫描。

选择分片键的三条要求:

  1. 高基数:取值足够多,能均匀铺开。用 status(只有 BORROWED / RETURNED 几个值)做分片键是灾难,所有数据挤在少数片。
  2. 分布均匀:避免绝大多数行落在同一个值上。
  3. 查询常带:最频繁的查询条件应该包含分片键,否则每次都是全片扫描。

对 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 迁移的代价:双写与校验

即使前面所有判断都指向「该分」,真正动手时的最大工程量也不在分片本身,而在把存量数据搬过去、且业务不停机。常见做法是双写迁移:

  1. 双写:应用同时写老库和新分片,以老库为准。
  2. 回填:把存量数据按分片规则迁移到新分片,边迁边补差异。
  3. 校验:比对老库与新分片的数据,确认一致。
  4. 切读:读流量逐步切到新分片,观察。
  5. 切写:确认无误后,写也切到新分片,老库降级为备份。

每一步都可能出问题:双写期间两边不一致怎么办、回填期间新写入的数据怎么补、校验发现差异怎么定位。迁移方案的工作量通常比「配置分片规则」大一个数量级,这也是分库分表最难回退的原因——切到一半出问题,回滚意味着要把已经写进新分片的数据再搬回去。

因此有一条务实的建议:迁移期间保留老库、以老库为准,直到新分片完全可信再切换。不要一上来就单向写入新分片,那等于放弃了回退路径。

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 事务边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计