跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

前面的 JPA 文章已经能够保存监控分组、加载关联并使用投影查询,但实体还没有记录创建和修改时间,也没有处理两个请求同时修改同一服务的情况。

数据库事务能保证单次提交的原子性,却不会自动决定并发写入谁应该覆盖谁。本章为 MonitoredServiceEntity 增加审计字段和版本号,让 Spring Data JPA 自动填写时间,并通过乐观锁拒绝基于旧版本提交的修改。

1. 审计字段

审计字段描述数据何时创建、何时修改以及由谁操作。当前心跳项目还没有用户体系,先只记录时间:

字段含义写入时机
created_at记录首次创建时间插入时写入,之后不修改
updated_at最近一次持久化修改时间插入和后续更新时写入
version实体并发版本每次成功更新后递增

业务事件时间与审计时间不是同一概念。health_check_history.checked_at 表示心跳检查发生的时间;updated_at 表示这行监控配置最近被修改的时间。两者来源和用途不同,不应该为了少一列而混用。

Spring Data JPA 提供 @CreatedDate 和 @LastModifiedDate。它还支持 @CreatedBy 与 @LastModifiedBy,但后两者需要通过 AuditorAware 明确当前操作者。没有可靠身份来源时,不应伪造一个固定用户名当作真实审计记录。

2. 数据库迁移

先让 Flyway 为已有表增加字段:

src/main/resources/db/migration/V5__add_monitored_service_auditing.sql
1
alter table monitored_service
2
add column enabled boolean not null default true,
3
add column version bigint not null default 0,
4
add column created_at timestamptz not null default now(),
5
add column updated_at timestamptz not null default now();

DEFAULT 让已有行和不是通过 JPA 插入的新行拥有初始值。数据库的 DEFAULT now() 只在插入时生效,不会在每次 UPDATE 时自动刷新 updated_at;通过 JPA 修改实体时,这个字段由审计监听器更新。

迁移继续新增 V5,不能修改已经执行的 V3。应用启动时,Flyway 先把数据库升级到新结构,Hibernate 的 ddl-auto: validate 再验证实体映射,职责顺序与前面保持一致。

版本列使用 bigint,对应 Java 的 long。版本号不是业务编号,也不用于排序,它只参与并发更新条件。

3. 实体映射

在服务实体中增加状态、审计字段和版本字段:

src/main/java/cn/usehook/spring/monitoring/MonitoredServiceEntity.java
01
package cn.usehook.spring.monitoring;
02
03
import jakarta.persistence.Column;
04
import jakarta.persistence.Entity;
05
import jakarta.persistence.EntityListeners;
06
import jakarta.persistence.FetchType;
07
import jakarta.persistence.GeneratedValue;
08
import jakarta.persistence.GenerationType;
09
import jakarta.persistence.Id;
10
import jakarta.persistence.JoinColumn;
11
import jakarta.persistence.ManyToOne;
12
import jakarta.persistence.Table;
13
import jakarta.persistence.Version;
14
import java.time.Instant;
15
import org.springframework.data.annotation.CreatedDate;
16
import org.springframework.data.annotation.LastModifiedDate;
17
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
18
19
@Entity
20
@Table(name = "monitored_service")
21
@EntityListeners(AuditingEntityListener.class)
22
public class MonitoredServiceEntity {
23
24
@Id
25
@GeneratedValue(strategy = GenerationType.IDENTITY)
26
private Long id;
27
28
@Column(nullable = false, length = 50, unique = true)
29
private String name;
30
31
@ManyToOne(fetch = FetchType.LAZY, optional = false)
32
@JoinColumn(name = "group_id", nullable = false)
33
private MonitoringGroupEntity group;
34
35
@Column(nullable = false)
36
private boolean enabled = true;
37
38
@Version
39
@Column(nullable = false)
40
private long version;
41
42
@CreatedDate
43
@Column(name = "created_at", nullable = false, updatable = false)
44
private Instant createdAt;
45
46
@LastModifiedDate
47
@Column(name = "updated_at", nullable = false)
48
private Instant updatedAt;
49
50
protected MonitoredServiceEntity() {}
51
52
MonitoredServiceEntity(String name, MonitoringGroupEntity group) {
53
this.name = name;
54
this.group = group;
55
}
56
57
public void changeEnabled(boolean enabled) {
58
this.enabled = enabled;
59
}
60
61
public Long getId() {
62
return id;
63
}
64
65
public String getName() {
66
return name;
67
}
68
69
public MonitoringGroupEntity getGroup() {
70
return group;
71
}
72
73
public boolean isEnabled() {
74
return enabled;
75
}
76
77
public long getVersion() {
78
return version;
79
}
80
81
public Instant getCreatedAt() {
82
return createdAt;
83
}
84
85
public Instant getUpdatedAt() {
86
return updatedAt;
87
}
88
}

