☰
从单库到分库分表:MyBatis+ShardingSphere-JDBC+MySQL实战全解析
2026/10/1 19:30:10 网站建设 项目流程

最近在把一套老系统从单库单表往分库分表迁移,选型的时候没有太多纠结,直接把 MyBatis + ShardingSphere-JDBC + MySQL 这套组合定了下来。原因很简单:MySQL 单库在千万级订单量面前明显开始吃力,而 ShardingSphere-JDBC 是一个 Java 生态里跟 JDBC 规范天然兼容的分片中间件,MyBatis 不需要感知分片逻辑,只要把 DataSource 替换掉,剩下的路由、拆表、结果归并都交给它处理。这篇文章就把这套组合从配置到踩坑的东西完整过一遍,适合正在用 MyBatis 且需要考虑分库分表、读写分离的场景,也适合那些项目刚起步、想提前把数据扩展方案留好余地的团队。

我尽量讲实际操作,少讲虚的。你会看到依赖怎么引、YAML 怎么配、为什么分片键要这样选、二级缓存为什么在这里容易出问题、以及我上线前是怎么验证路由结果的。

1. 一次SQL请求到底经过了多少层:MyBatis、JDBC和ShardingSphere的角色定位

1.1 三层各自的边界

要理解这套组合,必须先搞清楚一个请求从 Mapper 接口到 MySQL 实例中间发生了什么事。很多同学把 MyBatis 和 JDBC 混着说,加上 ShardingSphere 之后更乱。其实边界很清晰:

  • MyBatis负责 ORM 映射。它把OrderMapper.selectById(1)这个方法调用转换成一条 SQL 字符串,然后通过SqlSession拿到 Connection 执行。
  • JDBC是 Java 访问关系型数据库的标准接口。DriverManager或者DataSource负责获取 Connection,Statement负责执行 SQL,ResultSet负责装回结果。
  • ShardingSphere-JDBC正好夹在中间。它包装了你的真实数据源,对外暴露一个DataSource接口。当 MyBatis 从它身上拿 Connection 时,它不会直接给你 MySQL 的物理连接,而是先做 SQL 解析、路由、改写、归并,最后才通过底层真实的 JDBC 连接把改写后的 SQL 发给目标数据库实例。

拿一个查询举例:MyBatis 想执行SELECT * FROM t_order WHERE order_id = 1001,ShardingSphere 看到这条逻辑 SQL 后,按照配置的分片规则算出 order_id=1001 应该落在ds0.t_order_1,于是它把 SQL 改写成SELECT * FROM t_order_1 WHERE order_id = 1001,然后从 ds0 对应的 HikariCP 连接池里拿一条真实 JDBC Connection 发出去。

1.2 ShardingSphere-JDBC 和普通连接池的差别

很多人第一次用的时候会想:“我是不是要把 HikariCP 换成 ShardingSphere?它是不是一个连接池?” 不是。它更像一个“数据源拦截器”。HikariCP 负责管理真实连接,ShardingSphere 负责在这些连接之上做路由和改写。你可以把连接池想象成快递公司的仓库,ShardingSphere-JDBC 就是分拣中心:快递员(MyBatis)把包裹随便丢进来,分拣中心根据地址(分片键)把包裹重新分配到不同仓库(物理库表),最后再由仓库里的货车(JDBC 驱动)送到目的地。

如果你只用普通连接池,一个DataSource对应一个 MySQL 库。但 ShardingSphere 的数据源配置里可以挂多个真实数据源,比如ds0、ds1,每个数据源可以指向一个 MySQL 实例。它对外仍然只有一个DataSource,MyBatis 完全无感。

这里要特别提醒一句:ShardingSphere-JDBC 解析、改写 SQL 本身有开销,但它是在应用进程里完成的,没有额外的网络 RTT,性能损耗相对可控。如果你的表只有几十万行,就别上分片,纯属给自己找麻烦。

2. 环境准备中的版本陷阱与依赖配置

2.1 我选定的版本组合

版本组合这事,直接决定你能不能跑通。我这次用的是 MySQL 8.0.33、MySQL 官方 JDBC 驱动 8.0.33、MyBatis 3.5.15、ShardingSphere-JDBC 5.4.1、HikariCP 5.0.1。这套组合相对成熟,ShardingSphere 5.x 的 YAML 配置和 4.x 差别很大,别拿旧配置去套新版本,否则会报一堆莫名奇妙的属性错误。

