欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Oracle 9i & 10g编程艺术-深入数据库体系结构——第一章 开发成功的Oracle应用程序(转)

程序员文章站 2022-07-14 18:32:46
...

 

第1章                      开发成功的Oracle应用程序

我花了大量时间使用Oracle数据库软件,更确切地讲,一直在与和使用Oracle数据库软件的人打交道。在过去的18年间,我参与过许多项目,有的相当成功,有点却彻底失败,如果把这些经验用几句话来概括,可以总结如下:

q         基于数据库(或依赖于数据库)构建的应用是否成功,这取决于如何使用数据库。另外,从我的经验看,所有应用的构建都围绕着数据库。如果一个应用未在任何地方持久地存储数据,很难想象这个应用真的有用。

q         应用总是在“来来去去”,而数据不同,它们会永远存在。从长远来讲,我们的目标并不是构建应用,而应该是如何使用这些应用底层的数据。

q         开发小组的核心必须有一些精通数据库的开发人员,他们要负责确保数据库逻辑是可靠的,系统能够顺利构建。如果已成事实(应用已经部署)之后再去调优,这通常表明,在开发期间你没有认真考虑这些问题。

这些话看上去再显然不过了。然而,我发现太多的人都把数据库当成是一个黑盒(black box),好像不需要对它有输入了解。他们可能有一个SQL生成器,认为有了这个工具,就不需要再费功夫去学SQL语言。也可能认为使用数据库就像使用平面文件一样,只需要根据索引读数据就行。不管他们怎么想,有一点可以告诉你,如果按这种思路来考虑,往往会被误导:不了解数据库,你将寸步难行。这一章将讨论为什么需要了解数据库,具体地讲,就是为什么需要理解以下内容:

q         数据库的体系结构,数据库如何工作,以及有怎样的表现。

q         并发控制是什么,并发控制对你意味着什么。

q         性能、可扩缩性和安全性都是开发时就应该考虑的需求,必须适当地做出设计,不要指望能碰巧满足这些需求。

q         数据库的特性如何实现。某个特定数据库特性的实际实现方式可能与你想象的不一样。你必须根据数据库实际上如何工作(而不是认为它应该如何工作)来进行设计。

q         数据库已经提供了哪些特性,为什么使用数据库已提供的特性要优于自行构建自己的特性。

q         为什么粗略地了解SQL还不够,还需要更深入地学习SQL

q         DBA和开发人员都在为同一个目标努力,他们不是敌对的两个阵营,不是想在每个回合中比试谁更聪明。

初看上去,好像要学习的东西还不少,不过可以做个对照,请考虑这样一个问题:如果你在一个全新的操作系统(operating systemOS)上开发一个高度可扩缩的企业应用,首先要做什么?希望你的答案是:“了解这个新操作系统如何工作,应用在它上面怎样运行,等等”。如果不是这样,你的开发努力将会付诸东流。

例如,可以考虑一下WindowsLinux,它们都是操作系统。能为开发人员提供大致相同的一组服务,如文件管理、内存管理、进程管理、安全性等。不过,这两个操作系统的体系结构却大相径庭。因此,如果你一直是Windows程序员,现在给你一个任务,让你在Linux平台上开发新应用,那么很多东西都得从头学起。内存管理的处理就完全不同。建立服务器进程的方式也有很大差异。在Window下,你会开发一个进程,一个可执行程序,但有许多线程。在Linux下则不同,不会开发单个独立的可执行程序;相反,会有多个进程协作。总之,你在Windows环境下学到的许多知识到了Linux上并不适用(公平地讲,反之亦然)。要想在新平台上也取得成功,你必须把原来的一些习惯丢掉。

在不同操作系统上运行的应用存在上述问题,基于不同数据库运行的应用也存在同样的问题:你要懂得,数据库对于成功至关重要。如果不了解你的数据库做什么,或者不清楚它怎么做,那你的应用很可能会失败。如果你认为应用在SQL Server上能很好地运行,那它在Oracle上也肯定能很好地工作,你的应用往往会失败。另外,公平地讲,反过来也一样:一个Oracle应用可能开发得很好,可扩缩性很好,但是如果不对体系结构做重大改变,它在SQL Server上不一定能正常运行。WindowsLinux都是操作系统,但是二者截然不同,同样地,OracleSQL Server(甚至可以是任何其他数据库)尽管都是数据库,但二者也完全不同。

1.1   我的方法

在阅读下面的内容之前我觉得有必要先解释一下我的开发方法。针对问题,我喜欢采用一种以数据库为中心的方法。如果能在数据库中完成,我肯定就会让数据库来做,而不是自行实现。对此有几个原因。首先,也是最重要的一点,我知道如果让数据库来实现功能,应用就可以部署在任何环境中。据我所知,没有哪个流行的服务器操作系统不支持Oracle;从Windows到一系列UNIX/Linux系统,再到OS/390大型机系统,都支持Oracle软件和诸多选项。我经常在我的笔记本电脑上构建和测试解决方案,其中在LinuxWindows XP上(或者使用VMware来模拟这些环境)运行Oracle9i/Oracle 10g。这样一来,我就能把这些解决方案部署在运行相同数据库软件但有不同操作系统的多种服务器上。我发现,如果某个特性不是在数据库中实现,要想在希望的任何环境中部署这个特性将极其困难。Java语言之所以让人趋之若鹜,一个主要原因就是Java程序总在同样的虚拟环境[即Java虚拟机(Java Virtual MachineJVM)]中编译,这使得这些程序的可移植性很好。有意思的是,也正是这个特性让我对数据库着迷不已。数据库就是我的“虚拟机”,它也是我的“虚拟操作系统”。

前面已经提到,我采用的方法是尽可能在数据库中实现功能。如果数据库环境无法满足我的需求,我也会在数据库之外使用JavaC来实现。采用这种方式,操作系统的复杂细节对我来说几乎是隐藏的。我要了解我的“虚拟机”如何工作(也就是Oracle如何工作,有时可能还需要用到JVM),毕竟,起码要了解自己使用的工具才行。不过,至于在一个给定的操作系统上怎么才能最好地工作,这些都由“虚拟机”来负责。

所以,只需知道这个“虚拟操作系统”的细节,我构建的应用就能在多种操作系统上很好地工作和扩缩。我并不是暗示你可以完全忽略底层的操作系统。不过,作为一个构建数据库应用的软件开发人员,还是尽量避开它比较好,你不必处理操作系统的诸多细微之处。应该让你的 DBA(负责运行Oracle软件)来考虑如何适应操作系统(如果他或她做不到,你就该换个新的DBA了!)。如果你在开发客户/服务器软件,而且大量代码都是在数据库和虚拟机(VMJVM可能是最流行的虚拟机了)之外实现,那你还得再次考虑你的操作系统。

对于开发数据库软件,我有一套很简单的哲学,这是我多年以来一直信守的思想:

q         如果可能,尽量利用一条SQL语句完成工作。

q         如果无法用一条SQL语句完成,就通过PL/SQL实现(不过,尽可能少用PL/SQL!)。

q         如果在PL/SQL中也无法做到(因为它缺少一些特性,如列出目录中的文件),可以试试使用Java存储过程来实现。不过,有了Oracle9i 及以上版本后,如今需要这样做的可能性极小。

q         如果用Java还办不到,那就在C外部过程中实现。如果速度要求很高,或者要使用采用C编写的一个第三方API,就常常使用这种做法。

q         如果在C外部例程中还无法实现,你就该好好想想有没有必要做这个工作了。

在这本书中,你会看到我是怎样将上述思想付诸实现的。我会尽可能使用SQL,充分利用它强大的新功能,如使用分析函数来解决相当复杂的问题,而不是求助于过程性代码。如果需要,我会使用PL/SQLPL/SQL中的对象类型来完成SQL本身办不到的事情。PL/SQL发展至今已经有很长时间了,它得到了长达18年的调整和优化。实际上,Oracle 10g编译器本身就首次重写为一个优化编译器。你会发现,没有哪种语言能像PL/SQL这样与SQL如此紧密地耦合,也没有哪种语言得到如此优化,可以与SQL更好地交互。在PL/SQL中使用SQL是一件相当自然的事情,而在几乎所有其他语言(从Visual BasicJava)中,使用SQL感觉上都很麻烦。对于这些语言来说,使用SQL绝对没有“自然”的感觉;它不是这些语言本身的扩缩。如果PL/SQL还无法做到(这在Oracle 9i10g中可能相当少见),我们会使用Java。有时,如果C是惟一的选择,或者需要C才能提供的高速度,我们也会用C来完成工作,不过这往往是最后一道防线。随着本地Java编译(native Java compilation)的闪亮登场(可以把Java字节码转换为具体平台上特定于操作系统的对象码),你会发现,在许多情况下,JavaC的运行速度相差无几。所以,需要用到C的情况越来越少。

1.2   黑盒方法 

根据我个人的第一手经验(这表示,在学习软件开发时我自己也曾犯过错误),我对基于数据库的软件开发为什么如此频繁地遭遇失败有一些看法。先来澄清一下,这里提到的这些项目可能一般不算失败,但是启用和部署所需的时间比原计划多出许多,原因是需要大幅重写,重新建立体系结构,或者需要充分调优。我个人把这些延迟的项目称为“失败”,因为它们原本可以按时完成(甚至可以更快完成)。

数据库项目失败的最常见的一个原因是对数据库的实际认识不足,缺乏对所用基本工具的了解。黑盒方法是指有意让开发人员对数据库退避三舍,甚至鼓励开发人员根本不要学习数据库!在很多情况下,开发人员没有充分利用数据库。这种方法的出现,原因可以归结为FUD[恐惧(fear)、不确定(uncertainty)和怀疑(doubt)]。一般都认为数据库“很难”,SQL、事务和数据完整性都“很难”。所以“解决方法”就是:不要卷入难题中,要知难而退。他们把数据库当成一个黑盒,利用一些软件工具来生成所有代码。他们试图利用重重保护与数据库完全隔离,以避免接触这么“难”的数据库。

我一直很难理解这种数据库开发方法,原因有二。一个原因是对我来说,学习JavaC比学习数据库基本概念要难多了。现在我对JavaC已经很精通,但是在能熟练使用JavaC之前我经受了许多磨炼,而掌握数据库则没有这么费劲。对于数据库,你要知道它是怎么工作的,但无需了解每一个细节。用CJava编程时,则确实需要掌握每一个细枝末节,而这些语言实在是很“庞大”。

让我无法理解这种方法的另一个原因是,构建数据库应用时,最重要的软件就是数据库。成功的开发小组都会认识到这一点,而且每个开发人员都要了解数据库,并把重点放在数据库上。但我接触到的许多项目中,情况却几乎恰恰相反。例如,下面就是一种典型的情况:

q         在构建前端所用的GUI工具或语言(如Java)方面,开发人员得到了充分的培训。在很多情况下,他们会有数周甚至数月的培训。

q         开发人员没有进行过Oracle培训,也没有任何Oracle经验。大多数人都没有数据库经验,所以并未理解如何使用核心的数据库构造(如各种可用的索引和表结构)。

q         开发人员力图谨守“数据库独立性”这一原则,但是出于许多原因,他们可能做不到。最明显的一个原因是:他们对于数据库没有足够的了解,也不清楚这些数据库可能有什么区别。这样一个开发小组无法知道要避开数据库的哪些特性才能保证数据库独立性。

q         开发人员遇到大量性能问题、数据完整性问题、挂起问题等(但这些应用的界面往往很漂亮)。

因为出现了无法避免的性能问题,他们把我找来,要求帮助解决这些难题。我最早就是从构建数据库独立的应用做起的(从某种程度上讲,在ODBC问世之前,我就已经编写了自己的ODBC驱动程序),我知道哪些地方可能会犯错误,因为我以前就曾犯过这些错误。我总会查看是否存在下面这些问题:存在效率低下的SQL;有大量过程性代码,但这些工作原本用一条SQL语句就足够了;为了保持数据库独立性,没有用到新特性(1995年以后的新特性都不敢用),等等。

我还记得这样一个特例,有个项目找我来帮忙。当时要用到一个新命令,但我记不清那个新命令的语法。所以我让他们给我拿一本SQL Reference手册,谁知他们给了我一本Oracle 6.0文档。那个项目开发用的是7.3版本,要知道,6.0版本和7.3版本之间整整相差5年!7.3才是所有开发人员使用的版本,但似乎谁都不关心这一点。不用说他们需要了解的跟踪和调优工具在6.0版本中甚至不存在。更不用说在这5年间又增加了诸如触发器、存储过程和数百个其他特性,这些都是编写6.0版文档(也就是他们现在参考的文档)时根本没有的特性。由此很容易看出他们为什么需要帮助,但解决起来就是另一码事了。

注意      甚至时至今日,已经到了2005年,我还是经常发现有些数据库应用开发人员根本不花些时间来看文档。我的网站(http://asktom.oracle.com)上经常会有:“……的语法是什么”这样的问题,并解释说“我们拿不到文档,所以请告诉我们”。对于许多这样的问题,我拒绝直接做出回答,而是会把在线文档的地址告诉他们。无论你身处何地,都能免费得到这些文档。在过去10年中,“我们没有文档”或“我们无法访问资源”之类的借口已经站不住脚了。如今已经有了诸如http://otn.oracle.comOracle技术网络)和http://groups. google.com (Google Groups Usenet论坛)等网站,它们都提供了丰富的资源,如果你手边还没有一套完整的文档,那就太说不过去了!

构建数据库应用的开发人员要避开数据库的主张实在让我震惊,不过这种做法还顽固不化地存在着。许多人还认为开发人员没办法花那么多时间来进行数据库培训,而且他们根本不需要了解数据库。为什么?我不止一次地听到这样的说法:“Oracle是世界上最可扩缩的数据库,所以我们不用了解它,它自然会按部就班地把事情做好的。”Oracle是世界上最可扩缩的数据库,这一点没错。不过,用Oracle不仅能写出好的、可扩缩的代码,也同样能很容易地写出不好的、不可扩缩的代码(这可能更容易)。把这句话里的“Oracle”替换为其他任何一种技术的名字,这句话仍然正确。事实是:编写表现不佳的应用往往比编写表现优秀的应用更容易。如果你不清楚自己在做什么,可能会发现你打算用世界上最可扩缩的数据库建立一个单用户系统!

数据库是一个工具;不论是什么工具,如果使用不当都会带来灾难。举个例子,你想用胡桃钳弄碎胡桃,会不会把胡桃钳当锤子一样用呢?当然这也是可以的,不过这样用胡桃钳很不合适,而且后果可能很严重,没准会重重地伤到你的手指。如果还是对你的数据库一无所知,你也会有类似的结局。

例如,最近我参与了一个项目。开发人员正饱受性能问题之苦,看上去他们的系统中许多事务在串行进行。他们的做法不是大家并发地工作,而是每个人都要排一个长长的队,苦苦等着前面的人完成后才能继续。应用架构师向我展示了系统的体系结构,这是经典的三层方法。他们想让Web浏览器与一个运行JSPJavaServer Pages)的中间层应用服务器通信。JSP再使用另一个EJBEnterprise JavaBeans)层,在这一层执行所有SQLEJB中的SQL由某个第三方工具生成,这是采用一种数据库独立的方式完成的。

