Oracle 9i & 10g编程艺术-深入数据库体系结构——第6章:锁

第6章                     

开发多用户、数据库驱动的应用时,最大的难点之一是:一方面要力争取得最大限度的并发访问,与此同时还要确保每个用户能以一致的方式读取和修改数据。为此就有了锁定(locking)机制,这也是所有数据库都具有的一个关键特性,Oracle在这方面更是技高一筹。不过,Oracle的这些特性的实现是Oracle所特有的,就像SQL Server的实现只是SQL Server特有的一样,应用执行数据处理时,要正确地使用这些机制,而这一点要由你(应用的开发人员)来保证。如果你做不到,你的应用可能会表现得出人意料,而且不可避免地会危及数据的完整性(见第1章的说明)。

在这一章中,我们将详细介绍Oracle如何对数据(例如,表中的行)和共享数据结构(如SGA中的内存结构)锁定。这里会分析Oracle以怎样的粒度锁定数据,并指出这对你来说意味着什么。在适当的时候,我会把Oracle的锁定机制与其他流行的锁实现(即其他数据库中的锁定机制)进行对照比较,主要是为了消除关于行级锁的一个“神话”:人们认为行级锁总会增加开销;而实际上,在不同的实现中情况有所不同,只有当实现本身会增加开销时,行级锁才会增加开销。在下一章中,我们还会继续讨论这个内容,进一步研究Oracle的多版本技术,并说明锁定策略与多版本技术有什么关系。

6.1   什么是锁?

锁(lock)机制用于管理对共享资源的并发访问。注意,我说的是“共享资源”而不是“数据库行”。Oracle会在行级对表数据锁定,这固然不错,不过Oracle也会在其他多个级别上使用锁,从而对多种不同的资源提供并发访问。例如,执行一个存储过程时,过程本身会以某种模式锁定,以允许其他用户执行这个过程,但是不允许另外的用户以任何方式修改这个过程。数据库中使用锁是为了支持对共享资源进行并发访问,与此同时还能提供数据完整性和一致性。

在单用户数据库中,并不需要锁。根据定义,只有一个用户修改信息。不过,如果有多个用户访问和修改数据或数据结构,就要有一种机制来防止对同一份信息的并发修改,这一点至关重要。这正是锁定所要做的全部工作。

需要了解的重要一点是:有多少种数据库,其中就可能有多少种实现锁定的方法。你可能对某个特定的关系数据库管理系统(relational database management systemRDBMS)的锁定模型有一定的经验,但凭此并不意味着你通晓锁定的一切。例如,在我“投身”Oracle之前,曾经使用过许多其他的数据库,如SybaseMicrosoft SQL ServerInformix。这3个数据库都为并发控制提供了锁定机制,但是每个数据库中实现锁定的方式都大相径庭。为了说明这一点,下面简要地概括一下我的“行进路线”,告诉你我是怎样从SQL Server开发人员发展为Informix用户,最后又怎样成为Oracle开发人员。那是好多年前的事情了,可能有些SQL Server支持者会说:“但是我们现在也有行级锁了!”没错, SQL Server现在确实可以使用行级锁,但是其实现方式与Oracle中的实现方式完全不同。它们就像是苹果和桔子,是截然不同的两个物体,这正是关键所在。

作为SQL Server程序员,我很少考虑多个用户并发地向表中插入数据的可能性。在SQL Server数据库中,这种情况极少发生。那时,SQL Server只提供页级锁,对于非聚簇表,由于所有数据都会插入到表的最后一页,所以两个用户并发插入的情况根本不可能发生。

注意    从某种程度上讲,SQL Server聚簇表(有一个聚簇索引的表)与Oracle聚簇有点相似,但二者存在很大的差别。SQL Server以前只支持页(块)级锁,如果所插入的每一行都会插入到表的“末尾”,那么这个数据库中绝对不会有并发插入和并发事务。利用SQL Server中的聚簇索引,就能按聚簇键的顺序在整个表中插入行(而不是只在表的末尾插入),这就能改善SQL Server数据库的并发性。

并发更新也存在同样的问题(因为UPDATE实际上就是DELETE再加上一个INSERT)。可能正是由于这个原因,默认情况下,SQL Server每执行一条语句后就会立即提交或回滚。这样做的目的是为了得到更大的并发性,但是会破坏事务完整性。

因此,大多数情况下,如果采用页级锁,多个用户就不能同时修改同一个表。另外,如果正在修改一个表,这会有效地阻塞对这个表的多个查询。如果我想查询一个表,而且所需的页被一个更新锁住了,那我就必须等待(等待,再等待……)。这种锁定机制太糟糕了,要想支持耗时超过1秒的事务,结果可能是致命的;倘若真的这样做了,整个数据库看上去可能就像是“冻住”了一样。我从这里学到了很多坏习惯。我认识到:事务很“不好”,应该尽快地提交,而且永远不要持有数据的锁。并发性要以一致性为代价。要么保证正确,要么保证速度,在我看来,鱼和熊掌不可兼得。

等我转而使用Informix之后,情况好了一些,但也不是太好。只要创建表时记得启用行级锁,就能允许两个人同时向这个表中插入数据。遗憾的是,这种并发性的代价很高。Informix实现中的行级锁开销很大,不论从时间上讲还是从内存上讲都是如此。它要花时间获得和“不要”(或释放)这些行级锁,而且每个锁都要占用实际内存。另外,在启动数据库之前,必须计算系统可用的锁的总数。如果超过这个数,那你可就要倒霉了。由于这些原因,大多数表都采用页级锁创建,而且与SQL Server一样,Informix中的行级锁和页级锁都会阻塞查询。所以,我再一次发现需要尽快地提交。在SQL Server中学到的坏习惯在此得到了巩固,而且,我还学会了一条:要把锁当成一种很稀有的资源,一种可望而难求的事物。我了解到,应该手动地将行级锁升级为表级锁,从而尽量避免需要太多的锁而导致系统崩溃,我就曾经因此多次使系统崩溃。

等开始使用Oracle时,我没有费心去读手册,看看这个特定的数据库中锁定是怎么工作的。毕竟,我用数据库已经不是一年半载了,而且也算得上是这个领域的专家(除了SybaseSQL ServerInformix,我还用过IngresDB2Gupta SQLBase和许多其他的数据库)。我落入了过于自信的陷阱,自以为知道事情应该怎么做,所以想当然地认为事情肯定就会这样做。这一次我可大错特错了。

直到一次基准测试时,我才认识到犯了多大的错误。在这些数据库的早期阶段(大约1992/1993年),开发商常常对确实很大的数据库产品进行“基准测试”,想看看哪一个数据库能最快、最容易地完成工作,而且能提供最丰富的特性。

这个基准测试在InformixSybaseSQL ServerOracle之间进行。最先测试的是Oracle。他们的技术人员来到现场,读过基准测试规范后,他们开始进行设置。我首先注意到的是, Oracle技术人员只使用一个数据库表来记录他们的计时信息,尽管我们要建立数十条连接来执行测试,而且每条连接都需要频繁地向这个日志表中插入和更新数据。不仅如此,他们还打算在基准测试期间读这个日志表!出于好心,我把一位Oracle技术人员叫到一边,问他这样做是不是疯了,为什么还要故意往系统里引入竞争呢?基准测试进程难道不是对这个表串行地执行操作吗?别人正在对表做大量的修改,而此时他们还要读这个表,基准测试不会因此阻塞吗?为什么他们想引入所有这些额外的锁,要知道这些锁都需要他们来管理呀!我有一大堆“为什么你会那么想?”之类的问题。那时,我认为Oracle的技术人员有点傻。也就是说,直到我摆脱SQL ServerInformix的阴影,显示了让两个人同时插入一个表会有什么结果时;或者有人试图查询一个表,而其他人正在向这个表中插入行,此时会有什么结果(查询将每秒返回0行),我的观念才有所转变。Oracle的做法与几乎所有其他数据库的做法有显著的差别,简直是天壤之别。

不用说,无论是Informix还是SQL Server技术人员,都对这种数据库日志表方法不太热心。他们更倾向于把计时信息记录到操作系统上的平面文件中。Oracle人员对于如何胜出SQL ServerInformix很有自己的一套:他们只是问测试人员:“如果数据已经锁定,你当前的数据库每秒返回多少行?”并以此为出发点“展开攻势”。

从这个故事得到的教训是两方面的。首先,所有数据库本质上都不同。其次,为一个新的数据库平台设计应用时,对于数据库如何工作不能做任何假设。学习每一个新数据库时,应该假设自己从未使用过数据库。在一个数据库中能做的事情在另一个数据库中可能没有必要做,或者根本不能做。

Oracle中,你会了解到:

q         事务是每个数据库的核心,它们是“好东西”。

q         应该延迟到适当的时刻才提交。不要太快提交,以避免对系统带来压力。这是因为,如果事务很长或很大,一般不会对系统有压力。相应的原则是:在必要时才提交,但是此前不要提交。事务的大小只应该根据业务逻辑来定。

q         只要需要,就应该尽可能长时间地保持对数据所加的锁。这些锁是你能利用的工具,而不是让你退避三舍的东西。锁不是稀有资源。恰恰相反,只要需要,你就应该长期地保持数据上的锁。锁可能并不稀少,而且它们可以防止其他会话修改信息。

q         Oracle中,行级锁没有相关的开销,根本没有。不论你是有1个行锁,还是1 000 000个行锁,专用于锁定这个信息的“资源”数都是一样的。当然,与修改1行相比,修改1 000 000行要做的工作肯定多得多,但是对1 000 000行锁定所需的资源数与对1行锁定所需的资源数完全相同,这是一个固定的常量。

q         不要以为锁升级“对系统更好”(例如,使用表锁而不是行锁)。在Oracle中,锁升级(lock escalate)对系统没有任何好处,不会节省任何资源。也许有时会使用表锁,如批处理中,此时你很清楚会更新整个表,而且不希望其他会话锁定表中的行。但是使用表锁绝对不是为了避免分配行锁,想以此来方便系统。

q         可以同时得到并发性和一致性。每次你都能快速而准确地得到数据。数据读取器不会被数据写入器阻塞。数据写入器也不会被数据读取器阻塞。这是Oracle与大多数其他关系数据库之间的根本区别之一。

接下来在这一章和下一章的介绍中,我还会强调这几点。

6.2   锁定问题

讨论Oracle使用的各种类型的锁之前,先了解一些锁定问题会很有好处,其中很多问题都是因为应用设计不当,没有正确地使用(或者根本没有使用)数据库锁定机制产生的。

6.2.1             丢失更新

丢失更新(lost update)是一个经典的数据库问题。实际上,所有多用户计算机环境都存在这个问题。简单地说,出现下面的情况时(按以下所列的顺序),就会发生丢失更新:

(1) 会话Session1中的一个事务获取(查询)一行数据,放入本地内存,并显示给一个最终用户User1

(2) 会话Session2中的另一个事务也获取这一行,但是将数据显示给另一个最终用户User2

(3) User1使用应用修改了这一行,让应用更新数据库并提交。会话Session1的事务现在已经执行。

(4) User2也修改这一行,让应用更新数据库并提交。会话Session2的事务现在已经执行。

这个过程称为“丢失更新”,因为第(3)步所做的所有修改都会丢失。例如,请考虑一个员工更新屏幕,这里允许用户修改地址、工作电话号码等信息。应用本身非常简单:只有一个很小的搜索屏幕要生成一个员工列表,然后可以搜索各位员工的详细信息。这应该只是小菜一碟。所以,编写应用程序时没有考虑锁定,只是简单的SELECTUPDATE命令。

然后最终用户(User1)转向详细信息屏幕,在屏幕上修改一个地址,单击Save(保存)按钮,得到提示信息称更新成功。还不错,但是等到User1第二天要发出一个税表时,再来检查记录,会发现所列的还是原先的地址。到底出了什么问题?很遗憾,发生这种情况太容易了。在这种情况下,User1查询记录后,紧接着另一位最终用户(User2)也查询了同一条记录;也就是说,在User1读取数据之后,但在她修改数据之前,User2也读取了这个数据。然后,在User2查询数据之后,User1执行了更新,接到成功信息,甚至还可能再次查询看看是否已经修改。不过,接下来User2更新了工作电话号码字段,并单击Save(保存)按钮,完全不知道他已经用旧数据重写(覆盖)了User1对地址字段的修改!之所以会造成这种情况,这是因为应用开发人员编写的程序是这样的:更新一个特定的字段时,该记录的所有字段都会“刷新”(只是因为更新所有列更容易,这样就不用先得出哪些列已经修改,并且只更新那些修改过的列)。

可以注意到,要想发生这种情况,User1User2甚至不用同时处理记录。他们只要在大致同一时间处理这个记录就会造成丢失更新。

我发现,如果GUI程序员在数据库方面的培训很少(或者没有),编写数据库应用程序时就时常会冒出这个数据库问题。这些程序员了解了如何使用SELECTINSERTUPDATEDELETE等语句后,就着手开始编写应用程序。如果开发出来的应用程序有上述表现,就会让用户完全失去对它的信心,特别是这种现象只是随机地、零星地出现,而且在受控环境中完全不可再生(这就导致开发人员误以为是用户的错误)。

许多工具可以保护你避免这种情况,如Oracle FormsHTML DB,这些工具能确保:从查询记录的那个时刻开始,这个记录没有改变,而且对它执行任何修改时都会将其锁定,但是其他程序做不到这一点(如手写的Visual BasicJava程序)。为了保护你不丢失更新,这些工具在后台做了哪些工作呢?或者说开发人员必须自己做哪些工作呢?实际上就是要使用某种锁定策略,共有两种锁定策略:悲观锁定或乐观锁定。

6.2.2             悲观锁定

用户在屏幕上修改值之前,这个锁定方法就要起作用。例如,用户一旦有意对他选择的某个特定行(屏幕上可见)执行更新,如单击屏幕上的一个按钮,就会放上一个行锁。

