事务策略: 了解事务陷阱
在 Java 平台中实现事务时要注意的常见错误
原文: http://www.ibm.com/developerworks/cn/java/j-ts1.html
事务处理的目标应该是实现数据的高度完整性和一致性。本文是为 Java 平台开发有效事务策略 系列文章 的第一篇,介绍了一些妨碍您实现此目标的常见事务陷阱。本系列作者 Mark Richards 通过使用 Spring Framework 和企业 JavaBeans(Enterprise JavaBeans,EJB)3.0 规范中的代码示例解释了这些极其常见的错误。
在应用程序中使用事务常常是为了维护高度的数据完整性和一致性。如果不关心数据的质量,就不必使用事务。毕竟,Java 平台中的事务支持会降低性能,引发锁定问题和数据库并发性问题,而且会增加应用程序的复杂性。
|
|
但是不关心事务的开发人员就会遇到麻烦。几乎所有与业务相关的应用程序都需要高度的数据质量。金融投资行业在失败的交易上浪费数百亿美元,不好的数据是导致这种结果的第二大因素(请参阅 参考资料)。尽然缺少事务支持只是导致坏数据的一个因素(但是是主要的因素),但是完全可以这样认为,在金融投资行业浪费掉数十亿美元是由于缺少事务支持或事务支持不充分。
忽略事务支持是导致问题的另一个原因。我常常听到 “我们的应用程序中不需要事务支持,因为这些应用程序从来不会失败” 之类的说法。是的,我知道有些应用程序极少或从来不会抛出异常。这些应用程序基于编写良好的代码、编写良好的验证例程,并经过了充分的测试,有代码覆盖支持,可以避免性能损耗和与事务处理有关的复杂性。这种类型的应用程序只需考虑事务支持的一个特性:原子性。原子性确保所有更新被当作一个单独的单元,要么全部提交,要么回滚。但是回滚或同时更新不是事务支持的惟一方面。另一方面,隔离性 将确保某一工作单元独立于其他工作单元。没有适当的事务隔离性,其他工作单元就可以访问某一活动工作单元所做的更新,即使该工作单元还未完成。这样,就会基于部分数据作出业务决策,而这会导致失败的交易或产生其他负面(或代价昂贵的)结果。
|
|
因此,考虑到坏数据的高成本和负面影响,以及事务的重要性(和必须性)这些基本常识,您需要使用事务处理并学习如何处理可能出现的问题。您在应用程序中添加事务支持后常常会出现很多问题。事务在 Java 平台中并不总是如预想的那样工作。本文会探讨其中的原因。我将借助代码示例,介绍一些我在该领域中不断看到的和经历的常见事务陷阱,大部分是在生产环境中。
虽然本文中的大多数代码示例使用的是 Spring Framework(version 2.5),但事务概念与 EJB 3.0 规范中的是相同的。在大多数情况下,用 EJB 3.0 规范中的 _cnnew1@TransactionAttribute 注释替换 Spring Framework @Transactional 注释即可。如果这两种框架使用了不同的概念和技术,我将同时给出 Spring Framework 和 EJB 3.0 源代码示例。
最好先从最简单的场景开始,即使用本地事务,一般也称为数据库事务。在数据库持久性的早期(例如 JDBC),我们一般会将事务处理委派给数据库。毕竟这是数据库应该做的。本地事务很适合执行单一插入、更新或删除语句的逻辑工作单元(LUW)。例如,考虑清单 1 中的简单 JDBC 代码,它向 TRADE 表插入一份股票交易订单:
清单 1. 使用 JDBC 的简单数据库插入
@Stateless public class TradingServiceImpl implements TradingService { @Resource SessionContext ctx; @Resource(mappedName="java:jdbc/tradingDS") DataSource ds;
public long insertTrade(TradeData trade) throws Exception { Connection dbConnection = ds.getConnection(); try { Statement sql = dbConnection.createStatement(); String stmt = "INSERT INTO TRADE (ACCT_ID, SIDE, SYMBOL, SHARES, PRICE, STATE)" + "VALUES (" + trade.getAcct() + "','" + trade.getAction() + "','" + trade.getSymbol() + "'," + trade.getShares() + "," + trade.getPrice() + ",'" + trade.getState() + "')"; sql.executeUpdate(stmt, Statement.RETURN_GENERATED_KEYS); ResultSet rs = sql.getGeneratedKeys(); if (rs.next()) { return rs.getBigDecimal(1).longValue(); } else { throw new Exception("Trade Order Insert Failed"); } } finally { if (dbConnection != null) dbConnection.close(); } } } |
清单 1 中的 JDBC 代码没有包含任何事务逻辑,它只是在数据库中保存 TRADE 表中的交易订单。在本例中,数据库处理事务逻辑。
在 LUW 中,这是一个不错的单个数据库维护操作。但是如果需要在向数据库插入交易订单的同时更新帐户余款呢?如清单 2 所示:
清单 2. 在同一方法中执行多次表更新
public TradeData placeTrade(TradeData trade) throws Exception { try { insertTrade(trade); updateAcct(trade); return trade; } catch (Exception up) { //log the error throw up; } } |
在本例中,insertTrade() 和 updateAcct() 方法使用不带事务的标准 JDBC 代码。insertTrade() 方法结束后,数据库保存(并提交了)交易订单。如果 updateAcct() 方法由于任意原因失败,交易订单仍然会在 placeTrade() 方法结束时保存在 TRADE 表内,这会导致数据库出现不一致的数据。如果 placeTrade() 方法使用了事务,这两个活动都会包含在一个 LUW 中,如果帐户更新失败,交易订单就会回滚。
随着 Java 持久性框架的不断普及,如 Hibernate、TopLink 和 Java 持久性 API(Java Persistence API,JPA),我们很少再会去编写简单的 JDBC 代码。更常见的情况是,我们使用更新的对象关系映射(ORM)框架来减轻工作,即用几个简单的方法调用替换所有麻烦的 JDBC 代码。例如,要插入 清单 1 中 JDBC 代码示例的交易订单,使用带有 JPA 的 Spring Framework,就可以将 TradeData 对象映射到 TRADE 表,并用清单 3 中的 JPA 代码替换所有 JDBC 代码:
清单 3. 使用 JPA 的简单插入
public class TradingServiceImpl { @PersistenceContext(unitName="trading") EntityManager em;
public long insertTrade(TradeData trade) throws Exception { em.persist(trade); return trade.getTradeId(); } } |
注意,清单 3 在 EntityManager 上调用了 persist() 方法来插入交易订单。很简单,是吧?其实不然。这段代码不会像预期那样向 TRADE 表插入交易订单,也不会抛出异常。它只是返回一个值 0 作为交易订单的键,而不会更改数据库。这是事务处理的主要陷阱之一:基于 ORM 的框架需要一个事务来触发对象缓存与数据库之间的同步。这通过一个事务提交完成,其中会生成 SQL 代码,数据库会执行需要的操作(即插入、更新、删除)。没有事务,就不会触发 ORM 去生成 SQL 代码和保存更改,因此只会终止方法 — 没有异常,没有更新。如果使用基于 ORM 的框架,就必须利用事务。您不再依赖数据库来管理连接和提交工作。
这些简单的示例应该清楚地说明,为了维护数据完整性和一致性,必须使用事务。不过对于在 Java 平台中实现事务的复杂性和陷阱而言,这些示例只是涉及了冰山一角。
|
|
Spring Framework @Transactional 注释陷阱
您将测试 清单 3 中的代码,发现 persist() 方法在没有事务的情况下不能工作。因此,您通过简单的网络搜索查看几个链接,发现如果使用 Spring Framework,就需要使用 @Transactional 注释。于是您在代码中添加该注释,如清单 4 所示:
清单 4. 使用 @Transactional 注释
public class TradingServiceImpl { @PersistenceContext(unitName="trading") EntityManager em;
@Transactional public long insertTrade(TradeData trade) throws Exception { em.persist(trade); return trade.getTradeId(); } } |
现在重新测试代码,您发现上述方法仍然不能工作。问题在于您必须告诉 Spring Framework,您正在对事务管理应用注释。除非您进行充分的单元测试,否则有时候很难发现这个陷阱。这通常只会导致开发人员在 Spring 配置文件中简单地添加事务逻辑,而不会使用注释。
要在 Spring 中使用 @Transactional 注释,必须在 Spring 配置文件中添加以下代码行:
<tx:annotation-driven transaction-manager="transactionManager"/> |
transaction-manager 属性保存一个对在 Spring 配置文件中定义的事务管理器 bean 的引用。这段代码告诉 Spring 在应用事务拦截器时使用 @Transaction 注释。如果没有它,就会忽略 @Transactional 注释,导致代码不会使用任何事务。
让基本的 @Transactional 注释在 清单 4 的代码中工作仅仅是开始。注意,清单 4 使用 @Transactional 注释时没有指定任何额外的注释参数。我发现许多开发人员在使用 @Transactional 注释时并没有花时间理解它的作用。例如,像我一样在清单 4 中单独使用 @Transactional 注释时,事务传播模式被设置成什么呢?只读标志被设置成什么呢?事务隔离级别的设置是怎样的?更重要的是,事务应何时回滚工作?理解如何使用这个注释对于确保在应用程序中获得合适的事务支持级别非常重要。回答我刚才提出的问题:在单独使用不带任何参数的 @Transactional 注释时,传播模式要设置为 REQUIRED,只读标志设置为 false,事务隔离级别设置为 READ_COMMITTED,而且事务不会针对受控异常(checked exception)回滚。
|
|
@Transactional 只读标志陷阱
我在工作中经常碰到的一个常见陷阱是 Spring @Transactional 注释中的只读标志没有得到恰当使用。这里有一个快速测试方法:在使用标准 JDBC 代码获得 Java 持久性时,如果只读标志设置为 true,传播模式设置为 SUPPORTS,清单 5 中的 @Transactional 注释的作用是什么呢?
清单 5. 将只读标志与 SUPPORTS 传播模式结合使用 — JDBC
@Transactional(readOnly = true, propagation=Propagation.SUPPORTS) public long insertTrade(TradeData trade) throws Exception { //JDBC Code... } |
当执行清单 5 中的 insertTrade() 方法时,猜一猜会得到下面哪一种结果:
A. 抛出一个只读连接异常
B. 正确插入交易订单并提交数据
C. 什么也不做,因为传播级别被设置为 SUPPORTS
是哪一个呢?正确答案是 B。交易订单会被正确地插入到数据库中,即使只读标志被设置为 true,且事务传播模式被设置为 SUPPORTS。但这是如何做到的呢?由于传播模式被设置为 SUPPORTS,所以不会启动任何事务,因此该方法有效地利用了一个本地(数据库)事务。只读标志只在事务启动时应用。在本例中,因为没有启动任何事务,所以只读标志被忽略。
如果是这样的话,清单 6 中的 @Transactional 注释在设置了只读标志且传播模式被设置为 REQUIRED 时,它的作用是什么呢?
清单 6. 将只读标志与 REQUIRED 传播模式结合使用 — JDBC
@Transactional(readOnly = true, propagation=Propagation.REQUIRED) public long insertTrade(TradeData trade) throws Exception { //JDBC code... } |
执行清单 6 中的 insertTrade() 方法会得到下面哪一种结果呢:
A. 抛出一个只读连接异常
B. 正确插入交易订单并提交数据
C. 什么也不做,因为只读标志被设置为 true
根据前面的解释,这个问题应该很好回答。正确的答案是 A。会抛出一个异常,表示您正在试图对一个只读连接执行更新。因为启动了一个事务(REQUIRED),所以连接被设置为只读。毫无疑问,在试图执行 SQL 语句时,您会得到一个异常,告诉您该连接是一个只读连接。
关于只读标志很奇怪的一点是:要使用它,必须启动一个事务。如果只是读取数据,需要事务吗?答案是根本不需要。启动一个事务来执行只读操作会增加处理线程的开销,并会导致数据库发生共享读取锁定(具体取决于使用的数据库类型和设置的隔离级别)。总的来说,在获取基于 JDBC 的 Java 持久性时,使用只读标志有点毫无意义,并会启动不必要的事务而增加额外的开销。
使用基于 ORM 的框架会怎样呢?按照上面的测试,如果在结合使用 JPA 和 Hibernate 时调用 insertTrade() 方法,清单 7 中的 @Transactional 注释会得到什么结果?
清单 7. 将只读标志与 REQUIRED 传播模式结合使用 — JPA
@Transactional(readOnly = true, propagation=Propagation.REQUIRED) public long insertTrade(TradeData trade) throws Exception { em.persist(trade); return trade.getTradeId(); } |
清单 7 中的 insertTrade() 方法会得到下面哪一种结果:
A. 抛出一个只读连接异常
B. 正确插入交易订单并提交数据
C. 什么也不做,因为 readOnly 标志被设置为 true
正确的答案是 B。交易订单会被准确无误地插入数据库中。请注意,上一示例表明,在使用 REQUIRED 传播模式时,会抛出一个只读连接异常。使用 JDBC 时是这样。使用基于 ORM 的框架时,只读标志只是对数据库的一个提示,并且一条基于 ORM 框架的指令(本例中是 Hibernate)将对象缓存的 flush 模式设置为 NEVER,表示在这个工作单元中,该对象缓存不应与数据库同步。不过,REQUIRED 传播模式会覆盖所有这些内容,允许事务启动并工作,就好像没有设置只读标志一样。
这令我想到了另一个我经常碰到的主要陷阱。阅读了前面的所有内容后,您认为如果只对 @Transactional 注释设置只读标志,清单 8 中的代码会得到什么结果呢?
清单 8. 使用只读标志 — JPA
@Transactional(readOnly = true) public TradeData getTrade(long tradeId) throws Exception { return em.find(TradeData.class, tradeId); } |
清单 8 中的 getTrade() 方法会执行以下哪一种操作?
A. 启动一个事务,获取交易订单,然后提交事务
B. 获取交易订单,但不启动事务
正确的答案是 A。一个事务会被启动并提交。不要忘了,@Transactional 注释的默认传播模式是 REQUIRED。这意味着事务会在不必要的情况下启动。根据使用的数据库,这会引起不必要的共享锁,可能会使数据库中出现死锁的情况。此外,启动和停止事务将消耗不必要的处理时间和资源。总的来说,在使用基于 ORM 的框架时,只读标志基本上毫无用处,在大多数情况下会被忽略。但如果您坚持使用它,请记得将传播模式设置为