65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Spring Framework 进行数据库事务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.71/5 (10投票s)

2017 年 6 月 11 日

CPOL

15分钟阅读

viewsIcon

23646

关于数据库事务和使用 Spring Framework

前言

作者试图分享关于数据库事务以及如何应用 Spring 框架来处理它们的知识。这些知识来源于 Craig Walls 的书籍[1]、Spring 框架文档[2],以及在 Stack Overflow 和 Wikipedia 上搜索答案的经验。文章涵盖了与该主题最重要的事情,以便您能够对事务有正确的理解,并在您的项目中开始使用它们。

引言

事务遵循“全有或全无”的原则。它们由多个步骤组成。如果所有步骤都成功,则事务成功。如果至少有一个步骤没有成功执行,则所有先前步骤都必须回滚,就好像什么都没有发生一样。

事务可以应用于的一个简单场景是在银行进行资金转账。想象一下,一笔资金已成功从一个账户中取出,但并未存入第二个账户。或者反过来的情况,资金已成功存入第二个账户,但取出失败。这两种情况都是不可接受的。因此,资金转账行为必须是事务性的——要么完全成功,要么在至少一个步骤失败时回滚。

在本文中,我们将涵盖与事务相关的原则和概念。特别是,我们将专注于数据库事务,即我们需要在事务范围内执行多个查询的情况。我们将了解如何借助 Spring 框架在我们的 Java 应用程序中实现这种事务行为(并惊叹于它的简单性)。

主要原则

事务被描述为 ACID,代表

  • 原子性 (Atomic) — 前面提到的“全有或全无”原则
  • 一致性 (Consistent) — 一旦事务结束(无论成功与否),系统将保持与所支持的业务一致,即不会留下损坏的数据
  • 隔离性 (Isolated) — 相互隔离,防止对同一数据的并发读/写
  • 持久性 (Durable) — 一旦事务完成,其结果应该被持久化

我们可以说 AtomicIsolatedDurable 属性支持 Consistent 属性。一致性是事务关注的系统主要方面。

事务可能会受到其他并发执行的事务的影响。并发事务可能导致以下**问题**

  • 脏读 (Dirty reads) — 当一个事务读取了由第二个事务修改但尚未提交的数据时发生。如果第二个事务所做的更改随后被回滚,则第一个事务获得的数据将变得无效。
  • 不可重复读 (Non-repeatable reads) — 当一个事务多次执行相同的读取查询时,但每次读取的*同一行*数据都不同,因为另一个事务在两次读取查询之间更新了该行。然而,第一个事务期望数据是相同的。
  • 幻读 (Phantom reads) — 当一个事务执行相同的读取查询,第二次查询的结果*包含更多行*,因为另一个事务插入了一个满足读取查询的 WHERE 条件的行。

注意:不可重复读和幻读看起来可能非常相似。重要的区别在于,在不可重复读的情况下,*同一行*数据在之前读取后包含了不同的数据。在幻读的情况下,*新行*是由另一个事务添加的。

应用 Spring 框架

现在,我们了解了事务的主要原则、概念和相关问题,就可以开始处理它们了。让我们看看如何应用 Spring 框架来为我们与数据库交互的应用程序引入事务行为。

选择事务管理器

我们需要使用一个事务管理器来与特定于平台的事务实现进行交互。在此上下文中,平台是持久化框架,例如 JDBC、MyBatis、Hibernate、Java Transaction API (JTA)。因此,我们需要选择 Spring 提供的相应事务管理器。

假设我们的应用程序通过 JdbcTemplate[3] 与数据库交互。因此,我们需要将 DataSourceTransactionManager 添加到应用程序上下文中。

<bean id="transactionManager" 
 class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource" />
</bean>

<!-- DB2 DataSource example with placeholders -->
<bean id="dataSource" 
 class="org.springframework.jdbc.datasource.DriverManagerDataSource">
    <property name="driverClassName" value="com.ibm.db2.jcc.DB2Driver"/>
    <property name="url" value="jdbc:db2://${url}:${port}/${databaseName}"/>
    <property name="username" value="${username}"/>
    <property name="password" value="${password}"/>
</bean>

本文不详细介绍数据源配置,但您可以在 Spring 文档[3]中找到相关信息。

在后台,DataSourceTransactionManager 将使用从 DataSource 检索到的 java.sql.Connection 对象,在成功时调用 commit(),在失败时调用 rollback()。对于其他持久化框架,行为会有所不同,但 commitrollback 操作构成了核心功能。

事务属性

在代码中声明事务之前,我们需要熟悉事务属性。这些属性允许指定事务行为,使其符合需求。Spring 框架支持以下属性,每个属性都有默认值。