悲观锁定(pessimistic locking)仅用于有状态(stateful)或有连接(connected)环境,也就是说,你的应用与数据库有一条连续的连接,而且至少在事务生存期中只有你一个人使用这条连接。这是20世纪90年代中期客户/服务器应用中的一种流行做法。每个应用都得到数据库的一条直接连接,这条连接只能由该应用实例使用。这种采用有状态方式的连接方法已经不太常见了(不过并没有完全消失),特别是随着20世纪90年代中后期应用服务器的出现,有状态连接更是少见。

假设你在使用一条有状态连接,应用可以查询数据而不做任何锁定:

scott@ORA 10G > select empno, ename, sal from emp where deptno = 10;

         EMPNO   ENAME   SAL

         ----------    ----------    ----------

         7782        CLARK    2450

         7839        KING        5000

         7934        MILLER   1300

最后,用户选择他想更新的一行。在这个例子中,假设用户选择更新MILLER行。在这个时间点上(即用户还没有在屏幕上做任何修改,但是行已经从数据库中读出一段时间了),应用会绑定用户选择的值,从而查询数据库,并确保数据尚未修改。在SQL*Plus中,为了模拟应用可能执行的绑定调用,可以发出以下命令:

scott@ORA 10G > variable empno number

scott@ORA 10G > variable ename varchar2(20)

scott@ORA 10G > variable sal number

scott@ORA 10G > exec :empno := 7934; :ename := 'MILLER'; :sal := 1300;

PL/SQL procedure successfully completed.

下面,除了简单地查询值并验证数据尚未修改外,我们要使用FOR UPDATE NOWAIT锁定这一行。应用要执行以下查询:

scott@ORA 10G > select empno, ename, sal

2 from emp

3 where empno = :empno

4          and ename = :ename

5          and sal = :sal

6 for update nowait

7 /

EMPNO   ENAME   SAL

----------    ----------    ----------

7934        MILLER   1300

根据屏幕上输入的数据,应用将提供绑定变量的值(在这里就是7934MILLER1300),然后重新从数据库查询这一行,这一次会锁定这一行,不允许其他会话更新;因此,这种方法称为悲观锁定(pessimistic locking)。在试图更新之前我们就把行锁住了,因为我们很悲观,对于这一行能不能保持未改变很是怀疑。

所有表都应该有一个主键(前面的SELECT最多会获取一个记录,因为它包括主键EMPNO),而且主键应该是不可变的(不应更新主键),从这句话可以得出三个结论:

q         如果底层数据没有改变,就会再次得到MILLER行,而且这一行会被锁定,不允许其他会话更新(但是允许其他会话读)。

q         如果另一个用户正在更新这一行,我们就会得到一个ORA-00054resource busyORA-00054:资源忙)错误。相应地,必须等待更新这一行的用户执行工作。

q         在选择数据和指定有意更新之间,如果有人已经修改了这一行,我们就会得到0行。这说明,屏幕上的数据是过时的。为了避免前面所述的丢失更新情况,应用需要重新查询(requery),并在允许在最终用户修改之前锁定数据。有了悲观锁定,User2试图更新电话号码字段时,应用现在会识别出地址字段已经修改,所以会重新查询数据。因此,User2不会用这个字段的旧数据覆盖User1的修改。

一旦成功地锁定了这一行,应用就会绑定新值,发出更新命令后,提交所做的修改:

scott@ORA 10G > update emp

2 set ename = :ename, sal = :sal

3 where empno = :empno;

1 row updated.

 

scott@ORA 10G > commit;

Commit complete.

现在可以非常安全地修改这一行。我们不可能覆盖其他人所做的修改,因为已经验证了在最初读出数据之后以及对数据锁定之前数据没有改变。

6.2.3             乐观锁定

第二种方法称为乐观锁定(optimistic locking),即把所有锁定都延迟到即将执行更新之前才做。换句话说,我们会修改屏幕上的信息而不要锁。我们很乐观,认为数据不会被其他用户修改;因此,会等到最后一刻才去看我们的想法对不对。

这种锁定方法在所有环境下都行得通,但是采用这种方法的话,执行更新的用户“失败”的可能性会加大。这说明,这个用户要更新他的数据行时,发现数据已经修改过,所以他必须从头再来。

可以在应用中同时保留旧值和新值,然后在更新数据时使用如下的更新语句,这是乐观锁定的一种流行实现:

Update table

         Set column1 = :new_column1, column2 = :new_column2, ....

Where primary_key = :primary_key

         And column1 = :old_column1

         And column2 = :old_column2

...

在此,我们乐观地认为数据没有修改。在这种情况下,如果更新语句更新了一行,那我们很幸运;这说明,在读数据和提交更新之间,数据没有改变。但是如果更新了零行,我们就会失败;另外一个人已经修改了数据,现在我们必须确定应用中下一步要做什么。是让最终用户查询这一行现在的新值,然后再重新开始事务呢(这可能会让用户很受打击,因为这一行有可能又被修改了)?还是应该根据业务规则解决更新冲突,试图合并两个更新的值(这需要大量的代码)?

实际上,前面的UPDATE能避免丢失更新,但是确实有可能被阻塞,在等待另一个会话执行对这一行的UPDATE时,它会挂起。如果所有应用(会话)都使用乐观锁定,那么使用直接的UPDATE一般没什么问题,因为执行更新并提交时,行只会被锁定很短的时间。不过,如果某些应用使用了悲观锁定,它会在一段相对较长的时间内持有行上的锁,你可能就会考虑使用SELECT FOR UPDATE NOWAIT,以此来验证行是否未被修改,并在即将UPDATE之前锁定来避免被另一个会话阻塞。

实现乐观并发控制的方法有很多种。我们已经讨论了这样的一种方法,即应用本身会存储行的所有“前”(before)映像。在后几节中,我们将介绍另外三种方法,分别是:

q         使用一个特殊的列,这个列由一个数据库触发器或应用程序代码维护,可以告诉我们记录的“版本”

q         使用一个校验和或散列值,这是使用原来的数据计算得出的

q         使用新增的Oracle 10g 特性ORA_ROWSCN

1.      使用版本列的乐观锁定

这是一个简单的实现,如果你想保护数据库表不出现丢失更新问题,应对每个要保护的表增加一列。这一列一般是NUMBERDATE/TIMESTAMP列,通常通过表上的一个行触发器来维护。每次修改行时,这个触发器要负责递增NUMBER列中的值,或者更新DATE/TIMESTAMP列。

如果应用要实现乐观并发控制,只需要保存这个附加列的值,而不需要保存其他列的所有“前”映像。应用只需验证请求更新那一刻,数据库中这一列的值与最初读出的值是否匹配。如果两个值相等,就说明这一行未被更新过。

下面使用SCOTT.DEPT表的一个副本来看看乐观锁定的实现。我们可以使用以下数据定义语言(Data Definition LanguageDDL)来创建这个表:

ops$tkyte@ORA 10G > create table dept

2          ( deptno number(2),

3          dname varchar2(14),

4          loc varchar2(13),

5        last_mod timestamp with time zone

6               default systimestamp

7               not null,

8          constraint dept_pk primary key(deptno)

9          )

10 /

Table created.

然后向这个表INSERT(插入)DEPT数据的一个副本:

ops$tkyte@ORA 10G > insert into dept( deptno, dname, loc )

2 select deptno, dname, loc

3 from scott.dept;

4 rows created.

 

ops$tkyte@ORA 10G > commit;

Commit complete.

以上代码会重建DEPT表,但是将有一个附加的LAST_MOD列,这个列使用TIMESTAMP WITH TIME ZONE数据类型(Oracle9i 及以上版本中才有这个数据类型)。我们将这个列定义为NOT NULL,以保证这个列必须填有数据,其默认值是当前的系统时间。

这个TIMESTAMP数据类型在Oracle中精度最高,通常可以精确到微秒(百万分之一秒)。如果应用要考虑到用户的思考时间,这种TIMESTAMP级的精度实在是绰绰有余,而且数据库获取一行后,人看到这一行,然后修改,再向数据库发回更新,一般不太可能在不到1秒钟的片刻时间内执行整个过程。两个人在同样短的时间内(不到1秒钟)读取和修改同一行的几率实在太小了。

接下来,需要一种方法来维护这个值。我们有两种选择:可以由应用维护这一列,更新记录时将LAST_MOD列的值设置为SYSTIMESTAMP;也可以由触发器/存储过程来维护。如果让应用维护LAST_MOD,这比基于触发器的方法表现更好,因为触发器会代表Oracle对修改增加额外的处理。不过这并不是说:无论什么情况,你都要依赖所有应用在表中经过修改的所有位置上一致地维护LAST_MOD。所以,如果要由各个应用负责维护这个字段,就需要一致地验证LAST_MOD列未被修改,并把LAST_MOD列设置为当前的SYSTIMESTAMP。例如,如果应用查询DEPTNO=10这一行:

ops$tkyte@ORA 10G > variable deptno number

ops$tkyte@ORA 10G > variable dname varchar2(14)

ops$tkyte@ORA 10G > variable loc varchar2(13)

ops$tkyte@ORA 10G > variable last_mod varchar2(50)

ops$tkyte@ORA 10G > begin

2          :deptno := 10;

3          select dname, loc, last_mod

4                   into :dname,:loc,:last_mod

5          from dept

6          where deptno = :deptno;

7 end;

8 /

PL/SQL procedure successfully completed.

目前我们看到的是:

ops$tkyte@ORA 10G > select :deptno dno, :dname dname, :loc loc, :last_mod lm

2 from dual;

DNO           DNAME                 LOC                    LM

----------      ----------                  --------                  -----------------------------------

10               ACCOUNTING    NEW YORK       25-APR-05 10.54.00 .493380 AM -04:00

再使用下面的更新语句来修改信息。最后一行执行了一个非常重要的检查,以确保时间戳没有改变,并使用内置函数TO_TIMESTAMP_TZ(TZTimeZone的缩写,即时区)将以上select(选择)得到的串转换为适当的数据类型。另外,如果发现行已经更新,以下更新语句中的第3行会把LAST_MOD列更新为当前时间:

ops$tkyte@ORA 10G > update dept

2          set dname = initcap(:dname),

3          last_mod = systimestamp

4 where deptno = :deptno

5          and last_mod = to_timestamp_tz(:last_mod);

1 row updated.

可以看到,这里更新了一行,也就是我们关心的那一行。在此按主键(DEPTNO)更新了这一行,并验证从最初读取记录到执行更新这段时间,LAST_MOD列未被其他会话修改。如果我们想尝试再更新这个记录,仍然使用同样的逻辑,不过没有获取新的LAST_MOD值,就会观察到以下情况:

ops$tkyte@ORA 10G > update dept

2          set dname = upper(:dname),

3          last_mod = systimestamp

4 where deptno = :deptno

5          and last_mod = to_timestamp_tz(:last_mod);

0 rows updated.

注意到这一次报告称“0 rows updated”(更新了0行),因为关于LAST_MOD的谓词条件不能满足。尽管DEPTNO 10还存在,但是想要执行更新的那个时刻的LAST_MOD值与查询行时的时间戳值不再匹配。所以,应用知道,既然未能修改行,就说明数据库中的数据已经(被别人)改变,现在它必须得出下一步要对此做什么。

不能总是依赖各个应用来维护这个字段,原因是多方面的。例如,这样会增加应用程序代码,而且只要是表中需要修改的地方,都必须重复这些代码,并正确地实现。在一个大型应用中,这样的地方可能很多。另外,将来开发的每个应用也必须遵循这些规则。应用程序代码中很可能会“遗漏”某一处,未能适当地使用这个字段。因此,如果应用程序代码本身不负责维护这个LAST_MOD字段,我相信应用也不应负责检查这个LAST_MOD字段(如果它确实能执行检查,当然也能执行更新!)。所以在这种情况下,我建议把更新逻辑封装到一个存储过程中,而不要让应用直接更新表。如果无法相信应用能维护这个字段的值,那么也无法相信它能正确地检查这个字段。存储过程可以取以上更新中使用的绑定变量作为输入,执行同样的更新。当检测到更新了0行时,存储过程会向客户返回一个异常,让客户知道更新实际上失败了。

还有一种实现是使用一个触发器来维护这个LAST_MOD字段,但是对于这么简单的工作,我建议还是避免使用触发器,而让DML来负责。触发器会引入大量开销,而且在这种情况下没有必要使用它们。

2.      使用校验和的乐观锁定

这与前面的版本列方法很相似,不过在此要使用基数据本身来计算一个“虚拟的”版本列。为了帮助解释有关校验和或散列函数的目标和概念,以下引用了Oracle 10g PL/SQL Supplied Packages Guide中的一段话(尽管现在还没有介绍如何使用Oracle提供的任何一个包!):

单向散列函数取一个变长输入串(即数据),并把它转换为一个定长的输出串(通常更小),这个输出称为散列值(hash value)。散列值充当输入数据的一个惟一标识符(就像指纹一样)。可以使用散列值来验证数据是否被修改。

需要注意,单向散列函数只能在一个方向上应用。从输入数据计算散列值很容易,但是要生成能散列为某个特定值的数据却很难。

散列值或校验和并非真正惟一。只能说,通过适当地设计,能使出现冲突的可能性相当小,也就是说,两个随机的串有相同校验和或散列值的可能性极小,足以忽略不计。

与使用版本列的做法一样,我们可以采用同样的方法使用这些散列值或校验和,只需把从数据库读出数据时得到的散列或校验和值与修改数据前得到的散列或校验和值进行比较。在我们读出数据之后,但是在修改数据之前,如果有人在这段时间内修改了这一行的值,散列值或校验和值往往会大不相同。

有很多方法来计算散列或校验和。这里列出其中的3种方法,分别在以下3个小节中介绍。所有这些方法都利用了Oracle提供的数据库包:

