只读事务可能减少 Hibernate 的脏检查,却不一定阻止 SQL 写入。本文从 Spring 事务属性、ORM 刷新机制和 MySQL 访问模式三个层次,说明如何验证并设计可靠的只读边界。

问题背景

很多项目会在查询服务上统一添加:

@Transactional(readOnly = true)
public CustomerView findCustomer(Long id) {
    Customer customer = repository.findById(id)
            .orElseThrow();
    return CustomerView.from(customer);
}

这通常是合理的。但问题在于,团队很容易把 readOnly = true 理解为“该方法绝对不能修改数据库”。于是有人在只读方法中误改实体,或者调用了一个写方法,看到数据没有更新,便认为保护已经生效。

实际上,“没有更新”可能只是 Hibernate 没有自动刷新;如果代码改用 JdbcTemplate、原生 SQL 或显式刷新,结果可能完全不同。要理解这个问题,需要区分三个层次:Spring 的事务定义、Hibernate 的持久化上下文,以及 MySQL 真正执行的事务访问模式。

第一层:Spring 的 readOnly 是事务属性

@Transactional(readOnly = true) 首先是一项事务元数据。Spring 会把它交给当前使用的事务管理器,再由事务管理器决定如何传递给 JDBC 驱动或 ORM。

Spring Data JPA 明确指出,readOnly 不应被视为阻止修改语句的通用检查机制;它主要是传递给底层组件的提示。以 Hibernate 为实现时,只读事务通常会调整刷新策略,减少脏检查和不必要的同步。(docs.spring.io)

因此,下面这段代码的危险不在于它一定会更新数据库,而在于其行为依赖具体事务管理器、ORM 配置以及是否发生 flush:

@Service
@RequiredArgsConstructor
public class CustomerQueryService {

    private final CustomerRepository customerRepository;

    @Transactional(readOnly = true)
    public CustomerView findAndNormalize(Long id) {
        Customer customer = customerRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("客户不存在"));

        // 不要因为当前是只读事务,就允许自己修改托管实体
        customer.changeNickname(customer.getNickname().trim());

        return CustomerView.from(customer);
    }
}

即使这次修改没有落库,它仍然改变了当前持久化上下文中的对象。后续逻辑如果继续使用该实体,看到的已经是修改后的值。更稳妥的做法是让查询逻辑保持无副作用,在 DTO 映射阶段处理展示值:

public record CustomerView(Long id, String nickname) {

    public static CustomerView from(Customer customer) {
        String nickname = customer.getNickname() == null
                ? null
                : customer.getNickname().trim();
        return new CustomerView(customer.getId(), nickname);
    }
}

第二层:Hibernate 不刷新,不等于数据库拒绝写入

Hibernate 管理的实体发生变化后,通常会在提交事务或执行刷新时生成 UPDATE。只读事务下,自动刷新和脏检查可能被抑制,所以修改实体后经常看不到更新 SQL。

但下面这种直接写入绕开了实体脏检查:

@Service
@RequiredArgsConstructor
public class CustomerProbeService {

    private final JdbcTemplate jdbcTemplate;

    @Transactional(readOnly = true)
    public int updateByJdbc(Long id) {
        return jdbcTemplate.update(
                "update customer set last_viewed_at = current_timestamp where id = ?",
                id
        );
    }
}

如果底层数据库连接没有进入真正的只读事务,这条 SQL 仍可能成功。由此可见,Hibernate 的只读优化解决的是 ORM 工作量问题,并不天然构成数据库权限边界。

同理,不要用“事务结束后数据没变化”作为唯一测试依据。它无法区分以下情况:

  1. Hibernate 没有刷新实体变更;
  2. SQL 已执行,但事务最终回滚;
  3. 数据库真正拒绝了写语句;
  4. 测试本身开启了外层回滚事务。

验证只读保护时,应直接执行一条可控的写 SQL,并断言数据库返回拒绝写入的异常。测试应使用独立测试库和专用记录,不能在生产环境探测。

第三层:MySQL 的 READ ONLY 才是数据库访问模式

MySQL 支持 START TRANSACTION READ ONLY。处于这种事务访问模式时,对其他事务可见的普通表执行修改或加锁操作会被禁止;会话临时表是一个需要注意的例外。MySQL 还可以针对已知只读的 InnoDB 事务减少部分内部开销。(dev.mysql.com)

