再上一篇:8.2原子性
上一篇:8.3完整性约束和事务
主页
下一篇:8.6自治事务
再下一篇:8.7小结
文章列表

8.4不好的事务习惯

Oracle 9i 10g编程艺术:深入数据库体系结构

许多开发人员在事务方面都有一些不好的习惯。如果开发人员使用过另外某个数据库,其中只是“支 持”事务,而没有“提升”事务的使用,执行开发人员就常常有这样的一些坏习惯。例如,在Informix(默 认设置)、Sybase和SQL Server中,必须显式地BEGIN(开始)一个事务;否则,每条单个的语句本身就 是一个事务。Oracle在具体的语句外包了一个SAVEPOINT,采用类似的方式,那些数据库则在各条语句外 包了一个BEGIN WORK/COMMIT或ROLLBACK。这是因为,在这些数据库中,锁是稀有资源,另外读取器会阻 塞写入器,反之,写入器也会阻塞读取器。为了提高并发性,这些数据库希望你的事务越小越好,有时甚 至会以数据完整性为代价来做到这一点。
Oracle 则采用了完全不同的方法。事务总是隐式的,没有办法“自动提交”事务,除非应用专门实
交。事务的大小要根据需要而定。锁、阻塞等问题并不是决定事务大小的关键,数据完整性才是确定事务 大小 的根本。锁不是稀有资源,并发的数据读取器和数据写入器之间不存在竞争问题。这样在数据库中就 能有健壮的事务。这些事务不必很短,而要根据需求有足够长的 持续时间(但是不能不必要地太长)。事 务不是为了方便计算机及其软件,而是为了保护你的数据。

8.4.1在循环中提交

如果交给你一个任务,要求更新多行,大多数程序员都会力图找出一种过程性方法,通过循环来完成 这个任务,这样就能提交多行。据我听到的,这样做的两个主要原因是:
频繁地提交大量小事务比处理和提交一个大事务更快,也更高效。 没有足够的undo空间。
这 两个结论都存在误导性。另外,如果提交得太过频繁,很容易让你陷入危险,倘若更新做到一半 的时候失败了,这会使你的数据库处于一种“未知”的状态。要编写 一个过程从而在出现失败的情况下能 平滑地重启动,这需要复杂的逻辑。到目前为止,最好的方法是按业务过程的要求以适当的频度提交,并 且相应地设置undo段大小。
下面将更详细地分析这些问题。

1. 性能影响

如果频繁地提交,通常并不会更快。一般地,在一个 SQL语句中完成工作几乎总是更快一些。可以通 过一个小例子来说明,假设我们有一个表T,其中有大量的行,而且我们希望为该表中的每一行更新一个 列值。这里使用两个表T1和T2来进行演示:
ops$tkyte@ORA10G> create table t1 as select * from all_objects;
Table created.
ops$tkyte@ORA10G> exec dbms_stats.gather_table_stats( user, 'T1' ); PL/SQL procedure successfully completed.
ops$tkyte@ORA10G> create table t2 as select * from t1; Table created.

ops$tkyte@ORA10G> exec dbms_stats.gather_table_stats( user, 'T2' ); PL/SQL procedure successfully completed.
这样一来,更新时,只需简单地在一条UPDATE语句中完成,如下:
ops$tkyte@ORA10G> set timing on
ops$tkyte@ORA10G> update t1 set object_name = lower(object_name);
48306 rows updated.
Elapsed: 00:00:00.31

不过大多数人更喜欢像下面这样做(不管由于什么原因):
ops$tkyte@ORA10G> begin
2 for x in ( select rowid rid, object_name, rownum r
3 from t2 )
4 loop
5 update t2
6 set object_name = lower(x.object_name)
7 where rowid = x.rid;
8 if ( mod(x.r,100) = 0 ) then
9 commit;
10 end if;
11 end loop;
12 commit;
13 end;
14 /
PL/SQL procedure successfully completed. Elapsed: 00:00:05.38