q         OWA_OPT_LOCK.CHECKSUM:这个方法在Oracle8i 8.1.5 及以上版本中提供。给定一个串,其中一个函数会返回一个16位的校验和。给定ROWID时,另一个函数会计算该行的16位校验和,而且同时将这一行锁定。出现冲突的可能性是65 536分之一(65 536个串中有一个冲突,这是假警报的最大几率)。

q         DBMS_OBFUSCATION_TOOLKIT.MD5:这个方法在 Oracle8i 8.1.7 及以上版本中提供。它会计算一个128位的消息摘要。冲突的可能性是3.4028E+38分之一(非常小)。

q         DBMS_CRYPTO.HASH:这个方法在Oracle 10g Release 1及以上版本中提供。它能计算一个SHA-1(安全散列算法1Secure Hash Algorithm 1)或MD4/MD5消息摘要。建议你使用SHA-1算法。

注意    很多编程语言中都提供了一些散列和校验和函数,所以还可以使用数据库之外的散列和校验和函数。

下面的例子显示了如何使用Oracle 10g 中的DBMS_CRYPTO内置包来计算这些散列/校验和。这个技术也适用于以上所列的另外两个包;逻辑上差别不大,但是调用的API可能不同。

下面在某个应用中查询并显示部门10的信息。查询信息之后,紧接着我们使用DBMS_CRYPTO包计算散列。这是应用中要保留的“版本”信息:

ops$tkyte@ORA 10G > begin

2          for x in ( select deptno, dname, loc

3                   from dept

4                   where deptno = 10 )

5          loop

6                   dbms_output.put_line( 'Dname: ' || x.dname );

7                   dbms_output.put_line( 'Loc: ' || x.loc );

8                   dbms_output.put_line( 'Hash: ' ||

9                            dbms_crypto.hash

10                           ( utl_raw.cast_to_raw(x.deptno||'/'||x.dname||'/'||x.loc),

11                           dbms_crypto.hash_sh1 ) );

12         end loop;

13 end;

14 /

Dname: ACCOUNTING

Loc: NEW YORK

Hash: C 44F 7052661CE945D385D 5C 3F 911E70FA 99407A 6

PL/SQL procedure successfully completed.

可以看到,散列值就是一个很大的16进制位串。DBMS_CRYPTO的返回值是一个RAW变量,显示时,它会隐式地转换为HEX。这个值会在更新前使用。为了执行更新,需要在数据库中获取这一行,并按其现在的样子锁定,然后计算所获取的行的散列值,将这个新散列值与从数据库读出数据时计算的散列值进行比较。上述逻辑表示如下(当然,在实际中,可能使用绑定变量而不是散列值直接量):

ops$tkyte@ORA 10G > begin

2          for x in ( select deptno, dname, loc

3                   from dept

4                   where deptno = 10

5                   for update nowait )

6          loop

7                   if ( hextoraw( 'C 44F 7052661CE945D385D 5C 3F 911E70FA 99407A 6' ) <>

8                            dbms_crypto.hash

9                            ( utl_raw.cast_to_raw(x.deptno||'/'||x.dname||'/'||x.loc),

10                         dbms_crypto.hash_sh1 ) )

11                then

12                         raise_application_error(-20001, 'Row was modified' );

13                end if;

14       end loop;

15       update dept

16                set dname = lower(dname)

17       where deptno = 10;

18       commit;

19 end;

20 /

PL/SQL procedure successfully completed.

更新后,重新查询数据,并再次计算散列值,此时可以看到散列值大不相同。如果有人抢在我们前面先修改了这一行,我们的散列值比较就不会成功:

ops$tkyte@ORA 10G > begin

2          for x in ( select deptno, dname, loc

3                   from dept

4                   where deptno = 10 )

5          loop

6                   dbms_output.put_line( 'Dname: ' || x.dname );

7                   dbms_output.put_line( 'Loc: ' || x.loc );

8                   dbms_output.put_line( 'Hash: ' ||

9                            dbms_crypto.hash

10                         ( utl_raw.cast_to_raw(x.deptno||'/'||x.dname||'/'||x.loc),

11                         dbms_crypto.hash_sh1 ) );

12       end loop;

13 end;

14 /

Dname: accounting

Loc: NEW YORK

Hash: F3DE485922D44DF 598C 2CEBC 34C 27DD2216FB 90F

PL/SQL procedure successfully completed.

这个例子显示了如何利用散列或校验和来实现乐观锁定。要记住,计算散列或校验和是一个CPU密集型操作(相当占用CPU),其计算代价很昂贵。如果系统上CPU是稀有资源,在这种系统上就必须充分考虑到这一点。不过,如果从“网络友好性”角度看,这种方法会比较好,因为只需在网络上传输相当小的散列值,而不是行的完整的前映像和后映像(以便逐列地进行比较),所以消耗的资源会少得多。下面最后一个例子会使用一个新的Oracle 10g 函数ORA_ROWSCN,它不仅很小(类似于散列),而且计算时不是CPU密集的(不会过多占用CPU)。

3.      使用 ORA_ROWSCN的乐观锁定

Oracle 10g Release 1开始,你还可以使用内置的ORA_ROWSCN函数。它的工作与前面所述的版本列技术很相似,但是可以由Oracle自动执行,而不需要在表中增加额外的列,也不需要额外的更新/维护代码来更新这个值。

ORA_ROWSCN建立在内部Oracle系统时钟(SCN)基础上。在Oracle中,每次提交时,SCN都会推进(其他情况也可能导致SCN推进,要注意,SCN只会推进,绝对不会后退)。这个概念与前面在获取数据时得到ORA_ROWSCN的方法是一样的,更新数据时要验证SCN未修改过。之所以我会强调这一点(而不是草草带过),原因是除非你创建表时支持在行级维护ORA_ROWSCN,否则Oracle会在块级维护。也就是说,默认情况下,一个块上的多行会共享相同的ORA_ROWSCN值。如果更新一个块上的某一行,而且这个块上还有另外50行,那么这些行的ORA_ROWSCN也会推进。这往往会导致许多假警报,你认为某一行已经修改,但实际上它并没有改动。因此,需要注意这一点,并了解如何改变这种行为。

我们想查看这种行为,然后进行修改,为此还要使用前面的小表DEPT

ops$tkyte@ORA 10G > create table dept

2          (deptno, dname, loc, data,

3          constraint dept_pk primary key(deptno)

4          )

5          as

6          select deptno, dname, loc, rpad('*',3500,'*')

7          from scott.dept;

Table created.

现在可以观察到每一行分别在哪个块上(在这种情况下,可以假设它们都在同一个文件中,所以如果块号相同,就说明它们在同一个块上)。我使用的块大小是8 KB,一行的宽度大约3 550字节,所以我预料这个例子中每块上有两行:

ops$tkyte@ORA 10G > select deptno, dname,

2                   dbms_rowid.rowid_block_number(rowid) blockno,

3                   ora_rowscn

4          from dept;

DEPTNO    DNAME              BLOCKNO   ORA_ROWSCN

----------       --------------          ----------          ----------

10                ACCOUNTING                         20972      34676029

20                RESEARCH      20972           34676029

30                SALES               20973           34676029

40                OPERATIONS 20973           34676029

不错,我们观察的结果也是这样,每块有两行。所以,下面来更新块20972DEPTNO = 10的那一行:

ops$tkyte@ORA 10G > update dept

2 set dname = lower(dname)

3 where deptno = 10;

1 row updated.

 

ops$tkyte@ORA 10G > commit;

Commit complete.

接下来观察到,ORA_ROWSCN的结果在块级维护。我们只修改了一行,也只提交了这一行的修改,但是块20972上两行的ORA_ROWSCN值都推进了:

ops$tkyte@ORA 10G > select deptno, dname,

2 dbms_rowid.rowid_block_number(rowid) blockno,

3 ora_rowscn

4 from dept;

DEPTNO    DNAME              BLOCKNO   ORA_ROWSCN

----------       --------------          ----------          ----------

10            accounting    20972        34676046

20            RESEARCH   20972        34676046

30                SALES               20973           34676029

40                OPERATIONS 20973           34676029

如果有人读取DEPTNO=20这一行,看起来这一行已经修改了,但实际上并非如此。块20973上的行是“安全”的,我们没有修改这些行,所以它们没有推进。不过,如果更新其中任何一行,两行都将推进。所以现在的问题是:如何修改这种默认行为。遗憾的是,我们必须启用ROWDEPENDENCIES再重新创建这个段。

Oracle9i为数据库增加了行依赖性跟踪,可以支持推进复制,以便更好地并行传播修改。在Oracle 10g 之前,这个特性只能在复制环境中使用;但是从Oracle 10g 开始,还可以利用这个特性用ORA_ROWSCN来实现一种有效的乐观锁定技术。它会为每行增加6字节的开销(所以与自己增加版本列的方法(即DIY版本列方法)相比,并不会节省空间),而实际上,也正是因为这个原因,所以需要重新创建表,而不只是简单地ALTER TABLE:必须修改物理块结构来适应这个特性。

下面重新建立我们的表,启用ROWDEPENDENCIES。可以使用DBMS_REDEFINITION中(Oracle提供的另一个包)的在线重建功能来执行,但是对于一个这么小的任务,我们还是从头开始更好一些:

ops$tkyte@ORA 10G > drop table dept;

Table dropped.

 

ops$tkyte@ORA 10G > create table dept

2                   (deptno, dname, loc, data,

3                   constraint dept_pk primary key(deptno)

4          )

5       ROWDEPENDENCIES

6          as

7                   select deptno, dname, loc, rpad('*',3500,'*')

8                   from scott.dept;

Table created.

 

ops$tkyte@ORA 10G > select deptno, dname,

2 dbms_rowid.rowid_block_number(rowid) blockno,

3 ora_rowscn

4 from dept;

DEPTNO    DNAME              BLOCKNO   ORA_ROWSCN

----------       --------------          ----------          ----------

10                ACCOUNTING                         21020      34676364

20                RESEARCH      21020           34676364

30                SALES               21021           34676364

40                OPERATIONS 21021           34676364

又回到前面:两个块上有4行,它们都有相同的ORA_ROWSCN值。现在,更新DEPTNO=10的那一行时:

ops$tkyte@ORA 10G > update dept

2 set dname = lower(dname)

3 where deptno = 10;

1 row updated.

 

ops$tkyte@ORA 10G > commit;

Commit complete.

查询DEPT表时应该能观察到以下结果:

ops$tkyte@ORA 10G > select deptno, dname,

2          dbms_rowid.rowid_block_number(rowid) blockno,

3          ora_rowscn

4 from dept;

 

DEPTNO    DNAME              BLOCKNO   ORA_ROWSCN

----------       --------------          ----------          ----------

10                accounting        21020           34676381

20                RESEARCH      21020           34676364

30                SALES               21021           34676364

40                OPERATIONS 21021           34676364

此时,只有DEPTNO = 10这一行的ORA_ROWSCN改变,这正是我们所希望的。现在可以依靠ORA_ROWSCN来为我们检测行级修改了。

SCN转换为墙上时钟时间

使用透明的ORA_ROWSCN列还有一个好处:可以把SCN转换为近似的墙上时钟时间(有+/–3秒的偏差),从而发现行最后一次修改发生在什么时间。例如,可以执行以下查询:

ops$tkyte@ORA 10G > select deptno, ora_rowscn, scn_to_timestamp(ora_rowscn) ts

                    2 from dept;

 

DEPTNO ORA_ROWSCN TS

----------      ----------                  -------------------------------

10              34676381            25-APR-05 02.37.04 .000000000 PM

20              34676364            25-APR-05 02.34.42 .000000000 PM

30              34676364            25-APR-05 02.34.42 .000000000 PM

40              34676364            25-APR-05 02.34.42 .000000000 PM

在此可以看到,在表的最初创建和更新DEPTNO = 10行之间,我等了大约3分钟。不过,从SCN到墙上时钟时间的这种转换有一些限制:数据库的正常运行时间只有5天左右。例如,如果查看一个“旧”表,查找其中最旧的ORA_ROWSCN(注意,在此我作为SCOTT登录;没有使用前面的新表):

scott@ORA 10G > select min(ora_rowscn) from dept;

MIN(ORA_ROWSCN)

---------------

364937

如果我试图把这个SCN转换为一个时间戳,可能看到以下结果(取决于DEPT表有多旧!):

scott@ORA 10G > select scn_to_timestamp(min(ora_rowscn)) from dept;

select scn_to_timestamp(min(ora_rowscn)) from dept

                  *

ERROR at line 1:

ORA-08181: specified number is not a valid system change number

ORA-06512: at "SYS.SCN_TO_TIMESTAMP", line 1

ORA-06512: at line 1

所以从长远看不能依赖这种转换。

6.2.4             乐观锁定还是悲观锁定?

那么哪种方法最好呢?根据我的经验,悲观锁定在Oracle中工作得非常好(但是在其他数据库中可能不是这样),而且与乐观锁定相比,悲观锁定有很多优点。不过,它需要与数据库有一条有状态的连接,如客户/服务器连接,因为无法跨连接持有锁。正是因为这一点,在当前的许多情况下,悲观锁定不太现实。过去,客户/服务器应用可能只有数十个或数百个用户,对于这些应用,悲观锁定是我的不二选择。不过,如今对大多数应用来说,我都建议采用乐观并发控制。要在整个事务期间保持连接,这个代价太大了,一般无法承受。

在这些可用的方法中,我使用哪一种呢?我喜欢使用版本列方法,并增加一个时间戳列(而不只是一个NUMBER)。从长远看,这样能为我提供一个额外的信息:“这一行最后一次更新发生在什么时间?”所以意义更大。而且与散列或校验和方法相比,计算的代价不那么昂贵,在处理LONGLONG RAWCLOBBLOB和其他非常大的列时,散列或校验和方法可能会遇到一些问题,而版本列方法则没有这些问题。

