Spring Boot 3.5 整合 Spring Data JPA 与 ShardingSphere-JDBC 生产实践

欢迎你来读这篇博客,这篇博客主要是关于 Spring Boot 3.5Spring Data JPAApache 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_atupdated_atcreated_byupdated_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_0t_order_1 这种物理表名。
  • 生产环境不建议用 Hibernate 自动建表,spring.jpa.hibernate.ddl-auto 应设为 nonevalidate
  • 分片表不要依赖数据库自增主键。
  • 和 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_0ds_1 是逻辑数据源名字,不是数据库名字。
  • standardJdbcUrl 是 HikariCP 场景下推荐使用的连接地址字段。
  • t_order.actualDataNodes 表示真实节点范围。
  • databaseStrategy 表示分库策略。
  • tableStrategy 表示分表策略。
  • bindingTables 表示绑定表,t_ordert_order_item 使用相同路由规则,关联查询时可以避免笛卡尔路由。
  • BROADCAST 表示广播表,适合地区、状态、配置字典这类小表。
  • sql-show: true 适合开发和测试环境,可以打印逻辑 SQL、真实 SQL 和路由信息。
  • 生产环境通常关闭 sql-show,改用慢 SQL、采样日志和可观测性平台。

8. 建表脚本

每个物理库都需要创建对应物理表。

示例只写一个库的 DDL,order_ds_0order_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 内置支持 SNOWFLAKEUUID 等分布式主键生成算法。配置示例:

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_ordert_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_ordert_order_item 是否落到了同一个库。
  • 查询是否携带 user_idorder_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_order
order 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 提供 LOCALXABASE 等事务模式,但 Spring Boot 3 / Jakarta 生态下,使用 XA 要特别谨慎。

生产默认建议:

1
2
3
4
优先 LOCAL
通过分片建模让一个业务事务落在一个数据库分片内
跨库场景使用最终一致性
关键资金链路慎用透明分布式事务

19.1 单库事务

本文创建订单的事务是合理的:

1
2
创建订单主表
创建订单明细表

因为主表和明细表按相同 user_idorder_id 路由,正常会落到同一个库。

19.2 跨库事务

下面这种要避免:

1
2
3
4
5
@Transactional
public void mergeUserOrders(Long fromUserId, Long toUserId) {
// 写 fromUserId 的订单分片
// 写 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. 软删除

如果使用软删除,所有查询都要过滤:

1
2
deleted
= 0

可以手写 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_order
set 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 * 20 * 10 = 400

如果再加读写分离:

1
2
3
4
2 个分片组
每组 1 主 2 从
每个数据源 maximumPoolSize = 20
应用实例数 = 10

就是:

1
2 * 3 * 20 * 10 = 1200

生产建议:

  • 连接池大小按应用实例数、数据库 max_connections、接口并发估算。
  • 不要照抄单库应用的连接池配置。
  • 压测时观察数据库连接数、等待时间和线程池。
  • 对慢 SQL、全路由 SQL 做强告警。

25.2 sql-show

开发和测试:

1
2
3
props:
sql-show: true
sql-simple: false

生产:

1
2
props:
sql-show: 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 让你从数据分布角度管理规模。两者结合时,最重要的不是把配置写对,而是让业务模型、查询模型和分片模型朝同一个方向走。

分库分表不是架构升级的奖章,而是数据规模逼近边界时的一次工程手术。手术成功的关键不是刀有多快,而是你知道切在哪里、为什么切、切完之后怎么恢复。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Spring Boot 3.5 整合 Spring Data JPA 与 ShardingSphere-JDBC 生产实践
https://allendericdalexander.github.io/2026/06/11/java/spring/springboot-spring-data-jpa-shardingsphere-jdbc-production-guide/
作者
AtLuoFu
发布于
2026年6月11日
许可协议