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

上一章的一次心跳检查只更新 health_status 表。现在增加一个需求:除了保存每个服务的最新状态,还要把每一次检查追加到历史表中。这会让一个业务操作包含两条数据库写入。

如果第一条 SQL 成功、第二条 SQL 失败,数据库就会只更新最新状态却没有历史记录。两张表对同一次检查产生不同结果,说明只保证每条 SQL 独立成功还不够,需要用事务为整个业务操作建立共同的成功与失败边界。

1. 什么是事务

事务把一组数据库操作视为一个工作单元。在当前案例中,这个工作单元不是「执行一条 SQL」,而是「完成一次心跳检查的数据保存」:

health-check-transaction.txt
1
开始事务
2
-> 更新 health_status 最新状态
3
-> 插入 health_check_history 历史记录
4
-> 两步都成功:提交事务
5
-> 任一步失败:回滚事务

提交后,事务中的修改成为持久结果;回滚后,这次事务已经完成的修改也会被撤销。客户端不会看到「接口报告失败,但最新状态已经被部分更新」这种中间结果。

事务常用 ACID 描述四类保证:

特性含义当前案例
原子性 Atomicity一组操作要么都完成,要么都不保留最新状态和历史记录共同提交或回滚
一致性 Consistency提交前后都满足数据库约束和业务规则主键、非空等约束始终成立
隔离性 Isolation并发事务按隔离规则观察彼此的修改其他请求不会读取到尚未提交的结果
持久性 Durability成功提交的数据在故障后仍可恢复提交结果由 PostgreSQL 持久保存

「一致性」不是数据库自动理解所有业务含义。数据库约束、Service 规则和事务边界要共同设计,事务才能保证一个有效状态原子地变成另一个有效状态。

2. 增加历史表

在现有 schema.sql 末尾增加历史表:

src/main/resources/schema.sql
1
create table if not exists health_check_history (
2
id bigint generated always as identity primary key,
3
service varchar(50) not null,
4
status varchar(20) not null,
5
checked_at timestamptz not null default current_timestamp
6
);

最新状态表和历史表保存的是两种不同语义:

表一项服务有几行写入方式用途
health_status最多一行插入或更新快速读取当前状态
health_check_history可以有多行每次追加查看历次检查记录

id 使用 identity 列,由 PostgreSQL 为每条历史记录生成唯一编号。这里没有把 service 设为主键,因为同一服务需要保存多次检查。checked_at 继续使用数据库当前时间,调用方不必自己制造时间戳。

当前没有给历史表添加指向 health_status 的外键。历史记录表达的是已经发生过的事实,即使以后删除某个服务的当前状态,也未必应该同时删除历史。是否建立外键以及采用哪种删除行为,要由数据生命周期决定,不能只因为两张表都有 service 列就机械关联。

应用重启时,schema.sql 会尝试创建这张尚不存在的表。正如「数据库初始化与表结构」所说,增加一张新表可以由当前初始化脚本完成,但修改既有表仍然需要迁移工具可靠地记录版本。

3. 追加历史记录

历史记录当前只有一种数据库实现,也没有与最新状态相同的替换需求,因此先使用具体 Repository 类,不额外创建只有一个实现的接口:

src/main/java/cn/usehook/spring/health/HealthCheckHistoryRepository.java
01
package cn.usehook.spring.health;
02
03
import java.util.Objects;
04
import org.springframework.jdbc.core.simple.JdbcClient;
05
import org.springframework.stereotype.Repository;
06
07
@Repository
08
public class HealthCheckHistoryRepository {
09
10
private final JdbcClient jdbcClient;
11
12
public HealthCheckHistoryRepository(JdbcClient jdbcClient) {
13
this.jdbcClient = jdbcClient;
14
}
15
16
public void append(HealthStatus status) {
17
Objects.requireNonNull(status, "status must not be null");
18
19
jdbcClient.sql("""
20
insert into health_check_history (service, status)
21
values (:service, :status)
22
""")
23
.param("service", status.service())
24
.param("status", status.status())
25
.update();
26
}
27
}

append() 只负责插入一条历史记录。它不调用最新状态 Repository,也不决定两条 SQL 是否属于同一次业务操作;组合多个数据操作是 Service 的职责。

两个 Repository 都注入同一个 Spring 管理的 JdbcClient。它们不需要互相调用,也不需要把 Connection 作为参数层层传递。事务开始后,Spring 会让同一线程内参与该事务的 JDBC 操作使用绑定到事务的数据库连接。

4. 在 Service 声明边界

事务边界应该覆盖完整的业务操作,所以把 @Transactional 放在 HealthService 的检查方法上,而不是分别放在两个 Repository 方法上:

src/main/java/cn/usehook/spring/health/HealthService.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.config.HealthProperties;
04
import java.util.List;
05
import java.util.Locale;
06
import java.util.Optional;
07
import org.springframework.stereotype.Service;
08
import org.springframework.transaction.annotation.Transactional;
09
10
@Service
11
public class HealthService {
12
13
private final HealthProperties properties;
14
private final HealthStatusRepository repository;
15
private final HealthCheckHistoryRepository historyRepository;
16
17
public HealthService(
18
HealthProperties properties,
19
HealthStatusRepository repository,
20
HealthCheckHistoryRepository historyRepository
21
) {
22
this.properties = properties;
23
this.repository = repository;
24
this.historyRepository = historyRepository;
25
}
26
27
@Transactional
28
public HealthStatus check() {
29
return performCheck(properties.service());
30
}
31
32
@Transactional
33
public HealthStatus check(String service) {
34
return performCheck(service);
35
}
36
37
@Transactional(readOnly = true)
38
public Optional<HealthStatus> find(String service) {
39
return repository.findByService(normalizeService(service));
40
}
41
42
@Transactional(readOnly = true)
43
public HealthStatus getRequired(String service) {
44
String normalizedService = normalizeService(service);
45
return repository.findByService(normalizedService)
46
.orElseThrow(() -> new HealthStatusNotFoundException(normalizedService));
47
}
48
49
@Transactional(readOnly = true)
50
public List<HealthStatus> findAll() {
51
return repository.findAll();
52
}
53
54
private HealthStatus performCheck(String service) {
55
String normalizedService = normalizeService(service);
56
HealthStatus status = new HealthStatus(properties.status(), normalizedService);
57
HealthStatus savedStatus = repository.save(status);
58
historyRepository.append(savedStatus);
59
return savedStatus;
60
}
61
62
private String normalizeService(String service) {
63
if (service == null || service.isBlank()) {
64
throw new InvalidServiceNameException(service);
65
}
66
return service.strip().toLowerCase(Locale.ROOT);
67
}
68
}

check() 与 check(String) 都是 Controller 可以直接调用的公开入口,所以两者都声明事务。公共逻辑放进私有的 performCheck(),它只在已经开始的事务中执行,不需要再添加注解。

find()、getRequired() 和 findAll() 只读取数据,因此使用 readOnly = true 表达意图,并允许事务管理器或驱动进行相应优化。只读标记通常是一种提示,不是权限控制;应用仍然应该通过代码边界和数据库权限阻止不该发生的写入。

5. Spring 如何执行事务

项目已经引入 JDBC Starter,并且只有一个 DataSource。Spring Boot 会自动配置 JDBC 事务管理器并启用基于注解的事务支持,因此当前不需要手动添加 @EnableTransactionManagement 或自己创建事务管理器。

容器创建 HealthService 时,会为带有事务方法的 Bean 建立代理。Controller 调用代理公开的 check() 后,执行顺序可以简化为:

transactional-proxy.txt
1
Controller
2
-> HealthService 代理
3
-> 开始事务并取得 Connection
4
-> 调用真正的 HealthService.check()
5
-> 最新状态 Repository 使用该事务连接
6
-> 历史 Repository 使用该事务连接
7
-> 正常返回:commit
8
-> 抛出需要回滚的异常:rollback
9
-> 释放 Connection

@Transactional 本身是事务元数据,不是 Java 编译器关键字。真正建立事务的是 Spring 代理和事务管理器;PostgreSQL 则负责执行底层的提交、回滚和隔离规则。

默认传播行为是 REQUIRED:调用方已经有事务时,当前方法加入已有事务;没有事务时才创建新事务。这正适合当前 Service 作为业务入口的场景。只有明确需要独立提交等不同语义时,才应该调整传播级别。

默认隔离设置是 DEFAULT,表示沿用数据源和数据库的默认级别。PostgreSQL 默认使用 READ COMMITTED:一条 SQL 不会读取其他事务尚未提交的数据,但同一事务中先后执行的两条查询,可能看到期间由其他事务新提交的结果。事务因此不等于「自动解决所有并发问题」;涉及库存扣减、版本覆盖等竞争写入时,还要结合条件更新、行锁、版本号或更合适的隔离级别设计。

6. 回滚规则与代理边界

默认情况下,方法抛出 RuntimeException 或 Error 时事务回滚,正常返回时提交;普通受检异常默认不会触发回滚。Spring JDBC 抛出的 DataAccessException 属于运行时异常,所以第二条 SQL 失败并继续向外抛出时,第一条 SQL 的修改也会回滚。

如果业务方法必须在某个受检异常出现时回滚,可以明确声明:

transactional-rollback.java
1
@Transactional(rollbackFor = Exception.class)
2
public void importStatuses() throws Exception {
3
// 数据库操作
4
}

不要为了省事在所有方法上机械添加最宽的回滚规则。应先确定异常是否真的表示整个业务操作失败,并保持异常继续离开事务方法。下面这种捕获后返回成功的写法会让事务拦截器看不到失败,可能提交第一条修改:

swallowed-exception.java
1
try {
2
historyRepository.append(status);
3
} catch (DataAccessException exception) {
4
log.warn("Failed to append history", exception);
5
}