如果必须向一个表增加乐观并发控制,而此时还在利用悲观锁定机制使用这个表(例如,客户/服务器应用都在访问这个表,而且还在通过Web访问),我则倾向于选择ORA_ROWSCN方法。这是因为,在现有的遗留应用中,可能不希望出现一个新列,或者即使我们另外增加一步把这个额外的列隐藏起来,为了维护这个列,可能需要一个必要的触发器,而这个触发器的开销非常大,这是我们无法承受的。ORA_ROWSCN技术没有干扰性,而且在这个方面是轻量级的(当然,这是指我们执行表的重建之后)。

散列/校验和方法在数据库独立性方面很不错,特别是如果我们在数据库之外计算散列或校验和,则更是如此。不过,如果在中间层而不是在数据库中执行计算,从CPU使用和网络传输方面来看,就会带来更大的资源使用开销。

6.2.5             阻塞

如果一个会话持有某个资源的锁,而另一个会话在请求这个资源,就会出现阻塞(blocking)。这样一来,请求的会话会被阻塞,它会“挂起”,直至持有锁的会话放弃锁定的资源。几乎在所有情况下,阻塞都是可以避免的。实际上,如果你真的发现会话在一个交互式应用中被阻塞,就说明很有可能同时存在着另一个bug,即丢失更新,只不过你可能没有意识到这一点。也就是说,你的应用逻辑有问题,这才是阻塞的根源。

数据库中有5条常见的DML语句可能会阻塞,具体是:INSERTUPDATEDELETEMERGESELECT FOR UPDATE。对于一个阻塞的SELECT FOR UPDATE,解决方案很简单:只需增加NOWAIT子句,它就不会阻塞了。这样一来, 你的应用会向最终用户报告,这一行已经锁定。另外4DML语句才有意思。我们会分别分析这些DML语句,看看它们为什么不应阻塞,如果真的阻塞了又该如何修正。

1.      阻塞的INSERT

INSERT阻塞的情况不多见。最常见的情况是,你有一个带主键的表,或者表上有惟一的约束,但有两个会话试图用同样的值插入一行。如果是这样,其中一个会话就会阻塞,直到另一个会话提交或者回滚为止:如果另一个会话提交,那么阻塞的会话会收到一个错误,指出存在一个重复值;倘若另一个会话回滚,在这种情况下,阻塞的会话则会成功。还有一种情况,可能多个表通过引用完整性约束相互链接。对子表的插入可能会阻塞,因为它所依赖的父表正在创建或删除。

如果应用允许最终用户生成主键/惟一列值,往往就会发生INSERT阻塞。为避免这种情况,最容易的做法是使用一个序列来生成主键/惟一列值。序列(sequence)设计为一种高度并发的方法,用在多用户环境中生成惟一键。如果无法使用序列,那你可以使用以下技术,也就是使用手工锁来避免这个问题,这里的手工锁通过内置的DBMS_LOCK包来实现。

注意    会话可能因为主键或惟一约束而遭遇插入阻塞,下面的例子展示了如何避免这种情况。需要强调一点,这里所示的“修正方法”只能算是一个短期的解决方案,因为这个应用的体系结构本身就存在问题。这种方法显然会增加开销,而且不能轻量级地实现。如果应用设计得好,就不会遇到这个问题。只能把这当作最后一道防线,千万不要因为“以防万一”而对应用中的每个表都采用这种技术。

对于插入,不会选择现有的行,也不会对现有的行锁定[1]。没有办法避免其他人插入值相同的行,如果别人真的插入了具有相同值的行,这会阻塞我们的会话,而导致我们无休止地等待。此时,DBMS_LOCK就能派上用场了。为了介绍这个技术,下面创建一个带主键的表,还有一个触发器,它会防止两个(或更多)会话同时插入相同的值。这个触发器使用DBMS_UTILITY.GET_ HASH_VALUE来计算主键的散列值,得到一个01 073 741 823之间的数(这也是Oracle允许我们使用的锁ID号的范围)。在这个例子中,我选择了一个大小为1 024的散列表,这说明我们会把主键散列到1 024个不同的锁ID。然后使用DBMS_LOCK.REQUEST根据这个ID分配一个排他锁(也称独占锁,exclusive lock)。一次只有一个会话能做这个工作,所以,如果有人想用相同的主键值向表中插入一条记录,这个人的锁请求就会失败(并且会产生resource busy(资源忙)错误):

注意    为了成功地编译这个触发器,必须直接给你的模式授予DBMS_LOCK的执行权限。执行DBMS_LOCK的权限不能从角色得来:

scott@ORA 10G > create table demo ( x int primary key );

Table created.

 

scott@ORA 10G > create or replace trigger demo_bifer

2          before insert on demo

3          for each row

4          declare

5                   l_lock_id number;

6                   resource_busy exception;

7                   pragma exception_init( resource_busy, -54 );

8          begin

9                   l_lock_id :=

10                         dbms_utility.get_hash_value( to_char( :new.x ), 0, 1024 );

11                 if ( dbms_lock.request

12                         ( id => l_lock_id,

13                                   lockmode => dbms_lock.x_mode,

14                                   timeout => 0,

15                                  release_on_commit => TRUE ) <> 0 )

16                then

17                         raise resource_busy;

18                end if;

19       end;

20 /

Trigger created.

现在,如果在两个单独的会话中执行下面的插入:

scott@ORA 10G > insert into demo values ( 1 );

1 row created.

第一个会话会成功,但是紧接着第二个会话中会得出以下错误:

scott@ORA 10G > insert into demo values ( 1 );

insert into demo values ( 1 )

                  *

ERROR at line 1:

ORA-00054: resource busy and acquire with NOWAIT specified

ORA-06512: at "SCOTT.DEMO_BIFER", line 14

ORA-04088: error during execution of trigger 'SCOTT.DEMO_BIFER'

这里的思想是:为表提供的主键值要受触发器的保护,并把它放入一个字符串中。然后可以使用DBMS_UTILITY.GET_HASH_VALUE为这个串得出一个“几乎惟一”的散列值。只要使用小于1 073 741 823的散列表,就可以使用DBMS_LOCK独占地“锁住”这个值。

计算散列之后,取得这个散列值,并使用DBMS_LOCK来请求将这个锁ID独占地锁住(超时时间为ZERO,这说明如果已经有人锁住了这个值,它会立即返回)。如果超时或者由于某种原因失败了,将产生ORA-54 Resource Busy(资源忙)错误。否则什么也不做,完全可以顺利地插入,我们不会阻塞。

当然,如果表的主键是一个INTEGER,而你不希望这个主键超过1 000 000 000,那么可以跳过散列,直接使用这个数作为锁ID。要适当地设置散列表的大小(在这个例子中,散列表的大小是1 024),以避免因为不同的串散列为同一个数(这称为散列冲突)而人工地导致资源忙消息。散列表的大小与特定的应用(数据)有关,并发插入的数量也会影响散列表的大小。最后,还要记住,尽管Oracle有无限多个行级锁,但是enqueue锁(这是一种队列锁)的个数则是有限的。如果在会话中插入大量行,而没有提交,可能就会发现创建了太多的enqueue队列锁,而耗尽了系统的队列资源(超出了ENQUEUE_RESOURCES系统参数设置的最大值),因为每行都会创建另一个enqueue锁。如果确实发生了这种情况,就需要增大ENQUEUE_RESOURCES参数的值。还可以向触发器增加一个标志,允许打开或关闭这种检查。例如,如果我准备插入数百条或数千条记录,可能就不希望启用这个检查。

2.      阻塞的MergeUpdateDelete

在一个交互式应用中,可以从数据库查询某个数据,允许最终用户处理这个数据,再把它“放回”到数据库中,此时如果UPDATEDELETE阻塞,就说明你的代码中可能存在一个丢失更新问题(如果真是这样,按我的说法,就是你的代码中存在bug)。你试图UPDATE(更新)其他人正在更新的行(换句话说,有人已经锁住了这一行)。通过使用SELECT FOR UPDATE NOWAIT查询可以避免这个问题,这个查询能做到:

q         验证自从你查询数据之后数据未被修改(防止丢失更新)。

q         锁住行(防止UPDATEDELETE被阻塞)。

如前所述,不论采用哪一种锁定方法都可以这样做。不论是悲观锁定还是乐观锁定都可以利用SELECT FOR UPDATE NOWAIT查询来验证行未被修改。悲观锁定会在用户有意修改数据那一刻使用这条语句。乐观锁定则在即将在数据库中更新数据时使用这条语句。这样不仅能解决应用中的阻塞问题,还可以修正数据完整性问题。

由于MERGE只是INSERTUPDATE(如果在 10g 中采用改进的MERGE语法,还可以是DELETE),所以可以同时使用这两种技术。

6.2.6             死锁

如果你有两个会话,每个会话都持有另一个会话想要的资源,此时就会出现死锁(deadlock)。例如,如果我的数据库中有两个表AB,每个表中都只有一行,就可以很容易地展示什么是死锁。我要做的只是打开两个会话(例如,两个SQL*Plus会话)。在会话A中更新表A,并在会话B中更新表B。现在,如果我想在会话B中更新表A,就会阻塞。会话A已经锁定了这一行。这不是死锁;只是阻塞而已。我还没有遇到过死锁,因为会话A还有机会提交或回滚,这样会话B就能继续。

如果我再回到会话A,试图更新表B,这就会导致一个死锁。要在这两个会话中选择一个作为“牺牲品”,让它的语句回滚。例如,会话B中对表A的更新可能回滚,得到以下错误:

update a set x = x+1

*

ERROR at line 1:

ORA-00060: deadlock detected while waiting for resource

想要更新表B的会话A还阻塞着,Oracle不会回滚整个事务。只会回滚与死锁有关的某条语句。会话B仍然锁定着表B中的行,而会话A还在耐心地等待这一行可用。收到死锁消息后,会话B必须决定将表B上未执行的工作提交还是回滚,或者继续走另一条路,以后再提交。一旦这个会话执行提交或回滚,另一个阻塞的会话就会继续,好像什么也没有发生过一样。

Oracle认为死锁很少见,而且由于如此少见,所以每次出现死锁时它都会在服务器上创建一个跟踪文件。这个跟踪文件的内容如下:

*** 2005-04-25 15:53:01.455

*** ACTION NAME:() 2005-04-25 15:53:01.455

*** MODULE NAME:(SQL*Plus) 2005-04-25 15:53:01.455

*** SERVICE NAME:(SYS$USERS) 2005-04-25 15:53:01.455

*** SESSION ID:(145.208) 2005-04-25 15:53:01.455

DEADLOCK DETECTED

Current SQL statement for this session:

update a set x = 1

The following deadlock is not an ORACLE error. It is a

deadlock due to user error in the design of an application

or from issuing incorrect ad-hoc SQL. The following

information may aid in determining the deadlock:...

显然,Oracle认为这些应用死锁是应用自己导致的错误,而且在大多数情况下,Oracle的这种看法都是正确的。不同于许多其他的RDBMSOracle中极少出现死锁,甚至可以认为几乎不存在。通常情况下,必须人为地提供条件才会产生死锁。

根据我的经验,导致死锁的头号原因是外键未加索引(第二号原因是表上的位图索引遭到并发更新,这个内容将在第11章讨论)。在以下两种情况下,Oracle在修改父表后会对子表加一个全表锁:

q         如果更新了父表的主键(倘若遵循关系数据库的原则,即主键应当是不可变的,这种情况就很少见),由于外键上没有索引,所以子表会被锁住。

q         如果删除了父表中的一行,整个子表也会被锁住(由于外键上没有索引)。

Oracle9i及以上版本中,这些全表锁都是短期的,这意味着它们仅在DML操作期间存在,而不是在整个事务期间都存在。即便如此,这些全表锁还是可能(而且确实会)导致很严重的锁定问题。下面说明第二点[2],如果用以下命令建立了两个表:

ops$tkyte@ORA 10G > create table p ( x int primary key );

Table created.

 

ops$tkyte@ORA 10G > create table c ( x references p );

Table created.

 

ops$tkyte@ORA 10G > insert into p values ( 1 );

1 row created.

 

ops$tkyte@ORA 10G > insert into p values ( 2 );

1 row created.

ops$tkyte@ORA 10G > commit;

Commit complete.

然后执行以下语句:

ops$tkyte@ORA 10G > insert into c values ( 2 );

1 row created.

到目前为止,还没有什么问题。但是如果再到另一个会话中,试图删除第一条父记录:

ops$tkyte@ORA 10G > delete from p where x = 1;

此时就会发现,这个会话立即被阻塞了。它在执行删除之前试图对表C加一个全表锁。现在,别的会话都不能对C中的任何行执行DELETEINSERTUPDATE(已经开始的会话可以继续[3],但是新会话将无法修改C)。

更新主键值也会发生这种阻塞。因为在关系数据库中,更新主键是一个很大的禁忌,所以更新在这方面一般没有什么问题。在我看来,如果开发人员使用能生成SQL的工具,而且这些工具会更新每一列,而不论最终用户是否确实修改了那些列,此时更新主键就会成为一个严重的问题。例如,假设我们使用了Oracle Forms,并为表创建了一个默认布局。默认情况下,Oracle Forms会生成一个更新,对我们选择要显示的表中的每一列进行修改。如果在DEPT表中建立一个默认布局,包括3个字段,只要我们修改了DEPT表中的任何列,Oracle Forms都会执行以下命令:

update dept set deptno=:1,dname=:2,loc=:3 where rowid=:4

在这种情况下,如果EMP表有DEPT的一个外键,而且在EMP表的DEPTNO列上没有任何索引,那么更新DEPT时整个EMP表都会被锁定。如果你使用了能生成SQL的工具,就一定要当心这一点。即便主键值没有改变,执行前面的SQL语句后,子表EMP也会被锁定。如果使用Oracle Forms,解决方案是把这个表的UPDATE CHANGED COLUMNS ONLY属性设置为YES。这样一来,Oracle Forms会生成一条UPDATE语句,其中只包含修改过的列(而不包括主键)。