传播行为 (Propagation behavior) 定义了一个方法应该如何相对于事务执行。它指定是否需要创建一个新事务;或者是否必须使用现有事务(更全局的事务);或者是否应该在方法的持续时间内挂起事务;等等。事务有作用域,一个事务可能在一个“更大”(“更全局”)的事务范围内执行。我们可以指定事务如何相互关联。例如,在事务回滚的情况下,更全局的事务是否也应该回滚,还是应该继续执行?我们可以选择。

在 Spring 框架中,此属性的默认值为 REQUIRED,这意味着方法将在现有(更全局)事务的范围内运行,或者将启动一个新事务。如果我们的事务回滚,更全局的事务也将回滚。在简单的数据库工作场景中,通常不需要多个“层”的事务,因此默认值将完全满足我们。如果不是,请参考 Spring 文档[2]以更深入地理解传播行为。

隔离级别 (Isolation levels) 指定事务可能受到其他并发事务的影响程度。我们可以说隔离级别定义了事务对它所处理的数据有多么“自私”。隔离是为了解决(文章前面提到的)并发事务的**问题**。Spring 框架支持多个隔离级别,这些级别逐渐解决了问题。下图显示了在哪些隔离级别下仍然会发生哪些问题。

作者希望这张图至少能说明一些问题。从图中可以看出,SERIALIZABLE 级别解决了所有问题,而 REPEATABLE_READ 允许发生幻读,READ_UNCOMMITTED 则没有解决任何已知问题。

问题来了:为什么不总是使用最严格的隔离级别 SERIALIZABLE 呢?因为它解决了所有并发事务的问题。在数据库中,隔离是通过锁定行[4]来实现的——事务需要等待已获得锁的事务释放它。回想一下,我们可以将隔离级别视为事务对数据“自私”的程度。事务越“自私”,事务之间平均等待的时间就越长,因此我们数据库系统的性能就会下降。因此,您应该选择尽可能低的隔离级别,但不要牺牲一致性来追求性能。考虑您的事务对数据做了什么,以及它可能如何受到外部数据修改的影响,然后选择最宽松的隔离级别,以保证正确性。

由隔离(更准确地说,由锁的使用)引起的另一个问题是死锁[5]的可能性。简而言之,死锁的一个例子是,第一个事务等待第二个事务释放数据上的锁,同时,第二个事务等待第一个事务释放另一个数据对象上的锁。在这种情况下,事务会相互阻塞,无法继续——死锁发生了。死锁是一个很大的话题,我们将在本文中不详述。请记住,死锁问题存在于事务领域,最终必须解决。但现在,让我们不要过多关注它。

此隔离属性的默认值为 DEFAULT,它使用底层数据存储的默认设置。您很可能希望指定一个非默认值,以使您的事务在性能和一致性方面达到最佳,并使您的应用程序独立于特定数据库。

注意:并非所有数据库都支持上述隔离级别——请查阅您正在使用的数据库的文档。即使数据库可能不支持特定的隔离级别,您很可能希望您的应用程序独立于给定的数据源实现。因此,您应该选择隔离级别,就好像数据库支持我们讨论过的所有隔离级别一样。

只读 (Read-only) 属性,正如您所预期的,指定事务是否只执行读取操作而不修改任何数据。它允许数据库应用利用事务的只读特性来优化。默认值为 false

事务超时 (Transaction timeout) 指定事务将在多长时间后回滚。如前所述,事务可能会获取列的锁,从而阻塞其他事务,在某些情况下,长时间占用数据是不可接受的。默认值为底层持久化框架的默认超时(回想一下之前选择事务管理器),如果不支持超时则为无。

回滚规则 (Rollback rules)。当事务范围内抛出异常时,事务会回滚。通过回滚规则,我们可以定义哪些异常应触发回滚。默认情况下,事务在遇到未检查异常(即 RuntimeException 及其子类)时回滚,而在遇到已检查异常时不会回滚。

声明事务

Spring 提供了多种处理事务的方式。

  • 将类和方法注解为 @Transactional — 一种非常简单但功能强大的方法,用于使方法在事务范围内执行。
  • 声明式事务(Programmatic transactions)提供更精细化的控制。如果需要更多控制,请使用 TransactionTemplate[2],但请注意,代码将变得更复杂。
  • 在 **XML 中声明事务**与使用注解非常相似。通过采用这种方法,所有关于事务的信息都收集在 XML 文档中,而不是源代码中。

作者选择涵盖注解方法,因为它在大多数情况下足够强大,而且(主观地)优雅。作者认为,最好在源代码中声明事务行为,以便每个开发人员都能清楚地看到事务已被应用。但是,您可以自由使用任何您认为合适的方法。

要使用注解,您只需在配置中添加一行代码

<tx:annotation-driven transaction-manager="transactionManager" />