现在看来,对这个系统很难做任何诊断,因为没有可测量或可跟踪的代码。测量代码(instrumenting code)堪称一门艺术,可以把开发的每行代码变成调试代码,这样就能跟踪应用的执行,遇到性能、容量甚至逻辑问题时就能跟踪到问题出在哪里。在这里,我们只能肯定地说问题出在“浏览器和数据库之间的某个地方”。换句话说,整个系统都是怀疑对象。对此有好消息也有坏消息。一方面,Oracle数据库完全可测量;另一方面,应用必须能够在适当的位置打开和关闭测量,遗憾的是,这个应用不具备这种能力。

所以,我们面对的困难是,要在没有太多细节的情况下诊断出导致性能问题的原因,我们只能依靠从数据库本身收集的信息。一般地,要分析应用的性能问题,采用应用级跟踪更合适。不过,幸运的是,这里的解决方案很简单。通过查看一些 Oracle V$表(V$ 表是Oracle 提供其测量结果或统计信息的一种方法),可以看出,竞争主要都围绕着一个表,这是一种排队表。结论是根据V$LOCK视图和V$SQL做出的,V$LOCK视图可以显示阻塞的会话,V$SQL会显示这些阻塞会话试图执行的SQL。应用想在这个表中放记录,而另外一组进程要从表中取出记录并进行处理。通过更深入地“挖掘”,我们发现这个表的PROCESSED_FLAG列上有一个位图索引。

注意    12章会详细介绍位图索引,并讨论为什么位图索引只适用于低基数值,但是对频繁更新的列不适用。

原因在于,PROCESSED_FLAG列只有两个值:YN。对于插入到表中的记录,该列值为N(表示未处理)。其他进程读取和处理这个记录时,就会把该列值从N更新为Y。这些进程要很快地找出PROCESSED_FLAG列值为N的记录,所以开发人员知道,应该对这个列建立索引。他们在别处了解到,位图索引适用于低基数(low-cardinality)列,所谓低基数列就是指这个列只有很少的可取值,所以看上去位图索引是一个很自然的选择。

不过,所有问题的根由正是这个位图索引。采用位图索引,一个键指向多行,可能数以百计甚至更多。如果更新一个位图索引键,那么这个键指向的数百条记录会与你实际更新的那一行一同被有效地锁定。

所以,如果有人插入一条新记录(PROCESSED_FLAG列值为N),就会锁定位图索引中的N键,而这会有效地同时锁定另外数百条PROCESSED_FLAG列值为N的记录(以下记作N记录)。此时,想要读这个表并处理记录的进程就无法将N记录修改为Y记录(已处理的记录)。原因是,要想把这个列从N更新为Y,需要锁定同一个位图索引键。实际上,想在这个表中插入新记录的其他会话也会阻塞,因为它们同样想对这个位图索引键锁定。简单地讲,开发人员实现了这样一组结构,它一次最多只允许一个人插入或更新!

可以用一个简单的例子说明这种情况。在此,我使用两个会话来展示阻塞很容易发生:

ops$tkyte@ORA10G> create table t ( processed_flag varchar2(1) );

Table created.

ops$tkyte@ORA10G> create bitmap index t_idx on t(processed_flag);

Index created.

ops$tkyte@ORA10G> insert into t values ( 'N' );

1 row created.

现在,如果我在另一个SQL*Plus会话中执行以下命令:

ops$tkyte@ORA10G> insert into t values ( 'N' );

这条语句就会“挂起”,直到在第一个阻塞会话中发出COMMIT为止。

这里的问题就是缺乏足够的了解造成的;由于不了解数据库特性(位图索引),不清楚它做些什么以及怎么做,就导致这个数据库从一开始可扩缩性就很差。一旦找出了问题,修正起来就很容易了。处理标志列上确实要有一个索引,但不能是位图索引。这里需要一个传统的B*Tree索引。说服开发人员接受这个方案很是费了一番功夫,因为这个列只有两个不同的可取值,却需要使用一个传统的索引,对此没有人表示赞同。不过,通过仿真(我很热衷于仿真、测试和试验),我们证明了这确实是正确的选择。对这个列加索引有两种方法:

q         在处理标志列上创建一个索引。

q         只在处理标志为N时在处理标志列上创建一个索引,也就是说,只对感兴趣的值加索引。通常,如果处理标志为Y,我们可能不想使用索引,因为表中大多数记录处理标志的值都可能是Y。注意这里的用辞,我没有说“我们绝对不想使用索引”,如果出于某种原因需要频繁地统计已处理记录的数目,对已处理记录加索引可能也很有用。

最后,我们只在处理标志为N的记录上创建了一个非常小的索引,由此可以快速地访问感兴趣的记录。

到此就结束了吗?没有,绝对没有结束。开发人员的解决方案还是不太理想。由于他们对所用工具缺乏足够的了解,我们只是修正了由此导致的主要问题,而且经过大量研究后才发现系统不能很好地测量。我们还没有解决以下问题:

q         构建应用时根本没有考虑过可扩缩性。可扩缩性必须在设计中加以考虑。

q         应用本身无法调优,甚至无法修改。经验证明,80%90%的调优都是在应用级完成的,而不是在数据库级。

q         应用完成的功能(排队表)实际上在数据库中已经提供了,而且数据库是以一种高度并发和可扩缩的方式提供的。我指的就是数据库已有的高级排队(Advanced QueuingAQ)软件,开发人员没有直接利用这个功能,而是在埋头重新实现。

q         开发人员不清楚bean在数据库中做了什么,也不知道出了问题要到哪里去查。

这个项目的问题大致如此,所以我们需要解决以下方面的问题:

q         如何对SQL调优而不修改SQL。这看起来很神奇,不过在Oracle 10g中确实可以办得到,从很大程度上讲堪称首创。

q         如何测量性能。

q         如何查看哪里出现了瓶颈。

q         如何建立索引,对什么建立索引。

q         如此等等。

一周结束后,原本对数据库敬而远之的开发人员惊讶地发现,数据库居然能提供如此之多的功能,而且了解这些信息是如此容易。最重要的是,这使得应用的性能发生了翻天覆地的变化。最终他们的项目还是成功了,只是比预期的要晚几个星期。

这个例子不是批评诸如EJB和容器托管持久存储之类的工具或技术。我们要批评的是故意不去了解数据库,不去学习数据库如何工作以及怎样使用数据库这种做法。这个案例中使用的技术本来能很好地工作,但要求开发人员对数据库本身有一些深入的了解。

关键是:数据库通常是应用的基石。如果它不能很好地工作,那其他的都没有什么意义了。如果你手上有一个黑盒,它不能正常工作,你能做些什么呢?可能只能做一件事,那就是盯着这个黑盒子发愣,搞不明白为什么它不能正常工作。你没办法修理它,也没办法进行调整。你根本不知道它是怎样工作的,最后的决定只能是保持现状。我提倡的方法则是:了解你的数据库,掌握它是怎么工作的,弄清楚它能为你做什么,并且最大限度地加以利用。

1.3   开发数据库应用的正确(和不正确)方法 

到目前为止,我一直是通过闲聊来强调理解数据库的重要性。本章后面则会靠实验说话,明确地讨论为什么了解数据库及其工作原理对于成功的实现大有帮助(而无需把应用写两次!)。有些问题修改起来很简单,只要你知道怎么发现这些问题即可。还有一些问题则不同,必须大动干戈地重写应用方能更正。这本书的目标之一就是首先帮助避免问题的发生。

注意      在下面的几节中,我会谈到一些核心的Oracle特性,但并不深入地讨论这些特性到底是什么,也不会全面地介绍使用这些特性的全部细节。如果想了解更多的信息,建议你接着阅读本书后面的章节,或者参考相关的Oracle文档。

1.3.1             了解Oracle体系结构

最近,我参与了一个客户的项目,他运行着一个大型的生产应用。这个应用已经从SQL Server“移植到”Oracle。之所以把“移植”一词用引号括起来,原因是我看到的大多数移植都只是“怎么能只对SQL Server代码做最少的改动,就让它们在Oracle上编译和执行”。要把应用从一个数据库真正移植到另一个数据库,这绝对是一项大工程。必须仔细检查算法,看看算法在目标数据库上能否正确地工作;诸如并发控制和锁定机制等特性在不同的数据库中实现不同,这会直接影响应用在不同数据库上如何工作。另外还要深入地分析算法,看看在目标数据库中实现这些算法是否合理。坦率地讲,我看到的大多数应用通常都是根据“最小移植”得来的,因为这些应用最需要帮助。当然,反过来也一样:把一个Oracle应用简单地“移植”到SQL Server,而且尽可能地避免改动,这也会得到一个很成问题、表现极差的应用。

无论如何,这种“移植”的目标都是扩缩应用,要支持更大的用户群。不过,“移植”应用的开发人员一方面想达到这个目的,另一方面又想尽量少出力。所以,这些开发人员往往保持客户和数据库层的体系结构基本不变,简单地将数据从SQL Server移到Oracle,而且尽可能不改动代码。如果决定将原来SQL Server上的应用设计原封不动地用在Oracle上,就会导致严重的后果。这种决定最糟糕的两个后果是:

q         Oracle中采用与SQL Server同样的数据库连接体系结构。

q         开发人员在SQL中使用直接量(而不是绑定变量)。

这两个结果导致系统无法支持所需的用户负载(数据库服务器的可用内存会耗尽),即使用户能登录和使用应用,应用的性能也极差。

1.      Oracle中使用一个连接

目前SQL Server中有一种很普遍的做法,就是对想要执行的每条并发语句都打开一个数据库连接。如果想执行5个查询,可能就会在SQL Server中看到5个连接。SQL Server就是这样设计的,就好像Windows是针对多线程而不是多进程设计的一样。在Oracle中,不论你想执行5个查询还是500个查询,都希望最多打开一个连接。Oracle就是本着这样的理念设计的。所以,SQL Server中常用的做法在Oracle中却不提倡;你可能并不想维护多个数据库连接。

不过,他们确实这么做了。一个简单的Web应用对于每个网页可能打开5个、10个、15个甚至更多连接,这意味着,相对于服务器原本能支持的并发用户数,现在服务器只能支持其1/51/101/15甚至更少的并发用户数。另外,开发人员只是在Windows平台本身上运行数据库,这是一个平常的Windows XP服务器,而没有使用Datacenter版本的Windows。这说明,Windows单进程体系结构会限制Oracle数据库服务器总共只有大约1.75 GBRAM。由于每个Oracle连接要同时处理多条语句,所以Oracle连接通常比SQL Server连接占用更多的RAM(不过Oracle连接比SQL Server连接能干多了)。开发人员能否很好地扩缩应用,很大程度上受这个硬件的限制。尽管服务器上有8 GBRAM,但是真正能用的只有2 GB左右。

注意      Windows环境中还能通过其他办法得到更多的RAM,如利用/AWE开关选项,但是只有诸如Windows Server Datacenter Edition等版本的操作系统才支持这个选项,而在这个项目中并没有使用这种版本。

针对这个问题,可能的解决方案有3种,无论哪一种解决方案都需要做大量工作(另外要记住,这可是在原先以为“移植”已经结束的情况下补充的工作!)。具体如下:

q         重建应用,充分考虑到这样一个事实:应用是在Oracle上运行,而不是在另外某个数据库上;另外生成一个页面只使用一个连接,而不是515个连接。这是从根本上解决这个问题的惟一方法。

q         升级操作系统(这可不是小事情),使用Windows Datacenter版本中更大的内存模型(这本身就非区区小事,而且还会带来相当复杂的数据库安装,需要一些间接的数据缓冲区和其他非标准的设置)。

q         把数据库从Windows系列操作系统移植到另外某个使用多进程的操作系统,以便数据库使用所安装的全部RAM(重申一遍,这个任务也不简单)。

可以看到,以上都不是轻轻松松就能办到的。不论哪种方法,你都不会毫无芥蒂地一口应允“好的,我们下午就来办”。每种方案都相当复杂,所要解决的问题原本在数据库“移植”阶段修正才最为容易,那是你查看和修改代码的第一个机会。另外,如果交付生产系统之前先对“可扩缩性”做一个简单的测试,就能在最终用户遭遇苦痛之前及时地捕捉到这些问题。

2.      使用绑定变量

如果我要写一本书谈谈如何构建不可扩缩的Oracle应用,肯定会把“不要使用绑定变量”作为第一章和最后一章的标题重点强调。这是导致性能问题的一个主要原因,也是阻碍可扩缩性的一个重要因素。Oracle将已解析、已编译的SQL连同其他内容存储在共享池(shared pool)中,这是系统全局区(System Global Area SGA)中一个非常重要的共享内存结构。第4章将详细讨论共享池。这个结构能完成“平滑”操作,但有一个前提,要求开发人员在大多数情况下都会使用绑定变量。如果你确实想让Oracle缓慢地运行,甚至几近停顿,只要根本不使用绑定变量就可以办到。

绑定变量(bind variable)是查询中的一个占位符。例如,要获取员工123的相应记录,可以使用以下查询:

select * from emp where empno = 123;

或者,也可以将绑定变量:empno设置为123,并执行以下查询:

select * from emp where empno = :empno;

在典型的系统中,你可能只查询一次员工123,然后不再查询这个员工。之后,你可能会查询员工456,然后是员工789,如此等等。如果在查询中使用直接量(常量),那么每个查询都将是一个全新的查询,在数据库看来以前从未见过,必须对查询进行解析、限定(命名解析)、安全性检查、优化等。简单地讲,就是你执行的每条不同的语句都要在执行时进行编译。

第二个查询使用了一个绑定变量:empno,变量值在查询执行时提供。这个查询只编译一次,随后会把查询计划存储在一个共享池(库缓存)中,以便以后获取和重用这个查询计划。以上两个查询在性能和可扩缩性方面有很大差别,甚至可以说有天壤之别。