对于这个小例子,倘若在循环中频繁地提交,就会慢上好几倍。如果能在一条SQL语句中完成,就要
尽量这么做,因为这样几乎总是更快。即使我们“优化”了过程性代码,也要使用批处理来完成更新,如

下:
ops$tkyte@ORA10G> declare
2 type ridArray is table of rowid;
3 type vcArray is table of t2.object_name%type;
4
5 l_rids ridArray;
6 l_names vcArray;
7
8 cursor c is select rowid, object_name from t2;
9 begin
10 open c;
11 loop
12 fetch c bulk collect into l_rids, l_names LIMIT 100;
13 forall i in 1 .. l_rids.count
14 update t2
15 set object_name = lower(l_names(i))
16 where rowid = l_rids(i);
17 commit;
18 exit when c%notfound;
19 end loop;
20 close c;
21 end;
22 /
PL/SQL procedure successfully completed.

Elapsed: 00:00:02.36
这确实要块一些,但是本来还可以更快的。不仅如此,你还应该注意到这段代码变得越来越复杂。从
极其简单的一条UPDATE语句,到过程性代码,再到更复杂的过程性代码,我们正沿着错误的反向越走越远 !
下面再对这个讨论做个补充,给出一个对应的例子。应该记得在第 7章中,我们讨论过写一致性的概 念,并介绍了 UPDATE语句如何导致重启动。如果要针对一个行子集(有一个WHERE 子句)执行先前的UPDATE 语句,而其他用户正在修改这个UPDATE在WHERE 子句中使用的列,就可能需要使用一系列较小的事务而不 是一个大事务,或者更适合在执行大量更新之前先锁定表。这样做的目标是减少出现重启动的机会。如果 要UPDATE表中的大量行,这会导致我们使用LOCK TABLE命 令。不过,根据我的经验,这种大量更新或大 量删除(只有这些语句才可能遭遇重启动)都是独立完成的。一次性的大量更新或清除旧数据通常不会在 活动高发期间 完成。实际上,数据的清除根本不应受此影响,因为我们一般会使用某个日期字段来定位要 清除的信息,而其他应用不会修改这个日期数据。

2. Snapshot Too Old 错误

下面来看开发人员喜欢在过程循环中提交更新的第二个原因,这是因为他们可能被误导,试图节俭地 使用“受限资源”(undo段)。这是一个配置问题;你需要确保有足够的undo空间来适当地确定事务的大 小。如果在循环中提交,一般会更慢,不仅如此,这也是导致让人胆战心惊的ORA-01555错误的最常见的 原因。下面将更详细地说明。
如果你阅读过第1章和第7章,就会知道,Oracle的多版本模型会使用undo段数据依照语句或事务 开始时的原样来重建块(究竟是语句还是事务,这取决于隔离模式)。如果必要的undo信息不再存在,你 就会收到一个ORA-01555:snapshot too old错误消息,查询也不会完成。所以,如果像前面的那个例子 一样,你一边在读取表,一边在修改这个表,就会同时生成查询所需的 undo信息。UPDATE生成了undo信 息,你的查询可能会利用这些undo信息来得到待更新数据的读一致视图。如果提交了所做的更新,就会允 许系统重用刚刚填写的undo段空间。如果系统确实重用了undo段空间,擦除了旧的 undo数据(查询随后 要用到这些undo信息),你就有大麻烦了。SELECT会失败,而UPDATE也会中途停止。这样就有了一个部 分完成的逻辑事务,而且可能没有什么好办法来重启动(对此稍后还会更多说明)。
下面通过一个小演示例子来看看这个概念具体是怎样的。我在一个很小的测试数据库中建立了一个
表:
ops$tkyte@ORA10G> create table t as select * from all_objects;
Table created.
ops$tkyte@ORA10G> create index t_idx on t(object_name); Index created.
ops$tkyte@ORA10G> exec dbms_stats.gather_table_stats( user, 'T', cascade=>true );
PL/SQL procedure successfully completed.

