SpringData JPA 详解

欢迎你来读这篇博客,这篇博客主要是关于Spring Boot 整合 Spring Data JPA:从入门配置到工程化落地
其中包括了关于我的见解和收集的知识分享。

Spring Boot 整合 Spring Data JPA:从入门配置到工程化落地

适用基线:Spring Boot 3.x、Spring Data JPA 3.x/4.x、Hibernate 6.x、JDK 17/21。Spring Boot 3 以后 JPA 包名已经从
javax.persistence.* 切换到 jakarta.persistence.*,新项目不要再混用旧包名。

1. 为什么还要用 Spring Data JPA

在 Java 后端项目里,数据访问大致有三类路线:

  1. JdbcTemplate / JdbcClient:SQL 完全可控,适合强 SQL、强报表、强批处理场景。
  2. MyBatis / MyBatis-Plus:SQL 可控性强,国内业务系统使用非常广。
  3. Spring Data JPA:以实体和聚合为中心,适合中后台、管理端、领域模型比较清晰的业务系统。

Spring Data JPA 不是“少写 SQL 就万事大吉”。它真正适合的场景是:

  • 表结构和领域对象关系较稳定;
  • CRUD、分页、条件查询较多;
  • 希望把通用审计字段、软删除、乐观锁、基础 Repository 统一沉淀;
  • 希望用实体生命周期管理减少大量样板代码;
  • 复杂 SQL 可以接受局部使用 JPQL、Native SQL、QueryDSL、Specification 或 MyBatis 补位。

一句话:JPA 适合把“业务对象的生命周期”建模好,但不适合把所有 SQL 都藏起来。想让 JPA 在项目里长期稳定,靠的不是神奇注解,而是边界感。

2. Spring Boot、Spring Data JPA、Hibernate 的关系

Spring Boot 的作用是自动装配。引入 spring-boot-starter-data-jpa 后,它会把 Hibernate、Spring Data JPA、Spring ORM
等关键依赖带进来,并根据 spring.datasource.*spring.jpa.* 配置创建数据源、事务管理和 JPA 基础设施。

Spring Data JPA 的作用是 Repository 抽象。它可以基于接口生成常见 CRUD 实现,也支持方法名派生查询、@Query
、分页、排序、Specification、Projection 等能力。

Hibernate 是默认的 JPA 实现。真正负责 ORM 映射、实体状态管理、脏检查、一级缓存、关联加载、主键生成、SQL 生成的,是 Hibernate。

工程上要记住这个分层:

1
2
3
4
5
6
7
8
9
10
11
Controller / Facade
|
Application Service -> 事务边界
|
Domain Model / Entity
|
Repository Interface -> Spring Data JPA
|
Hibernate -> JPA Provider
|
Database

3. Maven 依赖

以 MySQL 为例:

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.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

<!-- 生产环境建议用数据库迁移工具管理 DDL -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
</dependencies>

如果是 PostgreSQL:

1
2
3
4
5
6

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

4. 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
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/demo_jpa?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
hikari:
pool-name: demo-jpa-pool
minimum-idle: 5
maximum-pool-size: 30
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000

jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
jdbc:
time_zone: Asia/Shanghai
batch_size: 50
order_inserts: true
order_updates: true
default_batch_fetch_size: 50

logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace

关键配置解释

spring.jpa.open-in-view=false

Web 项目里建议关闭。默认开启时,Controller/View 阶段仍可能触发懒加载,短期省事,长期容易把数据库访问扩散到表现层,导致 N+1
查询、事务边界不清晰、接口慢得很玄学。

spring.jpa.hibernate.ddl-auto=validate

生产环境不要用 update 自动改表。推荐:

  • 本地开发:validate 或临时 update
  • 测试环境:validate + Flyway/Liquibase;
  • 生产环境:validate + Flyway/Liquibase;
  • 演示 Demo:可以用 create-drop

hibernate.default_batch_fetch_size=50

用于缓解部分懒加载 N+1 查询问题。它不是银弹,但对 ManyToOne、OneToMany 的批量加载很有帮助。

hibernate.jdbc.batch_size=50

批量插入、批量更新时有用。但要注意,数据库自增主键 IDENTITY 往往会影响 Hibernate 对 insert batch 的优化,因为插入后才能拿到主键。

5. 推荐项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
com.example.demo
├── DemoApplication.java
├── config
│ └── JpaAuditConfiguration.java
├── domain
│ ├── common
│ │ ├── AuditActor.java
│ │ ├── AbstractAuditableEntity.java
│ │ ├── AbstractSnowflakeEntity.java
│ │ └── SnowflakeIdWorker.java
│ └── settlement
│ ├── SettlementOrder.java
│ └── SettlementOrderStatus.java
├── repository
│ └── SettlementOrderRepository.java
├── application
│ └── SettlementOrderService.java
└── interfaces
└── SettlementOrderController.java