删除父表中的一行可能导致子表被锁住,由此产生的问题更多。我已经说过,如果删除表P中的一行,那么在DML操作期间,子表C就会锁定,这样能避免事务期间对C执行其他更新(当然,这有一个前提,即没有人在修改C;如果确实已经有人在修改C,删除会等待)。此时就会出现阻塞和死锁问题。通过锁定整个表C,数据库的并发性就会大幅下降,以至于没有人能够修改C中的任何内容。另外,出现死锁的可能性则增加了,因为我的会话现在“拥有”大量数据,直到提交时才会交出。其他会话因为C而阻塞的可能性也更大;只要会话试图修改C就会被阻塞。因此,我开始注意到,数据库中大量会话被阻塞,这些会话持有另外一些资源的锁。实际上,如果其中任何阻塞的会话锁住了我的会话需要的资源,就会出现一个死锁。在这种情况下,造成死锁的原因是:我的会话不允许别人访问超出其所需的更多资源(在这里就是一个表中的所有行)。如果有人抱怨说数据库中存在死锁,我会让他们运行一个脚本,查看是不是存在未加索引的外键,而且在99%的情况下都会发现表中确实存在这个问题。只需对外键加索引,死锁(以及大量其他的竞争问题)都会烟消云散。下面的例子展示了如何使用这个脚本来找出表C中未加索引的外键:

ops$tkyte@ORA 10G > column columns format a30 word_wrapped

ops$tkyte@ORA 10G > column tablename format a15 word_wrapped

ops$tkyte@ORA 10G > column constraint_name format a15 word_wrapped

 

ops$tkyte@ORA 10G > select table_name, constraint_name,

2                   cname1 || nvl2(cname2,','||cname2,null) ||

3                   nvl2(cname3,','||cname3,null) || nvl2(cname4,','||cname4,null) ||

4                   nvl2(cname5,','||cname5,null) || nvl2(cname6,','||cname6,null) ||

5                   nvl2(cname7,','||cname7,null) || nvl2(cname8,','||cname8,null)

6                            columns

7          from ( select b.table_name,

8                            b.constraint_name,

9                            max(decode( position, 1, column_name, null )) cname1,

10                         max(decode( position, 2, column_name, null )) cname2,

11                         max(decode( position, 3, column_name, null )) cname3,

12                         max(decode( position, 4, column_name, null )) cname4,

13                         max(decode( position, 5, column_name, null )) cname5,

14                         max(decode( position, 6, column_name, null )) cname6,

15                         max(decode( position, 7, column_name, null )) cname7,

16                         max(decode( position, 8, column_name, null )) cname8,

17                         count(*) col_cnt

18                from (select substr(table_name,1,30) table_name,

19                                   substr(constraint_name,1,30) constraint_name,

20                                   substr(column_name,1,30) column_name,

21                                   position

22                          from user_cons_columns ) a,

23                                  user_constraints b

24                         where a.constraint_name = b.constraint_name

25                                  and b.constraint_type = 'R'

26                         group by b.table_name, b.constraint_name

27                         ) cons

28                where col_cnt > ALL

29                         ( select count(*)

30                         from user_ind_columns i

31                         where i.table_name = cons.table_name

32                                  and i.column_name in (cname1, cname2, cname3, cname4,

33                                           cname5, cname6, cname7, cname8 )

34                                  and i.column_position <= cons.col_cnt

35                         group by i.index_name

36       )

37 /

TABLE_NAME                  CONSTRAINT_NAME   COLUMNS

------------------------------     ---------------                       ------------------------------

C                                          SYS_C009485                X

这个脚本将处理外键约束,其中最多可以有8列(如果你的外键有更多的列,可能就得重新考虑一下你的设计了)。首先,它在前面的查询中建立一个名为CONS的内联视图(inline view)。这个内联视图将约束中适当的列名从行转置到列,其结果是每个约束有一行,最多有8列,这些列分别取值为约束中的列名。另外,这个视图中还有一个列COL_CNT,其中包含外键约束本身的列数。对于这个内联视图中返回的每一行,我们要执行一个关联子查询(correlated subquery),检查当前所处理表上的所有索引。它会统计出索引中与外键约束中的列相匹配的列数,然后按索引名分组。这样,就能生成一组数,每个数都是该表某个索引中匹配列的总计。如果原来的COL_CNT大于所有这些数,那么表中就没有支持这个约束的索引。如果COL_CNT小于所有这些数,就至少有一个索引支持这个约束。注意,这里使用了NVL2函数,我们用这个函数把列名列表“粘到”一个用逗号分隔的列表中。这个函数有3个参数:ABC。如果参数A非空,则返回B;否则返回参数C。这个查询有一个前提,假设约束的所有者也是表和索引的所有者。如果另一位用户对表加索引,或者表在另一个模式中(这两种情况都很少见),就不能正确地工作。

所以,这个脚本展示出,表C在列X上有一个外键,但是没有索引。通过对X加索引,就可以完全消除这个锁定问题。除了全表锁外,在以下情况下,未加索引的外键也可能带来问题:

q         如果有ON DELETE CASCADE,而且没有对子表加索引:例如,EMPDEPT的子表,DELETE DEPTNO = 10应该CASCADE(级联)至EMP[4]。如果EMP中的DEPTNO没有索引,那么删除DEPT表中的每一行时都会对EMP做一个全表扫描。这个全表扫描可能是不必要的,而且如果从父表删除多行,父表中每删除一行就要扫描一次子表。

q         从父表查询子表:再次考虑EMP/DEPT例子。利用DEPTNO查询EMP表是相当常见的。如果频繁地运行以下查询(例如,生成一个报告),你会发现没有索引会使查询速度变慢:

§           select * from dept, emp

§           where emp.deptno = dept.deptno and dept.deptno = :X;

那么,什么时候不需要对外键加索引呢?答案是,一般来说,当满足以下条件时不需要加索引:

q         没有从父表删除行。

q         没有更新父表的惟一键/主键值(当心工具有时会无意地更新主键!)。

q         没有从父表联结子表(如DEPT联结到EMP)。

如果满足上述全部3个条件,那你完全可以跳过索引,不需要对外键加索引。如果满足以上的某个条件,就要当心加索引的后果。这是一种少有的情况,即Oracle“过分地锁定了”数据。

6.2.7             锁升级

出现锁升级(lock escalation)时,系统会降低锁的粒度。举例来说,数据库系统可以把一个表的100个行级锁变成一个表级锁。现在你用的是“能锁住全部的一个锁”,一般而言,这还会锁住以前没有锁定的大量数据。如果数据库认为锁是一种稀有资源,而且想避免锁的开销,这些数据库中就会频繁使用锁升级。

注意    Oracle不会升级锁,从来不会。

Oracle从来不会升级锁,但是它会执行锁转换(lock conversion)或锁提升(lock promotion),这些词通常会与锁升级混淆。

注意    “锁转换”和“锁提升”是同义词。Oracle一般称这个过程为“锁转换”。

Oracle会尽可能地在最低级别锁定(也就是说,限制最少的锁),如果必要,会把这个锁转换为一个更受限的级别。例如,如果用FOR UPDATE子句从表中选择一行,就会创建两个锁。一个锁放在所选的行上(这是一个排他锁;任何人都不能以独占模式锁定这一行)。另一个锁是ROW SHARE TABLE锁,放在表本身上。这个锁能防止其他会话在表上放置一个排他锁,举例来说,这样能相应地防止这些会话改变表的结构。另一个会话可以修改这个表中的任何其他行,而不会有冲突。假设表中有一个锁定的行,这样就可以成功执行尽可能多的命令。

锁升级不是一个数据库“特性”。这不是我们想要的性质。如果数据库支持锁升级,就说明这个数据库的锁定机制中存在某些内部开销,而且管理数百个锁需要做大量的工作。在Oracle中,1个锁的开销与1 000 000个锁是一样的,都没有开销。

6.3   锁类型

Oracle中主要有3类锁,具体是:

q         DML锁(DML lock):DML代表数据操纵语言(Data Manipulation Language)。一般来讲,这表示SELECTINSERTUPDATEMERGEDELETE语句。DML锁机制允许并发执行数据修改。例如,DML锁可能是特定数据行上的锁,或者是锁定表中所有行的表级锁。

q         DDL锁(DDL lock):DDL代表数据定义语言(Data Definition Language),如CREATEALTER语句等。DDL锁可以保护对象结构定义。

q         内部锁和闩:Oracle使用这些锁来保护其内部数据结构。例如,Oracle解析一个查询并生成优化的查询计划时,它会把库缓存“临时闩”,将计划放在那里,以供其他会话使用。闩(latch)是Oracle采用的一种轻量级的低级串行化设备,功能上类似于锁。不要被“轻量级”这个词搞糊涂或蒙骗了,你会看到,闩是数据库中导致竞争的一个常见原因。轻量级指的是闩的实现,而不是闩的作用。

下面将更详细地讨论上述各个特定类型的锁,并介绍使用这些锁有什么影响。除了我在这里介绍的锁之外,还有另外一些锁类型。这一节以及下一节介绍的锁是最常见的,而且会保持很长时间。其他类型的锁往往只保持很短的一段时间。

6.3.1             DML

DML锁(DML Lock)用于确保一次只有一个人能修改某一行,而且你正在处理一个表时别人不能删除这个表。在你工作时,Oracle会透明程度不一地为你加这些锁。

1.      TX锁(事务锁)

事务发起第一个修改时会得到TX锁(事务锁),而且会一直持有这个锁,直至事务执行提交(COMMIT)或回滚(ROLLBACK)。TX锁用作一种排队机制,使得其他会话可以等待这个事务执行。事务中修改或通过SELECT FOR UPDATE选择的每一行都会“指向”该事务的一个相关TX锁。听上去好像开销很大,但实际上并非如此。要想知道这是为什么,需要从概念上对锁“居住”在哪里以及如何管理锁有所认识。在Oracle中,闩为数据的一个属性(第10章会给出Oracle块格式的一个概述)。Oracle并没有一个传统的锁管理器,不会用锁管理器为系统中锁定的每一行维护一个长长的列表。不过,其他的许多数据库却是这样做的,因为对于这些数据库来说,锁是一种稀有资源,需要对锁的使用进行监视。使用的锁越多,系统要管理的方面就越多,所以在这些系统中,如果使用了“太多的”锁就会有问题。

如果数据库中有一个传统的基于内存的锁管理器,在这样一个数据库中,对一行锁定的过程一般如下:

(1) 找到想锁定的那一行的地址。

(2) 在锁管理器中排队(锁管理器必须是串行化的,因为这是一个常见的内存中的结构)。

(3) 锁定列表。

(4) 搜索列表,查看别人是否已经锁定了这一行。

(5) 在列表中创建一个新的条目,表明你已经锁定了这一行。

(6) 对列表解锁。

既然已经锁定了这一行,接下来就可以修改它了。之后,在你提交修改时,必须继续这个过程,如下:

(7) 再次排队。

(8) 锁住锁的列表。

(9) 在这个列表中搜索,并释放所有的锁。

(10) 对列表解锁。

可以看到,得到的锁越多,这个操作所花的时间就越多,修改数据前和修改数据之后耗费的时间都会增加。Oracle不是这样做的。Oracle中的锁定过程如下:

(1) 找到想锁定的那一行的地址。

(2) 到达那一行。

(3) 锁定这一行(如果这一行已经锁定,则等待锁住它的事务结束,除非使用了NOWAIT选项)。

仅此而已。由于闩为数据的一个属性,Oracle不需要传统的锁管理器。事务只是找到数据[5],如果数据还没有被锁定,则对其锁定。有意思的是,找到数据时,它可能看上去被锁住了,但实际上并非如此。在Oracle中对数据行锁定时,行指向事务ID的一个副本,事务ID存储在包含数据的块中,释放锁时,事务ID却会保留下来。这个事务ID是事务所独有的,表示了回滚段号、槽和序列号。事务ID留在包含数据行的块上,可以告诉其他会话:你“拥有”这个数据(并非块上的所有数据都是你的,只是你修改的那一行“归你所有”)。另一个会话到来时,它会看到锁ID,由于锁ID表示一个事务,所以可以很快地查看持有这个锁的事务是否还是活动的。如果锁不活动,则允许会话访问这个数据。如果锁还是活动的,会话就会要求一旦释放锁就得到通知。因此,这就有了一个排队机制:请求锁的会话会排队,等待目前拥有锁的事务执行,然后得到数据。

以下是一个小例子,展示了这到底是怎么回事,这里使用了3V$ 表:

q         V$TRANSACTION,对应每个活动事务都包含一个条目。

q         V$SESSION,显示已经登录的会话。

q         V$LOCK,对应持有所有enqueue队列锁以及正在等待锁的会话,都分别包含一个条目。这并不是说,对于表中被会话锁定的每一行,这个视图中就有相应的一行。你不会看到这种情况。如前所述,不存在行级锁的一个主列表。如果某个会话将EMP表中的一行锁定,V$LOCK视图中就有对应这个会话的一行来指示这一事实。如果一个会话锁定了EMP表中的数百万行,V$LOCK视图中对应这个会话还是只有一行。这个视图显示了各个会话有哪些队列锁。

首先启动一个事务(如果你没有DEPT表的一个副本,只需使用CREATE TABLE AS SELECT来建立一个副本):

ops$tkyte@ORA 10G > update dept set deptno = deptno+10;

4 rows updated.

下面来看看此时系统的状态。这个例子假设是一个单用户系统;否则,在V$TRANS ACTION中可以看到多行。即使在一个单用户的系统中,如果看到V$TRANSACTION中有多行也不要奇怪,因为许多后台Oracle进程可能也会执行事务。

ops$tkyte@ORA 10G > select username,

2 v$lock.sid,

3 trunc(id1/power(2,16)) rbs,

4 bitand(id1,to_number('ffff','xxxx'))+0 slot,

5 id2 seq,

6 lmode,

7 request

8 from v$lock, v$session

9 where v$lock.type = 'TX'

