JDBC事务优化
作者:Jack Shirazi
开发通过ACID测试的应用程序
事务使得开发人员的工作变得简单多了。通过在JDBC API和诸如Oracle9i的关系数据库中使用事务功能,在更新多用户应用程序时,你可以把数据遭破坏的可能性降到最低。然而,事务需要处理开销,与免费事务应用程序(更容易被破坏)相比较,它会降低系统的性能。那么,当使用事务时,什么才是保持性能的最好方法?
最佳的性能调优建议是避免做那些没必要做的事情。事务处理是数据库的大量工作,而且数据库默认地维护多种资源以确保事务具有ACID(原子性,一致性,隔离性和持续性)属性(查看"ACID Transaction Properties"工具栏获取详细信息)。这些数据库资源管理多个数据并发操作以及提交和回滚操作,从而保证ACID事务属性。如果你能减少数据库的此类操作,就将提高应用程序的性能。让我们看一些避免处理开销并提高事务性能的方法。
自动提交模式
最大限度减少事务开销的第一个方法是通过把多个操作移到一个单一事务中来合并事务。默认情况下,JDBC连接工作在自动提交模式下,这就意味着每个发送到数据库的操作都会作为独立事务自动执行。在这种情况下,每个Statement.execute()方法调用都如同由一条BEGIN TRANSACTION命令开始,并由一条COMMIT命令结束。
关闭自动提交模式并且明确定义事务需要进行大量额外的工作,因为你必须手动添加事务划分语句(COMMIT和ROLLBACK)。但是合并事务可以减少性能开销,特别是当你对你的系统进行伸缩时。(下面的"批量更新"部分会涉及到合并更新事务的技术细节。)在重负荷系统中,事务开销意义重大。开销越低,系统的可伸缩性就越好。
简单地使用Connection.setAutoCommit(false)命令来关闭自动提交模式。
JDBC API还提供了一个Connection.getAutoCommit()方法来返回当前的自动提交模式。当你关闭了自动提交模式,你将需要使用两个事务划分方法:Connection.commit()和Connection.rollback()。
当人工控制事务时,需要遵循以下几条原则:使事务尽可能保持简短,不要在一个事务中包含很多操作使它们变得非常冗长。(使事务打开并保持行开锁状态,会影响其他事务并降低可伸缩性。)然而,如果几项操作可以一项接一项地执行,那么就把它们合并到一个事务中。
合并操作可能需要在你的SQL语句中增加额外的条件逻辑,并且可能需要临时表。不管这个开销,合并事务会更加有效,因为数据库可以在一个步骤内获得所有需要的锁,并在一个步骤内释放它们。
当自动提交模式没有关闭时,它所引起的更多事务会产生更多的通信开销,更多的锁定和释放时间,以及与其他会话发生冲突的更大可能性。
批量更新
批量更新简单地说就是在一个事务和一个数据库调用中将多个DML语句(例如插入、更新和删除)发送到数据库。JDBC通过Statement.addBatch()和Statement.executeBatch()方法支持这项功能。批量更新的技巧相当简单,在下文中会加以说明。记住关闭自动提交模式(确保一个批处理作为一个事务执行),并且当你完成后这一切后,明确提交批事务。
清单 1
中的示例使用了普通的JDBC Statement对象。另外,JDBC API提供了一个PreparedStatement类,它也可以用参数表示SQL语句。
此外,当你使用PreparedStatement 对象来代替Statement对象时,Oracle的JDBC批处理实施就可以得到优化。在Oracle JDBC中,Statement对象不会在一次网络传输中传送所有成批的SQL语句。由于这个限制,当成批传送语句时可以使用PreparedStatement对象,因为PreparedStatement在一次批处理中会传送所有的语句。
清单 2
给出了使用参数语句和PreparedStatement对象的相同批处理技巧。
借助于所有批处理语句相同的查询计划,在PreparedStatement对象中利用参数语句使数据库进一步优化批处理。如果没有参数设定,语句就会各不相同,因而数据库就不能重复使用查询计划。
虽然这种方法经常可以提高性能,但应注意以下几点:处理开销与创建查询计划的联合将导致第一次执行SQL语句时会比使用普通Statement对象时运行得更慢,而随后准备好的执行语句将会快很多。(开发人员经常把首次PreparedStatement批处理移动到应用程序中对时间要求低的部分加以执行。)使用PreparedStatement对象将比使用Statement对象更有效,特别是当使用超过50条语句的大批量处理时。
以上的示例使用了JDBC规范所定义的标准批处理模式。Oracle的JDBC实施提供了一种可选择的批处理模式,它使用了一种被称作OraclePreparedStatement.setExecuteBatch(int)的新方法。在这种模式下,预设的语句被自动保存在客户端,直到语句的数量与setExecuteBatch(int)中的参数所定义的"批量值"相等。这样一来,积累的语句在一次传送中被发送到数据库。Oracle所推荐的这种模式在某些情况下会比标准的批处理模式更快。当使用它的时候,调整批量值来优化你的应用程序中事务的性能。Oracle模式惟一需要注意的一点是:它不是标准的--它使用官方JDBC规范所不支持的扩展功能。
事务隔离级别
事务被定义为全有或全无操作。一个事务的ACID属性确保每件事情都发生在一个事务,如同在事务期间在数据库中没有发生其他操作。由此可见,对数据库来说,确保ACID属性有很多工作要做。
JDBC Connection界面定义了五种事务隔离级别(在下面说明)。并不是所有的数据库都支持所有的级别。例如,Oracle9i只支持TRANSACTION_READ_COMMITTED和TRANSACTION_ SERIALIZABLE这两个级别。
许多数据库,例如Oracle9i,提供了其他事务级别支持。这些级别不提供"真正的"事务,因为它们不完全符合ACID属性。然而,它们通过可接受的事务功能提供更好的性能,因此它们对很多操作类型是非常有用的。
在JDBC中定义的级别包括:
TRANSACTION_NONE。正式地讲,TRANSACTION_NONE不是一个有效的事务级别。根据java.sql Connection API文件,这个级别表示事务是不被支持的,因此理论上说你不能使用TRANSACTION_NONE作为一个自变量赋给Connection.setTransactionIsolation()方法。事实上,虽然一些数据库实施了这个事务级别,但是Oracle9i却没有实施。
TRANSACTION_READ_UNCOMMITTED。这是最快的完全有效的事务级别。它允许你读取其他还没有被提交到数据库的并发事务做出的修改。这个API文件指出,脏读取(dirty reads)、不可重复读取(non-repeatable reads)和错误读取(phantom reads)都可以在这个事务级别发生(参阅"
一些非ACID事务问题
"部分)。这个级别意在支持ACID的"原子性(Atomic)"部分,在这个级别中,你的修改如果被提交,将被认为是同时发生的;如果被撤销,就被当作什么也没发生。Oracle9i不支持这个级别。
TRANSACTION_READ_UNCOMMITTED。这是最快的完全有效的事务级别。它允许你读取其他还没有被提交到数据库的并发事务做出的修改。这个API文件指出,脏读取(dirty reads)、不可重复读取(non-repeatable reads)和错误读取(phantom reads)都可以在这个事务级别发生(参阅"
一些非ACID事务问题
"部分)。
这个级别意在支持ACID的"原子性(Atomic)"部分,在这个级别中,你的修改如果被提交,将被认为是同时发生的;如果被撤销,就被当作什么也没发生。Oracle9i不支持这个级别。
TRANSACTION_READ_COMMITTED。这是继TRANSACTION_READ_UNCOMMITTED之后最快的完全有效的级别。在此级别中,你可以读取已经被提交到数据库中的其他并发事务所做出的修改。API文件指出,脏读取在这个级别中是被禁止的,但是不可重复读取和错误读取都可以发生。这个级别是Oracle9i默认的级别。
TRANSACTION_REPEATABLE_READ。这个级别比TRANSACTION_SERIALIZABLE快,但是比其他的事务级别要慢。读取操作可以重复进行,这意味着两次读取同样的域应该总是得到同样的值,除非事务本身改变了这个值。API文件指出,脏读取和不可重复读取在这个事务级别中是被禁止的,但是错误读取可以发生。
从技术上讲,数据库通过在被读取或写入的行上加锁来实施这个级别,并且保持锁定状态直到事务结束。这就防止了这些行被修改或删除,但是不能防止额外的行被添加--因此,就可能产生错误读取。Oracle9i不支持这个级别。
TRANSACTION_SERIALIZABLE。这是最慢的事务级别,但是它完全与ACID兼容。"单词可串行化(serializable)"指的就是ACID兼容,其中你的事务被认为在整体上已经发生,就如同其他所有已提交的事务在这个事务之前或之后全部发生。换句话说,事务被串行执行。
脏读取、不可重复读取和错误读取在TRANSACTION_SERIALIZABLE级别是全部被禁止的。从技术上讲,数据库通过锁定在事务中使用的表来实施这个级别。Oracle9i支持这个级别(正如每个与符合ACID的数据库那样)。
开发通过ACID测试的应用程序
选择正确的级别
你可以通过使用Connection.setTransactionIsolation()方法设定一个连接的事务级别。类似地,你可以通过使用Connection.getTransactionIsolation()方法获得连接的当前事务级别。你可以通过使用DatabaseMetaData.supportsTransaction IsolationLevel()方法确定由你的数据库驱动程序所支持的事务级别,如
清单3
所示。
事务级别越高,数量越多、限制性更强的锁就会被运用到数据库记录或者表中。同时,更多的锁被运用到数据库和它们的覆盖面越宽,任意两个事务冲突的可能性就越大。
如果有一个冲突(例如两个事务试图获取同一个锁),第一个事务必将会成功,然而第二个事务将被阻止直到第一个事务释放该锁(或者是尝试获取该锁的行为超时导致操作失败)。
更多的冲突发生时,事务的执行速度将会变慢,因为它们将花费更多的时间用于解决冲突(等待锁被释放)。
最大限度地增加应用程序的可伸缩性需要平衡地理解事务执行方法。一方面,你可以通过将在事务中所执行的操作数量减到最少来优化应用程序,从而减少了单个事务所花费的时间。但是这样就增加了事务的总数量,这可能增加冲突的风险。使用批量操作,你可以最大限度地减少所执行事务的数量。
然而,这增加了单个事务的长度,也可能增加冲突的风险。在任意一种情况下,当你降低事务隔离级别时,事务使用的锁就越少,因此越不会引起性能的下降。这样做的风险是因为没有使用完全符合ACID的事务,从而损失了功能性。
如果你需要把事务执行时间减到最少的话,在你的整个应用程序中使用一个事务级别好像并不是很理想。在应用程序中查找读取查询,对于每个查询,考虑在下面"
一些非ACID事务问题
"部分列出的任何问题是否会对给定数据的查询或数据更新模式产生负面影响。读取静态表,或者只被读取它们的同一事务所更新的表,可以安全地使用最低的事务级别。在那些不可能进行并发更新的事务中,你可以安全、高效地使用诸如TRANSACTION_ READ_COMMITTED这样的级别。
一些非ACID事务问题
当一个连接使用了不完全符合ACID的TRANSATION_SERIALIZABLE事务级别时,就会发生很多问题。下面的例子使用了一个名为table_sizes的表,它有两个字段,tablename和tablesize。这个例子还使用了两个事务,T1和T2,其中T1使用TRANSACTION_SERIALIZABLE级别。
脏读取。当一个事务能发现一行中有未提交的更改时,就发生了一次脏读取。如果另一个事务改变了一个值,你的事务可以读取那个改变的值,但其他的事务将回滚其事务,使这个值无效或成为脏值。例如,这里给出了当T2使用事务级别TRANSACTION_ READ_UNCOMMITTED时发生的情况,其中记录为tablename=users,tablesize=11。
1.
T1和T2启动它们的事务。
2. T1将记录更新为tablename=users,tablesize=12。
3. T2读取T1未提交的修改,读取记录tablename=user,tablesize=12,因为T2的事务级别意味着未提交的修改有时可以被读取。
4. T1回滚事务,因此tablename=users行中tablesize=11。
5. T2仍有无效的记录值:记录tablename=users,tablesize=12。但是它可以通过脏表尺寸值工作,并且能在脏值的基础上成功地提交修改。
不可重复读取。当事务内一条记录在事务被两次读取而没有更新时,就发生了不可重复读取,而且,从两次读取中可以看到不同的结果。如果你的事务读取一个值,并且另一事务提交了对这个值的一次修改(或删除了这条记录),然后你的事务便可以读取这个修改后的值(或发现这条记录丢失),尽管你的事务还没有提交或回滚。例如,这里给出了当T2使用事务级别TRANSACTION_READ_COMMITTED时发生的情况,其中记录为tablename=users,tablesize=11。
1.
T1和T2启动它们的事务。
2. T2读取记录tablename=users,tablesize=11。
3. T1将记录更新为tablename=users,tablesize=12并提交修改。
4. T2重新读取记录tablename=users,并且现在看到tablesize=12,因为T2的事务级别意味着其他事务提交的修改可以被看到,尽管T2还没有提交或回滚。
错误读取。当一个事务读取由另一个未提交的事务插入的一行时,就发生了错误读取。如果另一事务将一行插入到一个表里,当你的事务查询那个表时就能够读取那条新记录,即使其他的事务相继回滚。例如,这里给出了当T2使用事务级别TRANSACTION_REPEATABLE_READ时发生的情况,其中记录为tablename=users,tablesize=11。
1.
T1和T2启动它们的事务。
2. T2执行SELECT * FROM table_sizes WHERE tablesize>10并读取一行,tablesize=11的行tablename=user。
3. T1插入tablename=groups,tablesize=28的记录。
4. T2再次执行SELECT * FROM table_sizes WHERE tablesize>10并读取两条记录: tablename=users,tablesize=11和tablename=groups,tablesize=28。
5. T1回滚事务,因此记录tablename=goups,tablesize=28不再存在于table_sizes表中。
根据T2被读取的数据集中所具有的一条额外错误记录,T2可以成功地提交数据。
在
表1
中,你会发现在每一个所允许的事件隔离级别中发生事务问题的可能性列表。
用户控制的事务
在许多应用程序中,用户在一个事务结束以前,必须执行一个明确的动作(比如点击"确定"或"取消")。这些情况会导致很多问题。例如,如果用户忘记了终止活动或者让活动保持未完成的状态,资源就一直在应用程序和数据库中保持开放状态,这可能会在并发的活动和锁定的资源之间产生冲突,降低了系统的性能。只有单一的用户应用程序或用户不共享资源的应用程序,才不受这个问题的影响。
让用户处于一个JDBC事务控制下的主要解决方法是使用优化的事务。这些事务为外部的JDBC事务更新收集信息,然后使用一种机制来检查更新没有与其他任一个可能在两者间已经被处理的更新发生冲突。
检查优化冲突的机制包括使用时间标记或更改计数器,从期望状态检查区别。例如,当应用程序从用户输入收集用于更新的数据时,数据可以作为一批包含时间标记的安全机制的SQL语句,被发送到数据库,以确保数据库中的原始数据与最初用于客户应用程序的数据相同。一个成功的事务更新记录,包括时间标记,显示了最近修改的数据。如果来自另一用户的更新使第一个用户的修改失效,那么时间标记就会改变,并且当前事务将需要被回滚而不是被提交。对于许多应用程序,中等程度的冲突事务是很少的,因此事务经常会成功完成。
当一个事务失败时,应用程序把所输入的数据提交给用户,使用户能够根据导致冲突的更改,做出必要的修改并且重新提交。
其他方面的考虑
将来,使用JDBC3.0特性的开发人员将在更多的方法来优化事务。例如,Oracle9i第2版实施了几个JDBC3.0特性,包括事务存储点(Savepoint)。存储点让你在一个事务中标记一个点并且回滚它,而不是回滚整个事务。
虽然这听起来有些难以置信,但存储点确实能够极大地减少性能开销。它们对性能的实际影响将证明JDBC3.0会得到更广泛的支持,但不要过多使用存储点或者避免在任何关键性能代码部分将它们放在一起。如果你确实要使用它们,就必须确保尽可能地使用Connection.release Savepoint(Savepoint)方法释放它们的资源。
当前,JDBC2.0支持跨越多个连接的分布式事务,并且Oracle提供了一个为分布式事务设计的符合业界XA规范的Java Transaction API(JTA)模块。为分布式事务实施一个外部事务管理器的决定并不是一件小事情,然而,分布式事务比普通事务明显要慢,因为它需要额外的通讯工作来协调多个数据库资源之间的连接。从纯粹的性能观点来看,最好是在转移到分布式事务体系结构以前考虑多种可选择的设计方案。
Jack Shirazi (jack@JavaPerformanceTuning.com)是 JavaPerformanceTuning.com
(一个关于Java应用程序性能调优的所有方面信息的首家资源站点)的主管,也是《Java Performance Tuning》(O'Reilly and Associates出版)一书的作者。