工程原则:

  • Entity 属于领域层,不要直接当接口返回对象。
  • Repository 只做持久化,不写业务编排。
  • 事务放在 Application Service,不要散落在 Controller。
  • 查询对象、返回 DTO、Entity 分开。
  • 复杂统计报表不要硬塞 JPA,直接 SQL 或 MyBatis 更舒服。

6. 建表规范:审计字段怎么设计

一个比较通用的表结构可以这样设计:

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

推荐使用 InstantLocalDateTime。如果系统跨时区、跨地区,优先用 Instant;如果是国内单体业务系统,LocalDateTime
也能用,但要统一数据库时区、JVM 时区和序列化格式。

create_id / update_id

保存操作人 ID。不要只保存用户名,因为用户名、昵称、手机号都可能变。

create_name / update_name

保存操作人快照。这样即使用户后来改名,单据历史也能看懂。

version

乐观锁字段。用于防止两个用户同时编辑同一条数据时后提交的人覆盖先提交的人。

deleted

软删除标记。建议使用 0/1false/true,公司内部统一即可。真正删除数据前先想清楚审计、追溯、财务对账、客服排查这些场景。

7. Spring Data JPA 审计字段自动填充

Spring Data 提供了 @CreatedDate@LastModifiedDate@CreatedBy@LastModifiedBy,可以自动记录谁在什么时间创建或更新了实体。

7.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
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_idcreate_nameupdate_idupdate_name

7.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
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 即可。

7.3 启用 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));
}

7.4 审计字段的几个工程细节

不要在业务代码里手动设置 createTimeupdateTime。业务服务里到处手填,后面一定会出现“某个分支忘了填”的问题。

createTime 设置 updatable = false。创建时间不应被更新 SQL 修改。

updateTime 每次更新实体时自动刷新。注意:如果你使用 @Modifying 写 JPQL 批量更新,实体生命周期回调和脏检查不一定按普通实体更新方式工作,这种场景建议在
JPQL 里显式更新 update_timeupdate_id

批量更新后要注意一级缓存。@Modifying(clearAutomatically = true, flushAutomatically = true) 可以在执行修改语句前
flush、执行后清理当前持久化上下文,避免你后面读到旧对象。

8. ID 主键策略怎么设计

主键设计要先看业务和数据库部署形态,不要上来就“全公司统一雪花 ID”。常见策略如下。

8.1 数据库自增:IDENTITY

1
2
3
4
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

优点:

  • 简单;
  • 数据库天然保证递增;
  • 小项目很舒服。

缺点:

  • 分库分表不友好;
  • 数据迁移、跨库合并麻烦;
  • Hibernate 需要插入后拿 ID,批量插入优化空间受限。

适合:单库单表、后台管理、小型系统。

8.2 数据库序列: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、传统企业系统。

8.3 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,数据库索引不会陪你演戏。

8.4 雪花 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
@PrePersist
void markNotNew() {
this.isNew = false;
}
}

8.5 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 方案会更轻。

9. 实体示例:结算单

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,枚举顺序一改数据库就翻车。

10. Repository 设计

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
package com.example.demo.repository;

import com.example.demo.domain.settlement.SettlementOrder;
import com.example.demo.domain.settlement.SettlementOrderStatus;
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;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import jakarta.persistence.LockModeType;

public interface SettlementOrderRepository
extends JpaRepository<SettlementOrder, Long>, JpaSpecificationExecutor<SettlementOrder> {

Optional<SettlementOrder> findByBillNoAndDeletedFalse(String billNo);

Page<SettlementOrder> findByShopIdAndStatusAndDeletedFalse(
Long shopId,
SettlementOrderStatus status,
Pageable pageable
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select o
from SettlementOrder o
where o.id = :id
and o.deleted = false
""")
Optional<SettlementOrder> findByIdForUpdate(@Param("id") Long id);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update SettlementOrder o
set o.deleted = true,
o.updateTime = CURRENT_TIMESTAMP
where o.id = :id
and o.deleted = false
""")
int softDeleteById(@Param("id") Long id);
}

Repository 里不要写业务流程。比如“提交结算单并生成明细、扣减额度、发送消息”这种逻辑应该在 Service 里完成。

11. Specification:复杂查询不要硬拼方法名

方法名派生查询适合简单条件,例如:

1
findByShopIdAndStatusAndDeletedFalse(...)

