过滤
Filtering
如何限定结果集是最关键的因素之一,有助于你在编写 SQL 语句时判断该用哪些技巧。用来过滤数据的所有准则,通常被视为是 where 子句中各种各样的条件,我们必须认真研究各种 where 子句(及 having 子句)。
过滤条件的含义
Meaning of Filtering Conditions
若从SQL语法来看,where子句中表达的所有过滤条件当然大同小异。但事实并非如此。有些过滤条件通过关系理论直接作用于select 运算符:where子句检查记录中的字段是否与指定条件相符。但其实,where 子句中的条件还可以使用另一个关系运算符 join。自从 SQL92 出现 join 语法后,人们就试图将“join过滤条件”(位在主 from 子句和 where 子句之间)和“select过滤条件”(位于where子句内)区分开来。从逻辑上讲,连接两个(或多个)表建立了新的关系。
下面是个常见的连接(join)的例子:
select .....
from t1
inner join t2
on t1.join1 = t2.joind2
where ...
假设表t2中有一字段c2,该不该把 c2上的条件当作 inner join 的额外条件呢?即是否应认为参与连接的不是“t2表”而是“t2表的子集”呢?或者,假设where 子句中有一些关于t1 字段的条件,那么这些条件是否会应用到 t1 连接 t2 的结果呢?连接条件放在何处应该都一样,然而其运行效率却会因优化器不同而异。
除了连接条件和简单的过滤条件之外,还有其他种类的条件。例如,规定返回的记录集为某种子类型的条件,以及检查另一个表内是否存在特定数据的条件。虽然从 SQL 语法上看它们相似,但在语义上却未必完全相同。有时条件的计算顺序无足轻重,但有时却影响重大。
下面的例子说明了条件计算顺序的重要性,实际上,在许多商用软件包中都能找到这样的例子。假设有个 parameters 表,它包含字段:parameter_name、parameter_type、
parameter_value,无论由parameter_type定义了什么参数属性,其中 parameter_ value 均以字符串表示。(从逻辑上来说,上述情况堪比罗密欧与茱莉叶的悲剧,因为属性parameter_value所表示的领域类型非常多,所以违反了关系理论的主要规则。)假设进行如下查询:
select * from parameters
where parameter_name like '%size'
and parameter_type = 'NUMBER'
在这个查询中,无论先计算两个条件中的哪一个,都无关紧要。然而,如果增加了以下条件,计算的顺序就变得非常重要了,其中int()是将字符转换为整数值的函数:
and int(parameter_value) > 1000
这时,parameter_type上的条件必须先计算,而parameter_value上的条件后计算,否则会因为试图把非数字字符串转换为整数,而造成运行时错误(假设 parameter_ type字段的类型定义为char)。如果你无法向数据库说明这一点,那么优化器也无从知道哪个条件应该有较高的优先权。
总结:查询条件是有差异的,有的好,有的差。
过滤条件的好坏
Evaluation of Filtering Conditions
编写 SQL 语句时,应首先考虑的问题是:
l 哪些数据是最终需要的,这些数据来自哪些表?
l 哪些输入值会传递到 DBMS 引擎?
l 哪些过滤条件能滤掉不想要的记录?
然而要清楚的是,有些数据(主要是用来连接表的数据)可能冗余地存储在几个表中。所以,即使所需的返回值是某表的主键,也不代表这个表必须出现在from子句中,这个主键可能会以外键的形式出现在另一个表中。
在编写查询之前,我们甚至应该对过滤条件进行排序,真正高效的条件(可能有多个,涉到不同的表)是查询的主要驱动力,低效条件只起辅助作用。那么定义高效过滤条
件的准则是什么呢?首先,要看过滤条件能否尽快减少必须处理的数据量。所以,我们必须倍加关注条件的编写方式,下面通过例子说明这一点。
蝙蝠车买主
假设有四个表: customers、orders、orderdetail、articles,如图4-5所示。注意,图中各表的方框大小不同,这代表各表的数据量大小,而不代表字段数量;加下划线的字段为主键。

