开头可以直接从问题切入。很多团队把分库分表当成“终极大招”,以为上了 ShardingSphere 就能解决所有性能问题,但实际上,分库分表是一个一旦做了就很难回头的架构决策。MySQL 在单库单表数据量达到千万级、亿级之后,索引维护成本、写入锁竞争、备份恢复时间都会显著恶化,这时候分库分表确实是绕不开的路。但问题在于,很多人还没想清楚“为什么要分”“怎么分”“分完以后怎么收场”,就匆匆引入了 ShardingSphere,最后不是被分布式事务坑,就是被跨库查询折磨。
这篇文章我会基于 ShardingSphere + MySQL 这套组合,把从决策、选型、配置到上线后排查的完整链路讲清楚。适合三类人看:一是业务数据量已经上来、正在评估是否需要分库分表的后端开发;二是已经决定接入 ShardingSphere 但不太清楚配置细节的工程团队;三是想了解分库分表落地后有哪些坑的架构师和 DBA。
1. 先搞清楚一件事:你的数据真的需要分库分表吗
分库分表不是性能优化的第一步,而是很多手段都用尽之后才考虑的路。我在现实里见过太多团队,订单表才几百万行,就急着拆库拆表,结果系统复杂度直线上升,业务迭代效率被拖垮。所以这一章先泼冷水,帮大家理清决策逻辑。
1.1 单库单表的真实瓶颈在哪里
MySQL 的 InnoDB 引擎使用 B+ 树作为索引结构,单表数据量涨到一定程度后,主要瓶颈并不是“查询变慢”这么简单。先说几个被低估的点:
- 索引层级加深:B+ 树索引在数据量小的时候可能只有 2~3 层,量级到了千万甚至上亿后,层数增加,每次索引查找的随机 IO 次数变多,延迟不再是毫秒级,而是上升一个量级。
- 写入竞争加剧:特别是使用自增主键和频繁更新时,热点页争抢会严重影响并发性能。一个表的写入瓶颈远早于查询瓶颈出现。
- 备份和恢复的窗口变长:单表 100GB 和单表 10GB 的物理备份时间完全不是一个概念。即使业务能忍,DBA 也忍不了每周备份超过两个小时。
- Blob/Text 大字段的拖累:如果有大字段,数据页的利用率会下降,行溢出还会带来额外的 IO,全表扫描会成为灾难。
一般来说,当单表行数超过 2000 万行(InnoDB 官方建议附近)或者单表物理文件超过 50GB 时,就该认真评估分库分表了,但重点不是这个绝对数字,而是数据库的写入能力和核心查询延迟是否已经明显劣化。
1.2 先做这些事,能拖延就拖延
很多人一查慢 SQL 就归咎于“数据量太大”,实际上,有三种情况远比分库分表优先级高:
- 索引设计问题。我见过一张 500 万行的订单表,查询条件里有 user_id 但索引建的是 order_no,导致每次用户查订单都全表扫描。这种情况回到一条
ALTER TABLE ADD INDEX就能解决。 - 未使用覆盖索引。大量
SELECT *导致回表成本高,把高频查询的字段组合成覆盖索引,性能提升非常明显。 - 架构层面的读写分离。把读流量从主库拆走,往往能解决 80% 的“数据库扛不住”问题。
按我的经验,MySQL 单库在合理索引和读写分离的前提下,撑到日均千万级读写是可行的。分库分表应该是最后手段,而不是最先想到的方案。
1.3 什么时候才必须分:对四个典型信号的判断
如果你遇到了下面四个信号中的两三个,那就不要犹豫,分库分表值得投入:
- 单表数据量已达到亿级,并且持续上涨,即便做了分区(Partition)和归档,核心热表仍在膨胀。
- 主库写入成为明确瓶颈,读写分离和硬件升级(如换 SSD、加内存)都解决不了。
- 单个数据库实例的连接数被打满,应用侧连接池一再调大也无济于事,或者主从延迟已经无法控制。
- 业务有明确的多租户或区域性隔离需求,比如按用户维度天然可以水平拆分。
判断逻辑很简单:如果瓶颈来自单表索引深度、单实例并发上限、备份窗口,而不是慢 SQL 和索引问题,那就果断走分库分表。
2. ShardingSphere 核心概念:先把这几个术语吃透再去碰配置
ShardingSphere 是目前国内使用最广泛的分库分表中间件,Apache 顶级项目。它有两个大方向:ShardingSphere-JDBC 和 ShardingSphere-Proxy。很多人一上来就看配置,结果连“逻辑表”和“实际表”都没分清,自然配置得一团糟。
2.1 两种接入模式:JDBC 和 Proxy,到底选哪个
这两种模式差别相当大,直接决定你的部署架构和应用改造成本。
| 对比维度 | ShardingSphere-JDBC | ShardingSphere-Proxy |
|---|---|---|
| 部署方式 | 以 jar 包形式集成在应用内,作为增强版 JDBC 驱动 | 独立部署的数据库代理服务,应用直接连接代理 |
| 支持的语言 | Java 应用原生支持 | 任何支持 MySQL/SQL 协议的客户端 |
| 网络开销 | 无中间层,应用直连数据库 | 多一跳,增加少量网络延迟 |
| 运维复杂度 | 低,跟随应用发布 | 高,需要单独维护代理集群 |
| 适合场景 | Java 技术栈为主、追求性能和低延迟 | 异构语言、不想改应用的团队,或者需要综合管理多个数据库 |
我在实际项目中,绝大多数 Java 团队都会优先选择 ShardingSphere-JDBC,因为它不走额外网络层,性能和稳定性都更好把控。但如果你是 PHP、Go 或者其他语言团队,不想大规模改代码,那就老实选 Proxy。不过要提醒一句:Proxy 本身是有状态的服务,连接数、内存消耗都要额外规划,别把它当成无状态网关来看待。
2.2 逻辑表、实际表、分片键:配置文件的四个核心元素
- 逻辑表
/逻辑库:你在 SQL 里写的表名(例如order),ShardingSphere 会根据配置把它路由到真实表。配置里逻辑表名就是你业务代码中的名字,业务层几乎不用改 SQL。 - 实际表
/物理表:数据库中真实存在的表,例如order_0、order_1、order_2。 - 分片键:决定一条数据落到哪张表的字段,通常是订单号、用户 ID 等分布均匀的字段。
- 分片算法:根据分片键计算出目标表 / 目标库的规则。
举一个简单的例子,如果业务代码里写的是INSERT INTO order (user_id, order_no, amount) VALUES (?),那么 ShardingSphere 拿到这条 SQL 后,会根据user_id字段做哈希取模,决定把它路由到order_0还是order_1等物理表。
2.3 分片算法选型:哈希取模、范围分片还是按时间分片
这一步选错,后面会非常痛苦。我给的参考逻辑其实很朴素:
- 按键取模(Mod):例如
user_id % 16,数据分布最均匀,适合大多数核心交易场景。但只要分片数量一变化(扩容),数据迁移几乎是强制性的。 - 哈希取模 + 一致性哈希:桶数固定后变动代价低,适合对扩容要求高的场景,但一致性哈希在数据分布均匀性上不如简单取模,而且实现复杂度高。
- 范围分片(Range):比如按
order_id范围切分,查询条件天生带边界,适合日志类或流水类数据,但是容易产生热点分片——最新写入的都是最后一段,老分片又闲置。 - 按时间分片(Interpolation):本质上是范围分片的一种,非常合适流水型业务(如交易流水、日志),配合定期归档效果很好。
经验法则是:核心在线交易表,优先按业务主维度做哈希取模;日志流水表,优先按时间分片;如果你知道未来扩容方向,从一开始就多预留分片数量,而不是先建 4 个分片、下个月再加到 16 个。
3. 基于 MySQL 的分库分表实战配置:从零跑通一个订单表
下面这部分我尽量按一个完整可运行的路径来写。我以 ShardingSphere-JDBC 5.x 版本为例,因为 4.x 和 5.x 的配置结构差异比较大,5.x 是当前主线版本,社区资料也最全。
3.1 环境准备:先准备好三个数据库实例
为了体现“分库分表”,我会准备两个数据库实例(或同一个 MySQL 实例下的两个库),命名为order_db_0和order_db_1。每个库内建 4 张订单表:t_order_0、t_order_1、t_order_2、t_order_3。这样组合起来就是 2 库 × 4 表 = 8 个分片。
建表 SQL 如下(我用的是 MySQL 8.0,引擎 InnoDB,字符集 utf8mb4):
CREATE DATABASE IF NOT EXISTS order_db_0 DEFAULT CHARACTER SET utf8mb4; CREATE DATABASE IF NOT EXISTS order_db_1 DEFAULT CHARACTER SET utf8mb4; -- 分别在两个库里执行 CREATE TABLE IF NOT EXISTS t_order_0 ( id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意一个细节:分片表如果同时含id和order_no两个唯一性字段,常规做法是把order_no也建为唯一索引,但在分布式分片环境下,这个唯一性约束的维护成本很高,而且一旦 ShardingSphere 按user_id路由,order_no这样的唯一索引只能保证片内唯一,无法保证全局唯一。这也就是为什么很多分库分表方案会把订单号设计成“全局唯一,但业务查询主要靠 user_id”的原因。
3.2 引入依赖并搭建 Spring Boot 工程
如果你在 Spring Boot 中使用 ShardingSphere-JDBC,核心依赖只需要一个:
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.4.1</version> </dependency>我建议用 5.3.x 或 5.4.x 这种比较稳定的版本,5.5 之后部分 API 有调整,社区资料匹配度需要自己先确认一下。另外提醒一点,ShardingSphere 对数据源连接池并没有绑定要求,HikariCP、Druid 都可以,但不要把 ShardingSphere 和连接池的配置搞混,它们各管一层。
3.3 核心 YAML 配置:分库分表规则的完整拆解
ShardingSphere 5.x 的配置可以用 YAML,也可以用 Java API,还可以把配置放到 nacos 这类配置中心。对大多数项目,YAML 是最直观的。下面是我常用的最小可运行配置:
spring: shardingsphere: datasource: names: db0, db1 db0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db_0?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 db1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db_1?useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 rules: sharding: tables: t_order: actualDataNodes: db${0..1}.t_order_${0..3} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: db_mod tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: table_mod keyGenerateStrategy: column: id keyGeneratorName: snowflake shardingAlgorithms: db_mod: type: MOD props: sharding-count: 2 table_mod: type: MOD props: sharding-count: 4 keyGenerators: snowflake: type: SNOWFLAKE props: worker-id: 1 props: sql-show: true这里面有几个非常容易踩坑的地方:
actualDataNodes: db${0..1}.t_order_${0..3}是 ShardingSphere 的表达式语法,${0..1}表示 0 到 1 的整数序列,10 分片就是${0..9},不要写成${0-9}。- 分库的算法是
user_id % 2,分表算法是user_id % 4。由于库数 2、表数 4,同一个user_id会先路由到库,再路由到表,组合之后是 8 个物理分片。 keyGenerateStrategy不是必须的,但建议配置。如果让 ShardingSphere 生成雪花 ID 作为主键,你业务代码里的id就不用手动赋值。
配置完启动应用后,可以把sql-show打开,你会看到每个 SQL 的路由结果。这一步价值很大——如果路由结果不符合预期,先别怀疑代码,去看路由日志。
3.4 写一个标准的插入和查询示例,验证路由是否正确
先定义一个简单的 Mapper。这里不做 ORM 框架对比,直接用 MyBatis-Plus 风格的写法来演示:
@Mapper public interface OrderMapper { void insert(Order order); Order selectByUserIdAndId(@Param("userId") Long userId, @Param("id") Long id); }对应的 XML:
<insert id="insert"> INSERT INTO t_order (id, order_no, user_id, amount, status, create_time) VALUES (#{id}, #{orderNo}, #{userId}, #{amount}, #{status}, #{createTime}) </insert> <select id="selectByUserIdAndId" resultType="com.example.Order"> SELECT id, order_no, user_id, amount, status, create_time FROM t_order WHERE id = #{id} AND user_id = #{userId} </select>这里面的核心要点是:查询条件必须带分片键user_id。如果 SQL 里没有分片键,ShardingSphere 会做全库全表路由(广播查询),对 8 个分片同时发起 SQL,然后在内存里合并结果。这种查询在小数据量下不算问题,一旦单表行数很大,广播查询就是性能灾难。
带分片键的查询,比如WHERE user_id = 10086 AND id = 100,ShardingSphere 计算出user_id % 2 = 0,定位到 db0,再计算user_id % 4 = 2,定位到t_order_2,所以最终只执行一次 SQL。这也直接回答了很多人问的“分库分表之后慢查询怎么办”——尽量让绝大多数查询都命中分片键。
4. 分库分表后的四大难题:从分布式 ID 到扩容迁移
分库分表的门槛不在“分”,而在“分完之后的日常运维”。
4.1 分布式 ID 生成:为什么不能再用自增主键
单库单表时代,AUTO_INCREMENT是最省心的主键生成方式。一旦分表,多个分片各自生成自增 ID,很大概率会出现重复主键。即便你通过设置不同表的自增起始值(比如 table0 从 1 开始,table1 从 1000001 开始),将来扩容时还是会撞车。
常用的替代方案有四种:
- 雪花算法(Snowflake):ShardingSphere 内置支持,64 位 Long 型,由时间戳、工作机器 ID、序列号组成。优点是生成速度快、趋势递增、无中心化依赖;缺点是强依赖系统时钟,如果服务器时钟回拨,可能出现 ID 重复。ShardingSphere 官方对时钟回拨有容忍参数,但生产环境务必配置 NTP 并监控时钟漂移。
- Redis INCR/INCRBY:用 Redis 自增生成分布式 ID,实现简单,但 Redis 本身成为新的单点,且每生成一个 ID 都多一次网络调用。可以用批量获取的方式优化。
- 数据库号段模式:建一张
id_generator表,每次取一个号段(比如每次取 1000 个 ID)缓存到内存。性能好,ID 趋势递增,但没有雪花算法那么“无所不在”。 - UUID:唯一性没问题,但作为主键会导致 B+ 树索引页分裂严重,性能很差,不推荐用作数据库主键。
我个人的选择:如果是 Java 技术栈且数据量中等,直接用 ShardingSphere 内置的雪花算法;如果对 ID 的“单调趋势递增”有强要求(比如用于冷热归档判断),建议自研或引入号段模式。
4.2 分布式事务:XA、BASE、还是最终一致性
这是分库分表之后最容易引发“架构回退”的老大难。
- 大部分简单的跨分片写操作,可以通过 ShardingSphere 的分布式事务能力处理,ShardingSphere 支持 LOCAL、XA、BASE(Seata 集成)三种事务模式。
- XA 事务(Atomikos/Narayana)具备强一致性,但加锁范围大,性能代价高,适合并发量低但一致性要求极高的场景。
- Seata 的 AT 模式属于最终一致性方案,性能比 XA 好,适合绝大多数交易类业务,但需要额外部署 Seata Server。
- 最容易被忽视的其实是“把事务边界压缩在单分片内”。如果业务能按用户维度聚合操作(比如“一次操作只涉及同一个 user_id 的订单数据”),就可以确保 SQL 路由到同一个分片,从而继续使用本地事务。
以普通电商交易为例,创建订单和扣库存往往是两个服务、两个数据库。这类场景靠分布式事务来硬撑,不如把扣库存设计成异步消息 + 本地事务表,利用最终一致性保证。当你发现“所有操作都难以避免跨分片事务”时,就要反问自己的拆分维度是不是选错了。
4.3 跨分片 JOIN 和分页排序:不用怕,但要有方法
先说 JOIN。分库分表之后,JOIN 的代价极高,因为两张表如果分片键不一致,数据可能分散在不同库中,中间的 JOIN 就需要跨库拉取数据到内存中完成,这是典型的“不可控操作”。实践上一般有三种应对:
- 冗余字段:在订单表里冗余商品名、价格等字段,避免关联商品表。
- 宽表设计:通过消息队列异步构建聚合宽表,让查询直接落到单表。
- 应用层聚合:先用第一个查询拿到结果集,再逐个查询关联数据,最后在应用内存里组装。
再说分页。LIMIT 100000, 10这种常规写法在分库分表下会产生一个陷阱:每个分片都会先查 100000 条数据,然后 ShardingSphere 在内存里取总量前 100010 条,再丢弃前 100000 条。这意味着深分页时,每个分片都要传输大结果集,性能损耗成倍放大。
正确的思路是:
- 用“上一页最后一条记录的某个字段”做条件(游标分页),比如
WHERE id > ? ORDER BY id LIMIT 10,每个分片只需要返回大于 ID 的最小 10 条。 - 如果业务必须支持任意跳页,则把排序字段和查询字段都落到同一个索引中,尽量让 ShardingSphere 的归并器处理的过程简单一些。但归根结底,B2C 后台管理系统这类浅分页场景问题不大,用户体验页深分页基本无解,只能给“搜索条件收窄”的限制。
4.4 扩容与数据迁移:从一开始就要想好的事
水平扩容是分库分表里最痛苦的一件事。假设一开始是 2 库 4 表,业务涨到需要 4 库 8 表,那么已经写入的数据不可能原地不动,必须做数据迁移。
常见迁移方案有:
- 停机迁移:在低峰期停服,用离线工具(如 DataX)将老分片的数据按新规则路由到新分片上。数据量大时迁移时间长,停机窗口不可控。
- 双写 + 追平:在业务层把新的读写按新旧两套规则同时执行,成功写入新库后,再把历史数据做批量追平。这种方式不要求停机,但对业务的侵入性大。
- 采用“分片数预分裂”的取模策略:比如一开始库表总数为 16,只部署 4 个分片,其他 12 个分片预留但先不创建。扩容时只需把老数据按新规则搬到预置的分片即可。这种方法限制了前期成本,但给后期留了余地。
从架构终极解法看,如果业务增长极快且无法预估,可以考虑不依赖分片数的路由策略,比如一致性哈希,它允许最小化迁移数据量。但如前所述,一致性哈希在数据均匀性上不如取模,且 SQL 路由规则复杂度更高,需要工程能力更强的团队来驾驭。
5. 生产环境里的性能调优与踩坑记录:真实排障经验
这一章我写一些平时很难从官方文档直接看到的体会,都是一些内部复盘下来的教训。
5.1 连接数、连接池与 SQL 路由开销
分库分表之后,连接数不是乘以 1,而是乘以分片数。假设应用连接池设置 maxPoolSize 为 50,那么同一时间点理论上可能向 2 个库发起 100 条连接。连接池参数的评估必须把分片数算进去,否则很容易出现“应用这边连接池没满,但数据库 max_connections 被打满”的情况。
另一个容易忽略的点是:ShardingSphere-JDBC 是嵌入在应用进程里的,路由和归并运算都会消耗 JVM 堆内存。尤其是大型报表类的 SQL,如果路由到多个分片再在内存里排序,堆内存压力极大。所以重 SQL 应该尽量避免过 ShardingSphere,要么把报表需求挪到 ClickHouse 之类的大数据组件,要么在业务侧做定制化聚合。
5.2 表结构变更和索引调整:别指望一条 ALTER TABLE
单表时代,新增一个索引就是一条ALTER TABLE ADD INDEX的事,而且还能用ONLINE选项降低锁表时间。分库分表之后,同样的变更需要同时在多个分片库上执行。
我的建议是:把所有的 DDL 变更脚本做成模板,利用脚本循环在每个分片执行。要注意的是,执行 DDL 时一些分片可能处于高负载状态,建议用 gh-ost 或 pt-online-schema-change 这类工具逐台执行,避免对在线业务造成明显影响。还有一个细节要注意,ShardingSphere 的配置信息里如果有字段映射的调整,需要同步检查配置中心里的逻辑表定义,不然可能出现 SQL 和实际表结构不一致的报错。
5.3 常见报错与排障思路:从日志中快速定位分片问题
我整理了几个高频问题,基本覆盖了大部分人第一次接入 ShardingSphere 时会遇到的坑:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
SQL 路由报错Cannot find datasource in sharding rule | 配置里的数据源名称和actualDataNodes表达式不一致 | 检查${0..1}是否对应names列表里的 db0、db1 |
| 插入数据成功但查询查不到 | 分片键前后类型不一致(例如一个是 String,一个是 Long) | 检查 Java 字段类型和 SQL 参数类型,统一分片键类型 |
| 分页查询结果缺失或重复 | 多个分片数据合并后排序不稳定 | 确保 ORDER BY 字段唯一,或者在排序字段后追加主键 |
| 批量插入报错 | 批量 SQL 里各条数据的分片目标不同 | 将批量插入按分片键分组重写,或使用insert ... values拆批 |
| 事务不生效 | 跨分片操作仍在使用 Local 事务模式 | 需要显式开启 XA 或 Seata BASE 事务 |
另外,日志里的Actual SQL: db0 ::: select * from t_order_0 ...这类内容极其有用,不要只盯着异常栈,先看 ShardingSphere 打出的 Actual SQL 是否路由到了预想的分片。如果路由错误,问题和配置相关;如果路由正确但执行慢,再往 MySQL 执行计划方向查。
5.4 三个容易忽视的配置细节
- 下划线 vs 驼峰命名:ShardingSphere 对 SQL 解析是严格按字段名匹配的,如果你的实体类字段用的是驼峰,SQL 里写的是下划线,务必确保 MyBatis 的
map-underscore-to-camel-case配置生效,否则分片键提取不到会直接发起全路由。 - 默认
props里的sql-show在生产环境要关掉,因为每条 SQL 都会打印路由信息,高并发下日志量非常可怕。 - ShardingSphere 5.x 默认使用
logic database概念,如果项目里用了多数据源框架(如 dynamic-datasource),两套机制会冲突,需要先想清楚哪一层负责路由。
写在最后:一个经历多次分库分表迁移后的体会
分库分表技术本身并不难理解,真正的成本在于它对整个研发流程的影响:SQL 书写规范、索引规范、事务设计、上线发布、数据订正、备份恢复,所有环节都要跟着改变。我见过太多团队把 ShardingSphere 当做一个“连接池增强插件”来引入,结果上线后才发现所有离线统计任务都在做广播查询,ETL 任务直接把生产库打挂。
我个人这几年带队做分库分表的习惯是先做容量评估,再定分片键,最后才选中间件。如果可能,我会把分片键的候选字段都列出来,逐个用线上真实数据跑分布均匀度测试,只保留那个数据分布最均匀、业务查询覆盖度最高的字段。至于 ShardingSphere,它在规则表达、联邦查询、分布式事务接入等能力上确实很成熟,5.x 的性能也比 4.x 有明显提升,是目前 Java 技术栈里分库分表方案的首选。
如果你还处在“要不要分库分表”的犹豫期,我多说一句:能不拆就不拆,能晚拆就晚拆;但一旦决定拆,就按生产标准把数据迁移、监控、回滚方案一起做进去。分库分表不是银弹,但做好了,它能替你挡住未来一到两年的数据增长压力。