前面的 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 为已有表增加字段:
1alter table monitored_service2add column enabled boolean not null default true,3add column version bigint not null default 0,4add column created_at timestamptz not null default now(),5add 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. 实体映射
在服务实体中增加状态、审计字段和版本字段:
01package cn.usehook.spring.monitoring;0203import jakarta.persistence.Column;04import jakarta.persistence.Entity;05import jakarta.persistence.EntityListeners;06import jakarta.persistence.FetchType;07import jakarta.persistence.GeneratedValue;08import jakarta.persistence.GenerationType;09import jakarta.persistence.Id;10import jakarta.persistence.JoinColumn;11import jakarta.persistence.ManyToOne;12import jakarta.persistence.Table;13import jakarta.persistence.Version;14import java.time.Instant;15import org.springframework.data.annotation.CreatedDate;16import org.springframework.data.annotation.LastModifiedDate;17import org.springframework.data.jpa.domain.support.AuditingEntityListener;1819@Entity20@Table(name = "monitored_service")21@EntityListeners(AuditingEntityListener.class)22public class MonitoredServiceEntity {2324@Id25@GeneratedValue(strategy = GenerationType.IDENTITY)26private Long id;2728@Column(nullable = false, length = 50, unique = true)29private String name;3031@ManyToOne(fetch = FetchType.LAZY, optional = false)32@JoinColumn(name = "group_id", nullable = false)33private MonitoringGroupEntity group;3435@Column(nullable = false)36private boolean enabled = true;3738@Version39@Column(nullable = false)40private long version;4142@CreatedDate43@Column(name = "created_at", nullable = false, updatable = false)44private Instant createdAt;4546@LastModifiedDate47@Column(name = "updated_at", nullable = false)48private Instant updatedAt;4950protected MonitoredServiceEntity() {}5152MonitoredServiceEntity(String name, MonitoringGroupEntity group) {53this.name = name;54this.group = group;55}5657public void changeEnabled(boolean enabled) {58this.enabled = enabled;59}6061public Long getId() {62return id;63}6465public String getName() {66return name;67}6869public MonitoringGroupEntity getGroup() {70return group;71}7273public boolean isEnabled() {74return enabled;75}7677public long getVersion() {78return version;79}8081public Instant getCreatedAt() {82return createdAt;83}8485public Instant getUpdatedAt() {86return updatedAt;87}88}
getName() 和 getGroup() 保留了关联映射章节已经建立的读取能力。@Version 必须使用 jakarta.persistence.Version;它属于 JPA 乐观锁映射,不是普通业务字段注解。
createdAt 和 updatedAt 没有公开 setter,业务代码不能随意伪造审计时间。changeEnabled() 表达允许发生的状态变化,JPA 在事务提交前通过脏检查生成 UPDATE。
4. 启用审计
创建独立配置类启用 JPA 审计:
1package cn.usehook.spring.config;23import org.springframework.context.annotation.Configuration;4import org.springframework.data.jpa.repository.config.EnableJpaAuditing;56@Configuration(proxyBeanMethods = false)7@EnableJpaAuditing8public class JpaAuditingConfiguration {}
实体上的审计注解只描述哪些字段需要填写,@EnableJpaAuditing 才注册处理这些注解的基础设施。两部分缺少任何一边,时间都不会按预期自动写入。
在 Service 的事务中加载实体并调用行为方法:
01package cn.usehook.spring.monitoring;0203import org.springframework.stereotype.Service;04import org.springframework.transaction.annotation.Transactional;0506@Service07public class MonitoringService {0809private final MonitoredServiceJpaRepository repository;1011public MonitoringService(MonitoredServiceJpaRepository repository) {12this.repository = repository;13}1415@Transactional16public void changeEnabled(long id, boolean enabled) {17MonitoredServiceEntity service = repository.findById(id)18.orElseThrow(() -> new IllegalArgumentException(19"Monitored service not found: " + id20));2122service.changeEnabled(enabled);23}24}
这里不需要再次调用 save()。findById() 返回的实体仍处于当前持久化上下文中,方法结束时 Hibernate 检测到 enabled 变化并执行更新。@LastModifiedDate 随这次更新刷新,版本号也在成功更新后递增。
查询不到数据的异常在真实接口中应该换成明确业务类型,再由统一异常处理转换为 404。本节使用 IllegalArgumentException 只为聚焦审计与并发,不把新的 HTTP 契约混进来。
5. 乐观锁
假设两个请求几乎同时读取版本为 0 的同一实体:
01请求 A 读取 id=1, version=002请求 B 读取 id=1, version=00304请求 A 更新05-> where id=1 and version=006-> 成功,version 变成 10708请求 B 更新09-> where id=1 and version=010-> 影响 0 行,抛出乐观锁异常
Hibernate 生成的 SQL 逻辑类似:
1update monitored_service2set enabled = ?, updated_at = ?, version = ?3where id = ? and version = ?;
没有版本条件时,后提交的请求会悄悄覆盖先提交的结果,调用方无法知道自己基于旧数据操作。乐观锁不提前锁住数据库行,适合读取频繁、冲突相对少的配置修改。
冲突发生以后不能一律静默重试。若操作是可以根据最新状态重新计算的幂等动作,可以重新读取后再尝试;若用户正在编辑一份配置,应返回冲突信息,让调用方看到数据已经变化。Web 层通常把 Spring 的 ObjectOptimisticLockingFailureException 转换为 409 Conflict。
高冲突且必须串行处理的场景可能需要条件更新、悲观锁或更具体的业务原子 SQL。选择哪种策略取决于冲突概率和业务语义,不是给所有实体统一添加锁注解。
6. 数据层测试
Spring Boot 4 将 @DataJpaTest 放在独立的数据 JPA 测试模块中。加入对应测试 Starter:
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:
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.TestcontainersConfiguration;04import cn.usehook.spring.config.JpaAuditingConfiguration;05import jakarta.persistence.EntityManager;06import jakarta.persistence.OptimisticLockException;07import org.junit.jupiter.api.Test;08import org.springframework.beans.factory.annotation.Autowired;09import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;10import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;11import org.springframework.context.annotation.Import;1213import static org.assertj.core.api.Assertions.assertThat;14import static org.assertj.core.api.Assertions.assertThatThrownBy;1516@DataJpaTest17@AutoConfigureTestDatabase(18replace = AutoConfigureTestDatabase.Replace.NONE19)20@Import({TestcontainersConfiguration.class, JpaAuditingConfiguration.class})21class MonitoredServiceAuditingTests {2223private final EntityManager entityManager;2425@Autowired26MonitoredServiceAuditingTests(EntityManager entityManager) {27this.entityManager = entityManager;28}2930@Test31void fillsAuditFields() {32MonitoringGroupEntity group = new MonitoringGroupEntity("auditing");33group.addService("spring-audit");3435entityManager.persist(group);36entityManager.flush();3738MonitoredServiceEntity service = group.getServices().getFirst();39assertThat(service.getCreatedAt()).isNotNull();40assertThat(service.getUpdatedAt()).isNotNull();41assertThat(service.getVersion()).isZero();42}4344@Test45void rejectsStaleEntityUpdate() {46MonitoringGroupEntity group = new MonitoringGroupEntity("locking");47group.addService("spring-locking");48entityManager.persist(group);49entityManager.flush();5051Long id = group.getServices().getFirst().getId();52entityManager.clear();5354MonitoredServiceEntity first = entityManager.find(55MonitoredServiceEntity.class,56id57);58entityManager.detach(first);59MonitoredServiceEntity stale = entityManager.find(60MonitoredServiceEntity.class,61id62);63entityManager.detach(stale);6465first.changeEnabled(false);66entityManager.merge(first);67entityManager.flush();68entityManager.clear();6970stale.changeEnabled(false);71assertThatThrownBy(() -> {72entityManager.merge(stale);73entityManager.flush();74}).isInstanceOf(OptimisticLockException.class);75}76}
@DataJpaTest 只加载实体、JPA Repository 和相关数据层自动配置,默认让每个测试在事务中运行并在结束后回滚。@AutoConfigureTestDatabase 明确不把数据源替换成内存数据库,@Import 再加入容器连接和审计配置。
第一个测试验证审计监听器,第二个测试保留一份旧版本实体,在另一份副本成功提交后尝试更新旧副本。它验证的是 Hibernate 与 PostgreSQL 的真实版本条件,不是手工模拟异常。
到这里,JPA 部分已经覆盖实体、关系、查询、加载、审计和并发写入。接下来进入应用集成:先让当前项目使用 RestClient 调用被监控服务,再学习定时执行、异步并发以及贯穿 HTTP 和数据库的端到端测试。完成这些运行流程后,再进入生产配置。