这和 JDBC 的 Connection.setReadOnly(true) 不能简单画等号。后者如何映射到底层数据库,取决于事务管理器、驱动与连接配置。不能仅凭注解名称推断 MySQL 已经执行了 START TRANSACTION READ ONLY。

如果项目使用纯 JDBC 的 DataSourceTransactionManager,Spring 提供了强制只读模式的配置:

@Configuration
public class TransactionConfiguration {

    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        DataSourceTransactionManager manager =
                new DataSourceTransactionManager(dataSource);
        manager.setEnforceReadOnly(true);
        return manager;
    }
}

该选项会尝试通过数据库语句强化只读限制,而不只是调用 JDBC 的只读提示。(docs.spring.io)

但在 JPA 项目里,常见事务管理器是 JpaTransactionManager。不要为了复制这段配置,随意再注册一个 JDBC 事务管理器,否则 JPA 操作与 JDBC 操作可能进入不同事务。首先要确认当前 Bean 实际使用的是哪个 PlatformTransactionManager,再决定验证和强化方案。

一个容易忽略的传播陷阱

外层只读事务调用内层写方法时,内层的普通 @Transactional 默认采用 REQUIRED,会加入已经存在的物理事务:

@Service
@RequiredArgsConstructor
public class InvoiceFacade {

    private final InvoiceRepository invoiceRepository;
    private final AuditService auditService;

    @Transactional(readOnly = true)
    public InvoiceView preview(Long invoiceId) {
        Invoice invoice = invoiceRepository.findById(invoiceId)
                .orElseThrow();

        auditService.recordPreview(invoiceId);
        return InvoiceView.from(invoice);
    }
}

@Service
@RequiredArgsConstructor
class AuditService {

    private final AuditLogRepository auditLogRepository;

    @Transactional
    public void recordPreview(Long invoiceId) {
        auditLogRepository.save(new AuditLog(invoiceId));
    }
}

默认情况下,参与现有事务的内层方法会沿用外层事务特征,本地声明的只读属性可能被忽略。(docs.spring.io) 因此,不应让查询用例顺便承担审计写入。如果业务确实要求记录,应重新划分事务边界;只有在确认独立提交符合一致性要求时,才考虑 REQUIRES_NEW。

还要注意,REQUIRES_NEW 会额外占用数据库连接。高并发下,外层事务持有连接、内层事务等待新连接,可能加剧连接池耗尽,所以它不是修复事务设计的快捷按钮。

常见坑与实践建议

1. 不要把只读注解当权限系统

真正不能写的数据源,应使用只读数据库账号、只读副本或数据库层访问模式控制。注解适合表达意图和优化执行,不适合承担安全边界。

2. 查询方法不要修改托管实体

即使当前配置不会刷新,也不要依赖这种偶然行为。查询结果的裁剪、格式化和脱敏应在 DTO 映射中完成。

3. 在服务层定义事务边界

一次完整业务操作应由服务层决定是读还是写,不要只依赖 Repository 方法上零散的事务注解。Spring Data JPA 也建议围绕工作单元定义事务边界。(docs.spring.io)

4. 记录真实事务管理器与数据源

多数据源项目应明确每个 @Transactional 使用的事务管理器。若只读注解还用于主从路由,需要确保路由发生在物理连接获取之前,并为读副本延迟制定一致性策略。

5. 测试必须覆盖原生写入

至少准备两类集成测试:一类验证托管实体在只读事务中的行为,另一类使用 JdbcTemplate 执行直接更新,确认数据库究竟是拒绝写入,还是只有 ORM 没有刷新。

总结

@Transactional(readOnly = true) 表达的是“这个工作单元按只读方式执行”,但它不是跨越所有技术层的写保护开关。

排查时可以按三层逐级确认:Spring 是否创建了只读事务,Hibernate 是否调整了刷新与脏检查策略,MySQL 是否真正进入 READ ONLY 访问模式。只有数据库权限或数据库事务模式明确拒绝写入时,才能把它称为可靠的写入边界。其余情况下,最重要的仍是保持查询代码无副作用,并让事务边界与业务用例保持一致。

最后修改:2026 年 09 月 26 日
如果觉得我的文章对你有用,请随意赞赏