如果条件是动态组合,例如店铺、状态、时间范围、金额范围、单据号模糊查询,就不要写成:

1
findByShopIdAndStatusAndCreateTimeBetweenAndBillNoContainingAndDeletedFalse(...)

这种名字长得像火车进站,维护起来很痛。推荐 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
package com.example.demo.repository;

import com.example.demo.domain.settlement.SettlementOrder;
import com.example.demo.domain.settlement.SettlementOrderStatus;
import java.time.Instant;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;

public final class SettlementOrderSpecs {

private SettlementOrderSpecs() {
}

public static Specification<SettlementOrder> notDeleted() {
return (root, query, cb) -> cb.isFalse(root.get("deleted"));
}

public static Specification<SettlementOrder> shopId(Long shopId) {
return (root, query, cb) -> shopId == null ? null : cb.equal(root.get("shopId"), shopId);
}

public static Specification<SettlementOrder> status(SettlementOrderStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}

public static Specification<SettlementOrder> billNoLike(String billNo) {
return (root, query, cb) -> {
if (!StringUtils.hasText(billNo)) {
return null;
}
return cb.like(root.get("billNo"), "%" + billNo.trim() + "%");
};
}

public static Specification<SettlementOrder> createTimeBetween(Instant start, Instant end) {
return (root, query, cb) -> {
if (start != null && end != null) {
return cb.between(root.get("createTime"), start, end);
}
if (start != null) {
return cb.greaterThanOrEqualTo(root.get("createTime"), start);
}
if (end != null) {
return cb.lessThanOrEqualTo(root.get("createTime"), end);
}
return null;
};
}
}

Service 使用:

1
2
3
4
5
6
7
8
Specification<SettlementOrder> spec = Specification
.where(SettlementOrderSpecs.notDeleted())
.and(SettlementOrderSpecs.shopId(query.shopId()))
.and(SettlementOrderSpecs.status(query.status()))
.and(SettlementOrderSpecs.billNoLike(query.billNo()))
.and(SettlementOrderSpecs.createTimeBetween(query.startTime(), query.endTime()));

Page<SettlementOrder> page = repository.findAll(spec, pageable);

12. 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
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example.demo.application;

import com.example.demo.domain.settlement.SettlementOrder;
import com.example.demo.repository.SettlementOrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SettlementOrderService {

private final SettlementOrderRepository repository;

@Transactional
public Long create(CreateSettlementOrderCommand command) {
repository.findByBillNoAndDeletedFalse(command.billNo())
.ifPresent(order -> {
throw new IllegalArgumentException("Bill no already exists.");
});

SettlementOrder order = new SettlementOrder(
command.billNo(),
command.shopId(),
command.statementAmount()
);

return repository.save(order).getId();
}

@Transactional
public void submit(Long id) {
SettlementOrder order = repository.findById(id)
.filter(it -> !Boolean.TRUE.equals(it.getDeleted()))
.orElseThrow(() -> new IllegalArgumentException("Settlement order not found."));

order.submit();
}

@Transactional(readOnly = true)
public SettlementOrderDetail getDetail(Long id) {
SettlementOrder order = repository.findById(id)
.filter(it -> !Boolean.TRUE.equals(it.getDeleted()))
.orElseThrow(() -> new IllegalArgumentException("Settlement order not found."));

return SettlementOrderDetail.from(order);
}
}

事务建议:

  • 查询方法加 @Transactional(readOnly = true)
  • 写方法加普通 @Transactional
  • 不要在 Controller 层控制事务。
  • 同类内部方法调用不会触发 Spring AOP 事务,别自己坑自己。
  • 批量更新、消息发送、外部接口调用要注意事务边界,外部接口不要长时间占着数据库事务。

13. DTO / Projection:不要把 Entity 直接返回给前端

直接返回 Entity 会带来几个问题:

  • 懒加载字段序列化时触发 SQL;
  • 双向关联可能递归;
  • 字段暴露不可控;
  • 接口模型和数据库模型强绑定。

推荐 DTO:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public record SettlementOrderDetail(
Long id,
String billNo,
Long shopId,
BigDecimal statementAmount,
String status
) {
public static SettlementOrderDetail from(SettlementOrder order) {
return new SettlementOrderDetail(
order.getId(),
order.getBillNo(),
order.getShopId(),
order.getStatementAmount(),
order.getStatus().name()
);
}
}

简单列表也可以用 Projection:

1
2
3
4
5
6
public interface SettlementOrderListView {
Long getId();
String getBillNo();
BigDecimal getStatementAmount();
SettlementOrderStatus getStatus();
}

14. 软删除怎么做