10 and v$lock.sid = v$session.sid

11 and v$session.username = USER;

 

USERNAME SID RBS     SLOT       SEQ      LMODE      REQUEST

---------             ----    ---         ----            ------     -----         -------

OPS$TKYTE           145      4               12        16582    6          0

 

ops$tkyte@ORA 10G > select XIDUSN, XIDSLOT, XIDSQN

2 from v$transaction;

XIDUSN    XIDSLOT   XIDSQN

----------      ----------       ----------

4                 12               16582

这里有几点很有意思:

q         $LOCK表中的LMODE6REQUEST0。如果在Oracle Server Reference手册中查看V$LOCK表的定义,会发现LMODE=6是一个排他锁。请求(REQUEST)值为0则意味着你没有发出请求;也就是说,你拥有这个锁。

q         这个表中只有一行。V$LOCK表更应算是一个队列表而不是一个锁表。许多人都认为V$LOCK中会有4行,因为我们锁定了4行。不过,你要记住,Oracle不会在任何地方存储行级锁的列表(也就是说,不会为每一个被锁定的行维护一个主列表)。要查看某一行是否被锁定,必须直接找到这一行[6]

q          我选择了ID1ID2列,并对它们执行了一些处理。Oracle需要保存316位的数,但是对此只有两个列。所以,第一个列ID1保存着其中两个数。通过用trunc(id1/power (2,16))rbs除以2^16[7],并用bitand(id1,to_number('ffff','xxxx'))+0 slot把高位屏蔽[8],就能从这个数中找回隐藏的两个数。

q         RBSSLOTSEQ值与V$TRANSACTION信息匹配。这就是我的事务ID

下面使用同样的用户名启动另一个会话,更新EMP中的某些行,并希望试图更新DEPT

ops$tkyte@ORA 10G > update emp set ename = upper(ename);

14 rows updated.

 

ops$tkyte@ORA 10G > update dept set deptno = deptno-10;

现在这个会话会阻塞。如果再次运行V$查询,可以看到下面的结果:

ops$tkyte@ORA 10G > select username,

2                   v$lock.sid,

3                   trunc(id1/power(2,16)) rbs,

4                   bitand(id1,to_number('ffff','xxxx'))+0 slot,

5                   id2 seq,

6                   lmode,

7                   request

8          from v$lock, v$session

9          where v$lock.type = 'TX'

10                and v$lock.sid = v$session.sid

11                and v$session.username = USER;

 

USERNAME SID     RBS   SLOT   SEQ     LMODE REQUEST

---------             ----       ---       ----        ------     -----          -------

OPS$TKYTE              144    4           12         16582     0 6

OPS$TKYTE              144    5           34         1759       6 0

OPS$TKYTE              145    4           12         16582     6 0

 

ops$tkyte@ORA 10G > select XIDUSN, XIDSLOT, XIDSQN

2 from v$transaction;

 

XIDUSN                     XIDSLOT          XIDSQN

----------   ----------      ----------

5              34              1759

4              12              16582

这里可以看到开始了一个新的事务,事务ID(5,34,1759)。这一次,这个新会话(SID=144)在V$LOCK中有两行。其中一行表示它所拥有的锁(LMODE=6)。另外还有一行,显示了一个值为6REQUEST。这是一个对排他锁的请求。有意思的是,这个请求行的RBS/SLOT/SEQ值正是锁持有者的事务IDSID=145的事务阻塞了SID=144的事务。只需执行V$LOCK的一个自联结,就可以更明确地看出这一点:

ops$tkyte@ORA 10G > select

2                (select username from v$session where sid=a.sid) blocker,

3                a.sid,

4                ' is blocking ',

5                (select username from v$session where sid=b.sid) blockee,

6                 b.sid

7       from v$lock a, v$lock b

8       where a.block = 1

9                 and b.request > 0

10              and a.id1 = b.id1

11              and a.id2 = b.id2;

BLOCKER     SID    'ISBLOCKING'                             BLOCKEE        SID

---------             ----      -------------          ---------               ----

OPS$TKYTE             145                    is blocking        OPS$TKYTE   144

现在,如果提交原来的事务(SID=145),并重新运行锁查询,可以看到请求行不见了:

ops$tkyte@ORA 10G > select username,

2                   v$lock.sid,

3                   trunc(id1/power(2,16)) rbs,

4                   bitand(id1,to_number('ffff','xxxx'))+0 slot,

5                   id2 seq,

6                   lmode,

7                   request

8          from v$lock, v$session

9          where v$lock.type = 'TX'

10                and v$lock.sid = v$session.sid

11                and v$session.username = USER;

 

USERNAME SID    RBS   SLOT SEQ   LMODE   REQUEST

---------             ----      ---       ----       ------    -----          -------

OPS$TKYTE             144    5          34       1759        6      0

 

ops$tkyte@ORA 10G > select XIDUSN, XIDSLOT, XIDSQN

2 from v$transaction;

XIDUSN XIDSLOT          XIDSQN

----------    ----------     ----------

5               34              1759

另一个会话一旦放弃锁,请求行就会消失。这个请求行就是排队机制。一旦事务执行,数据库会唤醒被阻塞的会话。当然,利用各种GUI工具肯定能得到更“好看”的显示,但是,必要时对你要查看的表有所了解还是非常有用的。

不过,我们还不能说自己已经很好地掌握了Oracle中行锁定是如何工作的,因为还有最后一个主题需要说明:如何用数据本身来管理锁定和事务信息。这是块开销的一部分。在第9章中,我们会详细分析块的格式,但是现在只需知道数据库块的最前面有一个“开销”空间,这里会存放该块的一个事务表,了解这一点就足够了。对于锁定了该块中某些数据的各个“实际”事务,在这个事务表中都有一个相应的条目。这个结构的大小由创建对象时CREATE语句上的两个物理属性参数决定:

q         INITRANS:这个结构初始的预分配大小。对于索引和表,这个大小默认为2(不过我已经提出,Oracle SQL Reference手册中与此有关的说明有问题)。

q         MAXTRANS:这个结构可以扩缩到的最大大小。它默认为255,在实际中,最小值为2。在Oracle 10g 中,这个设置已经废弃了,所以不再使用。这个版本中的MAXTRANS总是255

默认情况下,每个块最开始都有两个事务槽。一个块上同时的活动事务数受MAXTRANS值的约束,另外也受块上空间可用性的限制。如果没有足够的空间来扩大这个结构,块上就无法得到255个并发事务。

我们可以创建一个具有受限MAXTRANS的表,来专门展示这是如何工作的。为此,需要使用Oracle9i 或以前的版本,因为Oracle 10g 中会忽略MAXTRANS。在Oracle 10g 中,只要块上的空间允许,即使设置了MAXTRANSOracle也会不受约束地扩大事务表。在Oracle9i 及以前的版本中,一旦块达到了MAXTRANS值,事务表就不会再扩大了,例如:

ops$tkyte@ORA9IR2> create table t ( x int ) maxtrans 2;

Table created.

 

ops$tkyte@ORA9IR2> insert into t select rownum from all_users;

24 rows created.

 

ops$tkyte@ORA9IR2> commit;

Commit complete.

 

ops$tkyte@ORA9IR2> select distinct dbms_rowid.rowid_block_number(rowid) from t;

DBMS_ROWID.ROWID_BLOCK_NUMBER(ROWID)

------------------------------------

18

因此,我们有24行,而且经验证,它们都在同一个数据库块上。现在,在一个会话中发出以下命令:

ops$tkyte@ORA9IR2> update t set x = 1 where x = 1;

1 row updated.

在另一个会话中,发出下面的命令:

ops$tkyte@ORA9IR2> update t set x = 2 where x = 2;

1 row updated.

最后,在第三个会话中,发出如下命令:

ops$tkyte@ORA9IR2> update t set x = 3 where x = 3;

现在,由于这3行在同一个数据库块上,而且我们将MAXTRANS(该块的最大并发度)设置为2,所以第3个会话会被阻塞。

注意    要记住,在Oracle 10g 中,不会发生上例中出现的阻塞,不管怎样,MAXTRANS都会设置为255。在这个版本中,只有当块上没有足够的空间来扩大事务表时,才会看到这种阻塞。

从这个例子可以看出,如果多个MAXTRANS事务试图同时访问同一个块时会发生什么情况[9]。类似地,如果INITRANS设置得很低,而且块上没有足够的空间来动态地扩缩事务,也会出现阻塞。大多数情况下,INITRANS的默认值2就足够了,因为事务表会动态扩大(只要空间允许)。但是在某些环境中,可能需要加大这个设置来提高并发性,并减少等待。比如,在频繁修改的表上就可能要增加INITRANS设置,或者更常见的是,对于频繁修改的索引也可能需要这么做,因为索引块中的行一般比表中的行多。你可能需要增加PCTFREE(见第10章的讨论)或INITRANS,从而在块上提前预留足够的空间以应付可能的并发事务数。尤其是,如果你预料到块开始时几乎是满的(这说明块上没有空间来动态扩缩事务结构),则更需要增加PCTFREEINITRANS

2.      TM (DML Enqueue)

TM锁(TM lock)用于确保在修改表的内容时,表的结构不会改变。例如,如果你已经更新了一个表,会得到这个表的一个TM锁。这会防止另一个用户在该表上执行DROPALTER命令。如果你有表的一个TM锁,而另一位用户试图在这个表上执行DDL,他就会得到以下错误消息:

drop table dept

*

ERROR at line 1:

ORA-00054: resource busy and acquire with NOWAIT specified

初看上去,这是一条让人摸不着头脑的消息,因为根本没有办法在DROP TABLE上指定NOWAITWAIT。如果你要执行的操作将要阻塞,但是这个操作不允许阻塞,总是会得到这样一条一般性的消息。前面已经看到,如果在一个锁定的行上发出SELECT FOR UPDATE NOWAIT命令,也会得到同样的消息。

以下显示了这些锁在V$LOCK表中是什么样子:

ops$tkyte@ORA 10G > create table t1 ( x int );

Table created.

 

ops$tkyte@ORA 10G > create table t2 ( x int );

Table created.

 

ops$tkyte@ORA 10G > insert into t1 values ( 1 );

1 row created.

 

ops$tkyte@ORA 10G > insert into t2 values ( 1 );

1 row created.

 

ops$tkyte@ORA 10G > select (select username

2                   from v$session

3                   where sid = v$lock.sid) username,

4                   sid,

5                   id1,

6                   id2,

7                   lmode,

8                   request, block, v$lock.type

9          from v$lock

10       where sid = (select sid

11                                  from v$mystat

12                                  where rownum=1)

13 /

 

USERNAME SID    ID1         ID2         LMODE     REQUEST    BLOCK   TYPE

---------             ----      -------      ------       -----            -------              -----          ----

OPS$TKYTE             161        262151                     16584           6              0     0      TX

OPS$TKYTE             161        62074   0                 3                     0              0     TM

OPS$TKYTE             161        62073   0                 3                     0              0     TM

 

ops$tkyte@ORA 10G > select object_name, object_id

2 from user_objects

3 where object_name in ('T1','T2')

4 /

OBJECT_NAME       OBJECT_ID

------------                     ----------

T1                                62073

T2                                62074

尽管每个事务只能得到一个TX锁,但是TM锁则不同,修改了多少个对象,就能得到多少个TM锁。在此,有意思的是,TM锁的ID1列就是DML锁定对象的对象ID,所以,很容易发现哪个对象持有这个锁。

关于TM锁还有另外一个有意思的地方:系统中允许的TM锁总数可以由你来配置(有关细节请见Oracle Database Reference手册中的DML_LOCKS参数定义)。实际上,这个数可能设置为0。但这并不是说你的数据库变成了一个只读数据库(没有锁),而是说不允许DDL。在非常专业的应用(如RAC实现)中,这一点就很有用,可以减少实例内可能发生的协调次数。通过使用ALTER TABLE TABLENAME DISABLE TABLE LOCK命令,还可以逐对象地禁用TM锁。这是一种快捷方法,可以使意外删除表的“难度更大”,因为在删除表之前,你必须重新启用表锁。还能用它来检测由于外键未加索引而导致的全表锁(前面已经讨论过)。

6.3.2             DDL

DDL操作中会自动为对象加DDL锁(DDL Lock),从而保护这些对象不会被其他会话所修改。例如,如果我执行一个DDL操作ALTERTABLE T,表T上就会加一个排他DDL锁,以防止其他会话得到这个表的DDL锁和TM锁。在DDL语句执行期间会一直持有DDL锁,一旦操作执行就立即释放DDL锁。实际上,通常会把DDL语句包装在隐式提交(或提交/回滚对)中来执行这些工作。由于这个原因,在OracleDDL一定会提交。每条CREATEALTER等语句实际上都如下执行(这里用伪代码来展示):

Begin

         Commit;

         DDL-STATEMENT

         Commit;

Exception

         When others then rollback;

End;

因此,DDL总会提交(即使提交不成功也会如此)。DDL一开始就提交,一定要知道这一点。它首先提交,因此如果必须回滚,它不会回滚你的事务。如果你执行了DDL,它会使你所执行的所有未执行的工作成为永久性的,即使DDL不成功也会如此。如果你需要执行DDL,但是不想让它提交你现有的事务,就可以使用一个自治事务(autonomous transaction)。

3种类型的DDL锁:

q         排他DDL锁(Exclusive DDL lock):这会防止其他会话得到它们自己的DDL锁或TMDML)锁。这说明,在DDL操作期间你可以查询一个表,但是无法以任何方式修改这个表。

q         共享DDL锁(Share DDL lock):这些锁会保护所引用对象的结构,使之不会被其他会话修改,但是允许修改数据。

q         可中断解析锁(Breakable parse locks):这些锁允许一个对象(如共享池中缓存的一个查询计划)向另外某个对象注册其依赖性。如果在被依赖的对象上执行DDLOracle会查看已经对该对象注册了依赖性的对象列表,并使这些对象无效。因此,这些锁是“可中断的”,它们不能防止DDL出现。