图4-5:经典的订单Schema
现在假设 SQL 要处理的问题是:找出最近六个月内居住在Gotham市、订购了蝙蝠车的所有客户。当然,编写这个查询有多种方法,ANSI SQL的推崇者可能写出下列语句:
select distinct c.custname
from customers c
join orders o
on o.custid = c.custid
join orderdetail od
on od.ordid = o.ordid
join articles a
on a.artid = od.artid
where c.city = 'GOTHAM'
and a.artname = 'BATMOBILE'
and o.ordered >= somefunc
其中,somefunc是个函数,返回距今六个月前的具体日期。注意上面用了distinct,因为考虑到某个客户可以是大买家,最近订购了好几台蝙蝠车。
暂不考虑优化器将如何改写此查询,我们先看一下这段代码的含义。首先,来自customers表的数据应只保留城市名为 Gotham 的记录。接着,搜索orders表,这意味着custid字段最好有索引,否则只有通过排序、合并或扫描orders表建立一个哈
希表才能保证查询速度。对orders表,还要针对订单日期进行过滤:如果优化器比较聪明,它会在连接(join)前先过滤掉一些数据,从而减少后面要处理的数据量;不太聪明的优化器则可能会先做连接,再作过滤,这时在连接中指定过滤条件利于提高性能,例如:
join orders o
on o.custid = c.custid
and a.ordered >= somefunc
即使过滤条件与连接(join)无关,优化器也会受到过滤条件的影响。例如,若orderdetail的主键为(ordid, artid),即ordid为索引的第一个属性,那么我们可以利用索引找到与订单相关的记录,就和第3章中讲的一样。但如果主键是(artid, ordid)就太不幸了(注意,就关系理论而言,无论哪个版本都是完全一样),此时的访问效率比(ordid, artid)作为索引时要差,甚至一些数据库产品无法使用该索引(注3),唯一的希望就是在 ordid 上加独立索引了。
连接了表 orderdetail和orders之后,来看articles表,这不会有问题,因为表 orderdetail 主键包括 artid字段。最后,检查 articles 中的值是否为Batmobile。查询就这样结束了吗?未必结束,因为用了distinct ,通过层层筛选的客户名还必须要排序,以剔除重复项目。
分析至此,可以看出这个查询有多种编写方式。下面的语句采用了古老的join语法:
select distinct c.custname
from customers c,
orders o,
orderdetail od,
articles a
where c.city = 'GOTHAM'
and c.custid = o.custid
and o.ordid = od.ordid
and od.artid = a.artid
and a.artname = 'BATMOBILE'
and o.ordered >= somefunc
本性难移,我偏爱这种较古老的方式。原因只有一个:从逻辑的角度来看,旧方法突显出数据处理顺序无足轻重这一事实;无论以什么顺序查询表,返回结果都是一样的。customers 表非常重要,因为最终所需数据都来自该表,在此例中,其他表只起辅助作用。注意,没有适用于所有问题的解决方案,表连接的方式会因情况不同而异,而决定连接方式取决于待处理数据的特点。
特定的SQL查询解决特定的问题,而未必适用于另一些问题。这就像药,它能治好这个病人,却能将另一个病人医死。
蝙蝠车买主的进一步讨论
下面看看查询蝙蝠车买家的其他方法。我认为,避免在最高层使用distinct应该是一条基本规则。原因在于,即使我们遗漏了连接的某个条件,distinct也会使查询“看似正确”地执行——无可否认,较旧的SQL语法在此方面问题较大,但ANSI/SQL92 在通过多个字段进行表的连接时也可能出现问题。发现重复数据容易,但发现数据不准确很难,所以避免在最高层使用distinct应该是一条基本规则。
发现结果不正确更难,这很容易证明。前面使用 distinct 返回客户名的两个查询,都可能返回不正确结果。例如,如果恰巧有多位客户都叫“Wayne”,distinct不但会剔除由同个客户的多张订单产生的重复项目,也会剔除由名字相同的不同客户产生的重复项目。事实上,应该同时返回具唯一性的客户ID和客户名,以保证得到蝙蝠车买家的完整清单。在实际中,发现这个问题可不容易。
要摆脱 distinct,可考虑以下思路:客户在 Gohtam市,而且满足存在性测试,即在最近六个月订购过蝙蝠车。注意,多数(但非全部) SQL 方言支持以下语法:
select c.custname
from customers c
where c.city = 'GOTHAM'
and exists (select null
from orders o,
orderdetail od,
articles a
where a.artname = 'BATMOBILE'
and a.artid = od.artid
and od.ordid = o.ordid
and o.custid = c.custid
and o.ordered >= somefunc )
上例的存在性测试,同一个名字可能出现多次,但每个客户只出现一次,不管他有多少订单。有人认为我对 ANSI SQL 语法的挑剔有点苛刻(指“蝙蝠车买主”的例子),因为上面代码中customers表的地位并没有降低。其实,关键区别在于,新查询中customers表是查询结果的唯一来源(嵌套的子查询会负责找出客户子集),而先前的查询却用了join。
这个嵌套的子查询与外层的 select关系十分密切。如代码第 11 行所示(粗体部分),子查询参照了外层查询的当前记录,因此,内层子查询就是所谓的关联子查询(correlated subquery)。此类子查询有个弱点,它无法在确定当前客户之前执行。如果优化器不改写此查询,就必须先找出每个客户,然后逐一检查是否满足存在性测试,当来自Gotham市的客户非常少时执行效率倒是很高,否则情况会很糟(此时,优秀的优化器应尝试其他执行查询的方式)。
我们还可以这样编写查询:
select custname
from customers
where city = 'GOTHAM'
and custid in
(select o.custid
from orders o,
orderdetail od,
articles a
where a.artname = 'BATMOBILE'
and a.artid = od.artid
and od.ordid = o.ordid
and o.ordered >= somefunc)
在这个例子中,内层查询不再依赖外层查询,它已变成了非关联子查询(uncorrelated subquery),只须执行一次。很显然,这段代码采用了原有的执行流程。在本节的前一个例子中,必须先搜寻符合地点条件的客户(如均来自GOTHAM),接着依次检查各个订单。而现在,订购了蝙蝠车的客户,可以通过内层查询获得。
不过,如果更仔细地分析一下,前后两个版本的代码还有些更微妙的差异。含关联子查询的代码中,至关重要的是orders 表中的 custid字段要有索引,而这对另一段代码并不重要,因为这时要用到的索引(如果有的话)是表customers的主键索引。
你或许注意到,新版的查询中执行了隐式的 distinct。的确,由于连接操作,子查询可能会返回有关一个客户的多条记录。但重复项目不会有影响,因为 in 条件只检查该项目是否出现在子查询返回的列表中,且in不在乎某值在列表中出现了一次还是一百次。但为了一致性,作为整体,应该对子查询和主查询应用相同的规则,也就是在子查询中也加入存在性测试:
select custname
from customers
where city = 'GOTHAM'
and custid in
(select o.custid
from orders o
where o.ordered >= somefunc
and exists (select null
from orderdetail od,
articles a
where a.artname = 'BATMOBILE'
and a.artid = od.artid
and od.ordid = o.ordid))
或者:
select custname
from customers
where city = 'GOTHAM'
and custid in
(select custid
from orders
where ordered >= somefunc
and ordid in (select od.ordid
from orderdetail od,
articles a
where a.artname = 'BATMOBILE'
and a.artid = od.artid)
尽管嵌套变得更深、也更难懂了,但子查询内应选择 exists 还是in 的选择规则相同:此选择取决于日期与商品条件的有效性。除非过去六个月的生意非常清淡,否则商品名称应为最有效的过滤条件,因此子查询中用in 比 exists 好,这是因为,先找出所有蝙蝠车的订单、再检查销售是否发生在最近六个月,比反过来操作要快。如果表 orderdetail 的artid字段有索引,这个方法会更快,否则,这个聪明巧妙的举措就会黯然失色。
注意
每当对大量记录做存在性检查时,选择in还是exists须斟酌。
利于多数 SQL 方言,非关联子查询可以被改写成from 子句中的内嵌视图。然而,一定要记住的是,in 会隐式地剔除重复项目,当子查询改写为 from 子句中的内嵌视图时,必须要显式地消除重复项目。例如:
select custname
from customers
where city = 'GOTHAM'
and custid in
(select o.custid
from orders o,
(select distinct od.ordid
from orderdetail od,
articles a
where a.artname = 'BATMOBILE'
and a.artid = od.artid) x
where o.ordered >= somefunc
and x.ordid = o.ordid)
编写功能等价的查询时,不同的编写方式就好像同义词。在书面语和口语中,同义词的意思虽然大致相同,但又有细微差异,因此某个词在特定语境中更合适。同样,数据和处理的具体实现细节可以决定选择哪种查询方式。
蝙蝠车买主案例总结
前面讨论的各段SQL语句,看似意义不大的编程技巧练习,实则不然。关键是“擒获(attack)”数据的方法有很多,不必按照先customers、然后orders、接着orderdetail和articles的方式来编写查询。
现在以箭头表示搜索条件的强度——条件分辨力越强,箭头就越大。假设 Gotham市的客户非常少,但过去六个月的销售业绩不错,卖出了很多蝙蝠车,此时规划图如图4-6所示。虽然商品名称之上有个过滤条件,但图中的中等大小的箭头指向了表orderdetail,因为该表是真正重要的表。待售商品可能很少,反映出销售收入的百分比;也可能待售商品很多,最畅销的商品之一就是蝙蝠车。

图4-6:查询的分辨力主要受位置的影响
相反,如果我们假设多数客户在 Gotham市,但其中很少的客户买了蝙蝠车,则规划图如图4-7所示。很显然,此时表orderdetail 是最大的目标。来自这个表的数据的数据量缩减速度越快,查询执行得就越快。

图4-7:查询的分辨力主要受orderdetail表的影响
还要注意的非常重要的一点是,“过去六个月”并不是个非常精确的条件。但如果我们把条件改为过去两个月,而库中有十年的销售记录,会发生什么呢?在这种情况下,如果能先访问到近期的订单(借助第5章中描述的一些技术,这些数据或许就聚集在一起),查询的效率就会更高些;找出近期订单后,一方面选取Gotham 的客户,另一方面则选取蝙蝠车订单。所以,换个角度来看,最好的执行计划并不只相依于数据值,还应该随着时间而不断进化。
好了,总结一下。首先,解决问题的方法不只一种……而且查询的编写方式经常会与数据隐含的假设相关。殊途同归,最终的结果集都是一样的,但执行速度可能有极大差异。查询的编写方式会影响执行路径,尤其是应用无法在真正的关系环境中表达的条件时。若想让优化器发挥极致,我们就必须扩大关系处理的工作量,并确保非关系的部分对最后结果集的影响最小。
本章前面一直假设代码的执行方式与编写方式一样,但其实,优化器可能改写查询——有时改动还很大。你或许认为优化器所做的改写无关紧要,因为 SQL本是一种声明性语言(declarative language),用它来说明想要什么,并让 DBMS 予以执行。然而,你也看到了,每次用不同方式改写查询时,都必须更新关于数据分布和已有索引的假设。因此有一点非常重要:应预先考虑优化器的工作,以确定它能找到所需数据——这可能是索引,也可能是数据相关的详细统计信息。
总结:保证SQL 语句返回正确结果,只是建立最佳 SQL语句的第一步。
大数据量查询
Querying Large Quantities of Data
越快剔除不需要的数据,查询的后续阶段必须处理的数据量就越少,自然查询的效率就越高,这听起来显而易见。集合操作符(set operator)是这一原理的绝佳应用,其中的union使用最为广泛,我们经常看到通过union操作将几个表“粘”在一起。中等复杂程度的union语句较为常见,大多数被连接的表都会同时出现在union两端的select 语句中。例如下面这段代码:
select ...
from A,
B,
C,
D,
E1
where (condition on E1)
and (joins and other conditions)
union
select ...
from A,
B,
C,
D,
E2
where (condition on E2)
and (joins and other conditions)
这类查询是典型的“照搬式”编程。为了提高效率,可以仅对代码中非共用的表(本例中即E1和E2)使用union,然后配合筛选条件,把 union 语句降级为内嵌视图。代码如下:
select ...
from A,
B,
C,
D,
(select ...
from E1
where (condition on E1)
union
select ...
from E2
where (condition on E2)) E
where (joins and other conditions)
另一个“查询条件用错了地方”的经典例子,和在含有 group by 子句的查询中进行过滤操作有关。你可以过滤分了组的字段,也可以过滤聚合(aggregate)结果(例如检查 count() 的结果是否小于某阈值),或者同时过滤两者;SQL 允许在 having 子句中使用这类条件,但应该在 group by 完成后才进行过滤(比如排序之后再进行聚合操作)。任何影响聚合函数(aggregate function)结果的条件都应放在 having 子句中,因为在 group by 之前无从知道聚合函数的结果。任何与聚合无关的条件都应放在 where 子句中,从而减少为进行group by而必须执行的排序操作所处理的数据量。
现在回过头来看客户与订单的例子,我承认先前处理订单的方法比较复杂。在订单完成之前,必须经历几个阶段,这些都记录在表orderstatus中,该表的主要字段有:ordid(订单ID)、status、statusdate(时间戳)等,主键由ordid和statusdate组成。我们的需求是列出所有尚未标记为完成状态的订单(假设所有交易都已终止)的下列字
段:订单号、客户名、订单的最后状态,以及设置状态的时间。最终,我们写出下列查询,滤掉已完成的订单,并找出订单当前状态:
select c.custname, o.ordid, os.status, os.statusdate
from customers c,
orders o,
orderstatus os
where o.ordid = os.ordid
and not exists (select null
from orderstatus os2
where os2.status = 'COMPLETE'
and os2.ordid = o.ordid)
and os.statusdate = (select max(statusdate)
from orderstatus os3
where os3.ordid = o.ordid)
and o.custid = c.custid
乍一看,这个查询很合理,但事实上,它让人非常担心。首先,上面代码中有两个子查询,但它们嵌入的方式和前一个例子的方式不同,它们只是彼此间接相关的。最让人担心的是,这两个子查询访问相同的表,而且该表在外层已经被访问过。我们编写的过滤条件质量如何呢?因为只检查了订单是否完成,所以它不是非常精确。
这个查询如何执行的呢?很显然,可以扫描 orders 表,检查每一条订单记录是否为已完成状态——注意,仅通过表 orders 即可找出所要信息似乎令人高兴,但实际情况并非如此,因为只有上述活动之后,才能检查最新状态的日期,即必须按照子查询编写的顺序来执行。
上述两个子查询是关联子查询,这很不好。因为必须要扫描 orders 表,这意味着我们必须检查 orders 的每条订单记录状态是否为 “COMPLETE”,虽然检查状态的子查询执行很快,但多次重复执行就不那么快了。而且,若第一个子查询没找到 “COMPLETE” 状态时,还必须执行第二个子查询。那么,何不试试非关联子查询呢?
要编写非关联子查询,最简单的办法是在第二个子查询上做文章。事实上,在某些 SQL 方言中,我们可以这么写:
and (o.ordid, os.statusdate) = (select ordid, max(statusdate)
from orderstatus
group by ordid)
这个子查询会对 orderestatus 作“全扫描”,但未必是坏事,下面会对此加以解释。
重写的子查询条件中,等号左端的“字段对”有点别扭,因为这两个字段来自不同的表,其实不必这样。我们想让orders和orderstatus的订单ID相等,但优化器能感知这一点吗?答案是不一定。所以优化器可能依然先执行子查询,依然要把orders和orderstatus这两个表连接起来。我们应该将查询稍加修改,使优化器更容易明白我们的描述,最终按照“先获得子查询的结果,然后再连接orders和orderstatus表”的顺序工作:
and (os.ordid, os.statusdate) = (select ordid, max(statusdate)
from orderstatus
group by ordid)
这次,等号左端的字段来自相同的表,从而不必连接orders和orderstatus这两个表了。尽管好的优化器可能会帮我们做到这一点,但保险起见,一开始就指定这两个字段来自相同的表是更明智的选择。为优化器保留最大的自由度总是上策。
前面已经看到了,非关联子查询可以变成内嵌视图,且改动不大。下面,我们写出“列出待办订单”的整个查询语句:
select c.custname, o.ordid, os.status, os.statusdate
from customers c,
orders o,
orderstatus os,
(select ordid, max(statusdate) laststatusdate
from orderstatus
group by ordid) x
where o.ordid = os.ordid
and not exists (select null
from orderstatus os2
where os2.status = 'COMPLETE'
and os2.ordid = o.ordid)
and os.statusdate = x.laststatusdate
and os.ordid = x.ordid
and o.custid = c.custid
但还有问题,如果最终状态确实是“COMPLETE”,我们就没有必要用子查询检查其最新状态了。内嵌视图能帮我们找出最后状态,无论它是不是“COMPLETE”。所以我们把查询改为“检查已知的最新状态”,这个过滤条件非常令人满意:
select c.custname, o.ordid, os.status, os.statusdate
from customers c,
orders o,
orderstatus os,
(select ordid, max(statusdate) laststatusdate
from orderstatus
group by ordid) x
where o.ordid = os.ordid
and os.statusdate = x.laststatusdate
and os.ordid = x.ordid
and os.status != 'COMPLETE'
and o.custid = c.custid
如果进一步利用 OLAP 或SQL 引擎提供的分析功能,还可以避免对orderstatus的重复参照。不过就此打住,来思考一下我们是如何修改查询的,更重要的是“执行路径(execution path)”为何。基本上,正常路径是先扫描orders表,接着利用orderstatus表上预计非常高效的索引进行访问。在最后一版的代码中,我们改用完整扫描orderstatus的方法,这是为了执行group by。orderstatus中的记录条数一定会比 orders 中的大好几倍,然而,只以要扫描的数据量来看,估计前者比较小(而且可能小很多),这取决于为每张订单保存了多少信息。
无法确定哪种方法一定更好,这一切都取决于实际数据。补充说明一点,最好别在预期会增大的表上做全表扫描操作(若能把搜索限制在最近一个月或几个月的数据上则会好些)。不过,最后一版的代码肯定比第一版的(在where子句用子查询)要好。
在结束“大数据量查询”的话题之前,有个特殊情况值得一提。当查询要返回非常大量的数据时,该查询很可能不是某个用户坐在电脑前敲入的命令,而是来自于某个批处理操作。即便“预备阶段”稍长,只要整个处理能达到令人满意的结果,就是可以接受的。当然,不要忘了,无论是不是预备阶段,都会需要资源——CPU、内存,可能还有临时磁盘空间。即使最基本的查询完全相同,优化器在返回大量数据时所选择的路径,仍可能会与返回少量数据时完全不同,了解这一点是有用的。
总结:尽早过滤掉不需要的数据。
取出数据在表中的比例
The Proportions of Retrieved Data
有个典型的说法:当查询返回的记录数超过表中数据总量的 10% 时,就不要使用索引。这种说法暗示,当(常规)索引的键指向表中不足10%的记录时,它是高效的。正如第3章中所指出的,这个经验法则建立于许多公司仍对关系数据库有所怀疑的年代,那时,关系数据库一般用于部门级数据库,包含十万行数据的表就被认为是大型表。与含有五亿行数据的表相比,十万行的 10% 不值一提。所以,执行计划“佳者恒佳”仅是个美好的愿望罢了。
就算不考虑“10%的记录”这条“经验法则(rule of thumb)”产生的年代(现在的表大小早已今非昔比了),要知道,返回的记录数除了与期望响应时间有关之外,它本身并无意义。例如,计算十亿行数据的某字段的平均值,虽然返回结果只有一行,但DBMS 要做大量工作。甚至没有任何聚合处理,DBMS要访问的数据页的数量也会造成影响。因为要访问的数据页并非只依赖索引:第3章曾指出,表中记录的物理顺序与索引顺序是否一致,对要访问的页数有极大影响;第5章将讨论的一些物理实现也会造成影响,由于数据的物理存储方式不同,检索出相同数量的记录所要访问的数据页数量可能差异很大;此外,有的访问路径将以串行方式执行,有的则以大规模并行(parallelized)方式执行……。因此,再别拿“10%的记录”这根鸡毛当令箭了。
总结:当查询的结果集很大时,索引未必必要。
SQL语句为了返回结果集或更改数据,必须访问一定数量的数据。“战斗”的环境和条件,决定了我们“进攻”那些数据的方法。就如第4章所讨论的,“进攻”取决于:结果集的数据量、必须访问的数据量、可动用的“部队”(过滤条件)。
任何大型的、复杂的查询,都可以被分成一连串较简单的步骤,其中一些步骤可以并行执行,就像综合战役通常要面对敌军的不同部队。每次战斗的结果差异可能很大,但关键是最后的综合结果。
当我们分析查询的每个步骤时可能不会深入执行细节,但这些步骤可能的组合数量跟国际象棋不相上下,可以非常复杂。