从前面的描述应该能清楚地看到,与重用已解析的查询计划(称为软解析,soft parse)相比,解析包含有硬编码变量的语句(称为硬解析,hard parse)需要的时间更长,而且要消耗更多的资源。硬解析会减少系统能支持的用户数,但程度如何不太明显。这部分取决于多耗费了多少资源,但更重要的因素是库缓存所用的闩定(latching)机制。硬解析一个查询时,数据库会更长时间地占用一种低级串行化设备,这称为闩(latch),有关的详细内容请参见第6章。这些闩能保护Oracle共享内存中的数据结构不会同时被两个进程修改(否则,Oracle最后会得到遭到破坏的数据结构),而且如果有人正在修改数据结构,则不允许另外的人再来读取。对这些数据结构加闩的时间越长、越频繁,排队等待闩的进程就越多,等待队列也越长。你可能开始独占珍贵的资源。有时你的计算机显然利用不足,但是数据库中的所有应用都运行得非常慢。造成这种现象的原因可能是有人占据着某种串行化设备,而其他等待串行化设备的人开始排队,因此你无法全速运行。数据库中只要有一个应用表现不佳,就会严重地影响所有其他应用的性能。如果只有一个小应用没有使用绑定变量,那么即使其他应用原本设计得很好,能适当地将已解析的SQL放在共享池中以备重用,但因为这个小应用的存在,过一段时间就会从共享池中删除已存储的SQL。这就使得这些设计得当的应用也必须再次硬解析SQL。真是一粒老鼠屎就能毁了一锅汤。

如果使用绑定变量,无论是谁,只要提交引用同一对象的同一个查询,都会使用共享池中已编译的查询计划。这样你的子例程只编译一次就可以反复使用。这样做效率很高,这也正是数据库期望你采用的做法。你使用的资源会更少(软解析耗费的资源相当少),不仅如此,占用闩的时间也更短,而且不再那么频繁地需要闩。这些都会改善应用的性能和可扩缩性。

要想知道使用绑定变量在性能方面会带来多大的差别,只需要运行一个非常小的测试来看看。在这个测试中,将在一个表中插入一些记录行。我使用如下所示的一个简单的表:

ops$tkyte@ORA9IR2> drop table t;

Table dropped.

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

Table created.

下面再创建两个非常简单的存储过程。它们都向这个表中插入数字110 000;不过,第一个过程使用了一条带绑定变量的SQL语句:

ops$tkyte@ORA9IR2> create or replace procedure proc1

2          as

3          begin

4                   for i in 1 .. 10000

5                   loop

6                            execute immediate

7                     'insert into t values ( :x )' using i;

8                   end loop;

9          end;

10 /

Procedure created.

第二个过程则分别为要插入的每一行构造一条独特的SQL语句:

ops$tkyte@ORA9IR2> create or replace procedure proc2

2 as

3 begin

4          for i in 1 .. 10000

5          loop

6                   execute immediate

7               'insert into t values ( '||i||')';

8          end loop;

9 end;

10 /

Procedure created.

现在看来,二者之间惟一的差别,是一个过程使用了绑定变量,而另一个没有使用。它们都使用了动态SQL(所谓动态SQL是指直到运行时才确定的SQL),而且过程中的逻辑也是相同的。不同之处只在于是否使用了绑定变量。

下面用我开发的一个简单工具runstats对这两个方法详细地进行比较:

注意      关于安装runstats和其他工具的有关细节,请参见本书开头的“配置环境”一节。

ops$tkyte@ORA9IR2> exec runstats_pkg.rs_start

PL/SQL procedure successfully completed.

 

ops$tkyte@ORA9IR2> exec proc1

PL/SQL procedure successfully completed.

 

ops$tkyte@ORA9IR2> exec runstats_pkg.rs_middle

PL/SQL procedure successfully completed.

 

ops$tkyte@ORA9IR2> exec proc2

PL/SQL procedure successfully completed.

 

ops$tkyte@ORA9IR2> exec runstats_pkg.rs_stop(1000)

Run1 ran in 159 hsecs

Run2 ran in 516 hsecs

run 1 ran in 30.81% of the time

结果清楚地显示出,从墙上时钟来看,proc2(没有使用绑定变量)插入10 000行记录的时间比proc1(使用了绑定变量)要多出很多。实际上,proc2需要的时间是proc13倍多,这说明,在这种情况下,对于每个“无绑定变量”的INSERT,执行语句所需时间中有2/3仅用于解析语句!

注意      如果愿意,也可以不用runstats,而是在SQL*Plus中执行命令SET TIMING ON,然后运行proc1proc2,这样也能进行比较。

不过,对于proc2,还有更糟糕的呢!runstats工具生成了一个报告,显示出这两种方法在闩利用率方面的差别,另外还提供了诸如解析次数之类的统计结果。这里我要求runstats打印出差距在1 000以上的比较结果(这正是rs_stop调用中1000的含义)。查看这个信息时,可以看到各方法使用的资源存在显著的差别:

Name                                                      Run1                 Run2             Diff

STAT...parse count (hard)                            4              10,003         9,999

LATCH.library cache pin                    80,222            110,221       29,999

LATCH.library cache pin alloca        40,161              80,153      39,992

LATCH.row cache enqueue latch            78              40,082      40,004

LATCH.row cache objects 98           40,102              40,004

LATCH.child cursor hash table 35   80,023              79,988

LATCH.shared pool                            50,455            162,577    112,122

LATCH.library cache                        110,524            250,510    139,986

Run1 latches total versus runs -- difference and pct

Run1                   Run2                 Diff                 Pct

407,973          889,287        481,314        45.88%

PL/SQL procedure successfully completed.

注意    你自己测试时可能会得到稍微不同的值。如果你得到的数值和上面的一样,特别是如果闩数都与我的测试结果完全相同,那倒是很奇怪。不过,假设你也像我一样,也是在Linux平台上使用Oracle9i Release 2,应该能看到类似的结果。不论哪个版本,可以想见,硬解析处理每个插入所用的闩数总是要高于软解析(对于软解析,更确切的说法应该是,只解析一次插入,然后反复执行)。还在同一台机器上,但是如果使用 Oracle 10g Release 1执行前面的测试,会得到以下结果:与未使用绑定变量的方法相比,绑定变量方法执行的耗用时间是前者的1/10,而所用的闩总数是前者的17%。这有两个原因,首先,10g 是一个新的版本,有一些内部算法有所调整;另一个原因是在10g中,PL/SQL采用了一种改进的方法来处理动态SQL

可以看到,如果使用了绑定变量(后面称为绑定变量方法),则只有4次硬解析;没有使用绑定变量时(后面称为无绑定变量方法),却有不下10 000次的硬解析(每次插入都会带来一次硬解析)。还可以看到,无绑定变量方法所用的闩数是绑定变量方法的两倍之多。这是因为,要想修改这个共享结构,Oracle必须当心,一次只能让一个进程处理(如果两个进程或线程试图同时更新同一个内存中的数据结构,将非常糟糕,可能会导致大量破坏)。因此,Oracle采用了一种闩定(latching)机制来完成串行化访问,闩(latch)是一种轻量级锁定设备。不要被“轻量级”这个词蒙住了,作为一种串行化设备,闩一次只允许一个进程短期地访问数据结构。闩往往被硬解析实现滥用,而遗憾的是,这正是闩最常见的用法之一。共享池的闩和库缓存的闩就是不折不扣的闩;它们成为人们频繁争抢的目标。这说明,想要同时硬解析语句的用户越多,性能问题就会变得越来越严重。人们执行的解析越多,对共享池的闩竞争就越厉害,队列会排得越长,等待的时间也越久。

注意    如果机器的处理器不止一个,在9i 和以上版本中,共享池还可以划分为多个子池,每个子池都由其自己的闩保护。这样即使应用没有使用绑定变量,也可以提高可扩缩性,但是这并没有从根本上克服闩定问题。

执行无绑定变量的SQL语句,很像是在每个方法调用前都要编译子例程。假设把Java源代码交付给客户,在调用类中的方法之前,客户必须调用Java编译器,编译这个类,再运行方法,然后丢掉字节码。下一次想要执行同样的方法时,他们还要把这个过程再来一遍:先编译,再运行,然后丢掉字节码。你肯定不希望在应用中这样做。数据库里也应该一样,绝对不要这样做。

对于这个特定的项目,可以把现有的代码改写为使用绑定变量,这是最好的做法。改写后的代码与原先比起来,速度上有呈数量级的增长,而且系统能支持的并发用户数也增加了几倍。不过,在时间和精力投入方面却要付出很大的代价。并不是说使用绑定变量有多难,也不是说使用绑定变量容易出错,而只是因为开发人员最初没有使用绑定变量的意识,所以必须回过头去,几乎把所有代码都检查和修改一遍。如果他们从第一天起就很清楚在应用中使用绑定变量至关重要,就不用费这么大的功夫了。

1.3.2             理解并发控制

并发控制在不同的数据库中各不相同。正是因为并发控制,才使得数据库不同于文件系统,也使得不同的数据库彼此有所区别。你的数据库应用要在并发访问条件下正常地工作,这一点很重要,但这也是人们时常疏于测试的一个方面。有些技术在一切都顺序执行的情况下可能工作得很好,但是如果任务要同时进行,这些技术的表现可能就差强人意了。如果对你的特定数据库如何实现并发控制了解不够,就会遭遇以下结果:

q         破坏数据的完整性。

q         随着用户数的增多,应用的运行速度减慢。

q         不能很好地扩缩应用来支持大量用户。

注意我没有说“你可能……”或者“有……的风险”,而是直截了当地说:如果没有适当的并发控制,甚至如果未能充分了解并发控制,你肯定会遇到这些情况。如果没有正确的并发控制,就会破坏数据库的完整性,因为有些技术单独能工作,但是在多用户情况下就不能像你预期的那样正常工作了。你的应用会比预想的运行得慢,因为它总在等待资源。另外因为存在锁定和竞争问题,你将不能很好地扩缩应用。随着访问资源的等待队列变得越来越长,等待的时间也会越来越久。

这里可以打个比方,考虑一下发生在收费站的阻塞情况。如果所有汽车都以顺序的、可预料的方式到来,一辆接着一辆,就不会出现阻塞。但是,如果多辆车同时到达,就要排队。另外,等待时间并不是按照到达收费站的车辆数量线性增长。达到某个临界点后,一方面要花费大量额外的时间来“管理”排队等待的人,另一方面还要为他们提供服务(在数据库中这称为上下文切换)。

并发问题最难跟踪,就像调试多线程程序一样。在调试工具控制的人工环境下,程序可能工作得很好,但到实际中却可怕地崩溃了。例如,在竞争条件下,你会发现两个线程力图同时修改同一个数据结构。这种bug跟踪起来非常困难,也很难修正。如果你只是独立地测试你的应用,然后部署,并交给数十个并发用户使用,就很有可能痛苦地遭遇原先未能检测到的并发问题。

在下面两节中,我会通过两个小例子来谈谈对并发控制缺乏了解可能会破坏你的数据,或者会影响应用的性能和可扩缩性。

1.      实现锁定

数据库使用锁(lock)来保证任何给定时刻最多只有一个事务在修改给定的一段数据。实质上讲,正是锁机制才使并发控制成为可能。例如,如果没有某种锁定模型来阻止对同一行的并发更新,数据库就不可能提供多用户访问。不过,如果滥用或者使用不当,锁反倒会阻碍并发性。如果你或数据库本身不必要地对数据锁定,能并发地完成操作的人数就会减少。因此,要理解什么是锁定,你的数据库中锁定是怎样工作的,这对于开发可扩缩的、正确的应用至关重要。

还有一点很重要,你要知道每个数据库会以不同的方式实现锁定。有些数据库可能有页级锁,另外一些则有行级锁;有些实现会把行级锁升级为页级锁,另外一些则不然;有些使用读锁,另外一些不使用;有些通过锁定实现串行化事务,另外一些则通过数据的读一致视图来实现(没有锁)。如果你不清楚这些微小的差别,它们就会逐步膨胀为严重的性能问题,甚至演变成致命的bug

以下是对Oracle锁定策略的总结:

q         Oracle只在修改时才对数据加行级锁。正常情况下不会升级到块级锁或表级锁(不过两段提交期间的一段很短的时间内除外,这是一个不常见的操作)。

q         如果只是读数据,Oracle绝不会对数据锁定。不会因为简单的读操作在数据行上锁定。

q         写入器(writer)不会阻塞读取器(reader)。换种说法:读(read)不会被写(write)阻塞。这一点几乎与其他所有数据库都不一样。在其他数据库中,读往往会被写阻塞。尽管听上去这个特性似乎很不错(一般情况下确实如此),但是,如果你没有充分理解这个思想,而且想通过应用逻辑对应用施加完整性约束,就极有可能做得不对。第7章介绍并发控制时还会更详细地讨论这个内容。

q         写入器想写某行数据,但另一个写入器已经锁定了这行数据,此时该写入器才会被阻塞。读取器绝对不会阻塞写入器。

开发应用时必须考虑到这些因素,而且还要认识到这个策略是Oracle所独有的,每个数据库实现锁定的方法都存在细微的差别。即使你在应用中使用最通用的SQL,由于各数据库开发商采用的锁定和并发控制模型不同,你的应用也可能有不同的表现。倘若开发人员不了解自己的数据库如何处理并发性,肯定会遇到数据完整性问题。(开发人员从另外某种数据库转向Oracle,或者从Oracle转向其他数据库时,如果没有考虑在应用中采用不同的并发机制,这种情况就尤为常见。)

2.      防止丢失更新

Oracle的无阻塞方法有一个副作用,如果确实想保证一次最多只有一个用户访问一行数据,开发人员就得自己做些工作。

考虑下面这个例子。一位开发人员向我展示了他刚开发的一个资源调度程序(可以用来调度会议室、投影仪等资源),这个程序正在部署当中。应用中实现了这样一个业务规则:在给定的任何时间段都不能将一种资源分配给多个人。也就是说,应用中包含了实现这个业务规则的代码,它会明确地检查此前这个时间片没有分配给其他用户(至少,这个开发人员认为是这样)。这段代码先查询SCHEDULES表,如果不存在与该时间片重叠的记录行(该时间片尚未分配),则插入新行。所以,开发人员主要考虑两个表:

create table resources ( resource_name varchar2(25) primary key, ... );

create table schedules

( resource_name references resources,

start_time date not null,

end_time date not null,

check (start_time < end_time ),

primary key(resource_name,start_time)

);

在分配资源(如预订房间)之前,应用将查询:

select count(*)

         from schedules

where resource_name = :room_name

         and (start_time <= :new_end_time)

         and (end_time >= :new_start_time)

看上去很简单,也很安全(在开发人员看来):如果得到的计数为0,这个房间就是你的了。如果返回的数非0,那在此期间你就不能预订这个房间。了解他的逻辑后,我建立了一个非常简单的测试,来展示这个应用运行时可能出现的一个错误,这个错误极难跟踪,而且事后也很难诊断。有人甚至以为这必定是一个数据库bug