需要捕获异常时,要么在完成补偿后重新抛出,要么通过事务 API 明确标记回滚;不能既吞掉错误,又期待注解自动判断业务失败。

代理模式还意味着只有经过代理的调用才能触发事务。假设 check() 在同一个对象内部调用 check(String),而只有后者带 @Transactional,内部调用不会再次经过代理,事务可能根本没有开始。当前代码给两个外部入口都添加注解,再让它们调用不带注解的私有方法,避开了这种自调用陷阱。

同样,不要把事务注解放在私有方法上期待代理拦截,也不要在构造器或 @PostConstruct 中依赖事务代理。对初学阶段的代码,把事务声明在由其他 Bean 调用的公开 Service 方法上最清晰。

7. 验证与边界

重启应用,让新增的历史表完成初始化。调用一次检查接口:

terminal.bash
1
curl -i -X POST http://localhost:8080/api/v1/health/check \
2
-H 'Content-Type: application/json' \
3
-d '{"service":"postgresql"}'

随后在 psql 中分别查询最新状态和历史:

psql.sql
1
select service, status, checked_at
2
from health_status
3
where service = 'postgresql';
4
5
select id, service, status, checked_at
6
from health_check_history
7
where service = 'postgresql'
8
order by id desc;

重复请求后,第一条查询仍然只有一行且时间被更新,第二条查询会多出一行历史记录。

回滚测试需要让第二次写入稳定失败。@MockitoBean 可以只在测试容器中用 Mockito 对象替换历史 Repository;让它的 append() 抛出运行时异常后,真实 HealthService 和最新状态 Repository 仍然照常执行,因而可以检查第一次数据库写入是否被撤销:

src/test/java/cn/usehook/spring/health/HealthTransactionTests.java
01
package cn.usehook.spring.health;
02
03
import org.junit.jupiter.api.Test;
04
import org.springframework.beans.factory.annotation.Autowired;
05
import org.springframework.boot.test.context.SpringBootTest;
06
import org.springframework.dao.DataAccessResourceFailureException;
07
import org.springframework.jdbc.core.simple.JdbcClient;
08
import org.springframework.test.context.bean.override.mockito.MockitoBean;
09
10
import static org.assertj.core.api.Assertions.assertThat;
11
import static org.assertj.core.api.Assertions.assertThatThrownBy;
12
import static org.mockito.ArgumentMatchers.any;
13
import static org.mockito.BDDMockito.willThrow;
14
15
@SpringBootTest
16
class HealthTransactionTests {
17
18
private final HealthService healthService;
19
private final HealthStatusRepository repository;
20
private final JdbcClient jdbcClient;
21
22
@MockitoBean
23
private HealthCheckHistoryRepository historyRepository;
24
25
@Autowired
26
HealthTransactionTests(
27
HealthService healthService,
28
HealthStatusRepository repository,
29
JdbcClient jdbcClient
30
) {
31
this.healthService = healthService;
32
this.repository = repository;
33
this.jdbcClient = jdbcClient;
34
}
35
36
@Test
37
void rollsBackLatestStatusWhenHistoryWriteFails() {
38
String service = "rollback-test";
39
deleteStatus(service);
40
41
willThrow(new DataAccessResourceFailureException("simulated history failure"))
42
.given(historyRepository)
43
.append(any(HealthStatus.class));
44
45
try {
46
assertThatThrownBy(() -> healthService.check(service))
47
.isInstanceOf(DataAccessResourceFailureException.class);
48
assertThat(repository.findByService(service)).isEmpty();
49
} finally {
50
deleteStatus(service);
51
}
52
}
53
54
private void deleteStatus(String service) {
55
jdbcClient.sql("delete from health_status where service = :service")
56
.param("service", service)
57
.update();
58
}
59
}

执行测试:

terminal.bash
1
DB_PASSWORD='spring' ./mvnw -Dtest=HealthTransactionTests test

断言得到空结果,说明 repository.save() 虽然先执行,但历史写入抛出异常后并没有提交。这里必须通过容器取得 HealthService,直接 new HealthService(...) 不会经过 Spring 事务代理。测试结束时仍清理同名数据,避免测试前遗留记录干扰结果。

不要通过手工终止应用进程来模拟业务异常,因为进程退出和事务回滚不是同一个测试条件。

当前事务只覆盖同一个 DataSource 上的 PostgreSQL 操作。它不能自动撤销已经发送的 HTTP 请求、已经写入的文件或已经发布到独立消息系统的数据,也不能自动协调另一个不受同一事务管理器管理的数据库。

事务还会占用数据库连接并持有相关锁。不要先开始事务,再在其中等待耗时的远程调用或用户输入;应让事务围绕必要的数据库读写保持尽可能短。涉及数据库与外部系统的一致性时,通常需要幂等、重试、补偿或 Outbox 等额外设计,而不是把方法简单标成 @Transactional。

当前表结构仍由启动 SQL 管理,已经不适合继续演进。下一篇引入 Flyway,把建表和后续变更保存为只向前执行的版本迁移,再让测试与生产环境共享同一份结构历史。