然后创建一个非常小的 undo 表空间,并修改系统,要求使用这个 undo 表空间。注意,通过将
AUTOEXTEND设置为off,已经把这个系统中全部UNDO空间的大小限制为2MB或更小:

ops$tkyte@ORA10G> create undo tablespace undo_small
2 datafile size 2m
3 autoextend off
4 /
Tablespace created.
ops$tkyte@ORA10G> alter system set undo_tablespace = undo_small; System altered.

现在只能用这个小undo表空间,我运行了以下代码块来完成UPDATE:
ops$tkyte@ORA10G> begin
2 for x in ( select /*+ INDEX(t t_idx) */ rowid rid, object_name, rownum r
3 from t
4 where object_name > ' ' )
5 loop
6 update t
7 set object_name = lower(x.object_name)
8 where rowid = x.rid;
9 if ( mod(x.r,100) = 0 ) then
10 commit;
11 end if;
12 end loop;
13 commit;
14 end;
15 /

begin
*
ERROR at line 1:
ORA-01555: snapshot too old: rollback segment number with name "" too small
ORA-06512: at line 2
我收到了这个错误。应该指出,这里向查询增加了一个索引提示以及一个 WHERE子句,以确保随机地
读取这个表(这两方面加在一起,就能使基于代价的优化器读取按索引键“排序”的表)。通过索引来处理 表时,往往会为某一行读取一个块,可是我们想要的下一行又在另一个块上。最终,我们确实会处理块1 上的所有行,只不过不是同时处理。假设块1 可能包含OBJECT_NAME以字母A、M、N、Q和Z开头的所有行 的数据。这样我们就会多次命中(读取)这个块,因为我们在读取按OBJECT_NAME排序的数据,而且可能 有很多行的OBJECT_NAME以A~M之间的字母开头。由于我们正在频繁地提交和重用undo空间,最终再次访 问一个块时,可能已经无法再回滚到查询开始的那个时间点,此时就会得到这个错误。
这是一个特意构造的例子,纯粹是为了说明如何以一种可靠的方式发生这个错误。UPDATE语句正在 生成undo信息。我只能用一个很小的undo表空间(大小为2MB)。这里多次在 undo段中回绕,因为undo 段要以一种循环方式使用。每次提交时,都允许Oracle覆盖前面生成的undo数据。最终,可能需要某些 已生成的undo数据,但是它已经不在了(即已经被覆盖),这样就会收到ORA-01555错误。
你可能会指出,在这种情况下,如果没有在上一个例子的第10行提交,就会收到以下错误:
begin
*
ERROR at line 1:
ORA-30036: unable to extend segment by 8 in undo tablespace 'UNDO_SMALL' ORA-06512: at line 6

你说的没错。这两个错误之间的主要区别在于:
报ORA-01555错误的例子会使更新处于一种完全未知的状态。有些工作已经做了,而有些还没有做。 如果在游标的FOR循环中提交,要想避免ORA-01555,我绝对是无计可施。
ORA-30036错误是可以避免的,只需在系统中分配适当的资源。通过设置正确的大小就可以避免这个 错误;但是第一个错误(ORA-01555)则不然。另外,即使我未能避免ORA-30036错误,至少更新会回滚, 数据库还是处于一种已知的一致状态,而不会半路停在某个大更新的中间。
这里的关键是,无法通过频繁提交来“节省”undo空间——你会需要这些undo信息。收到ORA-01555 错误时,我运行的是单用户系统。只需一个会话就能导致这个错误,在实际中,很多情况下甚至各个会话 都能导致自己的ORA-01555错误。开发人员和DBA需要协作来适当地确定这些段的大小,从而完成所需完 成的任务。这里没有捷径。你必须通过分析系统来发现最大的事务是什么,并适当地为之确定段大小。动 态性能视图V$UNDOSTAT对于监视所生成的undo数量可能非常有用,你可以用来监视运行时间最长的查询
的持续时间。许多人认为像临时段、undo和redo都是“开销”,应该分配尽可能小的存储空间。这与计算 机行业2000年1月1日遭遇的千年虫问题同出一辙,所有问题都只是因为想在日期字段中节省2个字节。 数据库的这些组件不是开销,而是系统的关键组件。必须适当地设置大小(不要太大,也不要太小)。