getName() 和 getGroup() 保留了关联映射章节已经建立的读取能力。@Version 必须使用 jakarta.persistence.Version;它属于 JPA 乐观锁映射,不是普通业务字段注解。

createdAt 和 updatedAt 没有公开 setter,业务代码不能随意伪造审计时间。changeEnabled() 表达允许发生的状态变化,JPA 在事务提交前通过脏检查生成 UPDATE。

4. 启用审计

创建独立配置类启用 JPA 审计:

src/main/java/cn/usehook/spring/config/JpaAuditingConfiguration.java
1
package cn.usehook.spring.config;
2
3
import org.springframework.context.annotation.Configuration;
4
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
5
6
@Configuration(proxyBeanMethods = false)
7
@EnableJpaAuditing
8
public class JpaAuditingConfiguration {}

实体上的审计注解只描述哪些字段需要填写,@EnableJpaAuditing 才注册处理这些注解的基础设施。两部分缺少任何一边,时间都不会按预期自动写入。

在 Service 的事务中加载实体并调用行为方法:

src/main/java/cn/usehook/spring/monitoring/MonitoringService.java
01
package cn.usehook.spring.monitoring;
02
03
import org.springframework.stereotype.Service;
04
import org.springframework.transaction.annotation.Transactional;
05
06
@Service
07
public class MonitoringService {
08
09
private final MonitoredServiceJpaRepository repository;
10
11
public MonitoringService(MonitoredServiceJpaRepository repository) {
12
this.repository = repository;
13
}
14
15
@Transactional
16
public void changeEnabled(long id, boolean enabled) {
17
MonitoredServiceEntity service = repository.findById(id)
18
.orElseThrow(() -> new IllegalArgumentException(
19
"Monitored service not found: " + id
20
));
21
22
service.changeEnabled(enabled);
23
}
24
}

这里不需要再次调用 save()。findById() 返回的实体仍处于当前持久化上下文中,方法结束时 Hibernate 检测到 enabled 变化并执行更新。@LastModifiedDate 随这次更新刷新,版本号也在成功更新后递增。

查询不到数据的异常在真实接口中应该换成明确业务类型,再由统一异常处理转换为 404。本节使用 IllegalArgumentException 只为聚焦审计与并发,不把新的 HTTP 契约混进来。

5. 乐观锁

假设两个请求几乎同时读取版本为 0 的同一实体:

optimistic-lock.txt
01
请求 A 读取 id=1, version=0
02
请求 B 读取 id=1, version=0
03
04
请求 A 更新
05
-> where id=1 and version=0
06
-> 成功,version 变成 1
07
08
请求 B 更新
09
-> where id=1 and version=0
10
-> 影响 0 行,抛出乐观锁异常

Hibernate 生成的 SQL 逻辑类似:

optimistic-update.sql
1
update monitored_service
2
set enabled = ?, updated_at = ?, version = ?
3
where id = ? and version = ?;

没有版本条件时,后提交的请求会悄悄覆盖先提交的结果,调用方无法知道自己基于旧数据操作。乐观锁不提前锁住数据库行,适合读取频繁、冲突相对少的配置修改。

冲突发生以后不能一律静默重试。若操作是可以根据最新状态重新计算的幂等动作,可以重新读取后再尝试;若用户正在编辑一份配置,应返回冲突信息,让调用方看到数据已经变化。Web 层通常把 Spring 的 ObjectOptimisticLockingFailureException 转换为 409 Conflict。

