☰
SpringBoot + ShardingSphere 实现读写分离与分库分表实战
2026/10/21 19:28:40 网站建设 项目流程

1. 为什么单库单表迟早要出事

做后端开发的朋友大概率都经历过这样一个阶段:项目刚开始的时候,一张表几千条数据,查询飞快,开发爽得不行。然后业务跑了一年半载,订单表破了千万,用户表到了几百万,某天早上打开监控面板一看,慢查询日志刷了满屏,数据库CPU飙到90%以上,接口响应时间从50毫秒涨到了3秒。这时候你才意识到,单库单表这套玩法,真的撑不住了。

我经历过好几次这种局面,最开始的做法是加索引、优化SQL、加缓存,这些手段确实能续命,但都是治标不治本。索引加多了写入变慢,缓存一致性又是个大坑,SQL优化到极致也就那样了。真正要解决问题,必须从架构层面动手——读写分离和分库分表这两件事,迟早都得做。

这篇文章要聊的,就是怎么用SpringBoot + ShardingSphere这套组合拳来实现读写分离和分库分表。ShardingSphere 是 Apache 旗下的开源分布式数据库中间件,目前在国内互联网公司用得非常多,社区活跃,文档也相对完善。它最大的好处是对业务代码侵入极小,基本上你原来的MyBatis或JPA代码不用大改,只需要加依赖、改配置,就能让底层数据访问自动走分片和读写分离逻辑。

这篇文章适合哪些人看?如果你正在做中小型项目,数据量开始增长,想提前做好架构储备,那正好;如果你已经在维护一个数据量较大的系统,正在考虑分库分表方案选型,那这篇能帮你少踩不少坑;哪怕你只是想了解一下ShardingSphere到底怎么用,看完也能有个清晰的实操路径。我会从整体设计思路讲起,然后拆解核心配置,再给出完整的实操步骤,最后把我踩过的坑和排查经验都倒出来。

2. 整体架构设计与方案选型思路

2.1 读写分离和分库分表到底解决什么问题

先把概念理清楚,不然后面配置容易懵。

读写分离解决的是数据库读压力过大的问题。核心思路是搞一主多从,主库负责写(INSERT、UPDATE、DELETE),从库负责读(SELECT)。主库通过binlog把数据变更同步到从库,应用层根据SQL类型自动路由到不同的数据源。这样做的好处是读请求被分摊到多个从库上,主库压力大幅降低。根据经验,大部分互联网业务读写比例在7:3到9:1之间,也就是说读请求远多于写请求,读写分离的收益非常明显。

分库分表解决的是单表数据量过大和单库连接数瓶颈的问题。分表是把一张大表按某个规则拆成多张小表,比如订单表按用户ID取模拆成16张表;分库是把数据分散到多个数据库实例上,进一步分摊压力。分库分表之后,单表数据量控制在合理范围内(通常建议单表不超过500万到1000万行),查询性能就能保持稳定。

这两个方案经常一起用,但它们是独立的概念。你可以只做读写分离不分库分表,也可以只分库分表不做读写分离。实际项目中,通常是先做读写分离,数据量再涨就上分库分表。

2.2 为什么选ShardingSphere而不是其他方案

市面上做数据库中间件的方案不少,大致分两类:一类是客户端模式(如ShardingSphere-JDBC),一类是代理模式(如ShardingSphere-Proxy、MyCat)。我选ShardingSphere-JDBC,主要基于这几个考量。

第一,性能损耗小。JDBC模式是jar包直接嵌入应用,SQL解析和路由在应用进程内完成,少了一次网络转发,性能比代理模式好。代理模式虽然对应用透明,但多一跳网络开销,高并发场景下延迟更明显。

第二,运维成本低。JDBC模式不需要额外部署中间件服务,跟着应用一起发布就行,对于中小团队来说省事很多。代理模式你得单独维护一套集群,还要考虑代理层的高可用。

第三,功能覆盖全。ShardingSphere支持分库分表、读写分离、数据加密、影子库、分布式事务等,基本覆盖了数据访问层的常见需求。而且它兼容多种ORM框架,MyBatis、JPA、MyBatis-Plus都能无缝对接。