我所做的其实很简单,就是让另外一个人使用这个开发人员旁边的一台机器,两个人都浏览同一个屏幕,然后一起数到3时,两人都单击Go按钮,尽量同时预订同一个房间,一个人想预订下午3:00到下午4:00这个时段,另一个人要预订下午3:30到下午4:00这个时段。结果两个人都预订成功。这个逻辑独立执行时原本能很好地工作,但到多用户环境中就不行了。为什么会出现这个问题?部分原因就在于Oracle的非阻塞读。这两个会话都不会阻塞对方,它们只是运行查询,然后完成调度房间的逻辑。两个会话都通过运行查询来查找是否已经有预订,尽管另一个会话可能已经开始修改SCHEDULES表,但查询看不到这些修改(所做的修改在提交之前对其他会话来说是不可见的,而等到提交时已为时过晚)。由于这两个会话并没有试图修改SCHEDULES表中的同一行,所以它们不会相互阻塞。由此说来,这个应用不能像预期的那样保证前面提到的业务规则。

开发人员需要一种方法使得这个业务规则在多用户环境下也能得到保证,也就是要确保一次只有一个人预订一种给定的资源。在这种情况下,解决方案就是加入他自己的一些串行化机制。他的做法是,在对SCHEDULES表进行修改之前,先对RESOURCES表中的父行锁定。这样一来,SCHEDULES表中针对给定RESOURCE_NAME值的所有修改都必须依次按顺序进行,一次只能执行一个修改。也就是说,要预订资源X一段时间,就要锁定RESOURCES表中对应X的那一行,然后修改SCHEDULES表。所以,除了前面的count(*)外,开发人员首先需要完成以下查询:

select * from resources where resource_name = :room_name FOR UPDATE;

这里,他在调度资源之前先锁定了资源(这里指房间),换句话说,就是在SCHEDULES表中查询该资源的预订情况之前先锁定资源。通过锁定所要调度的资源,开发人员可以确保别人不会同时修改对这个资源的调度。其他人都必须等待,直到他提交了事务为止,此时就能看到他所做的调度。这样就杜绝了调度重叠的可能性。

开发人员必须了解到,在多用户环境中,他们必须不时地采用多线程编程中使用的一些技术。在这里,FOR UPDATE子句的作用就像是一个信号量(semaphore),只允许串行访问RESOURCES表中特定的行,这样就能确保不会出现两个人同时调度的情况。我建议把这个逻辑实现为一个事务API,也就是说,把所有逻辑都打包进一个存储过程中,只允许应用通过这个API修改数据。代码如下:

create or replace procedure schedule_resource

( p_resource_name in varchar2,

p_start_time in date,

p_end_time in date

)

as

         l_resource_name resources.resource_name%type;

         l_cnt number;

begin

首先在RESOURCES表中锁定我们想调度的那个资源的相应行。如果别人已经锁定了这一行,我们就会阻塞并等待 :

         select resource_name into l_resource_name

         from resources

         where resource_name = p_resource_name

         FOR UPDATE;

既然我们已经有了锁,那么只有我们能在这个SCHEDULES表中插入对应此资源名的调度,所以如下查看这个表是安全的:

         select count(*)

                  into l_cnt

         from schedules

         where resource_name = p_resource_name

                  and (start_time <= p_end_time)

                  and (end_time >= p_start_time);

         if ( l_cnt <> 0 )

         then

                  raise_application_error

                           (-20001, 'Room is already booked!' );

         end if;

如果能运行到这里而没有发生错误,就可以安全地在SCHEDULES表中插入预订资源的相应记录行,而不用担心出现重叠:

         insert into schedules

                  ( resource_name, start_time, end_time )

         values

                  ( p_resource_name, p_start_time, p_end_time );

end schedule_resources;

这个解决方案仍是高度并发的,因为可能有数以千计要预订的资源。这里的做法是,确保任何时刻只能有一个人修改资源。这是一种很少见的情况,在此要对并不会真正更新的数据手动锁定。我们要知道哪些情况下需要这样做,还要知道哪些情况下不需要这样做(稍后会给出这样一个例子),这同样很重要。另外,如果别人只是读取数据,就不会锁定资源不让他们读(但在其他数据库中可能不是这样),所以这种解决方案可以很好地扩缩。

如果你想把应用从一个数据库移植到另一个数据库,这一节讨论的问题就有很大的影响(本章后面还会再谈这个内容),而且可能会一再地把人“绊倒”。例如,如果你有使用另外某些数据库的经验,其中写入器会阻塞读取器,而且读取器也会阻塞写入器,你可能就会有成见,过分依赖这一点来避免数据完整性问题。一种解决办法是干脆不要并发,在许多非Oracle数据库中就是这样做的。但在Oracle中则是并发规则至上,因此,你必须知道可能会发生不同的情况(或者遭受不同的后果)。

注意    7章还会再次谈到这个例子。以上代码有一个前提,即假设事务隔离级别是READ COMMITTED。如果事务隔离级别是SERIALIZABLE,这个逻辑将无法正常工作。倘若现在就详细介绍这两种模式的区别,这一章就会变得过于复杂,所以这个内容以后再讨论。

99%的情况下,锁定是完全透明的,无需你来操心。但还有另外的1%,你必须清楚哪些情况下需要自己考虑锁定。对于这个问题,并没有非黑即白的直接结论,无法简单地罗列出“如果你要这样做,就应该这样做”之类的条条框框。关键是要了解应用在多用户环境中有何表现,另外在你的数据库中表现如何。

7章会更深入地讨论这个内容,你会进一步了解本节介绍的这种完整性约束,有些情况下,一个表中的多行必须遵循某个规则,或者两个表或多个表之间必须保证某个规则(如引用完整性约束),一定要特别注意这些情况,而且这些情况也最有可能需要采用手动锁定或者另外某种技术来确保多用户环境下的完整性。

1.3.3             多版本

这个主题与并发控制的关系非常紧密,因为这正是Oracle并发控制机制的基础,Oracle采用了一种多版本、读一致(read-consistent)的并发模型。再次说明,我们将在第7章更详细地介绍有关的技术。不过,实质上讲,Oracle利用这种机制提供了以下特性:

q         读一致查询:对于一个时间点(point in time),查询会产生一致的结果。

q         非阻塞查询:查询不会被写入器阻塞,但在其他数据库中可能不是这样。

Oracle数据库中有两个非常重要的概念。多版本(multi-versioning)一词实质上指Oracle能够从数据库同时物化多个版本的数据。如果你理解了多版本如何工作,就会知道能从数据库得到什么。在进一步深入讨论Oracle如何实现多版本之前,下面用我认为最简单的一个方法来演示Oracle中的多版本:

ops$tkyte@ORA10G> create table t

2 as

3 select *

4 from all_users;

Table created.

 

ops$tkyte@ORA10G> variable x refcursor

ops$tkyte@ORA10G> begin

2 open :x for select * from t;

3 end;

4 /

PL/SQL procedure successfully completed.

 

ops$tkyte@ORA10G> delete from t;

28 rows deleted.

 

ops$tkyte@ORA10G> commit;

Commit complete.

 

ops$tkyte@ORA10G> print x

USERNAME                         USER_ID      CREATED

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

BIG_TABLE                           411                14-NOV-04

OPS$TKYTE                         410                14-NOV-04

DIY                                          69                   26-SEP-04

...

OUTLN                                   11                   21-JAN-04

SYSTEM                                5                     21-JAN-04

SYS                                        0                     21-JAN-04

28 rows selected.

在前面的例子中,我创建了一个测试表T,并把ALL_USERS表的一些数据加载到这个表中。然后在这个表上打开一个游标。在此没有从该游标获取数据,只是打开游标而已。

注意    要记住,Oracle并不“回答”这个查询。打开游标时,Oracle不复制任何数据,你可以想想看,即使一个表有十亿条记录,是不是也能很快就打开游标?没错,游标会立即打开,它会边行进边回答查询。换句话说,只是在你获取数据时它才从表中读数据。

在同一个会话中(或者也可以在另一个会话中;这同样能很好地工作),再从该表删除所有数据。甚至用COMMIT提交了删除所做的工作。记录行都没有了,但是真的没有了吗?实际上,还是可以通过游标获取到数据。OPEN命令返回的结果集在打开的那一刻(时间点)就已经确定。打开时,我们根本没有碰过表中的任何数据块,但答案已经是铁板钉钉的了。获取数据之前,我们无法知道答案会是什么;不过,从游标角度看,结果则是固定不变的。打开游标时,并非Oracle将所有数据复制到另外某个位置;实际上是DELETE命令为我们把数据保留下来,把它放在一个称为undo段(undo segment)的数据区,这个数据区也称为回滚段(rollback segment)。

读一致性(read-consistency)和多版本就是这么回事。如果你不了解Oracle的多版本机制是怎样工作的,不清楚这意味着什么,你就不可能充分利用Oracle,也不可能在Oracle上开发出正确的应用(也就是说,能确保数据完整性的应用)。

1.      多版本和闪回

过去,Oracle总是基于查询的某个时间点来做决定(从这个时间点开始查询是一致的)。也就是说,Oracle会保证打开的结果集肯定是以下两个时间点之一的当前结果集:

q         游标打开时的时间点。这是READ COMMITTED隔离模式的默认行为,该模式是默认的事务模式(第7章将介绍READ COMMITTEDREAD ONLYSERIALIZABLE事务级别之间的差别)。

q         查询所属事务开始的时间点。这是READ ONLYSERIALIZABLE隔离级别中的默认行为。

不过,从Oracle9i开始,情况要灵活得多。实际上,我们可以指示Oracle提供任何指定时间的查询结果(对于回放的时间长度有一些合理的限制;当然,这要由你的DBA来控制),这里使用了一种称为闪回查询(flashback query)的特性。

请考虑以下例子。首先得到一个SCN,这是指系统修改号(System Change Number)或系统提交号(System Commit Number);这两个术语可互换使用。SCNOracle的内部时钟:每次发生提交时,这个时钟就会向上滴答(递增)。实际上也可以使用日期或时间戳,不过这里SCN很容易得到,而且相当准确:

scot@ORA10G> variable SCN number

scott@ORA10G> exec :scn := dbms_flashback.get_system_change_number

PL/SQL procedure successfully completed.

scott@ORA10G> print scn

SCN

----------

33295399

现在可以让Oracle提供SCN值所表示时间点的数据。以后再查询Oracle时,就能看看这一时刻表中的内容。首先来看EMP表中现在有什么:

scott@ORA10G> select count(*) from emp;

COUNT(*)

----------

14

下面把这些信息都删除,并验证数据是否确实“没有了”:

scott@ORA10G> delete from emp;

14 rows deleted.

scott@ORA10G> select count(*) from emp;

COUNT(*)

----------

0

此外,使用闪回查询(即AS OF SCNAS OF TIMESTAMP子句),可以让Oracle告诉我们SCN值为33295399的时间点上表中有什么:

scott@ORA10G> select count(*) from emp AS OF SCN :scn;

COUNT(*)

----------

14

不仅如此,这个功能还能跨事务边界。我们甚至可以在同一个查询中得到同一个对象在“两个时间点”上的结果!因此可以做些有意思的事情:

scott@ORA10G> commit;

Commit complete.

 

scott@ORA10G> select *

2 from (select count(*) from emp),

3 (select count(*) from emp as of scn :scn)

4 /

COUNT(*)          COUNT(*)

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

0                          14

如果你使用的是Oracle 10g 及以上版本,就有一个“闪回”(flashback)命令,它使用了这种底层多版本技术,可以把对象返回到以前某个时间点的状态。在这个例子中,可以将EMP表放回到删除信息前的那个时间点:

scott@ORA10G> flashback table emp to scn :scn;

Flashback complete.

 

scott@ORA10G> select *

2 from (select count(*) from emp),

3 (select count(*) from emp as of scn :scn)

4 /

COUNT(*) COUNT(*)

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

14                       14

注意    如果你得到一个错误“ORA-08189: cannot flashback the table because row movement is not enabled using the FLASHBACK command”(ORA-08189:无法闪回表,因为不支持使用FLASHBACK命令完成行移动),就必须先执行一个命令:ALTER TABLE EMP ENABLE ROW MOVEMENT。这个命令的作用是,允许Oracle修改分配给行的rowid。在Oracle中,插入一行时就会为它分配一个rowid,而且这一行永远拥有这个rowid。闪回表处理会对EMP完成DELETE,并且重新插入行,这样就会为这些行分配一个新的rowid。要支持闪回就必须允许Oracle执行这个操作。

2.      读一致性和非阻塞读

下面来看多版本、读一致查询以及非阻塞读的含义。如果你不熟悉多版本,下面的代码看起来可能有些奇怪。为简单起见,这里假设我们读取的表在每个数据库块(数据库中最小的存储单元)中只存放一行,而且这个例子要全面扫描这个表。

我们查询的表是一个简单的ACCOUNTS表。其中包含了一家银行的账户余额。其结构很简单:

create table accounts

( account_number number primary key,

account_balance number

);

在实际中,ACCOUNTS表中可能有上百万行记录,但是为了力求简单,这里只考虑一个仅有4行的表(第7章还会更详细地分析这个例子),如表1-1所示。

1-1  ACCOUNTS 表的内容

                                 账户余额

1                     123                 $500.00

2                     234                 $250.00

3                     345                 $400.00

4                     456                 $100.00

我们可能想运行一个日报表,了解银行里有多少钱。这是一个非常简单的查询:

select sum(account_balance) from accounts;

当然,这个例子的答案很明显:$1 250.00。不过,如果我们现在读了第1行,准备读第2行和第3行时,一台自动柜员机(ATM)针对这个表发生了一个事务,将$400.00从账户123转到了账户456,又会怎么样呢?查询会计算出第4行的余额为$500.00,最后就得到了$1 650.00,是这样吗?当然,应该避免这种情况,因为这是不对的,任何时刻账户余额列中的实际总额都不是这个数。读一致性就是Oracle为避免发生这种情况所采用的办法,你要了解,与几乎所有的其他数据库相比,Oracle采用的方法有什么不同。

在几乎所有的其他数据库中,如果想得到“一致”和“正确”的查询答案,就必须在计算总额时锁定整个表,或者在读取记录行时对其锁定。这样一来,获取结果时就可以防止别人再做修改。如果提前锁定表,就会得到查询开始时数据库中的结果。如果在读取数据时锁定(这通常称为共享读锁(shared read lock),可以防止更新,但不妨碍读取器访问数据),就会得到查询结束时数据库中的结果。这两种方法都会大大影响并发性。由于存在表锁,查询期间会阻止对整个表进行更新(对于一个仅有4行的表,这可能只是很短的一段时间,但是对于有上百万行记录的表,可能就是几分钟之多)。“边读边锁定”的办法也有问题,不允许对已经读取和已经处理过的数据再做更新,实际上这会导致查询与其他更新之间产生死锁。