回想一下,我们之前已经声明了事务管理器。就是这样。现在我们可以将存储库类和/或方法注解为事务性的。请注意,**您应该注解存储库的实现而不是存储库接口**。这是因为 Java 注解不会从接口继承,只有在使用基于接口的代理时,将 @Transactional 放在接口上才有效,而您很可能不希望使您的应用程序依赖于这一点。

用例。我们有一个持久化 Documents 的存储库。Documents 可能有几个 Attachments。为了集中讨论事务,让我们假设 DocumentMapper 负责将 Java 对象映射到 SQL 查询并执行它。我们只需要调用它的方法(DocumentMapper 由 MyBatis 支持,但这并不重要)。所以,让我们继续注解我们的类和方法来应用事务行为。

@Repository
@Transactional(isolation = SERIALIZABLE)
public class DocumentRepositoryImpl implements DocumentRepository {

    private final DocumentMapper documentMapper;
    
    @Autowired
    public DocumentRepositoryImpl(DocumentMapper documentMapper) {
        this.documentMapper = documentMapper;
    }

    @Transactional(isolation = READ_UNCOMMITTED)
    public void save(Document document) {
        documentMapper.saveDocument(document);
        for (Attachment attachment : document.getAttachments()) {
            documentMapper.saveAttachment(document, attachment);
        }
    }

    @Transactional(isolation = READ_COMMITTED, readOnly = true)
    public Document readDocument(long id) {
        return documentMapper.readDocument(id);
    }

    ...
}

拆解代码

将类注解为 @Transactional 等同于将此注解添加到类中的每个 public 方法。但是,方法级别的 @Transactional 注解优先于类级别的注解。因此,通过添加 @Transactional(isolation = SERIALIZABLE),我们声明默认情况下,我们存储库的每个 public 方法都应在具有最严格隔离级别的事务范围内执行,但我们允许为每个方法单独重新配置事务行为。

save(Document) 方法将作为主要示例来演示事务的强大功能。在该方法中,文档及其附件将被保存。每次调用 documentWrapper 时,都会向数据库发送并执行一个 SQL 查询。因此,例如,如果一个文档有三个附件,保存该文档将需要四个查询:一个用于文档本身,三个用于其附件。需要注意的是,在与数据库的查询之间,执行流程会返回到我们的 Java 应用程序。应用程序中可能发生很多事情,而我们不希望将已持久化的数据置于不一致的状态(例如,文档只保存了部分提交的附件)。因此,通过将 save(Document) 注解为 @Transactional,我们使得封装在 documentWrapper 中的 SQL 查询在事务范围内执行。事务将在 save(Document) 方法执行流程返回时提交。如果从方法中抛出任何未检查异常(RuntimeException 及其子类),事务将回滚。因此,关于文档的持久化数据将保持一致——我们确信如果文档被保存了,它的所有附件也都被保存了。

让我们看一下 save(Document) 方法的事务属性。我们明确指定**隔离级别**是 READ_UNCOMMITTED,这是允许幻读、不可重复读和脏读的最弱隔离级别。之所以做出此选择,是因为我们的事务在执行过程中不读取任何数据,只写入。因此,没有必要使隔离级别更严格。所有其他属性都隐式保留为默认值:我们对默认的**传播行为**感到满意,它将创建一个新事务或使用一个更全局的事务;read-only 属性的值为 false 显然是正确的,因为事务会修改数据;由于该方法不抛出任何已检查异常,因此在未检查异常时回滚正是我们所需要的,所以**回滚规则**保持默认;最后,没有必要修改默认的**事务超时**(至少目前是这样),所以我们将其保留为默认值。

Spring 框架管理事务的一个巨大好处是,可以对它们进行单元测试。请看下面的代码

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:test-config.xml")
// text-config.xml contains data source, transaction manager, DocumentRepositoryImpl
// bean definitions as well as <tx:annotation-driven> element.
public class DocumentRepositoryImplTest {

    @Autowired
    private DocumentRepository testedObject;

    // ...

    @Test
    transactionalSaveDocument_withCorruptedAttachment_rollsBack() throws Exception {
        Document document = createDocumentWithAttachments();
        Document documentWithCorruptedAttachment = addCorruptedAttachmentTo(document);
        trySaving(documentWithCorruptedAttachment);
        assertNoDataIsLeftInDatabase();
    }

    // ...

    private static Document createDocumentWithAttachments() {
        // Create document object with three normal attachments.
    }

    private static Document addCorruptedAttachmentTo(Document document) {
        List<Attachment> attachments = document.getAttachments();
        attachments.add(new CorruptedAttachment());
        return document;
    }

