事务是可靠应用程序的构建块
如果您阅读过任何有关 J2EE 的介绍性文章或者书籍,那么就会发现,只有一小部分资料是专门针对 Java Transaction Service(JTS)或 Java Transaction API(JTA)的。这并不是因为 JTS 是 J2EE 中不重要的部分或者可选部分 —— 恰恰相反。JTS 受到的关注之所以会比 EJB 技术少,是因为它为应用程序提供的服务非常透明 —— 很多开发人员甚至没有注意到在他们的应用程序中事务在哪里开始和结束。在某种意义上,JTS 的默默无闻恰恰是它的成功:因为它非常有效地隐藏了事务管理的很多细节,因此,我们没有听说过或者谈论过很多关于它的内容。但是,您可能想了解它在幕后都为您执行什么功能。
毫不夸张地说,没有事务就不能编写可靠的分布式应用程序。事务允许采用某种控制方式修改应用程序的持久性状态,以便使应用程序对于各种各样的系统故障(包括系统崩溃、网络故障、电源故障甚至自然灾害)更加健壮。事务是构建容错、高可靠性以及高可用性应用程序所需的基本构建块之一。
事务的动机
假设您正在从一个账户向另一个账户进行转账个账户差额由数据库表中的某一行来表示。如果您想从账户 A 转账到账户 B,则可能执行如下这些 SQL 代码:
SELECT accountBalance INTO aBalance FROM Accounts WHERE accountId=aId;IF (aBalance >= transferAmount) THEN UPDATE Accounts SET accountBalance = accountBalance - transferAmount WHERE accountId = aId; UPDATE Accounts SET accountBalance = accountBalance + transferAmount WHERE accountId = bId; INSERT INTO AccountJournal (accountId, amount) VALUES (aId, -transferAmount); INSERT INTO AccountJournal (accountId, amount) VALUES (bId, transferAmount);else FAIL "Insufficient funds in account";END if
到目前为止,这段代码看起来非常简单易懂。如果手头的资金充足,则从一个账户中减去资金,并添加到另一个账户中。但是,如果出现系统电源故障或者崩溃,又会发生什么情况呢?表示账户 A 和账户 B 的行可能不会存储在同一个磁盘块中,这意味着要完成转账需要进行多个磁盘 IO。如果在已写入第一个磁盘块之后,在写入第二个磁盘块之前,系统发生故障,又会发生什么情况呢?A 账户中的资金已经划走,但是没有出现在账户 B 中(A 和 B 客户都不会愿意),或者资金将出现在 账户 B 中,但是没有记入账户 A 的借出账中(银行不会愿意)。如果账户已正确更新,而账户日记账没有更新,又会发生什么情况呢?那么账户 A 和账户 B 的每月银行结账单将与它们账户的余额不一致。
不仅不可能同时将多个数据块写入磁盘,而且每当进行修改时马上将每个数据块写入磁盘,也对系统性能有不利影响。将磁盘写入延迟到比较适宜的时间可能会大大改善应用程序的吞吐量,但是,需要采用不损害数据完整性的方式执行。
甚至在系统没有发生故障时,上面讨论的代码还有另一种风险 —— 并发性。如果账户 A 中有 100 美元,但是却同时开始向它的两个不同的账户分别转账 100 美元,那么会发生什么情况呢?如果时间上凑巧,并且没有适当的锁定机制,两次转账都可能成功,从而使账户 A 的余额为负值。
这些情况似乎都是非常可能发生的,因此希望企业数据系统能够解决这些问题是理所应当的。我们希望在发生火灾、洪水、电源故障、磁盘以及系统出现故障时,银行都能够保持正确的账户记录。可以通过冗余(冗余的磁盘、计算机以及数据中心)来提供容错,但是事务 使得构建容错的软件应用程序成为可能。事务提供了一个框架,用于在系统或组件发生故障时保持数据一致性和完整性。
什么是事务?
那么到底什么是事务呢?在定义这个术语之前,我们首先定义应用程序状态 的概念。应用程序的状态包含影响应用程序操作的所有内存和磁盘中的数据项目 —— 应用程序 “知道” 的所有内容。应用程序状态可以存储在内存、文件或者数据库中。如果系统发生故障,例如应用程序、网络或者计算机系统崩溃,则我们想确保当重新启动系统时,可以恢复应用程序的状态。
现在,我们将事务 定义为对应用程序状态的相关操作的集合。事务具有原子性、一致性、隔离性 以及持久性 这几个属性。这些属性统称为 ACID 属性。
原子性 意味着要么所有事务操作都应用于应用程序状态,要么都不应用;事务是不可拆分的工作单元。
一致性 意味着事务代表应用程序状态的正确转换 —— 即事务不能违反应用程序中固有的任何完整性限制。实际上,一致性的概念是特定于应用程序的。例如,在记账应用程序中,一致性可能包括所有资产账户的总和始终等于所有负债账户的总和这个不变式。在本系列的第 3 部分中讨论事务划分时,我们将详细讨论这个需求。
隔离性 意味着一个事务的效果不影响正在同时执行的其他事务。从事务的角度讲,它意味着事务按顺序执行而不是并行执行。在数据库系统中,通常通过使用锁机制来实现隔离性。为了使应用程序获得最佳性能,有时也会对某些事务放松隔离性的要求。
持久性 意味着一旦成功完成某个事务,对应用程序状态所做的更改将 “经得起失败”。
什么是 “经得起失败”呢?它由什么组成?这取决于系统,一个设计良好的系统将明确地标识可以从哪些故障中恢复过来。在我的桌面工作站上运行的事务数据库,对于系统崩溃和电源故障非常稳定健壮,但是对于我的办公大楼发生大火灾却没有任何作用。银行可能不仅仅在数据中心具有冗余的磁盘、网络以及系统,而且还可能在别的城市有冗余的数据中心,该冗余数据中心通过冗余的通信链路连接,目的是允许从严重的故障(如自然灾害)中进行恢复。军用的数据系统甚至可能有更严格的容错要求。
事务的剖析
典型事务有几个参与者 —— 应用程序、事务监视器(TPM)以及一个或多个资源管理器(RM)。资源管理器存储应用程序状态,常常是数据库,但也可能是消息队列服务器(在 J2EE 应用程序中,它们将是 JMS 提供者)或其他事务性资源。TPM 协调 RM 的活动,以确保事务 “要么全有要么全无” 属性。
当应用程序请求容器或事务监视器启动新的事务时,事务开始。由于应用程序访问各种各样的 RM,因此,在事务中对它们进行征用。RM 必须使对应用程序状态所做的任何更改与请求更改的事务相关联。
当发生以下事件之一或者两个事件都发生时,事务结束:事务应用程序提交 该事务;通过应用程序或者由于其中一个 RM 失败,回滚 该事务。如果事务成功提交,则将写入与该事务相关联的更改,以使更改持久化并使其对于新的事务可见。如果事务被回滚,则该事务所做的所有更改都将被丢弃;就好像该事务从来没有发生过一样。
事务日志 —— 持久性的关键
事务 RM 通过在一个事务日志中记录多个事务的结果,获得持久性以及可接受的性能。事务日志存储为连续的磁盘文件(有时存储在原始分区中),并且一般只是用于写入而不用于读取,回滚或恢复的情况例外。在我们的银行账户示例中,与账户 A 和账户 B 相关联的余额将在内存中进行更新,新的余额和旧的余额将被写入到事务日志中。编写事务日志的更新记录不需要将全部数据都写入磁盘(只需要写入已更改的数据,而不需要写入全部磁盘块),而且所需的磁盘寻道时间也会更少(原因是所有更改都包含在日志中连续的磁盘块中)。此外,与多个并发事务关联的更改可以合并到一起,一次写入事务日志,这意味着每次磁盘写入时我们可以处理多个事务,而不需要 每个事务进行几次磁盘写入。之后,RM 将根据所更改的数据更新实际的磁盘块。
重新启动时进行恢复
如果系统出现故障,重新启动时要做的第一件事就是重新应用所有已提交事务的作用,所有这些已提交的事务都 位于日志中,但是它们的数据块尚未更新。采用这种方式,日志保证了故障之间的持久性,而且还能够减少所执行的磁盘 IO 操作的数量,或者至少使它们延迟到对系统性能影响更小的时间。
两阶段提交
很多事务只涉及一个 RM —— 通常是数据库。在这种情况下,RM 通常执行提交或回滚事务所需的大部分工作。(几乎所有事务 RM 都有它们自己的内置的事务管理器,这个管理器可以处理本地事务 —— 只涉及该 RM 的事务)。但是,如果事务涉及两个 RM 或多个 RM —— 可能是两个单独的数据库,或者是一个数据库和一个 JMS 队列,或者是两个单独的 JMS 提供者 —— 我们想确保 “要么全有要么全无” 的语义不仅仅应用于这个 RM 中,而且还应用于事务中的所有 RM。在这种情况下,TPM 将组织一个两阶段提交。在两阶段提交中,TPM 首先向每个 RM 发送一个 “准备” 消息,询问它是否准备就绪以及是否能够提交事务;如果它收到来自所有 RM 的确认应答,则将事务在其自己的事务日志中标记为已提交,然后指示所有 RM 提交事务。如果某个 RM 失败,则重新启动时它将向 TPM 询问有关失败时未处理的所有事务的状态,并提交它们或者对它们执行回滚操作。
两个阶段提交类似于社会上的结婚典礼 —— 牧师或神父询问双方 “您愿意让这个男人/女人作为您的丈夫/妻子吗?” 如果双方都回答是,则将宣布他们成为夫妻;否则,双方不能结婚。不管双方中的哪一方首先说 “我愿意”,在一方没有回答时,另一方决不能完成结婚。
事务作为处理异常的机制
您可能观察到,事务向对块进行同步的应用程序数据提供很多与内存中数据相同的功能 —— 保证原子性、更改的可见性以及显而易见的排序。但是,当同步主要是并发控制的机制时,则事务主要是处理异常的机制。如果在一个磁盘不会发生故障、系统和软件不会崩溃以及电源是百分百可靠的世界中,我们将不需要事务。事务在企业应用程序中所起的作用与合同法在社会上所起的作用一样 —— 它们规定,如果一方不能履行他那一部分合同,则交易将失效。当我们编写合同时,我们通常希望它是多余的,令人感到欣慰的是大部分时候都是如此。
与比较简单的 Java 程序进行类比,事务在应用程序级别所提供的一些优势与 catch 和 finally 块在方法级别所提供的优势相同;它们使我们不用编写很多错误复原代码,即可执行可靠的错误复原。考虑下面这个方法,该方法将一个文件复制到另一个文件:
public boolean copyFile(String inFile, String outFile) { InputStream is = null; OutputStream os = null; byte[] buffer; boolean success = true; try { is = new FileInputStream(inFile); os = new FileOutputStream(outFile); buffer = new byte[is.available()]; is.read(buffer); os.write(buffer); } catch {IOException e) { success = false; } catch (OutOfMemoryError e) { success = false; } finally { if (is != null) is.close(); if (os != null) os.close(); } return success;}
忽略为整个文件分配一个缓冲区是一个不好的想法,但是在这个方法中哪里错了呢?有很多东西。输入文件可能不存在,或者该用户可能没有这个文件的读权限。用户可能没有输出文件的写权限,或者该文件被另一个用户锁定。可能没有足够的磁盘空间来完成该文件的写操作,或者由于没有足够的内存可用,分配缓冲区可能失败。幸运的是,所有这些都由 finally 语句来处理,该语句释放了 copyFile()所使用的所有资源。
如果您使用原来的 C 语言编写这个方法,则对于每个操作(打开输入、打开输出、malloc、读、写),必须测试返回状态,如果操作失败,则取消以前成功的所有操作,并返回适当的状态代码。由于需要这么多错误处理代码,该代码可能更大,因此更难阅读。同时在错误处理代码(它也是最难测试的部分)中很容易出错,比如不能释放资源、对一个资源释放两次或者释放尚未分配的资源。更复杂的方法可能涉及更多资源,而不仅仅是两个文件和一个缓冲区,这使得问题变得更加复杂。在大量错误复原代码中,很难发现实际的程序逻辑。
现在,假设您正在执行一个复杂的操作,该操作涉及在多个数据库中插入或更新多个行,其中一个操作违反了完整性约束并失败了。如果您管理自己的错误复原,则必须跟踪已经执行的操作,并知道在随后的操作失败的情况下如何取消每个操作。如果工作单元分布在多个方法或组件上,则会更加困难。借助事务来构造应用程序,就可以将所有这些清理工作委托给数据库(即进行 ROLLBACK),并取消自从事务开始所执行的所有操作。
结束语
通过借助事务构造应用程序,我们定义一组正确的应用程序状态转换,并确保应用程序始终处于正确的状态,甚至在系统或组件发生故障之后也是如此。事务使我们能够将很多异常处理和恢复工作委托给 TPM 和 RM,从而简化了我们的代码,并使我们能够空出更多时间来考虑应用程序逻辑。
在此系列的第 2 部分中,我们将探讨这对于 J2EE 应用程序意味着什么 —— J2EE 如何使我们能够将事务语义告知 J2EE 组件(EJB 组件、servlet 以及 JSP 页面);它如何使资源征用对应用程序(甚至对于 bean 管理的事务)完全透明;单个事务如何 透明地遵循从一个 EJB 组件到另一个 EJB 组件,或者从一个 servlet 到一个 EJB 组件,甚至跨越多个系统的控制流程。
尽管 J2EE 提供了相当透明的对象事务服务,但是应用程序设计者仍然必须仔细考虑在哪里划分事务,以及如何在应用程序中使用事务资源 —— 不正确的事务划分可能会使应用程序处于不一致的状态,而不正确地使用事务资源可能会造成非常严重的性能问题。在此系列的第 3 部分中,我们将讨论这些问题并提供一些关于如何构造应用程序的建议。
![45de7742aaebff9282aae66adef127fc.png](https://i-blog.csdnimg.cn/blog_migrate/793b8fb8b87713be761899bc72687c88.jpeg)