3. 可重启动的过程需要复杂的逻辑

如果采用“在逻辑事务结束之前提交”的方法,最验证的问题是:如果UPDATE半截失败了,这会经 常将你的数据库置于一种未知的状态中。除非你提取对此做了规划,否则很难重启动这个失败的过程,让 它从摔倒的地方再爬起来。例如,假设我们不是像上一个例子那样对列应用LOWER()函数,而是应用了以 下的列函数:
last_ddl_time = last_ddl_time + 1;

如果UPDATE循环半路停止了,怎么重启动呢?我们不能简单地重新运行,因为这样有可能导致某些

日期加2,而另外一些只加了1.如果我们再次失败,可能会对某些日期加 3,另外一些加 2,还有一些加1, 依此类推。我们还需要更复杂的逻辑,必须有办法对数据“分区”。例如,可以处理以 A 开头的每一个 OBJECT_NAME,然后是以B开头的,依此类推:
ops$tkyte@ORA10G> create table to_do
2 as
3 select distinct substr( object_name, 1,1 ) first_char
4 from T
5 /
Table created.
ops$tkyte@ORA10G> begin
2 for x in ( select * from to_do )
3 loop
4 update t set last_ddl_time = last_ddl_time+1
5 where object_name like x.first_char || '%';
6
7 dbms_output.put_line( sql%rowcount || ' rows updated' );
8 delete from to_do where first_char = x.first_char;

9
10 commit;
11 end loop;
12 end;
13 /
22257 rows updated
1167 rows updated
135 rows updated
1139 rows updated
2993 rows updated
691 rows updated
...
2810 rows updated
6 rows updated
10 rows updated
2849 rows updated
1 rows updated
2 rows updated
7 rows updated
PL/SQL procedure successfully completed.
现在,如果这个过程失败了,我们就能重启动,因为不会再处理已经得到成功处理的任何对象名。不
过,这种方法也是有问题的,除非有某个属性能均匀地划分数据。否则最终行的分布就会差异很大在这里, 第一个UPDATE比所有其他UPDATE加在一起完成的工作还多。另外,如果其他会话正在访问这个表,并且 在修改数据,则它们可能也会更新OBJECT_NAME字段。假设我们已经处理完A对象,此后另外某个会话把
LAST_DDL_TIME+1相比,这个过程效率非常低。我们可能使用索引来读取表中的每一行,或者我们要对它 做n次全扫描,不论哪种情况这个过程都不能让人满意。这种方法的缺点太多了。
最好的方法还是我在第1章刚开始时推荐的做法:力求简单。如果能在 SQL中完成,那就在SQL里完 成。如果不能在SQL中完成,就用PL/SQL实 现。要用尽可能少的代码来完成,另外应当分配充分的资源。 一定要考虑到万一出现错误会怎么样。有些人编写了更新循环,对测试数据做了大量工作,但是把这个 更 新循环应用到实际数据上时,却中途失败了,这种情况我见得实在太多了。此时他们确实很为难,因为不 知道处理是在哪里停止的。应当正确地设置undo段的大小,比起编写一个可重启动的程序来说,前者要容 易得多。如果你有非常大的表需要更新,就应该使用分区(有关的更多内容见第10章),这样就能单独地更 新各个分区。甚至可以使用并行DML来执行更新。

8.4.2使用自动提交

