上一章的一次心跳检查只更新 health_status 表。现在增加一个需求:除了保存每个服务的最新状态,还要把每一次检查追加到历史表中。这会让一个业务操作包含两条数据库写入。
如果第一条 SQL 成功、第二条 SQL 失败,数据库就会只更新最新状态却没有历史记录。两张表对同一次检查产生不同结果,说明只保证每条 SQL 独立成功还不够,需要用事务为整个业务操作建立共同的成功与失败边界。
1. 什么是事务
事务把一组数据库操作视为一个工作单元。在当前案例中,这个工作单元不是「执行一条 SQL」,而是「完成一次心跳检查的数据保存」:
1开始事务2-> 更新 health_status 最新状态3-> 插入 health_check_history 历史记录4-> 两步都成功:提交事务5-> 任一步失败:回滚事务
提交后,事务中的修改成为持久结果;回滚后,这次事务已经完成的修改也会被撤销。客户端不会看到「接口报告失败,但最新状态已经被部分更新」这种中间结果。
事务常用 ACID 描述四类保证:
| 特性 | 含义 | 当前案例 |
|---|---|---|
| 原子性 Atomicity | 一组操作要么都完成,要么都不保留 | 最新状态和历史记录共同提交或回滚 |
| 一致性 Consistency | 提交前后都满足数据库约束和业务规则 | 主键、非空等约束始终成立 |
| 隔离性 Isolation | 并发事务按隔离规则观察彼此的修改 | 其他请求不会读取到尚未提交的结果 |
| 持久性 Durability | 成功提交的数据在故障后仍可恢复 | 提交结果由 PostgreSQL 持久保存 |
「一致性」不是数据库自动理解所有业务含义。数据库约束、Service 规则和事务边界要共同设计,事务才能保证一个有效状态原子地变成另一个有效状态。
2. 增加历史表
在现有 schema.sql 末尾增加历史表:
1create table if not exists health_check_history (2id bigint generated always as identity primary key,3service varchar(50) not null,4status varchar(20) not null,5checked_at timestamptz not null default current_timestamp6);
最新状态表和历史表保存的是两种不同语义:
| 表 | 一项服务有几行 | 写入方式 | 用途 |
|---|---|---|---|
health_status | 最多一行 | 插入或更新 | 快速读取当前状态 |
health_check_history | 可以有多行 | 每次追加 | 查看历次检查记录 |
id 使用 identity 列,由 PostgreSQL 为每条历史记录生成唯一编号。这里没有把 service 设为主键,因为同一服务需要保存多次检查。checked_at 继续使用数据库当前时间,调用方不必自己制造时间戳。
当前没有给历史表添加指向 health_status 的外键。历史记录表达的是已经发生过的事实,即使以后删除某个服务的当前状态,也未必应该同时删除历史。是否建立外键以及采用哪种删除行为,要由数据生命周期决定,不能只因为两张表都有 service 列就机械关联。
应用重启时,schema.sql 会尝试创建这张尚不存在的表。正如「数据库初始化与表结构」所说,增加一张新表可以由当前初始化脚本完成,但修改既有表仍然需要迁移工具可靠地记录版本。
3. 追加历史记录
历史记录当前只有一种数据库实现,也没有与最新状态相同的替换需求,因此先使用具体 Repository 类,不额外创建只有一个实现的接口:
01package cn.usehook.spring.health;0203import java.util.Objects;04import org.springframework.jdbc.core.simple.JdbcClient;05import org.springframework.stereotype.Repository;0607@Repository08public class HealthCheckHistoryRepository {0910private final JdbcClient jdbcClient;1112public HealthCheckHistoryRepository(JdbcClient jdbcClient) {13this.jdbcClient = jdbcClient;14}1516public void append(HealthStatus status) {17Objects.requireNonNull(status, "status must not be null");1819jdbcClient.sql("""20insert into health_check_history (service, status)21values (: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 方法上:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import java.util.List;05import java.util.Locale;06import java.util.Optional;07import org.springframework.stereotype.Service;08import org.springframework.transaction.annotation.Transactional;0910@Service11public class HealthService {1213private final HealthProperties properties;14private final HealthStatusRepository repository;15private final HealthCheckHistoryRepository historyRepository;1617public HealthService(18HealthProperties properties,19HealthStatusRepository repository,20HealthCheckHistoryRepository historyRepository21) {22this.properties = properties;23this.repository = repository;24this.historyRepository = historyRepository;25}2627@Transactional28public HealthStatus check() {29return performCheck(properties.service());30}3132@Transactional33public HealthStatus check(String service) {34return performCheck(service);35}3637@Transactional(readOnly = true)38public Optional<HealthStatus> find(String service) {39return repository.findByService(normalizeService(service));40}4142@Transactional(readOnly = true)43public HealthStatus getRequired(String service) {44String normalizedService = normalizeService(service);45return repository.findByService(normalizedService)46.orElseThrow(() -> new HealthStatusNotFoundException(normalizedService));47}4849@Transactional(readOnly = true)50public List<HealthStatus> findAll() {51return repository.findAll();52}5354private HealthStatus performCheck(String service) {55String normalizedService = normalizeService(service);56HealthStatus status = new HealthStatus(properties.status(), normalizedService);57HealthStatus savedStatus = repository.save(status);58historyRepository.append(savedStatus);59return savedStatus;60}6162private String normalizeService(String service) {63if (service == null || service.isBlank()) {64throw new InvalidServiceNameException(service);65}66return 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() 后,执行顺序可以简化为:
1Controller2-> HealthService 代理3-> 开始事务并取得 Connection4-> 调用真正的 HealthService.check()5-> 最新状态 Repository 使用该事务连接6-> 历史 Repository 使用该事务连接7-> 正常返回:commit8-> 抛出需要回滚的异常:rollback9-> 释放 Connection
@Transactional 本身是事务元数据,不是 Java 编译器关键字。真正建立事务的是 Spring 代理和事务管理器;PostgreSQL 则负责执行底层的提交、回滚和隔离规则。
默认传播行为是 REQUIRED:调用方已经有事务时,当前方法加入已有事务;没有事务时才创建新事务。这正适合当前 Service 作为业务入口的场景。只有明确需要独立提交等不同语义时,才应该调整传播级别。
默认隔离设置是 DEFAULT,表示沿用数据源和数据库的默认级别。PostgreSQL 默认使用 READ COMMITTED:一条 SQL 不会读取其他事务尚未提交的数据,但同一事务中先后执行的两条查询,可能看到期间由其他事务新提交的结果。事务因此不等于「自动解决所有并发问题」;涉及库存扣减、版本覆盖等竞争写入时,还要结合条件更新、行锁、版本号或更合适的隔离级别设计。
6. 回滚规则与代理边界
默认情况下,方法抛出 RuntimeException 或 Error 时事务回滚,正常返回时提交;普通受检异常默认不会触发回滚。Spring JDBC 抛出的 DataAccessException 属于运行时异常,所以第二条 SQL 失败并继续向外抛出时,第一条 SQL 的修改也会回滚。
如果业务方法必须在某个受检异常出现时回滚,可以明确声明:
1@Transactional(rollbackFor = Exception.class)2public void importStatuses() throws Exception {3// 数据库操作4}
不要为了省事在所有方法上机械添加最宽的回滚规则。应先确定异常是否真的表示整个业务操作失败,并保持异常继续离开事务方法。下面这种捕获后返回成功的写法会让事务拦截器看不到失败,可能提交第一条修改:
1try {2historyRepository.append(status);3} catch (DataAccessException exception) {4log.warn("Failed to append history", exception);5}
需要捕获异常时,要么在完成补偿后重新抛出,要么通过事务 API 明确标记回滚;不能既吞掉错误,又期待注解自动判断业务失败。
代理模式还意味着只有经过代理的调用才能触发事务。假设 check() 在同一个对象内部调用 check(String),而只有后者带 @Transactional,内部调用不会再次经过代理,事务可能根本没有开始。当前代码给两个外部入口都添加注解,再让它们调用不带注解的私有方法,避开了这种自调用陷阱。
同样,不要把事务注解放在私有方法上期待代理拦截,也不要在构造器或 @PostConstruct 中依赖事务代理。对初学阶段的代码,把事务声明在由其他 Bean 调用的公开 Service 方法上最清晰。
7. 验证与边界
重启应用,让新增的历史表完成初始化。调用一次检查接口:
1curl -i -X POST http://localhost:8080/api/v1/health/check \2-H 'Content-Type: application/json' \3-d '{"service":"postgresql"}'
随后在 psql 中分别查询最新状态和历史:
1select service, status, checked_at2from health_status3where service = 'postgresql';45select id, service, status, checked_at6from health_check_history7where service = 'postgresql'8order by id desc;
重复请求后,第一条查询仍然只有一行且时间被更新,第二条查询会多出一行历史记录。
回滚测试需要让第二次写入稳定失败。@MockitoBean 可以只在测试容器中用 Mockito 对象替换历史 Repository;让它的 append() 抛出运行时异常后,真实 HealthService 和最新状态 Repository 仍然照常执行,因而可以检查第一次数据库写入是否被撤销:
01package cn.usehook.spring.health;0203import org.junit.jupiter.api.Test;04import org.springframework.beans.factory.annotation.Autowired;05import org.springframework.boot.test.context.SpringBootTest;06import org.springframework.dao.DataAccessResourceFailureException;07import org.springframework.jdbc.core.simple.JdbcClient;08import org.springframework.test.context.bean.override.mockito.MockitoBean;0910import static org.assertj.core.api.Assertions.assertThat;11import static org.assertj.core.api.Assertions.assertThatThrownBy;12import static org.mockito.ArgumentMatchers.any;13import static org.mockito.BDDMockito.willThrow;1415@SpringBootTest16class HealthTransactionTests {1718private final HealthService healthService;19private final HealthStatusRepository repository;20private final JdbcClient jdbcClient;2122@MockitoBean23private HealthCheckHistoryRepository historyRepository;2425@Autowired26HealthTransactionTests(27HealthService healthService,28HealthStatusRepository repository,29JdbcClient jdbcClient30) {31this.healthService = healthService;32this.repository = repository;33this.jdbcClient = jdbcClient;34}3536@Test37void rollsBackLatestStatusWhenHistoryWriteFails() {38String service = "rollback-test";39deleteStatus(service);4041willThrow(new DataAccessResourceFailureException("simulated history failure"))42.given(historyRepository)43.append(any(HealthStatus.class));4445try {46assertThatThrownBy(() -> healthService.check(service))47.isInstanceOf(DataAccessResourceFailureException.class);48assertThat(repository.findByService(service)).isEmpty();49} finally {50deleteStatus(service);51}52}5354private void deleteStatus(String service) {55jdbcClient.sql("delete from health_status where service = :service")56.param("service", service)57.update();58}59}
执行测试:
1DB_PASSWORD='spring' ./mvnw -Dtest=HealthTransactionTests test
断言得到空结果,说明 repository.save() 虽然先执行,但历史写入抛出异常后并没有提交。这里必须通过容器取得 HealthService,直接 new HealthService(...) 不会经过 Spring 事务代理。测试结束时仍清理同名数据,避免测试前遗留记录干扰结果。
不要通过手工终止应用进程来模拟业务异常,因为进程退出和事务回滚不是同一个测试条件。
当前事务只覆盖同一个 DataSource 上的 PostgreSQL 操作。它不能自动撤销已经发送的 HTTP 请求、已经写入的文件或已经发布到独立消息系统的数据,也不能自动协调另一个不受同一事务管理器管理的数据库。
事务还会占用数据库连接并持有相关锁。不要先开始事务,再在其中等待耗时的远程调用或用户输入;应让事务围绕必要的数据库读写保持尽可能短。涉及数据库与外部系统的一致性时,通常需要幂等、重试、补偿或 Outbox 等额外设计,而不是把方法简单标成 @Transactional。
当前表结构仍由启动 SQL 管理,已经不适合继续演进。下一篇引入 Flyway,把建表和后续变更保存为只向前执行的版本迁移,再让测试与生产环境共享同一份结构历史。