欢迎你来读这篇博客。
这不是一篇只介绍 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
数据库
MySQL 8.x
数据库变更
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[(MySQL / PostgreSQL)]
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 > com.mysql</groupId > <artifactId > mysql-connector-j</artifactId > <scope > runtime</scope > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-core</artifactId > </dependency > <dependency > <groupId > org.flywaydb</groupId > <artifactId > flyway-mysql</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies >
使用 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:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:3306}/${DB_NAME:demo}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: ${DB_USERNAME:root} password: ${DB_PASSWORD:root} 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 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 CREATE TABLE settlement_order ( id BIGINT NOT NULL PRIMARY KEY , bill_no VARCHAR (64 ) NOT NULL , shop_id BIGINT NOT NULL , statement_amount DECIMAL (18 , 2 ) NOT NULL , status VARCHAR (32 ) NOT NULL , create_id BIGINT NULL , create_name VARCHAR (64 ) NULL , create_time TIMESTAMP (6 ) NOT NULL , update_id BIGINT NULL , update_name VARCHAR (64 ) NULL , update_time TIMESTAMP (6 ) NOT NULL , version BIGINT NOT NULL DEFAULT 0 , deleted TINYINT(1 ) NOT NULL DEFAULT 0 , UNIQUE KEY uk_settlement_order_bill_no (bill_no), KEY idx_settlement_order_shop_status (shop_id, status), KEY idx_settlement_order_create_time (create_time) );
字段建议 create_time / update_time
推荐使用 Instant 或 LocalDateTime。如果系统跨时区、跨地区,优先用 Instant;如果是国内单体业务系统,LocalDateTime 也能用,但要统一数据库时区、JVM 时区和序列化格式。
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 项目很常用。
缺点:
依赖数据库序列;
MySQL 原生不走这个模型;
分库分表仍要额外设计。
适合:PostgreSQL、Oracle、传统企业系统。
UUID 1 2 3 4 @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id", columnDefinition = "char(36)") private UUID id;
优点:
不依赖数据库;
多节点生成简单;
外部暴露时不容易被猜测。
缺点:
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 UNIQUE KEY uk_order_bill_no (bill_no)
软删除以后,旧行仍然存在,因此相同业务号不能再次创建。
这不一定是问题。财务单号通常就应该永久唯一,删除后也不允许复用。
如果业务明确要求“只约束未删除数据唯一”,不能简单建立:
1 UNIQUE KEY uk_bill_deleted (bill_no, deleted)
因为它通常只能允许一条未删除记录和一条已删除记录,无法保留多条相同业务号的删除历史。
在 MySQL 中可以评估生成列:
1 2 3 4 5 6 7 8 9 10 11 ALTER TABLE settlement_orderADD COLUMN active_bill_no VARCHAR (64 ) GENERATED ALWAYS AS ( CASE WHEN deleted = 0 THEN bill_no ELSE NULL END ) STORED;CREATE UNIQUE INDEX uk_active_bill_noON settlement_order(active_bill_no);
由于唯一索引允许多个 NULL,已删除记录可以保留多条,而未删除业务号仍保持唯一。
是否允许业务号复用必须由业务规则决定,不能只从数据库技巧出发。
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 是否一致;
数据库驱动配置;
事务边界。
使用 MySQL IDENTITY 自增主键时,Hibernate 的插入批处理能力可能受到限制。需要通过 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 与 MySQL、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 / TIMESTAMP(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 的控制权。
参考资料
Spring Data JPA Reference Documentation 3.5
Spring Data JPA Current Reference
Spring Data JPA - Persisting Entities
Spring Data JPA - Query Methods
Spring Data JPA - Specifications
Spring Data JPA - Query by Example
Spring Data JPA - Projections
Spring Data JPA - Transactionality
Spring Data JPA - Auditing
Spring Data JPA - Locking
Spring Data Commons - Scrolling
Spring Boot - SQL Databases
Spring Boot 3.5 - Task Execution and Scheduling
Spring Framework - Declarative Transaction Management
Hibernate ORM 6.6 User Guide
Hibernate ORM 6.6 Introduction
Jakarta Persistence Specification
Jakarta Persistence @Version
Flyway Documentation
Testcontainers for Java
MySQL Reference Manual
启示录 富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。