关于不好的事务习惯,最后要说的是由于使用流行的编程API(ODBC和JDBC)所带来的问题。这些 API会默认地”自动提交“(autocommit)。考虑以下语句,它把$1,000从一个支票账户转到一个储蓄账户:
update accounts set balance = balance - 1000 where account_id = 123;
update accounts set balance = balance + 1000 where account_id = 456;

如果提交这些语句时你的程序在使用JDBC,那么JDBC会(悄悄地)在每个UPDATE 之后插入一个提
交。如果在第一个UPDATE之后并在第二个UPDATE之前系统失败了,请考虑这个自动提交所带来的影响。 你会凭空失去$1,000!
我很清楚为什么ODBC会这样做。这是因为,ODBC是SQL Server的开发人员设计的,而SQL Server 数据库要求使用非常短的事务,这是有其并发模型造成的(SQL Server数据库的并发模型是:写会阻塞读 , 读会阻塞写,而且锁是稀有资源)。但我不能理解的是,这一点为什么会传承到JDBC,要知道这个API本 来是要支持“企业“的。在我看来,在JDBC中打开一个连接之后紧接着应该有下面这样几行代码:
connection conn = DriverManager.getConnection
("jdbc:oracle:oci:@database","scott","tiger");
conn.setAutoCommit (false);

这会把事务的控制权返回给你(开发人员),这才是合适的。这样,你就能安全地编写转账事务,并
在两个语句都成功之后再提交。在这种情况下,如果对API缺乏了解,结果将是致命的。我曾经见过很多 开发人员没有注意到这个自动提交“特性“,等到出现错误时应用就会遇到大麻烦。

8.5 分布式事务

Oracle 有很多很好的特性,其中之一就是能够透明地处理分布式事务。在一个事务的范围内,可以 更新多个不同数据库中的数据。提交时,要么提交所有实例中的更新,要么一个都不提交(它们都会回滚)。 为此,我不需要另外编写任何代码:只是“提交“就行了。
Oracle 中分布式事务的关键是数据库链接(database link)。数据库链接是一个数据库对象,描述 了如何从你的实例登录到另一个实例。不过,这一节的目的不是介绍数据库链接命令的语法(在文档中有

全面的说明),而是要展示这些数据库链接是存在的。一旦建立了一个数据库链接,访问远程对象就很简单 了,如下:
select * from T@another_database;
这会从数据库链接ANOTHER_DATABASE所定义数据库实例的表T中选择。一般地,你会创建表T的一
个视图(或一个同义词),来“隐藏“T是一个远程表的事实。例如,可以发出以下命令,然后就可以像访 问本地表一样地访问T了:
create synonym T for T@another_database;

既然建立了这个数据库链接,而且能读取一些表,还能够修改这些表(当然,假设有适当的权限)。
现在执行一个分布式事务与执行一个本地事务没有什么两样。我要做的只是:
update local_table set x = 5;
update remote_table@another_database set y = 10;
commit;