我曾经说过,如果你没有理解多版本的概念,就无法充分利用Oracle。下面告诉你一个原因。Oracle会利用多版本来得到结果,也就是查询开始时那个时间点的结果,然后完成查询,而不做任何锁定(转账事务更新第1行和第4行时,这些行会对其他写入器锁定,但不会对读取器锁定,如这里的SELECT SUM...查询)。实际上,Oracle根本没有“共享读”锁(这是其他数据库中一种常用的锁),因为这里不需要。对于可能妨碍并发性的一切因素,只要能去掉的,Oracle都已经去掉了。

我见过这样一些实际案例,开发人员没有很好地理解Oracle的多版本功能,他编写的查询报告将整个系统紧紧地锁起来。之所以会这样,主要是因为开发人员想从查询得到读一致的(即正确的)结果。这个开发人员以前用过其他一些数据库,在这些数据库中,要做到这一点都需要对表锁定,或者使用一个SELECT ... WITH HOLDLOCK(这是SQL Server中的一种锁定机制,可以边读取边以共享模式对行锁定)。所以开发人员想在运行报告前先对表锁定,或者使用SELECT ... FOR UPDATE(这是Oracle中与holdlock最接近的命令)。这就导致系统实质上会停止处理事务,而这完全没有必要。

那么,如果Oracle读取时不对任何数据锁定,那它又怎么能得到正确、一致的答案($1 250.00)呢?换句话说,如何保证得到正确的答案同时又不降低并发性?秘密就在于Oracle使用的事务机制。只要你修改数据,Oracle就会创建撤销(undo)条目。这些undo条目写至undo段(撤销段,undo segment)。如果事务失败,需要撤销,Oracle就会从这个回滚段读取“之前”的映像,并恢复数据。除了使用回滚段数据撤销事务外,Oracle还会用它撤销读取块时对块所做的修改,使之恢复到查询开始前的时间点。这样就能摆脱锁来得到一致、正确的答案,而无需你自己对任何数据锁定。

所以,对我们这个例子来说,Oracle得到的答案如表1-2所示。

1-2 实际的多版本例子

                                                                                                              转账事务

        T1        读第1行;到目前为止sum = $500                      

        T2                                                                                    更新第1行;对第1行加一个排他锁(也称独占锁,exclusive

                                                                                                lock),阻止其他更新第1行。现在有$100

        T3        读第2行;到目前为止sum = $750                      

        T4        读第3行;到目前为止sum = $1 150                   

        T5                                                                                    更新第4行;对第4行加一个排他锁,阻止其他更新(但不

                                                                                                阻止读操作)。第4行现在有$500

        T6        读第4行,发现第4行已修改。这会将块回滚

                    T1时刻的状态。查询从这个块读到值$100        

        T7        得到答案$1 250

T6时,Oracle有效地“摆脱”了事务加在第4行上的锁。非阻塞读是这样实现的:Oracle只看数据是否改变,它并不关心数据当前是否锁定(锁定意味着数据已经改变)。Oracle只是从回滚段中取回原来的值,并继续处理下一个数据块。

下一个例子也能很好地展示多版本。在数据库中,可以得到同一个信息处于不同时间点的多个版本。Oracle能充分使用不同时间点的数据快照来提供读一致查询和非阻塞查询。

数据的读一致视图总是在SQL语句级执行。SQL语句的结果对于查询开始的时间点来说是一致的。正是因为这一点,所以下面的语句可以插入可预知的数据集:

Begin

         or x in (select * from t)

         loop

                  insert into t values (x.username, x.user_id, x.created);

         end loop;

end;

SELECT * FROM T的结果在查询开始执行时就已经确定了。这个SELECT并不看INSERT生成的任何新数据。倘若真的能看到新插入的数据,这条语句就会陷入一个无限循环。如果INSERTT中生成了更多的记录行,而SELECT也随之能“看到”这些新插入的行,前面的代码就会建立数目未知的记录行。如果表T刚开始有10行,等结束时T中可能就会有202123或无限行记录。这完全不可预测。Oracle为所有语句都提供了这种读一致性,所以如下的INSERT也是可预知的:

insert into t select * from t;

这个INSERT语句得到了T的一个读一致视图。它看不到自己刚刚插入的行,而只是插入INSERT操作刚开始时表中已有的记录行。许多数据库甚至不允许前面的这种递归语句,因为它们不知道到底可能插入多少行。

所以,如果你用惯了其他数据库,只熟悉这些数据库中处理查询一致性和并发性的方法,或者你根本没有接触过这些概念(也就是说,你根本没有使用数据库的经验),现在应该知道,理解Oracle的做法对你来说有何等重要的意义。要想最大限度地发挥Oracle的潜能,以及为了实现正确的代码,你必须了解Oracle中的这些问题是怎么解决的(而不是其他数据库中是如何实现的)。

1.3.4             数据库独立性

至此,你可能想到这一节要讲什么了。我提到了其他的数据库,也谈到各个数据库中会以不同的方式实现特性。除了一些只读应用外,我的观点是:要构建一个完全数据库独立的应用,而且是高度可扩缩的应用,是极其困难的。实际上,这几乎不可能,除非你真正了解每个数据库具体如何工作。另外,如果你清楚每个数据库工作的具体细节,就会知道,数据库独立性可能并不是你真正想要的(这个说法有点绕!)。

例如,再来看最早提到的资源调度例子(增加FOR UPDATE子句之前)。假设在另一个数据库上开发这个应用,这个数据库有着与Oracle完全不同的锁定/并发模型。我想说的是,如果把应用从一个数据库移植到另一个数据库,就必须验证它在完全不同的环境下还能正常地工作,而且为此我们要做大幅修改!

假设把这个资源调度应用部署在这样一个数据库上,它采用了阻塞读机制(读会被写阻塞)。现在业务规则通过一个数据库触发器实现(在INSERT之后,但在事务提交之前,我们要验证表中对应特定时间片的记录只有一行,也就是刚插入的记录)。在阻塞读系统中,由于有这种新插入的数据,所以表的插入要串行完成。第一个人插入他(她)的请求,要在星期五的下午2:00到下午3:00预订“房间A”,然后运行一个查询查看有没有重叠的预订。下一个人想插入一个重叠的请求,查找重叠情况时,这个请求会被阻塞(它发现有新插入的数据,但要等待直到这些数据确实可以读取)。在这个采用阻塞读机制的数据库中,我们的应用显然可以正常工作(不过如果两个人都插入自己的行,然后试图读对方的数据,就有可能得到一个死锁,这个概念将在第6章讨论),但不能并发工作,因为我们是一个接一个地检查是否存在重叠的资源分配。

如果把这个应用移植到Oracle,并简单地认为它也能同样地工作,结果可能让人震惊。由于Oracle会在行级锁定,并提供了非阻塞读,所以看上去一切都乱七八糟。如前所示,必须使用FOR UPDATE子句来完成串行访问。如果没有这个子句,两个用户就可能同时调度同一个资源。如果不了解所用数据库在多用户环境中如何工作,就会导致这样的直接后果。

将应用从数据库A移植到数据库B时,我时常遇到这种问题:应用在数据库A上原本无懈可击,到了数据库B上却不能工作,或者表现得很离奇。看到这种情况,我们的第一个想法往往是,数据库B是一个“不好的”数据库。而真正的原因其实是数据库B的工作方式完全不同。没有哪个数据库是错的或“不好的”,它们只是有所不同而已。应当了解并理解它们如何工作,这对于处理这些问题有很大的帮助。将应用从Oracle移植到SQL Server时,也会暴露SQL Server的阻塞读和死锁问题,换句话说,不论从哪个方向移植都可能存在问题。

例如,有人请我帮忙将一些Transact-SQLT-SQLSQL Server的存储过程语言)转换为PL/SQL。做这个转换的开发人员一直在抱怨OracleSQL查询返回的结果是“错的”。查询如下所示:

declare

         l_some_variable varchar2(25);

begin

         if ( some_condition )

         then

                  l_some_variable := f( ... );

         end if;

         for C in ( select * from T where x = l_some_variable )

         loop

                  ...

这个查询的目标是:在T表中,如果不满足某个条件,则找出xNULL的所有行;如果满足某个条件,就找出x等于某个特定值的所有行。

开发人员抱怨说,在Oracle中,如果L_SOME_VARIABLE未设置为一个特定的值(仍为NULL),这个查询居然不返回任何数据。但是在SybaseSQL Server中不是这样的,查询会找到将x设置为NULL值的所有行。从SybaseSQL ServerOracle的转换中,几乎都能发现这个问题。SQL采用一种三值逻辑来操作,Oracle则是按ANSI SQL的要求来实现NULL值。基于这些规则的要求,xNULL的比较结果既不为true也不为false,也就是说,实际上,它是未知的(unknown)。从以下代码可以看出我的意思:

ops$tkyte@ORA10G> select * from dual where null=null;

no rows selected

 

ops$tkyte@ORA10G> select * from dual where null <> null;

no rows selected

 

ops$tkyte@ORA10G> select * from dual where null is null;

D

-

X

第一次看到这些结果可能会被搞糊涂。这说明,在Oracle中,NULLNULL既不相等,也不完全不相等。默认情况下,SQL Server则不是这样处理;在SQL ServerSybase中,NULL就等于NULL。不能说OracleSQL处理是错的,也不能说SybaseSQL Server的处理不对,它们只是方式不同罢了。实际上,所有这些数据库都符合ANSI,但是它们的具体做法还是有差异。有许多二义性、向后兼容性等问题需要解决。例如, SQL Server也支持ANSI方法的NULL比较,但这不是默认的方式(如果改成ANSI方法的NULL比较,基于SQL Server构建的数千个遗留应用就会出问题)。

在这种情况下,一种解决方案是编写以下查询:

select *

from t

where ( x = l_some_variable OR (x is null and l_some_variable is NULL ))

不过,这又会带来另一个问题。在SQL Server中,这个查询会使用x上的索引。Oracle中却不会这样,因为B*树索引不会对一个完全为NULL的项加索引(索引技术将在第12章介绍)。因此,如果需要查找NULL值,B*树索引就没有什么用处。

这里,为了尽量减少对代码的影响,我们的做法是赋给x某个值,不过这个值并没有实际意义。在此,根据定义可知,x的正常值是正数,所以可以选择 –1。这样一来,查询就变成:

select * from t where nvl(x,-1) = nvl(l_some_variable,-1)

由此创建一个基于函数的索引:

create index t_idx on t( nvl(x,-1) );

只需做最少的修改,就能在Oracle中得到与SQL Server同样的结果。从这个例子可以总结出以下几个要点:

q         数据库是不同的。在一个数据库上取得的经验也许可以部分应用于另一个数据库,但是你必须有心理准备,二者之间可能存在一些基本差别,可能还有一些细微的差别。

q         细微的差别(如对NULL的处理)与基本差别(如并发控制机制)可能有同样显著的影响。

q         应当了解数据库,知道它是如何工作的,它的特性如何实现,这是解决这些问题的惟一途径。

常有开发人员问我如何在数据库中做某件特定的事情(通常这样的问题一天不止一个),例如“如何在一个存储过程中创建临时表?”对于这些问题,我并不直接回答,而是反过来问他们“你为什么想那么做?”给我的回答常常是:“我们在SQL Server中就是用存储过程创建临时表,所以在Oracle中也要这么做。”这不出我所料,所以我的回答很简单:“你根本不是想在Oracle中用存储过程创建临时表,你只是以为自己想那么做。”实际上,在Oracle中这样做是很不好的。在Oracle中,如果在存储过程中创建表,你会发现存在以下问题:

q         DDL操作会阻碍可扩缩性。

q         DDL操作的速度往往不快。

q         DDL操作会提交事务。

q         必须在所有存储过程中使用动态SQL而不是静态SQL来访问这个表。

q         PL/SQL的动态SQL没有静态SQL速度快,或者说没有静态SQL优化。

关键是,即使真的需要在Oracle中创建临时表,你也不愿意像在SQL Server 中那样在过程中创建临时表。你希望在Oracle中能以最佳方式工作。反过来也一样,在Oracle中,你会为所有用户创建一个表来共享临时数据;但是从Oracle移植到SQL Server时,可能不希望这样做,这会影响SQL Server的可扩缩性和并发性。所有数据库创建得都不一样,它们存在很大的差异。

1.      标准的影响

如果所有数据库都符合SQL99,那它们肯定一样。至少我们经常做这个假设。在这一节中,我将揭开它的神秘面纱。

SQL99是数据库的一个ANSI/ISO标准。这个标准的前身是SQL92 ANSI/ISO标准,而SQL92之前还有一个SQL89 ANSI/ISO标准。它定义了一种语言(SQL)以及数据库的行为(事务、隔离级别等)。你知道许多商业数据库至少在某种程度上是符合SQL99的吗?不过,这对于查询和应用的可移植性没有多大的意义,这一点你也清楚吗?

SQL92标准有4个层次:

q         入门级(Entry level)。这是大多数开发商符合的级别。这一级只是对前一个标准SQL89稍做修改。所有数据库开发商都不会有更高的级别,实际上,美国国家标准和技术协会NISTNational Institute of Standards and Technology,这是一家专门检验SQL合规性的机构)除了验证入门级外,甚至不做其他的验证。Oracle 7.01993年通过了NISTSQL92入门级合规性验证,那时我也是小组中的一个成员。如果一个数据库符合入门级,它的特性集则是Oracle 7.0的一个功能子集。

q         过渡级。这一级在特性集方面大致介于入门级和中间级之间。

q         中间级。这一级增加了许多特性,包括(以下所列并不完整):

§         动态SQL

§         级联DELETE以保证引用完整性

§         DATETIME数据类型

§         

§         变长字符串

§         CASE表达式

§         数据类型之间的CAST函数

q         完备级。增加了以下特性(同样,这个列表也不完整):

§         连接管理

§         BIT串数据类型

§         可延迟的完整性约束

§         FROM子句中的导出表

§         CHECK子句中的子查询

§         临时表

入门级标准不包括诸如外联结(outer join)、新的内联结(inner join)语法等特性。过渡级则指定了外联结语法和内联结语法。中间级增加了更多的特性,当然,完备级就是SQL92全部。有关SQL92的大多数书都没有区别这些级别,这就会带来混淆。这些书只是说明了一个完整实现SQL92的理论数据库会是什么样子。所以无论你拿起哪一本书,都无法将书中所学直接应用到任何SQL92数据库上。关键是,SQL92最多只达到入门级,如果你使用了中间级或更高级里的特性,就存在无法“移植”应用的风险。