高冲突且必须串行处理的场景可能需要条件更新、悲观锁或更具体的业务原子 SQL。选择哪种策略取决于冲突概率和业务语义,不是给所有实体统一添加锁注解。

6. 数据层测试

Spring Boot 4 将 @DataJpaTest 放在独立的数据 JPA 测试模块中。加入对应测试 Starter:

pom.xml
1
<dependency>
2
<groupId>org.springframework.boot</groupId>
3
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
4
<scope>test</scope>
5
</dependency>

沿用 Testcontainers 配置,让测试切片连接真实 PostgreSQL:

src/test/java/cn/usehook/spring/monitoring/MonitoredServiceAuditingTests.java
01
package cn.usehook.spring.monitoring;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import cn.usehook.spring.config.JpaAuditingConfiguration;
05
import jakarta.persistence.EntityManager;
06
import jakarta.persistence.OptimisticLockException;
07
import org.junit.jupiter.api.Test;
08
import org.springframework.beans.factory.annotation.Autowired;
09
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
10
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
11
import org.springframework.context.annotation.Import;
12
13
import static org.assertj.core.api.Assertions.assertThat;
14
import static org.assertj.core.api.Assertions.assertThatThrownBy;
15
16
@DataJpaTest
17
@AutoConfigureTestDatabase(
18
replace = AutoConfigureTestDatabase.Replace.NONE
19
)
20
@Import({TestcontainersConfiguration.class, JpaAuditingConfiguration.class})
21
class MonitoredServiceAuditingTests {
22
23
private final EntityManager entityManager;
24
25
@Autowired
26
MonitoredServiceAuditingTests(EntityManager entityManager) {
27
this.entityManager = entityManager;
28
}
29
30
@Test
31
void fillsAuditFields() {
32
MonitoringGroupEntity group = new MonitoringGroupEntity("auditing");
33
group.addService("spring-audit");
34
35
entityManager.persist(group);
36
entityManager.flush();
37
38
MonitoredServiceEntity service = group.getServices().getFirst();
39
assertThat(service.getCreatedAt()).isNotNull();
40
assertThat(service.getUpdatedAt()).isNotNull();
41
assertThat(service.getVersion()).isZero();
42
}
43
44
@Test
45
void rejectsStaleEntityUpdate() {
46
MonitoringGroupEntity group = new MonitoringGroupEntity("locking");
47
group.addService("spring-locking");
48
entityManager.persist(group);
49
entityManager.flush();
50
51
Long id = group.getServices().getFirst().getId();
52
entityManager.clear();
53
54
MonitoredServiceEntity first = entityManager.find(
55
MonitoredServiceEntity.class,
56
id
57
);
58
entityManager.detach(first);
59
MonitoredServiceEntity stale = entityManager.find(
60
MonitoredServiceEntity.class,
61
id
62
);
63
entityManager.detach(stale);
64
65
first.changeEnabled(false);
66
entityManager.merge(first);
67
entityManager.flush();
68
entityManager.clear();
69
70
stale.changeEnabled(false);
71
assertThatThrownBy(() -> {
72
entityManager.merge(stale);
73
entityManager.flush();
74
}).isInstanceOf(OptimisticLockException.class);
75
}
76
}

@DataJpaTest 只加载实体、JPA Repository 和相关数据层自动配置,默认让每个测试在事务中运行并在结束后回滚。@AutoConfigureTestDatabase 明确不把数据源替换成内存数据库,@Import 再加入容器连接和审计配置。

第一个测试验证审计监听器,第二个测试保留一份旧版本实体,在另一份副本成功提交后尝试更新旧副本。它验证的是 Hibernate 与 PostgreSQL 的真实版本条件,不是手工模拟异常。

到这里,JPA 部分已经覆盖实体、关系、查询、加载、审计和并发写入。接下来进入应用集成:先让当前项目使用 RestClient 调用被监控服务,再学习定时执行、异步并发以及贯穿 HTTP 和数据库的端到端测试。完成这些运行流程后,再进入生产配置。