组件版本说明
MySQL Server8.0.33使用 caching_sha2_password 认证插件
mysql-connector-j8.0.33官方 JDBC 驱动
MyBatis3.5.15较稳定的版本,支持 JDK 8+
ShardingSphere-JDBC5.4.1核心包名改为shardingsphere-jdbc-core
HikariCP5.0.1连接池,ShardingSphere 本身不实现连接池

Maven 依赖这样写:

<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core</artifactId> <version>5.4.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.15</version> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency>

注意 ShardingSphere 5.x 的 GAV 是org.apache.shardingsphere:shardingsphere-jdbc-core,不是shardingsphere-jdbc。这两个名字在不同的版本里混用过,搜索资料的时候要留个心。

2.2 Maven 依赖下载失败的两种常见原因

我在项目里遇到过几次 Maven 下载失败,甚至 IDEA 提示Download from Maven failed。排查下来基本就两类原因。

第一类是仓库地址问题。公司私服里没有同步 Apache ShardingSphere 的某些 artifact,或者本地的 Maven 中央仓库镜像不完整。最简单的处理是在settings.xml里配置一个全量镜像源。国内镜像速度也更快,我用过阿里云的公共仓库,配置是:

<mirror> <id>aliyunmaven</id> <mirrorOf>*</mirrorOf> <name>Aliyun Public Repository</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>

第二类是驱动包和项目 JDK 版本不匹配。比如mysql-connector-j8.0.x 需要 JDK 8 以上,如果你的项目还在 JDK 7,就只能用 5.1.x 的老驱动。建议先确认mvn dependency:tree里没有意外升级冲突,再检查 IDE 的 Runner 设置的 JVM 参数,有时候自定义的 VM options 会覆写全局 JDK 版本。

2.3 连接 MySQL 时最容易踩的 SSL 与时区坑

搜索“mysql ssl连接错误”能翻出一大堆案例,我也在这里卡过一次。MySQL 8 默认开启 SSL,而 JDBC URL 里的useSSL如果不显式设置,驱动会做 SSL 握手,在部分网络环境和自签名证书下会直接报Communications link failure或者The driver could not establish secure connection。

我现在的做法是开发环境直接关闭 SSL,并顺手把公钥检索和时区都写在 URL 里:

jdbc:mysql://127.0.0.1:3306/db0?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4

这里有两个参数必须解释一下:allowPublicKeyRetrieval=true问题,MySQL 8 的 caching_sha2_password 插件做认证时,如果连接没有先用 SSL 加密,客户端需要从服务端获取 RSA 公钥来做密码加密传输。驱动可能因为无法获取公钥而报错。在可信的内网环境,设置allowPublicKeyRetrieval=true可以绕过这个步骤,但生产环境如果网络链路不安全,最好还是保留 SSL,改成useSSL=true并配置证书路径。

serverTimezone=Asia/Shanghai也很重要。如果 JDBC 时区和 MySQL 系统时区不一致,PreparedStatement.setTimestamp传参时会出现 8 小时的偏差,查出来的数据就像“穿越”了。把这些参数放在 ShardingSphere 数据源配置里,因为 ShardingSphere 不会替你做驱动层面的参数豁免。

3. MyBatis 侧要提前做好的三件事:Configuration、TypeHandler 和二级缓存

3.1 XMLConfigBuilder 与自定义 Configuration

MyBatis 的初始化过程说到底就是XMLConfigBuilder读取mybatis-config.xml,然后把各个标签解析成Configuration对象。如果你追过源码,会看到它把settings、typeAliases、mappers等节点逐个解析,最终生成一个完整的Configuration。很多人在面试题里碰到“mybatis 中 xmlconfigbuilder 的工作流程”,答案其实就是:创建解析器、解析根节点、逐项构建配置、返回Configuration。

实际开发中,我们不会去手写 XMLConfigBuilder,但一定要知道Configuration里的几个关键设置项。我至少会改这三个:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="defaultExecutorType" value="REUSE"/> <setting name="callSettersOnNulls" value="true"/> </settings>

mapUnderscoreToCamelCase解决数据库字段下划线和 Java 属性驼峰映射,否则你要写一堆resultMap。defaultExecutorType设为REUSE会复用 PreparedStatement,相对 SIMPLE 能减少一次编译,在频繁调用 Mapper 的场景下有一点性能帮助。callSettersOnNulls则是为了避免查询结果某个字段为 null 时,MyBatis 直接把整个属性跳过,导致对象某些字段不完整。

如果你用 Spring Boot,可以通过实现ConfigurationCustomizer在启动时微调 Configuration:

@Component public class MyConfigurationCustomizer implements ConfigurationCustomizer { @Override public void customize(Configuration configuration) { configuration.setMapUnderscoreToCamelCase(true); configuration.setCallSettersOnNulls(true); configuration.setExecutorType(ExecutorType.REUSE); } }

