2. 什么是事务?

事务是一个包含一个或多个语句的原子操作。之所以称为原子操作,是因为事务中的所有语句要么全部成功(提交),要么全部失败(回滚),即“全有或全无”。ACID特性中的“A”就代表事务的原子性。

另一个关键点是:InnoDB引擎中所有语句都会成为事务,即使没有显式声明,也会隐式形成事务。当并发加入后,这个概念会变得更复杂。此时需要理解ACID中的“I”——隔离性。

理解隔离级别对于权衡性能与一致性至关重要。但在深入讨论隔离级别前,请记住:InnoDB中所有语句都是事务,都可以提交或回滚。如果没有显式声明事务,数据库会自动创建一个,并根据autocommit属性决定是否提交。

2.1. 隔离级别

本文以MySQL默认的可重复读隔离级别为例。它能保证同一事务内的读取一致性:第一次读取会建立快照(时间点),后续所有读取都与此快照一致。更多细节可参考MySQL官方文档。维护这种快照虽有一定开销,但能保证较高的一致性。

不同数据库可能有不同的隔离级别名称或选项,但大多类似。

3. 为什么使用事务及何时使用?

理解了事务的概念和特性后,我们来讨论只读事务。如前所述,InnoDB中所有语句都是事务,可能涉及锁定和快照等操作。但对于纯查询语句,事务协调相关的开销(如标记事务ID等内部结构)可能是不必要的。这时只读事务就派上用场了。

我们可以通过START TRANSACTION READ ONLY语法显式声明只读事务。MySQL也能自动检测只读事务,但显式声明能触发更多优化。读密集型应用可以利用这些优化,节省数据库集群的资源消耗

3.1. 应用层与数据库层

需要认识到,应用中的持久层可能包含多层抽象,每层职责不同。为简化说明,这些抽象最终会影响应用与数据库的交互方式。以Spring应用为例,这些层的作用包括:

  • DAO层:作为业务逻辑与持久化细节的桥梁
  • 事务抽象层:处理应用级事务复杂性(Begin/Commit/Rollback)
  • JPA抽象层:提供厂商无关的标准API
  • ORM框架:JPA的具体实现(如Hibernate)
  • JDBC层:负责与数据库实际通信

关键点在于:这些因素都可能影响事务行为。但我们将重点讨论直接影响行为的属性组。通常这些属性可在全局或会话级别设置。属性列表很长,我们只讨论两个关键属性(你应该已经熟悉它们)。

3.2. 事务管理

JDBC驱动通过关闭autocommit属性启动事务。这相当于执行BEGIN TRANSACTION语句,之后所有语句必须提交或回滚才能结束事务。

在全局级别设置此属性时,数据库会将所有请求视为手动事务,要求用户显式提交或回滚。但如果用户在会话级别覆盖此设置,全局设置将失效。因此许多驱动默认关闭此属性,确保行为一致且应用可控。

接下来,可使用transaction属性定义是否允许写操作。但有个陷阱:即使在只读事务中,仍可操作使用TEMPORARY关键字创建的临时表。该属性同样有全局和会话作用域,但应用中通常在会话级别处理。

使用连接池时,由于连接复用特性,框架/库必须确保新事务开始前会话处于干净状态。为此可能执行一些清理语句。

我们知道读密集型应用可通过只读事务优化资源使用。但开发者常忽略:切换事务设置会导致额外的数据库往返通信,影响连接吞吐量。

在MySQL中,全局设置示例:

SET GLOBAL TRANSACTION READ WRITE;
SET autocommit = 0;
/* 事务操作 */
commit;

会话级别设置示例:

SET SESSION TRANSACTION READ ONLY;
SET autocommit = 1;
/* 事务操作 */

3.3. 实用技巧

对于单查询事务,启用autocommit可节省往返通信开销。如果这是应用中的主要场景,使用单独配置为只读且默认启用autocommit的数据源效果更佳。

当事务包含多个查询时,应使用显式只读事务。创建只读数据源也能避免读写事务切换,节省往返开销。但如果存在混合读写负载,管理额外数据源的复杂性可能得不偿失

处理多语句事务时,还需考虑隔离级别对结果和性能的影响。为简化,示例中均使用默认隔离级别(可重复读)。

4. 实践应用

现在从应用角度,探讨如何处理这些属性及哪些层能控制此行为。实现方式因框架而异,以JPA和Spring为例,其他场景可触类旁通。

4.1. JPA

使用JPA/Hibernate定义只读事务:

EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("jpa-unit");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.unwrap(Session.class).setDefaultReadOnly(true);
entityManager.getTransaction().begin();
entityManager.find(Book.class, id);
entityManager.getTransaction().commit();

