欢迎你来读这篇博客。
这不是一篇把 Spring Data JPA、Flyway、ShardingSphere-JDBC 和 HikariCP 分别介绍一遍的技术清单,而是一篇围绕真实业务边界展开的深度整合实践。
本文使用一个 多租户结算平台 作为贯穿始终的示例,完整覆盖:
两个物理数据库,每个分片逻辑表在每个库中再拆成两张真实表;
tenant_id 分库、settlement_id 分表;
结算单主表与明细表配置为绑定表;
收款人姓名、银行账号通过 ShardingSphere 透明加密;
币种字典和结算状态规则使用广播表;
调度任务定义使用单表,只保存在 ds_0;
Spring Data JPA 与 Hibernate 继续操作逻辑表、逻辑字段;
Flyway 直接迁移每一个物理数据库,而不是把逻辑分片数据源当成普通单库;
每个物理数据源使用独立的 HikariCP 连接池;
事务只采用 Spring @Transactional 与 ShardingSphere LOCAL,不引入 XA、Seata 或其他分布式事务框架;
通过聚合边界与分片键设计,保证核心写事务只命中一个物理数据库。
这篇文章重点解决的不是“配置能不能启动”,而是下面这些工程问题:
Flyway 到底迁移逻辑库还是物理库?
Hibernate 的实体字段如何映射 ShardingSphere 的加密逻辑列?
JPA 的 findById()、脏检查和乐观锁为什么可能造成全路由?
广播表和单表如何与分片表共存?
LOCAL 事务在什么条件下才真正等价于可靠的单库事务?
两个物理 HikariCP 池应该如何计算总连接数?
分页、绑定表 Join、批量写入、字段加密和数据库迁移如何测试?
扩容、密钥轮换、广播表不一致和 Schema Drift 如何治理?
分库分表中最危险的误解,是以为中间件隐藏了物理表,就顺便消除了分布式边界。它没有。它只是让边界更容易被忽略。
总体阅读路线 flowchart LR
A[Spring Data JPA 基础] --> B[Hibernate 持久化上下文]
B --> C[Flyway 物理迁移]
C --> D[ShardingSphere 路由与加密]
D --> E[HikariCP 物理连接池]
E --> F[LOCAL 单库事务]
F --> G[测试、监控与演进]
全文分为两部分:
第一部分 保留并系统整理 Spring Data JPA、Hibernate 与 Flyway 的工程基础;
第二部分 在这些基础上完成 ShardingSphere-JDBC、HikariCP、分库分表、字段加密、绑定表、广播表、单表与 LOCAL 事务的深度整合。
第一部分:Spring Data JPA、Hibernate 与 Flyway 工程基础 欢迎你来读这篇博客。
这不是一篇只介绍 JpaRepository 和 CRUD 的入门文章,而是一篇面向真实业务项目的 Spring Data JPA 深度工程实践。
很多开发者第一次接触 Spring Data JPA 时,会觉得它的核心价值是“定义一个 Repository 接口,然后少写 SQL”。真正进入生产环境后,问题却很快从“怎么查数据”变成了:
为什么修改实体后没有调用 save(),数据库仍然发生了更新?
为什么调用 save(),执行的却不一定是 INSERT?
为什么关闭事务后访问关联对象会出现懒加载异常?
为什么列表只查 20 条数据,却执行了 21 条甚至上百条 SQL?
为什么 Page<T> 的 count 查询比数据查询还慢?
为什么 saveAll() 并没有带来真正的批量写入性能?
为什么相同代码在并发下会出现覆盖更新、重复数据或死锁?
为什么一个看起来很优雅的对象图,最终生成了非常昂贵的 SQL?
这些问题说明,Spring Data JPA 的真正学习门槛不在注解数量,而在于是否理解持久化上下文、实体生命周期、工作单元、事务边界、加载策略与数据库执行成本。
本文融合了“JPA 核心原理与常用查询体系”和“面向生产的审计、主键、软删除、结算单建模方案”,并进一步补充:
Persistence Context、一级缓存、实体状态与 flush;
主键策略与 save() 新实体判断;
操作人快照、软删除元数据与审计边界;
Keyset Pagination、Scroll API 与大结果集处理;
缓存、虚拟线程、Outbox、多租户与读写分离;
SQL 数量、执行计划、索引与生产观测;
JPA、MyBatis 和 JdbcTemplate 的职责分工。
基础机制使用简化的 UserEntity 说明,工程化部分使用“结算单”模型展开。这样既保留 API 学习的清晰度,也能进入财务、订单和结算类系统真正需要考虑的复杂边界。
序言 Spring Data JPA 的学习难点,不在于 API 数量,而在于它同时横跨了多个层次:
JPA 是持久化规范;
Hibernate 是常见的 JPA 实现;
Spring Data JPA 是 Repository 抽象;
Spring Framework 管理事务;
Spring Boot 负责自动配置;
数据库最终执行的仍然是 SQL。
如果只记住 JpaRepository 提供了哪些方法,却不了解实体状态、持久化上下文和事务边界,那么代码看起来很简洁,运行时却可能像一只藏在沙发底下的猫:平时安静,出问题时突然挠你一下。
本文示例环境如下:
组件
示例版本或约定
JDK
JDK 21
Spring Boot
3.5.x
Spring Data JPA
3.5.x
JPA 包名
jakarta.persistence.*
JPA 实现
Hibernate ORM
数据库
PostgreSQL 18.4
数据库变更
Flyway
构建工具
Maven
Spring Boot 3 之后,JPA 注解已经从 javax.persistence 迁移至 jakarta.persistence。旧项目升级时需要特别注意包名变化。
截至 2026 年 7 月,Spring Data JPA 官方文档同时维护 4.1、4.0 与 3.5 稳定线。本文以 Spring Boot 3.5 项目常用的 Spring Data JPA 3.5 与 Hibernate 6.6 为主要基线,避免把旧版 javax.persistence API 或过时配置直接照搬到新项目。
正文 chapter 1:先搞清楚 JPA、Hibernate 与 Spring Data JPA 1.1 JDBC、ORM、JPA、Hibernate 与 Spring Data JPA 的关系 先看一张分层图:
flowchart TB
A[业务代码<br/>Application Service] --> B[Spring Data JPA Repository]
B --> C[JPA API<br/>EntityManager / JPQL / Criteria]
C --> D[Hibernate ORM<br/>JPA Provider]
D --> E[JDBC Driver]
E --> F[(PostgreSQL 18.4)]
G[Spring Framework] -.事务与依赖注入.-> A
G -.事务与代理.-> B
H[Spring Boot] -.自动配置.-> B
H -.配置数据源与 Hibernate.-> D
各层职责可以概括为:
技术
定位
主要职责
JDBC
Java 数据库访问底层 API
建立连接、执行 SQL、处理结果集
ORM
对象关系映射思想
将 Java 对象与关系型数据库表进行映射
JPA
Jakarta Persistence 规范
定义实体映射、EntityManager、JPQL 等标准
Hibernate
JPA 的常见实现
实现实体管理、脏检查、缓存、SQL 生成等
Spring Data JPA
Spring Data 的 JPA Repository 实现
自动生成 Repository、派生查询、分页、投影等
Spring Boot
自动配置框架
自动配置数据源、实体扫描、Repository 和事务管理器
一句话概括:
JPA 定规则,Hibernate 干活,Spring Data JPA 帮你少写重复代码,Spring Boot 帮你把它们装配起来。
1.2 Spring Data JPA 不是 Hibernate 的替代品 Spring Data JPA 本身并不直接完成对象到 SQL 的全部转换。
当我们调用:
1 userRepository.findById(1L );
背后大致会经历以下过程:
sequenceDiagram
participant S as Service
participant R as Repository Proxy
participant EM as EntityManager
participant H as Hibernate
participant DB as Database
S->>R: findById(1L)
R->>EM: find(UserEntity.class, 1L)
EM->>H: 查询或从持久化上下文获取
H->>DB: select ... from sys_user where id = ?
DB-->>H: ResultSet
H-->>EM: UserEntity
EM-->>R: UserEntity
R-->>S: Optional<UserEntity>
Repository 接口通常由 Spring Data 在运行时生成代理实现,真正的实体管理仍然依赖 JPA 与 Hibernate。
1.3 Spring Data JPA 与 MyBatis 怎么选 二者并不是非黑即白。
场景
Spring Data JPA
MyBatis
标准 CRUD
非常适合
需要编写 SQL 或 Mapper
领域对象建模
较强
较弱
动态简单查询
Specification、QBE 较方便
XML 动态 SQL 较方便
复杂报表与多表聚合
可做,但可读性可能下降
通常更直接
SQL 精细控制
相对间接
很强
批量写入与超复杂 SQL
需要额外优化
更容易精确控制
数据库方言依赖
JPQL 可降低部分依赖
SQL 通常直接依赖数据库
学习成本
需要理解实体生命周期
需要熟悉 SQL 与映射
工程中常见的合理组合是:
简单聚合根的增删改查使用 JPA;
复杂报表、跨域统计、超长 SQL 使用 MyBatis 或 JdbcTemplate;
不要为了“技术纯洁”强迫一种 ORM 承担所有工作。
chapter 2:持久化上下文、工作单元与实体状态 2.1 为什么这是 JPA 最重要的一章 如果只会定义 Repository,却不理解持久化上下文,那么后续遇到的很多问题都会显得像框架玄学:
为什么修改实体后没有调用 save(),数据库仍然发生了更新;
为什么调用 save() 后,执行的不一定是 INSERT;
为什么同一事务内两次查询相同主键,可能只执行一次 SQL;
为什么批量 JPQL 更新数据库后,再读取实体却还是旧值;
为什么事务结束后访问懒加载关联会抛出异常;
为什么大批量处理时内存持续增长。
这些行为都与 Persistence Context 有关。
持久化上下文可以理解为:
Hibernate 在一个业务工作单元中,用来维护实体身份、跟踪状态变化,并协调对象状态与数据库状态的运行时空间。
在 Spring 常见的事务模型中,通常可以近似理解为:
1 2 3 4 5 6 7 一个业务事务 ↓ 一个线程绑定的 EntityManager ↓ 一个 Persistence Context ↓ 若干被管理的 Entity
2.2 一级缓存首先解决的是实体身份 同一个持久化上下文中,按相同实体类型和主键读取数据,通常会得到同一个 Java 对象:
1 2 3 4 5 6 7 @Transactional public void identityMap (Long id) { UserEntity first = entityManager.find(UserEntity.class, id); UserEntity second = entityManager.find(UserEntity.class, id); System.out.println(first == second); }
一级缓存不仅是为了“少执行一次 SQL”,更重要的是保证:
1 2 3 同一个 Persistence Context 中 同一个实体类型 + 同一个主键 对应同一个托管对象
需要明确它的边界:
一级缓存只属于当前持久化上下文;
不同事务通常不会共享一级缓存;
它不是 Redis,也不是跨节点业务缓存;
直接使用 JPQL、Native SQL 或外部程序修改数据库后,一级缓存可能过期;
clear() 会清空当前持久化上下文中的托管实体。
2.3 实体的四种常见状态 stateDiagram-v2
[*] --> Transient: new
Transient --> Managed: persist / query
Managed --> Detached: clear / close / detach
Detached --> Managed: merge
Managed --> Removed: remove
Removed --> [*]: flush / commit
Transient:瞬时状态 1 UserEntity user = new UserEntity ("Mario" , "mario@example.com" );
它只是一个普通 Java 对象,还没有被当前持久化上下文管理。
Managed:托管状态 1 UserEntity user = userRepository.findById(id).orElseThrow();
查询得到的实体通常处于托管状态。字段发生变化后,Hibernate 可以在 flush 时进行脏检查。
Detached:游离状态 实体曾经被管理,但当前已经离开原来的持久化上下文,例如:
事务结束;
EntityManager 关闭;
调用了 clear();
调用了 detach(entity);
实体被序列化并传递到其他层。
游离实体继续发生变化时,不会自动被当前持久化上下文跟踪。
Removed:删除状态 实体已经被标记删除,flush 时会执行物理删除,或者在软删除方案中转化为更新删除标志。
2.4 脏检查为什么能自动生成 UPDATE 1 2 3 4 5 6 7 @Transactional public void changeUsername (Long id, String username) { UserEntity user = userRepository.findById(id) .orElseThrow(); user.changeUsername(username); }
这里没有再次调用 save(),事务提交时仍可能执行 UPDATE。
原因是 Hibernate 会保存实体加载时的状态快照,并在 flush 阶段比较当前状态。如果发现字段发生变化,就生成相应 SQL。
脏检查的价值:
业务代码不需要到处显式调用 update;
多个实体修改可以组成一个完整工作单元;
实体行为可以专注表达业务状态变化。
脏检查的成本:
持久化上下文中的托管实体越多,状态跟踪成本越高;
无意中修改托管实体,也可能在提交时写入数据库;
大批量任务不及时 clear(),会持续占用内存;
事务边界模糊时,开发者很难判断 SQL 何时生成。
2.5 flush 不等于 commit flush 表示把持久化上下文中的变化转换为 SQL,并同步到数据库连接。
commit 表示提交数据库事务。
典型过程:
1 2 3 4 5 6 7 修改托管实体 ↓ flush:执行 INSERT / UPDATE / DELETE ↓ 变更仍处于当前事务 ↓ commit:事务正式提交
因此:
1 repository.saveAndFlush(entity);
并不代表事务已经提交。后续代码抛出异常时,数据库操作仍然可以被回滚。
常见 flush 时机包括:
事务提交前;
显式调用 EntityManager.flush();
调用 saveAndFlush();
某些查询执行前,为保证查询能看到当前事务中的变化;
Provider 根据 FlushMode 决定的其他时机。
不要依赖“SQL 一定等到方法最后才执行”。
2.6 EntityManager 不是线程安全对象 不要把当前事务中的实体或 EntityManager 交给另一个线程继续操作:
1 2 3 4 5 6 7 8 @Transactional public void wrong (Long id) { UserEntity user = userRepository.findById(id).orElseThrow(); CompletableFuture.runAsync(() -> { user.changeUsername("Other Thread" ); }); }
即使使用 JDK 21 虚拟线程,也必须遵守:
一个事务工作单元应在一个执行上下文中完成;
EntityManager 不能跨线程共享;
虚拟线程降低的是 Java 线程阻塞成本,不会降低 SQL 执行成本;
数据库连接池仍然是并发上限;
不要在一个事务中并行操作同一个持久化上下文。
2.7 从工作单元角度理解事务 JPA 最适合的思维方式不是:
而是:
1 2 3 4 加载业务对象 执行业务行为 维护对象间一致性 在事务边界统一同步数据库
这就是 Unit of Work。
当业务只是超复杂统计、批量清洗或百万级数据迁移时,工作单元和托管实体反而可能成为额外负担。这也是为什么成熟项目会让 JPA、MyBatis 和 JdbcTemplate 各自承担适合的任务。
chapter 3:Repository 抽象到底提供了什么 3.1 Repository 接口体系 Spring Data 的核心是 Repository<T, ID> 标记接口。
常见接口如下:
接口
作用
Repository<T, ID>
标记接口,不直接提供 CRUD 方法
CrudRepository<T, ID>
提供基础 CRUD,集合结果通常为 Iterable
ListCrudRepository<T, ID>
提供基础 CRUD,集合结果返回 List
PagingAndSortingRepository<T, ID>
提供分页和排序能力
JpaRepository<T, ID>
增加 JPA 特有操作,如刷新、批量删除等
JpaSpecificationExecutor<T>
支持基于 Criteria API 的动态条件查询
QueryByExampleExecutor<T>
支持 Query by Example
Spring Data 3.0 之后,排序接口不再继承对应的 CRUD 接口。如果直接组合通用接口,需要显式继承所需能力。
普通 JPA 项目通常直接使用:
1 2 3 4 public interface UserRepository extends JpaRepository <UserEntity, Long>, JpaSpecificationExecutor<UserEntity> { }
3.2 为什么 Repository 不需要自己写实现类 Spring Data 会扫描 Repository 接口,并通过 JpaRepositoryFactory 等组件创建代理对象。
代理对象会根据方法类型选择不同执行路径:
flowchart LR
A[Repository 方法] --> B{方法属于哪一类}
B -->|JpaRepository 基础方法| C[SimpleJpaRepository]
B -->|符合命名规则| D[PartTree 解析方法名]
B -->|带 @Query| E[解析 JPQL 或原生 SQL]
B -->|自定义片段| F[调用自定义实现]
C --> G[EntityManager]
D --> G
E --> G
F --> G
因此,Repository 接口不是“没有实现”,而是实现由 Spring Data 在运行时生成。
3.3 自定义基础 Repository 大型项目中可以限制暴露的方法,避免所有 Repository 都随意调用 deleteAll()、findAll() 等高风险操作。
1 2 3 4 5 6 7 8 9 @NoRepositoryBean public interface BaseRepository <T, ID> extends Repository <T, ID> { Optional<T> findById (ID id) ; boolean existsById (ID id) ; <S extends T > S save (S entity) ; }
业务 Repository 再继承它:
1 2 3 4 public interface UserRepository extends BaseRepository <UserEntity, Long> { Optional<UserEntity> findByEmailIgnoreCase (String email) ; }
@NoRepositoryBean 的作用是告诉 Spring Data:这是通用父接口,不要尝试为它创建 Repository Bean。
chapter 4:Spring Boot 整合 Spring Data JPA 4.1 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 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-jpa</artifactId > </dependency > <dependency > <groupId > org.postgresql</groupId > <artifactId > postgresql</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-core</artifactId > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-database-postgresql</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies >
使用 Spring Boot 依赖管理时,一般不要单独指定 Hibernate、Spring Data JPA 和数据库驱动版本,避免版本矩阵错配。
4.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 spring: datasource: url: jdbc:postgresql://${DB_HOST:127.0.0.1}:${DB_PORT:5432}/${DB_NAME:demo}?currentSchema=public&ApplicationName=demo-jpa&reWriteBatchedInserts=true&tcpKeepAlive=true username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} hikari: pool-name: demo-jpa-pool minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 validation-timeout: 5000 idle-timeout: 600000 max-lifetime: 1800000 jpa: open-in-view: false show-sql: false hibernate: ddl-auto: validate properties: hibernate: format_sql: true default_batch_fetch_size: 50 jdbc: batch_size: 50 order_inserts: true order_updates: true query: fail_on_pagination_over_collection_fetch: true flyway: enabled: true locations: classpath:db/migration validate-on-migrate: true logging: level: org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace
关键配置说明:
配置
建议
spring.jpa.open-in-view
Web 项目建议显式设为 false
spring.jpa.hibernate.ddl-auto
生产环境建议使用 validate 或 none
spring.jpa.show-sql
不建议作为正式 SQL 日志方案
hibernate.format_sql
开发环境可开启
hibernate.jdbc.batch_size
批量写入时可设置,但还需结合主键策略验证效果
Flyway
生产环境使用版本化 SQL 管理表结构
ddl-auto=update 看起来方便,但它不是可靠的数据库迁移方案。生产环境应使用 Flyway 或 Liquibase,历史迁移文件一旦执行就不应修改,只新增更高版本脚本。
4.3 连接池参数不能照抄模板 示例中的 HikariCP 参数只是一个可运行起点,不是适用于所有系统的标准答案。
连接池越大,不代表吞吐量越高。过多数据库连接可能导致:
数据库线程竞争;
内存和 Buffer 压力;
锁竞争加剧;
上下文切换增加;
慢 SQL 同时堆积;
故障时更快压垮数据库。
连接池大小至少要结合:
1 2 3 4 5 6 7 8 数据库最大连接数 应用实例数量 单条 SQL 延迟 事务平均时长 峰值并发 读写比例 慢查询比例 数据库 CPU 与 IO 能力
如果使用 JDK 21 虚拟线程,能够创建更多并发任务,也不代表数据库能够同时处理更多事务。虚拟线程减少的是 Java 平台线程成本,数据库连接仍然是有限资源。
建议监控:
active connections;
idle connections;
pending threads;
connection acquire time;
transaction duration;
SQL P95 / P99。
当请求大量等待连接时,正确答案未必是扩大连接池,也可能是慢 SQL、长事务、锁等待或流量缺少背压。
4.4 建议的目录结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 src/main/java/com/example/user ├── application │ ├── UserApplicationService.java │ └── command ├── domain │ ├── UserEntity.java │ └── UserStatus.java ├── infrastructure │ ├── UserRepository.java │ └── UserSpecifications.java └── interfaces └── UserController.java src/main/resources ├── application.yml └── db └── migration ├── V1__create_user_table.sql └── V2__add_user_version.sql
具体是否严格采用 DDD 分层,要根据项目复杂度决定。关键是避免把 Controller、事务、实体修改和 SQL 拼接全部塞进一个类。
chapter 5:实体映射与建模 5.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 28 29 30 package com.example.common.persistence;import jakarta.persistence.Column;import jakarta.persistence.EntityListeners;import jakarta.persistence.MappedSuperclass;import java.time.Instant;import org.springframework.data.annotation.CreatedDate;import org.springframework.data.annotation.LastModifiedDate;import org.springframework.data.jpa.domain.support.AuditingEntityListener;@MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseAuditEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) private Instant updatedAt; public Instant getCreatedAt () { return createdAt; } public Instant getUpdatedAt () { return updatedAt; } }
开启审计:
1 2 3 4 5 6 7 8 9 package com.example.config;import org.springframework.context.annotation.Configuration;import org.springframework.data.jpa.repository.config.EnableJpaAuditing;@Configuration @EnableJpaAuditing public class JpaConfig { }
如果还要记录创建人和修改人,可以增加:
1 2 3 4 5 @CreatedBy private Long createdBy;@LastModifiedBy private Long updatedBy;
并提供 AuditorAware<Long> Bean,从登录上下文中获取当前用户 ID。
5.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 77 78 79 80 81 82 83 84 85 86 87 package com.example.user.domain;import com.example.common.persistence.BaseAuditEntity;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.EnumType;import jakarta.persistence.Enumerated;import jakarta.persistence.FetchType;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.JoinColumn;import jakarta.persistence.ManyToOne;import jakarta.persistence.Table;import jakarta.persistence.Version;import java.time.Instant;@Entity @Table(name = "sys_user") public class UserEntity extends BaseAuditEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "username", nullable = false, length = 64) private String username; @Column(name = "email", nullable = false, length = 128, unique = true) private String email; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) private UserStatus status; @Column(name = "last_login_at") private Instant lastLoginAt; @Version @Column(name = "version", nullable = false) private Long version; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "department_id") private DepartmentEntity department; protected UserEntity () { } public UserEntity (String username, String email) { this .username = username; this .email = email; this .status = UserStatus.ACTIVE; } public void changeUsername (String username) { this .username = username; } public void disable () { this .status = UserStatus.DISABLED; } public void assignDepartment (DepartmentEntity department) { this .department = department; } public Long getId () { return id; } public String getUsername () { return username; } public String getEmail () { return email; } public UserStatus getStatus () { return status; } public DepartmentEntity getDepartment () { return department; } }
枚举:
1 2 3 4 5 public enum UserStatus { ACTIVE, DISABLED, LOCKED }
5.3 常用实体注解
注解
作用
@Entity
声明 JPA 实体
@Table
指定数据库表及索引、唯一约束等
@Id
声明主键
@GeneratedValue
声明主键生成策略
@Column
配置字段名、长度、空值、更新能力等
@Enumerated
映射枚举
@Transient
声明字段不持久化
@MappedSuperclass
将父类字段映射给子实体
@Embedded
嵌入值对象
@Version
实现乐观锁版本控制
@OneToOne
一对一关联
@ManyToOne
多对一关联
@OneToMany
一对多关联
@ManyToMany
多对多关联
5.4 枚举建议使用 STRING 推荐:
1 2 @Enumerated(EnumType.STRING) private UserStatus status;
不推荐:
1 2 @Enumerated(EnumType.ORDINAL) private UserStatus status;
ORDINAL 保存的是枚举序号。枚举顺序一旦调整,数据库中的旧数据含义可能发生变化。
5.5 关联关系默认使用 LAZY 思维 对业务实体进行关联建模时,建议明确写出:
1 2 @ManyToOne(fetch = FetchType.LAZY) private DepartmentEntity department;
原因不是“懒加载永远更快”,而是加载边界应该由查询用例决定,而不是让每一次查询都隐式拉取整张对象图。
常见原则:
默认保持关联懒加载;
查询详情时用 join fetch、@EntityGraph 或 DTO 投影明确加载;
不要把 JPA Entity 直接作为 Controller 返回值;
不要随意使用 CascadeType.ALL;
多对多关系在复杂业务中通常应拆成中间实体。
5.6 不要对实体滥用 Lombok @Data @Data 会生成 toString()、equals() 和 hashCode()。关联字段被包含后,可能导致:
懒加载被意外触发;
双向关联递归;
日志打印整张对象图;
实体放入 HashSet 后因字段变化导致哈希不一致;
Hibernate 代理对象比较异常。
实体的相等性策略需要结合主键生成时机和业务标识单独设计,不能简单地“所有字段一起比较”。
chapter 6:审计字段、操作人快照与可追溯数据模型 前面的 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 CREATE TABLE settlement_order ( id BIGINT PRIMARY KEY , bill_no VARCHAR (64 ) NOT NULL , shop_id BIGINT NOT NULL , statement_amount NUMERIC (18 , 2 ) NOT NULL , status VARCHAR (32 ) NOT NULL , create_id BIGINT , create_name VARCHAR (64 ), create_time TIMESTAMPTZ(6 ) NOT NULL , update_id BIGINT , update_name VARCHAR (64 ), update_time TIMESTAMPTZ(6 ) NOT NULL , version BIGINT NOT NULL DEFAULT 0 , deleted BOOLEAN NOT NULL DEFAULT FALSE , CONSTRAINT uk_settlement_order_bill_no UNIQUE (bill_no) );CREATE INDEX idx_settlement_order_shop_status ON settlement_order (shop_id, status);CREATE INDEX idx_settlement_order_create_time ON settlement_order (create_time);
字段建议 create_time / update_time
PostgreSQL 中建议明确区分两种语义:
Instant 映射 TIMESTAMPTZ,表示时间线上的绝对时刻,适合创建时间、更新时间、支付时间和审计时间;
LocalDateTime 映射 TIMESTAMP WITHOUT TIME ZONE,表示不带时区的本地日历时间,适合确实不应随时区转换的业务时间。
跨地区系统优先使用 Instant + TIMESTAMPTZ(6)。同时统一数据库会话时区、JVM 时区和 JSON 序列化格式,不要把 TIMESTAMPTZ 误解为“数据库在每一行保存一个时区名称”;它保存的是绝对时间,展示时按会话时区转换。
create_id / update_id
保存操作人 ID。不要只保存用户名,因为用户名、昵称、手机号都可能变。
create_name / update_name
保存操作人快照。这样即使用户后来改名,单据历史也能看懂。
version
乐观锁字段。用于防止两个用户同时编辑同一条数据时后提交的人覆盖先提交的人。
deleted
软删除标记。建议使用 0/1 或 false/true,公司内部统一即可。真正删除数据前先想清楚审计、追溯、财务对账、客服排查这些场景。
Spring Data JPA 审计字段自动填充 Spring Data 提供了 @CreatedDate、@LastModifiedDate、@CreatedBy、@LastModifiedBy,可以自动记录谁在什么时间创建或更新了实体。
操作人快照对象 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 package com.example.demo.domain.common;import jakarta.persistence.Column;import jakarta.persistence.Embeddable;import lombok.AccessLevel;import lombok.AllArgsConstructor;import lombok.Getter;import lombok.NoArgsConstructor;@Getter @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(staticName = "of") public class AuditActor { @Column(name = "operator_id") private Long id; @Column(name = "operator_name", length = 64) private String name; public static AuditActor system () { return AuditActor.of(0L , "system" ); } }
这里使用 @Embeddable 是为了让 @CreatedBy / @LastModifiedBy 一次性填充操作人 ID 和名称。落表时再通过@AttributeOverride 映射成 create_id、create_name、update_id、update_name。
审计基类 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 package com.example.demo.domain.common;import jakarta.persistence.AttributeOverride;import jakarta.persistence.AttributeOverrides;import jakarta.persistence.Column;import jakarta.persistence.Embedded;import jakarta.persistence.EntityListeners;import jakarta.persistence.MappedSuperclass;import jakarta.persistence.Version;import java.time.Instant;import lombok.Getter;import lombok.Setter;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.jpa.domain.support.AuditingEntityListener;@Getter @Setter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class AbstractAuditableEntity { @CreatedBy @Embedded @AttributeOverrides({ @AttributeOverride(name = "id", column = @Column(name = "create_id", updatable = false)), @AttributeOverride(name = "name", column = @Column(name = "create_name", length = 64, updatable = false)) }) private AuditActor createdBy; @LastModifiedBy @Embedded @AttributeOverrides({ @AttributeOverride(name = "id", column = @Column(name = "update_id")), @AttributeOverride(name = "name", column = @Column(name = "update_name", length = 64)) }) private AuditActor updatedBy; @CreatedDate @Column(name = "create_time", nullable = false, updatable = false) private Instant createTime; @LastModifiedDate @Column(name = "update_time", nullable = false) private Instant updateTime; @Version @Column(name = "version", nullable = false) private Long version; @Column(name = "deleted", nullable = false) private Boolean deleted = false ; }
如果你只想保存 ID,不想保存名称,可以把 AuditActor 换成 Long:
1 2 3 4 5 6 7 @CreatedBy @Column(name = "create_id", updatable = false) private Long createId;@LastModifiedBy @Column(name = "update_id") private Long updateId;
这种方式更简单,AuditorAware<Long> 返回当前用户 ID 即可。
启用 JPA Auditing 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 package com.example.demo.config;import com.example.demo.domain.common.AuditActor;import java.time.Instant;import java.util.Optional;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.auditing.DateTimeProvider;import org.springframework.data.domain.AuditorAware;import org.springframework.data.jpa.repository.config.EnableJpaAuditing;import org.springframework.security.core.Authentication;import org.springframework.security.core.context.SecurityContextHolder;@Configuration @EnableJpaAuditing( auditorAwareRef = "auditorAware", dateTimeProviderRef = "dateTimeProvider" ) public class JpaAuditConfiguration { @Bean public AuditorAware<AuditActor> auditorAware () { return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .filter(Authentication::isAuthenticated) .map(authentication -> { Object principal = authentication.getPrincipal(); if (principal instanceof LoginUser loginUser) { return AuditActor.of(loginUser.userId(), loginUser.realName()); } return AuditActor.system(); }) .or(() -> Optional.of(AuditActor.system())); } @Bean public DateTimeProvider dateTimeProvider () { return () -> Optional.of(Instant.now()); } public record LoginUser (Long userId, String realName) { } }
如果你的项目没有 Spring Security,也可以使用自己封装的上下文:
1 2 3 4 @Bean public AuditorAware<Long> auditorAware () { return () -> Optional.ofNullable(UserContext.getUserId()).or(() -> Optional.of(0L )); }
审计字段的几个工程细节 不要在业务代码里手动设置 createTime、updateTime。业务服务里到处手填,后面一定会出现“某个分支忘了填”的问题。
createTime 设置 updatable = false。创建时间不应被更新 SQL 修改。
updateTime 每次更新实体时自动刷新。注意:如果你使用 @Modifying 写 JPQL 批量更新,实体生命周期回调和脏检查不一定按普通实体更新方式工作,这种场景建议在 JPQL 里显式更新 update_time、update_id。
批量更新后要注意一级缓存。@Modifying(clearAutomatically = true, flushAutomatically = true) 可以在执行修改语句前 flush、执行后清理当前持久化上下文,避免你后面读到旧对象。
chapter 7:主键策略:IDENTITY、SEQUENCE、UUID 与雪花 ID 主键策略会影响插入时机、批处理能力、跨库迁移、分布式生成和 save() 的新实体判断,它不是一个只看注解写法的问题。
主键设计要先看业务和数据库部署形态,不要上来就“全公司统一雪花 ID”。常见策略如下。
数据库自增:IDENTITY 1 2 3 4 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id;
优点:
缺点:
分库分表不友好;
数据迁移、跨库合并麻烦;
Hibernate 需要插入后拿 ID,批量插入优化空间受限。
适合:单库单表、后台管理、小型系统。
数据库序列:SEQUENCE PostgreSQL、Oracle 更适合序列。
1 2 3 4 5 6 7 8 9 @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "settlement_order_seq") @SequenceGenerator( name = "settlement_order_seq", sequenceName = "seq_settlement_order", allocationSize = 50 ) @Column(name = "id") private Long id;
优点:
比 IDENTITY 更利于批量插入;
数据库层统一生成;
PostgreSQL / Oracle 项目很常用。
缺点:
依赖数据库序列;
PostgreSQL 原生支持 Sequence,配合合理的 allocationSize 时更利于 Hibernate 批处理;
分库分表仍要额外设计。
适合:PostgreSQL、Oracle、传统企业系统。
UUID 1 2 3 4 @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id", columnDefinition = "uuid") private UUID id;
优点:
不依赖数据库;
多节点生成简单;
外部暴露时不容易被猜测。
缺点:
PostgreSQL 原生 uuid 类型固定占用 16 字节,比保存为 char(36) 更紧凑;
随机 UUID 对 B+Tree 索引不友好;
排查问题不如 Long 顺手。
如果用 UUID,建议考虑数据库原生 UUID 类型,或使用有序 UUID/ULID。不要为了“看起来高级”就把所有主键都改成 UUID,数据库索引不会陪你演戏。
雪花 ID:应用侧生成 Long 对国内常见业务系统,尤其是后续可能分库分表、消息流转、跨服务关联的系统,BIGINT + 雪花 ID 是一个很实用的方案。
实体基类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.demo.domain.common;import jakarta.persistence.Column;import jakarta.persistence.Id;import jakarta.persistence.MappedSuperclass;import jakarta.persistence.PrePersist;import lombok.Getter;@Getter @MappedSuperclass public abstract class AbstractSnowflakeEntity extends AbstractAuditableEntity { @Id @Column(name = "id", nullable = false, updatable = false) private Long id; @PrePersist protected void initId () { if (this .id == null ) { this .id = Ids.nextId(); } } }
ID 工具入口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.example.demo.domain.common;public final class Ids { private static final SnowflakeIdWorker WORKER = new SnowflakeIdWorker (resolveWorkerId()); private Ids () { } public static long nextId () { return WORKER.nextId(); } private static long resolveWorkerId () { String workerId = System.getProperty("app.worker-id" , "1" ); return Long.parseLong(workerId); } }
雪花 ID 实现:
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 package com.example.demo.domain.common;import java.time.Instant;public final class SnowflakeIdWorker { private static final long EPOCH = Instant.parse("2024-01-01T00:00:00Z" ).toEpochMilli(); 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 SEQUENCE_MASK = ~(-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 SnowflakeIdWorker (long workerId) { if (workerId < 0 || workerId > MAX_WORKER_ID) { throw new IllegalArgumentException ("workerId must be between 0 and " + MAX_WORKER_ID); } this .workerId = workerId; } public synchronized long nextId () { long timestamp = currentTimeMillis(); if (timestamp < lastTimestamp) { throw new IllegalStateException ("Clock moved backwards. Refusing to generate id." ); } if (timestamp == lastTimestamp) { sequence = (sequence + 1 ) & SEQUENCE_MASK; if (sequence == 0 ) { timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L ; } lastTimestamp = timestamp; return ((timestamp - 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(); } }
这种 @PrePersist 方式的优点是简单、JPA 侵入少。因为调用 repository.save(entity) 时 id 仍然是 null,Spring Data JPA 会把它识别为新实体,然后在 persist 前由 @PrePersist 填充 ID。
但是,如果你在构造对象时就提前设置了 ID,Spring Data JPA 可能会把它判断为“已存在实体”,从而走 merge 而不是 persist 。这种场景建议实现 Persistable,显式告诉 Spring Data 当前对象是不是新对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @MappedSuperclass public abstract class AbstractAssignedIdEntity <ID> implements Persistable <ID> { @Transient private boolean isNew = true ; @Override public boolean isNew () { return isNew; } @PostLoad @PostPersist void markNotNew () { this .isNew = false ; } }
Hibernate 6 自定义生成器:@IdGeneratorType 如果你希望主键策略更像 Hibernate 原生生成器,可以使用 Hibernate 6 推荐的 @IdGeneratorType。
自定义注解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.example.demo.domain.common;import static java.lang.annotation.ElementType.FIELD;import static java.lang.annotation.ElementType.METHOD;import static java.lang.annotation.RetentionPolicy.RUNTIME;import java.lang.annotation.Retention;import java.lang.annotation.Target;import org.hibernate.annotations.IdGeneratorType;@IdGeneratorType(SnowflakeHibernateIdGenerator.class) @Retention(RUNTIME) @Target({FIELD, METHOD}) public @interface SnowflakeId { }
生成器:
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 package com.example.demo.domain.common;import java.lang.reflect.Member;import org.hibernate.engine.spi.SharedSessionContractImplementor;import org.hibernate.generator.GeneratorCreationContext;import org.hibernate.id.IdentifierGenerator;public class SnowflakeHibernateIdGenerator implements IdentifierGenerator { private static final SnowflakeIdWorker WORKER = new SnowflakeIdWorker (resolveWorkerId()); public SnowflakeHibernateIdGenerator ( SnowflakeId annotation, Member member, GeneratorCreationContext context ) { } @Override public Object generate (SharedSessionContractImplementor session, Object entity) { return WORKER.nextId(); } private static long resolveWorkerId () { return Long.parseLong(System.getProperty("app.worker-id" , "1" )); } }
实体使用:
1 2 3 4 @Id @SnowflakeId @Column(name = "id", nullable = false, updatable = false) private Long id;
这个方式更 Hibernate 化,适合你想把 ID 生成做成基础设施能力时使用。注意它绑定 Hibernate,不是纯 JPA 标准。如果团队希望尽量少绑定 ORM Provider,@PrePersist 方案会更轻。
chapter 8:从教学实体走向生产实体:结算单建模示例 UserEntity 适合说明 JPA API,但真实生产实体通常需要状态机、金额精度、审计、软删除、版本和业务不变量。下面用结算单展示这些能力如何组合。
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 package com.example.demo.domain.settlement;import com.example.demo.domain.common.AbstractSnowflakeEntity;import jakarta.persistence.Column;import jakarta.persistence.Entity;import jakarta.persistence.EnumType;import jakarta.persistence.Enumerated;import jakarta.persistence.Index;import jakarta.persistence.Table;import java.math.BigDecimal;import lombok.AccessLevel;import lombok.Getter;import lombok.NoArgsConstructor;@Getter @Entity @Table( name = "settlement_order", indexes = { @Index(name = "idx_settlement_order_shop_status", columnList = "shop_id,status"), @Index(name = "idx_settlement_order_create_time", columnList = "create_time") } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class SettlementOrder extends AbstractSnowflakeEntity { @Column(name = "bill_no", nullable = false, length = 64, unique = true) private String billNo; @Column(name = "shop_id", nullable = false) private Long shopId; @Column(name = "statement_amount", nullable = false, precision = 18, scale = 2) private BigDecimal statementAmount; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) private SettlementOrderStatus status; public SettlementOrder (String billNo, Long shopId, BigDecimal statementAmount) { this .billNo = billNo; this .shopId = shopId; this .statementAmount = statementAmount; this .status = SettlementOrderStatus.DRAFT; } public void submit () { if (status != SettlementOrderStatus.DRAFT) { throw new IllegalStateException ("Only draft settlement order can be submitted." ); } this .status = SettlementOrderStatus.SUBMITTED; } public void markDeleted () { setDeleted(true ); } }
1 2 3 4 5 6 7 8 package com.example.demo.domain.settlement;public enum SettlementOrderStatus { DRAFT, SUBMITTED, CONFIRMED, CANCELED }
实体设计建议:
构造函数保证必要字段完整。
状态流转放实体方法,不要让 Service 到处 setStatus。
业务字段尽量不要开放无脑 setter。
金额用 BigDecimal,不要用 double。
枚举推荐 EnumType.STRING,不要用 ordinal,枚举顺序一改数据库就翻车。
上面的结算单示例用于展示工程化实体基座。下面重新使用更精简的 UserEntity 讲解 save()、脏检查和实体状态;这些核心机制对结算单等业务实体完全相同。
chapter 9:CRUD、实体状态与 save 的真实行为 9.1 基础 Repository 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.example.user.infrastructure;import com.example.user.domain.UserEntity;import com.example.user.domain.UserStatus;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.JpaSpecificationExecutor;public interface UserRepository extends JpaRepository <UserEntity, Long>, JpaSpecificationExecutor<UserEntity> { Optional<UserEntity> findByEmailIgnoreCase (String email) ; boolean existsByEmailIgnoreCase (String email) ; Page<UserEntity> findByStatus ( UserStatus status, Pageable pageable ) ; }
常用基础方法:
1 2 3 4 5 6 userRepository.save(user); userRepository.findById(id); userRepository.existsById(id); userRepository.findAll(); userRepository.count(); userRepository.deleteById(id);
9.2 save() 不等于数据库中的单一 INSERT Spring Data JPA 会根据实体是否为新对象,选择:
新实体:EntityManager.persist();
已存在实体:EntityManager.merge()。
判断实体是否为新的默认策略大致为:
优先检查非基本类型的 @Version 字段是否为 null;
没有版本字段时检查主键是否为 null;
也可以通过实现 Persistable<ID> 自定义 isNew()。
因此,手工分配主键且没有版本字段时,Spring Data 可能把新对象当作已存在对象,从而调用 merge()。
9.3 merge() 的一个重要细节 merge() 返回的是受当前持久化上下文管理的实例。
传入的原对象不一定被纳入管理,因此不要依赖下面这种写法中的原对象状态:
1 2 3 4 UserEntity detached = new UserEntity ("Mario" , "mario@example.com" );UserEntity managed = userRepository.save(detached);
对于新增实体,通常直接保留 save() 的返回值是更稳妥的做法。
9.4 脏检查意味着更新时不一定需要再次 save 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Service public class UserApplicationService { private final UserRepository userRepository; public UserApplicationService (UserRepository userRepository) { this .userRepository = userRepository; } @Transactional public void changeUsername (Long userId, String newUsername) { UserEntity user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException ("用户不存在" )); user.changeUsername(newUsername); } }
在事务中查询得到的 user 是托管实体。属性变化后,Hibernate 会在 flush 时执行脏检查并生成 UPDATE,因此这里不强制要求再次调用 save()。
但这不意味着 save() 没有价值:
新增实体需要 save();
处理游离实体时需要明确合并;
团队可以为了 Repository 抽象一致性选择显式 save();
不要把“是否调用 save”当成风格之争,关键是理解实体状态。
9.5 findById() 与 getReferenceById() 1 2 3 4 UserEntity user = userRepository.findById(id) .orElseThrow();UserEntity reference = userRepository.getReferenceById(id);
区别:
findById() 通常立即查询数据库,并返回 Optional;
getReferenceById() 通常返回代理引用,真正访问属性时才可能查询;
只需要建立外键关系时,代理引用可以减少一次查询;
代理离开事务后再访问未初始化属性,可能抛出懒加载异常。
示例:
1 2 3 4 5 6 7 8 9 10 @Transactional public void assignDepartment (Long userId, Long departmentId) { UserEntity user = userRepository.findById(userId) .orElseThrow(); DepartmentEntity department = departmentRepository.getReferenceById(departmentId); user.assignDepartment(department); }
chapter 10:方法名派生查询 10.1 基础语法 Spring Data JPA 可以根据 Repository 方法名生成查询:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 List<UserEntity> findByUsername (String username) ; Optional<UserEntity> findByEmailIgnoreCase (String email) ; List<UserEntity> findByStatusAndUsernameContainingIgnoreCase ( UserStatus status, String username ) ; List<UserEntity> findTop10ByStatusOrderByCreatedAtDesc ( UserStatus status ) ;boolean existsByEmailIgnoreCase (String email) ;long countByStatus (UserStatus status) ;long deleteByStatus (UserStatus status) ;
方法名通常由两部分构成:
例如:
1 2 3 4 5 findTop10ByStatusOrderByCreatedAtDesc │ │ │ │ │ └── 排序 │ └───────── 条件 └────────────────── 查询主题与结果限制
10.2 常用关键字
关键字
示例
And
findByStatusAndUsername
Or
findByEmailOrUsername
Between
findByCreatedAtBetween
LessThan
findByAgeLessThan
GreaterThanEqual
findByAmountGreaterThanEqual
IsNull
findByDeletedAtIsNull
IsNotNull
findByEmailIsNotNull
Like
findByUsernameLike
Containing
findByUsernameContaining
StartingWith
findByUsernameStartingWith
EndingWith
findByUsernameEndingWith
In
findByIdIn
NotIn
findByStatusNotIn
True
findByEnabledTrue
False
findByEnabledFalse
IgnoreCase
findByEmailIgnoreCase
OrderBy
findByStatusOrderByCreatedAtDesc
Top / First
findTop10ByStatus
Distinct
findDistinctByDepartmentName
10.3 派生查询的边界 派生查询适合:
条件少;
含义清楚;
不需要复杂分组;
方法名仍然容易阅读。
不适合:
1 findTop20DistinctByStatusAndUsernameContainingIgnoreCaseOrEmailContainingIgnoreCaseAndCreatedAtBetweenOrderByCreatedAtDesc(...)
当方法名开始像密码时,就该换 @Query、Specification 或自定义 Repository 了。
chapter 11:使用 JPQL 与原生 SQL 11.1 JPQL 面向实体而不是表 1 2 3 4 5 6 7 8 9 10 11 12 13 @Query(""" select u from UserEntity u where (:keyword is null or lower(u.username) like lower(concat('%', :keyword, '%')) or lower(u.email) like lower(concat('%', :keyword, '%'))) and (:status is null or u.status = :status) """) Page<UserEntity> search ( @Param("keyword") String keyword, @Param("status") UserStatus status, Pageable pageable ) ;
JPQL 中使用的是:
不是数据库表名和列名。
11.2 使用命名参数 推荐:
1 where u.status = :status
不推荐在复杂查询中大量使用位置参数:
命名参数在增加或调整条件后更容易维护。
11.3 更新与删除需要 @Modifying 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Modifying( flushAutomatically = true, clearAutomatically = true ) @Query(""" update UserEntity u set u.status = :targetStatus where u.lastLoginAt < :deadline and u.status = :sourceStatus """) int updateInactiveUsers ( @Param("sourceStatus") UserStatus sourceStatus, @Param("targetStatus") UserStatus targetStatus, @Param("deadline") Instant deadline ) ;
调用方:
1 2 3 4 5 6 7 8 @Transactional public int disableInactiveUsers (Instant deadline) { return userRepository.updateInactiveUsers( UserStatus.ACTIVE, UserStatus.DISABLED, deadline ); }
注意:
@Modifying 告诉 Spring Data 该查询不是普通 SELECT;
更新和删除必须运行在写事务中;
JPQL 批量更新会绕过逐实体脏检查;
当前持久化上下文中已加载的实体可能变旧;
clearAutomatically = true 可以在执行后清理持久化上下文,但未提交的实体改动要谨慎处理。
还要区分派生删除与批量 JPQL 删除:deleteByStatus(...) 可能先查出实体,再逐个删除,从而触发生命周期回调;@Modifying @Query("delete ...") 通常直接执行一条批量删除语句,不会逐实体触发 @PreRemove。数据量大时二者的内存占用和语义差别都很明显。
11.4 原生 SQL 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Query( value = """ select u.* from sys_user u where u.status = :status and u.created_at >= :startTime order by u.created_at desc """, countQuery = """ select count(*) from sys_user u where u.status = :status and u.created_at >= :startTime """, nativeQuery = true ) Page<UserEntity> searchNative ( @Param("status") String status, @Param("startTime") Instant startTime, Pageable pageable ) ;
原生 SQL 适合:
数据库特有函数;
CTE、窗口函数;
复杂报表;
需要精确控制执行计划;
JPQL 表达困难的场景。
但它也会增加:
数据库方言耦合;
字段映射风险;
分页统计 SQL 维护成本;
重构实体字段时遗漏 SQL 的风险。
chapter 12:分页、排序与大数据量遍历 12.1 Page、Slice 与 List
返回类型
是否查询总数
适用场景
Page<T>
通常会执行 count 查询
需要总页数、总记录数
Slice<T>
不需要完整总数
只关心是否还有下一页
List<T>
不包含分页元数据
结果量明确且较小
Window<T>
用于滚动式读取
大结果集连续遍历
标准分页:
1 2 3 4 5 6 7 8 9 10 11 Pageable pageable = PageRequest.of( 0 , 20 , Sort.by( Sort.Order.desc("createdAt" ), Sort.Order.asc("id" ) ) ); Page<UserEntity> page = userRepository.findByStatus(UserStatus.ACTIVE, pageable);
Spring Data 的页码从 0 开始。
12.2 为什么 Page 有时很慢 Page<T> 通常至少执行两条 SQL:
查询当前页数据;
查询总记录数。
复杂联表查询的 count SQL 可能比数据 SQL 还慢。
可选方案:
页面不需要总数时改用 Slice<T>;
为原生 SQL 显式编写更简单的 countQuery;
对过滤条件建立合适索引;
避免 count 查询中无意义的 fetch join;
超大数据量导出使用游标、滚动或分段主键查询。
12.3 深分页问题 下面的查询在页码很大时可能性能较差:
1 2 3 4 select * from sys_userorder by created_at desc , id desc limit 20 offset 1000000 ;
数据库需要扫描并跳过大量记录。
更适合持续滚动的方式是基于稳定排序键进行 Keyset Pagination:
1 2 3 4 5 6 select * from sys_userwhere created_at < :lastCreatedAt or (created_at = :lastCreatedAt and id < :lastId)order by created_at desc , id desc limit 20 ;
Keyset 分页要求:
排序字段稳定;
排序条件能够唯一定位;
通常需要把主键作为最后一个排序字段;
对相应字段建立联合索引。
chapter 13:动态查询:QBE 与 Specification 13.1 Query by Example QBE 通过示例对象构建查询:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 UserEntity probe = new UserEntity ("mario" , null );ExampleMatcher matcher = ExampleMatcher.matching() .withIgnoreNullValues() .withMatcher( "username" , ExampleMatcher.GenericPropertyMatchers .contains() .ignoreCase() ); Example<UserEntity> example = Example.of(probe, matcher); List<UserEntity> users = userRepository.findAll(example);
QBE 适合简单的“字段按值匹配”,优点是容易上手,也不需要手写查询字段。
它的限制包括:
不适合复杂的 AND、OR 分组;
不适合集合和 Map 条件;
范围查询能力有限;
复杂关联查询表达能力不足。
13.2 Specification Repository:
1 2 3 4 public interface UserRepository extends JpaRepository <UserEntity, Long>, JpaSpecificationExecutor<UserEntity> { }
查询条件对象:
1 2 3 4 5 6 7 public record UserSearchCondition ( String keyword, UserStatus status, Instant createdFrom, Instant createdTo ) { }
Specification 工具类:
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 package com.example.user.infrastructure;import com.example.user.domain.UserEntity;import com.example.user.domain.UserStatus;import java.time.Instant;import org.springframework.data.jpa.domain.Specification;import org.springframework.util.StringUtils;public final class UserSpecifications { private UserSpecifications () { } public static Specification<UserEntity> keywordContains ( String keyword ) { return (root, query, cb) -> { if (!StringUtils.hasText(keyword)) { return cb.conjunction(); } String pattern = "%" + keyword.toLowerCase() + "%" ; return cb.or( cb.like(cb.lower(root.get("username" )), pattern), cb.like(cb.lower(root.get("email" )), pattern) ); }; } public static Specification<UserEntity> hasStatus ( UserStatus status ) { return (root, query, cb) -> status == null ? cb.conjunction() : cb.equal(root.get("status" ), status); } public static Specification<UserEntity> createdAtBetween ( Instant from, Instant to ) { return (root, query, cb) -> { if (from != null && to != null ) { return cb.between(root.get("createdAt" ), from, to); } if (from != null ) { return cb.greaterThanOrEqualTo( root.get("createdAt" ), from ); } if (to != null ) { return cb.lessThanOrEqualTo( root.get("createdAt" ), to ); } return cb.conjunction(); }; } }
Service 中组合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Transactional(readOnly = true) public Page<UserEntity> search ( UserSearchCondition condition, Pageable pageable ) { Specification<UserEntity> specification = UserSpecifications.keywordContains(condition.keyword()) .and(UserSpecifications.hasStatus(condition.status())) .and(UserSpecifications.createdAtBetween( condition.createdFrom(), condition.createdTo() )); return userRepository.findAll(specification, pageable); }
Specification 的优势是条件可以组合和复用,但也要避免把所有业务查询都堆到一个巨大的工具类中。
13.3 动态查询方案怎么选 flowchart TD
A[需要实现查询] --> B{条件是否固定且简单}
B -->|是| C[方法名派生查询]
B -->|否| D{JPQL 是否容易表达}
D -->|是| E[@Query]
D -->|否| F{是否主要是动态筛选}
F -->|是| G[Specification]
F -->|简单字段匹配| H[Query by Example]
F -->|复杂统计/数据库特性| I[Native SQL / MyBatis / JdbcTemplate]
chapter 14:投影与 DTO 查询 14.1 为什么不要所有查询都返回完整实体 列表接口通常只需要:
如果每次都加载整个实体及其关联,不仅浪费 IO,还容易触发额外懒加载。
14.2 接口投影 1 2 3 4 5 6 7 8 9 10 public interface UserSummaryView { Long getId () ; String getUsername () ; String getEmail () ; UserStatus getStatus () ; }
Repository:
1 List<UserSummaryView> findByStatus (UserStatus status) ;
当投影属性与实体属性匹配时,Spring Data 可以基于返回类型构建投影。
14.3 DTO 投影 1 2 3 4 5 6 7 8 9 10 11 package com.example.user.application.dto;import com.example.user.domain.UserStatus;public record UserSummary ( Long id, String username, String email, UserStatus status ) { }
JPQL 构造器表达式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Query(""" select new com.example.user.application.dto.UserSummary( u.id, u.username, u.email, u.status ) from UserEntity u where u.status = :status order by u.createdAt desc """) List<UserSummary> findSummariesByStatus ( @Param("status") UserStatus status ) ;
投影的价值:
降低查询字段数量;
避免把实体暴露到接口层;
明确接口返回结构;
减少不必要的对象图加载;
更适合查询模型与写模型分离。
chapter 15:N+1 查询与关联加载 15.1 什么是 N+1 假设先查询 20 个用户:
1 select * from sys_user limit 20 ;
随后代码遍历访问部门:
1 2 3 for (UserEntity user : users) { System.out.println(user.getDepartment().getName()); }
如果每个部门都触发一条查询,就会形成:
这就是常见的 N+1 查询问题。
15.2 使用 fetch join 1 2 3 4 5 6 7 @Query(""" select u from UserEntity u left join fetch u.department where u.id = :id """) Optional<UserEntity> findDetailById (@Param("id") Long id) ;
15.3 使用 @EntityGraph 1 2 3 4 5 @EntityGraph(attributePaths = "department") Page<UserEntity> findByStatus ( UserStatus status, Pageable pageable ) ;
@EntityGraph 可以为某个查询方法声明需要加载的关联,而不必把关联永久改成 EAGER。
15.4 使用 DTO 投影 如果接口只需要部门名称:
1 2 3 4 5 6 7 8 9 10 @Query(""" select new com.example.user.application.dto.UserDetail( u.id, u.username, u.department.name ) from UserEntity u where u.id = :id """) Optional<UserDetail> findUserDetail (@Param("id") Long id) ;
DTO 投影往往是查询接口最清晰的方案。
15.5 分页与一对多 fetch join 的陷阱 对一对多集合进行 fetch join 并直接分页,可能导致:
主表记录重复;
Hibernate 在内存中分页;
count SQL 难以生成;
查询结果数量与预期不一致。
常见解决方式:
第一条 SQL 只分页查询主表 ID;
第二条 SQL 根据 ID 集合查询详情;
使用 DTO 聚合;
将集合拆成独立查询;
根据业务场景使用批量抓取。
不要看到 N+1 就无脑给所有关联加 fetch join。查询优化不是贴创可贴,贴多了也会闷出问题。
chapter 16:软删除不是一个布尔字段那么简单 方案一:业务显式过滤
1 Optional<SettlementOrder> findByBillNoAndDeletedFalse (String billNo) ;
优点是清晰、可控;缺点是每个查询都要记得加条件。
方案二:统一 Specification
1 Specification.where(notDeleted()).and(otherConditions)
适合动态查询。
方案三:Hibernate @SoftDelete
Hibernate 6.4 开始提供了 @SoftDelete。它更自动,但这是 Hibernate 能力,不是 JPA 标准。如果项目强绑定 Hibernate,可以评估;如果项目强调 ORM Provider 可替换,建议先用显式字段和查询条件。
我的建议:
财务、订单、结算类系统:显式 deleted 字段 + Repository/Specification 统一约束;
简单后台系统:可以评估 @SoftDelete;
审计要求强的系统:软删除之外还要记录删除人、删除时间、删除原因。
软删除与业务状态不能混为一谈 财务系统中的“作废”“撤销”“冲销”“关闭”通常是业务状态,而不是技术删除。
例如已经确认的结算单出现错误,更合理的处理可能是:
1 2 3 4 保留原单 生成冲销记录 记录原因和操作人 建立原单与冲销单关联
而不是直接把原记录改成:
技术软删除主要用于隐藏无效数据;业务状态则用于表达业务事实。两者需要分别建模。
删除元数据 审计要求较高时,建议至少考虑:
1 2 3 4 deleted deleted_by deleted_at delete_reason
如果允许恢复,还需要明确:
哪些状态可以恢复;
恢复后回到什么状态;
谁执行恢复;
是否影响下游数据;
是否需要重新发布事件。
软删除与唯一索引 假设业务号有普通唯一约束:
1 2 ALTER TABLE settlement_order ADD CONSTRAINT uk_order_bill_no UNIQUE (bill_no);
软删除以后,旧行仍然存在,因此相同业务号不能再次创建。
这不一定是问题。财务单号通常应该永久唯一,删除后也不允许复用。
如果业务明确要求“只约束未删除数据唯一”,PostgreSQL 可以直接使用部分唯一索引:
1 2 3 CREATE UNIQUE INDEX uk_active_bill_no ON settlement_order (bill_no) WHERE deleted = FALSE ;
这个索引只覆盖未删除记录,因此:
同一业务号最多存在一条 deleted = FALSE 的有效记录;
可以保留多条 deleted = TRUE 的历史记录;
不需要额外生成列;
查询条件包含 deleted = FALSE 时,优化器也有机会直接使用该索引。
是否允许业务号复用必须由业务规则决定,不能只从数据库技巧出发。
Native SQL 仍需显式处理删除语义 即使使用 Hibernate @SoftDelete,以下访问路径仍然需要特别审查:
Native SQL;
MyBatis;
JdbcTemplate;
数据导出;
报表;
数据修复脚本;
CDC 下游;
缓存预热任务。
框架自动过滤只覆盖框架知道的查询路径,不会让所有系统组件自动理解“已删除”的业务语义。
批量软删除的审计边界 批量 JPQL 更新:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Modifying( flushAutomatically = true, clearAutomatically = true ) @Query(""" update SettlementOrder o set o.deleted = true, o.deletedAt = :now, o.deletedBy = :operatorId, o.updateTime = :now, o.version = o.version + 1 where o.id in :ids and o.deleted = false """) int softDeleteByIds ( @Param("ids") Collection<Long> ids, @Param("operatorId") Long operatorId, @Param("now") Instant now ) ;
它不会逐实体调用生命周期回调,也不会自动发布每个聚合的领域事件。使用前必须确认业务是否允许绕过逐实体校验。
chapter 17:事务边界 17.1 事务应该放在 Service 层 推荐:
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 @Service public class UserApplicationService { private final UserRepository userRepository; private final DepartmentRepository departmentRepository; public UserApplicationService ( UserRepository userRepository, DepartmentRepository departmentRepository ) { this .userRepository = userRepository; this .departmentRepository = departmentRepository; } @Transactional public Long register (RegisterUserCommand command) { if (userRepository.existsByEmailIgnoreCase(command.email())) { throw new IllegalStateException ("邮箱已经存在" ); } UserEntity user = new UserEntity ( command.username(), command.email() ); UserEntity saved = userRepository.save(user); return saved.getId(); } @Transactional(readOnly = true) public UserEntity get (Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException ("用户不存在" )); } }
事务边界应覆盖完整的业务工作单元,而不只是某一条 SQL。
例如“创建订单并扣减库存”必须作为一个业务事务,而不是分别依赖两个 Repository 方法自己的事务。
Spring Data JPA 对继承自 CrudRepository 的基础方法已经提供默认事务配置:读取方法通常带有 readOnly = true,写方法使用普通事务。但自行声明的查询方法默认不会自动获得事务属性。工程上仍建议由 Service 层开启覆盖整个工作单元的外部事务;一旦存在外部事务,它将决定内部 Repository 调用实际参与的事务配置。
17.2 readOnly = true 是优化提示,不是绝对防火墙 1 2 3 4 @Transactional(readOnly = true) public UserDetail queryUser (Long id) { }
readOnly = true 可以向底层事务管理器、JDBC 驱动和 Hibernate 传递只读提示。Hibernate 在只读事务下还可能调整 flush 策略,减少脏检查开销。
但它不等于 Java 编译器级别的“禁止修改”,不能依赖它代替代码规范和权限控制。
17.3 默认回滚规则 Spring @Transactional 默认行为:
RuntimeException 和 Error 触发回滚;
checked exception 默认不触发回滚。
需要让受检异常也回滚时:
1 2 3 4 @Transactional(rollbackFor = Exception.class) public void importUsers () throws Exception { }
17.4 同类方法自调用问题 1 2 3 4 5 6 7 8 9 10 11 12 @Service public class DemoService { public void outer () { inner(); } @Transactional public void inner () { } }
默认代理模式下,outer() 直接调用同一个对象的 inner(),不会经过 Spring 事务代理,因此 inner() 上的事务可能不会生效。
常见解决方案:
将事务方法拆到另一个 Spring Bean;
将事务标注放到外部调用入口;
重新设计业务边界;
不要轻易通过“从容器里获取自己”来绕过设计问题。
chapter 18:并发控制与锁 18.1 乐观锁 实体字段:
1 2 @Version private Long version;
更新时 SQL 会带上版本条件:
1 2 3 4 update sys_userset username = ?, version = version + 1 where id = ? and version = ?;
如果影响行数为 0,说明数据已被其他事务修改,Hibernate 会抛出乐观锁相关异常。
乐观锁适合:
冲突概率较低;
读多写少;
希望避免长时间数据库锁;
可以在业务层重试或提示用户刷新。
18.2 悲观锁 1 2 3 4 5 6 7 @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" select u from UserEntity u where u.id = :id """) Optional<UserEntity> findByIdForUpdate (@Param("id") Long id) ;
需要在事务中调用:
1 2 3 4 5 6 7 @Transactional public void updateCriticalData (Long userId) { UserEntity user = userRepository.findByIdForUpdate(userId) .orElseThrow(); }
悲观锁适合必须串行修改的关键资源,但需要控制:
锁持有时间;
查询索引;
锁顺序;
死锁重试;
事务中不要执行远程 HTTP 调用。
18.3 锁不能代替业务幂等 支付回调、消息消费、任务补偿等场景,还应结合:
业务唯一键;
去重表;
状态机;
CAS 条件更新;
幂等号;
数据库唯一约束。
chapter 19:批量操作与性能优化 19.1 saveAll() 不等于天然高性能批量写入 1 userRepository.saveAll(users);
它提供批量调用入口,但最终能否形成 JDBC Batch,还取决于:
hibernate.jdbc.batch_size;
主键生成策略;
是否频繁 flush;
SQL 是否一致;
数据库驱动配置;
事务边界。
使用 PostgreSQL IDENTITY 时,Hibernate 往往需要在插入后获取主键,批处理能力可能受限。单库项目更适合使用 Sequence 并设置合理的 allocationSize;本文的分片场景则继续使用应用侧生成的 BIGINT ID。无论采用哪种策略,都必须通过 SQL 日志和压测验证,不能看到 saveAll() 就默认已经批量优化。
19.2 分批 flush 与 clear 大批量处理时可以分段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Transactional public void batchInsert (List<CreateUserCommand> commands) { int batchSize = 50 ; for (int i = 0 ; i < commands.size(); i++) { CreateUserCommand command = commands.get(i); entityManager.persist( new UserEntity (command.username(), command.email()) ); if ((i + 1 ) % batchSize == 0 ) { entityManager.flush(); entityManager.clear(); } } }
clear() 可以避免持久化上下文长期持有大量实体导致内存膨胀。
19.3 批量更新优先考虑单条 UPDATE 低效方式:
1 2 3 4 List<UserEntity> users = userRepository.findAllById(ids);for (UserEntity user : users) { user.disable(); }
如果业务不需要逐实体事件、校验或审计,可以使用:
1 2 3 4 5 6 7 8 9 10 @Modifying(clearAutomatically = true) @Query(""" update UserEntity u set u.status = :status where u.id in :ids """) int updateStatusByIds ( @Param("ids") Collection<Long> ids, @Param("status") UserStatus status ) ;
但超大的 IN 集合也要拆批,或者使用临时表、批量表关联等数据库方案。
19.4 索引必须围绕查询设计 例如查询:
1 2 3 where status = ? and created_at >= ?order by created_at desc , id desc
可以评估联合索引:
1 2 create index idx_user_status_created_id on sys_user(status, created_at, id);
索引设计需要结合:
过滤选择性;
排序;
回表成本;
写入负担;
数据分布;
实际执行计划。
ORM 可以生成 SQL,但不会自动替你理解业务数据分布。
chapter 20:一级缓存、二级缓存与查询缓存 20.1 一级缓存是持久化上下文的一部分 一级缓存默认存在,范围通常是当前 EntityManager。
它主要解决:
实体身份一致性;
同一工作单元中的重复主键读取;
脏检查所需的状态管理。
它不解决:
跨请求缓存;
跨节点缓存;
热点数据保护;
数据库高并发读压力。
批量 JPQL、Native SQL 或外部程序直接修改数据库后,一级缓存中的实体可能变旧。因此批量更新经常需要:
1 2 entityManager.flush(); entityManager.clear();
20.2 二级缓存要以一致性成本为前提 二级缓存跨持久化上下文共享,需要额外缓存 Provider 和缓存策略。
比较适合:
变化很少的字典;
基础配置;
读取频繁、写入极少的数据;
所有更新路径都能被 Hibernate 感知的数据。
不适合轻易缓存:
余额;
库存;
结算金额;
高频状态;
强一致财务实体;
还会被其他系统或脚本直接修改的数据。
缓存会减少数据库读取,但也制造新的状态副本。多一个副本,就多一个一致性问题。
20.3 查询缓存不是万能加速器 查询缓存只适合参数重复度高、结果变化少的查询。
对于高基数参数、频繁变化的数据,可能出现:
命中率低;
失效频繁;
维护成本高;
内存浪费;
性能问题更难解释。
默认不要全局开启查询缓存。先检查 SQL、索引、分页和 N+1,再讨论缓存。
chapter 21:JDK 21 虚拟线程与 JPA 的真实边界 21.1 虚拟线程能解决什么 Spring Boot 在 JDK 21+ 环境可以启用:
1 2 3 4 spring: threads: virtual: enabled: true
虚拟线程可以降低大量阻塞任务占用平台线程的成本,适合传统 MVC、JDBC、阻塞式远程调用等模型。
21.2 JDBC 仍然是阻塞式,数据库仍然要干活 虚拟线程可以更便宜地等待 JDBC,但数据库仍然需要:
获取连接;
执行 SQL;
等待锁;
读取数据;
返回结果。
因此它不会修复:
全表扫描;
N+1;
深分页;
锁等待;
慢 SQL;
超长事务。
21.3 连接池仍然是硬约束 即使能够创建大量虚拟线程,HikariCP 可能只有 20 个数据库连接。
超过连接池容量的事务仍然需要排队。
不要因为启用虚拟线程,就把连接池无脑放大。连接池大小仍然需要结合数据库容量、实例数量、SQL 延迟和峰值并发压测决定。
21.4 不要并行操作同一个事务 EntityManager 不是线程安全对象。不要让多个虚拟线程并行共享同一个事务中的实体。
如果确实需要并行读取:
每个任务使用独立事务与持久化上下文;
明确一致性语义;
控制连接池占用;
评估是否应该改成一条聚合 SQL;
避免把 Java 并发直接转化成数据库并发风暴。
chapter 22:远程调用、领域事件与 Outbox 22.1 事务中不要长时间调用外部服务 不推荐:
1 2 3 4 5 6 7 8 @Transactional public void confirm (Long id) { SettlementOrder order = repository.findById(id).orElseThrow(); paymentClient.confirm(order); order.confirm(); }
风险:
长时间占用数据库连接;
长时间持有行锁;
网络超时使事务不可控;
数据库回滚无法撤销远程成功;
重试可能导致重复调用。
22.2 更可靠的事务结构 1 2 3 4 5 6 7 8 9 10 本地事务中: 修改业务状态 写入 Outbox 事件 提交事务 事务提交后: 异步扫描或 CDC 投递 调用外部系统 消费端幂等 失败重试与补偿
Spring Data 的领域事件能力可以改善聚合内表达,但“发布了一个 Java 事件”并不自动意味着 MQ 一定成功。
跨服务可靠性仍然需要:
Transactional Outbox;
本地消息表;
CDC;
可靠投递;
消费幂等;
重试;
死信;
对账补偿。
chapter 23:多租户、读写分离与数据修复 23.1 多租户边界 共享表多租户至少需要:
并确保:
所有查询都带租户条件;
唯一索引包含 tenant_id;
Native SQL 经过租户审查;
管理端跨租户能力单独隔离;
自动化测试覆盖数据越权;
异步任务正确传递租户上下文。
仅依赖每个开发者记得写 findByTenantIdAnd...,风险非常高。
23.2 读写分离不是 readOnly=true 的自动魔法 实现读写分离时要考虑:
主从延迟;
刚写后读;
事务内一致性;
悲观锁查询必须走主库;
路由上下文;
异步线程上下文传播;
故障切换。
23.3 数据修复会绕过实体生命周期 直接 SQL 修复数据库后,可能出现:
审计字段缺失;
version 不一致;
二级缓存过期;
应用缓存未失效;
领域事件未发布;
搜索索引未同步;
下游系统不知道数据变化。
数据修复应该具备:
SQL Review;
影响行数预估;
备份;
回滚方案;
审批记录;
缓存处理;
下游同步;
执行结果审计。
chapter 24:性能诊断与生产观测 24.1 先确认一个接口执行了多少条 SQL 很多慢接口的第一问题不是单条 SQL 慢,而是 SQL 数量异常:
N+1;
重复查询;
JSON 序列化触发懒加载;
权限检查反复访问数据库;
事件监听器再次查询;
相同实体在不同持久化上下文中重复加载。
可使用:
Hibernate SQL 日志;
APM;
Hibernate Statistics;
datasource-proxy;
P6Spy;
集成测试中的 Statement Count。
24.2 参数日志必须考虑敏感信息 1 2 3 4 logging: level: org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace
bind trace 可能包含手机号、单号、金额、身份信息和业务数据。生产环境不要长期无差别开启,应配合脱敏、采样和访问控制。
24.3 执行计划才是数据库的真实回答 对慢查询必须检查数据库执行计划,重点关注:
是否使用预期索引;
扫描行数;
过滤率;
join 顺序;
临时表;
文件排序;
回表;
count 查询;
估算行数与实际行数偏差。
ORM 负责生成 SQL,最终仍然由数据库优化器决定执行方式。
24.4 索引围绕访问路径设计 查询:
1 2 3 4 5 WHERE shop_id = ? AND status = ? AND deleted = 0 AND create_time >= ?ORDER BY create_time DESC , id DESC
可以评估:
1 2 3 4 5 6 7 8 CREATE INDEX idx_order_queryON settlement_order( shop_id, status, deleted, create_time, id );
但不能简单把所有 WHERE 字段都塞进一个索引。还要结合:
等值与范围条件;
字段选择性;
排序;
左前缀;
回表成本;
索引长度;
写入负担;
数据分布;
实际执行计划。
24.5 常见性能反模式
大表无条件 findAll();
不需要总数却使用 Page;
一对多 fetch join 直接分页;
每条记录 saveAndFlush();
列表循环访问懒关联;
事务中执行慢远程调用;
在索引列上套函数;
前导 %keyword% 模糊搜索;
用缓存掩盖坏 SQL;
把连接池调大当作唯一优化。
24.6 性能诊断漏斗 flowchart TD
A[接口变慢] --> B[确认耗时分布]
B --> C{数据库耗时高?}
C -->|否| D[检查远程调用/序列化/锁/线程]
C -->|是| E[统计 SQL 数量]
E --> F{SQL 数量异常?}
F -->|是| G[N+1/重复查询/隐式加载]
F -->|否| H[检查单条 SQL]
H --> I[执行计划]
I --> J[索引/Join/排序/聚合/分页]
J --> K[压测验证]
不要在没有证据时同时修改连接池、线程池、缓存和 SQL。变量一多,最后可能只知道“好像快了”,却不知道为什么。
chapter 25:常见问题与避坑清单 25.1 LazyInitializationException 原因:
事务已经结束;
持久化上下文已经关闭;
此时访问未初始化的懒加载关联。
不推荐的解决方式:
重新开启 Open Session in View;
把所有关联改成 EAGER;
在 Controller 中随意访问 Entity 关联。
推荐方式:
在事务内完成查询模型组装;
使用 fetch join;
使用 @EntityGraph;
使用 DTO 投影。
25.2 N+1 查询 排查方式:
开启 SQL 日志;
观察一次接口请求执行了多少 SQL;
使用 Hibernate Statistics、APM 或数据源监控;
编写集成测试统计 SQL 数量。
25.3 ddl-auto=update 用到生产 风险:
变更不可审计;
无法可靠回滚;
复杂字段变更可能失败;
多实例启动可能冲突;
不同环境结构容易漂移。
生产使用 Flyway,并坚持:
1 2 3 V1__init.sql V2__add_status.sql V3__create_index.sql
只新增迁移,不修改已经执行过的历史版本。
25.4 直接返回 Entity 风险:
暴露内部字段;
触发懒加载;
双向关联导致 JSON 递归;
API 与数据库模型强耦合;
Entity 修改后接口结构意外变化。
推荐:
1 Entity -> Application DTO -> Response
25.5 Repository 方法越多越好 Repository 的目标不是收集所有可能的查询,而是表达当前聚合的持久化需求。
当一个 Repository 出现几百个方法时,通常意味着:
查询职责没有拆分;
读模型和写模型混在一起;
复杂查询应该转移到专用 QueryRepository;
聚合边界可能需要重新审视。
chapter 26:测试 Spring Data JPA 26.1 使用 @DataJpaTest 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @DataJpaTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void shouldFindUserByEmailIgnoringCase () { UserEntity user = new UserEntity ("Mario" , "Mario@Example.com" ); userRepository.saveAndFlush(user); Optional<UserEntity> result = userRepository.findByEmailIgnoreCase( "mario@example.com" ); assertThat(result).isPresent(); assertThat(result.get().getUsername()).isEqualTo("Mario" ); } }
@DataJpaTest 通常只加载 JPA 相关组件,并在测试结束后回滚事务。
26.2 测试不要只依赖 H2 H2 与 PostgreSQL 在以下方面可能存在差异:
SQL 方言;
JSON 类型;
时间类型;
索引与执行计划;
锁行为;
大小写规则;
窗口函数与数据库函数。
对于关键 Repository 和原生 SQL,推荐使用 Testcontainers 启动与生产一致的数据库。
26.3 应测试什么 至少覆盖:
Repository 派生查询;
JPQL;
原生 SQL;
唯一约束;
乐观锁;
悲观锁;
分页 count;
Specification 条件组合;
N+1 与查询次数;
Flyway 脚本能否从空库完整执行。
chapter 27:工程落地模板与最小可用清单 新项目可以按这个组合落地:
1 2 3 4 5 6 7 8 9 主键:BIGINT + 雪花 ID 审计:@EnableJpaAuditing + @CreatedDate + @LastModifiedDate + @CreatedBy + @LastModifiedBy 时间:Instant / TIMESTAMPTZ(6) 删除:deleted 软删除字段 并发:@Version 乐观锁 DDL:Flyway 管理,JPA validate 事务:Service 层统一控制 查询:简单用方法名,动态条件用 Specification,复杂报表用 SQL 返回:DTO / Projection,不直接返回 Entity
最小可用清单 引入依赖:
1 2 3 4 spring-boot-starter-data-jpa 数据库驱动 spring-boot-starter-validation Flyway 或 Liquibase
配置:
1 2 spring.jpa.open-in-view: false spring.jpa.hibernate.ddl-auto: validate
基础设施:
1 2 3 4 5 6 AbstractAuditableEntity AbstractSnowflakeEntity AuditorAware DateTimeProvider Base Repository / Specification 统一异常处理
表字段:
1 2 3 4 5 6 7 8 9 id create_id create_name create_time update_id update_name update_time version deleted
chapter 28:一套实用的选型与编码原则 最终可以将 Spring Data JPA 的使用原则总结为:
把 JPA 当作实体与工作单元管理工具,而不是“自动 SQL 魔法”;
简单查询优先使用方法名派生;
固定复杂查询使用 @Query;
动态筛选使用 Specification;
报表和超复杂 SQL 使用原生 SQL、MyBatis 或 JdbcTemplate;
事务边界放在业务 Service;
查询接口优先使用 DTO 投影;
关联默认保持 LAZY 思维;
主动排查 N+1;
生产环境关闭 Open Session in View;
数据库结构使用 Flyway 管理;
并发更新使用 @Version、数据库锁与业务幂等综合治理;
通过 SQL 日志、执行计划和压测验证性能;
不要因为 Repository 代码短,就误以为数据库访问没有成本。
chapter 29:总结 Spring Data JPA 的真正价值不是“完全不用写 SQL”,而是建立一套统一的数据访问模型:
使用 Entity 表达领域状态;
使用 Repository 表达聚合持久化接口;
使用事务组织业务工作单元;
使用 JPQL、Specification 和投影表达查询;
由 Hibernate 管理实体状态与 SQL 生成;
在复杂查询中保留直接控制 SQL 的能力。
理解这些边界后,Spring Data JPA 会成为提高生产力的工具;忽略这些边界,它就可能变成一个“代码看着很少,SQL 跑得很多”的黑盒。
技术没有银弹,只有适用边界。真正成熟的工程实践,不是坚持所有查询都必须由 JPA 完成,而是知道什么时候应该使用 JPA,什么时候应该果断拿回 SQL 的控制权。
第二部分:ShardingSphere-JDBC、HikariCP 与 JPA 深度整合 chapter 30:先固定完整业务场景 30.1 多租户结算平台 假设系统为多个品牌租户提供结算能力。一张结算单包含若干结算明细,并保存收款人姓名、银行账号、币种、结算金额和状态。
业务逐渐增长后,出现以下约束:
单个订单或结算明细表持续增长;
所有核心查询天然带有 tenant_id;
结算单详情通常同时携带 tenant_id 与 settlement_id;
同一结算单的主表和明细必须在一个本地事务中提交;
收款人和银行账号不能以明文落库;
币种、状态规则需要在每个分片本地可读;
平台任务定义只需要保存一份,不值得水平分片;
报表查询不应拖垮核心 OLTP 分片库。
因此设计如下物理拓扑:
flowchart TB
APP[Spring Boot Settlement Service]
LOGIC[(逻辑库 settlement_logic_db)]
APP --> LOGIC
LOGIC --> DS0[(ds_0 / settlement_ds_0)]
LOGIC --> DS1[(ds_1 / settlement_ds_1)]
DS0 --> O00[t_settlement_order_0]
DS0 --> O01[t_settlement_order_1]
DS0 --> I00[t_settlement_item_0]
DS0 --> I01[t_settlement_item_1]
DS1 --> O10[t_settlement_order_0]
DS1 --> O11[t_settlement_order_1]
DS1 --> I10[t_settlement_item_0]
DS1 --> I11[t_settlement_item_1]
DS0 --> C0[t_currency_dict]
DS1 --> C1[t_currency_dict]
DS0 --> R0[t_settlement_status_rule]
DS1 --> R1[t_settlement_status_rule]
DS0 --> J0[t_job_definition]
30.2 表类型划分
逻辑表
规则类型
路由策略
业务定位
t_settlement_order
分片表
tenant_id 分库、settlement_id 分表
结算聚合根
t_settlement_item
分片表、绑定表
与主表完全一致
结算明细
t_currency_dict
广播表
所有物理库各一份
币种字典
t_settlement_status_rule
广播表
所有物理库各一份
状态流转规则
t_job_definition
单表
固定在 ds_0
平台任务定义
这里要纠正一个常见叫法:ShardingSphere 中与主表按相同规则共同路由、用于优化关联查询的表叫 绑定表(Binding Table) ,不是“分配表”。
30.3 分片规则 1 2 3 4 5 分库键:tenant_id 分库算法:tenant_id % 2 分表键:settlement_id 分表算法:settlement_id % 2
tenant_id
settlement_id
数据源
主表
100
8000
ds_0
t_settlement_order_0
100
8001
ds_0
t_settlement_order_1
101
8000
ds_1
t_settlement_order_0
101
8001
ds_1
t_settlement_order_1
flowchart LR
T[tenant_id] --> TM[tenant_id % 2]
TM -->|0| D0[ds_0]
TM -->|1| D1[ds_1]
S[settlement_id] --> SM[settlement_id % 2]
SM -->|0| P0[table_0]
SM -->|1| P1[table_1]
30.4 为什么分库键选择 tenant_id tenant_id 具备几个优势:
是绝大多数写入和查询的固定条件;
同一租户的数据稳定进入一个物理库;
一次租户内业务事务天然容易落单库;
便于租户级备份、归档、迁移和限流;
大租户未来可以迁移到独立数据源;
多租户越权检查可以与数据库路由统一。
但它也意味着:
跨租户实时查询需要多库路由;
租户数据量可能不均匀;
大租户可能形成热点;
只带 settlement_id 的查询不能精准定位数据库。
没有完美分片键,只有与主访问路径最匹配的分片键。
30.5 为什么分表键选择 settlement_id 使用 settlement_id 分表后,同一个租户数据库中的结算单被进一步分散到两张真实表。
同时,主表和明细表都使用该字段决定表后缀,因此:
1 2 t_settlement_order_0 <-> t_settlement_item_0 t_settlement_order_1 <-> t_settlement_item_1
能够形成绑定表关系。
如果只使用 tenant_id 同时分库和分表,则同一个租户的数据永远只进入一张表,库内分表几乎失去意义。
chapter 31:组件职责与启动顺序 31.1 谁负责什么
组件
负责
不负责
Spring Data JPA
Repository、投影、Specification、分页
物理分片路由
Hibernate
实体状态、脏检查、SQL 生成
决定真实库表
ShardingSphere-JDBC
解析、路由、改写、加密、执行、归并
设计业务聚合边界
HikariCP
管理真实数据库连接
提高数据库本身容量
Flyway
物理 Schema 版本迁移
运行期 SQL 路由
Spring Transaction
业务事务边界
自动把 LOCAL 变成跨库强一致
PostgreSQL
索引、MVCC、锁、本地事务、执行计划
理解逻辑表和逻辑列
31.2 正确启动顺序 Flyway 必须先把每个物理数据库迁移到应用所需版本,再初始化 ShardingSphere 和 Hibernate。
flowchart TD
A[加载物理数据库配置] --> B[Flyway migrate ds_0 common]
B --> C[Flyway migrate ds_1 common]
C --> D[Flyway migrate ds_0 single]
D --> E[校验迁移版本]
E --> F[创建 ShardingSphere DataSource]
F --> G[初始化 EntityManagerFactory]
G --> H[Repository Ready]
H --> I[Application Ready]
如果 Hibernate 先启动,会发生:
元数据加载时真实表不存在;
单表和广播表元数据不完整;
加密物理列不存在;
第一个业务请求才暴露结构错误;
多实例环境中迁移和初始化互相竞争。
31.3 为什么 Flyway 不应通过逻辑数据源迁移 普通单库中,Flyway 使用主 DataSource 很自然;分片系统中却存在根本差异:
1 2 3 4 5 逻辑表 t_settlement_order -> ds_0.t_settlement_order_0 -> ds_0.t_settlement_order_1 -> ds_1.t_settlement_order_0 -> ds_1.t_settlement_order_1
Flyway 需要对每一个真实数据库和真实表有确定控制,因此本文采用:
Flyway 直连物理数据源;JPA 只连接 ShardingSphere 逻辑数据源。
这样可以明确知道:
哪个库已经完成迁移;
哪个库失败;
哪个表后缀缺失;
广播表是否在每个库存在;
单表是否只在指定库存在;
每个物理库的迁移 checksum 是否一致。
chapter 32:依赖与项目结构 32.1 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 <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.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-actuator</artifactId > </dependency > <dependency > <groupId > org.apache.shardingsphere</groupId > <artifactId > shardingsphere-jdbc</artifactId > <version > ${shardingsphere.version}</version > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-core</artifactId > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-database-postgresql</artifactId > </dependency > <dependency > <groupId > org.postgresql</groupId > <artifactId > postgresql</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > org.testcontainers</groupId > <artifactId > junit-jupiter</artifactId > <scope > test</scope > </dependency > <dependency > <groupId > org.testcontainers</groupId > <artifactId > postgresql</artifactId > <scope > test</scope > </dependency > </dependencies >
Spring Boot 默认使用 HikariCP,因此通常不需要单独声明 HikariCP 版本。覆盖 Spring Boot 管理的 Hibernate、Spring Data、Flyway、HikariCP 或 PostgreSQL JDBC Driver 版本前,必须验证完整版本矩阵。
32.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 src/main/java/com/example/settlement ├── SettlementApplication.java ├── config │ ├── PhysicalDatabaseProperties.java │ ├── PhysicalFlywayConfiguration.java │ ├── ShardingDataSourceConfiguration.java │ └── JpaAuditConfiguration.java ├── common │ ├── BusinessIdGenerator.java │ ├── AbstractAuditableEntity.java │ └── ShardedAggregateRepository.java ├── settlement │ ├── application │ ├── domain │ └── infrastructure ├── dictionary └── job src/main/resources ├── application.yml ├── shardingsphere.yaml └── db/migration ├── common │ ├── V1__create_sharded_tables.sql │ ├── V2__create_broadcast_tables.sql │ └── V3__create_indexes.sql └── ds0-single └── V1__create_job_definition.sql
common 在所有物理数据库执行;ds0-single 仅在 ds_0 执行。
32.3 PostgreSQL 18.4 本地双库环境 开发与集成测试环境可以使用两个 PostgreSQL 18.4 容器模拟两个物理分片:
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 services: postgres-ds-0: image: postgres:18.4 container_name: settlement-postgres-ds-0 environment: POSTGRES_DB: settlement_ds_0 POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres TZ: Asia/Singapore ports: - "54321:5432" healthcheck: test: ["CMD-SHELL" , "pg_isready -U postgres -d settlement_ds_0" ] interval: 5s timeout: 3s retries: 30 volumes: - postgres-ds-0-data:/var/lib/postgresql postgres-ds-1: image: postgres:18.4 container_name: settlement-postgres-ds-1 environment: POSTGRES_DB: settlement_ds_1 POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres TZ: Asia/Singapore ports: - "54322:5432" healthcheck: test: ["CMD-SHELL" , "pg_isready -U postgres -d settlement_ds_1" ] interval: 5s timeout: 3s retries: 30 volumes: - postgres-ds-1-data:/var/lib/postgresql volumes: postgres-ds-0-data: postgres-ds-1-data:
启动并检查:
1 2 3 4 5 6 7 8 9 docker compose up -d docker compose ps psql "postgresql://postgres:postgres@127.0.0.1:54321/settlement_ds_0" \ -c "select current_database(), current_schema(), version();" psql "postgresql://postgres:postgres@127.0.0.1:54322/settlement_ds_1" \ -c "select current_database(), current_schema(), version();"
本文把两个分片设计为两个独立 PostgreSQL Database,并统一使用 public Schema。也可以为业务单独创建 settlement Schema,但所有 JDBC URL、Flyway defaultSchema、ShardingSphere 元数据和运维脚本必须保持一致,不能依赖不透明的 search_path 漂移。
PostgreSQL 18 官方 Docker 镜像已经把默认 PGDATA 改为 /var/lib/postgresql/18/docker,并把镜像 VOLUME 改为 /var/lib/postgresql。因此 18+ 应把持久卷挂载到 /var/lib/postgresql,不要沿用 17 及以前常见的 /var/lib/postgresql/data,否则升级或重建容器时可能出现数据卷未实际承载数据的问题。
chapter 33: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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 server: port: 8080 spring: application: name: settlement-sharding-demo datasource: driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver url: jdbc:shardingsphere:classpath:shardingsphere.yaml?placeholder-type=environment flyway: enabled: false jpa: open-in-view: false show-sql: false hibernate: ddl-auto: none properties: hibernate: format_sql: true default_batch_fetch_size: 50 order_inserts: true order_updates: true jdbc: batch_size: 50 time_zone: UTC query: fail_on_pagination_over_collection_fetch: true threads: virtual: enabled: true app: physical-databases: nodes: ds_0: jdbc-url: ${DS0_JDBC_URL:jdbc:postgresql://127.0.0.1:54321/settlement_ds_0?currentSchema=public&ApplicationName=settlement-ds-0&reWriteBatchedInserts=true&tcpKeepAlive=true} username: ${DS0_USERNAME:postgres} password: ${DS0_PASSWORD:postgres} ds_1: jdbc-url: ${DS1_JDBC_URL:jdbc:postgresql://127.0.0.1:54322/settlement_ds_1?currentSchema=public&ApplicationName=settlement-ds-1&reWriteBatchedInserts=true&tcpKeepAlive=true} username: ${DS1_USERNAME:postgres} password: ${DS1_PASSWORD:postgres} logging: level: org.hibernate.SQL: debug org.hibernate.orm.jdbc.bind: trace org.apache.shardingsphere: info com.zaxxer.hikari: info management: endpoints: web: exposure: include: health,info,metrics,prometheus
33.1 为什么关闭默认 Flyway 1 spring.flyway.enabled: false
不是不用 Flyway,而是默认自动配置只会围绕一个主数据源运行。本文需要显式迁移两个物理数据库,并为单表维护独立迁移生命周期。
33.2 为什么使用 ddl-auto=none JPA Entity 映射逻辑列:
物理表保存:
1 2 3 4 payee_name_cipher payee_name_assisted bank_account_cipher bank_account_assisted
Hibernate Schema Validator 通过 JDBC Metadata 验证逻辑结构时,受 ShardingSphere 版本、元数据装载、加密规则和单表规则影响。为了避免把运行期逻辑元数据验证误当成物理迁移保证,本文采用:
1 spring.jpa.hibernate.ddl-auto: none
并使用以下手段保证结构:
Flyway checksum;
双 PostgreSQL Testcontainers;
启动前物理 Schema 检查;
路由与加密冒烟测试;
发布阶段 Schema Drift 校验。
经过本项目版本组合验证后,也可以在测试环境尝试 validate,但绝不能使用 update 自动修改真实分片表。
chapter 34:完整 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 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 databaseName: settlement_logic_db mode: type: Standalone dataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: org.postgresql.Driver standardJdbcUrl: $${DS0_JDBC_URL::jdbc:postgresql://127.0.0.1:54321/settlement_ds_0?currentSchema=public&ApplicationName=settlement-ds-0&reWriteBatchedInserts=true&tcpKeepAlive=true} username: $${DS0_USERNAME::postgres} password: $${DS0_PASSWORD::postgres} poolName: settlement-ds-0-pool minimumIdle: 5 maximumPoolSize: 15 connectionTimeout: 10000 validationTimeout: 3000 idleTimeout: 600000 maxLifetime: 1700000 keepaliveTime: 120000 ds_1: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: org.postgresql.Driver standardJdbcUrl: $${DS1_JDBC_URL::jdbc:postgresql://127.0.0.1:54322/settlement_ds_1?currentSchema=public&ApplicationName=settlement-ds-1&reWriteBatchedInserts=true&tcpKeepAlive=true} username: $${DS1_USERNAME::postgres} password: $${DS1_PASSWORD::postgres} poolName: settlement-ds-1-pool minimumIdle: 5 maximumPoolSize: 15 connectionTimeout: 10000 validationTimeout: 3000 idleTimeout: 600000 maxLifetime: 1700000 keepaliveTime: 120000 rules: - !SHARDING tables: t_settlement_order: actualDataNodes: ds_$->{0..1}.t_settlement_order_$->{0..1} databaseStrategy: standard: shardingColumn: tenant_id shardingAlgorithmName: settlement_database_inline tableStrategy: standard: shardingColumn: settlement_id shardingAlgorithmName: settlement_order_table_inline auditStrategy: auditorNames: - sharding_key_required_auditor allowHintDisable: false t_settlement_item: actualDataNodes: ds_$->{0..1}.t_settlement_item_$->{0..1} databaseStrategy: standard: shardingColumn: tenant_id shardingAlgorithmName: settlement_database_inline tableStrategy: standard: shardingColumn: settlement_id shardingAlgorithmName: settlement_item_table_inline auditStrategy: auditorNames: - sharding_key_required_auditor allowHintDisable: false bindingTables: - t_settlement_order,t_settlement_item shardingAlgorithms: settlement_database_inline: type: INLINE props: algorithm-expression: ds_$->{tenant_id % 2 } allow-range-query-with-inline-sharding: false settlement_order_table_inline: type: INLINE props: algorithm-expression: t_settlement_order_$->{settlement_id % 2 } allow-range-query-with-inline-sharding: false settlement_item_table_inline: type: INLINE props: algorithm-expression: t_settlement_item_$->{settlement_id % 2 } allow-range-query-with-inline-sharding: false auditors: sharding_key_required_auditor: type: DML_SHARDING_CONDITIONS - !BROADCAST tables: - t_currency_dict - t_settlement_status_rule - !SINGLE tables: - ds_0.t_job_definition defaultDataSource: ds_0 - !ENCRYPT tables: t_settlement_order: columns: payee_name: cipher: name: payee_name_cipher encryptorName: payee_name_aes assistedQuery: name: payee_name_assisted encryptorName: assisted_md5 bank_account: cipher: name: bank_account_cipher encryptorName: bank_account_aes assistedQuery: name: bank_account_assisted encryptorName: assisted_md5 encryptors: payee_name_aes: type: AES props: aes-key-value: $${SETTLEMENT_AES_KEY::} digest-algorithm-name: SHA-256 bank_account_aes: type: AES props: aes-key-value: $${SETTLEMENT_AES_KEY::} digest-algorithm-name: SHA-256 assisted_md5: type: MD5 props: salt: $${SETTLEMENT_QUERY_SALT::} props: sql-show: true sql-simple: false kernel-executor-size: 8 max-connections-size-per-query: 1 check-table-metadata-enabled: true load-table-metadata-batch-size: 1000
34.1 5.5.3 为什么写 standardJdbcUrl ShardingSphere 5.5.3 数据源文档对 HikariCP 示例使用 standardJdbcUrl。属性名称最终取决于具体连接池的属性定义和 ShardingSphere 的数据源池创建逻辑,不要把其他版本文章中的 jdbcUrl、url 或 standardJdbcUrl 混用。
如果升级到后续版本,应以目标版本数据源文档和启动测试为准。
34.2 为什么使用 $->{} 行表达式写成:
1 algorithm-expression: ds_$->{tenant_id % 2 }
可以减少与 Spring、Shell、CI 模板中的 ${...} 占位符冲突。
34.3 为什么不额外配置事务规则 本文明确使用 Spring @Transactional 和默认 LOCAL 行为,不启用 XA 或 BASE,因此配置中不额外声明事务 Provider。Spring Boot 会围绕 ShardingSphere 主 DataSource 创建 JPA 事务管理器;只要一次写事务最终只命中一个物理数据库,提交和回滚就是该 PostgreSQL 数据库的普通本地事务。
这也符合本文的核心约束:事务可靠性来自 单库路由设计 ,不是来自额外的分布式事务配置。
34.4 动态占位符为什么写成 $${...} ShardingSphereDriver 直接加载 shardingsphere.yaml,不会自动使用 Spring Boot 的 ${...} 属性解析器。本文在 JDBC URL 中加入:
1 ?placeholder-type=environment
并在 YAML 中使用:
1 2 username: $${DS0_USERNAME::postgres} password: $${DS0_PASSWORD::postgres}
:: 右侧是可选默认值。生产密码和加密密钥不应配置默认值,缺失时应通过启动自检直接失败。
34.5 密钥不能直接写进仓库 示例为了展示结构使用占位符。生产环境应从:
KMS;
Secret Manager;
Kubernetes Secret;
受控配置中心;
容器环境变量;
注入密钥,并确保日志、错误页、Actuator 和配置导出不会泄露它。
chapter 35:Flyway 多物理库迁移 35.1 配置属性 1 2 3 4 5 6 7 8 9 10 11 @ConfigurationProperties(prefix = "app.physical-databases") public record PhysicalDatabaseProperties ( Map<String, Node> nodes ) { public record Node ( String jdbcUrl, String username, String password ) { } }
35.2 common 迁移与 single 迁移 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 public final class PhysicalFlywayMigrationRunner implements InitializingBean { private final PhysicalDatabaseProperties properties; public PhysicalFlywayMigrationRunner ( PhysicalDatabaseProperties properties ) { this .properties = properties; } @Override public void afterPropertiesSet () { migrateCommonSchemas(); migrateDs0SingleSchema(); } private void migrateCommonSchemas () { properties.nodes().forEach((name, node) -> { Flyway.configure() .dataSource( node.jdbcUrl(), node.username(), node.password() ) .locations("classpath:db/migration/common" ) .defaultSchema("public" ) .schemas("public" ) .table("flyway_schema_history" ) .validateOnMigrate(true ) .validateMigrationNaming(true ) .cleanDisabled(true ) .load() .migrate(); }); } private void migrateDs0SingleSchema () { PhysicalDatabaseProperties.Node ds0 = Objects.requireNonNull( properties.nodes().get("ds_0" ), "Missing ds_0" ); Flyway.configure() .dataSource( ds0.jdbcUrl(), ds0.username(), ds0.password() ) .locations("classpath:db/migration/ds0-single" ) .defaultSchema("public" ) .schemas("public" ) .table("flyway_single_schema_history" ) .validateOnMigrate(true ) .validateMigrationNaming(true ) .cleanDisabled(true ) .load() .migrate(); } }
35.3 为什么使用两张 Schema History Table common 和 ds0-single 生命周期不同。如果共用一张历史表,就需要人为协调全局版本号,后续非常容易遇到版本顺序和 out-of-order 问题。
因此使用:
1 2 flyway_schema_history flyway_single_schema_history
分别管理:
所有分片库都必须一致的表;
仅 ds_0 存在的单表。
Flyway 官方也建议对生命周期独立的 Schema 或数据库使用多个 Flyway 实例和独立迁移位置。
本文显式设置:
1 2 .defaultSchema("public" ) .schemas("public" )
这样 Flyway History Table、迁移对象和 pgJDBC 的 currentSchema=public 保持一致。生产若使用独立业务 Schema,应把这里、JDBC URL、初始化权限和运维脚本一起改成同一个 Schema。
35.4 让迁移先于 JPA 初始化 1 2 3 4 5 6 7 8 9 10 11 @Configuration @EnableConfigurationProperties(PhysicalDatabaseProperties.class) public class PhysicalFlywayConfiguration { @Bean("physicalFlywayMigrationRunner") public PhysicalFlywayMigrationRunner migrationRunner ( PhysicalDatabaseProperties properties ) { return new PhysicalFlywayMigrationRunner (properties); } }
逻辑数据源或 JPA 配置应依赖迁移 Bean:
1 2 3 4 5 6 @Bean @Primary @DependsOn("physicalFlywayMigrationRunner") public DataSource dataSource (...) { }
在大型生产环境中,更推荐把 Flyway 迁移作为独立发布 Job,只运行一次;应用启动时只执行 validate 或版本检查,避免几十个实例同时争抢迁移锁。
35.5 PostgreSQL DDL 事务与并发索引 PostgreSQL 的多数 DDL 可以运行在事务中,这是 Flyway 管理结构变更的重要优势。但 CREATE INDEX CONCURRENTLY、DROP INDEX CONCURRENTLY 等命令不能运行在普通事务块中。
对在线大表新增索引时,可以使用独立迁移:
1 2 3 CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_settlement_order_0_status_timeON t_settlement_order_0 (tenant_id, status, created_at DESC );
并为该 Flyway 脚本配置非事务执行,例如使用同名脚本配置文件:
1 executeInTransaction =false
必须注意:
CONCURRENTLY 降低阻塞,但执行时间通常更长;
失败后可能留下 INVALID 索引,需要检查 pg_index.indisvalid;
不要把多条 CREATE INDEX CONCURRENTLY 随意塞进一个版本脚本;
发布系统要能够识别非事务迁移失败并阻止继续部署;
小表和初始化空库不必机械使用 CONCURRENTLY。
chapter 36:真实表 DDL 36.1 分片主表和明细表 V1__create_sharded_tables.sql:
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 97 98 99 100 101 102 103 104 105 CREATE TABLE IF NOT EXISTS t_settlement_order_0 ( settlement_id BIGINT PRIMARY KEY , tenant_id BIGINT NOT NULL , settlement_no VARCHAR (64 ) NOT NULL , currency_code VARCHAR (16 ) NOT NULL , total_amount NUMERIC (18 , 2 ) NOT NULL , status VARCHAR (32 ) NOT NULL , payee_name_cipher VARCHAR (512 ) NOT NULL , payee_name_assisted CHAR (32 ) NOT NULL , bank_account_cipher VARCHAR (512 ) NOT NULL , bank_account_assisted CHAR (32 ) NOT NULL , version BIGINT NOT NULL DEFAULT 0 , deleted BOOLEAN NOT NULL DEFAULT FALSE , created_at TIMESTAMPTZ(6 ) NOT NULL , updated_at TIMESTAMPTZ(6 ) NOT NULL , CONSTRAINT uk_settlement_order_0_no UNIQUE (settlement_no) );CREATE INDEX IF NOT EXISTS idx_settlement_order_0_tenant_status_time ON t_settlement_order_0 (tenant_id, status, created_at DESC , settlement_id DESC );CREATE INDEX IF NOT EXISTS idx_settlement_order_0_payee_assisted ON t_settlement_order_0 (tenant_id, payee_name_assisted);CREATE INDEX IF NOT EXISTS idx_settlement_order_0_account_assisted ON t_settlement_order_0 (tenant_id, bank_account_assisted);CREATE TABLE IF NOT EXISTS t_settlement_order_1 ( settlement_id BIGINT PRIMARY KEY , tenant_id BIGINT NOT NULL , settlement_no VARCHAR (64 ) NOT NULL , currency_code VARCHAR (16 ) NOT NULL , total_amount NUMERIC (18 , 2 ) NOT NULL , status VARCHAR (32 ) NOT NULL , payee_name_cipher VARCHAR (512 ) NOT NULL , payee_name_assisted CHAR (32 ) NOT NULL , bank_account_cipher VARCHAR (512 ) NOT NULL , bank_account_assisted CHAR (32 ) NOT NULL , version BIGINT NOT NULL DEFAULT 0 , deleted BOOLEAN NOT NULL DEFAULT FALSE , created_at TIMESTAMPTZ(6 ) NOT NULL , updated_at TIMESTAMPTZ(6 ) NOT NULL , CONSTRAINT uk_settlement_order_1_no UNIQUE (settlement_no) );CREATE INDEX IF NOT EXISTS idx_settlement_order_1_tenant_status_time ON t_settlement_order_1 (tenant_id, status, created_at DESC , settlement_id DESC );CREATE INDEX IF NOT EXISTS idx_settlement_order_1_payee_assisted ON t_settlement_order_1 (tenant_id, payee_name_assisted);CREATE INDEX IF NOT EXISTS idx_settlement_order_1_account_assisted ON t_settlement_order_1 (tenant_id, bank_account_assisted);CREATE TABLE IF NOT EXISTS t_settlement_item_0 ( item_id BIGINT PRIMARY KEY , tenant_id BIGINT NOT NULL , settlement_id BIGINT NOT NULL , business_type VARCHAR (32 ) NOT NULL , business_no VARCHAR (64 ) NOT NULL , amount NUMERIC (18 , 2 ) NOT NULL , created_at TIMESTAMPTZ(6 ) NOT NULL , CONSTRAINT uk_settlement_item_0_business UNIQUE (tenant_id, business_type, business_no) );CREATE INDEX IF NOT EXISTS idx_settlement_item_0_parent ON t_settlement_item_0 (tenant_id, settlement_id, item_id);CREATE TABLE IF NOT EXISTS t_settlement_item_1 ( item_id BIGINT PRIMARY KEY , tenant_id BIGINT NOT NULL , settlement_id BIGINT NOT NULL , business_type VARCHAR (32 ) NOT NULL , business_no VARCHAR (64 ) NOT NULL , amount NUMERIC (18 , 2 ) NOT NULL , created_at TIMESTAMPTZ(6 ) NOT NULL , CONSTRAINT uk_settlement_item_1_business UNIQUE (tenant_id, business_type, business_no) );CREATE INDEX IF NOT EXISTS idx_settlement_item_1_parent ON t_settlement_item_1 (tenant_id, settlement_id, item_id);
36.2 为什么不创建逻辑加密列 真实数据库只有:
1 2 3 4 payee_name_cipher payee_name_assisted bank_account_cipher bank_account_assisted
应用和 Hibernate 使用:
ShardingSphere 在 SQL 改写阶段完成映射。不要同时在物理表保留同名明文列,否则非常容易出现:
业务误查明文列;
双写不一致;
数据修复脚本泄露明文;
误认为已经完成加密改造。
36.3 唯一索引不是全局唯一 每张真实表上的:
1 CONSTRAINT uk_settlement_order_0_no UNIQUE (settlement_no)
只保证当前真实表内唯一,不能保证四张真实表全局唯一。
因此业务号应由全局唯一 ID 派生,或者使用独立全局唯一服务。物理唯一索引只作为局部防线。
chapter 37:广播表与单表 DDL 37.1 广播表 V2__create_broadcast_tables.sql:
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 CREATE TABLE IF NOT EXISTS t_currency_dict ( currency_code VARCHAR (16 ) PRIMARY KEY , currency_name VARCHAR (64 ) NOT NULL , scale_value INTEGER NOT NULL , enabled BOOLEAN NOT NULL DEFAULT TRUE , config_version BIGINT NOT NULL DEFAULT 0 , updated_at TIMESTAMPTZ(6 ) NOT NULL );CREATE TABLE IF NOT EXISTS t_settlement_status_rule ( status_code VARCHAR (32 ) PRIMARY KEY , allow_edit BOOLEAN NOT NULL , allow_submit BOOLEAN NOT NULL , allow_cancel BOOLEAN NOT NULL , config_version BIGINT NOT NULL DEFAULT 0 , updated_at TIMESTAMPTZ(6 ) NOT NULL );INSERT INTO t_currency_dict ( currency_code, currency_name, scale_value, enabled, config_version, updated_at ) VALUES ('CNY' , '人民币' , 2 , TRUE , 1 , CURRENT_TIMESTAMP ), ('USD' , '美元' , 2 , TRUE , 1 , CURRENT_TIMESTAMP )ON CONFLICT (currency_code) DO UPDATE SET currency_name = EXCLUDED.currency_name, scale_value = EXCLUDED.scale_value, enabled = EXCLUDED.enabled, config_version = EXCLUDED.config_version, updated_at = EXCLUDED.updated_at;
广播表应具备:
数据量小;
更新低频;
读取高频;
所有分片都需要;
可以幂等重放;
可以计算版本或校验和。
用户、商品、库存、余额等高频大表不应广播。
37.2 单表 仅 ds_0 执行:
1 2 3 4 5 6 7 8 9 10 11 CREATE TABLE IF NOT EXISTS t_job_definition ( job_id BIGINT PRIMARY KEY , job_code VARCHAR (64 ) NOT NULL , cron_expression VARCHAR (64 ) NOT NULL , enabled BOOLEAN NOT NULL DEFAULT TRUE , version BIGINT NOT NULL DEFAULT 0 , updated_at TIMESTAMPTZ(6 ) NOT NULL , CONSTRAINT uk_job_definition_code UNIQUE (job_code) );
单表适合小型平台配置,但不能成为“所有不想设计分片规则的表”的垃圾桶。否则 ds_0 很快会变成新的单点和热点。
chapter 38:JPA Entity 如何映射逻辑模型 38.1 结算单 Entity 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 @Entity @Table(name = "t_settlement_order") public class SettlementOrder extends AbstractAuditableEntity { @Id @Column(name = "settlement_id", nullable = false, updatable = false) private Long id; @Column(name = "tenant_id", nullable = false, updatable = false) private Long tenantId; @Column(name = "settlement_no", nullable = false, length = 64) private String settlementNo; @Column(name = "currency_code", nullable = false, length = 16) private String currencyCode; @Column(name = "total_amount", nullable = false, precision = 18, scale = 2) private BigDecimal totalAmount; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) private SettlementStatus status; @Column(name = "payee_name", nullable = false, length = 128) private String payeeName; @Column(name = "bank_account", nullable = false, length = 128) private String bankAccount; @OneToMany( mappedBy = "settlementOrder", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY ) private List<SettlementItem> items = new ArrayList <>(); protected SettlementOrder () { } }
Entity 映射的是逻辑表名和逻辑列名。不要把:
1 2 payee_name_cipher payee_name_assisted
放入领域 Entity。
38.2 明细 Entity 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 @Entity @Table(name = "t_settlement_item") public class SettlementItem { @Id @Column(name = "item_id") private Long id; @Column(name = "tenant_id", nullable = false, updatable = false) private Long tenantId; @Column(name = "settlement_id", nullable = false, updatable = false) private Long settlementId; @Column(name = "business_type", nullable = false, length = 32) private String businessType; @Column(name = "business_no", nullable = false, length = 64) private String businessNo; @Column(name = "amount", nullable = false, precision = 18, scale = 2) private BigDecimal amount; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn( name = "settlement_id", insertable = false, updatable = false ) private SettlementOrder settlementOrder; }
明细冗余 tenant_id 是有意设计,因为路由阶段不能先查询主表再推断明细属于哪个物理库。
38.3 不建立数据库外键 水平分片表通常不建立跨逻辑表数据库外键,原因包括:
外键只能约束当前物理库表;
扩容迁移困难;
批量写入和清理成本增加;
路由错误不能被全局外键修复;
分片拓扑变化时耦合过强。
完整性由以下机制共同保证:
聚合根方法;
同库本地事务;
绑定表路由;
唯一索引;
定期一致性检查;
修复任务。
chapter 39:主键策略与 save 的陷阱 39.1 应用侧提前生成 ID 分表依赖 settlement_id,因此必须在 INSERT 前得到 ID。
不推荐:
1 @GeneratedValue(strategy = GenerationType.IDENTITY)
推荐:
1 2 3 @Id @Column(name = "settlement_id") private Long id;
在创建聚合时生成:
1 long settlementId = idGenerator.nextId();
39.2 assigned ID 与 Spring Data 的 isNew Spring Data JPA 默认会根据非基本类型 @Version 和 ID 判断实体是否为新对象。如果构造时 ID 已经非空、版本策略又不明确,save() 可能调用 merge() 而不是 persist()。
可采用以下方案之一:
使用非基本类型 @Version,新实体版本为 null;
实现 Persistable<ID>;
使用 Hibernate 自定义 ID Generator;
在集成测试中确认首次 save() 的 SQL 行为。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @MappedSuperclass public abstract class AssignedIdEntity <ID> implements Persistable <ID> { @Transient private boolean newEntity = true ; @Override public boolean isNew () { return newEntity; } @PostLoad @PostPersist void markPersisted () { this .newEntity = false ; } }
不要因为 ID 已经生成,就默认 JPA 一定会执行理想的 INSERT 路径。
chapter 40:绑定表为什么重要 主表和明细配置:
1 2 bindingTables: - t_settlement_order,t_settlement_item
当查询条件包含:
1 2 tenant_id = 101 settlement_id = 8001
只需要访问:
1 2 ds_1.t_settlement_order_1 ds_1.t_settlement_item_1
flowchart TB
Q[tenant_id=101 settlement_id=8001]
Q --> D[ds_1]
Q --> T[后缀 1]
D --> O[t_settlement_order_1]
D --> I[t_settlement_item_1]
O --> J[本地 Join]
I --> J
如果没有绑定表,ShardingSphere 可能将多个主表路由单元与多个明细路由单元组合,增加笛卡尔路由数量。
绑定表有效的前提:
分库键一致;
分表键一致;
算法一致;
真实表后缀关系一致;
Join 条件符合绑定关系。
绑定表不是写一个配置就自动正确,物理数据必须真的遵守同路由约束。
chapter 41:Repository 必须显式携带分片键 41.1 默认 findById 的问题 1 repository.findById(settlementId);
只有分表键,没有分库键 tenant_id,可能路由两个数据库。
推荐:
1 2 3 4 Optional<SettlementOrder> findByTenantIdAndId ( Long tenantId, Long id ) ;
41.2 限制危险的基础方法 1 2 3 4 5 6 7 8 @NoRepositoryBean public interface ShardedAggregateRepository <T, ID> extends Repository <T, ID> { <S extends T > S save (S entity) ; void flush () ; }
业务 Repository:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public interface SettlementOrderRepository extends ShardedAggregateRepository <SettlementOrder, Long> { Optional<SettlementOrder> findByTenantIdAndId ( Long tenantId, Long id ) ; Optional<SettlementOrder> findByTenantIdAndSettlementNo ( Long tenantId, String settlementNo ) ; }
这样可以避免开发者随手调用:
1 2 3 4 findAll count deleteAll findById
让高风险全路由 API 从编译期就消失。
41.3 查询加密字段仍要带 tenant_id 1 2 3 4 5 Optional<SettlementOrder>findByTenantIdAndPayeeName ( Long tenantId, String payeeName ) ;
辅助查询列只能解决“如何匹配加密值”,不能解决“数据在哪个库”。
chapter 42:字段加密的完整链路 42.1 逻辑 SQL 到真实 SQL Hibernate 生成:
1 2 3 4 5 6 7 insert into t_settlement_order ( settlement_id, tenant_id, payee_name, bank_account, ... ) values (?, ?, ?, ?, ...);
ShardingSphere 改写为:
1 2 3 4 5 6 7 8 9 insert into t_settlement_order_1 ( settlement_id, tenant_id, payee_name_cipher, payee_name_assisted, bank_account_cipher, bank_account_assisted, ... ) values (?, ?, ?, ?, ?, ?, ...);
flowchart LR
A[Entity 明文字段] --> B[Hibernate 逻辑 SQL]
B --> C[ShardingSphere Encrypt Rule]
C --> D[AES 密文]
C --> E[MD5 辅助查询摘要]
D --> F[(物理密文列)]
E --> G[(物理辅助列)]
42.2 加密与辅助查询 AES 提供可逆加解密;MD5 辅助列提供稳定等值查询。
适合:
精确匹配姓名;
精确匹配银行账号;
读取时透明解密。
不适合:
范围查询;
排序;
聚合计算;
任意函数;
前后模糊匹配;
忽略大小写的复杂语义。
42.3 物理字段长度 逻辑字段长度 128,不代表密文列也只需要 128。需要考虑:
加密块填充;
Base64 或 Hex;
IV;
算法版本标识;
字符集;
未来密钥轮换。
因此示例预留 VARCHAR(512),生产中应按算法实测,而不是凭感觉。
42.4 存量明文迁移 flowchart TD
A[增加 cipher 与 assisted 列] --> B[部署兼容读写代码]
B --> C[新数据写密文]
C --> D[历史数据分批回填]
D --> E[解密结果校验]
E --> F[查询切换到逻辑加密列]
F --> G[停止明文写入]
G --> H[清理明文列]
加一条规则不会让历史明文自动加密。
42.5 密钥轮换 直接替换 AES Key 会让历史数据无法解密。成熟轮换方案需要:
密钥版本;
新写使用新密钥;
读取兼容旧密钥;
老数据后台重加密;
校验与重试;
完成后下线旧密钥;
全过程审计。
内置 AES 规则解决透明加解密,不自动解决企业级密钥生命周期。
chapter 43:LOCAL 事务的正确使用方式 43.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 28 29 30 31 @Service public class SettlementApplicationService { @Transactional public Long create (CreateSettlementCommand command) { validateCurrency(command.currencyCode()); long settlementId = idGenerator.nextId(); SettlementOrder order = SettlementOrder.create( settlementId, command.tenantId(), createSettlementNo(settlementId), command.currencyCode(), command.payeeName(), command.bankAccount() ); for (CreateSettlementCommand.Item item : command.items()) { order.addItem( idGenerator.nextId(), item.businessType(), item.businessNo(), item.amount() ); } order.submit(); repository.save(order); return settlementId; } }
该事务中:
广播表只读;
主表和明细使用同一 tenant_id;
主表和明细使用同一 settlement_id 决定表后缀;
不修改单表;
不修改其他租户;
不调用远程服务。
因此所有写入进入同一个物理数据库,本质上使用 PostgreSQL 本地事务。
flowchart TD
A[@Transactional] --> B[读取币种广播表]
B --> C[写结算主表]
C --> D[写结算明细]
D --> E{是否同 tenant_id}
E -->|是| F[同一物理库 LOCAL Commit]
E -->|否| G[业务层拒绝]
43.2 不要在同一事务更新广播表 1 2 3 4 5 @Transactional public void createAndPublishCurrency (...) { settlementRepository.save(order); currencyRepository.save(currency); }
广播表写入会命中所有数据源,事务瞬间从单库变为跨库。
43.3 不要在同一事务更新单表 订单可能路由到 ds_1,而 t_job_definition 固定在 ds_0。同时更新就是跨库 LOCAL。
43.4 LOCAL 不是 XA 通常业务异常导致的回滚可以回滚当前事务已经使用的物理连接,但在多库提交阶段遇到数据库宕机或网络中断时,LOCAL 不提供 XA 级原子提交保证。
因此本文的稳定性来自:
而不是:
chapter 44:广播表的读写边界 44.1 读取 广播表在每个数据源都有副本,适合:
分片表与字典本地 Join;
低延迟读取;
避免每次访问中心库;
数据源故障时降低依赖。
44.2 更新 广播表更新会路由所有物理库。最佳实践:
独立管理端发布;
使用 config_version;
使用发布批次号;
UPSERT 幂等;
发布后逐库校验;
失败节点单独重放;
不与核心业务事务绑定。
校验示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 SELECT COUNT (* ) AS row_count, MAX (config_version) AS version_value, MD5( STRING_AGG( CONCAT_WS( '#' , currency_code, currency_name, scale_value, enabled, config_version ), '|' ORDER BY currency_code ) ) AS checksum_valueFROM t_currency_dict;
分别在每个物理库执行并比较结果。
chapter 45:单表的边界 单表 Entity 与普通 JPA Entity 没有明显代码差异:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Entity @Table(name = "t_job_definition") public class JobDefinition { @Id @Column(name = "job_id") private Long id; @Column(name = "job_code") private String jobCode; @Column(name = "cron_expression") private String cronExpression; @Version private Long version; }
真正的差异是数据归属:
必须制定规则:
单表不与结算聚合建立 JPA 关联;
单表 Repository 不允许从结算写 Service 调用;
单表变更使用独立事务;
ds_0 容量和可用性单独监控;
表增长到一定规模后重新评估是否分片。
可以用 ArchUnit 禁止依赖:
1 2 3 4 noClasses() .that().resideInAPackage("..settlement.application.." ) .should().dependOnClassesThat() .resideInAPackage("..job.infrastructure.." );
架构规则写进测试,比写在 Wiki 里更不容易“自然失效”。
chapter 46:JPA 脏检查与 UPDATE 路由风险 查询时携带 tenant_id,不代表 Hibernate 后续脏检查生成的 UPDATE 一定带 tenant_id。
典型 SQL可能是:
1 2 3 4 update t_settlement_orderset status = ?, version = version + 1 where settlement_id = ? and version = ?;
如果分库算法只依赖 tenant_id,该 UPDATE 可能无法精准定位数据库。
这是 JPA 与分库分表整合中最容易漏掉的问题之一。
解决方案按优先级考虑:
让主键编码包含数据库路由信息,并使用自定义分片算法;
显式 JPQL 更新携带 tenant_id;
使用 Hint 指定路由;
建立 ID 到租户或数据源的路由映射;
对关键更新改用 QueryRepository 或 JdbcTemplate;
集成测试断言 Actual SQL 只命中一个库。
显式更新示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Modifying( flushAutomatically = true, clearAutomatically = true ) @Query(""" update SettlementOrder o set o.status = :status, o.version = o.version + 1 where o.tenantId = :tenantId and o.id = :settlementId and o.version = :version """) int updateStatus ( @Param("tenantId") Long tenantId, @Param("settlementId") Long settlementId, @Param("version") Long version, @Param("status") SettlementStatus status ) ;
不要因为 JPA 代码优雅,就跳过对真实 SQL 路由的验证。
chapter 47:查询、分页与关联加载 47.1 精准详情 1 findByTenantIdAndId(tenantId, settlementId);
同时包含分库键和分表键,最理想。
47.2 租户内列表 1 2 3 4 5 6 7 8 Page<SettlementOrder>findByTenantIdAndStatusAndCreatedAtBetween ( Long tenantId, SettlementStatus status, Instant start, Instant end, Pageable pageable ) ;
能定位单库,但会查询该库两张真实表,并执行排序、分页和 count 归并。
不需要总数时使用 Slice。
47.3 深分页 不要在分片表上依赖:
推荐 Keyset Pagination:
1 2 3 4 5 6 7 8 where tenant_id = ? and status = ? and ( created_at < ? or (created_at = ? and settlement_id < ?) )order by created_at desc , settlement_id desc limit 20 ;
47.4 一对多 fetch join 分页 一对多集合 fetch join 与分页组合可能导致:
主表重复;
内存分页;
count 查询复杂;
多路归并扩大。
推荐两阶段查询:
分页查主表 ID;
按 ID 集合查主表与明细;
应用层恢复原排序。
47.5 全局报表 跨租户、跨库、跨月的复杂报表不应直接依赖 JPA 扫描全部分片。推荐通过 CDC 将数据同步到:
Doris;
ClickHouse;
Elasticsearch;
数据仓库;
专用报表库。
ShardingSphere 解决 OLTP 水平扩展,不等于所有查询都应该继续打在 OLTP 上。
chapter 48:批量写入与路由分组 saveAll() 只表示批量调用 Repository,不保证形成理想 JDBC Batch。
需要同时满足:
应用侧 ID;
hibernate.jdbc.batch_size;
order_inserts=true;
pgJDBC reWriteBatchedInserts=true;
SQL 结构相同;
合理 flush;
同一路由数据尽量连续。
大批量导入应先按路由分组:
flowchart LR
A[Import Commands] --> B[按 tenant_id 分库]
B --> C[按 settlement_id 分表]
C --> D[每组按 50 条 Batch]
D --> E[flush]
E --> F[clear]
如果不同分片的数据交错写入,Hibernate 和 ShardingSphere 会频繁切换真实 SQL 与连接,批处理收益明显下降。
pgJDBC 的 reWriteBatchedInserts=true 可以把兼容的批量 INSERT 改写为 multi-values INSERT。还要关注:
每行参数数量与 PostgreSQL 协议参数上限;
prepareThreshold 和服务端 Prepared Statement;
SQL 模板、绑定参数类型必须稳定;
DDL 后连接中的缓存计划可能需要重新准备;
经 PgBouncer transaction pooling 时,需要单独验证 Prepared Statement 策略。
大任务还要避免单个巨大事务,使用可恢复批次、幂等键、进度记录和失败重试。对于超大离线导入,可以评估 PostgreSQL COPY,但它通常应放在专用导入通道中,不应伪装成普通 JPA 聚合写入。
chapter 49:HikariCP 连接池设计 49.1 每个物理数据源一个池 本文:
1 2 ds_0 maximumPoolSize = 15 ds_1 maximumPoolSize = 15
单应用实例理论最大物理连接:
10 个实例:
再加 Flyway、DBA、CDC、备份、监控和其他应用,数据库连接预算必须全局计算。
49.2 参数关系 maximumPoolSize
决定池内空闲和活跃连接总上限。池满后 getConnection() 最多等待 connectionTimeout。
maxLifetime
应比数据库、代理或网络基础设施的连接回收时间略短,避免服务端先关闭连接。
keepaliveTime
必须小于 maxLifetime,只对空闲连接执行保活。
minimumIdle
HikariCP 官方倾向于固定大小池,即不单独设置它,让其等于 maximumPoolSize;具体是否采用固定池要结合流量形态、连接成本和数据库连接预算压测。
49.3 虚拟线程不是数据库扩容 JDK 21 虚拟线程可以让更多请求便宜地等待,但不能让 15 个连接同时执行 1000 个 SQL。
连接池排队增加时,先检查:
SQL 延迟;
全路由;
锁等待;
长事务;
远程调用放在事务中;
Page count;
数据库 CPU 和 IO。
不要把连接池调大当成第一反应。那经常只是让数据库更快地被压垮。
chapter 50:完整测试体系 50.1 必须使用两个真实 PostgreSQL H2 无法覆盖:
PostgreSQL 方言、Schema 与类型系统;
ShardingSphere 双库路由;
AES 与辅助查询;
HikariCP;
Flyway PostgreSQL DDL;
PostgreSQL MVCC、行锁、序列与批处理;
真实 JDBC Metadata。
flowchart TB
TEST[SpringBootTest] --> SS[ShardingSphere DataSource]
SS --> P0[PostgreSQLContainer ds_0]
SS --> P1[PostgreSQLContainer ds_1]
TEST --> R[Route Assertions]
TEST --> E[Encryption Assertions]
TEST --> T[Transaction Assertions]
TEST --> F[Flyway Assertions]
50.2 路由测试矩阵
tenant_id
settlement_id
预期数据源
预期表
100
8000
ds_0
table_0
100
8001
ds_0
table_1
101
8000
ds_1
table_0
101
8001
ds_1
table_1
50.3 加密测试 应用层断言:
物理库断言:
1 2 3 cipher != 明文 assisted != 明文 物理表不存在逻辑明文列
不能只测试“能解密回来”,还要证明“明文没有落库”。
50.4 LOCAL 回滚测试 在保存主表和若干明细后主动抛异常,断言目标物理库中主表和明细均不存在。
50.5 广播表测试 更新后分别直连两个 PostgreSQL 容器,检查版本和内容一致。
50.6 单表测试 断言:
1 2 ds_0 存在 t_job_definition ds_1 不存在 t_job_definition
50.7 Flyway 空库测试 每次 CI 从两个空数据库启动,验证所有迁移能够完整重建 Schema。只在已经存在多年的测试库上跑迁移,发现不了历史脚本缺失或顺序问题。
chapter 51:可观察性与故障定位 51.1 三层 SQL 排查时需要同时理解:
1 2 3 JPQL / Repository Method Logic SQL Actual SQL
flowchart LR
A[Repository Method] --> B[Hibernate Logic SQL]
B --> C[ShardingSphere Route]
C --> D[Actual SQL ds_0]
C --> E[Actual SQL ds_1]
51.2 关键指标 应用:
接口 P95/P99;
事务时长;
每请求 SQL 数量;
Hibernate flush 时长;
乐观锁冲突。
ShardingSphere:
Logic SQL 数;
Actual SQL 数;
平均路由节点数;
全路由比例;
改写失败;
归并耗时。
HikariCP:
active;
idle;
pending;
timeout;
acquire time;
usage time。
PostgreSQL:
pg_stat_statements 中的调用次数、总耗时与均值;
顺序扫描与索引扫描比例;
shared_buffers 命中情况;
行锁等待、死锁与长事务;
WAL 生成速率与 Checkpoint;
autovacuum 延迟与表膨胀;
CPU、IO、连接数与临时文件。
一个非常直观的异常指标:
1 Actual SQL Count / Logic SQL Count
精准详情通常应接近 1。突然从 1 变成 4,往往说明分片条件丢失。
51.3 生产 SQL 日志 sql-show 和 Hibernate bind trace 可能泄露敏感数据。生产环境应:
默认关闭详细参数;
必要时短期开启;
采样;
日志脱敏;
严格访问控制;
设置短保留周期。
数据库加密了,日志又把账号完整打印出来,这类“左手上锁、右手开窗”的事故并不少见。
chapter 52:扩容、迁移与密钥演进 52.1 分片扩容不是修改 0..1 从 2 库 2 表扩到 4 库 4 表,如果只修改:
相同分片键会计算出新的位置,但历史数据仍在旧位置,查询立即错误。
完整流程:
flowchart TD
A[创建新库新表] --> B[Flyway 初始化]
B --> C[配置目标规则]
C --> D[全量迁移]
D --> E[增量同步]
E --> F[行数/金额/状态校验]
F --> G[灰度读]
G --> H[灰度写]
H --> I[切流]
I --> J[观察与回滚窗口]
财务和结算数据至少校验:
行数;
主键集合;
金额合计;
状态分布;
最大更新时间;
抽样解密结果;
主表明细金额一致性。
52.2 Flyway 滚动演进 表变更使用 Expand/Contract:
新增可空列;
所有物理库迁移完成;
部署兼容新旧结构代码;
回填;
切换读取;
增加约束;
后续版本删除旧列。
已在永久环境执行的版本迁移不要修改,新增更高版本向前修复。
52.3 密钥轮换 密钥轮换本质也是数据迁移,必须有:
旧密钥读取窗口;
新密钥写入;
分批重加密;
checksum 与抽样验证;
失败重试;
旧密钥下线审批。
chapter 53:常见错误 53.1 使用 findById 导致多库路由 改为显式包含 tenant_id 的查询,或让 ID 能推导数据源。
53.2 Entity 映射 cipher 字段 会污染领域模型,并绕开透明加密设计。Entity 只映射逻辑列。
53.3 Flyway 只迁移 ds_0 启动可能成功,但访问 ds_1 时才报表不存在。CI 必须从两个空库启动。
53.4 广播表与核心业务一起提交 把单库本地事务扩大为跨库 LOCAL,故障语义完全变化。
53.5 单表与 ds_1 分片数据一起更新 同样形成跨库写事务,应拆分业务边界。
53.6 以为唯一索引全局生效 每张真实表的唯一索引只保证局部唯一。
53.7 用 ddl-auto=update Hibernate 不知道你期望创建多少真实表、广播副本和加密物理列。生产必须用显式迁移。
53.8 连接池按逻辑库只算一次 实际上每个物理数据源有独立池,总连接数要乘应用实例数和数据源数。
53.9 忽略脏检查 UPDATE 路由 SELECT 精准并不代表 UPDATE 也精准,必须检查 Actual SQL。
53.10 把全局报表继续压在 OLTP 分片库 多路扫描、排序、count 和归并最终会吞掉所有连接。
chapter 54:生产上线检查清单 数据模型
JPA
Flyway
加密
事务
HikariCP
测试与监控
chapter 55:最终推荐架构 flowchart TB
subgraph APPLICATION[Spring Boot Application]
API[Controller]
SERVICE[Application Service]
TX[Spring Transaction]
REPO[Spring Data JPA]
ORM[Hibernate]
SS[ShardingSphere-JDBC]
end
subgraph RULES[Rules]
SHARD[分库分表]
BIND[绑定表]
ENC[字段加密]
BROAD[广播表]
SINGLE[单表]
LOCAL[LOCAL]
end
subgraph POOLS[Physical Pools]
H0[HikariCP ds_0]
H1[HikariCP ds_1]
end
subgraph DATABASES[PostgreSQL 18.4]
D0[(settlement_ds_0)]
D1[(settlement_ds_1)]
end
subgraph MIGRATION[Migration]
FC[Flyway common]
FS[Flyway ds0-single]
end
API --> SERVICE --> TX --> REPO --> ORM --> SS
SS --> SHARD
SS --> BIND
SS --> ENC
SS --> BROAD
SS --> SINGLE
SS --> LOCAL
SS --> H0 --> D0
SS --> H1 --> D1
FC --> D0
FC --> D1
FS --> D0
最终原则:
JPA 管理逻辑实体和工作单元;
Hibernate 生成逻辑 SQL;
ShardingSphere 路由、改写和透明加密;
HikariCP 管理每个真实数据库连接;
Flyway 直接管理物理 Schema;
分片键必须进入核心查询和更新;
主表与明细必须同库同后缀;
广播表低频发布并校验一致性;
单表不参与分片聚合写事务;
LOCAL 事务可靠的前提是写入只命中一个物理数据库。
技术组件可以隐藏实现细节,但不能替业务做边界设计。
真正成熟的方案不是配置项最多,而是发生故障时能够明确回答:
1 2 3 4 5 6 7 数据应该在哪个库? 这条 SQL 为什么路由到这里? 事务到底涉及几个数据库? 密文由哪个密钥版本产生? 哪个 Flyway 版本创建了这张真实表? 连接为什么在等待? 如何验证迁移前后金额一致?
能回答这些问题,才算真正完成了整合。
参考资料
Apache ShardingSphere YAML Configurationhttps://shardingsphere.apache.org/document/current/en/user-manual/shardingsphere-jdbc/yaml-config/
Apache ShardingSphere 5.5.3 Data Source Configurationhttps://shardingsphere.apache.org/document/5.5.3/en/user-manual/shardingsphere-jdbc/yaml-config/data-source/
Apache ShardingSphere 5.5.3 JDBC Driverhttps://shardingsphere.apache.org/document/5.5.3/en/user-manual/shardingsphere-jdbc/yaml-config/jdbc-driver/
Apache ShardingSphere Single Tablehttps://shardingsphere.apache.org/document/5.5.3/en/user-manual/shardingsphere-jdbc/yaml-config/rules/single/
Apache ShardingSphere Encryption YAMLhttps://shardingsphere.apache.org/document/current/en/user-manual/shardingsphere-jdbc/yaml-config/rules/encrypt/
Apache ShardingSphere Encryption Algorithmshttps://shardingsphere.apache.org/document/5.5.3/en/user-manual/common-config/builtin-algorithm/encrypt/
Spring Data JPA 3.5 Referencehttps://docs.spring.io/spring-data/jpa/reference/3.5/
Spring Data JPA Entity Persistencehttps://docs.spring.io/spring-data/jpa/reference/3.5/jpa/entity-persistence.html
Spring Data JPA Transactionalityhttps://docs.spring.io/spring-data/jpa/reference/3.5/jpa/transactions.html
Spring Framework Transaction Managementhttps://docs.spring.io/spring-framework/reference/data-access/transaction/
Hibernate ORM User Guidehttps://docs.hibernate.org/orm/6.6/userguide/html_single/
Flyway Java APIhttps://documentation.red-gate.com/flyway/reference/usage/api-java
Flyway Migrationshttps://documentation.red-gate.com/flyway/flyway-concepts/migrations
Flyway Schema History Tablehttps://documentation.red-gate.com/flyway/flyway-concepts/migrations/flyway-schema-history-table
Flyway Versioned Migrationshttps://documentation.red-gate.com/flyway/flyway-concepts/migrations/versioned-migrations
HikariCP Configurationhttps://github.com/brettwooldridge/HikariCP
HikariCP Pool Sizinghttps://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
Testcontainers for Javahttps://java.testcontainers.org/
PostgreSQL 18 Documentationhttps://www.postgresql.org/docs/18/
PostgreSQL JDBC Driver Documentationhttps://jdbc.postgresql.org/documentation/
PostgreSQL Docker Official Imagehttps://hub.docker.com/_/postgres
启示录 JPA 管对象,Hibernate 管状态,ShardingSphere 管路由与改写,HikariCP 管连接,Flyway 管结构,PostgreSQL 管本地事务,业务设计管边界。
最后一层最重要。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。