第四,社区生态好。Apache顶级项目,版本迭代快,中文文档齐全,遇到问题搜一下基本都有答案。

当然它也有缺点,比如SQL兼容性有边界,某些复杂SQL它解析不了会直接报错;再比如分片键选择不当会导致全路由,性能反而下降。这些后面会详细说。

2.3 整体架构分层设计

我这次设计的架构大致是这样的:

  • 应用层:SpringBoot应用,通过MyBatis-Plus访问数据库,业务代码不感知分片逻辑。
  • 数据访问层:ShardingSphere-JDBC作为数据源代理,拦截SQL并做解析、路由、改写、执行、归并。
  • 数据库层:两个主库(db0、db1),每个主库挂两个从库(db0-slave0、db0-slave1、db1-slave0、db1-slave1),共6个数据源。
  • 分片策略:订单表按user_id取模分到2个库,每个库内再按order_id取模分到2张表,总共4张订单表。

读写分离的规则是:写操作走主库,读操作走从库,从库采用轮询负载均衡。分库分表的规则是:根据user_id计算库索引,根据order_id计算表索引。

这个设计的好处是读写压力都被分摊了,而且分片规则清晰,后续扩容也有章可循。接下来我会一步步拆解怎么落地。

3. 核心配置细节与实操要点拆解

3.1 依赖引入与版本选择

第一步是加依赖。ShardingSphere的版本迭代比较快,我建议用5.x的稳定版,4.x虽然也稳定但功能少一些。这里有个坑要注意:ShardingSphere-JDBC的版本要和你用的SpringBoot版本匹配,不然可能出现Bean初始化失败的问题。

<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.3.2</version> </dependency>

如果你用的是SpringBoot 2.x,5.3.x基本都能兼容;如果是SpringBoot 3.x,建议用5.4.x以上版本。我实测下来5.3.2配SpringBoot 2.7最稳,没出现过兼容性问题。

另外,数据库驱动别忘了加,MySQL用mysql-connector-java,版本8.x。连接池我用的HikariCP,SpringBoot默认就是它,性能好配置简单。

3.2 数据源配置的关键参数

数据源配置是读写分离的基础,这里要定义所有的主库和从库。我用YAML格式配置,比properties清晰很多。

spring: shardingsphere: datasource: names: db0,db0-slave0,db0-slave1,db1,db1-slave0,db1-slave1 db0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db0?useSSL=false&serverTimezone=Asia/Shanghai username: root password: password0 db0-slave0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db0?useSSL=false&serverTimezone=Asia/Shanghai username: root password: password0 # 其余数据源省略,结构相同

这里有几个细节值得说。第一,names里列出的名字要和下面定义的key完全对应,大小写敏感,写错了启动就报错。第二,jdbc-url和url的区别要注意,HikariCP用的是jdbc-url,如果你写成url会报找不到属性的错。第三,serverTimezone一定要配,不然MySQL 8.x驱动会报时区错误。

提示:生产环境的密码不要明文写在配置文件里,建议用配置中心或者环境变量注入。我见过太多项目把生产密码提交到代码仓库的,这是大忌。

3.3 读写分离规则配置

数据源定义好之后,要告诉ShardingSphere哪些是主库、哪些是从库、读请求怎么分发。

rules: readwrite-splitting: >sharding: tables: t_order: actual-data-nodes: readwrite-ds.t_order_${0..1} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db-inline table-strategy: standard: sharding-column: order_id sharding-algorithm-name: table-inline sharding-algorithms: db-inline: type: INLINE props: algorithm-expression: t_order_${user_id % 2} table-inline: type: INLINE props: algorithm-expression: t_order_${order_id % 2}

这里我简化了一下,实际是分2库2表。actual-data-nodes定义了真实的数据节点,readwrite-ds是逻辑数据源名,t_order_${0..1}表示表名后缀是0或1。

database-strategy里的sharding-column是分库键,algorithm-expression是分片算法表达式。user_id % 2表示按user_id取模2来决定去哪个库。table-strategy同理,按order_id取模2决定去哪张表。

