分库分表这四个字,听起来挺吓人,实际上就是把原本存在一张表里的数据,按照某种规则拆到多个库、多张表里去。这几年我接手过的项目里,因为数据量暴涨、单表撑不住而被迫搞分库分表的,不在少数。用 MySQL 做底层存储,再用 ShardingSphere 做中间层来承接分片逻辑,是目前 Java 技术栈里比较省心的一套组合。这篇内容就是基于 MySQL + ShardingSphere 的分库分表落地记录,把核心原理、配置细节、实操步骤和踩过的坑一次讲清楚,给打算搞或者正在搞分库分表的同学做个参考。
1. 分库分表到底解决什么问题:单库单表的容量天花板
1.1 单表为什么扛不住:从 B+ 树到锁竞争
先聊一个最基础的问题:为什么一张表的数据量大了之后,读写会变慢?
MySQL 的 InnoDB 引擎用的是 B+ 树索引结构,数据量越大,B+ 树的层数就越高。三层 B+ 树能存储的记录数,大概也就是百万到千万级别,因为普通非叶子节点的索引项占用空间有限。一旦数据量涨到几千万甚至上亿,索引深度增加,每次查询要走更多磁盘 IO,性能自然往下掉。
这还只是单次查询的情况。更麻烦的是写入竞争。一张表同一时刻只能有一个写锁(或者行锁但范围也会扩大),当业务流量大起来,insert、update 在这些行锁上排队,延迟就会飙升。再加上 binlog 同步、主从复制延迟、备份恢复时间变长等一系列连锁反应,单库单表的架构很快就到了瓶颈。
很多人会把分区表和分库分表搞混。分区表解决的也是大表问题,但它还是在同一个 MySQL 实例里,只是把数据按照一定规则放在不同的物理分区文件中。分区的本质依然受限于单机 CPU、内存、磁盘 IO 的上限,而且分区键的选择受限、全局唯一索引也不太好处理。分库分表不一样,它是把数据物理地分散到多个库、多个表甚至多台服务器上,从根本上分摊压力。
1.2 分库分表带来哪些能力,又引入哪些问题
分库分表的核心收益有三点:第一,单表数据量可控,索引层级不再失控,查询性能稳定;第二,写入被分散到多张表、多个库,锁竞争大幅降低;第三,资源可以横向扩展,扛不住的时候加机器就行,而不是把单机配置拉到顶。
但收益和代价是并存的。分库分表之后,原本一条 SQL 就能完成的事情,现在可能要被拆成多条 SQL 再合并结果。跨库 JOIN 基本废了,分布式事务成本变高,分页排序要做二次聚合,全局主键不能再用数据库自增,这些全是新的麻烦。
所以我一直有个观点:分库分表不是银弹,它是在单表确实扛不住的情况下才做的技术决策。如果你的数据量还在百万级别,索引建得合理、慢查询都优化过,那老老实实先把单表优化做好,比盲目上分库分表要靠谱得多。
判断是否要分库分表的几个信号,我这里整理一下:
- 单表行数超过两千万,且查询性能明显劣化。
- 写入并发高,行锁竞争导致业务超时。
- 数据增长趋势明确,未来一年内可能翻倍。
- 单实例的磁盘、CPU、内存已经出现周期性高峰。
如果命中两条以上,就可以认真考虑分库分表了。如果只是偶尔一条 SQL 慢,那大概率是索引问题,别把分库分表当退路,该优化的先优化。
2. ShardingSphere 核心概念与选型思路
2.1 两种形态:ShardingSphere-JDBC 和 ShardingSphere-Proxy 怎么选
ShardingSphere 是 Apache 下的一个开源生态,目前最常用的组件是 ShardingSphere-JDBC 和 ShardingSphere-Proxy。
ShardingSphere-JDBC 是一个轻量级 Java 框架,以 jar 包的形式存在,直接在应用层完成分片路由、SQL 改写、结果归并,应用通过 DataSource 连接就可以使用,代码里基本无感知。因为它在应用进程内执行,所以性能损耗比较小,部署也简单,业务方只需要引入依赖、配置规则、重启应用就能生效。
ShardingSphere-Proxy 则是一个独立部署的代理服务,对应用来说就是一个普通的 MySQL 数据库。应用不需要改造,连上代理就行。Proxy 的优点是支持异构语言、隔离性强、 DBA 可以直接用 SQL 操作,适合团队里有多个语言栈、不方便改应用的场景。缺点是多了一层网络转发,性能有一些损耗,而且需要额外部署和维护一套服务。
我个人的经验是:如果是 Java 技术栈统一的新项目,优先选 ShardingSphere-JDBC,因为它跟 Spring Boot 集成很顺滑,架构简单直接,排查问题也容易。如果你们是多语言混用,或者 DBA 想直接上手管理分片,那考虑 Proxy,但要对性能损耗有心理准备。
两种形态的对比,列个表格更直观:
| 对比项 | ShardingSphere-JDBC | ShardingSphere-Proxy |
|---|---|---|
| 部署方式 | 应用内集成 jar | 独立部署代理服务 |
| 应用改造 | 需要引入依赖、改数据源配置 | 客户端零改造,当 MySQL 直连 |
| 性能损耗 | 较低(进程内计算) | 较高(多一层网络转发) |
| 支持语言 | Java | 任意 MySQL 客户端 |
| 运维复杂度 | 低,随应用发布 | 中,需要独立运维 |
| 适合场景 | 新项目、Java 统一技术栈 | 存量系统改造、多语言混用 |
2.2 分片设计:逻辑表、数据节点、分片键和分片算法
理解 ShardingSphere,必须先理解几个核心概念。
第一个是逻辑表。比如你定义了t_order作为逻辑表,它在真实库里并不存在,实际存在的是t_order_0、t_order_1、t_order_2、t_order_3这些物理表。业务 SQL 写的是逻辑表名,ShardingSphere 在运行时把 SQL 改写成真实表的 SQL,这个过程叫 SQL 改写。
第二个是数据节点。数据节点是逻辑表对应真实物理表的位置,用表达式描述,比如ds$->{0..1}.t_order_$->{0..3},表示分布在ds0、ds1两个库上,每个库各 4 张分片表,总共 8 张表。这个写法是 ShardingSphere 特有的$->{...}行表达式,初始化时会自动展开成实际节点列表。
第三个是分片键。分片键用来决定一条记录最终落到哪个物理表,比如订单表用order_id做分片键,用户表用user_id做分片键。分片键的选择极其重要,它直接决定了查询能否精确定位到唯一分片。选分片键的原则是:主查询条件里的高频字段,而且要保证数据分布均匀。
第四个是分片算法。最常见的分片算法有三种:
- 取模法:
order_id % 4,简单粗暴,但数据规模增大需要扩容时,取模的基数一变,大量数据要迁移。 - 哈希取模:先把分片键做哈希,再取模,可以让字符串类型的分片键分布更均匀。
- 范围分片:比如按时间段、按区域分片,适合有明显分区维度的数据,但对访问热点不敏感的数据容易歪斜。
选哪种算法,取决于业务模型。订单表、流水表这类数据增长快、又常按主键查询的,用取模或哈希取模很顺手。时间维度明显的日志数据,用范围分片更自然。
另外还有两个容易被忽略的概念:绑定表和广播表。绑定表是指分片规则一致的一组表,比如t_order和t_order_detail都按order_id分片,关联查询时 ShardingSphere 会把两个表的路由结果绑定到同一分片,避免笛卡尔积式的跨库 JOIN。广播表则是所有库都保有一份相同数据的表,比如配置表、字典表,适合这种全量数据需要随时 JOIN 的场景。
3. 实操:基于 ShardingSphere 的分库分表落地
3.1 环境准备:版本匹配是第一道坎
先说环境选择。我这次用的是 MySQL 8.0.28、JDK 8、Spring Boot 2.7.x、ShardingSphere-JDBC 5.1.2。这套组合在项目里跑得很稳。如果你用的是 Spring Boot 3.x,需要对应选择 ShardingSphere 5.3+ 版本,因为 Spring Boot 3 基于 Jakarta EE,老的 starter 可能会不兼容。
Maven 依赖长这样:
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.1.2</version> </dependency>引入依赖之后,还需要注意 JDBC 驱动。MySQL 8 对应的驱动类名已经变成了com.mysql.cj.jdbc.Driver,不再是老的com.mysql.jdbc.Driver。如果是从 MySQL 5.x 迁移过来的项目,这个坑很容易踩到,启动的时候直接报无法加载驱动类。
连接 URL 这里有个细节。MySQL 8 默认开启了 SSL 认证,如果你本地的 MySQL 服务器没配 SSL 证书,连接时会报类似SSL connection error的错误。在开发环境里,我在 JDBC URL 上追加了关闭 SSL 的参数来解决:
jdbc:mysql://localhost:3306/db0?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8注意,生产环境建议还是把 SSL 打开,别为了省事丢掉安全。开发环境关掉只是图个方便。
3.2 一份能跑起来的分库分表配置实例
这里结合我实际的项目场景:订单系统,单表数据增长太快,需要把订单表拆成 2 个库,每个库 4 张表,总共 8 张物理表。
YAML 配置如下:
spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/ds0?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/ds1?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 rules: sharding: tables: t_order: actual-data-nodes: ds$->{0..1}.t_order_$->{0..3} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: order_inline key-generate-strategy: column: order_id key-generator-name: order_snowflake sharding-algorithms: order_inline: type: INLINE props: algorithm-expression: t_order_$->{order_id % 4} key-generators: order_snowflake: type: SNOWFLAKE props: sql-show: true这段配置拆开来看:
actual-data-nodes定义了物理表的位置,ds$->{0..1}展开为ds0、ds1,t_order_$->{0..3}展开为t_order_0到t_order_3。table-strategy指定了分片键是order_id,分片算法名称指向order_inline。sharding-algorithms里定义的是 INLINE 算法,表达式为t_order_$->{order_id % 4}。这里有个容易踩的坑,如果我对两个库各 4 张表做表级分片,那么每个库内部都是order_id % 4。但如果库级也做了分片,表达式还会更复杂,后面细说。key-generate-strategy配置了雪花算法生成分布式主键。因为订单表的主键不能再用 MySQL 自增,分库分表后各分片表自增 ID 会重复。ShardingSphere 的 SNOWFLAKE 会生成全局唯一主键。
库级分片和表级分片要区分清楚。我这里的配置只在表级别做了分片,两个库只是水平复制关系,所有数据按order_id % 4散落到每个库的 4 张表里。也就是说,ds0.t_order_0和ds1.t_order_0里都有数据,查询时路由结果分别落到两个库。如果你设计的场景是“库分片 + 表分片”两层,那actual-data-nodes和库级分片策略都要配套定义,配置复杂度会翻倍。
3.3 分片键和分片算法的选择,到底该怎么算清楚
分片算法不是随便写的,选错了后面扩容、查询全是坑。拿取模法来说,核心参数就是取模的基数,也就是分片总数。
我在上面示例中使用的是order_id % 4,意思是按订单 ID 的末两位落到 4 张表里。为什么是 4 而不是 8 或者 16?因为当前数据量评估后,每张表未来一年的数据量大约是 500 万行,这个体量对 MySQL 单表来说是舒服的区间。于是分片总数 = 预估总数据量 / 单表目标容量 = 2000 万 / 500 万 = 4 个分片。
这里要注意一个很经典的细节:取模分片的下标是从 0 开始的,order_id % 4的取值范围是 0、1、2、3,对应t_order_0到t_order_3,所以分片总数和物理表数量必须严格对应。
INLINE 表达式看似简单,但数据类型和写法有个隐藏问题。如果分片键是字符串类型,直接用%可能报错。比如用手机号分片,不要直接写mobile % 4,而是先取哈希,再取模。ShardingSphere 里的 HASH_MOD 算法就是干这个的:
sharding-algorithms: user_inline: type: HASH_MOD props: sharding-count: 4HASH_MOD会把分片键先用 MD5 或一致性哈希处理,再对sharding-count取模,字符串类型的分布效果要好很多。
除了分片算法,分片键还有一个容易忽略的点:业务 SQL 如果不带分片键,ShardingSphere 会把这条 SQL 广播到所有数据节点去执行。听起来好像也能查到,但数据量一大,全表扫描的成本直接爆炸。所以在设计表结构时,就要把业务查询路径理清楚,让核心查询都强制带上分片键。比如订单表按order_id分片,那按用户查订单这种需求,就一定要配套维护一个“用户-订单映射表”,或者用 ES 这类搜索引擎来承接。
3.4 读写分离、分布式 ID 和分布式事务的配套方案
分库分表上线一段时间后,读压力也会跟着上来,这时候通常还要配上读写分离。ShardingSphere 的读写分离配置和分库分表规则是并列的,定义在主库和从库的数据源之上,指定负载均衡策略即可。
一个简化配置的思路如下:在datasource里配置write-ds和read-ds两组数据源,然后在rules下新增readwrite-splitting规则,把write-ds作为主库,read-ds作为从库,查询请求默认走从库。这个机制能有效分担查询压力,但要注意主从同步延迟问题,刚写入的数据如果立刻查,可能查不到。关键业务场景可以通过hint强制路由主库。
分布式 ID 方面,ShardingSphere 内置了 SNOWFLAKE 算法生成分布式主键。雪花算法的好处是全局唯一、趋势递增、性能高,生成的 ID 是 Long 类型。但有个使用细节:雪花算法生成的 ID 末尾有 4 个连续数字位,MySQL 的 BIGINT 可以承载,但如果你用 JavaScript 处理这类 ID,因为 JS 的 Number 精度只有 53 位,会丢失精度,所以前后端交互要用字符串方式传递 ID。
分布式事务是另一个大话题。ShardingSphere 支持三种事务模式:
- Local Transaction:默认模式,只保证单个分片内的局部事务,跨分片不保证原子性。
- XA 事务:基于两阶段提交协议,适合跨分片强一致性要求高的场景,缺点是性能损耗明显,事务时间长。
- BASE 事务:集成 Seata,适合追求最终一致性的长事务场景,对业务侵入小,吞吐量更好。
我的建议是:核心账务类操作用 XA,业务链路过长、有消息补偿机制的用 Seata。不要把分布式事务当万能,尽量通过设计避免分布式事务,比如把同一用户的多笔操作路由到同一个分片上,这样大部分写操作其实都是本地事务,根本不需要跨分片协调。
4. 常见问题与排查技巧实录
4.1 启动报错、驱动不兼容和 SSL 连接的坑
先列我踩过的几个启动期问题。
第一个是Cannot load driver class: com.mysql.jdbc.Driver。原因就是 MySQL 8 的驱动类名变了,ShardingSphere 配置里的driver-class-name必须写com.mysql.cj.jdbc.Driver。这一点看似简单,但项目里如果多个数据源混用,很容易漏改。
第二个是 SSL 连接错误。MySQL 8 默认开了 SSL,如果你没有为服务器配置证书,连接时报错信息会很长,核心是SSL connection error或者Public Key Retrieval is not allowed。MySQL 8 的 caching_sha2_password 认证插件下,还会出现Public Key Retrieval is not allowed的报错,这也是很多人拿到新 MySQL 8 就连接报错的原因。解决方法是在 JDBC URL 加allowPublicKeyRetrieval=true。安全起见,开发环境可以useSSL=false,测试和生产还是建议正常配置 SSL。
第三个是行表达式解析出错。配置里写了ds$->{0..1},但加载时报表达式格式错误。这种问题多半是缩进或引号问题,YAML 里$->{...}的表达式不要加多余的引号,写完配置后先确认表达式能独立解析再启动。
4.2 不带分片键查询导致的全路由风暴
这是运行时最高危的问题。某个查询条件没有包含分片键,ShardingSphere 只能把 SQL 发给所有分片去执行,然后归并结果。在分片少的时候问题不大,一旦分片数到了 8 个、16 个,一个不带分片键的查询就是一次小型全库扫描,而且全部串行或并行执行,数据库压力瞬间飙升。
我遇到过一例:运营后台按订单状态查询订单列表,条件里只传了status,没传order_id,结果每次执行都走全路由,直接把从库打到了 100% CPU。排查方法很简单,开启sql-show: true,在日志里看 SQL 的路由结果,发现每条查询都路由到了全部 8 张表。
解决办法有几个:
- 业务查询必须限定允许不带分片键的场景,后台低频率查询可以接受,但高频接口绝对不行。
- 为高频的按用户查询场景,建立“用户-订单映射表”,先把分片键查出来,再带分片键去查订单表。
- 使用绑定表或广播表减轻 JOIN 场景下的全局路由压力。
另外还可以用 ShardingSphere 的hint强制指定分片,这种手动控制路由的方式在特殊业务里很管用。
4.3 分页排序聚合查询的坑
分页问题在分库分表之后非常典型。假设执行SELECT * FROM t_order ORDER BY create_time LIMIT 100000, 10,ShardingSphere 的逻辑是:每个分片都取前 100010 条,然后在内存里归并排序,再取最终的 10 条。分片越多、偏移量越大,内存和 CPU 的浪费成倍增长。
我踩过最深的一次:运营后台翻页到第 5000 页,接口直接超时,数据库内存撑到接近上限。
应对策略有几个:
- 限制翻页深度,加上截止条件,比如只能查最近 30 天、只能翻前 200 页。
- 使用“延迟关联”技巧:先查询出主键 ID,再用主键回表查询详细信息,减少传输量。
- 如果业务有实时翻页需求,建议在上层引入搜索引擎或者做宽表冗余,不要用 MySQL 分库分表去硬扛深度分页。
聚合查询也有类似的问题。COUNT(*)、SUM()这类操作会在每个分片执行一次,然后归并汇总。如果分片键字段本身和聚合维度不一致,结果可能不准。所以设计分片键时,也要把你最核心的聚合统计维度考虑进去,尽量让高频统计能在单个分片内完成。
4.4 扩容和数据迁移:为什么说配分片方案要想清楚后路
最后聊一下扩容。这是分库分表最难受的一个环节。
如果你用的是取模算法,分片总数从 4 扩到 8,所有数据的分布位置基本全部变化,比如原来order_id % 4落在t_order_1的记录,现在对 8 取模可能落在t_order_1、t_order_5等不同的表里。这意味着大量数据需要迁移,而且迁移过程中还要保证新旧数据保持一致。
可选的迁移方案有几种:
- 停机迁移:维护时间段内停服,把数据按新规则重新灌入,简单但影响业务。
- 双写迁移:新旧两套库同时写,迁移历史数据完成后切读流量,过程冗长但不停服。
- 工具迁移:用 Canal 监听 binlog,把增量数据同步到新库,历史数据用批量任务搬完后再校验。
不管用哪种方案,提前规划分片策略都特别重要。如果业务还处于早期,数据量增长可控,我建议直接用一致性哈希思路,或者至少预留分片数的倍数关系(比如从 4 扩到 8 时,用 4 的倍数扩展),这样老的 4 张表映射到新的 8 张表时可以保持一类映射规律,迁移成本会小很多。
根据我个人的实际感受,分库分表不是一次性的改造,而是一个需要持续打磨的技术底座。最早可以先小规模试点,比如只拆两个库,每库拆两张表,把分片键、分布式 ID、事务边界、SQL 规范全跑通,积累几个月的真实数据和日志,再决定要不要扩大分片规模。
我在实际项目里最深的体会是:分库分表最大的难点从来不是 ShardingSphere 的配置怎么写,而是业务上的分片键设计是否合理。分片键一旦定下来,后面对查询路径、扩容策略、数据迁移的影响是深远的。另外,sql-show这个配置建议一直开着,至少在测试阶段千万不要关,它能直观看到每条 SQL 被路由到哪些分片,排查全路由、笛卡尔积关联、分布式聚合问题都靠它。
最后再分享一个小技巧:如果你刚接手一个没做过数据分层的项目,又评估下来确实需要分库分表,别一上来就追求 16 分片、64 分片这种大规格。先把分片总数设成 4 或者 8,物理机不够就用同一实例多个库先顶着,把链路、监控、告警、数据校验脚本全部准备好,等到业务量真的逼近了,再平滑扩容。能稳定跑起来的方案,比看起来酷炫的方案要值钱得多。