    private void trySaving(Document documentWithCorruptedAttachment) {
        try {
            testedObject.save(documentWithCorruptedAttachment);
            Assert.fail("Document with corrupted attachment should not have passed.");
        } catch (RuntimeException e) {
            // OK, as expected.
        }
    }

    private void assertNoDataIsLeftInDatabase() {
        // Connect to database and verify that test document and attachments
        // are not in database, i.e., transaction rolled back successfully. 
    }

    // ...

    private static class CorruptedAttachment extends Attachment {
        @Override
        public String getType() {
            throw new RuntimeException();
        }
    }
}

测试的关键点在于,我们添加了一个附件,该附件在调用 getType() 时会抛出 RuntimeException。在底层,DocumentMapper 使用访问器方法来形成 SQL 查询,因此当被测试的存储库尝试在此行保存损坏的附件时,RuntimeException 将被抛出:documentMapper.saveAttachment(document, attachment);。我们在测试中捕获了异常。由于异常已从 save(Document) 方法中抛出,我们期望事务已被回滚。我们可以通过检查数据库中是否不再有关于文档和附件的数据来验证这一点——调用 assertNoDataIsLeftInDatabase()

我们可以更进一步,尝试调试测试。通过在 documentMapper.saveAttachment(document, attachment) 行设置断点,我们可以确保 SQL 查询是一个接一个地执行的。特别是,可以看到 DocumentAttachment 对象从数据库获取 ID(数据库映射到 Java 对象的输出由 DocumentMapper 处理)。由于文档的 ID 是已知的,当执行在断点处等待时,可以使用数据库客户端通过 SELECT 语句获取有关文档的数据。作者尝试使用 IBM DB2 数据库进行此操作,并且该查询由于被阻塞而无法执行。这是预期的,因为我们感兴趣的行被事务锁定,直到它提交。

关于 save(Document) 方法就说到这里。我们快速看一下 readDocument(long)。读取带附件的文档需要一个 SELECT 查询,因此不可能发生幻读和不可重复读。然而,我们想避免脏读。这就是为什么隔离级别是 READ_COMMITTED。为了启用数据库对只读事务的优化,我们继续声明 readOnly = true

这样,我们就看到了如何应用和测试事务行为。让我们总结一下我们学到的内容。

摘要

数据库事务应用于数据处理需要多个步骤,如果其中任何一个步骤失败,就必须撤销这些步骤,以避免违反数据一致性。事务可以被描述为原子性、隔离性、持久性和一致性(ACID)。

在数据库中,事务是并发执行的。事务的并发会导致脏读、不可重复读和幻读等问题。隔离级别解决了这些问题。隔离级别越严格,对数据库吞吐量的影响就越大。必须分析读取问题如何影响正在考虑的事务,并选择能够保证事务不受影响的最低隔离级别。

通过应用 Spring 框架处理事务是多么简单,真是令人印象深刻。您只需要做以下几件事:

  1. 根据您使用的持久化框架(JDBC、MyBatis、Hibernate...),选择合适的事务管理器,并将其声明为应用程序上下文中的一个 bean。
  2. 通过在配置中添加一行来启用 @Transactional 注解处理:<tx:annotation-driven transaction-manager="transactionManager" />
  3. 将您的存储库类和/或方法注解为 @Transactional
  4. 指定事务属性。如我们所见,Spring 支持不同的传播行为、隔离级别、声明事务为只读、指定超时和回滚规则。

请注意,@Transactional 注解只是 Spring 框架中处理事务的一种方式。

现在您已经掌握了关于数据库事务及其应用以及如何特别是使用 Spring 框架处理它们的扎实基础知识。感谢您的阅读!

历史

  • 2017 年 6 月 11 日:提交文章

参考文献

  1. Craig Walls. Spring in Action. 第 3 版. Manning, 2011 年 6 月. 424 页. ISBN 9781935182351. 第 6 章:管理事务。
  2. The Spring Framework. Spring Framework Reference, Part V. Data Access, Chapter 17: Transaction Management. [在线,访问于 2017 年 6 月 4 日]. 可从:https://docs.springframework.org.cn/spring/docs/current/spring-framework-reference/html/transaction.html
  3. The Spring Framework. Spring Framework Reference, Part V. Data Access, Chapter 19: Data access with JDBC. [在线,访问于 2017 年 6 月 4 日]. 可从:https://docs.springframework.org.cn/spring-framework/docs/current/spring-framework-reference/html/jdbc.html
  4. Wikipedia. Isolation (database systems). [在线,访问于 2017 年 6 月 6 日]. 可从:https://en.wikipedia.org/wiki/Isolation_(database_systems)
  5. Wikipedia. Deadlock. [在线,访问于 2017 年 6 月 6 日]. 可从:https://en.wikipedia.org/wiki/Index_locking
© . All rights reserved.