SQL99只定义了两级一致性:核心(core)一致性和增强(enhanced)一致性。SQL99力图远远超越传统的“SQL”,并引入了一些对象—关系构造(数组、集合等)。它包括SQL MM(多媒体,multimedia)类型、对象—关系类型等。还没有哪个开发商的数据库经认证符合SQL99核心级或增强级,实际上,据我所知,甚至没有哪个开发商声称他们的产品完全达到了某级一致性。

对于不同的数据库来说,SQL语法可能存在差异,实现有所不同,同一个查询在不同数据库中的性能也不一样,不仅如此,还存在并发控制、隔离级别、查询一致性等问题。我们将在第7章详细讨论这些问题,并介绍不同数据库的差异对你会有什么影响。

SQL92/SQL99试图对事务应如何工作以及隔离级别如何实现给出一个明确的定义,但最终,不同的数据库还是有不同的结果。这都是具体实现所致。在一个数据库中,某个应用可能会死锁并完全阻塞。但在另一个数据库中,同样是这个应用,这些问题却有可能不会发生,应用能平稳地运行。在一个数据库中,你可能利用了阻塞(物理串行化),但在另一个数据库上部署时,由于这个数据库不会阻塞,你就会得到错误的答案。要将一个应用部署在另一个数据库上,需要花费大量的精力,付出艰辛的劳动,即使你100%地遵循标准也不例外。

关键是,不要害怕使用开发商特有的特性,毕竟,你为这些特性花了钱。每个数据库都有自己的一套“技巧”,在每个数据库中总能找到一种完成操作的好办法。要使用最适合当前数据库的做法,移植到其他数据库时再重新实现。要使用合适的编程技术,从而与这些修改隔离,我把这称为防御式编程(defensive programming)。

2.      防御式编程

我推崇采用防御式编程技术来构建真正可移植的数据库应用,实际上,编写操作系统可移植的应用时也采用了这种技术。防御式编程的目标是充分利用可用的工具,但是确保能够根据具体情况逐一修改实现。

可以对照来看,Oracle是一个可移植的应用。它能在许多操作系统上运行。不过,在Windows上,它就以Windows方式运行,使用线程和其他Windows特有的工具。在UNIX上,Oracle则作为一个多进程服务器运行,使用进程来完成Windows上线程完成的工作,也就是采用UNIX的方式运行。两个平台都提供了“核心Oracle”功能,但是在底层却以完全不同的方式来实现。如果你的数据库应用要在多个数据库上运行,道理也是一样的。

例如,许多数据库应用都有一个功能,即为每一行生成一个惟一的键。插入行时,系统应自动生成一个键。为此,Oracle实现了一个名为SEQUENCE的数据库对象。Informix有一个SERIAL数据类型。SybaseSQL Server有一个IDENTITY类型。每个数据库都有一个解决办法。不过,不论从做法上讲,还是从输出来看,各个数据库的方法都有所不同。所以,有见识的开发人员有两条路可走:

q         开发一个完全独立于数据库的方法来生成惟一的键。

q         在各个数据库中实现键时,提供不同的实现,并使用不同的技术。

从理论上讲,第一种方法的好处是从一个数据库转向另一个数据库时无需执行任何修改。我把它称为“理论上” 的好处,这是因为这种实现实在太庞大了,所以这种方案根本不可行。要开发一个完全独立于数据库的进程,你必须创建如下所示的一个表:

ops$tkyte@ORA10G> create table id_table

2 ( id_name varchar2(30) primary key,

3 id_value number );

Table created.

 

ops$tkyte@ORA10G> insert into id_table values ( 'MY_KEY', 0 );

1 row created.

 

ops$tkyte@ORA10G> commit;

Commit complete.

然后,为了得到一个新的键,必须执行以下代码:

ops$tkyte@ORA10G> update id_table

2 set id_value = id_value+1

3 where id_name = 'MY_KEY';

1 row updated.

 

ops$tkyte@ORA10G> select id_value

2 from id_table

3 where id_name = 'MY_KEY';

ID_VALUE

----------

1

看上去很简单,但是有以下结果(注意结果不止一项):

q         一次只能有一个用户处理事务行。需要更新这一行来递增计数器,这会导致程序必须串行完成这个操作。在最好的情况下,一次只有一个人生成一个新的键值。

q         Oracle中(其他数据库中的行为可能有所不同),倘若隔离级别为SERIALIZABLE,除第一个用户外,试图并发完成此操作的其他用户都会接到这样一个错误:“ORA-08177: can't serialize access for this transaction”(ORA-08177:无法串行访问这个事务)。

例如,使用一个可串行化的事务(在J2EE环境中比较常见,其中许多工具都自动将SERIALIZABLE用作默认的隔离模式,但开发人员通常并不知道),你会观察到以下行为。注意SQL提示符(使用SET SQLPROMPT SQL*Plus命令)包含了活动会话的有关信息:

OPS$TKYTE session(261,2586)> set transaction isolation level serializable;

Transaction set.

 

OPS$TKYTE session(261,2586)> update id_table

2 set id_value = id_value+1

3 where id_name = 'MY_KEY';

1 row updated.

 

OPS$TKYTE session(261,2586)> select id_value

2 from id_table

3 where id_name = 'MY_KEY';

 

ID_VALUE

----------

1

下面,再到另一个SQL*Plus会话完成同样的操作,并发地请求惟一的ID

OPS$TKYTE session(271,1231)> set transaction isolation level serializable;

Transaction set.

 

OPS$TKYTE session(271,1231)> update id_table

2 set id_value = id_value+1

3 where id_name = 'MY_KEY';

此时它会阻塞,因为一次只有一个事务可以更新这一行。这展示了第一种可能的结果,即这个会话会阻塞,并等待该行提交。但是由于我们使用的是Oracle,而且隔离级别是SERIALIZABLE,提交第一个会话的事务时会观察到以下行为:

OPS$TKYTE session(261,2586)> commit;

Commit complete.

第二个会话会立即显示以下错误:

OPS$TKYTE session(271,1231)> update id_table

2 set id_value = id_value+1

3 where id_name = 'MY_KEY';

update id_table

*

ERROR at line 1:

ORA-08177: can't serialize access for this transaction

所以,尽管这个逻辑原本想做到独立于数据库,但它根本不是数据库独立的。取决于隔离级别,这个逻辑甚至在单个数据库中都无法可靠地完成,更不用说跨数据库了!有时我们会阻塞并等待,但有时却会得到一条错误消息。说得简单些,无论是哪种情况(等待很长时间,或者等待很长时间后得到一个错误),都至少会让最终用户不高兴。

实际上,我们的事务比上面所列的要大得多,所以问题也更为复杂。实际的事务中包含多条语句,上例中的UPDATESELECT只是其中的两条而已。我们还要用刚生成的这个键向表中插入行,并完成这个事务所需的其他工作。这种串行化对于应用的扩缩是一个很大的制约因素。如果把这个技术用在处理订单的网站上,而且使用这种方式来生成订单号,可以想想看可能带来的后果。这样一来,多用户并发性就会成为泡影,我们不得不按顺序做所有事情。

对于这个问题,正确的解决方法是针对各个数据库使用最合适的代码。在Oracle中,代码应该如下(假设表T需要所生成的主键):

create table t ( pk number primary key, ... );

create sequence t_seq;

create trigger t_trigger before insert on t for each row

begin

         select t_seq.nextval into :new.pk from dual;

end;

其效果是为所插入的每一行自动地(而且透明地)指定一个惟一键。还有一种性能更优的方法:

Insert into t ( pk, ... ) values ( t_seq.NEXTVAL, ... );

也就是说,完全没有触发器的开销(这是我的首选方法)。

在第一个例子中,我们特意使用了各个数据库的特性来生成一个非阻塞、高度并发的惟一键,而且未对应用代码带来任何真正的改动,因为在这个例子中所有逻辑都包含在DDL中。

提示    在其他数据库中也可以使用其内置的特性或者生成惟一的数来达到同样的效果。CREATE TABLE语法可能不同,但是最终结果是一样的。

理解了每个数据库会以不同的方式实现特性,再来看一个支持可移植性的防御式编程的例子,这就是必要时将数据库访问分层。例如,假设你在使用JDBC进行编程,如果你用的都是直接的SQLSELECTINSERTUPDATEDELETE),可能不需要抽象层。你完全可以在应用程序中直接编写SQL,前提是只能用各个数据库都支持的构造,而且经验证,这些构造在不同数据库上会以同样的方式工作(还记得关于NULL=NULL的讨论吧!)。另一种方法的可移植性更好,而且可以提供更好的性能,就是使用存储过程来返回结果集。你会发现,每个开发商的数据库都可以从存储过程返回结果集,但是返回的方式不同。针对不同的数据库,要编写的具体源代码会有所不同。

这里有两个选择,一种做法是不使用存储过程返回结果集,另一种做法是针对不同的数据库实现不同的代码。我就坚持第二种做法,即针对不同的开发商编写不同的代码,而且大量使用存储过程。初看上去,另换一个数据库实现时这好像会增加开发时间。不过你会发现,在多个数据库上实现时,采用这种方法实际上容易得多。你不用寻找适用于所有数据库的最佳SQL(也许在某些数据库上表现好一些,但在另外一些数据库上可能并不理想),而只需实现最适合该数据库的SQL。这些工作可以在应用之外完成,这样对应用调优时就有了更大的灵活性。你可以在数据库自身中修正一个表现很差的查询,并立即部署所做的改动,而无需修改应用。另外,采用这种方法,还可以充分利用开发商提供的SQL扩缩。例如,Oracle在其SQL中提供了CONNECT BY操作,能支持层次查询。这个独有的特性对于处理递归查询很有意义。在Oracle中,你可以*地使用这个SQL扩缩,因为它在应用“之外”(也就是说,隐藏在数据库中)。在其他数据库中,则可能需要使用一个临时表,并通过存储过程中的过程性代码才能得到同样的结果。既然你花钱购买了这些特性,自然可以充分地加以使用。

应用要在哪个数据库上部署,就针对这个数据库开发一个专用的代码层,这种技术与实现多平台代码所用的开发技术是一样的。例如,Oracle公司在开发Oracle数据库时就使用了这些技术。这一层代码量很大(但相对于数据库的全部代码来讲,还只是很少的一部分),称为操作系统相关(operating system-dependentOSD)代码,是专门针对各个平台实现的。使用这层抽象,Oracle就能利用许多本地OS特性来提高性能和支持集成,而无需重写数据库本身的很大一部分代码。Oracle能作为一个多线程应用在Windows上运行,也能作为一个多进程应用在UNIX上运行,这就反映出Oracle利用了这种OSD代码。它将进程间通信的机制抽象到这样一个代码层上,可以根据不同的操作系统重新实现,所以允许有完全不同的实现,它们的表现与直接(专门)为各平台编写的应用相差无几。

采用这个方法还有一个原因,要想找到一个样样精通的开发人员,要求他熟知OracleSQL ServerDB2之间的细微差别(这里只讨论这3个数据库)几乎是不可能的,更别说找到这样一个开发小组了。我在过去11年间一直在用Oracle(大体如此,但不排除其他软件)。每一天使用Oracle,都会让我学到一些新的东西。但我还是不敢说同时精通这3种数据库,知道它们之间的差别,并且清楚这些差别会对要构建的“泛型代码”层有什么影响。我觉得自己无法准确或高效地实现这样一个“泛型代码”层。再说了,我们指的是一般的开发人员,有多少开发人员能真正理解或充分使用了手上的数据库呢?更别说掌握这3种数据库了!要寻找这样一个“全才”,他能开发安全、可扩缩而且独立于数据库的程序,就像是大海捞针一样。而希望由这样的人员组建一支开发队伍更是绝无可能。反过来,如果去找一个Oracle专家、一个DB2专家和一个SQL Server专家,告诉他们“我们需要事务完成XYZ”,这倒是很容易。只需告诉他们“这是你的输入,这些是我们需要的输出,这是业务过程要做的事情”,根据这些来生成满足要求的事务性API(存储过程)就很简单了。针对特定的数据库,按照数据库特有的一组功能,可以采用最适于该数据库的方式来实现。开发人员可以*地使用底层数据库平台的强大能力(也可能底层数据库缺乏某种能力,而需要另辟蹊径)。

3.      特性和功能

你不必努力争取数据库独立性,这还有一个很自然的理由:你应当准确地知道特定数据库必须提供什么,并充分加以利用。这一节不会列出Oracle 10g提供的所有特性,光是这些特性本身就需要一本很厚的书才能讲完。Oracle 9i Release 19i Release 210g Release 1本身的新特性在Oracle文档中已做介绍。Oracle为此提供了大约10 000页的文档,涵盖了每一个有意义的特性和功能。你起码要对数据库提供的特性和功能有一个大致的了解,这一节只是讨论大致了解有什么好处。

前面提到过,我总在http://asktom.oracle.com上回答有关Oracle的问题。我说过,我的答案中80%都只是给出相关文档的URL(这是指我公开提出的那些问题,其中许多答案都只是指向文档,另外还会有几个问题我没有公开提出,因为这些问题的答案几乎都是“读读这本书”)。人们问我怎么在数据库中编写一些复杂的功能(或者在数据库之外编写),我就会告诉他们在文档的哪个地方可以了解到Oracle已经实现了这个功能,并且还说明了应该如何使用这个功能。我时常会遇到一些有关复制的问题。可能有这样一个问题:“我想在每个地方都留有数据的一个副本。我希望这是一个只读的副本,而且每天只在半夜更新一次。我该怎么编写代码来做到呢?”答案很简单,只是一个CREATE MATERIALIZED VIEW命令而已。这是数据库中的一个内置功能。实际上,实现复制还有许多方法,从只读的物化视图到可更新的物化视图,再到对等复制以及基于流的复制,等等。

你当然可以编写你自己的复制,这么做可能很有意思,但是从最后看来,自己编写可能不是最明智的做法。数据库做了很多工作。一般来说,数据库会比我们自己做得更好。例如,Oracle中复制是用C编写的,充分考虑到了国际化。不仅速度快、相当容易,而且很健壮。它允许跨版本和跨平台,并且提供了强大的技术支持,所以倘若你遇到问题,Oracle Support会很乐意提供帮助。如果你要升级,也会同步地提供复制支持,可能还会增加一些新的特性。下面考虑一下如果由你自己来开发会怎么样。你必须为每一个版本都提供支持。老版本和新版本之间的互操作性谁来负责?这个任务会落在你的头上。如果出了“问题”,你没有办法寻求支持,至少在得到一个足够小的测试用例(但足以展示你的主要问题)之前,没有人来帮助你。当新版本的Oracle推出时,也要由你自己将你的复制代码移植到这个新版本。

