深入理解JTS平衡安全性和性能
JTS 只是一个组件事务监视器(有时也称为 对象事务监视器(object transaction monitor)),或称为 CTM。大家可以看看这篇文章,Java理论和实践: 理解JTS。
事务主要是一种异常处理机制。事务在程序中的用途与合法合同在日常业务中的用途相似:如果出了什么问题它们可以帮助恢复。但由于大多数时间内都没实际 发生什么错误,我们就希望能够尽量减少它们的开销以及对其余时间的占用。我们在应用程序中如何使用事务会对应用程序的性能和可伸缩性产生很大的影响。
事务划分
J2EE 容器提供了两种机制用来定义事务的起点和终点:bean 管理的事务和容器管理的事务。在 bean 管理的事务中,用 UserTransaction.begin() 和 UserTransaction.commit() 在 bean 方法中显式开始和结束一个事务。另一方面,容器管理的事务提供了更多的灵活性。通过在装配描述符中为每个 EJB 方法定义事务性属性,您可以指定每个方法的事务性需求并让容器确定何时开始和结束一个事务。无论在哪种情况下,构建事务的基本指导方针都是一样的。
进来,出去
事务划分的第一条规则是“尽量短小”。事务提供并发控制;这通常意味着资源管理器将代表您获得您在事务期间访问的数据项的锁,并且它必须一直持有这些锁,直到事务结束。(请回忆一下本系列第 1 部分所讨论的 ACID特性,其中“ACID”的“I”代表“隔离”(Isolation)。也就是说,一个事务的结果影响不到与该事务并发执行的其它事务。)当您拥有锁时,任何需要访问您锁定的数据项的其它事务将不得不一直等待,直到您释放锁。如果您的事务很长,那些其它的所有事务都将被锁定,您的应用程序吞吐量将大幅度下降。
规则 1:使事务尽可能短小。
通过使事务尽量短小,您可以把阻碍其它事务的时间缩到最短,从而提高应用程序的可伸缩性。保持事务尽可能短小的最好方法当然是不在事务中间做任何不必要耗费时间的事,特别是不要在事务中间等待用户输入。
开始一个事务,从数据库检索一些数据,显示数据,然后在仍处于事务中时请用户做出一个选择可能比较诱人。千万别这么做!即使用户注意力集中,也要花费数秒来响应 D 而在数据库中拥有锁数秒的时间已经是很长的了。如果用户决定离开计算机,或许是去吃午餐或者甚至回家一天,会发生什么情况?应用程序将只好无奈停机。在事务期间执行 I/O 是导致灾难的秘诀。
规则 2:在事务期间不要等待用户输入。
将相关的操作归在一起
由于每个事务都有不小的开销,您可能认为最好是在单个事务中执行尽可能多的操作以使每个操作的开销达到最小。但规则 1 告诉我们长事务对可伸缩性不利。那么如何实现最小化每个操作的开销和可伸缩性之间的平衡呢?
我们把规则 1 设置为逻辑上的极端 D 每个事务一个操作 D 这样不仅会导致额外开销,还会危及应用程序状态的一致性。假定事务性资源管理器维护应用程序状态的一致性(请回忆一下第 1 部分,其中“ACID”的“C”代表“一致性”(Consistency)),但它们依赖应用程序来定义一致性的意思。实际上,我们在描述事务时使用的一致性的定义有点圆滑:应用程序说一致性是什么意思它就是什么意思。应用程序把几组应用程序状态的变化组织到几个事务中,结果应用程序的状态就成了 定义上的(by definition)一致。然后资源管理器确保如果它必须从故障恢复的话,就把应用程序状态恢复到最近的一致状态。
在第 1 部分中,我们给出了一个在银行应用程序中将资金从一个帐户转移到另一个帐户的示例。清单 1 展示了这个示例可能的 SQL 实现,它包含 5 个 SQL 操作(一个选择,两个更新和两个插入操作):
清单 1. 资金转移的样本 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 取了钱,作为执行第一次 SELECT(检查余额)和随后的记入借方 UPDATE 之间的一个单独事务的一部分,会发生什么情况?这样会违反我们认为这段代码会强制遵守的业务规则 D 帐户余额应该是非负的。如果在第一次 UPDATE 和第二次 UPDATE 之间系统失败会发生什么情况?现在,当系统恢复时,钱已经离开了帐户 A 但还没有记入帐户 B 的贷方,并且也无记录说明原因。这样,哪个帐户的所有者都不会开心。
清单 1 中的五个 SQL 操作是单个相关操作 D 将资金从一个帐户转移到另一个帐户 D 的一部分。因此,我们希望要么全部执行它们,要么一个也不执行,建议在单个事务中全部执行它们。
规则 3:将相关操作归到单个事务中。
理想化的平衡
规则 1 说事务应尽可能短小。清单 1 中的示例表明有时候我们必须把一些操作归到一个事务中来维护一致性。当然,它要依赖应用程序来确定“相关操作”是由什么组成的。我们可以把规则 1 和 3 结合在一起,提供一个描述事务范围的一般指导,我们规定它为规则 4:
规则 4:把相关操作归到单个事务中,但把不相关的操作放到单独的事务中。
容器管理的事务
在使用容器管理的事务时,不是显式声明事务的起点和终点,而是为每个 EJB 方法定义事务性需求。bean 的 assembly-descriptor 的 container-transaction 部分的 trans-attribute 元素中定义了事务模式。(清单 2 中显示了一个 assembly-descriptor 示例。)方法的事务模式以及状态 D 调用方法是否早已在事务中被征用 D 决定了当 EJB 方法被调用时容器应该进行下面几个操作中的哪一个:
征用现有事务中的方法。
创建一个新事务,并征用该事务中的方法。
不征用任何事务中的方法。
抛出一个异常。
清单 2. 样本 EJB 装配描述符
<assembly-descriptor> ... <container-transaction> <method> <ejb-name>MyBean</ejb-name> <method-name>*</method-name> </method> <trans-attribute>Required</trans-attribute> </container-transaction> <container-transaction> <method> <ejb-name>MyBean</ejb-name> <method-name>logError</method-name> </method> <trans-attribute>RequiresNew</trans-attribute> </container-transaction> ... </assembly-descriptor>
那么我们应该为自己的 bean 方法选择哪种模式呢?对于会话 bean 和消息驱动 bean,您通常想使用 Required 来确保每个调用都被作为事务的一部分执行,但仍将允许方法作为一个更大的事务的组件。请小心使用 RequiresNew ;只有在确定自己的方法的行为应该与调用您的方法的行为分开提交时,才应该使用这种模式。 RequiresNew 一般情况下只和与系统中其它对象关系很少或没什么关系的对象(比如日志对象)一起使用。(把 RequiresNew 与日志对象一起使用比较有意义,因为您可能希望在不管外围事务是否提交的情况下提交日志消息。)
RequiresNew 使用不当会导致与上面的描述相似的情况,其中,清单 1 中的代码在五个分开的事务而不是一个事务中执行,这样会使应用程序处于不一致状态。
对于 CMP(容器管理的持久性,container-managed persistence)实体 bean,通常是希望使用 Required 。 Mandatory 也是一个合理的选项,特别是在最初开发时;这将会警告您实体 bean 方法在事务外被调用这种情况,这时可能会指出一个部署错误。您几乎从不希望把 RequiresNew 和 CMP 实体 bean 一起使用。 NotSupported 和 Never 旨在用于非事务性资源,比如 Java 事务 API(Java Transaction API,JTA)事务中无法征用的外部非事务性系统或事务性系统的适配器。
如果 EJB 应用程序设计得当,应用上面的事务模式指导往往会自然地产生规则 4 建议的事务划分。原因是 J2EE 体系架构鼓励把应用程序分解为最小的方便处理的块,并且每个块都作为一个单独的请求被处理( 不管是以 HTTP 请求的形式还是作为在 JMS 队列中排队的消息的结果)。
重温隔离
在第 1 部分中,我们定义了 隔离(isolation)的意思是:一个事务的影响对与该事务并发执行的其它事务是不可见的;从事务的角度来看,好象事务是连续执行而非并行执行。尽管事务性资源管理器经常可以同时处理许多事务并提供隔离的假象,但有时隔离限制实际上要求把新事务延迟到现有事务完成后才开始。由于完成一个事务至少包括一个同步磁盘 I/O(写到事务日志),这就会把每秒的事务数限制到接近每秒的写磁盘次数,这对可伸缩性不利。
实际上,通常是充分放松隔离需求以允许更多的事务并发执行并使系统响应能够得到改善,使可伸缩性变得更强。几乎所有的数据库都支持标准隔离级别:读未提交的(Read Uncommitted)、读已提交的(Read Committed)、可重复的读(Repeatable Read) 和可串行化的(Serializable)。
不幸的是,为容器管理的事务管理隔离目前是在 J2EE 规范的范围之外。但是,许多 J2EE 容器,比如 IBM WebSphere 和 BEA WebLogic,将提供特定于容器的扩展,这些扩展允许您以每方法(per-method)为基础设置事务隔离级别,设置方法与在装配描述符中设置事务模式的方法相同。对于 bean 管理的事务,您可以通过 JDBC 或者其它资源管理器连接设置隔离级别。
为阐明隔离级别之间的差异,我们首先把几个并发危险分类 D 这几种危险是当没有适当地隔离时一个事务可能会干涉另一个事务的情况。下列的所有这些危险都与这种情况( 第二个事务已经启动后第一个事务变得对第二个事务 可见)的结果有关:
脏读(Dirty Read):当一个事务的中间(未提交的)结果对另一个事务可见时就会发生这种情况。
不可重复的读(Unrepeatable Read):当一个事务读取一个数据项,然后重新读取这个数据项并看到不同的值时就是发生了这种情况。
虚读(Phantom Read):当一个事务执行返回多个行的查询,稍后再次执行同一个查询并看到第一次执行该查询没出现的额外行时就是发生了这种情况。
四个标准隔离级别与这三个隔离危险相关,如表 2 所示。最低的隔离级别“读未提交的”并不能保护事务不被其它事务更改,但它的速度最快,因为它不需要争夺读锁。最高的隔离级别“可串行化的”与上面给出的隔离的定义相当;每个事务好象都与其它事务的影响完全隔离。
表 2. 事务隔离级别
对于大多数数据库,缺省的隔离级别为“读已提交的”,这是个很好的缺省选择,因为它阻止事务在事务中的任何给定的点看到应用程序数据的不一致视图。“读已提交的”是一个很不错的隔离级别,用于大多数典型的短事务,比如获取报表数据或获取要显示给用户的数据的时候(多半是作为 Web 请求的结果),也用于将新数据插入到数据库的情况。
当您需要所有事务间有较高级别的一致性时,使用较高的隔离级别“可重复的读”和“可串行化的”比较合适,比如在清单 1 示例中,您希望从检查余额以确保有足够的资金到您实际取钱期间账户余额一直保持不变;这就要求至少要用“可重复的读”隔离级别。在数据一致性绝对重要的情况下,比如审核记帐数据库以确保一个帐户的所有借方金额和贷方金额的总数等于它目前的余额时,可能还需要防止创建新行。这种情况下就需要使用“可串行化的”隔离级别。
最低的隔离级别“读未提交的”很少使用。它适用于您只需要获得近似值,否则查询将导致您不希望的性能开销这种情况。当您想要估计一个变化很快的数量,如定单数或者今天所下定单的总金额(以美元为单位)时一般使用““读未提交的”。
因为隔离和可伸缩性之间实际是一种此消彼长的关系,所以您在为事务选择隔离级别时应该小心行事。选择太低的级别对数据比较危险。选择太高的级别可能对性能不利,尽管负载比较轻时可能不会这样。一般来说,数据一致性问题比性能问题更严重。如果拿不准,应该以小心为主,选择一个较高的隔离级别。这就引出了规则 5:
规则 5:使用保证数据安全 style="COLOR: #000000" href="http://safe.it168.com/" target=_blank>安全的最低隔离级别,但如果拿不准,请使用“可串行化的”。
即使您打算刚开始时以小心为主并希望结果性能可以接受 D(被称为“拒绝和祈祷(denial and prayer)”的性能管理技术 D 很可能是最常用的性能策略,尽管大多数开发者都不承认这一点),在开发组件时考虑隔离需求也是有利的。您应该努力编写能够容忍级别较低但实用的隔离级别的事务,这样,当稍后性能成为问题时,自己就不会陷入困境。因为您需要知道方法正在做什么以及这个方法中隐藏了什么一致性假设来正确设置隔离级别,那么在开发期间仔细说明并发需求和假设,以便在装配应用程序时帮助作出正确的决定也不失为一个好主意。