ShardingSphere JDBC与MyBatis整合中saveBatch主键回填失效的深度解析与解决方案
1. 问题复现:当批量插入遇上分库分表,你的ID去哪儿了?
最近在项目里踩了个坑,折腾了好几天,感觉有必要跟大家分享一下。我们项目用的是 Spring Boot + MyBatis-Plus 这套经典组合,数据库是 PostgreSQL,主键配置了自增。一开始,所有功能都跑得挺顺,新增数据后,实体对象里的id字段都能自动拿到数据库生成的值,这功能在 MyBatis-Plus 里叫“主键回填”,用起来特别省心。
后来因为业务数据量上来了,单表扛不住,就引入了ShardingSphere JDBC来做分库分表。配置路由规则、改 SQL,一顿操作下来,普通的单条插入、查询都正常,心里还美滋滋的。结果一到某个需要批量导入数据的场景,用了 MyBatis-Plus 的saveBatch方法,问题就来了:数据是成功插进去了,但返回的实体对象列表里,每个对象的id字段全是null或者默认值(比如0)。这就尴尬了,我后续的业务逻辑还等着用这些新生成的 ID 去做关联操作呢,ID 没了,链路直接就断了。
这个现象非常典型:在整合了 ShardingSphere JDBC 后,使用 MyBatis 或 MyBatis-Plus 的批量插入方法(如saveBatch、insertBatchSomeColumn)时,数据库自增主键无法正确回填到实体对象中。单条插入(save或insert)通常没问题,问题只出在批量操作。如果你也遇到了同样的情况,别慌,这几乎可以确定是 ShardingSphere JDBC 与 ORM 框架在批量处理机制上存在兼容性问题,不是你代码写错了。接下来,我们就一起挖一挖这背后的原因,并找到可靠的解决方案。
2. 深度剖析:ShardingSphere JDBC 是如何“弄丢”你的主键的?
要解决问题,得先搞清楚问题是怎么产生的。我们不能只停留在“它不工作了”的表面,得看看在saveBatch这个过程中,数据到底经历了什么。
2.1 标准流程:没有 ShardingSphere 时,主键是怎么回家的?
我们先回忆一下,在单纯的 MyBatis-Plus + 数据库驱动环境下,一次成功的批量插入并回填主键是怎么完成的:
- MyBatis-Plus 的魔法:当你调用
saveBatch(entityList)时,MyBatis-Plus 底层会为这个列表生成一条带有RETURNING子句(PostgreSQL)或者利用Statement.RETURN_GENERATED_KEYS标志(MySQL)的插入 SQL。 - JDBC 的标准操作:生成的 SQL 通过 JDBC
PreparedStatement执行。关键在这里,JDBC 驱动在执行插入语句后,如果被告知需要返回生成的主键,它会通过Statement的getGeneratedKeys()方法拿到一个ResultSet,里面就包含了刚插入数据的所有自增 ID。 - ORM 的赋值:MyBatis-Plus 会遍历这个
ResultSet,并按照执行顺序,将取出的 ID 值逐个设置回你传入的实体对象列表中对应的对象属性上。至此,宾主尽欢。
这个过程依赖于 JDBC 规范的标准接口。只要数据库驱动和 ORM 框架都遵守这个规范,主键回填就是透明的、自动的。
2.2 搅局者:ShardingSphere JDBC 的重写与拦截
ShardingSphere JDBC 的核心工作是在应用层对 JDBC 进行“增强”或“重写”。它扮演了一个代理的角色,你的应用代码调用 JDBC 接口,实际被 ShardingSphere 的包装类接管了。它的主要任务是根据你配置的分片规则,对 SQL 进行解析、改写、路由,然后分发到不同的真实数据库节点上去执行。
问题就出在它对PreparedStatement的addBatch()和executeBatch()这两个方法的处理上。为了支持跨多个数据库节点的批量操作,ShardingSphere 必须实现自己的批量逻辑:
- SQL 解析与路由:ShardingSphere 会拦截你通过 MyBatis-Plus 生成的批量插入 SQL。它并不是简单地把这条 SQL 发到某个库,而是要根据分片键的值,决定每条数据应该插入到哪个库的哪个表。
- 批量分组:假设你批量插入100条数据,根据分片规则,可能30条去
ds0.user_0,40条去ds0.user_1,另外30条去ds1.user_0。ShardingSphere 会在内存中将这100条数据分组,并为每个目标数据源和表创建独立的、真正的 JDBCPreparedStatement。 - 执行与结果归并:ShardingSphere 会依次执行这些分散的
PreparedStatement的executeBatch()。关键的断裂点就在这里:每个真实的PreparedStatement执行后,确实会返回生成的主键。但是,ShardingSphere 在它的ShardingSpherePreparedStatement实现中,可能没有妥善地收集、保存这些从各个真实数据库节点返回的生成键结果集。它更专注于 DML 执行是否成功(返回的影响行数),而对于getGeneratedKeys()这个方法的支持,在批量模式下出现了缺失或漏洞。 - 结果丢失:当 MyBatis-Plus 满怀期待地调用
ShardingSpherePreparedStatement.getGeneratedKeys()时,它拿到的是一个空的结果集或者顺序错乱的结果集,自然就无法正确回填 ID 了。
简单来说,ShardingSphere 像一个忙碌的邮差分拣员,他把你的信(数据)准确送到了各个街区(分库分表),但却忘了把每个收件人的回执(生成的主键)收集齐、按顺序带回来给你。
2.3 官方态度与社区现状
我在排查这个问题时,也去 GitHub 上搜了相关 issue。确实存在不少历史讨论,比如#9592、#3207等。从官方维护者的回复来看,他们的核心立场是:ShardingSphere 的首要目标是保证与 JDBC 接口的兼容性,而非与所有第三方 ORM 框架(如 MyBatis, Hibernate)的深度整合。
他们的意思是,只要 JDBC 标准接口的行为被正确实现,他们的任务就完成了。至于上游的 ORM 框架如何利用这些接口,特别是像批量主键回填这种比较细节的交互,如果出了问题,优先级可能不会排到最高。这倒也不是推卸责任,毕竟 ORM 框架众多,每个框架对 JDBC 的用法都有细微差别,全量兼容确实是个巨大工程。但这对于我们开发者来说,就意味着需要自己寻找出路。好消息是,社区里总有热心的大神提供解决方案,我们接下来就看看具体怎么搞定它。
3. 解决方案一:多数据源混合配置(治标且实用)
第一种思路比较“务实”,或者说有点“绕道而行”。既然 ShardingSphere JDBC 在批量插入主键回填上有问题,那我们能不能只在需要分片的表上使用它,而对于那些需要频繁批量插入并获取ID的非分片表(或者对性能要求不极致、可以接受单表操作的表),绕开 ShardingSphere,直接使用原生的数据源呢?
答案是肯定的。这其实就是多数据源配置。你的应用里同时存在两个数据源:一个是被 ShardingSphere 代理的、用于分片操作的shardingDataSource;另一个是直连数据库的、原生的rawDataSource。
具体操作步骤:
定义两个数据源 Bean:在你的配置类中,显式地创建两个
DataSource。@Configuration public class DataSourceConfig { // 原生数据源,用于非分片操作(特别是需要批量回填主键的) @Bean @ConfigurationProperties(prefix = "spring.datasource.raw") public DataSource rawDataSource() { return DataSourceBuilder.create().build(); } // ShardingSphere 数据源,用于分片操作 @Bean public DataSource shardingDataSource() throws SQLException { // 这里加载你的分片规则配置,例如从YAML文件 Map<String, DataSource> dataSourceMap = ... // 包含 rawDataSource 或其他源 ShardingRuleConfiguration shardingRuleConfig = ... return ShardingSphereDataSourceFactory.createDataSource(dataSourceMap, Collections.singleton(shardingRuleConfig), new Properties()); } }注意,
shardingDataSource所依赖的dataSourceMap里,可以包含上面定义的rawDataSource,也可以包含其他物理库。配置 MyBatis 使用多个 SqlSessionFactory:你需要为不同的数据源创建不同的
SqlSessionFactory和Mapper扫描路径。@Configuration @MapperScan(basePackages = "com.yourpackage.mapper.sharding", sqlSessionFactoryRef = "shardingSqlSessionFactory") public class ShardingMyBatisConfig { @Bean public SqlSessionFactory shardingSqlSessionFactory(@Qualifier("shardingDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); // 其他配置,如 mapperLocations, typeAliasesPackage return bean.getObject(); } } @Configuration @MapperScan(basePackages = "com.yourpackage.mapper.raw", sqlSessionFactoryRef = "rawSqlSessionFactory") public class RawMyBatisConfig { @Bean public SqlSessionFactory rawSqlSessionFactory(@Qualifier("rawDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); return bean.getObject(); } }在 Service 层按需注入和使用:现在,你的 Mapper 接口根据所在的包路径,会自动绑定到对应的
SqlSessionFactory和数据源。@Service public class UserService { @Autowired private UserShardingMapper userShardingMapper; // 操作分片表 @Autowired private LogRawMapper logRawMapper; // 操作非分片日志表,需要批量插入 public void batchInsertLogs(List<Log> logs) { // 这个方法会使用 rawDataSource,MyBatis-Plus 的 saveBatch 主键回填功能正常 logRawMapper.insertBatchSomeColumn(logs); // 插入后,logs 里的每个对象都有了 id for (Log log : logs) { System.out.println("Generated ID: " + log.getId()); } } public void addUser(User user) { // 这个方法会走 ShardingSphere 代理,进行分片路由 userShardingMapper.insert(user); // 单条插入,主键回填通常也是正常的 } }
这种方案的优缺点:
- 优点:实现相对简单,能彻底规避 ShardingSphere 的批量主键回填 Bug。对非分片表的操作性能无损。
- 缺点:架构变复杂了,需要维护两套数据源和 Mapper 配置。事务管理变得更棘手,如果有一个业务需要同时操作分片表和非分片表并放在一个事务里,就需要引入分布式事务(如 Seata),复杂度飙升。所以,这更适合那些批量插入操作和分片操作耦合度不高的业务场景。
4. 解决方案二:代码级修复(治本但需动手)
如果你觉得维护多套数据源太麻烦,或者你的业务就是需要在一个事务里批量插入分片表并立刻使用其ID,那么就需要直面问题,从代码层面修复 ShardingSphere 的行为。这需要你深入了解其内部机制,并且有一定的定制能力。
核心思路是:增强 ShardingSphere 的ShardingSpherePreparedStatement类,在其批量执行过程中,显式地缓存每个真实PreparedStatement返回的生成键,并在getGeneratedKeys()方法中正确地合并和返回它们。
社区大神(比如在 MyBatis-Plus 的 issue #3207 中提到的方案)已经指出了方向。关键在于重写addBatch()方法,并确保关联的PreparedStatement对象被缓存起来。请注意,这个方案通常需要 ShardingSphere 5.2.1 及以上版本,因为相关类和方法可能在新版本中才暴露或易于扩展。
实施步骤(概念性代码,需根据实际版本调整):
创建自定义的
ShardingSpherePreparedStatement子类:import org.apache.shardingsphere.driver.jdbc.core.statement.ShardingSpherePreparedStatement; import org.apache.shardingsphere.infra.executor.sql.context.ExecutionUnit; import org.apache.shardingsphere.infra.executor.sql.execute.engine.driver.jdbc.JDBCExecutionUnit; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; public class FixedGeneratedKeysPreparedStatement extends ShardingSpherePreparedStatement { // 用于缓存批量操作中每个真实 PreparedStatement 返回的生成键 private List<ResultSet> cachedGeneratedKeys = new ArrayList<>(); // 缓存真实的 PreparedStatement,确保后续能调用其 getGeneratedKeys private List<PreparedStatement> cachedStatements = new ArrayList<>(); public FixedGeneratedKeysPreparedStatement(...) { // 构造函数参数需与父类匹配 super(...); } @Override public void addBatch() throws SQLException { // 先调用父类逻辑,完成SQL解析、路由等 super.addBatch(); // 关键:在父类执行后,获取当前批次对应的真实 ExecutionUnit 和 PreparedStatement // 这里需要利用反射或访问父类protected方法,来拿到 executionContext 或 executionGroupContext // 假设我们能通过某个方法拿到当前批次的所有 JDBCExecutionUnit Collection<JDBCExecutionUnit> currentBatchUnits = getCurrentBatchExecutionUnits(); // 这个方法需要你自己根据源码结构实现 for (JDBCExecutionUnit unit : currentBatchUnits) { PreparedStatement realStatement = (PreparedStatement) unit.getStorageResource(); if (!cachedStatements.contains(realStatement)) { cachedStatements.add(realStatement); } } } @Override public int[] executeBatch() throws SQLException { // 执行前清空上一次的缓存 cachedGeneratedKeys.clear(); // 调用父类执行 int[] result = super.executeBatch(); // 执行后,立即从所有缓存的真实 Statement 中获取生成键 for (PreparedStatement stmt : cachedStatements) { ResultSet keys = stmt.getGeneratedKeys(); if (keys != null) { cachedGeneratedKeys.add(keys); } } cachedStatements.clear(); // 清空 statement 缓存,为下一批次准备 return result; } @Override public ResultSet getGeneratedKeys() throws SQLException { // 这里需要将 cachedGeneratedKeys 中的多个 ResultSet 合并成一个 // 合并时需要特别注意顺序,必须与原始数据的插入顺序保持一致! // 这是一个复杂的操作,可能需要自定义一个 MergedResultSet 类 return mergeGeneratedKeys(cachedGeneratedKeys); } private ResultSet mergeGeneratedKeys(List<ResultSet> resultSets) throws SQLException { // 实现合并逻辑,例如使用 ShardingSphere 自带的 TransparentResultSet 或自己实现 // 伪代码:遍历所有resultSets,将每一行的键值收集到一个列表中,然后包装成新的ResultSet返回 // 注意处理关闭资源等问题 // ... } // 需要反射或其它方式获取父类内部状态的方法 private Collection<JDBCExecutionUnit> getCurrentBatchExecutionUnits() { // 实现省略,这需要深入阅读 ShardingSphere 对应版本的源码 return Collections.emptyList(); } }创建自定义的
Connection子类来返回我们的 Statement:public class FixedGeneratedKeysConnection extends AbstractConnectionAdapter { private final Connection realConnection; public FixedGeneratedKeysConnection(Connection connection) { this.realConnection = connection; } @Override public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { // 当 MyBatis 要求返回生成键时,返回我们自定义的 PreparedStatement if (autoGeneratedKeys == Statement.RETURN_GENERATED_KEYS) { // 这里需要调用 ShardingSphere 的底层方法来创建原始的 ShardingSpherePreparedStatement // 然后将其包装成我们的 FixedGeneratedKeysPreparedStatement PreparedStatement innerStatement = realConnection.prepareStatement(sql, autoGeneratedKeys); // 假设有办法将 innerStatement 转换成可操作的上下文 return new FixedGeneratedKeysPreparedStatement(...); } return super.prepareStatement(sql, autoGeneratedKeys); } // ... 重写其他 prepareStatement 方法 }通过 DataSource 包装器注入自定义 Connection:你需要创建一个自定义的
DataSource,在创建Connection时返回我们包装过的FixedGeneratedKeysConnection。
这种方案的优缺点:
- 优点:从根源上解决问题,对所有使用该数据源的操作透明,无需业务代码做任何改动。
- 缺点:实现难度极高,严重依赖 ShardingSphere 的内部实现细节,代码侵入性强。一旦 ShardingSphere 版本升级,内部 API 变动,你的定制代码很可能需要重写甚至失效。维护成本巨大。
更可行的变通方案:考虑到直接修改核心类的复杂性,一个更实际的“代码级”方案是,在 Service 层放弃使用saveBatch,改为循环调用save。虽然这会带来一定的性能损耗(网络往返次数增加),但对于数据量不是特别巨大的批量操作(比如一次几百条),在事务内循环单条插入,主键回填是正常的。你可以权衡业务对性能和主键需求的紧迫性来做选择。或者,可以考虑使用数据库特有的批量 COPY 命令(如 PostgreSQL 的COPY)配合自定义 ID 生成器(如雪花算法)来规避自增主键回填的问题。
5. 版本选择与升级建议:为什么强调 5.2.1+?
在社区提供的修复方案和讨论中,经常会看到对ShardingSphere 5.2.1及以上版本的强调。这是为什么呢?主要基于以下几点:
- API 稳定性与可扩展性:5.x 版本相较于早期的 4.x 版本,内核进行了大规模重构,API 设计更加清晰和稳定。一些在 4.x 版本中可能是包私有或内部的方法,在 5.x 版本中可能变成了
protected或提供了 SPI 扩展点,使得开发者有机会通过继承或插件的方式定制行为。虽然我们上面的“代码级修复”依然困难,但在高版本中寻找切入点相对容易一些。 - Bug 修复与特性完善:Apache 社区在持续迭代,5.2.1 作为一个修复版本,可能已经解决了早期 5.x 版本中一批已知的、影响稳定性的问题。使用较新的修复版本作为基础,能避免你掉进已经填平的坑里。
- 官方态度的潜在转变:虽然官方对 ORM 整合问题的优先级不一定最高,但随着版本迭代,社区反馈的声音会被逐渐重视。在较新的版本中,或许会有相关的改进或至少是更清晰的扩展指引。从 issue 的历史看,确实有贡献者在尝试提交 PR 修复类似问题,这些修复更可能被合并到新版本的主干中。
给你的建议是:如果你的项目正在选型或计划引入 ShardingSphere,强烈建议直接从 5.3.x 或更新的稳定版本开始。并仔细阅读其官方文档中关于“与 ORM 框架整合”的章节(如果有的话)以及版本更新日志,看看是否有关于生成键(Generated Keys)处理的改进说明。对于已经使用了较低版本(如 4.x)且受此问题困扰的项目,升级到 5.x 可能是一个值得评估的选项,但务必充分测试,因为版本间可能存在不兼容的配置变更。
6. 实战排查清单与避坑指南
当你怀疑遇到了 ShardingSphere JDBC 批量插入主键回填失效的问题时,不要急于修改代码,先按以下清单排查一遍,也许问题出在其他地方:
- 确认是否真的由 ShardingSphere 引起:最直接的验证方法是,在配置文件中临时关闭 ShardingSphere(比如注释掉相关配置,使用原生数据源),然后运行同样的批量插入代码。如果主键能正常回填,那么问题就可以锁定在 ShardingSphere 层。
- 检查数据库驱动和 JDBC URL:确保你使用的数据库驱动版本与 ShardingSphere 兼容。对于 PostgreSQL,JDBC URL 中可能需要显式配置
reWriteBatchedInserts=true参数来优化批量插入,但这个参数一般不影响主键回填。对于 MySQL,确保useGeneratedKeys和rewriteBatchedStatements等参数配置正确。 - 检查 MyBatis-Plus 配置:确认你的实体类主键字段使用了
@TableId(type = IdType.AUTO)注解(对于自增主键)。检查全局配置mybatis-plus.global-config.db-config.id-type是否设置正确。 - 查看执行的 SQL 日志:开启 ShardingSphere 的 SQL 日志(
sql-show: true)和 MyBatis 的 SQL 日志。观察批量插入时,ShardingSphere 实际分发执行的 SQL 是什么样子。是否每条插入语句都正确包含了获取生成键的指令(如 PostgreSQL 的RETURNING id)?这能帮你判断问题出在 SQL 生成阶段还是结果集处理阶段。 - 尝试单条插入:在相同的数据源和配置下,尝试用
save方法单条插入数据,看主键是否能回填。如果单条可以而批量不行,那基本就是 ShardingSphere 批量处理逻辑的特定问题了。 - 查阅对应版本的 Issue:去 GitHub 的 Apache ShardingSphere 仓库和 MyBatis-Plus 仓库,用 “batch insert generated keys”、“saveBatch id return” 等关键词搜索 issue。看看是否有与你版本号完全相同的已知问题,以及是否有临时解决方案或官方修复的进度。
避坑指南:
- 明确需求:是否真的必须使用数据库自增主键?在分布式分库分表场景下,使用分布式 ID 生成器(如雪花算法、UUID)作为业务主键,可以彻底避免主键回填的烦恼,也是更常见的实践。
- 评估批量大小:如果批量操作的数据量不大(比如几十到几百条),循环单条插入虽然性能有损耗,但代码简单可靠,可以作为快速解决问题的备选方案。
- 保持版本更新:关注 ShardingSphere 的版本发布,有时官方会在新版本中悄然修复这类兼容性问题。在测试环境定期尝试升级小版本,也许有一天你会发现这个问题已经消失了。
在我自己的项目里,最终根据业务情况选择了“多数据源混合配置”方案。因为需要批量插入并立即使用 ID 的表恰好是操作日志表,它不需要分片,单独用一个原生数据源合情合理。这个过程中,最深的体会就是,在引入像 ShardingSphere 这样强大的中间件时,一定要对其“增强”和“代理”的本质有清醒认识,它可能在带来便利的同时,改变一些你习以为常的细节行为。遇到问题,耐心分析数据流,从原理层面思考,再结合社区智慧,总能找到适合自己项目的解决路径。