注意:分片键一旦确定,后续很难改。因为改分片键意味着所有历史数据都要重新分布,代价极大。所以选分片键的时候一定要想清楚,选那些查询条件里经常出现的字段。

3.5 分片键选择的经验法则

分片键选得好不好,直接决定分库分表方案的成败。我总结了几条经验。

第一,选高基数字段。比如user_id、order_id这种,取值多,分布均匀。如果选性别、状态这种低基数字段,数据根本分不均匀,某个库可能承担80%的数据。

第二,选查询高频字段。分片键最好是WHERE条件里经常出现的字段,这样查询能精准路由到某个库某张表。如果查询条件里不带分片键,ShardingSphere会做全路由,把SQL发到所有库所有表,性能比不分片还差。

第三,避免热点。如果按时间分片,最近的月份数据会被频繁访问,形成热点。可以考虑时间+用户ID组合分片,或者用一致性哈希。

第四,考虑扩容。取模分片在扩容时很麻烦,从2库扩到4库,几乎所有数据都要迁移。一致性哈希或者范围分片扩容更平滑,但实现复杂度高。中小项目用取模就够了,等真到了要扩容的体量,再考虑迁移方案。

4. 完整实操流程与核心环节实现

4.1 环境准备与数据库初始化

先把环境搭起来。我本地用Docker起了6个MySQL实例,模拟2主4从的架构。生产环境当然不会这么部署,但本地验证逻辑足够了。

docker run -d --name mysql-master0 -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=password0 mysql:8.0 docker run -d --name mysql-slave0 -p 3307:3306 \ -e MYSQL_ROOT_PASSWORD=password0 mysql:8.0

主从同步的配置这里不展开,核心就是主库开binlog,从库配置change master指向主库。生产环境建议用云数据库的只读实例,省去自己维护主从同步的麻烦。

数据库建好之后,在每个库里建对应的表。db0里建t_order_0和t_order_1,db1里同样。表结构保持一致:

CREATE TABLE `t_order_0` ( `order_id` bigint NOT NULL, `user_id` bigint NOT NULL, `order_no` varchar(64) NOT NULL, `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`order_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意主键是order_id,但分片键是user_id和order_id。这里有个问题:如果order_id是自增的,分片后自增会冲突。所以生产环境一般用雪花算法生成分布式ID,保证全局唯一。

4.2 应用层代码适配

ShardingSphere最大的好处是业务代码几乎不用改。MyBatis-Plus的Mapper还是照常写:

@Mapper public interface OrderMapper extends BaseMapper<Order> { @Select("SELECT * FROM t_order WHERE user_id = #{userId}") List<Order> selectByUserId(@Param("userId") Long userId); }

注意SQL里写的是逻辑表名t_order,不是真实的t_order_0。ShardingSphere会自动把逻辑表名改写成真实表名。这就是它的核心能力——SQL改写。

插入数据也一样:

Order order = new Order(); order.setOrderId(snowflakeIdGenerator.nextId()); order.setUserId(1001L); order.setOrderNo("NO20240101001"); order.setAmount(new BigDecimal("99.90")); orderMapper.insert(order);

ShardingSphere会根据user_id和order_id计算出目标库和目标表,然后把INSERT语句路由过去。整个过程对业务代码完全透明。

4.3 验证读写分离是否生效

配置完之后怎么验证读写分离真的生效了?我的做法是打开ShardingSphere的SQL日志,看它实际路由到了哪个数据源。

spring: shardingsphere: props: sql-show: true

开启之后控制台会打印类似这样的日志:

Logic SQL: SELECT * FROM t_order WHERE user_id = ? Actual SQL: db0-slave0 ::: SELECT * FROM t_order_0 WHERE user_id = ?

看到db0-slave0就说明读请求走了从库。如果是写操作,会显示db0主库。这一步验证很关键,我见过配置写错了但没报错,结果读写都走主库的情况,等于白配。

4.4 分片路由的验证方法

分片路由的验证稍微复杂一点。我一般插几条不同user_id的数据,然后直接去各个库查,看数据是不是按预期分布。

比如插入user_id=1001和user_id=1002两条订单。按user_id % 2的规则,1001应该去db1(1001%2=1),1002应该去db0(1002%2=0)。然后分别连到db0和db1去查,确认数据落到了正确的库。

再验证表路由,order_id按模2分表。如果order_id是偶数去t_order_0,奇数去t_order_1。同样直接查真实表确认。

这个过程虽然笨,但最可靠。等验证通过之后,再写单元测试固化下来,后续改配置能快速回归。

4.5 分布式主键的生成方案

分库分表之后,自增主键不能用了,必须用分布式ID。常见方案有几种。

雪花算法是最常用的,64位ID,包含时间戳、机器ID、序列号,趋势递增,性能好。缺点是依赖机器时钟,时钟回拨会出问题。可以用百度开源的UidGenerator或者美团的Leaf,都做了时钟回拨处理。

号段模式是每次从数据库取一批ID缓存在内存里,用完再取。性能好,但依赖数据库,数据库挂了就发不了号。

Redis自增简单直接,但多一次网络请求,高并发下Redis压力大。

我这次用的是雪花算法,自己实现了一个简单的版本,主要是为了演示。生产环境建议用成熟的开源实现,别自己造轮子。

public class SnowflakeIdGenerator { private final long workerId; private final long datacenterId; private long sequence = 0L; private long lastTimestamp = -1L; public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { throw new RuntimeException("时钟回拨"); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & 4095; if (sequence == 0) { timestamp = tilNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - 1288834974657L) << 22) | (datacenterId << 17) | (workerId << 12) | sequence; } }