如果没有充分地了解数据库已经提供了哪些功能,从长远看,其坏影响还会几次三番地出现。我曾经与一些有多年数据库应用开发经验的人共事,不过他们原先是在其他数据库上开发应用。这一次他们在Oracle上构建了一个分析软件(趋势分析、报告和可视化软件),要用于分析临床医学数据(与保健相关)。这些开发人员不知道SQL的一些语法特性,如内联视图、分析功能和标量子查询。他们遇到的一个主要问题是需要分析一个父表及两个子表的数据。相应的实体—关系图(entity-relationship diagramERD)如图1-1所示。

1-1 简单的ERD

他们想生成父记录的报告,并提供子表中相应子记录的聚集统计。他们原来使用的数据库不支持子查询分解(WITH子句),也不支持内联视图(所谓内联视图,就是 “查询一个查询”,而不是查询一个表)。由于不知道有这些特性,开发人员们在中间层编写了他们自己的一个数据库。他们的做法是先查询父表,对应返回的每一行,再对各个子表分别运行聚集查询。这样做的后果是:对于最终用户想要运行的每一个查询,他们都要运行数千个查询才能得到所需的结果。或者,他们的另一种做法是在中间层获取完整的聚集子表,再放入内存中的散列表,并完成一个散列联结(hash join)。

简而言之,他们重新开发了一个数据库,自行完成了与嵌套循环联结或散列联结相当的功能,而没有充分利用临时表空间、复杂的查询优化器等所提供的好处。这些开发人员把大量时间都花费在这个软件的开发、设计、调优和改进上,而这个软件只是要做数据库已经做了的事情,要知道他们原本已经花钱买了这些功能!与此同时,最终用户还在要求增加新特性,但是一直没有如愿,因为开发人员总忙于开发报告“引擎”,没有更多的时间来考虑这些新特性,实际上这个报告引擎就是一个伪装的数据库引擎。

我告诉他们,完全可以联结两个聚集来比较用不同方法以不同详细程度存储的数据(见代码清单1-1~代码清单1-3)。

代码清单1-1  内联视图:对“查询”的查询

select p.id, c1_sum1, c2_sum2

from p,

         (select id, sum(q1) c1_sum1

         from c1

         group by id) c1,

         (select id, sum(q2) c2_sum2

         from c2

         group by id) c2

where p.id = c1.id

         and p.id = c2.id

/

代码清单1-2  标量子查询:每行运行另一个查询

select p.id,

         (select sum(q1) from c1 where c1.id = p.id) c1_sum1,

         (select sum(q2) from c2 where c2.id = p.id) c2_sum2

from p

where p.name = '1234'

/

代码清单1-3  WITH子查询分解

with c1_vw as

         (select id, sum(q1) c1_sum1

         from c1

         group by id),

c2_vw as

         (select id, sum(q2) c2_sum2

         from c2

         group by id),

c1_c2 as

         (select c1.id, c1.c1_sum1, c2.c2_sum2

         from c1_vw c1, c2_vw c2

         where c1.id = c2.id )

select p.id, c1_sum1, c2_sum2

from p, c1_c2

where p.id = c1_c2.id

/

更何况他们还可以使用LAGLEADROW_NUMBER之类的分析函数、分级函数等。我们没有再花时间去考虑如何对他们的中间层数据库引擎进行调优,而是把余下的时间都用来学习SQL Reference Guide,我们把它投影在屏幕上,另外还打开一个SQL*Plus实际演示到底如何工作。最终目标不是对中间层调优,而是尽快地把中间层去掉。

我曾经见过许多人在Oracle数据库中建立后台进程从管道(一种数据库IPC机制)读消息。这些后台进程执行管道消息中包含的SQL,并提交工作。这样做是为了在事务中执行审计,即使更大的事务(父事务)回滚了,这个事务(子事务)也不会回滚。通常,如果使用触发器之类的工具来审计对某数据的访问,但是后来有一条语句失败,那么所有工作都会回滚。所以,通过向另一个进程发送消息,就可以有一个单独的事务来完成审计工作并提交。即使父事务回滚,审计记录仍然保留。在Oracle8i以前的版本中,这是实现此功能的一个合适的方法(可能也是惟一的方法)。我告诉他们,数据库还有一个称为自治事务(autonomous transaction)的特性,他们听后很是郁闷。自治事务的实现只需一行代码,就完全可以做到他们一直在做的事情。好的一面是,这说明他们可以丢掉原来的大量代码,不用再维护了。另外,系统总的来讲运行得更快,而且更容易理解。不过,他们还在为“重新发明”浪费了那么多时间而懊恼不已。特别是那个写后台进程的开发人员更是沮丧,因为他写了一大堆的代码。

还是我反复重申的那句话:针对某个问题,开发人员力图提供复杂的大型解决方案,但数据库本身早已解决了这个问题。在这个方面,我自己也有些心虚。我还记得,有一天我的Oracle销售顾问走进我的办公室(那时我还只是一个客户),看见我被成堆的Oracle文档包围着。我抬起头,问他“这是真的吗?”接下来的几天我一直在深入研究这些文档。此前我落入一个陷阱,自以为“完全了解数据库”,因为我用过SQL/DSDB2IngressSybaseInformixSQLBaseOracle,还有其他一些数据库。我没有花时间去了解每个数据库提供了什么,而只是把从其他数据库学到的经验简单地应用到当时正在使用的数据库上(移植到Sybase/SQL Server时对我的触动最大,它与其他数据库的工作根本不一样)。等到我真正发现Oracle(以及其他数据库)能做什么之后,我才开始充分利用它,不仅能更快地开发,而且写的代码更少。我认识到这一点的时候是1993年。请仔细想想你能用手头的软件做些什么,不过与我相比,你已经晚了十多年了。

除非你花些时间来了解已经有些什么,否则你肯定会在某个时候犯同样的错误。在这本书中,我们会深入地分析数据库提供的一些功能。我选择的是人们经常使用的特性和功能,或者是本应更多地使用但事实上没有得到充分利用的功能。不过,这里涵盖的内容只是冰山一角。Oracle的知识太多了,单用一本书来讲清楚是做不到的。

重申一遍:每天我都会学到Oracle的一些新知识。这需要“与时俱进”,时刻跟踪最新动态。我自己就常常阅读文档(不错,我还在看文档)。即使如此,每天还是会有人指出一些我不知道的知识。

4.      简单地解决问题

通常解决问题的途径有两种:容易的方法和困难的方法。我总是看到人们在选择后者。这并不一定是故意的,更多的情况下,这么做只是出于无知。他们没想到数据库能“做那个工作”。而我则相反,我总是希望数据库什么都能做,只有当我发现它确实做不了某件事时才会选择困难的办法(自己来编写)。

例如,人们经常问我,“怎么确保最终用户在数据库中只有一个会话?”(其实类似这样的例子还有很多,我只是随便选了一个)。可能许多应用都有这个需求,但是我参与的应用都没有这样做,我不知道有什么必要以这种方式限制用户。不过,如果确实想这样做,人们往往选择困难的方法来实现。例如,他们可能建立一个由操作系统运行的批作业,这个批作业将查看V$SESSION表;如果用户有多个会话,就坚决地关闭这些会话。还有一种办法,他们可能会创建自己的表,用户登录时由应用在这个表中插入一行,用户注销时删除相应行。这种实现无疑会带来许多问题,于是咨询台的铃声大作,因为应用“崩溃”时不会将该行删除。为了解决这个问题,我见过许多“有创意的”方法,不过哪一个也没有下面这种方法简单:

ops$tkyte@ORA10G> create profile one_session limit sessions_per_user 1;

Profile created.

 

ops$tkyte@ORA10G> alter user scott profile one_session;

User altered.

 

ops$tkyte@ORA10G> alter system set resource_limit=true;

System altered.

 

ops$tkyte@ORA10G> connect scott/tiger

Connected.

 

scott@ORA10G> host sqlplus scott/tiger

SQL*Plus: Release 10.1.0.2.0 - Production on Sun Nov 28 12:49:49 2004

Copyright (c) 1982, 2004, Oracle. All rights reserved.

ERROR:

ORA-02391: exceeded simultaneous SESSIONS_PER_USER limit

 

Enter user-name:

仅此而已。现在有ONE_SESSION配置文件的所有用户都只能登录一次。每次我提出这个解决方案时,人们总是拍着自己的脑门,不无惊羡地说:“我不知道居然还能这么做!”正所谓磨刀不误砍柴工,花些时间好好熟悉一下你所用的工具,了解它能做些什么,在开发时这会为你节省大量的时间和精力。

还是这句“力求简单”,它也同样适用于更宽泛的体系结构层。我总是鼓励人们在采用非常复杂的实现之前先要再三思量。系统中不固定的部分越多,出问题的地方就越多。在一个相当复杂的体系结构中,要想准确地跟踪到错误出在哪里不是一件容易的事。实现一个有“无数”层的应用可能看起来很“酷”,但是既然用一个简单的存储过程就能更好、更快地完成任务,而且只利用更少的资源,实现为多层的做法就不是正确的选择。

我见过许多项目的应用开发持续数月之久,好像没有尽头。开发人员都在使用最新、最好的技术和语言,但是开发速度还是不快。应用本身的规模并不大,也许这正是问题所在。如果你在建一个狗窝(这是一个很小的木工活),就不会用到重型机器。你只需要几样小工具就行了,大玩艺是用不上的。另一方面,如果你在建一套公寓楼,就要下大功夫,可能要用到大型机器。与建狗窝相比,解决这个问题所用的工具完全不同。应用开发也是如此。没有一种“万全的体系结构”,没有一种“完美的语言”,也没有一个“无懈可击的方法”。

例如,我就使用了HTML DB来建我的网站。这是一个很小的应用,只有一个(或两个)开发人员参与。它有大约20个界面。这个实现使用PL/SQLHTML DB是合适的,这里不需要用Java编写大量的代码,不需要建立EJB,等等。这是一个简单的问题,所以应该用简单的方式解决。确实有一些大型应用很复杂、规模很大(如今这些应用大多会直接购买,如人力资源HR系统、ERP系统等),但是小应用更多。我们要选用适当的方法和工具来完成任务。

不论什么时候,我总是提倡用最简单的体系结构来解决问题,而不要采用复杂的体系结构。这样做可能有显著的回报。每种技术都有自己合适的位置。不要把每个问题都当成钉子,高举铁锤随处便砸,我们的工具箱里并非只有铁锤。

5.      开放性

我经常看到,人们选择艰难的道路还有一个原因。这还是与那种观点有关,我们总认为要不遗余力地追求开放性和数据库独立性。开发人员希望避免使用封闭的专有数据库特性,即使像存储过程或序列这样简单的特性也不敢用,因为使用这些专有特性会把他们锁定到某个数据库系统。这么说吧,我的看法是只要你开发一个涉及读/写的应用,就已经在某种程度上被锁定了。一旦开始运行查询和修改,你就会发现数据库间存在着一些微小的差别(有时还可能存在显著差异)。例如,在一个数据库中,你可能发现SELECT COUNT(*) FROM T查询与两行记录的更新发生了死锁。在Oracle中,却发现SELECT COUNT(*)绝对不会阻塞写入器。你可能见过这样的情况,一个数据库看上去能保证某种业务规则,这是由于该数据库锁定模型的副作用造成的,但另一个数据库则不能保证这个业务规则。给定完全相同的事务,在不同数据库中却有可能报告全然不同的答案,原因就在于数据库的实现存在一些基本的差别。你会发现,要想把一个应用轻轻松松地从一个数据库移植到另一个数据库,这种应用少之又少。不同数据库中对于如何解释SQL(例如,NULL=NULL这个例子)以及如何处理SQL往往有不同的做法。

在我最近参与的一个项目中,开发人员在使用Visual BasicActiveX控件、IIS服务器和Oracle构建一个基于Web的产品。他们不无担心地告诉我,由于业务逻辑是用PL/SQL编写的,这个产品已经依赖于数据库了。他们问我:“怎么修正这个问题?”

先不谈这个问题,退一步说,针对他们所选的技术,我实在看不出依赖于数据库有什么“不好”:

q         开发人员选择的语言已经把他们与一个开发商提供的一个操作系统锁定(要想独立于操作系统,其实他们更应选择Java)。

q         他们选择的组件技术已经把他们与一个操作系统和一个开发商锁定(选择J2EE更合适)。

q         他们选择的Web服务器已经将他们与一个开发商和一个平台锁定(为什么不用Apache呢?)。

所选择的每一项技术都已经把他们锁定到一个非常特定的配置,实际上,就操作系统而言,惟一能让他们有所选择的技术就是数据库。

暂且不管这些(选择这些技术可能有他们自己的原因),这些开发人员还刻意不去用体系结构中一个重要部件的功能,而美其名曰是为了开放性。在我看来,既然精心地选择了技术,就应该最大限度地加以利用。购买这些技术你已经花了不少钱,难道你想白白地花冤枉钱吗?我认为,他们一直想尽力发挥其他技术的潜能,那么为什么要把数据库另眼相看呢?再者,数据库对于他们的成功至关重要,单凭这一点也说明,不充分利用数据库是说不过去的。

如果从开放性的角度来考虑,可以稍稍换个思路。你把所有数据都放在数据库中。数据库是一个很开放的数据池。它支持通过大量开放的系统协议和访问机制来访问数据。这听起来好像很不错,简直就是世界上最开放的事物。

不过接下来,你把所有应用逻辑还有(更重要的)安全都放在数据库之外。可能放在访问数据的bean中;也可能放在访问数据的JSP中;或者置于在Microsoft事务服务器(Microsoft Transaction ServerMTS)管理之下运行的Visual Basic代码中。最终结果就是,你的数据库被封闭起来,这么一来,数据库已经被你弄得“不开放”了。人们无法再采用现有技术使用这些数据;他们必须使用你的访问方法(或者干脆绕过你的安全防护)。尽管现在看上去还不错,但是你要记住,今天响当当的技术(比如说,EJB)也会成为昨日黄花,到了明天可能就是一个让人厌倦的技术了。在关系领域中(以及大多数对象实现中),过去25年来只有数据库自己傲然屹立。数据前台技术几乎每年一变,如果应用把安全放在内部实现,而不是在数据库中实现,随着前台技术的变革,这些应用就会成为前进道路上的绊脚石。

Oracle数据库提供了一个称为细粒度访问控制(fine-grained access controlFGAC)的特性。简而言之,这种技术允许开发人员把过程嵌入数据库中,向数据库提交查询时可以修改查询。这种查询修改可用于限制客户只能接收或修改某些行。过程在运行查询时能查看是谁在运行查询,他们从哪个终端运行查询,等等,然后能适当地约束对数据的访问。利用FGAC,可以保证以下安全性,例如:

q         某类用户在正常工作时间之外执行的查询将返回0条记录。

q         如果终端在一个安全范围内,可以向其返回所有数据,但是远程客户终端只能得到不敏感的信息。

