ShardingSphere-JDBC多租户分片实战:JPA、HikariCP与KingbaseES配置全解 简介一套面向多租户场景的Spring Boot集成方案基于ShardingSphere-JDBC与JPA构建并完成与人大金仓KingbaseES V8R6的适配。资源解决多租户数据隔离、分库分表、读写分离等核心问题同时针对性重写了JPA的saveAndFlush方法修复了更新数据时被置为null的常见隐患适合需要快速落地国产数据库分片方案的Java后端开发人员。压缩包共137个文件其中包含110个xml配置文件用于定义分片规则、租户策略与数据源路由另有Java源码、class编译文件及properties配置等整体仅136KB轻量易用。已有1520人浏览学习。解压后导入IDEA创建数据库即可直接运行测试便于读者验证多租户下分库分表与读写分离的真实效果也可作为JPA兼容国产数据库的改造参考。1. 多租户骨架里的分片层ShardingSphere-JDBC 第一次和 JPA 正面相遇在给政务系统做多租户改造时我拿到过同一个组合Spring Boot、Spring Data JPA、HikariCP 连接池、人大金仓 KingbaseES V8R6以及一套基于 ShardingSphere-JDBC 的分库分表和读写分离配置。刚开始以为只是把连接池顺手换成 Hikari、再配两份分片规则真正跑起来才发现问题集中在两条线上一条是 JPA 的实体管理对分片键完全无感知另一条是saveAndFlush在 ShardingSphere 下的行为被放大更新数据为 null 的坑就是从这条线里冒出来的。这个组合适合两种人一类是正在把单库单表改成多租户库表结构的后端另一类是接手别人留下的分片工程、需要搞清规则与 ORM 边界的人。下面把拆解过程中有用的配置路径、分片思路和 JPA 修复方法整理出来。2. HikariCP 连接池与 KingbaseES V8R6 驱动的落地配置使用 ShardingSphere-JDBC 时核心数据源不是传统 Spring Boot 里的单一数据源 Bean。需要先在application.yml里定义一组物理数据源主库、从库、分片库都有可能再让 ShardingSphere 把它们编排成一个逻辑数据源。最后只有逻辑数据源会注册成 BeanJPA、Hibernate 和事务管理器拿到的都是它。项目里的物理数据源没有选默认的 Tomcat JDBC Pool而是统一指定了 HikariDataSource。原因很直接Hikari 的 fastPathPool 加锁方式在高并发的租户请求下竞争更小而且它的连接泄漏检测在分片表频繁切换连接时比很多连接池更敏锐。一个常见的基础配置长这样spring: shardingsphere: datasource: names: ds-write,ds-read-0,ds-read-1 ds-write: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.kingbase8.Driver jdbc-url: jdbc:kingbase8://192.168.10.21:54321/db_multi_tenant?currentSchemapublic username: app_user password: app_password maximum-pool-size: 32 connection-timeout: 30000 ds-read-0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.kingbase8.Driver jdbc-url: jdbc:kingbase8://192.168.10.22:54321/db_multi_tenant?currentSchemapublic username: app_user password: app_password maximum-pool-size: 16 connection-timeout: 30000 ds-read-1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.kingbase8.Driver jdbc-url: jdbc:kingbase8://192.168.10.23:54321/db_multi_tenant?currentSchemapublic username: app_user password: app_password maximum-pool-size: 16 connection-timeout: 30000注意这段配置里的ds-write、ds-read-0、ds-read-1都是物理数据源ShardingSphere 会把还带横线或下划线的数据源名原样保留。这里真正在起作用的是 Hikari 原生属性maximum-pool-size和connection-timeoutShardingSphere 的 DataSource 装载器会把未知属性反射写入 HikariDataSource。如果遇到参数没生效常见做法是把 Hikari 参数放到spring.shardingsphere.props之外的初始化类里手动设置再用ShardingSphereDataSourceFactory包装不改动 yaml 里既有的物理数据源结构。这几个 Hikari 参数在多租户 JPA 场景里值得认真调参数推荐值说明connection-timeout30000KingbaseES 在高负载下获取连接较慢低于 10s 容易误判超时maximum-pool-size32一个租户一个事务再大吞吐提升不再明显max-lifetime1800000必须小于数据库侧的 wait_timeout避免连接被服务端回收validation-timeout5000必须小于 connection-timeoutHikari 的连接存活校验才有意义配置完物理数据源后JPA 侧不能用spring.datasource.url再指一份普通数据源否则 ShardingSphere 的逻辑数据源和 Boot 自动装配出来的数据源会互相抢事务。项目里解压后的src/main/resources下只有一个shardingsphere相关配置application.yml里不出现裸的spring.datasource块这正是接入 ShardingSphere 后最容易漏的一步。再说 KingbaseES 的驱动和 JPA 方言。KingbaseES V8R6 通常运行在 PostgreSQL 兼容模式JDBC 驱动类是com.kingbase8.DriverURL 前缀是jdbc:kingbase8://注意不是jdbc:kingbase://。Hibernate 没有自带人大金仓的方言最常见的做法是在 JPA 配置里显式指定 PostgreSQL 方言spring: jpa: database-platform: org.hibernate.dialect.PostgreSQL10Dialect hibernate: ddl-auto: update properties: hibernate.jdbc.batch_size: 20 hibernate.order_updates: true hibernate.batch_versioned_data: true逻辑说明PostgreSQL10Dialect 会让 Hibernate 生成nextval序列调用和limit ?分页语法这两点与 KingbaseES V8R6 的兼容模式一致。order_updates的作用是让 Hibernate 按主键顺序生成 update 语句避免多线程环境里分片表出现同一行记录的更新乱序。batch_versioned_data配合批量更新减少 KingbaseES 端的往返。实际启动时如果报方言不支持或语法解析错误优先检查 dialect 和 driver 是否同属 PostgreSQL 兼容链路不要先怀疑分片配置。3. 按租户分片还是按业务主键分片复合分片键与分片算法多租户系统最忌讳把所有租户的数据按业务主键均匀打散因为tenant_id是查询的天然前缀。如果只按id做唯一分片键租户 1001 和租户 1002 的记录会散落到不同库表跨租户查询时 ShardingSphere 必须广播到全部分片过滤成本和误伤都很难控。项目里的做法是复合分片库路由看tenant_id表路由看tenant_id id。这样每个租户的数据落在固定的某一对表上普通业务查询只需访问一个库表。分片规则配置示意rules: sharding: tables: book: actual-data-nodes: ds_$-{0..1}.book_$-{0..1} database-strategy: complex: sharding-columns: tenant_id sharding-algorithm-name: tenant_mod table-strategy: complex: sharding-columns: id,tenant_id sharding-algorithm-name: book_prefix key-generate-strategy: column: id key-generator-name: snowflake binding-tables: book algorithms: tenant_mod: type: MOD props: sharding-count: 2 book_prefix: type: CLASS_BASED props: strategy: complex algorithmClassName: com.tenant.sharding.BookPrefixShardingAlgorithm snowflake: type: SNOWFLAKE props: worker-id: 1ds_$-{0..1}是 ShardingSphere 的枚举表达式展开后得到ds_0和ds_1book_$-{0..1}同理。binding-tables声明book为绑定表让book和其他绑定到一起的表在关联查询时保持相同的分片路由避免跨库 join。tenant_mod用内置的 MOD 算法按租户号取模分库表路由则交给自定义的book_prefix算法类因为这里的分片键不是一个而是id tenant_id。算法类实现public class BookPrefixShardingAlgorithm implements ComplexKeysShardingAlgorithmLong { Override public CollectionString doSharding(CollectionString availableTargetNames, ComplexKeysShardingValueLong shardingValue) { Long tenantId shardingValue.getColumnNameAndShardingValuesMap() .get(tenant_id).iterator().next(); Long id shardingValue.getColumnNameAndShardingValuesMap() .get(id).iterator().next(); int dbIndex (int) (tenantId % 2); int tableIndex (int) ((tenantId id) % 2); return availableTargetNames.stream() .filter(name - name.startsWith(ds_ dbIndex .)) .filter(name - name.endsWith(.book_ tableIndex)) .collect(Collectors.toSet()); } }这段代码返回的是物理表名集合ShardingSphere 会把所有以ds_0.开头且以.book_1结尾的节点都作为目标。所以两个 filter 必须同时写少一个都会把其他库的表选进来。这里有一个常见的误用直接用name.endsWith(String.valueOf(dbIndex))过滤会把ds_1.book_0这种表也选中因为book_0同样以0结尾。先按库前缀过滤再按表后缀过滤才能保证复合路由不出界。分片算法类型的选型可以直接看这张表算法类型配置 type适用场景边界MODMOD单分片键、按取模散列分片数变更时需要迁移数据INLINEINLINE简单的等值分片不支持多键组合配置里写死表达式CLASS_BASEDCLASS_BASED复合分片键、需要代码兜底每次都要维护算法类适合规则复杂时INTERVALINTERVAL按时间归档多租户场景不常用适合日志表JPA 实体与物理表名之间还要注意一个细节实体上的Table(name book)只是逻辑表名运行时 ShardingSphere 会替换成book_0或book_1。如果在 KingbaseES 的 PostgreSQL 兼容模式下遇到relation book_0 does not exist检查是不是 JPA 的命名策略把book转成了大写BOOK。可以在配置里固定物理命名策略spring.jpa.hibernate.naming.physical-strategyorg.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl让表名严格保持book不变。这样分片表和实体的映射关系才不会被命名转换打断。4. 读写分离下的 Transactional 路由与只读事务陷阱第 2 章配置里已经出现了ds-read-0和ds-read-1两个从库物理节点。要让 ShardingSphere 识别主从关系需要在 rules 里配置 readwrite-splitting并把分片规则里的实际数据节点改成逻辑数据源名。配置如下rules: readwrite-splitting: >Transactional(readOnly true) public ListBook listByTenant(Long tenantId) { return bookRepository.findByTenantId(tenantId); }这里要理解readOnly true不是提示 Hibernate 不做脏检查而是给 ShardingSphere 一个路由标记。事务内第一次 SQL 决定了它绑定哪个数据源后续操作在这个事务里不会再切换到另一个数据源因此一个事务里先写后读时即使方法加了readOnly也会固定在主库。不要试图在同一个事务方法里同时完成大量读和少量写让读写分离失效。强制某一次查询走主库是常见的刚性需求比如数据刚从主库写入从库还没同步完成时二次查询必须拿到刚写入的那条记录。标准做法用 HintManagerTransactional public Book syncBook(Long id) { try (HintManager hintManager HintManager.getInstance()) { hintManager.setWriteRouteOnly(); return bookRepository.findById(id).orElse(null); } }这段代码先把路由标记放到 ThreadLocal 里try-with-resources确保方法结束时标记被清除避免路由标记污染连接池里的其他线程。setWriteRouteOnly是 ShardingSphere 5.x 提供的强制主库路由入口比在方法里临时拼一个写 SQL 干净得多。调试时打开spring.shardingsphere.props.sql-show: true控制台会输出实际执行在哪个物理数据源上的 SQL能快速判断readOnly和 HintManager 是否真的生效。主从路由场景整理如下场景路由目标做法纯查询从库方法加Transactional(readOnly true)写操作主库默认行为不需要额外配置主从延迟时的二次读主库HintManager.setWriteRouteOnly()同一事务先写后读主库不切库换REQUIRES_NEW拆事务还有一个容易被忽略的指标从库负载均衡算法。ROUND_ROBIN 适合所有从库配置相同的场景如果ds-read-0和ds-read-1的机器规格不同需要换成 WEIGHT 算法配置读权重避免性能差的从库被轮询到过多请求。负载均衡器类型在load-balancers中声明并在readwrite-splitting.data-sources.rwds.load-balancer-name中引用。整个读写分离配置遵循一个原则物理数据源只负责“连接”逻辑数据源才负责“路由”。5. 重写 saveAndFlush把“更新为 null”堵在仓库入口Spring Data JPA 的saveAndFlush对非空 ID 的实体执行 mergemerge 会先从数据库和持久化上下文里查出已有实体再把传入实体的所有字段合并进托管实体。只要有字段在客户端被置空最终生成的 update 语句就会把这个字段重新写为空。普通数据库场景里还能忍受多租户分片表场景里就很难排查一条记录从一处分片迁移到另一处时null 字段导致的隐式覆盖会被误认为分片丢数据。项目里重写的JpaRepositoryReBuild类绕开了 merge自己控制非空字段的拷贝Repository public class JpaRepositoryReBuildT, ID { PersistenceContext private EntityManager em; Transactional public S extends T S saveAndFlush(S entity) { ID id (ID) em.getEntityManagerFactory().getPersistenceUnitUtil() .getIdentifier(entity); if (id null) { em.persist(entity); em.flush(); return entity; } T managed em.find((ClassT) entity.getClass(), id); if (managed null) { em.persist(entity); em.flush(); return entity; } copyNonNullFields(entity, managed); em.flush(); return managed; } private S extends T void copyNonNullFields(S source, T target) { Arrays.stream(source.getClass().getDeclaredFields()) .filter(field - !field.getName().equals(id)) .forEach(field - { try { field.setAccessible(true); Object value field.get(source); if (value ! null) { field.set(target, value); } } catch (IllegalAccessException e) { throw new RuntimeException(字段拷贝失败: field.getName(), e); } }); } }关键点在em.find拿到托管实体再把传入实体上的非空字段拷贝过去最后 flush。整个更新过程不经过 mergenull 字段永远不会进入更新语句。这里的反射拷贝只是为了演示实际项目里可以用 Hibernate 的PropertyAccessor或字段元数据缓存替代避免每次写入都做全量反射遍历。注意copyNonNullFields里排除了 id 字段否则主键被覆盖成 null会导致 ShardingSphere 在路由阶段拿不到分片键直接抛出路由异常。除了重写仓库实现老项目里常有人试图用DynamicUpdate解决 null 覆盖问题要区分两者的边界方案生成 SQL 行为局限性DynamicUpdateupdate 只包含发生变更的字段是 Hibernate 层优化null 字段不会出现但依赖 Hibernate 状态检测重写 saveAndFlush手动决定哪些字段参与更新代码可控所有分片表统一生效直接em.merge全字段 merge无法解决 null 覆盖推荐避免DynamicUpdate在普通单表下很有效但多租户分片场景里实体从查询到修改往往不在同一个持久化上下文Hibernate 的状态检测可能不准确最终 update 还是会带上 null。重写saveAndFlush的思路反而简单把“更新哪些字段”从 Hibernate 的推断变成显式编码。这个修复放在仓库入口之后所有 Service 层调用saveAndFlush的路径都被接管不用逐个 Controller 检查字段是否被置空。实际排查分片表数据被覆盖时优先看这个入口比在 SQL 日志里翻大字段 update 快得多。本文还有配套的精品资源点击获取