本章的第一节已详细讨论了SQL包含的关系和非关系特性,及其对优化器有效工作的影响。带着来自第一节的经验教训,接下来我们将集中讨论使用SQL时必须考虑的关键因素。依我看来,有五大要素:
获得结果集所需访问的数据量 定义结果集所需的查询条件 结果集的大小 获得结果集所涉及的表的数量 多少用户会同时修改这些数据
数据总量 Total Quantity of Data 必须访问的数据总量,是要考虑的最重要因素。一个查询方案,用于只有14 行数据的 emp表和4行数据的dept表时表现非常出色,但它可能完全不适用于有1 500万行数据的 financial_flows 表与有 500 万行数据的 products 表的join操作。注意,以许多公司的标准来看,1 500 万行的表并不算特别大。所以结论是,没有确定目标容量之前,很难断定查询执行的效率。
定义结果集的查询条件 Criteria Defining the Result Set 在编写 SQL 语句时,多数情况下会涉及 where 子句的条件,而在子查询或视图(普通视图或内嵌视图)中可能有多个 where 子句。然而,过滤条件的效率有高有低,这会受到其他因素的极大影响,例如物理实现(将在第5章中讨论)及要访问的数据量等因素。
为了定义结果集,必须从几个方面来考虑,包括过滤、主要SQL语句,以及庞大的数据量对查询的影响等。这是个复杂的问题,须做深度探讨,详见本章“过滤”一节。
结果集的大小 Size of the Result Set 查询所返回的数据量(或是SQL语句改动的数据量),是个重要且常被忽略的因素。一般而言,这取决于表的大小和过滤条件的细节,但不都是这样。典型的情况是,若干个独立使用时效率不高的条件,结合起来使用时会产生极高的效率;例如,以“是否获得理工科或文科学位”作为查询学生姓名的条件,结果集会非常大,但如果同时使用这两个条件(获得这两个学位),则产生的结果集就会大幅缩小。
从技术的角度来看,查询结果集的大小并不重要,重要的是最终用户的感觉。用户的耐心,在很大的程度上和预期返回的记录条数有关:用户只检索一条记录,则他期望非常快,他不会关心整个数据库有多大。更极端的例子是,查询之后并未返回任何结果:好的开发者都会努力使返回少量记录或不返回记录的查询尽量快,因为对用户而言,最令人沮丧的事莫过于等待了数分钟后,看到“无相符数据”的结果;若是按下回车键后马上察觉查询语句有误,而又无法终止查询,等待就更为恼人。最终用户情愿等待的,是预期返回大量数据时。如果把每个过滤条件定义的特定结果集看作中间结果,而最终结果是它们的交集(在条件中用and相连)或并集(在条件中用or相连),那么小型中间结果集的交集很可能为空。换言之,更精确的条件经常是零结果集产生的主要原因。无论何时,只要查询有可能返回零结果集时,都应该先检查那个最大可能导致空结果集的条件——尤其是在该检查执行非常快捷时。不用说,条件的顺序与条件所在上下文的关系十分密切,这在稍后“过滤”一节中讲述。
总结:熟练的开发者应该努力使响应时间与返回的记录数成比例。
表的数量 Number of Tables 查询中涉及的表的数量,自然会对性能有所影响。这不是因为 DBMS 引擎不能很好地执行连接操作——恰恰相反,现代的DBMS都能非常高效地连接很多表。
连接(Join) 认为连接效率不高的想法,来自另一个对关系数据库的成见。通常的说法是不该连接太多表,建议的上限是 5 个。事实上,连接 15 个表也一样可以极高效地执行。但在连接大量表时,会产生一些额外的问题。 当需要连接多个表时(例如 15 个),按常理你就应该质疑设计的正确性。回忆一下第1章的内容—— 表的一条记录陈述了某个事实,而且可以将它比作数学的公理,通过连接表的操作,可衍生出其他事实。但要清楚一点,即哪些是显而易见的事实,可以称为公理;哪些是较不明显的事实,必须推衍得到。如果我们需要花大量时间来推衍事实,或许最初选择的公理就不合适。
对于优化器来说,随着表数量的增加,复杂度将呈指数增长。再次提醒,统计优化器通常有出色的表现,但同时其耗时在查询总响应时间中的比例也很高,尤其是在查询第一次执行时。如果表比较多,让优化器分析所有可能的查询路径,是非常不切实际的。除非查询语句是为方便优化器刻意编写的,否则,查询越复杂,优化器越容易“押错宝(bet on the wrong horse)”。
编写涉及许多表的复杂查询时,若可以用好几种截然不同的方式进行连接,最终选择失误的几率很高。如果我们连接表 A、B、C 和 D,优化器可能没有足够的信息判断出A 直接与 D 连接的效率会很高。想以 distinct 解决记录重复问题的开发者,也常会遗漏连接条件。
复杂查询与复杂视图 我们必须明白,表面上看到的参与查询的表的数量可能不真实,有些表实际上是视图,它们有时很复杂。和查询一样,视图的复杂程度也差异极大。视图可以屏蔽字段、记录、甚至是字段和记录的组合,只让少数有权限的用户可以访问。视图从特定视角反映数据,从表的现存关系中推衍出新的关系。此时,视图可以看作查询的简略表达方式,这是视图最常见的用途之一。随着查询复杂度的增加,似乎应该把查询拆成一系列独立视图,每个视图代表复杂查询的一部分。
总结:表明简单的查询背后,可能隐藏着复杂的视图。
不要走极端,完全不使用视图也不合理,一般它们并无坏处。然而,将视图用在复杂查询中时,我们多半只对视图返回数据中的一小部分感兴趣——可能是几十个字段中的几个字段——这时,优化器会试图将简单视图重新并入一段更大的查询语句中。但是,一旦查询复杂到一定程度,此方法就太复杂了,以至于难以保证效率。
在某些情况下,视图的编写方式,能有效地预防优化器把它并入上级语句中。我已提过 rownum,那是 Oracle 使用的虚拟字段,用来显示记录最初被查到时的顺序。如果在视图中使用rownum,复杂性会进一步增加。任何想把参照了 rownum 的视图并入上级查询中的尝试,都会改变后续 rownum 的顺序,所以此时不允许优化器改写查询。于是,复杂查询中这种视图将独立执行。DBMS 优化器常把视图原样并入语句中,把它当成语句执行的一步来运行(注2),而且只使用视图执行结果中所需要的部分。
视图中执行的操作(典型的例子是通过join获取ID号对应的描述信息),往往与其所属查询的上下文无关;或者,查询条件很特殊,会淘汰组成视图的一些表。例如,对若干个表进行union得到的视图,代表了多个子类型,而上级查询的过滤器只针对其中一个子类型,所以unio其实是不必要的。将“视图”与“视图中出现的表”进行join也有危险,这会强制多次扫描该表并多次访问相同记录,但其实只扫描一次就足够了。
当视图返回的数据远多于上级查询所需时,放弃使用该视图(或改用一个较简单的视图),通常可使效率大为改善。首先,用SQL 查询取代主查询中用到的视图。对视图的组成部分有了整体的了解之后,要去除严格意义上不必要的部分就容易多了。改用较简单视图的效果也不错,从查询中去除了不必要部分,执行速度快多了。
许多开发者不愿在复杂查询中,再引入复杂的视图,他们认为这会使情况更为复杂。推导与分解复杂的SQL表达式的确有点令人生畏,不过,和高中时常做的数学表达式推导也差不多。在我看来,这有助于形成良好的编程风格,值得花些时间去掌握。对于渴望提高编程技巧的开发者来说,研究上述技巧有利于对查询内部工作原理的深入了解,常常使你受益匪浅。
总结:当视图返回不必要的元素时,别把视图内嵌在查询中,而是应将视图分解,将其组成部分加到查询主体中。
并发用户数 Number of other Users
最后,在设计 SQL 程序时,并发性(concurrency)是个必须认真对待的因素。写数据库时需要关注并发性:是否存在数据块访问争用(block-access contention)、阻塞(locking)、或闩定(latching)(DBMS内部资源阻塞)等重要问题;甚至有时,为保证读取一致性(read consistency)也会导致某种程度的资源争用。任何服务器的处理能力都是有限的,不管其说明书有多令人震撼。在机器相同的情况下,很少并发或没有并发操作时设计可能是完美的,但对有大量并发操作的情况未必完美。排序操作可能没有足够内存可用,于是转而求助于磁盘,引发了新的资源争用……一些计算密集型(CPU- intensive)操作——例如负责复杂计算的函数、索引区块的重复扫描,均可引起计算机负荷过多。我遇到过一些案例,增加物理 I/O 会使任务执行效率更高,因为其中计算密集操作的并发执行程度很高,一个进程刚因等待 I/O 而阻塞,被释放的CPU就被另一个进程占用了,这样一来CPU资源就被充分利用了。一般而言,我们必须考虑特定商业任务的整体吞吐量(throughput),而不是个别用户的响应时间(response-time)。
注意 第9章将更详细地探讨并发性。 |