欢迎你来读这篇博客,这篇博客主要是关于 Spring Boot 3.5、Spring Data JPA 和 Apache ShardingSphere-JDBC 的工程化整合。
很多文章讲 ShardingJDBC,最后只停留在“配置两个数据源,然后插入一条数据”。这篇文章会换一个角度:把它放到真实 Spring Boot 后端工程里看,重点讨论版本选择、工程结构、JPA 实体设计、审计字段、主键策略、分库分表配置、Repository 写法、事务边界、SQL 限制、生产运维和迁移路径。
本文使用 ShardingJDBC 这个历史上更常见的叫法,但实际依赖和配置均基于现在的 Apache ShardingSphere-JDBC。
序言 Spring Data JPA 和 ShardingSphere-JDBC 可以一起用,但不能“无脑一起用”。
原因很简单:JPA 的抽象目标是让你以实体为中心操作数据,而分库分表的目标是让 SQL 带着足够的路由条件落到确定的数据节点。两者的抽象方向并不完全一致。
JPA 喜欢这样写:
1 orderRepository.findById(orderId);
但分库分表系统更希望你这样写:
1 orderRepository.findByUserIdAndId(userId, orderId);
前者只知道主键,后者同时知道分片键。少了分片键,ShardingSphere 可能只能全路由。全路由在开发环境里看起来只是“慢一点”,到生产环境里就可能变成数据库连接被打满、查询抖动、分页不稳定、接口超时。
所以,Spring Boot + Spring Data JPA + ShardingSphere-JDBC 的核心不是“能不能跑”,而是:
JPA 实体怎么设计,才能不和分库分表冲突。
created_at、updated_at、created_by、updated_by 这类审计字段放在哪里维护。
主键到底由谁生成,是数据库、Hibernate、应用,还是 ShardingSphere。
Repository 方法如何约束,避免开发同学写出全路由查询。
事务怎么划边界,哪些操作必须避免跨库强一致。
上线以后如何观察路由、慢 SQL、连接池和主从延迟。
本文不会把 ShardingSphere 当作魔法。它本质上是在 JDBC 层做 SQL 解析、路由、改写、执行和结果归并。它可以让 Java 应用以一个逻辑数据源访问多组物理数据源,但不能替你消除分布式数据系统的复杂度。
正文 1. 先给结论 如果你正在做新项目,推荐基线如下:
1 2 3 4 5 6 7 8 JDK: 17 或 21 Spring Boot: 3.5.x Spring Data JPA: 跟随 Spring Boot 管理 Hibernate ORM: 跟随 Spring Boot 管理 Apache ShardingSphere-JDBC: 5.5.3 Database: MySQL 8.x Connection Pool: HikariCP Migration: Flyway 或 Liquibase
工程上的关键结论:
Spring Boot 3.5 项目不要优先使用老版本的 shardingsphere-jdbc-spring-boot-starter。
推荐使用 org.apache.shardingsphere:shardingsphere-jdbc,通过 org.apache.shardingsphere.driver.ShardingSphereDriver 接入。
JPA 只连接 ShardingSphere 暴露出来的逻辑数据源,不直接感知多个物理库。
JPA 实体上的 @Table 应该写逻辑表名,例如 t_order,不要写 t_order_0、t_order_1 这种物理表名。
生产环境不建议用 Hibernate 自动建表,spring.jpa.hibernate.ddl-auto 应设为 none 或 validate。
分片表不要依赖数据库自增主键。
和 JPA 搭配时,主键最推荐由应用生成,再由 JPA 带着 ID 写入。
Repository 查询必须尽量携带分片键。
一对多、多对多、级联保存、懒加载集合这些 ORM 能力在分库分表场景要非常克制。
跨库事务不是默认方案,默认应通过业务建模避免跨库强一致。
2. ShardingJDBC 和 ShardingSphere-JDBC 是什么关系 早期大家常说的 ShardingJDBC,现在已经是 Apache ShardingSphere 生态中的 ShardingSphere-JDBC。
它是一个增强版 JDBC Driver。应用侧看起来仍然是在使用一个 DataSource,但这个数据源背后可以管理多个真实数据库连接池。
典型调用链如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 Controller -> Application Service -> Spring Data JPA Repository -> EntityManager / Hibernate -> ShardingSphereDataSource -> SQL Parse -> SQL Route -> SQL Rewrite -> SQL Execute -> Result Merge -> MySQL shard 0 -> MySQL shard 1 -> MySQL shard n
对 JPA 来说,它只知道自己拿到了一个普通 DataSource。
对 ShardingSphere 来说,它只看到了 Hibernate 发出来的 SQL。
真正的协作点就在 SQL 层:Hibernate 生成的 SQL 必须足够清晰、稳定,并且尽量带着分片键。
3. 示例业务场景 本文使用一个订单系统作为示例。
业务表:
1 2 3 t_order t_order_item t_region
其中:
t_order 是订单主表,按 user_id 分库,按 order_id 分表。
t_order_item 是订单明细表,和订单主表绑定,按相同规则路由。
t_region 是地区字典表,所有库都需要一份,使用广播表。
物理结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 order_ds_0 t_order_0 t_order_1 t_order_2 t_order_3 t_order_item_0 t_order_item_1 t_order_item_2 t_order_item_3 t_region order_ds_1 t_order_0 t_order_1 t_order_2 t_order_3 t_order_item_0 t_order_item_1 t_order_item_2 t_order_item_3 t_region
逻辑结构:
1 2 3 t_order t_order_item t_region
应用代码永远只写逻辑表,永远不写物理表。
4. Maven 依赖 示例使用 Maven。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 <project xmlns ="http://maven.apache.org/POM/4.0.0" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation ="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" > <modelVersion > 4.0.0</modelVersion > <parent > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-parent</artifactId > <version > 3.5.15</version > <relativePath /> </parent > <groupId > com.example</groupId > <artifactId > jpa-shardingsphere-demo</artifactId > <version > 1.0.0</version > <properties > <java.version > 21</java.version > <shardingsphere.version > 5.5.3</shardingsphere.version > </properties > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-jpa</artifactId > </dependency > <dependency > <groupId > org.apache.shardingsphere</groupId > <artifactId > shardingsphere-jdbc</artifactId > <version > ${shardingsphere.version}</version > </dependency > <dependency > <groupId > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-core</artifactId > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-mysql</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies > </project >
几个说明:
spring-boot-starter-data-jpa 带来 Spring Data JPA 和 Hibernate。
shardingsphere-jdbc 是 ShardingSphere-JDBC 核心依赖。
mysql-connector-j 是真实连接 MySQL 需要的驱动。
Flyway 用于生产 DDL 管理。
不再引入旧版 ShardingSphere Spring Boot Starter。
5. 工程目录 推荐目录结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 src/main/java/com/example/order JpaShardingApplication.java common audit JpaAuditingConfig.java SecurityAuditorAware.java id SnowflakeIdGenerator.java SnowflakeProperties.java web ApiResponse.java order api OrderController.java request CreateOrderRequest.java response OrderResponse.java application OrderCommandService.java OrderQueryService.java domain OrderStatus.java infrastructure persistence BaseAuditEntity.java OrderEntity.java OrderItemEntity.java OrderRepository.java OrderItemRepository.java src/main/resources application.yml shardingsphere.yaml db migration mysql V1__create_order_tables.sql
这个结构刻意把领域服务、接口对象、JPA 实体分开。原因是分库分表项目里,持久化规则本身就已经很复杂,不建议让 Controller 直接操作 Repository。
6. Spring Boot 配置 src/main/resources/application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 server: port: 8080 spring: application: name: jpa-shardingsphere-demo datasource: driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver url: jdbc:shardingsphere:classpath:shardingsphere.yaml jpa: open-in-view: false hibernate: ddl-auto: none properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect jdbc: time_zone: UTC format_sql: false show_sql: false show-sql: false flyway: enabled: false logging: level: root: info org.hibernate.SQL: warn org.apache.shardingsphere: info app: id-generator: worker-id: 1
说明:
spring.datasource.url 指向 shardingsphere.yaml。
open-in-view: false 是后端接口项目的推荐实践,避免事务结束后还触发懒加载 SQL。
ddl-auto: none 表示 Hibernate 不负责建表。
hibernate.jdbc.time_zone: UTC 避免多时区部署时写入时间不一致。
flyway.enabled: false 是因为示例有多个物理库,生产中通常对每个真实库单独执行迁移,或者在发布流水线中显式执行。
如果你想在本地快速验证,可以先手动执行 DDL。生产环境不要依赖 Hibernate 自动建表。
7. ShardingSphere 配置 src/main/resources/shardingsphere.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 databaseName: order_logic_db mode: type: Standalone dataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver standardJdbcUrl: jdbc:mysql://127.0.0.1:3306/order_ds_0?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true username: root password: root maximumPoolSize: 20 minimumIdle: 5 connectionTimeout: 3000 idleTimeout: 600000 maxLifetime: 1800000 ds_1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver standardJdbcUrl: jdbc:mysql://127.0.0.1:3306/order_ds_1?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true username: root password: root maximumPoolSize: 20 minimumIdle: 5 connectionTimeout: 3000 idleTimeout: 600000 maxLifetime: 1800000 rules: - !SHARDING tables: t_order: actualDataNodes: ds_${0..1}.t_order_${0..3} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_database_inline tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_table_inline t_order_item: actualDataNodes: ds_${0..1}.t_order_item_${0..3} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_database_inline tableStrategy: standard: shardingColumn: order_id shardingAlgorithmName: order_item_table_inline bindingTables: - t_order,t_order_item defaultDatabaseStrategy: none: defaultTableStrategy: none: shardingAlgorithms: order_database_inline: type: INLINE props: algorithm-expression: ds_${user_id % 2 } allow-range-query-with-inline-sharding: false order_table_inline: type: INLINE props: algorithm-expression: t_order_${order_id % 4 } allow-range-query-with-inline-sharding: false order_item_table_inline: type: INLINE props: algorithm-expression: t_order_item_${order_id % 4 } allow-range-query-with-inline-sharding: false - !BROADCAST tables: - t_region props: sql-show: true sql-simple: false kernel-executor-size: 8 check-table-metadata-enabled: true
配置解释:
databaseName 是逻辑数据库名。
mode.type: Standalone 适合本地开发和单应用示例。
dataSources 是真实数据源。
ds_0 和 ds_1 是逻辑数据源名字,不是数据库名字。
standardJdbcUrl 是 HikariCP 场景下推荐使用的连接地址字段。
t_order.actualDataNodes 表示真实节点范围。
databaseStrategy 表示分库策略。
tableStrategy 表示分表策略。
bindingTables 表示绑定表,t_order 和 t_order_item 使用相同路由规则,关联查询时可以避免笛卡尔路由。
BROADCAST 表示广播表,适合地区、状态、配置字典这类小表。
sql-show: true 适合开发和测试环境,可以打印逻辑 SQL、真实 SQL 和路由信息。
生产环境通常关闭 sql-show,改用慢 SQL、采样日志和可观测性平台。
8. 建表脚本 每个物理库都需要创建对应物理表。
示例只写一个库的 DDL,order_ds_0 和 order_ds_1 都要执行。
1 2 3 4 CREATE DATABASE IF NOT EXISTS order_ds_0 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;CREATE DATABASE IF NOT EXISTS order_ds_1 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
订单表模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 CREATE TABLE t_order_0 ( order_id BIGINT NOT NULL , user_id BIGINT NOT NULL , order_no VARCHAR (64 ) NOT NULL , status VARCHAR (32 ) NOT NULL , total_amount DECIMAL (18 , 2 ) NOT NULL , remark VARCHAR (512 ) NULL , created_at DATETIME(6 ) NOT NULL , updated_at DATETIME(6 ) NOT NULL , created_by VARCHAR (64 ) NOT NULL , updated_by VARCHAR (64 ) NOT NULL , version BIGINT NOT NULL DEFAULT 0 , deleted TINYINT NOT NULL DEFAULT 0 , PRIMARY KEY (order_id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at), KEY idx_user_status_created (user_id, status, created_at) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
订单明细表模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 CREATE TABLE t_order_item_0 ( order_item_id BIGINT NOT NULL , order_id BIGINT NOT NULL , user_id BIGINT NOT NULL , sku_id BIGINT NOT NULL , sku_name VARCHAR (128 ) NOT NULL , quantity INT NOT NULL , sale_price DECIMAL (18 , 2 ) NOT NULL , created_at DATETIME(6 ) NOT NULL , updated_at DATETIME(6 ) NOT NULL , created_by VARCHAR (64 ) NOT NULL , updated_by VARCHAR (64 ) NOT NULL , version BIGINT NOT NULL DEFAULT 0 , deleted TINYINT NOT NULL DEFAULT 0 , PRIMARY KEY (order_item_id), KEY idx_order_user (order_id, user_id), KEY idx_user_order (user_id, order_id) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
广播表:
1 2 3 4 5 6 7 8 9 10 CREATE TABLE t_region ( region_code VARCHAR (32 ) NOT NULL , region_name VARCHAR (128 ) NOT NULL , parent_code VARCHAR (32 ) NULL , created_at DATETIME(6 ) NOT NULL , updated_at DATETIME(6 ) NOT NULL , PRIMARY KEY (region_code), KEY idx_parent_code (parent_code) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COLLATE = utf8mb4_0900_ai_ci;
然后复制生成:
1 2 3 4 5 6 7 CREATE TABLE t_order_1 LIKE t_order_0;CREATE TABLE t_order_2 LIKE t_order_0;CREATE TABLE t_order_3 LIKE t_order_0;CREATE TABLE t_order_item_1 LIKE t_order_item_0;CREATE TABLE t_order_item_2 LIKE t_order_item_0;CREATE TABLE t_order_item_3 LIKE t_order_item_0;
生产建议:
每个分片库必须有完全一致的表结构。
每个物理表必须有一致的索引。
不要手动改某一个分片表的字段。
DDL 应通过发布系统、Flyway、Liquibase 或 DBA 平台统一执行。
上线前必须校验所有物理表结构一致。
9. 审计字段如何设计 几乎所有生产表都应该有审计字段。
推荐字段:
1 2 3 4 5 6 created_at updated_at created_by updated_by version deleted
含义:
created_at: 创建时间,应用侧写入,UTC。
updated_at: 更新时间,应用侧写入,UTC。
created_by: 创建人,可以是用户 ID、系统账号、服务名。
updated_by: 更新人。
version: 乐观锁版本号。
deleted: 软删除标记。
9.1 为什么建议应用维护审计字段 有三种常见方案:
1 2 3 方案一: 数据库 DEFAULT 和 ON UPDATE 方案二: JPA Auditing 方案三: 业务代码手动 set
在 Spring Data JPA 项目里,推荐使用 JPA Auditing。
原因:
审计逻辑统一。
测试更容易。
不依赖某个数据库方言。
和实体生命周期一致。
对分库分表没有额外要求。
但要注意:如果你还有其他系统绕过应用直接写库,就需要数据库默认值或触发器兜底。生产环境最好明确一个事实:审计字段的主维护者是谁。
本文选择:
1 2 主维护者: Spring Data JPA Auditing 兜底策略: 数据库 NOT NULL 约束,不写默认值
9.2 开启 JPA Auditing 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.order.common.audit;import java.util.Optional;import org.springframework.context.annotation.Configuration;import org.springframework.data.domain.AuditorAware;import org.springframework.data.jpa.repository.config.EnableJpaAuditing;import org.springframework.context.annotation.Bean;@Configuration @EnableJpaAuditing(auditorAwareRef = "auditorAware") public class JpaAuditingConfig { @Bean AuditorAware<String> auditorAware () { return () -> Optional.of("system" ); } }
真实项目里,AuditorAware 应从登录态、网关 Header、JWT、内部任务上下文中获取当前操作者。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.order.common.audit;import java.util.Optional;import org.springframework.data.domain.AuditorAware;import org.springframework.security.core.Authentication;import org.springframework.security.core.context.SecurityContextHolder;public class SecurityAuditorAware implements AuditorAware <String> { @Override public Optional<String> getCurrentAuditor () { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !authentication.isAuthenticated()) { return Optional.of("anonymous" ); } return Optional.ofNullable(authentication.getName()).filter(name -> !name.isBlank()); } }
9.3 BaseAuditEntity 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 package com.example.order.order.infrastructure.persistence;import jakarta.persistence.Column;import jakarta.persistence.EntityListeners;import jakarta.persistence.MappedSuperclass;import jakarta.persistence.PostLoad;import jakarta.persistence.PostPersist;import jakarta.persistence.Transient;import jakarta.persistence.Version;import java.time.Instant;import org.springframework.data.annotation.CreatedBy;import org.springframework.data.annotation.CreatedDate;import org.springframework.data.annotation.LastModifiedBy;import org.springframework.data.annotation.LastModifiedDate;import org.springframework.data.domain.Persistable;import org.springframework.data.jpa.domain.support.AuditingEntityListener;@MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseAuditEntity <ID> implements Persistable <ID> { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) private Instant updatedAt; @CreatedBy @Column(name = "created_by", nullable = false, updatable = false, length = 64) private String createdBy; @LastModifiedBy @Column(name = "updated_by", nullable = false, length = 64) private String updatedBy; @Version @Column(name = "version", nullable = false) private Long version; @Column(name = "deleted", nullable = false) private Boolean deleted = Boolean.FALSE; @Transient private boolean newEntity = true ; @Override public boolean isNew () { return newEntity; } @PostLoad @PostPersist protected void markNotNew () { this .newEntity = false ; } public Instant getCreatedAt () { return createdAt; } public Instant getUpdatedAt () { return updatedAt; } public String getCreatedBy () { return createdBy; } public String getUpdatedBy () { return updatedBy; } public Long getVersion () { return version; } public Boolean getDeleted () { return deleted; } public void softDelete () { this .deleted = Boolean.TRUE; } }
这里实现 Persistable<ID> 非常重要。
因为本文推荐应用提前生成 ID。默认情况下,Spring Data JPA 会通过 ID 是否为 null 判断实体是不是新对象。如果你在保存前已经设置了 ID,Spring Data JPA 可能会走 merge,多一次查询,甚至引入一些不符合预期的行为。
实现 Persistable 后,可以明确告诉 Spring Data JPA:这个对象仍然是新对象,应该走 persist。
10. ID 策略如何设计 分库分表项目不要使用数据库自增主键作为核心业务主键。
不推荐:
1 2 @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
原因:
多个物理表之间自增值不互相感知。
不同库可能生成重复 ID。
插入前没有 ID,无法用 ID 做分表路由。
JPA 需要回填数据库生成值,和 SQL 改写层协作复杂。
后续扩容、迁移、归档不方便。
10.1 推荐方案 生产优先级:
1 2 3 4 5 第一选择: 应用侧生成 Long 类型分布式 ID 第二选择: 独立 ID 服务 第三选择: ShardingSphere keyGenerateStrategy 第四选择: UUID / ULID 字符串 不推荐: 数据库自增 ID
如果你使用 Spring Data JPA,最稳的方式是应用在 repository.save() 前生成 ID。
1 2 3 4 5 6 Application Service -> idGenerator.nextId() -> new OrderEntity(id, userId, ...) -> orderRepository.save(entity) -> Hibernate INSERT SQL includes order_id -> ShardingSphere routes by user_id and order_id
这样有几个好处:
JPA 实体保存前就有 ID。
ShardingSphere 路由时可以看到 order_id。
业务日志、消息、幂等表可以提前拿到 ID。
不依赖 JDBC generated keys 回填。
10.2 SnowflakeProperties 1 2 3 4 5 6 7 package com.example.order.common.id;import org.springframework.boot.context.properties.ConfigurationProperties;@ConfigurationProperties(prefix = "app.id-generator") public record SnowflakeProperties (long workerId) { }
启动类启用配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.order;import com.example.order.common.id.SnowflakeProperties;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.boot.context.properties.EnableConfigurationProperties;@SpringBootApplication @EnableConfigurationProperties(SnowflakeProperties.class) public class JpaShardingApplication { public static void main (String[] args) { SpringApplication.run(JpaShardingApplication.class, args); } }
10.3 SnowflakeIdGenerator 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 package com.example.order.common.id;import org.springframework.stereotype.Component;@Component public class SnowflakeIdGenerator { private static final long CUSTOM_EPOCH = 1704067200000L ; private static final long WORKER_ID_BITS = 10L ; private static final long SEQUENCE_BITS = 12L ; private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; private final long workerId; private long lastTimestamp = -1L ; private long sequence = 0L ; public SnowflakeIdGenerator (SnowflakeProperties properties) { if (properties.workerId() < 0 || properties.workerId() > MAX_WORKER_ID) { throw new IllegalArgumentException ("workerId must be between 0 and " + MAX_WORKER_ID); } this .workerId = properties.workerId(); } public synchronized long nextId () { long currentTimestamp = currentTimeMillis(); if (currentTimestamp < lastTimestamp) { throw new IllegalStateException ("Clock moved backwards. Refuse to generate id." ); } if (currentTimestamp == lastTimestamp) { sequence = (sequence + 1 ) & MAX_SEQUENCE; if (sequence == 0 ) { currentTimestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L ; } lastTimestamp = currentTimestamp; return ((currentTimestamp - CUSTOM_EPOCH) << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence; } private long waitNextMillis (long lastTimestamp) { long timestamp = currentTimeMillis(); while (timestamp <= lastTimestamp) { timestamp = currentTimeMillis(); } return timestamp; } private long currentTimeMillis () { return System.currentTimeMillis(); } }
生产注意事项:
workerId 必须全局唯一。
Kubernetes 环境不能随便用 Pod 序号,重启、扩缩容、滚动发布都要考虑重复。
可以从配置中心、数据库号段、注册中心、机器标签中分配 workerId。
必须监控机器时间回拨。
如果对时钟敏感,可以改成号段模式,或者接入专门 ID 服务。
10.4 ShardingSphere 内置 ID 生成器 ShardingSphere 内置支持 SNOWFLAKE 和 UUID 等分布式主键生成算法。配置示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 rules: - !SHARDING tables: t_order: actualDataNodes: ds_${0..1}.t_order_${0..3} keyGenerateStrategy: column: order_id keyGeneratorName: snowflake keyGenerators: snowflake: type: SNOWFLAKE props: worker-id: 1
但是在 Spring Data JPA 项目里,我更建议应用侧生成主键。
原因不是 ShardingSphere 的主键生成不能用,而是 JPA 对实体 ID 生命周期很敏感。JPA 保存实体时,实体对象、一级缓存、持久化上下文都希望知道主键是什么。让 SQL 路由层在更底层补主键,会让调试和一致性验证更麻烦。
如果你坚持使用 ShardingSphere 的 keyGenerateStrategy,至少要做这些验证:
repository.save(entity) 后实体 ID 是否能回填。
批量插入是否能正确回填。
事务回滚后 ID 是否会被业务错误复用。
Hibernate flush 时是否出现实体状态异常。
分片键是否足够路由。
压测下是否存在 ID 倾斜。
11. JPA 实体设计 11.1 OrderStatus 1 2 3 4 5 6 7 8 package com.example.order.order.domain;public enum OrderStatus { CREATED, PAID, CANCELLED, FINISHED }
11.2 OrderEntity 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 package com.example.order.order.infrastructure.persistence;import com.example.order.order.domain.OrderStatus;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.EnumType;import jakarta.persistence.Enumerated;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.math.BigDecimal;@Entity @Table(name = "t_order") public class OrderEntity extends BaseAuditEntity <Long> { @Id @Column(name = "order_id", nullable = false) private Long id; @Column(name = "user_id", nullable = false) private Long userId; @Column(name = "order_no", nullable = false, length = 64) private String orderNo; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) private OrderStatus status; @Column(name = "total_amount", nullable = false, precision = 18, scale = 2) private BigDecimal totalAmount; @Column(name = "remark", length = 512) private String remark; protected OrderEntity () { } private OrderEntity (Long id, Long userId, String orderNo, BigDecimal totalAmount, String remark) { this .id = id; this .userId = userId; this .orderNo = orderNo; this .totalAmount = totalAmount; this .remark = remark; this .status = OrderStatus.CREATED; } public static OrderEntity create (Long id, Long userId, String orderNo, BigDecimal totalAmount, String remark) { if (id == null ) { throw new IllegalArgumentException ("id must not be null" ); } if (userId == null ) { throw new IllegalArgumentException ("userId must not be null" ); } return new OrderEntity (id, userId, orderNo, totalAmount, remark); } @Override public Long getId () { return id; } public Long getUserId () { return userId; } public String getOrderNo () { return orderNo; } public OrderStatus getStatus () { return status; } public BigDecimal getTotalAmount () { return totalAmount; } public String getRemark () { return remark; } public void pay () { if (this .status != OrderStatus.CREATED) { throw new IllegalStateException ("Only CREATED order can be paid." ); } this .status = OrderStatus.PAID; } public void cancel () { if (this .status == OrderStatus.FINISHED) { throw new IllegalStateException ("Finished order can not be cancelled." ); } this .status = OrderStatus.CANCELLED; } }
注意点:
@Table(name = "t_order") 写逻辑表名。
@Id 不使用 @GeneratedValue。
userId 是分库键,业务查询必须尽量带上。
orderId 是分表键,插入前必须生成。
实体里不声明 @OneToMany 集合,避免懒加载和跨分片级联。
11.3 OrderItemEntity 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 package com.example.order.order.infrastructure.persistence;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.Id;import jakarta.persistence.Table;import java.math.BigDecimal;@Entity @Table(name = "t_order_item") public class OrderItemEntity extends BaseAuditEntity <Long> { @Id @Column(name = "order_item_id", nullable = false) private Long id; @Column(name = "order_id", nullable = false) private Long orderId; @Column(name = "user_id", nullable = false) private Long userId; @Column(name = "sku_id", nullable = false) private Long skuId; @Column(name = "sku_name", nullable = false, length = 128) private String skuName; @Column(name = "quantity", nullable = false) private Integer quantity; @Column(name = "sale_price", nullable = false, precision = 18, scale = 2) private BigDecimal salePrice; protected OrderItemEntity () { } private OrderItemEntity (Long id, Long orderId, Long userId, Long skuId, String skuName, Integer quantity, BigDecimal salePrice) { this .id = id; this .orderId = orderId; this .userId = userId; this .skuId = skuId; this .skuName = skuName; this .quantity = quantity; this .salePrice = salePrice; } public static OrderItemEntity create (Long id, Long orderId, Long userId, Long skuId, String skuName, Integer quantity, BigDecimal salePrice) { return new OrderItemEntity (id, orderId, userId, skuId, skuName, quantity, salePrice); } @Override public Long getId () { return id; } public Long getOrderId () { return orderId; } public Long getUserId () { return userId; } public Long getSkuId () { return skuId; } public String getSkuName () { return skuName; } public Integer getQuantity () { return quantity; } public BigDecimal getSalePrice () { return salePrice; } }
为什么不写:
1 2 @ManyToOne private OrderEntity order;
不是说一定不能写,而是分库分表项目里 ORM 关联要格外谨慎。
一旦写了实体关联,开发者就容易这样用:
1 order.getItems().size();
这可能在事务外触发懒加载,也可能生成不带完整分片键的 SQL。生产项目里更推荐显式 Repository 查询:
1 orderItemRepository.findByUserIdAndOrderId(userId, orderId);
12. Repository 写法 12.1 OrderRepository 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 package com.example.order.order.infrastructure.persistence;import com.example.order.order.domain.OrderStatus;import java.time.Instant;import java.util.List;import java.util.Optional;import org.springframework.data.domain.Page;import org.springframework.data.domain.Pageable;import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.data.jpa.repository.Modifying;import org.springframework.data.jpa.repository.Query;import org.springframework.data.repository.query.Param;public interface OrderRepository extends JpaRepository <OrderEntity, Long> { Optional<OrderEntity> findByUserIdAndIdAndDeletedFalse (Long userId, Long id) ; Optional<OrderEntity> findByUserIdAndOrderNoAndDeletedFalse (Long userId, String orderNo) ; Page<OrderEntity> findByUserIdAndStatusAndCreatedAtBetweenAndDeletedFalse ( Long userId, OrderStatus status, Instant startAt, Instant endAt, Pageable pageable ) ; List<OrderEntity> findTop50ByUserIdAndIdLessThanAndDeletedFalseOrderByIdDesc (Long userId, Long lastOrderId) ; @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" update OrderEntity o set o.status = :status where o.userId = :userId and o.id = :orderId and o.deleted = false """) int updateStatus (@Param("userId") Long userId, @Param("orderId") Long orderId, @Param("status") OrderStatus status) ; }
12.2 OrderItemRepository 1 2 3 4 5 6 7 8 9 package com.example.order.order.infrastructure.persistence;import java.util.List;import org.springframework.data.jpa.repository.JpaRepository;public interface OrderItemRepository extends JpaRepository <OrderItemEntity, Long> { List<OrderItemEntity> findByUserIdAndOrderIdAndDeletedFalse (Long userId, Long orderId) ; }
12.3 Repository 规则 生产中建议制定团队规范:
1 2 3 4 5 6 7 分片表查询必须带分片键。 分片表更新必须带分片键。 分片表删除必须带分片键。 禁止随意调用 JpaRepository.findById。 禁止在分片表上做无条件 count。 禁止跨分片大分页。 禁止在分片表实体上滥用级联关系。
为什么 findById 不推荐?
本文的分库键是 user_id,分表键是 order_id。如果只用 order_id:
1 orderRepository.findById(orderId);
ShardingSphere 可以根据 order_id 判断物理表,但判断不了在哪个库,因为分库键 user_id 缺失。结果可能是所有库都查一遍。
更好的写法:
1 orderRepository.findByUserIdAndIdAndDeletedFalse(userId, orderId);
13. Application Service 13.1 创建订单命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 package com.example.order.order.api.request;import jakarta.validation.Valid;import jakarta.validation.constraints.DecimalMin;import jakarta.validation.constraints.Min;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.NotEmpty;import jakarta.validation.constraints.NotNull;import java.math.BigDecimal;import java.util.List;public record CreateOrderRequest ( @NotNull Long userId, @NotBlank String orderNo, String remark, @NotEmpty List<@Valid Item> items ) { public record Item ( @NotNull Long skuId, @NotBlank String skuName, @NotNull @Min(1) Integer quantity, @NotNull @DecimalMin("0.01") BigDecimal salePrice ) { } }
13.2 创建订单服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 package com.example.order.order.application;import com.example.order.common.id.SnowflakeIdGenerator;import com.example.order.order.api.request.CreateOrderRequest;import com.example.order.order.infrastructure.persistence.OrderEntity;import com.example.order.order.infrastructure.persistence.OrderItemEntity;import com.example.order.order.infrastructure.persistence.OrderItemRepository;import com.example.order.order.infrastructure.persistence.OrderRepository;import java.math.BigDecimal;import java.util.List;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;@Service public class OrderCommandService { private final SnowflakeIdGenerator idGenerator; private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; public OrderCommandService (SnowflakeIdGenerator idGenerator, OrderRepository orderRepository, OrderItemRepository orderItemRepository) { this .idGenerator = idGenerator; this .orderRepository = orderRepository; this .orderItemRepository = orderItemRepository; } @Transactional public Long createOrder (CreateOrderRequest request) { long orderId = idGenerator.nextId(); BigDecimal totalAmount = request.items().stream() .map(item -> item.salePrice().multiply(BigDecimal.valueOf(item.quantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); OrderEntity order = OrderEntity.create( orderId, request.userId(), request.orderNo(), totalAmount, request.remark() ); orderRepository.save(order); List<OrderItemEntity> items = request.items().stream() .map(item -> OrderItemEntity.create( idGenerator.nextId(), orderId, request.userId(), item.skuId(), item.skuName(), item.quantity(), item.salePrice() )) .toList(); orderItemRepository.saveAll(items); return orderId; } @Transactional public void pay (Long userId, Long orderId) { OrderEntity order = orderRepository.findByUserIdAndIdAndDeletedFalse(userId, orderId) .orElseThrow(() -> new IllegalArgumentException ("Order not found." )); order.pay(); } @Transactional public void cancel (Long userId, Long orderId) { OrderEntity order = orderRepository.findByUserIdAndIdAndDeletedFalse(userId, orderId) .orElseThrow(() -> new IllegalArgumentException ("Order not found." )); order.cancel(); } }
为什么 t_order 和 t_order_item 能在同一个本地事务里写?
因为在本文配置里:
两张表按相同 user_id 分库。
两张表按相同 order_id 分表。
t_order,t_order_item 是绑定表。
创建订单时,主表和明细表会落到同一个数据库分片。
这样可以尽量避免跨库事务。
如果一个事务里写入两个不同用户的数据,就可能跨库。生产中要从业务模型上避免这种操作。
13.3 查询服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package com.example.order.order.application;import com.example.order.order.api.response.OrderResponse;import com.example.order.order.infrastructure.persistence.OrderEntity;import com.example.order.order.infrastructure.persistence.OrderItemEntity;import com.example.order.order.infrastructure.persistence.OrderItemRepository;import com.example.order.order.infrastructure.persistence.OrderRepository;import java.util.List;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;@Service public class OrderQueryService { private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; public OrderQueryService (OrderRepository orderRepository, OrderItemRepository orderItemRepository) { this .orderRepository = orderRepository; this .orderItemRepository = orderItemRepository; } @Transactional(readOnly = true) public OrderResponse getOrder (Long userId, Long orderId) { OrderEntity order = orderRepository.findByUserIdAndIdAndDeletedFalse(userId, orderId) .orElseThrow(() -> new IllegalArgumentException ("Order not found." )); List<OrderItemEntity> items = orderItemRepository.findByUserIdAndOrderIdAndDeletedFalse(userId, orderId); return OrderResponse.from(order, items); } }
14. Controller 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 package com.example.order.order.api;import com.example.order.order.api.request.CreateOrderRequest;import com.example.order.order.api.response.OrderResponse;import com.example.order.order.application.OrderCommandService;import com.example.order.order.application.OrderQueryService;import jakarta.validation.Valid;import java.util.Map;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController @RequestMapping("/api/orders") public class OrderController { private final OrderCommandService commandService; private final OrderQueryService queryService; public OrderController (OrderCommandService commandService, OrderQueryService queryService) { this .commandService = commandService; this .queryService = queryService; } @PostMapping public Map<String, Long> create (@Valid @RequestBody CreateOrderRequest request) { Long orderId = commandService.createOrder(request); return Map.of("orderId" , orderId); } @GetMapping("/{userId}/{orderId}") public OrderResponse get (@PathVariable Long userId, @PathVariable Long orderId) { return queryService.getOrder(userId, orderId); } @PostMapping("/{userId}/{orderId}/pay") public void pay (@PathVariable Long userId, @PathVariable Long orderId) { commandService.pay(userId, orderId); } @PostMapping("/{userId}/{orderId}/cancel") public void cancel (@PathVariable Long userId, @PathVariable Long orderId) { commandService.cancel(userId, orderId); } }
响应对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package com.example.order.order.api.response;import com.example.order.order.domain.OrderStatus;import com.example.order.order.infrastructure.persistence.OrderEntity;import com.example.order.order.infrastructure.persistence.OrderItemEntity;import java.math.BigDecimal;import java.time.Instant;import java.util.List;public record OrderResponse ( Long orderId, Long userId, String orderNo, OrderStatus status, BigDecimal totalAmount, Instant createdAt, List<Item> items ) { public static OrderResponse from (OrderEntity order, List<OrderItemEntity> items) { return new OrderResponse ( order.getId(), order.getUserId(), order.getOrderNo(), order.getStatus(), order.getTotalAmount(), order.getCreatedAt(), items.stream().map(Item::from).toList() ); } public record Item ( Long orderItemId, Long skuId, String skuName, Integer quantity, BigDecimal salePrice ) { public static Item from (OrderItemEntity item) { return new Item ( item.getId(), item.getSkuId(), item.getSkuName(), item.getQuantity(), item.getSalePrice() ); } } }
15. 本地验证 15.1 启动 MySQL 可以用 Docker Compose 起一个 MySQL,然后创建两个 schema。
1 2 3 4 5 6 7 8 9 10 11 12 13 services: mysql: image: mysql:8.4 container_name: mysql-sharding-demo ports: - "3306:3306" environment: MYSQL_ROOT_PASSWORD: root TZ: UTC command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_0900_ai_ci - --default-time-zone=+00:00
创建库:
1 2 3 4 CREATE DATABASE order_ds_0 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;CREATE DATABASE order_ds_1 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
然后在两个库中分别执行前面的建表语句。
15.2 创建订单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 curl -X POST http://localhost:8080/api/orders \ -H 'Content-Type: application/json' \ -d '{ "userId": 10001, "orderNo": "NO202606110001", "remark": "first sharding order", "items": [ { "skuId": 90001, "skuName": "Java Book", "quantity": 2, "salePrice": 59.90 } ] }'
返回:
1 2 3 { "orderId" : 123456789012345678 }
15.3 查询订单 1 curl http://localhost:8080/api/orders/10001/123456789012345678
如果 sql-show: true,日志里应该能看到真实路由结果。你要关注的是:
是否只命中了一个库。
是否只命中了一个物理表。
t_order 和 t_order_item 是否落到了同一个库。
查询是否携带 user_id 和 order_id。
16. 分片策略设计 16.1 分库键和分表键怎么选 常见选择:
1 2 3 4 5 订单: user_id / order_id 支付流水: merchant_id / payment_id 消息: tenant_id / message_id 日志: tenant_id / created_at 账户: account_id
选分片键时要回答:
绝大多数查询是否都能带上这个字段。
这个字段的分布是否均匀。
这个字段是否稳定,不会频繁修改。
这个字段是否能把相关数据聚合在同一个库。
按这个字段分片后,核心事务是否能在单库内完成。
本文用 user_id 分库,是为了让一个用户的订单尽量在同一个库中,方便用户维度查询。
本文用 order_id 分表,是为了让订单主键均匀分散到多个表。
16.2 为什么不直接用 order_id 分库分表 只用 order_id 的好处是简单:
1 2 database = order_id % db_count table = order_id % table_count
但问题是用户维度查询会很麻烦。
比如“查询用户最近 20 个订单”,如果订单按 order_id 分散到多个库,你必须全库扫描或额外维护用户订单索引。
所以分片设计要从查询模型倒推,而不是只从主键生成方便倒推。
16.3 范围查询要谨慎 INLINE 算法很适合等值查询:
1 2 where user_id = ?where order_id = ?
但不适合大范围查询:
1 2 where user_id between ? and ?where order_id > ?
如果开启 allow-range-query-with-inline-sharding,范围查询可能退化为多节点路由。生产环境通常不建议随意开启。
17. 分页设计 分库分表下最危险的分页是:
1 2 3 select * from t_orderorder by created_at desc limit 100000 , 20 ;
如果没有分片键,这类 SQL 可能会在多个物理表上执行,再归并排序,代价很高。
推荐按用户维度分页:
1 2 3 4 5 6 7 Page<OrderEntity> findByUserIdAndStatusAndCreatedAtBetweenAndDeletedFalse ( Long userId, OrderStatus status, Instant startAt, Instant endAt, Pageable pageable ) ;
更推荐游标分页:
1 List<OrderEntity> findTop50ByUserIdAndIdLessThanAndDeletedFalseOrderByIdDesc (Long userId, Long lastOrderId) ;
接口层可以设计成:
1 GET /api/users/{userId}/orders?lastOrderId=xxx&pageSize=50
不要轻易提供全局订单列表。如果后台管理确实需要全局查询,推荐走搜索引擎、OLAP、宽表、数据仓库或单独的运营查询库。
18. 读写分离 ShardingSphere-JDBC 可以在分库分表基础上继续配置读写分离。
一个典型结构:
1 2 3 4 5 6 7 ds_0_write ds_0_read_0 ds_0_read_1 ds_1_write ds_1_read_0 ds_1_read_1
示例配置片段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 dataSources: ds_0_write: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver standardJdbcUrl: jdbc:mysql://127.0.0.1:3306/order_ds_0 username: root password: root ds_0_read_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver standardJdbcUrl: jdbc:mysql://127.0.0.1:3307/order_ds_0 username: root password: root rules: - !READWRITE_SPLITTING dataSourceGroups: rw_ds_0: writeDataSourceName: ds_0_write readDataSourceNames: - ds_0_read_0 transactionalReadQueryStrategy: PRIMARY loadBalancerName: random loadBalancers: random: type: RANDOM
生产建议:
强一致读走主库。
事务内读建议走主库。
支付、库存、余额、订单支付状态这类读不能随便走从库。
从库延迟必须监控。
如果主从复制延迟不可控,不要在关键链路开启透明读写分离。
19. 事务边界 ShardingSphere 提供 LOCAL、XA、BASE 等事务模式,但 Spring Boot 3 / Jakarta 生态下,使用 XA 要特别谨慎。
生产默认建议:
1 2 3 4 优先 LOCAL 通过分片建模让一个业务事务落在一个数据库分片内 跨库场景使用最终一致性 关键资金链路慎用透明分布式事务
19.1 单库事务 本文创建订单的事务是合理的:
因为主表和明细表按相同 user_id、order_id 路由,正常会落到同一个库。
19.2 跨库事务 下面这种要避免:
1 2 3 4 5 @Transactional public void mergeUserOrders (Long fromUserId, Long toUserId) { }
如果两个用户落在不同库,这就是跨库事务。
更好的方式:
拆成两个本地事务。
用事件表记录状态。
用消息队列驱动后续操作。
用补偿任务兜底。
保证接口幂等。
20. JPA 和 Hibernate 的注意事项 20.1 不建议使用实体级联写入跨分片对象 不推荐:
1 2 @OneToMany(cascade = CascadeType.ALL) private List<OrderItemEntity> items;
推荐:
1 2 orderRepository.save(order); orderItemRepository.saveAll(items);
显式保存虽然代码多一点,但路由条件清晰,事务边界清晰,排查也更容易。
20.2 谨慎使用懒加载 懒加载可能在你没意识到的时候发 SQL。
如果这个 SQL 没带分片键,就可能全路由。
建议:
关闭 open-in-view。
查询服务里显式查询需要的数据。
DTO 组装在事务内完成。
不在 Controller 或 JSON 序列化阶段触发懒加载。
20.3 不要把 JPA 当报表工具 分库分表系统不适合大量动态组合查询。
例如:
1 2 3 4 按任意字段筛选订单 全局按金额排序 跨用户统计订单数 跨月份聚合销售额
这些更适合:
Elasticsearch / OpenSearch。
ClickHouse / Doris。
数据仓库。
报表宽表。
离线同步后的运营库。
20.4 JPQL 仍然会变成 SQL JPQL 不是绕过 ShardingSphere 的通道。
1 2 @Query("select o from OrderEntity o where o.userId = :userId and o.status = :status") List<OrderEntity> findUserOrders (Long userId, OrderStatus status) ;
Hibernate 最终会生成 SQL,然后交给 ShardingSphere。
所以 JPQL 也要遵守分片键规则。
21. 软删除 如果使用软删除,所有查询都要过滤:
可以手写 Repository 方法:
1 findByUserIdAndIdAndDeletedFalse
也可以使用 Hibernate 注解:
1 2 @SQLDelete(sql = "UPDATE t_order SET deleted = 1 WHERE order_id = ? AND version = ?") @Where(clause = "deleted = 0")
但在分库分表环境里,我更推荐显式 Repository 方法,而不是过度依赖 Hibernate 自动过滤。
原因:
自动 SQL 可能不带分片键。
团队成员不容易意识到 SQL 真实长什么样。
某些批量操作和自定义 JPQL 不一定符合预期。
22. 乐观锁 JPA 的 @Version 可以继续使用。
1 2 3 @Version @Column(name = "version", nullable = false) private Long version;
更新时 Hibernate 会生成类似:
1 2 3 4 5 update t_orderset status = ?, version = ?where order_id = ? and version = ?
注意:如果 where 里没有分库键,仍然可能路由不精准。
所以业务更新最好仍然先通过 user_id + order_id 查询到实体,再在同一个事务里修改。
批量更新也要显式带分片键:
1 2 3 4 5 6 7 8 @Modifying @Query(""" update OrderEntity o set o.status = :status where o.userId = :userId and o.id = :orderId """) int updateStatus (Long userId, Long orderId, OrderStatus status) ;
23. 唯一约束 分库分表后,唯一约束要重新思考。
物理表上的:
1 UNIQUE KEY uk_order_no (order_no)
只能保证单个物理表内唯一,不能天然保证全局唯一。
如果 order_no 必须全局唯一,有几种做法:
订单号本身由全局 ID 生成。
创建订单前写全局唯一索引表。
使用独立订单号服务。
保证订单号包含分片信息,并按相同规则路由。
用数据库唯一约束只做单分片内防护。
本文建议:订单号由应用生成,并保证全局唯一。
24. Hint 强制路由 有些 SQL 里没有分片键,但业务上下文里知道分片键。
例如:
1 2 当前登录用户 userId 在请求上下文里 SQL 本身只查 order_no
可以使用 Hint 强制路由。
示例:
1 2 3 4 5 6 7 8 9 import org.apache.shardingsphere.infra.hint.HintManager;public OrderEntity findByOrderNoWithHint (Long userId, String orderNo) { try (HintManager hintManager = HintManager.getInstance()) { hintManager.addDatabaseShardingValue("t_order" , userId); return orderRepository.findByUserIdAndOrderNoAndDeletedFalse(userId, orderNo) .orElseThrow(); } }
但 Hint 不是日常查询的首选。
使用 Hint 的注意事项:
Hint 基于当前线程上下文。
异步线程、线程池、响应式链路要特别小心。
必须用 try-with-resources 确保清理。
不要让 Hint 逻辑散落在业务代码中。
25. 生产配置建议 25.1 连接池 每个物理数据源都有自己的连接池。
如果配置:
1 2 3 2 个库 每个库 maximumPoolSize = 20 应用实例数 = 10
那么数据库侧最大连接压力可能是:
如果再加读写分离:
1 2 3 4 2 个分片组 每组 1 主 2 从 每个数据源 maximumPoolSize = 20 应用实例数 = 10
就是:
生产建议:
连接池大小按应用实例数、数据库 max_connections、接口并发估算。
不要照抄单库应用的连接池配置。
压测时观察数据库连接数、等待时间和线程池。
对慢 SQL、全路由 SQL 做强告警。
25.2 sql-show 开发和测试:
1 2 3 props: sql-show: true sql-simple: false
生产:
生产不要长期开全量 SQL 日志,否则可能带来:
日志量暴涨。
敏感数据泄露。
I/O 抖动。
排查时反而被噪声淹没。
更好的方式:
开慢 SQL。
接入链路追踪。
对核心接口做采样 SQL 日志。
用压测环境打开 sql-show 验证路由。
25.3 配置管理 shardingsphere.yaml 包含数据库地址、账号、密码。
生产建议:
密码不要明文提交到代码仓库。
使用环境变量、密钥系统或配置中心注入。
不同环境使用不同配置文件。
配置变更要经过评审。
分片规则变更要有回滚方案。
如果是多实例生产部署,并且需要统一治理、动态规则和元数据持久化,应评估 Cluster 模式与注册中心。
25.4 元数据校验 建议开启:
1 2 props: check-table-metadata-enabled: true
它可以帮助你尽早发现不同物理表结构不一致的问题。
但如果物理节点非常多,启动校验可能增加启动耗时,需要结合项目规模评估。
26. 迁移方案 很多系统不是一开始就分库分表,而是从单库演进而来。
推荐迁移路径:
1 2 3 4 5 6 第一阶段: 单库单表 第二阶段: 引入 ShardingSphere-JDBC,但仍然只配置一个真实库 第三阶段: 改造代码,所有查询带分片键 第四阶段: 双写或数据迁移 第五阶段: 切换到多库多表 第六阶段: 校验、回补、下线旧表
关键点:
不要边改代码边改分片规则边迁数据。
先让应用适配逻辑表和分片键查询。
再做数据迁移。
迁移前必须冻结或兼容 DDL。
迁移中必须做数据校验。
切流要能灰度和回滚。
27. 测试策略 27.1 单元测试 单元测试不需要真的启动 ShardingSphere。
重点测试:
ID 生成器。
金额计算。
订单状态流转。
参数校验。
领域规则。
27.2 集成测试 集成测试必须覆盖真实 ShardingSphere 路由。
建议用例:
插入不同 user_id 的订单,确认落库不同。
插入不同 order_id 的订单,确认落表不同。
查询带完整分片键,只命中一个节点。
查询缺失分片键,确认会触发多节点路由,并在团队规范中禁止。
主表和明细表查询确认绑定表路由正确。
乐观锁更新失败能正确返回。
软删除后查询不可见。
事务回滚后主表和明细表都不落库。
27.3 压测 压测不要只看平均耗时。
重点看:
P95、P99。
数据库连接数。
慢 SQL。
全路由次数。
单个分片热点。
ShardingSphere 结果归并耗时。
JVM GC。
线程池等待。
28. 常见问题 28.1 启动报找不到 ShardingSphereDriver 检查依赖:
1 2 3 4 5 6 <dependency > <groupId > org.apache.shardingsphere</groupId > <artifactId > shardingsphere-jdbc</artifactId > <version > 5.5.3</version > </dependency >
检查配置:
1 2 3 spring: datasource: driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
28.2 JPA 建表没有创建物理分片表 这是正常的。
JPA 只知道逻辑表,不适合自动创建多个物理分片表。生产应该用 Flyway、Liquibase 或 DBA 脚本创建所有物理表。
28.3 save 后 ID 是 null 如果你使用数据库自增或 ShardingSphere 底层补主键,可能遇到回填问题。
推荐改成应用侧生成 ID:
1 2 3 long id = idGenerator.nextId();OrderEntity order = OrderEntity.create(id, userId, orderNo, totalAmount, remark); orderRepository.save(order);
28.4 findById 很慢 大概率是缺少分库键。
不要写:
1 orderRepository.findById(orderId);
要写:
1 orderRepository.findByUserIdAndIdAndDeletedFalse(userId, orderId);
28.5 分页很慢 检查是否跨库分页。
危险写法:
1 orderRepository.findAll(PageRequest.of(1000 , 20 ));
推荐:
1 orderRepository.findTop50ByUserIdAndIdLessThanAndDeletedFalseOrderByIdDesc(userId, lastOrderId);
28.6 updated_at 没更新 检查:
是否开启 @EnableJpaAuditing。
实体是否配置 @EntityListeners(AuditingEntityListener.class)。
字段是否使用 @LastModifiedDate。
更新是否在事务内。
是否直接执行了绕过 JPA 的原生 SQL。
28.7 乐观锁不生效 检查:
表里是否有 version 字段。
实体是否有 @Version。
更新是否走 JPA 脏检查。
自定义 @Modifying SQL 是否绕过了 version 条件。
29. 不建议做的事 下面这些在小 demo 里能跑,在生产里容易出事:
1 2 3 4 5 6 7 8 9 10 使用 GenerationType.IDENTITY 作为分片表主键 让 Hibernate 自动创建分片表 在 Controller 里直接调用 Repository 默认使用 findById 查询分片表 在分片实体上大量使用 @OneToMany 和 CascadeType.ALL 跨分片做大分页 跨分片做复杂报表 生产长期开启 sql-show 一个事务里写多个不相关用户的数据 把 ShardingSphere 当作数据库性能万能药
30. 生产落地清单 接入前:
1 2 3 4 5 6 7 8 9 确认核心查询都能带分片键 确认分片键分布均匀 确认主事务能落在单库 确认全局唯一 ID 策略 确认唯一约束设计 确认物理表数量和未来扩容方案 确认 DDL 管理方式 确认数据迁移方案 确认读写分离一致性要求
开发阶段:
1 2 3 4 5 6 7 Repository 方法命名必须体现分片键 禁止直接暴露 JpaRepository 给 Controller 关闭 open-in-view 关闭 Hibernate ddl-auto 开启 sql-show 验证路由 为缺失分片键的查询加代码评审规则 为核心 Repository 写集成测试
上线前:
1 2 3 4 5 6 7 8 9 10 所有物理库表结构一致 所有物理表索引一致 连接池容量评估完成 慢 SQL 监控完成 数据库连接数告警完成 主从延迟告警完成 全路由 SQL 排查完成 回滚方案完成 压测完成 数据校验完成
上线后:
1 2 3 4 5 6 7 8 观察 P95/P99 观察单分片热点 观察慢 SQL 观察连接池等待 观察错误 SQL 观察主从延迟 观察 ID 生成异常 定期校验分片数据分布
31. 一份推荐的最终组合 对于大多数 Spring Boot + JPA 业务系统,我推荐:
1 2 3 4 5 6 7 8 9 10 11 12 13 Spring Boot 3.5.x Spring Data JPA Hibernate 6.x ShardingSphere-JDBC 5.5.3 HikariCP MySQL 8.x 应用侧 Snowflake ID Spring Data JPA Auditing Flyway / Liquibase 管理 DDL LOCAL 本地事务 业务事件实现最终一致性 关键查询强制带分片键 后台报表走独立查询链路
这套组合不是功能最多的,但可控性强,排查链路清晰,适合生产落地。
参考资料
启示录 Spring Data JPA 让你从实体角度理解业务,ShardingSphere-JDBC 让你从数据分布角度管理规模。两者结合时,最重要的不是把配置写对,而是让业务模型、查询模型和分片模型朝同一个方向走。
分库分表不是架构升级的奖章,而是数据规模逼近边界时的一次工程手术。手术成功的关键不是刀有多快,而是你知道切在哪里、为什么切、切完之后怎么恢复。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。