实质上讲,FGAC允许我们把访问控制放在数据库中,与数据“如影随形”。不论用户从beanJSP、使用ODBCVisual Basic应用,还是通过SQL*Plus访问数据,都会执行同样的安全协议。这样你就能很好地应对即将到来的下一种新技术。

现在我再来问你,你想让所有数据访问都通过调用Visual Basic代码和ActiveX控件来完成(如果愿意,也可以把Visual Basic换成Java,把ActiveX换成EJB,我并不是推崇哪一种技术,这里只是泛指这种实现);还是希望能从任何地方访问数据(只要能与数据库通信),而不论协议是SSLHTTPOracle Net,还是其他协议,也不论使用的是ODBCJDBCOCI,还是其他API,这两种实现中哪一种更“开放”?  我还没见过哪个报告工具能“查询”Visual Basic代码,但是能查询SQL的工具却有不少。

人们总是不遗余力地去争取数据库独立性和完全的开放性,但我认为这是一个错误的决定。不管你使用的是什么数据库,都应该充分地加以利用,把它的每一个功能都“挤出来”。不论怎样,等到调优阶段你也会这样做的(不过,往往在部署之后才会调优)。如果通过充分利用软件的功能,会让你的应用快上5倍,你会惊讶地发现,居然这么快就把数据库独立性需求抛在脑后了。

1.3.5             怎么能让应用运行得更快?”

总是有人问我这个问题:“怎么能让应用运行得更快?”所有人都希望有一个“fast = true”开关,认为“数据库调优”就意味着让你调整数据库。实际上,根据我的经验,80%以上(甚至经常是100%)的性能问题都出现在设计和实现级,而不是数据库级。通过修改应用,我常常能让性能呈数量级地增长。但是,如果只是在数据库级做修改,就不太可能得到这么大幅度的提高。在对数据库上运行的应用进行调优之前,先不要对数据库进行调优。

随着时间的推移,数据库级也有了一些开关,有助于减轻编程错误带来的影响。例如,Oracle 8.1.6增加了一个新参数CURSOR_SHARING=FORCE。如果你愿意,这个特性会实现一个自动绑定器(auto-binder)。如果有一个查询编写为SELECT * FROM EMP WHERE EMPNO = 1234,自动绑定器会悄无声息地把它改写成SELECT * FROM EMP WHERE EMPNO = :x。这确实能动态地大大减少硬解析数,并减少前面讨论的库闩等待时间——但是(凡事总有个“但是”),它可能有一些副作用。游标共享的一个常见副作用如下所示:

ops$tkyte@ORA10G> select /* TAG */ substr( username, 1, 1 )

2 from all_users au1

3 where rownum = 1;

 

S

-

B

ops$tkyte@ORA10G> alter session set cursor_sharing=force;

Session altered.

 

ops$tkyte@ORA10G> select /* TAG */ substr( username, 1, 1 )

2 from all_users au2

3 where rownum = 1;

SUBSTR(USERNAME,1,1)

 

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

B

这里到底发生了什么?为什么到第二个查询时SQL*Plus报告的列突然变得这么大?要知道,这还是同一个查询呀!如果查看一下游标共享设置为我们做了些什么,原因就会很清楚了(还会明白其他一些问题):

ops$tkyte@ORA10G> select sql_text from v$sql

2 where sql_text like 'select /* TAG */ %';

SQL_TEXT

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

select /* TAG */ substr( username, 1, 1 ) from all_users au1 where rownum = 1

select /* TAG */ substr( username, :"SYS_B_0", :"SYS_B_1" ) from all_users au2 where rownum = :"SYS_B_2"

游标共享会删除查询中的信息。它找到每一个直接量(literal),包括内置求子串函数(substr)的参数,直接量就是我们使用的常量。它把这些直接量从查询中删除,并代之以绑定变量。SQL引擎再也不知道这个列是长度为1的子串,它的长度是不确定的。另外,可以看到where rownum = 1现在也已经绑定。看上去似乎不错;不过,优化器把一个重要的信息也一并删除了。它不知道“这个查询将获取一行”;现在只认为“这个查询将返回前N行,而N可能是任何值”。实际上,如果加上SQL_TRACE=TRUE后再运行这些查询,你会发现每个查询使用的查询计划都不同,它们完成的工作量也大相径庭。考虑以下查询:

select /* TAG */ substr( username, 1, 1 )

from all_users au1

where rownum = 1

call          count                 cpu           elapsed   disk              query          current         rows

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

Parse      1         0.00       0.00          0               0                   0                  0

Execute 1         0.00       0.00          0               0                   0                  0

Fetch       2         0.00       0.00          0               77                 0                  1

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

total         4         0.00       0.00          0               77                 0                  1

 

Misses in library cache during parse: 0

Optimizer mode: ALL_ROWS

Parsing user id: 412

 

Rows        Row Source Operation

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

1                COUNT STOPKEY (cr=77 pr=0 pw=0 time=5767 us)

1                HASH JOIN (cr=77 pr=0 pw=0 time=5756 us)

1028         HASH JOIN (cr=70 pr=0 pw=0 time=8692 us)

9                TABLE ACCESS FULL TS$ (cr=15 pr=0 pw=0 time=335 us)

1028         TABLE ACCESS FULL USER$ (cr=55 pr=0 pw=0 time=2140 us)

4                TABLE ACCESS FULL TS$ (cr=7 pr=0 pw=0 time=56 us)

********************************************************************************

select /* TAG */ substr( username, :"SYS_B_0", :"SYS_B_1" )

from all_users au2

where rownum = :"SYS_B_2"

 

call          count                 cpu           elapsed   disk              query          current         rows

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

Parse      1         0.00       0.00          0               0                   0                  0

Execute 1         0.00       0.00          0               0                   0                  0

Fetch       2         0.00       0.00          0               85                 0                  1

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

total         4         0.00       0.00          0               85                 0                  1

 

Misses in library cache during parse: 0

Optimizer mode: ALL_ROWS

Parsing user id: 412

Rows        Row Source Operation

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

1                COUNT (cr=85 pr=0 pw=0 time=3309 us)

1                FILTER (cr=85 pr=0 pw=0 time=3301 us)

1028         HASH JOIN (cr=85 pr=0 pw=0 time=5343 us)

1028         HASH JOIN (cr=70 pr=0 pw=0 time=7398 us)

9                TABLE ACCESS FULL TS$ (cr=15 pr=0 pw=0 time=148 us)

1028         TABLE ACCESS FULL USER$ (cr=55 pr=0 pw=0 time=1079 us)

9                TABLE ACCESS FULL TS$ (cr=15 pr=0 pw=0 time=90 us)

查询计划有一些微小的差别(有时甚至完全不同);另外它们的工作量也有很大差异。所以,打开游标共享确实需要特别谨慎(而且需要进行充分测试)。游标共享可能会改变应用的行为(例如,列宽发生变化),而且由于它删除了SQL中的所有直接量,甚至包括那些绝对不会变化的直接量,所以可能会对查询计划带来负面影响。

另外,与解析和优化大量各不相同的查询相比,尽管使用CURSOR_SHARING = FORCE会让运行速度更快,但同时我也发现,倘若开发人员确实在查询中使用了绑定变量,查询的速度就比使用游标共享要快。这不是因为游标共享代码的效率不高,而是因为程序本身的效率低下。在许多情况下,如果应用没有使用绑定变量,也不会高效地解析和重用游标。因为应用认为每个查询都是惟一的(并把查询分别建立为不同的语句),所以绝对不会多次使用一个游标。事实上,如果程序员刚开始就使用了绑定变量,他(或她)就能只解析一次查询,然后多次重用它。正是这种解析开销降低了总体性能。

实质上讲,一定要记住重要的一点,只打开CURSOR_SHARING = FORCE并不一定能解决你的问题。而且游标共享还可能带来新的问题:在有些情况下CURSOR_SHARING是一个非常有用的工具,但它不是银弹。开发得很好的应用从不需要游标共享。从长远来看,要尽可能地使用绑定变量,而在需要时才使用常量,这才是正确的做法。

注意    世上没有银弹——要记住,根本没有。如果有的话,自然就会默认地采用那种做法,这样也就无所谓银弹了。

就算是确实能在数据库级放几个开关(这种开关真的很少),但是有些问题与并发控制和执行不佳的查询(可能是因为查询写得不好,也可能是因为数据的结构性差)有关,这些问题用开关是解决不了的。这些情况往往需要重写(而且时常需要重建)。移动数据文件、修改多块读计数(multiblock read count)和其他数据库级开关对应用的总体性能通常影响很小。你想让用户接受你的应用,可能需要让性能提升2倍、3倍、……、n倍才行。你的应用是不是只慢了10%,这种情况多不多?如果只是慢10%,没有人会有太多抱怨。但是如果慢了5倍,就会让人很不高兴。再说一遍,如果只是移动数据文件,性能不会提升5倍。要想达到这个目的,只能通过调整应用才能办到,可能要让它大幅减少I/O操作。

在整个开发阶段,你都要把性能作为一个目标精心地设计,合理地构建,并且不断地测试。绝对不能把它当作马后炮,事后才想起来。我真是很奇怪,为什么那么多人根本不对应用调优,就草率地把应用交付到客户手里,匆匆上马,并运行起来。我见过一些应用除了主键索引外,居然没有其他的任何索引。从来没有对查询执行过调优,也没有执行过压力测试。应用的用户数很少,从未让更多的用户试用过。这些应用总是把调优当成产品安装的一部分。对我来说,这种做法绝对不可接受。最终用户应该第一天就拿到一个响应迅速、充分优化的系统。肯定还有许多“产品问题”需要处理,但不能让用户从一开始就领教糟糕的性能。对用户来说,一个新应用里有几个bug尚能容忍,但你别指望他们能耐心地在屏幕前等待漫长的时间。

1.3.6             DBA与开发人员的关系

有一点很肯定,要建立最成功的信息系统,前提是DBA与应用开发人员之间要有一种“共生关系”。在这一节里,我想从开发人员的角度谈谈开发人员与DBA之间的分工(假设所有正式开发都有DBA小组的参与)。

作为一名开发人员,你不必知道如何安装和配置软件。这应该是DBA或者系统管理员(system administratorSA)的任务。安装Oracle Net、配置监听器、配置共享服务器、建立连接池、安装数据库、创建数据库等,这些事情我都会交给DBA/SA来做。

一般来讲,开发人员不必知道如何对操作系统调优。我个人通常会让系统的SA负责这个任务。作为数据库应用的软件开发人员,应该能熟练地使用你选择的操作系统,但是不要求你能对它调优。

DBA最重大的职责是数据库恢复。注意,我说的可不是“备份”,而是“恢复”。而且,我认为这也是DBA惟一重要的职责。DBA要知道回滚(rollback)和重做(redo)怎么工作,不错,这也是开发人员要了解的。DBA还要知道如何完成表空间时间点恢复,这一点开发人员不必介入。如果你能有所了解,也许以后会用得上,但是作为开发人员目前不必亲力而为。

在数据库实例级调优,并得出最优的PGA_AGGREGATE_TARGET是什么,这一般是DBA的任务(数据库往往能帮助他们得出正确的答案)。也有一些例外情况,有时开发人员可能需要修改会话的某个设置,但是如果在数据库级修改设置,就要由DBA来负责。一般数据库并不是只支持一位开发人员的应用,而是运行着多个应用,因此只有支持所有应用的DBA才能做出正确的决定。

分配空间和管理文件也是DBA的工作。开发人员可以对分配的空间做出估计(他们觉得需要多少空间),但是余下的都要由DBA/SA决定。

实质上讲,开发人员不必知道如何运行数据库,他们只需要知道如何在数据库中运行。开发人员和DBA要协同解决问题,但各有分工。假设你是一位开发人员,如果你的查询用的资源太多,DBA就会来找你;如果你不知道怎么让系统跑得更快,可以去找DBA(如果应用已经得到充分调优,此时就可以完成实例级调优)。

这些任务因环境而异,不过我还是认为存在着分工。好的开发人员往往是很糟糕的DBA,反之亦然。在我看来,他们的能力不同、思路不同,而且个性也不同。很自然地,人们都爱做自己最喜欢的工作,而且能越做越好,形成良性循环。如果一个人比较喜欢某项工作,他会做得更好,但是这并不是说其他工作就一定做得很糟。就我而言,我觉得我更应算是一位开发人员,但兼有DBA的许多观点。我不仅喜欢开发,也很喜欢“服务器方面”的工作(这大大提高了我的应用调优水平,而且总会有很多收获)。

1.4   小结 

这一章好像一直在东拉西扯地闲聊,我是想用这种方式让你认识到为什么需要了解数据库。这里提到的例子并不是个别现象,这些情况每天都在出现。我注意到,诸如此类的问题总在连续不断地发生。

下面把要点再重述一遍。如果你要用Oracle开发,应该做到:

q         需要理解Oracle体系结构。不要求你精通到能自行重写服务器的程度,不过确实需要有足够的了解,知道使用某个特定特性的含义。

q         需要理解锁定和并发控制特性,而且知道每个数据库都以不同的方式实现这些特性。如果不清楚这一点,你的数据库就可能给出“错误”的答案,而且应用会遭遇严重的竞争问题,以至于性能低下。

q         不要把数据库当作黑盒,也就是说,不要以为无需了解数据库。在大多数应用中,数据库都是最为重要的部分。如果忽略它,后果是致命的。

q         用尽可能简单的方法解决问题,要尽量使用Oracle提供的内置功能。这可是你花大价钱买来的。

q         软件项目、编程语言以及框架总是如走马灯似地在变。作为开发人员,我们希望几周(可能几个月)内就把系统建立并运行起来,然后再去解决下一个问题。如果总是从头开始重新“创造”,就永远也追不上开发的脚步。你肯定不会用Java建立你自己的散列表,因为Java已经提供了一个散列表,同样,你也应该使用手头可用的数据库功能。当然,为此第一步是要了解有哪些数据库功能可用。我曾经见过不止一个开发小组遇到麻烦,不光技术上有困难,人员也很紧张,而造成这种结果的原因只是不清楚Oracle已经免费提供了哪些功能。

q         还是上面这一条(软件项目和编程语言总是像走马灯似的),但数据是永远存在的。我们构建了使用数据的应用,从长远看,这些数据会由多个应用使用。所以重点不是应用,而是数据。应该采用允许使用和重用数据的技术和实现。如果把数据库当成一个桶,所有数据访问都必须通过你的应用,这就错了。这样一来,你将无法自主地查询应用,也无法在老应用之上构建新应用。但是,如果充分地使用数据库,你就会发现,无论是增加新应用、新报告,还是其他任何功能,都会容易得多

牢记以上这几点,再接着看下面的内容。