
上个月我接手一个订单中心时单表已经堆到接近1.5亿行慢查询日志里一半以上都是订单表的。当时团队已经明确要上分库分表我在方案选型里最终定了 ShardingSphere5.2.1 配合 Spring Boot。这篇文章就是把从选型、依赖引入、配置拆分到实际踩坑的完整过程记录下来给准备在 Spring Boot 项目里快速上手分库分表的同学做一份可以直接照着实操的参考。我会默认你已经有基本的 Spring Boot 和 MyBatis 使用经验对分库分表的概念也有个大概了解。文章里不会把官网文档整篇翻译一遍而是把“从零搭起来、跑通第一个增删改查、并知道如何验证分片是否生效”这几件事讲清楚。如果你正卡在版本选择、配置不生效、SQL乱路由这几个坎上这篇应该能帮你省下不少时间。1. 为什么选 ShardingSphere 5.2.1从方案选型说起1.1 在 MyCat、ShardingSphere-JDBC、手写路由中间层之间怎么取舍分库分表的落地方式无非那么几条路中间件代理MyCat/ShardingSphere-Proxy、应用内嵌入式数据源ShardingSphere-JDBC、以及最原始的手写路由逻辑。多数项目最开始是手写比如在 Service 层根据订单号取模动态拼出t_order_0到t_order_7这种表名。这种方式业务代码里到处是t_order_ (orderId % 8)写起来爽维护起来想哭。关联查询、分页、排序全部要自己处理一旦分片规则调整波及面是整个业务层。所以手写只适合临时小范围实验不适合往前飞。MyCat 这类独立代理的方案我之前也用过它对应用透明SQL 进去之后由代理层做路由团队不用改代码。代价是架构里多了一个必须保证高可用的关键节点网络链路多一跳排查问题时要多查一层中间件日志。对没有专职 DBA 团队的中小项目来说部署和维护成本不算低。ShardingSphere-JDBC 是嵌入应用进程内的轻量方案以 jar 包形式引入后它自己包装了一个 DataSource业务代码拿到的还是那个熟悉的DataSourceMyBatis、JPA 都能无缝接。路由、改写、归并都在应用内完成不依赖额外进程。对绝大多数 Spring Boot 单体应用来说这是最“轻”的上车方式。我在这次选型里最终用的就是它具体依赖是shardingsphere-jdbc-core-spring-boot-starter的 5.2.1 版本。1.2 为什么锁定 5.2.1 而不是更旧的 4.x 或更新的 5.4版本选择上我吃过亏。如果一上来就去搜教程很容易搜到 ShardingSphere 4.x 的老配置比如sharding-column: order_id直接写在 table 节点下面或者是spring.shardingsphere.common这种老段位。照抄基本是启动报错或者规则不生效。5.x 重构了配置模型分片算法、分布式ID生成器都独立成配置节点语义清晰很多。5.2.1 是 5.x 体系里相当稳的一个小版本官方文档和社区案例密度都高。5.3、5.4 虽然更新但 starter 的 artifact 有改动部分内部 API 调整会带来兼容性差异。如果你不是追新特性的场景5.2.1 足够扛住常规订单、用户、流水类数据的水平拆分。另外一个关键点是 Spring Boot 版本配套。ShardingSphere 5.2.1 对 Spring Boot 2.x 的支持是完整的我实测用 2.7.x 系列非常稳。Spring Boot 3.x 是基于 Spring Framework 6 的5.2.1 当时并没有针对性适配硬上会碰到循环依赖、代理方式等奇奇怪怪的问题。如果你项目里已经是 Spring Boot 3,那不建议用 5.2.1需要换到后续支持 Spring Boot 3 的版本。2. 环境准备与依赖引入版本搭配是第一道坎2.1 Spring Boot 2.7.x 与 JDK/驱动版本组合我这次项目实际上用的是 Spring Boot 2.7.14JDK 8。这里额外说一句JDK 8 直到现在依然是很多后端服务的主力运行时Spring Boot 2.7 对 JDK 8 的支持非常成熟所以不用为了用新特性把 JDK 升到 17没必要。数据库是 MySQL 8.0驱动用mysql-connector-java8.0.x 版本。需要注意的是Spring Boot 2.7 的依赖管理可能已经帮你管了这个驱动版本但为了和 ShardingSphere 5.2.1 的兼容性我建议显式指定驱动版本避免因为驱动内部行为差异导致连接初始化异常。另外一个容易被忽略的点是连接池。ShardingSphere 配置数据源时用的是 HikariCP对应的类型是com.zaxxer.hikari.HikariDataSource。HikariCP 在 Spring Boot 2.x 里是默认连接池所以你只要确保项目里没有额外引入其他连接池把 Hikari 冲掉就行。实际排查中我见过有同事引入了 Druid 之后ShardingSphere 初始化时拿不到正确的数据源类型报Cannot convert value of type错误。2.2 Maven 依赖一个 starter 加一个数据库驱动核心依赖就两个dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.2.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.31/version /dependency如果你项目里已经通过spring-boot-starter-jdbc引用了驱动mysql-connector-java那块可以省略。想要用 MyBatis 就再引入mybatis-spring-boot-starter2.3.x这里不单独展开。这里有个容易踩的依赖冲突点ShardingSphere 5.2.1 会传递引入较新版本的 Guava 和 Jackson。如果项目里其他地方强制指定了老版本 Guava比如项目里为了兼容某个老 SDK 把 Guava 压到 18.0ShardingSphere 内部可能直接 NoSuchMethodError。这种错误不会在启动时就暴露通常是调用分片算法时才炸。解决方法不是去排除 Guava而是尽量保持依赖版本统一或者借助 Maven 的 dependencyManagement 把 ShardingSphere 需要的版本统一调上来。2.3 老项目改造时要注意的依赖残留如果是已有项目接分库分表我最想提醒的是别把旧的 ShardingSphere 4.x 依赖残留着。4.x 和 5.x 的类名大量重叠但内部 design 完全变了两个版本的 starter 同时出现在 classpath 里会导致启动时出现各种BeanDefinitionStoreException或者找不到算法类的诡异报错。检查手段很简单本地执行mvn dependency:tree -Dincludesorg.apache.shardingsphere把输出里旧坐标比如sharding-jdbc-spring-boot-starter找出来排除掉。ShardingSphere 从 5.1 开始就已经把坐标改成shardingsphere-jdbc-core-spring-boot-starter如果你的项目是从老版本升级上来的这一步不能省。3. 分片规则设计建表、分片算法、绑定表与广播表3.1 演示场景按订单号取模的订单库假设业务场景是订单系统订单量每天几百万单表扛不住。我设计了两个物理库db_order_0和db_order_1每个库下面订单表拆成 4 张t_order_0到t_order_3这样一共 8 个物理分片。分片键选订单号order_id因为它业务上具有全局唯一性而且几乎所有订单查询都会带它。路由策略是分库order_id % 2结果为 0 进db_order_0为 1 进db_order_1分表order_id % 4结果为 0 进t_order_0为 1 进t_order_1以此类推为什么库和表都基于同一个分片键取模因为这样能让同一笔订单的数据和它的明细表落在同一个数据节点上后续订单表和订单明细表做关联查询时才不会出现跨库 join。这是个非常关键的设计决策。我见过其他团队为了平衡数据量库用user_id分、表用order_id分结果订单主表和明细表经常跨库查询只能靠应用层拼装分布式事务也跟着升级复杂度直接翻倍。所以对于快速开始的项目能用同一个分片键就别用两个。3.2 建表语句每个库里建 4 张表表结构必须完全一致下面给出db_order_0库里订单表的建表语句。注意表结构里的主键不要用数据库自增分布式场景下自增 ID 没有任何全局唯一性后面分布式 ID 章节会细说。CREATE TABLE t_order_0 ( id bigint NOT NULL COMMENT 全局唯一主键, order_id bigint NOT NULL COMMENT 订单号同时也是分片键, user_id bigint DEFAULT NULL COMMENT 用户ID, order_amount decimal(12,2) DEFAULT NULL COMMENT 订单金额, create_time datetime DEFAULT NULL COMMENT 下单时间, PRIMARY KEY (id), KEY idx_order_id (order_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单分片表0;然后依次建t_order_1、t_order_2、t_order_3结构一模一样。再在db_order_1库里也建同样的 4 张表。订单明细表t_order_item也是同样的套路每个库 4 张明细表。注意明细表的分片键也要是order_id这样主表与明细表用同一个键分片才能配置绑定表。3.3 application.yml 配置逐项拆解直接给出我验证过可用的核心配置再加逐项说明。spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/db_order_0?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/db_order_1?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 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: table-inline key-generate-strategy: column: order_id keygen-algorithm-name: snowflake_id t_order_item: actual-data-nodes: ds$-{0..1}.t_order_item_$-{0..3} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: table-inline key-generate-strategy: column: id keygen-algorithm-name: snowflake_id binding-tables: - t_order,t_order_item broadcast-tables: - t_dict sharding-algorithms: database-inline: type: INLINE props: algorithm-expression: ds$-{order_id % 2} table-inline: type: INLINE props: algorithm-expression: t_order_$-{order_id % 4} key-generators: snowflake_id: type: SNOWFLAKE props: worker-id: 1 props: sql-show: true逐项说几个容易出问题的地方。actual-data-nodes里的ds$-{0..1}是 ShardingSphere 自己的内联表达式用于展开枚举范围结果就是ds0.t_order_0、ds0.t_order_1……全部物理节点。很多初学者写成ds${0..1}这在 Spring Boot 的application.yml里会被 Spring 当作占位符提前解析导致运行时节点全部失效。5.x 推荐用$-{}就能避免和 Spring 属性占位符冲突。sharding-algorithms里database-inline和table-inline是两个独立算法配置。这里的INLINE是 Groovy 表达式算法表达式里出现的order_id是数据库列名不是 Java 实体字段名。很多人在这里把实体类的驼峰字段写进去结果发现路由出来的表永远不对。记住分片表达式里出现的都是物理列名。key-generators里配置了SNOWFLAKE算法并指定worker-id。这个 worker-id 在多实例部署时非常重要每个应用实例的 worker-id 必须不同否则两个实例生成的雪花 ID 可能重复进而导致分片数据乱掉。我见过生产环境因为全用默认 worker-id上线后出现主键冲突的事故。sql-show: true是开发调试阶段的利器它会打印逻辑 SQL 和真实路由 SQL生产环境记得关掉。3.4 绑定表和广播表两个容易忽略但很关键的配置配置里的binding-tables: t_order,t_order_item就是绑定表。含义是这两张表的分片键和分片策略完全相同ShardingSphere 在遇到两张绑定表的 join 查询时可以精确路由到同一个数据节点避免笛卡尔积型的全节点扫描。举个例子如果订单表和订单明细表都按order_id分片那么查询订单和明细 join 时理论上只需要匹配同库同表的物理分片即可。没有绑定表配置的情况下ShardingSphere 不知道两张表的分片规则对应关系会把两个表的所有分片做一次笛卡尔积组合比如 8×864 种组合只是执行计划大性能完全不可接受。配了绑定表后路由组合会收窄到 8 个。广播表则是所有分片库里都要保存一份相同数据的表比如字典表、系统配置表。配置了broadcast-tables: t_dict后插入、更新、删除会同步到所有库查询就随机取一个节点。这个设计很贴心但要注意写入频次写一次放多个库事务边界会变复杂所以广播表只放极低写入频率的字典数据。4. 代码接入MyBatis 逻辑表 CRUD 的注意事项4.1 实体与 Mapper永远写逻辑表名物理表是t_order_0到t_order_3但业务代码里写的是逻辑表名t_orderShardingSphere 会在路由层自动改写 SQL。实体类就是普通 POJOData TableName(t_order) public class OrderDO { private Long id; private Long orderId; private Long userId; private BigDecimal orderAmount; private LocalDateTime createTime; }TableName 是 MyBatis-Plus 的注解如果你用原生 MyBatisXML 里的表名也写t_order就行。Mapper 接口我以原生 MyBatis 为例Mapper public interface OrderMapper { Insert(INSERT INTO t_order (id, order_id, user_id, order_amount, create_time) VALUES (#{id}, #{orderId}, #{userId}, #{orderAmount}, #{createTime})) int insert(OrderDO orderDO); Select(SELECT * FROM t_order WHERE order_id #{orderId}) OrderDO selectByOrderId(Param(orderId) Long orderId); Select(SELECT * FROM t_order WHERE order_id #{orderId} AND user_id #{userId}) OrderDO selectByOrderIdAndUserId(Param(orderId) Long orderId, Param(userId) Long userId); }注意 SQL 里主键字段是显式写入的因为主键由应用层生成插库时不会依赖数据库自增。为什么这么做下一节展开。4.2 分布式 ID不要指望 ShardingSphere 自动回填配置里我加了key-generate-strategy它的作用是当 INSERT 语句里没有order_id时ShardingSphere 会调用雪花算法自动生成 ID 并写进去。听起来很省事但有一个隐含坑这个由 ShardingSphere 生成的 ID 并不会回填到你的 Java 实体对象里。假设你在insert(orderDO)之后立刻打印orderDO.getOrderId()大概率打印出null但数据库里那行数据已经有一个不同的 ID 了。如果后面业务要拿订单号去发 MQ、查流水就会对不上。所以我在实际项目里的建议是应用层自己生成分布式 ID插入前就把orderId和主键都 set 好。这样能保证程序里的对象和数据库记录完全一致。生成 Snowflake ID 的方式有很多Hutool 的IdUtil.getSnowflakeNextId()就能满足大部分场景或者直接用 ShardingSphere 底层算法。关键点只有一个全局唯一并且同一套订单表体系里的 ID 必须同源生成。另外千万别给物理表保留AUTO_INCREMENT自增属性后再用 ShardingSphere。逻辑上这是 8 张表每张表的自增计数都从 1 开始插入 8 笔订单会出现 8 个id1的记录。虽然分片键不同不至于物理冲突但逻辑主键语义完全废掉任何按 ID 的关联查询都会串。所以我建表时主键列都是普通 bigint不带自增主键和分片键都由应用控制。4.3 插入和查询分片键必须出现在 SQL 中路由的前提是 SQL 里有分片键。插入时order_id当然在字段里查询时如果你只按user_id查ShardingSphere 无法定位具体节点只能全库全表扫描。举一个最常见的问题查询SELECT * FROM t_order WHERE user_id 10086这条 SQL 走的是广播路由每个库的每张表都要扫一遍8 个分片全部响应最后归并结果。单条数据量小的时候感觉不到一旦某个user_id的订单量上万这个查询会直接拖垮数据库。我处理后端查询接口时强制要求所有订单查询接口的入参必须包含order_id至少选项上要支持按order_id精确过滤。前端如果不传订单号只传用户 id那就走旁路的用户订单索引表或者用搜索引擎而不是让分片库全量扫。如果业务实在绕不开不带分片键的查询可以借助其他组件维护一个“分片键映射索引”比如用 Redis 或者 ES 存user_id - order_id的映射先查出订单号再带着订单号走分片查询。这是很朴素但非常有效的方案。5. 实测中踩过的坑路由失效、事务失效与配置不生效5.1 SQL 没带分片键路由“帮倒忙”的现场我印象最深的一个生产问题是报表系统凌晨跑任务查最近三个月的订单明细SQL 长这样SELECT * FROM t_order WHERE create_time 2023-01-01分片键order_id完全没出现。ShardingSphere 的处理方式是把这条 SQL 复制到所有 8 个物理分片上去执行然后再把结果合并。从逻辑结果看没错但从性能看是一次不折不扣的全表扫描风暴。那天晚上慢查询日志直接把监控打爆了。所以要提前在团队规范里明确对分片表的所有查询SQL 里必须带分片键。不仅是IN这种精确条件也可以精确路由。范围条件比如BETWEEN、、INLINE 算法不擅长也会退化成全路由。如果你的系统里有大量这类范围查询要么换支持范围分片的算法要么像上面说的维护映射索引。5.2 Transactional 默认管不了跨库事务ShardingSphere-JDBC 在 Spring 工程里默认事务类型是本地事务。当Transactional方法里只操作一个分片时没问题但它一旦要更新两个不同的物理库本地事务就失效了一个库提交成功、另一个库提交失败时不会自动回滚数据就处在不一致状态。这个坑很多从单表单库迁过来的同事都会踩。我当时有一个下单后同时写订单表和扣减库存余额的逻辑订单在ds0用户余额在ds1加了个Transactional以为万事大吉结果模拟余额扣减失败时订单还是落库了。解决方案有几个层次业务设计上尽量让一次操作只落在一个分片内比如所有关联数据都按同一个分片键组织。如果必须跨库需要引入分布式事务。ShardingSphere 5.2.1 支持 XA 事务需要额外引入shardingsphere-transaction-xa-core并且在方法上使用ShardingSphereTransactionType(TransactionType.XA)。也可以用 Seata 的 AT 模式但 seata-server 本身需要部署和运维复杂度更高。先想清楚业务能不能按分片键收敛是成本最低也最稳的做法。5.3 配置属性名差一个词启动一半就挂ShardingSphere 的配置键比较长而且不同版本有差异抄博客时特别容易踩。常见错误是把keygen-algorithm-name写成key-generator-name或者把sharding-algorithm-name写成algorithm-name。这些属性在 5.x 中拼错后Spring Boot 的宽松绑定不一定能及时给你报错而是会当作普通配置项忽略后果就是启动日志显示规则正常实际跑起来插入时没有 ID 生成或者分片策略完全没生效。我的建议是配置写完以后启动时重点观察 ShardingSphere 的初始化日志。它会打印加载的分片规则包括Sharding tables load和算法注册信息。如果没看到t_order的节点列表那大概率就是配置键拼错了。5.4 排查思路先看启动日志再看 Actual SQL如果你已经出现了路由不对、CRUD 报错等问题我的排查顺序是固定的先用开头的sql-show: true日志确认路由目标。日志里会出现两类 SQLLogic SQL业务写的逻辑 SQL比如SELECT * FROM t_order WHERE order_id 123Actual SQL真实路由后的 SQL比如SELECT * FROM t_order_2 WHERE order_id 123前缀可能还会带上ds0这样的数据源标记如果Actual SQL里的表名还是逻辑表名t_order说明 ShardingSphere 根本没有接管这条 SQL。这时候去检查 starter 依赖有没有生效spring.shardingsphere配置有没有被 Spring Boot 读取。如果表名被改写成了多个分片并且全部执行说明分片键没有出现在条件中去补条件。如果Actual SQL只有一条但表号不对比如order_id5应该进t_order_1结果进t_order_0大概率是表达式里取模基准错了检查order_id % 4的算法表达式以及数据库列的实际类型。列类型是 varchar 而表达式里直接% 4也会出问题需要用HASH_MOD之类的算法先哈希再取模。实际操作中我 90% 的问题靠这两步就能定位。6. 验证分片是否生效的三种方法6.1 日志法观察 Actual SQL 的路由结果开发阶段最直接的验证方式就是开启sql-show: true。插入一条order_id 5的数据理想日志应该是逻辑 SQLINSERT INTO t_order (...) VALUES (...)实际 SQLActual SQL: ds1 ::: INSERT INTO t_order_1 (...) VALUES (...)因为5 % 2 1进ds15 % 4 1进t_order_1。如果实际路由到ds0或t_order_0立即排查算法表达式。查询验证同理SELECT * FROM t_order WHERE order_id 5路由到ds1.t_order_1就是正确的。多试几个订单号把 0 到 7 的余数覆盖全确认 8 个分片都能按预期命中。6.2 直连数据库查数据分布日志法能确认单次路由但没法确认历史数据分布。我会在验证阶段直接连上两个物理库执行-- 在 db_order_0 SELECT count(*) FROM t_order_0; SELECT count(*) FROM t_order_1; SELECT count(*) FROM t_order_2; SELECT count(*) FROM t_order_3;再对比db_order_1里 4 张表的数量。正常插入 N 条数据后8 张表的数量应该大致均匀不会出现某一张表空了的情况。还可以抽查一些订单号比如取order_id 10的记录手动计算10 % 4 2然后到db_order_0的t_order_2里查。如果查到了路由逻辑和数据落库位置一致。6.3 用聚合和分页 SQL 验证归并能力分库分表之后普通查询容易验证但聚合和排序会经过归并引擎也要提前测。比如SELECT user_id, SUM(order_amount) FROM t_order GROUP BY user_id这条没有分片键的聚合 SQL 会很辛苦但它能验证 ShardingSphere 的归并是否正常工作。如果结果和单表时代统计一致说明归并层没问题。性能上的问题另说至少正确性有保障。分页查询也要测。跨分片的分页不是简单 LIMIT而是每个分片各自取出一部分数据再归并排序深分页时性能衰减很严重。我建议压测一下第 1000 页之后的翻页查询大概率会发现不能接受的延迟。后续优化方向是改成按order_id游标分页或者限制只能按时间倒序取前 N 页。7. 我对这套组合的真实体会分库分表不是银弹但它确实是单表数据量到了瓶颈之后一条很务实的路。ShardingSphere 5.2.1 加 Spring Boot 这套组合对我来说最大的价值是接入成本低、规则可配置、问题可观测不需要专门搭一套中间件集群就能先把拆分跑起来。操作上的几个重点再强调一次分片键要精挑细选能覆盖绝大多数查询INSERT 的主键和分片键统一由应用层生成别依赖数据库自增所有业务 SQL 强制约束必须带分片键跨库事务能避免就避免上线之前先把sql-show打开全链路确认一遍 Actual SQL。如果你第一次做分库分表我建议先拿一个只读报表或低峰期的订单查询接口做试点不要直接切核心写链路。等路由、归并、分布式 ID 都经过了充分验证再逐步扩大范围。后面我有时间的话再单独写写 ShardingSphere 的分布式事务接入和读写分离组合配置这里也算留个引子。