就这么简单。Oracle会完成所有数据库中的提交,或者都不提交。它使用了一个2PC协议来做到这
一点。2PC是一个分布式协议,如果一个修改影响到多个不同的数据库,2PC允许原子性地提交这个修改。 它会在提交之前尽可能地关闭分布式失败窗口。在多个数据库之间的一个 2PC事务中,其中一个数据库(通 常是客户最初登录的那个数据库)会成为分布式事务的协调器。这个站点会询问其他站点是否已经准备好 提交。实际上,这个站点会转向其他站点,问它们是否准备就绪。其他的每个站点会报告它的“就绪状态 “(YES或NO)。如果只要有一个站点投票NO,整个事务就会回滚。如果所有站点都投票YES,站点协调器 会广播一条消息,使每个站点上的提交成为永久性的。
2PC会限制可能出现的严重错误的窗口(时间窗)。在2PC上“投票“之前,任何分布式错误都会导 致所有这点回滚。对于事务的结果来说,这里不存在疑义。在提交或回滚之后,分布式事务的结果同样没 有疑义。只有一个非常短的时间窗除外,此时协调器要收集投票结果,只有在这个时候如果失败,结果可 能有疑义。
例如,假设有 3个站点参与一个事务,其中站点1是协调器。站点1问站点2是否准备好提交,站点
2报告说是。站点1再问站点3 是否准备好提交,站点3也说准备好了。在这个时间点,站点1就是惟一 知道事务结果的站点,它现在要负责把这个结果广播给其他站点。如果现在出现一个错误,比如说网络失 败了,站点1掉电,或者其他某个原因,站点2 和站点3就会“挂起“它们就会有所谓的可疑分布式事务
(in-doubt distributed transaction)。2PC协议力图尽可能地关闭这个错误窗口,但是无法完全将其关 闭。站点2和站点3必须保持事务打开,等待站点1发出的结果通知。如果还记得第 5章讨论的体系结构, 应该知道这个问题要由RECO进程来解决。有FORCE选项的COMMIT和ROLLBACK在这里就有了用武之地。如 果问题的原因是站点1、2和3之间的网络故障,站点2和站点3的DBA可以打电话给站点1的DBA,问他 结果是什么,并相应地手动应用提交或回滚。
对于分布式事务中能做的事情,还存在一些限制(不过并不多),这些限制是合理的(在我看来,它 们确实是合理的)。其中重要的限制如下:
不能在数据库链接上发出COMMIT。也就是说,不能发出COMMIT@remote_site。只能从发起 事务的那个站点提交。
不能在数据库链接上完成DDL。这是上一个问题带来的直接结果。DDL会提交,而除了发起 事务的站点外,你不能从任何其他站点提交,所以不能在数据库链接上完成DDL。
不能在数据库链接上发出SAVEPOINT。简单地说,不能在数据库链接发出任务事务控制语 句 。 所有事务控制都有最初打开数据库链接的会话继承得来;对于事务中的分布式实例,不能有不 同的事务控制。
尽管数据库链接上缺乏事务控制,但是这是合理的,因为只有发起事务的站点才有参与事务的所有站 点的一个列表。在我们的3站点配置中,如果站点2 试图提交,它无从知道站点3也参与了这个事务。在 Oracle中,只有站点1可以发出提交命令。此时,允许站点1把分布式事务控制的责任委托给另一个站点 。
我们可以设置站点的COMMIT_POINT_STRENGTH(这是一个参数),从而改变具体的提交站点。提交点 能力(commit-point strength) 会为分布式事务中的服务器关联一个相对的重要性级别。服务器越重要
(要求这个服务器上的数据有更大的可用性),它就越有可能协调这个分布式事务。如果需要 在生产主机 和测试主机之间完成一个分布式事务,你可能就希望这样做。由于事务协调器对于事务的结果绝对不会有 疑义,最好是由生产主机协调分布式事务。你并 不关心测试主机是否有一些打开的事务和锁定的资源。但 是如果生产系统上有这种情况,你肯定会很关心。
不能在数据库链接上执行DDL,实际上这并不太糟糕。首先,DDL很“少见“。只会在安装或升级期 间执行一次DDL。生产系统不会执行DDL(应该说,生产系统不应该执行DDL)。其次,要在数据库链接上 执行 DDL也是有办法的,只是要采用另一种方式。可以使用作业队列工具DBMS_JOB,或者在 Oracle 10g 中可以使用调度工具包DBMS_SCHEDULER。你不用试图在链接上执行DDL,而是可以使用链接来调度一个远 程作业,一旦提交就执行这个远程作业。采用这种方式,作业在远程主机上运行,这不是一个分布式事务, 可以执行DDL。实际上,Oracle Replication Services(远程服务)就采用这种方法执行分布式DDL来实 现模式复制。