大多数DDL都带有一个排他DDL锁。如果发出如下一条语句:

Alter table t add new_column date;

在执行这条语句时,表T不能被别人修改。在此期间,可以使用SELECT查询这个表,但是大多数其他操作都不允许执行,包括所有DDL语句。在Oracle中,现在有些DDL操作没有DDL锁也可以发生。例如,可以发出以下语句:

create index t_idx on t(x) ONLINE;

ONLINE关键字会改变具体建立索引的方法。Oracle并不是加一个排他DDL锁来防止数据修改,而只会试图得到表上的一个低级(mode 2TM锁。这会有效地防止其他DDL发生,同时还允许DML正常进行。Oracle执行这一“壮举”的做法是,为DDL语句执行期间对表所做的修改维护一个记录,执行CREATE时再把这些修改应用至新的索引。这样能大大增加数据的可用性。

另外一类DDL会获得共享DDL锁。在创建存储的编译对象(如过程和视图)时,会对依赖的对象加这种共享DDL锁。例如,如果执行以下语句:

Create view MyView

as

         select *

         from emp, dept

         where emp.deptno = dept.deptno;

EMPDEPT上都会加共享DDL锁,而CREATE VIEW命令仍在处理。可以修改这些表的内容,但是不能修改它们的结构。

最后一类DDL锁是可中断解析锁。你的会话解析一条语句时,对于该语句引用的每一个对象都会加一个解析锁。加这些锁的目的是:如果以某种方式删除或修改了一个被引用的对象,可以将共享池中已解析的缓存语句置为无效(刷新输出)。

有一个意义非凡的视图可用于查看这个信息,即DBA_DDL_LOCKS视图。对此没有相应的V$视图。DBA_DDL_LOCKS视图建立在更神秘的X$表基础上,而且默认情况下,你的数据库上不会安装这个视图。可以运行[ORACLE_HOME]/rdbms/admin目录下的catblock.sql脚本来安装这个视图以及其他锁视图。必须作为用户SYS来执行这个脚本才能成功。一旦执行了这个脚本,可以对视图运行一个查询。例如,在一个单用户数据库中,我看到以下结果:

ops$tkyte@ORA 10G > select session_id sid, owner, name, type,

2 mode_held held, mode_requested request

3 from dba_ddl_locks;

 

SID   OWNER               NAME                                      TYPE                              HELD     REQUEST

----     ---------                  ---------------------                          --------------------                   ----       ---------

161   SYS                    DBMS_UTILITY                       Body                               Null      None

161   SYS                    DBMS_UTILITY                       Body                               Null      None

161   SYS                    DBMS_APPLICATION_INFO   Table/Procedure/Type     Null      None

161   OPS$TKYTE       OPS$TKYTE                           18                                   Null      None

161   SYS                    DBMS_OUTPUT                      Body                               Null      None

161   SYS                    DATABASE                             18                                   Null      None

161   SYS                    DBMS_UTILITY                       Table/Procedure/Type     Null      None

161   SYS                    DBMS_UTILITY                       Table/Procedure/Type     Null None

161   SYS                    PLITBLM                                 Table/Procedure/Type     Null      None

161   SYS                    DBMS_APPLICATION_INFO   Body                               Null      None

161   SYS                    DBMS_OUTPUT                      Table/Procedure/Type     Null      None

11 rows selected.

这些就是我的会话“锁定”的所有对象。我对一组DBMS_*包加了可中断解析锁。这是使用SQL*Plus的副作用;例如,它会调用DBMS_APPLICATION_INFO[10]。可以看到不同的对象可能有不止一个副本,这是正常的,这只是表明,共享池中有多个“事物”引用了这些对象。需要指出有意思的一点,在这个视图中,OWNER列不是锁的所有者;而是所锁定对象的所有者。正是由于这个原因,所以你会看到多个SYS行。SYS拥有这些包,但是它们都属于我的会话。

要看到一个实际的可中断解析锁,下面先创建并运行存储过程P

ops$tkyte@ORA 10G > create or replace procedure p as begin null; end;

2 /

Procedure created.

 

ops$tkyte@ORA 10G > exec p

PL/SQL procedure successfully completed.

过程P现在会出现在DBA_DDL_LOCKS视图中。我们有这个过程的一个解析锁:

然后重新编译这个过程,并再次查询视图:

ops$tkyte@ORA 10G > select session_id sid, owner, name, type,

2 mode_held held, mode_requested request

3 from dba_ddl_locks

4 /

SID   OWNER               NAME                                      TYPE                              HELD REQUEST

----     ---------                  ---------------------                          --------------------                   ----       ---------

161 OPS$TKYTE   P                                  Table/Procedure/Type        Null   None

161   SYS                    DBMS_UTILITY                       Body                               Null      None

161   SYS                    DBMS_UTILITY                       Body                               Null      None

...

161   SYS                    DBMS_OUTPUT                      Table/Procedure/Type     Null      None

12 rows selected.

可以看到,现在这个视图中没有P了。我们的解析锁被中断了。

这个视图对开发人员很有用,发现测试或开发系统中某段代码无法编译时,将会挂起并最终超时。这说明,有人正在使用这段代码(实际上在运行这段代码),你可以使用这个视图来查看这个人是谁。对于GRANTS和对象的其他类型的DDL也是一样。例如,无法对正在运行的过程授予EXECUTE权限。可以使用同样的方法来发现潜在的阻塞者和等待者。

6.3.3            

闩(latch)是轻量级的串行化设备,用于协调对共享数据结构、对象和文件的多用户访问。

闩就是一种锁,设计为只保持极短的一段时间(例如,修改一个内存中数据结构所需的时间)。闩用于保护某些内存结构,如数据库块缓冲区缓存或共享池中的库缓存。一般会在内部以一种“愿意等待”(willing to wait)模式请求闩。这说明,如果闩不可用,请求会话会睡眠很短的一段时间,并在以后再次尝试这个操作。还可以采用一种“立即”(immediate)模式请求其他闩,这与SELECT FOR UPDATE NOWAIT的思想很相似,说明这个进程会做其他事情(如获取另一个与之相当的空闲闩),而不只是坐而等待这个闩直到它可用。由于许多请求者可能会同时等待一个闩,你会看到一些进程等待的时间比其他进程要长一些。闩的分配相当随机,这要看运气好坏了。闩释放后,紧接着不论哪个会话请求闩都会得到它。等待闩的会话不会排队,只是一大堆会话在不断地重试。

Oracle使用诸如“测试和设置”(test and set)以及“比较和交换”(compare and swap)之类的原子指令来处理闩。由于设置和释放闩的指令是原子性的,尽管可能有多个进程在同时请求它,但操作系统本身可以保证只有一个进程能测试和设置闩。指令仅仅是一个指令而已,它执行得可能非常快。闩只保持很短的时间,而且提供了一种清理机制,万一某个闩持有者在持有闩时异常地“死掉了”,就能执行清理。这个清理过程由PMON执行。

队列锁(enqueue)在前面已经讨论过,这也是一种更复杂的串行化设备,例如,在更新数据库表中的行时就会使用队列锁。与闩的区别在于,队列锁允许请求者“排队”等待资源。对于闩请求,请求者会话会立即得到通知是否得到了闩。而对于队列锁,请求者会话会阻塞,直至真正得到锁。

注意    使用SELECT FOR UPDATE NOWAITWAIT [n],你还可以决定倘若会话被阻塞,则并不等待一个队列锁,但是如果确实阻塞并等待,就会在一个队列中等待。

因此,队列锁没有闩快,但是它确实能提供闩所没有的一些功能。可以在不同级别上得到队列锁,因此可以有多个共享锁以及有不同程度共享性的锁。

1.      闩“自旋”

关于闩还要了解一点:闩是一种锁,锁是串行化设备,而串行化设备会妨碍可扩缩性。如果你的目标是构建一个能在Oracle环境中很好地扩缩的应用,就必须寻找合适的方法和解决方案,尽量减少所需执行的闩定的量。

有些活动尽管看上去很简单(如解析一条SQL语句),也会为共享池中的库缓存和相关结构得到并释放数百个或数千个闩。如果我们有一个闩,可能会有另外某个人在等待这个闩。而当我们想要得到一个闩时,也许我们自己也必须等待(因为别人拥有着这个闩)。

等待闩可能是一个代价很高的操作。如果闩不是立即可用的,我们就得等待(大多数情况下都是如此),在一台多CPU机器上,我们的会话就会自旋(spin),也就是说,在循环中反复地尝试来得到闩。出现自旋的原因是,上下文切换(context switching)的开销很大(上下文切换是指被“踢出”CPU,然后又必须调度回CPU)。所以,如果进程不能立即得到闩,我们就会一直呆在CPU上,并立即再次尝试,而不是先睡眠,放弃CPU,等到必须调度回CPU时才再次尝试。之所以呆在CPU上,是因为我们指望闩的持有者正在另一个CPU上忙于处理(由于闩设计为只保持很短的时间,所以一般是这样),而且会很快放弃闩。如果出现自旋并不断地尝试想得到闩,但是之后还是得不到闩,此时我们的进程才会睡眠,或者让开CPU,而让其他工作进行。得到闩的伪代码如下所示:

Attempt to get Latch

If Latch gotten

Then

         return SUCCESS

Else

         Misses on that Latch = Misses+1;

         Loop

                   Sleeps on Latch = Sleeps + 1

                   For I in 1 .. 2000

                   Loop

                            Attempt to get Latch

                            If Latch gotten

                            Then

                                     Return SUCCESS

                            End if

                   End loop

                   Go to sleep for short period

         End loop

End if

其逻辑是,尝试得到闩,如果失败,则递增未命中计数(miss count),这个统计结果可以在Statspack报告中看到,或者直接查询V$LATCH视图也可以看到。一旦进程未命中,它就会循环一定的次数(有一个参数能控制这个次数,通常设置为2 000,但是这个参数在文档中未做说明),反复地试图得到闩。如果某次尝试成功,它就会返回,我们能继续处理。如果所有尝试都失败了,这个进程就会将该闩的睡眠计数(sleep count)递增,然后睡眠很短的一段时间。醒来时,整个过程会再重复一遍。这说明,得到一个闩的开销不只是“测试和设置”操作这么简单,我们尝试得到闩时,可能会耗费大量的CPU时间。系统看上去非常忙(因为消耗了很多CPU时间),但是并没有做多少实际的工作。

2.      测量闩定共享资源的开销

举个例子,我们来研究闩定共享池的开销。我们会把一个编写得很好的程序和一个编写得不太好的程序进行比较,前者使用了绑定变量,而在编写得不好的程序中,每条语句使用了SQL直接量或各不相同的SQL。为此,我们使用了一个很小的Java程序,它只是登录Oracle,关掉自动提交(所有Java程序在连接数据库后紧接着都应这么做),并通过一个循环执行25 000条不同的INSERT语句。我们会执行两组测试:在第一组测试中,程序不使用绑定变量;在第二组测试中,程序会使用绑定变量。

要评估这些程序以及它们在多用户环境中的行为,我喜欢用Statspack来收集度量信息,如下:

(1) 执行一个Statspack快照来收集系统的当前状态。

(2) 运行程序的N个副本,每个程序向其自己的数据库表中插入(INSERT),以避免所有程序都试图向一个表中插入而产生的竞争。

(3) 在最后一个程序副本执行后,紧接着取另一个快照。

然后只需打印出Statspack报告,并查看完成N个程序副本需要多长时间,使用了多少CPU时间,主要的等待事件是什么,等等。

这些测试在一台双CPU机器上执行,并启用了超线程(看上去就好像有4CPU)。给定两个物理CPU,你可能以为能线性扩缩,也就是说,如果一个用户使用了一个CPU单位来处理其插入,那么两个客户可能需要两个CPU单位。你会发现,这个假设尽管听上去好像是正确的,但可能并不正确(随后将会看到,不正确的程度取决于你的编程水平)。如果所要执行的处理不需要共享资源,这么说可能是正确的,但是我们的进程确实会使用一个共享资源,即共享池(Shared pool)。我们需要闩定共享池来解析SQL语句,为什么要闩定共享池呢?因为这是一个共享数据结构,别人在读取这个共享资源时,我们不能对其进行修改,另外如果别人正在修改它,我们就不能读取。

注意    我分别使用JavaPL/SQLPro*C和其他语言执行过这些测试。每一次的最终结果基本上都一样。这里所展示和讨论的内容适用于所有语言和所有数据库接口。这个例子之所以选择Java,是因为我发现处理Oracle数据库时,JavaVisual Basic应用最有可能不使用绑定变量。

l   不使用绑定变量

在第一个实例中,我们的程序不使用绑定变量,而是使用串连接来插入数据:

import java.sql.*;

public class instest

{

         static public void main(String args[]) throws Exception

         {

                   DriverManager.registerDriver(new oracle.jdbc.driver.OracleDriver());

                   Connection

                   conn = DriverManager.getConnection

                            ("jdbc:oracle:thin:@dellpe:1521:ora10gr1",

                            "scott","tiger");

                   conn.setAutoCommit( false );

                   Statement stmt = conn.createStatement();

                   for( int i = 0; i < 25000; i++ )

                   {

                            stmt.execute

                                     ("insert into "+ args[0] +

                                     " (x) values(" + i + ")" );

                   }

                   conn.commit();

                   conn.close();

         }

}

我以“单用户”模式运行这个测试,Statspack报告返回了以下信息:

Elapsed:          0.52 (mins)

Cache Sizes (end)

~~~~~~~~~~~~~~~~~

                    Buffer Cache:                     768M        Std Block Size:          8K

                    Shared Pool Size:             244M        Log Buffer:          1,024K

Load Profile

~~~~~~~~~~~~                                                      Per Second              Per Transaction

                                            ---------------                      ---------------

...

                    Parses:                                                   810.58                       12,564.00

                    Hard parses:                                          807.16                       12,511.00

....

 

Top 5 Timed Events

~~~~~~~~~~~~~~~~~~                                                            % Total

Event                                                        Waits        Time (s)   Call Time

--------------------------------------------        ------------   -----------    ---------

CPU time                                                                                26        55.15

class slave wait                                                  2              10        21.33

Queue Monitor Task Wait                                2              10        21.33

log file parallel write                                         48                 1           1.35

control file parallel write                                  14                 0              .51

这里加入了SGA配置以供参考,不过其中最重要的统计信息是:

q         耗用时间大约是30

q         每秒有807次硬解析

q         使用了26秒的CPU时间

现在,如果要同时运行这样的两个程序,你可能会认为硬解析会跃升至每秒1 600个(毕竟,我们有两个可用的CPU),并认为CPU时间会加倍为大约52秒。下面看一下:

Elapsed:          0.78 (mins)

Load Profile

~~~~~~~~~~~~                                   Per Second              Per Transaction

                                                                    ---------------                 ---------------

                    Parses:                                1,066.62                    16,710.33

                    Hard parses:                       1,064.28                    16,673.67

 

Top 5 Timed Events

~~~~~~~~~~~~~~~~~~                                                             % Total

Event                                                        Waits        Time (s)   Call Time

--------------------------------------------        ------------   -----------    ---------

CPU time                                                            74                            97.53

log file parallel write                                         53                 1           1.27

latch: shared pool                                          406                 1             .66

control file parallel write                                  21                 0             .45

log file sync                                                          6                 0             .04

可以发现,硬解析数比预计的稍微多了一点,但是CPU时间是原来的3倍而不是两倍!怎么会这样呢?答案就在于Oracle的闩定实现。在这台多CPU机器上,无法立即得到一个闩时,我们就会“自旋”。自旋行为本身会消耗CPU时间。进程1多次尝试想要得到共享池的闩,但最终只是一再地发现进程2持有着这个闩,所以进程1必须自旋并等待(这会消耗CPU时间)。反过来对进程2也是一样,通过多次尝试,它发现进程1正持有着所需资源的闩。所以,很多处理时间都没有花在正事上,只是在等待某个资源可用。如果把Statspack报告向下翻页到“Latch Sleep Breakdown”报告部分,可以发现:

Latch Name                                          Requests     Misses      Sleeps      Sleeps 1->3+

----------------                                            -------------    -----------        --------      ------------

shared pool                                          1,126,006    229,537            406      229135/398/4/0

library cache                                        1,108,039      45,582                 7      45575/7/0/0

注意到这里SLEEPS列怎么出现了一个406呢?这个406对应于前面“Top 5 Timed Events”报告中报告的等待数。这个报告显示了自旋循环中尝试得到闩并且失败的次数。这说明,“Top 5 报告只是显示了闩定问题的冰山一角,而没有给出共有229 537次未命中这一信息(这说明我们尝试得到闩时陷入了自旋)。尽管这里存在一个严重的硬解析问题,但是分析“Top 5 报告后,我们可能想不到:“这里有一个硬解析问题”。为了完成两个单位的工作,这里需要使用3个单位的CPU时间。其原因就在于:我们需要一个共享资源(即共享池),这正是闩定的本质所在。不过,除非我们知道闩定实现的原理,否则可能很难诊断与闩定相关的问题。简单地看一下Statspack报告,从“Top 5 部分我们可能会漏掉这样一个事实:此时存在很糟糕的扩缩问题。只有更深入地研究Statspack报告的闩定部分才会发现这个问题。

另外,由于存在这种自旋,通常不可能确定系统使用多少CPU时间,从这个两用户的测试所能知道的只是:我们使用了74秒的CPU时间,而且力图得到共享池闩时未命中次数共有229 537次。我们不知道每次得不到闩时要尝试多少次 ,所以没有具体的办法来度量有多少CPU时间花在自旋上,而有多少CPU时间用于处理。要得到这个信息,我们需要多个数据点。

在我们的测试中,由于有一个单用户例子可以对照比较,因此可以得出结论:大约22秒的CPU时间花费在闩的自旋上,这些CPU时间白白浪费在等待资源上。

l   使用了绑定变量

现在来看与上一节相同的情况,不过,这一次使用的程序在处理时使用的闩要少得多。还是用原来的Java程序,但把它重写为要使用绑定变量。为此,把Statement改为PreparedStatement,解析一条INSERT语句,然后在循环中反复绑定并执行这个PreparedStatement

import java.sql.*;

public class instest

{

         static public void main(String args[]) throws Exception

         {

         DriverManager.registerDriver(new oracle.jdbc.driver.OracleDriver());

         Connection

                   conn = DriverManager.getConnection

                            ("jdbc:oracle:thin:@dellpe:1521:ora10gr1",

                            "scott","tiger");

         conn.setAutoCommit( false );

         PreparedStatement pstmt =

                   conn.prepareStatement

                            ("insert into "+ args[0] + " (x) values(?)" );

         for( int i = 0; i < 25000; i++ )

         {

                   pstmt.setInt( 1, i );

                   pstmt.executeUpdate();

         }

         conn.commit();

         conn.close();

         }

}

与前面“不使用绑定变量”的例子一样,下面来看所生成的单用户情况和两个用户情况下的Statspack报告。可以看到这里有显著的差别。以下是单用户情况下的报告:

Elapsed:          0.12 (mins)

Load Profile

~~~~~~~~~~~~                       Per Second                Per Transaction

                                                        ---------------                   ---------------

...

                    Parses:                                8.43                            29.50

                    Hard parses:                       0.14                            0.50

 

Top 5 Timed Events

~~~~~~~~~~~~~~~~~~                                                             % Total

Event                                                        Waits        Time (s)   Call Time

--------------------------------------------        ------------   -----------    ---------

CPU time                                                                                  4        86.86

log file parallel write                                         49                 0        10.51

control file parallel write                                    4                 0           2.26

log file sync                                                          4                 0             .23

control file sequential read                          542                 0             .14

差别确实很大,不使用绑定变量的例子中需要26秒的CPU时间,现在只需4秒。原来每秒钟有807次硬解析,现在仅为每秒0.14次。甚至耗用时间也从45秒大幅下降到8秒。没有使用绑定变量时,我们的CPU时间中有5/6的时间都用于解析SQL。这并非都是闩导致的,因为没有使用绑定变量时,解析和优化SQL也需要许多CPU时间。解析SQLCPU密集型操作(需要耗费大量CPU时间),不过如果大幅增加CPU时间,但其中5/6CPU时间都只是用来执行我们并不需要的解析,而不是做对我们有用的事情,这个代价实在太昂贵了。

再来看两个用户情况下的测试,结果看上去更好:

Elapsed:          0.20 (mins)

Load Profile

~~~~~~~~~~~~                       Per Second                Per Transaction

                                                        ---------------                   ---------------

                    Parses:                                6.58                            26.33

                    Hard parses:                       0.17                            0.67

 

Top 5 Timed Events

~~~~~~~~~~~~~~~~~~                                                           % Total

Event                                                        Waits        Time (s)   Call Time

--------------------------------------------        ------------   -----------    ---------

CPU time                                                                                11        89.11

log file parallel write                                         48                 1           9.70

control file parallel write                                    4                 0             .88

log file sync                                                          5                 0             .23

log buffer space                                                  2                 0             .05

CPU时间大约是单用户测试用例所报告CPU时间的22.5倍。

注意    由于取整,4秒的CPU时间实际上是指3.54.49秒之间,11实际上表示10.511.49秒。

另外,使用绑定变量时,与不使用绑定变量的一个用户所需的CPU时间相比,两个用户使用的CPU时间还不到前者的一半!查看这个Statspack报告中的闩部分时,我发现,如果使用了绑定变量,则根本没有闩等待,对共享池和库缓存的竞争太少了,所以甚至没有相关的报告。实际上,再进一步挖掘还可以发现,使用绑定变量时,两用户测试中请求共享池闩的次数是50 367次,而在前面不使用绑定变量的两用户测试中,请求次数超过1 000 000次。

l   性能/可扩缩性比较

6-1总结了随着用户数的增加(超过2个),各个实现所用的CPU时间以及相应的闩定结果。可以看到,随着用户负载的增加,使用较少闩的方案能更好地扩缩。

6-1  使用和不使用绑定变量时CPU使用情况的比较

用户数            CPU时间(秒)                    共享池闩请求                        对闩等待的度量

             /耗用时间(分钟)                                                     (等待数/等待时间(秒))

      不使用绑定变量   使用绑定变量   不使用绑定变量   使用绑定变量   不使用绑定变量   使用绑定变量

1                      26/0.52                  4/0.10                   563 883                 25 232                           0/0                           

2                      74/0.78                11/0.20                1 126 006                 50 367                       406/1                           

3                    155/1.13                29/0.37                1 712 280                 75 541                    2 830/4                           

4                    272/1.50                44/0.45                2 298 179               100 682                    9 400/5                           

5                    370/2.03                64/0.62                2 920 219               125 933                13 800/20                           

6                    466/2.58                74/0.72                3 526 704               150 957                30 800/80                     17/0

7                    564/3.15                95/0.92                4 172 492               176 085              40 800/154                           

8                    664/3.57              106/1.00                4 734 793               201 351              56 300/240                   120/1

9                    747/4.05              117/1.15                5 360 188               230 516              74 600/374                   230/1

10                  822/4.42              137/1.30                5 901 981               251 434              60 000/450                   354/1

对我来说,我观察到很有意思的一点,如果10个用户使用绑定变量(所以闩请求很少),使用的硬件资源与不使用绑定变量的22.5个用户(也就是说,过量使用了闩,或者执行了本不需要的处理)所需的硬件资源相当。检查10个用户的结果时,可以看到,倘若未使用绑定变量,与使用了绑定变量的方案相比,所需的CPU时间是后者的6倍,执行时间也是后者的3.4倍。随着增加更多的用户,每个用户等待闩所花费的时间就更长。当有5个用户时,对闩的等待时间平均为4/会话,等到有10个用户时,平均等待时间就是45/会话。不过,如果采用了能避免过量使用闩的实现,则用户规模的扩大不会带来不好的影响。

6.3.4             手动锁定和用户定义锁

到此为止,前面主要了解了Oracle为我们所加的锁,这些锁定工作对我们来说都是透明的。更新一个表时,Oracle会为它加一个TM锁,以防止其他会话删除这个表(实际上,也会防止其他会话对这个表执行大多数DDL)。在我们修改的各个块上会加上TX锁,这样就能告诉别人哪些数据是“我们的”。数据库采用DDL锁来保护对象,这样当我们正在修改这些对象时,别人不会同时对它们进行修改。数据库在内部使用了闩和锁(lock)来保护自己的结构。

接下来,我们来看看如何介入这种锁定活动。有以下选择:

q         通过一条SQL语句手动地锁定数据。

q         通过DBMS_LOCK包创建我们自己的锁。

在后面的小节中,我们将简要地讨论这样做的目的。

1.      手动锁定

我们可能想使用手动锁定(manual locking),实际上,前面已经见过这样的几种情况了。SELECT...FOR UPDATE语句就是手动锁定数据的一种主要方法。在前面的例子中,曾经用过这个语句来避免丢失更新问题(也就是一个会话可能覆盖另一个会话所做的修改)。我们已经看到,可以用这种方法来串行访问详细记录,从而执行业务规则(例如,第1章中的资源调度程序示例)。

还可以使用LOCK TABLE语句手动地锁定数据。这个语句实际上很少使用,因为锁的粒度太大。它只是锁定表,而不是对表中的行锁定。如果你开始修改行,它们也会被正常地“锁定”。所以,这种方法不能节省资源(但在其他RDBMS中可以用这个方法节省资源)。如果你在编写一个大批量的更新,它会影响给定表中的大多数行,而且你希望保证没有人能“阻塞”你,就可以使用LOCK TABLE IN EXCLUSIVE MODE语句。通过以这种方式锁定表,就能确保你的更新能够执行所有工作,而不会被其他事务所阻塞。不过,有LOCK TABLE语句的应用确实很少见。

2.      创建你自己的锁

通过DBMS_LOCK包,Oracle实际上向开发人员公开了它在内部使用的队列锁(enqueue lock)机制。你可能会奇怪,为什么想创建你自己的锁呢?答案通常与应用有关。例如,你可能要使用这个包对Oracle外部的一些资源进行串行访问。假设你在使用UTL_FILE例程,它允许你写至服务器文件系统上的一个文件。你可能已经开发了一个通用的消息例程,每个应用都能调用这个例程来记录消息。由于这个文件是外部的,Oracle不会对试图同时修改这个文件的多个用户进行协调。现在,由于有了DBMS_LOCK包,在你打开、写入和关闭文件之前,可以采用排他模式请求一个锁(以文件命名),一次只能有一个人向这个文件写消息。这样所有人都会排队。通过利用DBMS_LOCK包,等你用完了锁之后能手动地释放这个锁,或者在你提交时自动放弃这个锁,甚至也可以在你登录期间一直保持这个锁。

6.4   小结

这一章介绍的内容很多,可能会让你觉得很难,不时地抓耳挠腮。尽管锁定本身相当直接,但是它的一些副作用却不是这样。关键是你要理解这些锁定问题。例如,倘若没有对外键加索引,Oracle会使用表锁来保证外键关系,如果你不知道这一点,你的应用就会性能很差。如果你不知道如何查看数据字典来得出谁锁住了什么,可能永远也发现不了到底是怎么回事。你可能只是认为数据库有时会“挂起”。有时,面对一个看上去无法解决的挂起问题,我只是运行一个查询来检测外键是不是没有索引,并建议对导致问题的外键加上索引,就能很好地解决问题。这种情况太常见了,我想如果每次解决这样一个问题就能得到1美元的报酬的话,我肯定会成为一个富翁。


 

你可能感兴趣的:(Oracle 9i & 10g编程艺术-深入数据库体系结构——第6章:锁)