注意:JPA没有定义只读事务的标准方式,因此需要获取Hibernate的底层Session进行设置。

4.2. JPA+Spring

使用Spring事务管理会更简洁:

@Transactional(readOnly = true)
public Book getBookById(long id) {
    return entityManagerFactory.createEntityManager().find(Book.class, id);
}

Spring会负责开启、关闭事务并设置事务模式。使用Spring Data JPA时,已内置此类配置。

Spring JPA仓库基类已将所有方法标记为只读事务。在类级别添加@Transactional可覆盖方法行为。

也可在数据源配置中设置只读连接和autocommit属性。当应用仅需读取时,这能进一步提升性能:

@Bean
public DataSource readOnlyDataSource() {
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl("jdbc:mysql://localhost/baeldung?useUnicode=true&characterEncoding=UTF-8");
    config.setUsername("baeldung");
    config.setPassword("baeldung");
    config.setReadOnly(true);
    config.setAutoCommit(true);
    return new HikariDataSource(config);
}

但这仅适用于单查询为主的场景。使用Spring Data JPA时,还需禁用默认事务:

@Configuration
@EnableJpaRepositories(enableDefaultTransactions = false)
@EnableTransactionManagement
public class Config {
    // 数据源及其他持久化Bean定义
}

此后需完全手动添加@Transactional(readOnly=true)。不过大多数应用无需此配置,除非确定能带来显著收益。

4.3. 语句路由

更实际的场景是配置两个数据源:写数据源和只读数据源,然后在组件级别指定使用哪个数据源。这种方式能更高效处理读连接,避免确保会话状态所需的额外命令

实现方式多样,首先创建路由数据源类:

public class RoutingDS extends AbstractRoutingDataSource {

    public RoutingDS(DataSource writer, DataSource reader) {
        Map<Object, Object> dataSources = new HashMap<>();
        dataSources.put("writer", writer);
        dataSources.put("reader", reader);

        setTargetDataSources(dataSources);
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return ReadOnlyContext.isReadOnly() ? "reader" : "writer";
    }
}

关于路由数据源的细节很多。简单说,此类会根据应用请求返回合适数据源。我们使用ReadOnlyContext类在运行时维护数据源上下文:

public class ReadOnlyContext {

    private static final ThreadLocal<AtomicInteger> READ_ONLY_LEVEL = ThreadLocal.withInitial(() -> new AtomicInteger(0));

    // 默认构造函数

    public static boolean isReadOnly() {
        return READ_ONLY_LEVEL.get()
            .get() > 0;
    }

    public static void enter() {
        READ_ONLY_LEVEL.get()
            .incrementAndGet();
    }

    public static void exit() {
        READ_ONLY_LEVEL.get()
            .decrementAndGet();
    }
}

接下来定义数据源并注册到Spring上下文:

// 前述注解
public Config {
    // 其他Bean...

    @Bean
    public DataSource routingDataSource() {
        return new RoutingDS(
          dataSource(false, false),
          dataSource(true, true)
        );
    }
    
    private DataSource dataSource(boolean readOnly, boolean isAutoCommit) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost/baeldung?useUnicode=true&characterEncoding=UTF-8");
        config.setUsername("baeldung");
        config.setPassword("baeldung");
        config.setReadOnly(readOnly);
        config.setAutoCommit(isAutoCommit);
        return new HikariDataSource(config);
    }

    // 其他Bean...
}

最后,创建注解告知Spring何时使用只读上下文。定义@ReaderDS注解:

@Inherited
@Retention(RetentionPolicy.RUNTIME)
public @interface ReaderDS {
}

使用AOP将组件执行包装到上下文中:

@Aspect
@Component
public class ReadOnlyInterception {
    @Around("@annotation(com.baeldung.readonlytransactions.mysql.spring.ReaderDS)")
    public Object aroundMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            ReadOnlyContext.enter();
            return joinPoint.proceed();
        } finally {
            ReadOnlyContext.exit();
        }
    }
}

通常建议在尽可能高的层级添加注解。为简化示例,我们在仓库层添加(组件中仅包含单查询):

public interface BookRepository extends JpaRepository<BookEntity, Long> {

    @ReaderDS
    @Query("Select t from BookEntity t where t.id = ?1")
    BookEntity get(Long id);
}

可见,这种配置通过完整只读事务和避免会话上下文切换,能更高效处理只读操作,从而显著提升应用吞吐量和响应速度。

5. 总结

本文探讨了只读事务及其优势,理解了MySQL InnoDB引擎的处理机制,以及配置影响应用事务的主要属性。此外,还讨论了通过专用资源(如独立数据源)实现进一步优化的可能性。


原始标题:Using Transactions for Read-Only Operations | Baeldung