方案一:业务显式过滤

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
  • 审计要求强的系统:软删除之外还要记录删除人、删除时间、删除原因。

15. 乐观锁:@Version

1
2
3
@Version
@Column(name = "version", nullable = false)
private Long version;

适合场景:

  • 用户编辑单据;
  • 审批状态流转;
  • 库存、额度、余额等不能被覆盖更新的场景;
  • 管理端多人同时操作同一条记录。

当两个事务同时读取同一条数据并修改,先提交的事务会更新版本号;后提交的事务提交时发现版本不一致,会抛出乐观锁异常。

注意:

  • @Version 保护的是实体更新,不是所有 Native SQL。
  • 批量 JPQL update 可能绕开普通实体脏检查,版本字段要自己处理。
  • 乐观锁异常要转换成业务可读提示,比如“数据已被其他人修改,请刷新后重试”。

16. 关联关系:默认保守一点

JPA 关联很强,但也很容易用过头。建议:

1
2
3
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_id", insertable = false, updatable = false)
private Shop shop;

原则:

  • ManyToOne 默认是 EAGER,建议显式改成 LAZY。
  • OneToMany 默认 LAZY,但不要随便级联 CascadeType.ALL
  • 财务单据这类系统,聚合边界要清晰,别让一个 save(order) 顺手级联改了一堆明细和基础资料。
  • 查询详情时需要关联数据,优先使用 join fetch@EntityGraph 或 DTO 查询。

17. 常见坑

17.1 ddl-auto=update 不是迁移工具

update 适合开发阶段偷懒,不适合生产环境。它不会像 Flyway 那样留下版本记录,也不能可靠处理复杂变更。生产用它改表,后面排查问题会非常刺激,但不是那种健康的刺激。

17.2 Entity 没有无参构造

JPA 需要无参构造。可以用:

1
@NoArgsConstructor(access = AccessLevel.PROTECTED)

17.3 equals / hashCode 乱写

实体对象不要轻易用所有字段生成 equals / hashCode。如果实体还没持久化,ID 为空;如果持久化后 ID 才生成,hashCode
会变化。保守做法是不要让 Entity 进入 HashSet/HashMap 这种依赖 hash 的复杂场景。

17.4 懒加载异常

关闭 open-in-view 后,Controller 层访问懒加载字段可能报错。这是好事,它逼你把查询边界放回 Service 层。

17.5 N+1 查询

列表接口不要循环访问懒加载关联。解决方式:

  • DTO 查询;
  • join fetch
  • @EntityGraph
  • default_batch_fetch_size
  • 分两次批量查询再组装。

17.6 批量更新后一级缓存不一致

@Modifying 的 update/delete 直接打到数据库,当前 Persistence Context 里的实体可能还是旧值。需要 clearAutomatically
,或者在业务上避免同一事务里混用批量更新和实体状态修改。

17.7 手动分配 ID 导致 savemerge

如果实体保存前 ID 已经不为空,Spring Data JPA 默认可能认为它不是新对象。解决方式:

  • 使用 @PrePersist 在 persist 前生成 ID;
  • 使用非基本类型 @Version 辅助新旧判断;
  • 实现 Persistable
  • 不要在构造函数里提前生成 ID,除非你知道自己在做什么。

18. 推荐落地模板

新项目可以按这个组合落地:

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

19. 最小可用清单

引入依赖:

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

20. 总结

Spring Boot 整合 Spring Data JPA 并不难,难的是把它用得稳定、可控、可维护。

真正工程化的 JPA 项目,重点不是会不会写 JpaRepository,而是:

  • 审计字段能不能统一自动填充;
  • ID 策略能不能适配未来扩展;
  • Entity 边界能不能守住;
  • 事务边界能不能清晰;
  • 查询方式能不能按复杂度分层;
  • DDL 能不能版本化;
  • 懒加载、N+1、批量更新、乐观锁这些坑有没有提前处理。

如果项目是强财务、强结算、强对账系统,我会优先选择:

1
2
3
4
5
6
7
8
9
Spring Boot 3.x
+ Spring Data JPA
+ Hibernate 6
+ BIGINT 雪花 ID
+ 审计字段自动填充
+ deleted 软删除
+ version 乐观锁
+ Flyway 管理 DDL
+ 复杂报表 SQL 补位

这样既能享受 JPA 的实体建模和 Repository 效率,又不会在复杂 SQL 和生产治理上把自己锁死。

参考资料


SpringData JPA 详解
https://allendericdalexander.github.io/2026/06/11/java/spring/springboot-spring-data-jpa-engineering-blog/
作者
AtLuoFu
发布于
2026年6月11日
许可协议