
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 groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.3.2/version /dependency如果你用的是SpringBoot 2.x5.3.x基本都能兼容如果是SpringBoot 3.x建议用5.4.x以上版本。我实测下来5.3.2配SpringBoot 2.7最稳没出现过兼容性问题。另外数据库驱动别忘了加MySQL用mysql-connector-java版本8.x。连接池我用的HikariCPSpringBoot默认就是它性能好配置简单。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?useSSLfalseserverTimezoneAsia/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?useSSLfalseserverTimezoneAsia/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_PASSWORDpassword0 mysql:8.0 docker run -d --name mysql-slave0 -p 3307:3306 \ -e MYSQL_ROOT_PASSWORDpassword0 mysql:8.0主从同步的配置这里不展开核心就是主库开binlog从库配置change master指向主库。生产环境建议用云数据库的只读实例省去自己维护主从同步的麻烦。数据库建好之后在每个库里建对应的表。db0里建t_order_0和t_order_1db1里同样。表结构保持一致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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意主键是order_id但分片键是user_id和order_id。这里有个问题如果order_id是自增的分片后自增会冲突。所以生产环境一般用雪花算法生成分布式ID保证全局唯一。4.2 应用层代码适配ShardingSphere最大的好处是业务代码几乎不用改。MyBatis-Plus的Mapper还是照常写Mapper public interface OrderMapper extends BaseMapperOrder { Select(SELECT * FROM t_order WHERE user_id #{userId}) ListOrder 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_id1001和user_id1002两条订单。按user_id % 2的规则1001应该去db11001%211002应该去db01002%20。然后分别连到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 一些个人经验最后分享几个我在实际项目中总结的经验。分片键的选择比技术选型更重要。技术方案可以换分片键选错了要改就是伤筋动骨。选分片键的时候把未来两年的业务增长考虑进去。不要过度设计。如果数据量还没到千万级先别急着分库分表读写分离加索引优化可能就够了。分库分表带来的复杂度是实实在在的运维成本、开发成本、排查问题的难度都会上升。测试要充分。分库分表后的测试不能只测功能还要测数据分布、边界条件、扩容场景。我一般会写一批数据分布验证的脚本每次改配置都跑一遍。文档要跟上。分片规则、数据分布、扩容方案这些一定要写清楚不然过半年自己都忘了当初为什么这么设计。这套方案我在几个项目里用过数据量从千万到亿级都跑得挺稳。当然每个项目情况不同具体参数和策略要结合实际调整。希望这些经验能帮你少走点弯路。