3.2 TypeHandler:类型转换机制

TypeHandler 是 MyBatis 里很容易被忽略但很实用的扩展点。它的作用是在 Java 类型和 JDBC 类型之间做转换。像 MySQL 的 JSON 字段、PostgreSQL 的数组类型,默认映射器往往只能拿出字符串或二进制,这时候就需要自定义 TypeHandler。

如果你的分片键是字符串,同时底层又是雪花算法生成的长整型,那请一定注意 Mapper XML 里参数的jdbcType有没有写错。搜索“mybatis中typehandler的工作流程图”的同学,大多是想搞明白setParameter和getResult这两个核心方法,我简单说下:写入时,MyBatis 调用setParameter,把 Java 对象转换成对应的PreparedStatement参数;读取时,getResult把ResultSet中的列转换成 Java 对象。

举个例子,订单表有个extra_info字段,数据库里是 JSON,Java 里是OrderExtInfo:

@MappedTypes(OrderExtInfo.class) @MappedJdbcTypes(JdbcType.VARCHAR) public class OrderExtInfoTypeHandler extends BaseTypeHandler<OrderExtInfo> { private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void setNonNullParameter(PreparedStatement ps, int i, OrderExtInfo parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, MAPPER.writeValueAsString(parameter)); } @Override public OrderExtInfo getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } @Override public OrderExtInfo getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public OrderExtInfo getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private OrderExtInfo parse(String value) { if (value == null) return null; try { return MAPPER.readValue(value, OrderExtInfo.class); } catch (Exception e) { throw new RuntimeException(e); } } }

在 mybatis-config.xml 里注册后,Mapper 的 ResultMap 就可以直接用了。这个能力在分片场景下特别重要,因为 ShardingSphere 会做结果归并,如果你没有注册正确的 TypeHandler,归并得到的ResultSet在列名映射时很容易拿到 null 或者类型转换错误。

3.3 二级缓存为何在此场景慎用

MyBatis 二级缓存是跨 SqlSession 的全局缓存,默认不带,需要手动开启。很多人一搜“mybatis二级缓存实现”就直接往项目里加,但在 ShardingSphere 场景下,我强烈建议先想清楚缓存维度。

问题在于 ShardingSphere 会对逻辑 SQL 做改写,逻辑表名会变成物理表名。如果你的二级缓存以逻辑表名为 key,那么一次查询的结果会被缓存到t_order这个 key 下面。下次另一个 route 到t_order_0的查询来了,MyBatis 发现缓存命中,直接把t_order的缓存返回,这个数据可能根本不是目标分片上的数据。

更麻烦的是,二级缓存存储的是一条 SQL 在逻辑表维度的结果,如果分片键没参与 SQL 条件,ShardingSphere 会把多个分片的查询结果归并之后才返回给 MyBatis。MyBatis 缓存的这个“归并后的整体结果”其实是安全的,但前提是缓存 key 必须包含所有分片查询的实际条件,而 MyBatis 默认的缓存 key 只包含逻辑 SQL 和参数,这一点在复杂查询下很容易踩坑。

我现在的处理规则是:

  • 静态字典表可以开二级缓存,表结构不分片,缓存风险小。
  • 分片业务表不集中开启二级缓存,只在代码里对 Mapper 方法单独使用useCache="false"。
  • 如果确实要做缓存,建议用 Redis 自己做业务缓存,而不是依赖 MyBatis 二级缓存。

4. 把 ShardingSphere-JDBC 挂进 MyBatis:分片规则与 SQL 路由实战

4.1 数据源工厂如何包装原连接池

ShardingSphere-JDBC 官方提供YamlShardingSphereDataSourceFactory,用来读取 YAML 配置并创建最外层的 DataSource。这一步其实很简单,难点在于后续接入 MyBatis 的方式。

传统的 MyBatis 单独使用,是给SqlSessionFactoryBuilder传一个Reader,里面加载 mybatis-config.xml,同时通过<environments>里的<dataSource>定义数据库连接。但一旦使用 ShardingSphere,这个 DataSource 就应该由 ShardingSphere 直接管理,MyBatis 只需要接收一个现成的DataSource实例。

我通常会自己写一个配置类:

@Configuration public class DataSourceConfig { @Bean public DataSource shardingDataSource() throws Exception { return YamlShardingSphereDataSourceFactory.createDataSource( new File("classpath:sharding.yaml")); } @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/**/*.xml")); factoryBean.setConfiguration(configuration()); return factoryBean.getObject(); } @Bean public Configuration configuration() { Configuration cfg = new Configuration(); cfg.setMapUnderscoreToCamelCase(true); cfg.setCallSettersOnNulls(true); return cfg; } }

如果你用 Spring Boot,记得排除其自动配置的数据源,或者在application.yml里把spring.datasource.type指向 ShardingSphere 的类。否则 Spring Boot 会先帮你建一个 HikariCP 的 DataSource,接着 ShardingSphere 又要建一个,导致 MyBatis 拿到的不是分片数据源。

4.2 分片规则配置:订单表按月分片

分片规则是最需要动脑的地方。我拿订单表举例,下面这个配置做了两个维度:按user_id决定进哪个库,按order_id决定进哪张表。实际项目里可能比这复杂,但套路是一致的。

dataSources: ds0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://127.0.0.1:3306/order_db_0?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 ds1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://127.0.0.1:3306/order_db_1?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root123 rules: - !SHARDING tables: t_order: actualDataNodes: ds${0..1}.t_order_${0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: user_db_inline tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_table_inline keyGenerateStrategy: column: order_id keyGeneratorName: snowflake shardingAlgorithms: user_db_inline: type: INLINE props: algorithm-expression: ds${user_id % 2} order_table_inline: type: INLINE props: algorithm-expression: t_order_${order_id % 2} keyGenerators: snowflake: type: SNOWFLAKE props: sql-show: true

这个配置里的actualDataNodes说的是逻辑表t_order实际存在的物理表集合。ShardingSphere 看到ds${0..1}.t_order_${0..1}就会展开成ds0.t_order_0、ds0.t_order_1、ds1.t_order_0、ds1.t_order_1一共四张表。

databaseStrategy和tableStrategy分别指定库分片、表分片的路由列和算法。INLINE算法就是写一个 Groovy 风格的表达式,注意它只有一个输入参数,就是分片列的值。user_id % 2决定库序号,order_id % 2决定表序号。

这里有一个关键点:为什么分库键选user_id,分表键选order_id,而不是统一用一个键?因为业务里绝大多数查询是“查某个用户的所有订单”,此时 SQL 条件里带user_id,ShardingSphere 可以先按 user_id 定位到具体库,再在库内部聚合所有表的数据;如果查询条件只带order_id,它可以根据 order_id 的奇偶性直接定位到具体表。两个维度互相配合,能精准覆盖两类高频查询。

4.3 事务处理:JDBC 本地事务与 ShardingSphere 强一致性

事务这条线值得单独说。ShardingSphere-JDBC 默认的事务行为和普通 JDBC 事务一样,connection.setAutoCommit(false)、connection.commit()、connection.rollback()都有。但前提是操作的所有数据都在同一个数据库实例上。如果一次业务操作要更新ds0和ds1两张表,本地事务就管不住了,这属于分布式事务问题。

ShardingSphere 5.x 支持三种事务模式:

  • 本地事务:默认,不引入额外组件,适合单分片内操作。如果你只在同一个库内操作多张业务表,完全够用。
  • XA 事务:基于两阶段提交协议,会引入 Atomikos 之类的组件。跨分片强一致,但性能损耗明显,而且要对全局锁和事务日志做进一步配置。
  • BASE 事务:基于柔性事务,通常要搭配 Seata 使用。适合最终一致性要求的业务场景,比如下单后异步扣库存。

坦白说,我这次迁移没碰 XA,因为订单创建、支付回调这些核心链路都能通过合理的设计控制在同一个库内完成。例如把某个用户的所有订单都分发到固定的 ds,再在 ds 内部按 order_id 分表,那么“同一个用户创建订单”就一定落在同一库,本地事务就能覆盖。真正的跨库事务只出现在一些统计类操作里,这类操作本来就可以走离线离线分析,不需要强一致。

用 JDBC 事务时还有一个容易犯的错:获取 Connection 后一定要在 finally 块里释放,ShardingSphere 包装的连接池如果没有正确归还连接,会导致连接耗尽。我见过不少案例,直接报Connection is not available, request timed out。

5. 上线前必做的路由验证与性能对比

5.1 如何确认 SQL 真的路由到了目标分片

配置完 ShardingSphere 后,第一件事不是写业务,而是验证路由结果。很多人配完发现查询结果不对或慢,就是因为路由没生效,SQL 跑到全表扫描了。最少在props里打开sql-show: true,然后执行一条带分片键的查询,控制台会打印类似这样的日志:

ShardingSphere-SQL: Logic SQL: SELECT * FROM t_order WHERE order_id = 123456 ShardingSphere-SQL: Actual SQL: ds0 ::: SELECT * FROM t_order_0 WHERE order_id = 123456

看到这两行,说明路由正常。如果打印出来的 Actual SQL 仍然是t_order,那说明 ShardingSphere 没接管数据源,或者配置里actualDataNodes写错了,表被当成了单表处理。

还有一种情况是查询条件里没带分片键,比如SELECT * FROM t_order WHERE status = 1。此时 ShardingSphere 只能做全路由,你会看到它把逻辑 SQL 广播到 ds0 和 ds1 的t_order_0、t_order_1四张物理表,然后做结果归并。全路由不是 bug,但高并发下性能会很差,线上要尽量避免。给这种表加上索引、或者通过内部分片键补齐条件,是更务实的做法。

5.2 同一条 SQL 在单库和分片下的表现对比

我迁移前后做了一个简单压测,这里把数据整理成表格,仅供思路参考。测试表是订单表,单表 1000 万行,分片后每张物理表 250 万行,共 4 张表。压测条件是固定order_id做等值查询,并发 100。

场景SQL平均响应时间耗时说明
单库单表SELECT * FROM t_order WHERE order_id = ?42ms走二级索引,但单表数据量大,Buffer Pool 命中率偏低
分片后SELECT * FROM t_order WHERE order_id = ?19ms路由到单一物理表,表体积小,缓存命中率明显提升
全路由查询SELECT * FROM t_order WHERE status = ?168ms广播到 4 张表,归并结果导致耗时上升

从表里能明显看出来,分片不是万能的。等值查询带分片键时收益最大;全路由查询反而比单库还慢,因为要收集多个分片的数据再归并。所以分片设计必须和业务查询路径深度绑定,而不是机械地按 ID 随机拆表。

5.3 分页与排序在分片下的特殊处理

分页是另一个隐藏深坑。普通单表分页LIMIT 100000, 20,数据库只需要扫描到第 100020 行。但 ShardingSphere 要把每个分片上LIMIT 100000, 20的结果都取回来,再在应用层归并,最后再丢弃前面多余的记录。也就是说,偏移量越大,内存和 CPU 消耗越高。

碰到这种情况,我建议改成“游标分页”或“key-based 分页”。例如把分页查询改成:

SELECT * FROM t_order WHERE order_id > #{lastOrderId} ORDER BY order_id ASC LIMIT 20

这样每个分片都可以快速拿到自己要的 20 条,ShardingSphere 归并时只需要对最多 40 条记录排序,性能非常稳定。如果你的业务必须用页码跳转,那就只能全路由加上内存归并,代价是会随页数显著增长。

排序字段也尽量选择和分片键相同的字段。如果排序字段不是分片键,ShardingSphere 会做一次精准的全局归并排序,但需要把每个分片里符合条件的记录都加载到内存。数据量一大,很容易触发 OOM。

6. 上一个坑之后:关于分片键的思考

这个标题可能有点怪,但我确实想单独拎出来说说。

我见过太多人为了“均匀分布”而选了一个业务上完全没有查询价值的列当分片键,比如纯随机生成的batch_id。结果导入数据的时候是均匀了,业务查询却全部变成全路由,在线系统卡到怀疑人生。

正确做法应该是:先拉出线上 Top 50 慢 SQL,统计哪些查询条件经常出现,然后选“出现频率最高、选择性又很强”的列做分片键。比如交易系统,用户 ID 和订单 ID 几乎出现在每一个查询里,那分库分表就围绕它们来设计。如果一个业务表只能通过一个非分片键的字段查询,那你要么接受全路由,要么再加一张映射表。

另一个细节是分布式主键。ShardingSphere 内置了雪花算法,可以在逻辑 SQL 执行时自动为主键生成全局 ID。雪花算法生成的 ID 是趋势递增的,对范围查询友好,而且能保证全局唯一。但要注意,如果分片键本身就是这个 ID,那雪花算法里机的 ID 分布是均匀的取模,一般没问题。如果你打算用数据库自增主键,分库后千万不能用auto_increment,否则同一张逻辑表的物理表各自生成同一个 ID,联合查询直接错乱。

我最后再分享一个操作习惯:配置完分片规则,先用几条典型 SQL 在测试环境跑一遍,把逻辑 SQL 和 Actual SQL 全部打出来核对。包括等值查询、排序分页、批量插入、跨库 join 这几种情况都过一遍,比上线之后在日志里发现问题要高效得多。分片这件事,设计阶段花的时间越少,上线之后花的时间越多。

这套组合目前已经稳定跑了六周,订单类查询响应时间下降超过一半。如果你的项目也正卡在 MySQL 单库性能上,可以从这套方案开始试。

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

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

立即咨询