这段代码是简化版,实际用的时候要注意workerId和datacenterId的分配,不能重复,否则会生成重复ID。

5. 常见问题与排查技巧实录

5.1 启动报错:找不到数据源

这是最常见的问题,报错信息通常是Cannot find data source 'xxx'。原因一般是names里列出的名字和下面定义的key不一致,或者读写分离规则里引用的数据源名写错了。

排查方法:先检查spring.shardingsphere.datasource.names里的每个名字,是否都在下面有对应的配置块。再检查readwrite-splitting里引用的write-data-source-name和read-data-source-names,是否都在names列表里。

我踩过一次坑,names里写的是db0-slave0,但下面配置块写的是db0_slave0,下划线和横线混了,找了半天才发现。

5.2 查询报错:SQL解析失败

ShardingSphere需要解析SQL才能做路由和改写,但它的解析器不是万能的。某些复杂SQL,比如嵌套子查询、UNION、窗口函数,它可能解析不了,直接抛SQLParsingException。

遇到这种情况有几个处理办法。第一,简化SQL,拆成多条简单查询在应用层组装。第二,用HintManager强制指定路由,绕过解析。第三,如果实在绕不过,考虑用ShardingSphere-Proxy模式,它的解析能力稍强一些。

提示:分片表上的SQL尽量保持简单,避免复杂的JOIN和子查询。分片环境下跨库JOIN性能很差,能避免就避免。

5.3 数据分布不均

如果发现某个库的数据量明显比其他库多,说明分片键选得不好,或者分片算法有问题。比如按user_id取模,但你的用户ID生成规则导致低位分布不均,就会出现倾斜。

解决办法:换分片键,或者改用一致性哈希算法。如果数据已经写进去了,迁移成本很高,可以考虑加一层映射,把热点数据单独处理。

5.4 读写分离延迟导致数据不一致

主从同步是有延迟的,通常几十毫秒到几秒不等。如果业务场景是"写完之后立刻读",可能会读到旧数据。比如用户下单后立刻查订单列表,结果查不到刚下的单。

解决办法有几种。第一,写后读强制走主库,用HintManager指定:

try (HintManager hintManager = HintManager.getInstance()) { hintManager.setWriteRouteOnly(); // 这里的查询会走主库 orderMapper.selectById(orderId); }

第二,用半同步复制,主库等从库确认收到binlog再返回,能大幅降低延迟,但会牺牲一点写入性能。第三,业务上容忍延迟,比如下单后跳转到"订单处理中"页面,过几秒再查。

5.5 常见问题速查表

问题现象可能原因排查方向解决方案
启动报找不到数据源数据源名不一致检查names和配置块key统一命名
SQL解析失败复杂SQL不支持查看异常堆栈简化SQL或用Hint
数据分布不均分片键基数低统计各库数据量换分片键
读不到刚写的数据主从延迟检查同步延迟强制走主库
分片不生效查询不带分片键看SQL日志带上分片键
性能反而下降全路由看Actual SQL数量优化查询条件

5.6 几个容易忽略的坑

第一个坑:事务问题。分库分表之后,跨库事务不能用本地事务了。ShardingSphere支持柔性事务,但配置复杂,性能也有损耗。我的建议是尽量让事务在单库内完成,跨库操作通过最终一致性方案解决。

第二个坑:分页查询。分库分表后的分页很麻烦,因为每个库都要查,然后归并排序。深分页性能极差,比如LIMIT 100000, 10,每个库都要扫描大量数据。解决办法是限制分页深度,或者用游标分页。

第三个坑:全局表。有些字典表、配置表数据量小但每个库都要用,可以配置成广播表,每个库都存一份,避免跨库JOIN。

第四个坑:绑定表。如果订单表和订单明细表都按order_id分片,可以配置成绑定表,这样它们的分片规则一致,JOIN时不会产生笛卡尔积。

6. 性能调优与生产环境建议

6.1 连接池参数调优

ShardingSphere底层还是用连接池,HikariCP的参数要调好。核心参数是maximum-pool-size,建议设置为CPU核数 * 2 + 磁盘数。比如4核8G的机器,设置10到20比较合适。设太大反而会因为线程切换导致性能下降。

connection-timeout建议设3000毫秒,idle-timeout设600000毫秒,max-lifetime设1800000毫秒。这些参数要根据实际压测结果调整,没有万能值。

6.2 分片算法的性能考量

INLINE表达式的分片算法性能最好,因为就是简单的取模运算。但如果分片逻辑复杂,比如要查表或者调远程服务,性能就会下降。建议分片算法尽量用纯内存计算,别引入外部依赖。

如果分片规则特别复杂,可以考虑自定义分片算法,实现StandardShardingAlgorithm接口。但自定义算法要保证线程安全,因为ShardingSphere会并发调用。

6.3 监控与告警

生产环境一定要有监控。ShardingSphere本身提供了一些指标,可以接入Prometheus。关键指标包括:SQL执行耗时、路由次数、全路由次数、连接池活跃连接数。

全路由次数是个重要指标,如果这个值很高,说明很多查询没带分片键,性能肯定好不了。要重点优化这些SQL。

6.4 灰度上线策略

分库分表改造是个大工程,千万别一次性全量切换。我的建议是分阶段来。

第一阶段,先做读写分离,这个改动小,风险低,验证通过后再进行下一步。

第二阶段,新表按分片规则建,新数据写分片表,老数据还在原表。应用层做双写或者用数据同步工具把老数据迁移过去。

第三阶段,数据迁移完成后,切换读路径到分片表,观察一段时间。

第四阶段,确认没问题后,下线老表。

整个过程可能要持续几周甚至几个月,急不得。我见过想一周搞定结果数据错乱的案例,回滚都回滚不了。

6.5 一些个人经验

最后分享几个我在实际项目中总结的经验。

分片键的选择比技术选型更重要。技术方案可以换,分片键选错了要改就是伤筋动骨。选分片键的时候,把未来两年的业务增长考虑进去。

不要过度设计。如果数据量还没到千万级,先别急着分库分表,读写分离加索引优化可能就够了。分库分表带来的复杂度是实实在在的,运维成本、开发成本、排查问题的难度都会上升。

测试要充分。分库分表后的测试不能只测功能,还要测数据分布、边界条件、扩容场景。我一般会写一批数据分布验证的脚本,每次改配置都跑一遍。

文档要跟上。分片规则、数据分布、扩容方案这些一定要写清楚,不然过半年自己都忘了当初为什么这么设计。

这套方案我在几个项目里用过,数据量从千万到亿级都跑得挺稳。当然每个项目情况不同,具体参数和策略要结合实际调整。希望这些经验能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询