人们提到SQL时总是说,既然它是一种声明性语言,你不必告诉它如何获得你要的数据; 你只需描述你要找的数据。确实如此:描述你的需求,你就会得到你想要的,但没人能保证能够以你预期的速度和成本获得。这就像在一个陌生的城市乘坐出租车。你可以告诉司机你要去哪儿,并且希望他会带你走最好的路线,但有时候时间和花费都超过了你的预期,除非你能告诉司机一些你想要他走的路线的相关信息。不管优化器多么优秀,一定会有某些情形存在,这时它的算法不能很好地满足你的需求。可能是统计信息造成的误导,或者优化器对于你的数据做了一些假定,而这些假定是错误的。如果发生了这样的事,你就需要找到一个方法能够给优化器一些指引。这篇文章描述了一种视觉的方法来设计SQL查询,特别是复杂的查询,它能够让你写出恰当的执行计划。它不仅仅在写新查询的时候有用,特别是对那些优化器表现不够好、需要帮助的查询进行"查错"、返工重写的时候尤其有用。思想很简单,本质上对所有数据库都适用,尽管你的编程方法从计划到SQL都很可能是依赖于某种数据库的。了解你的数据在你优化查询之前,首先你必须知道:. 你期望处理多少数据(数据量). 数据放在哪里(数据分布)当你在估计获得所需结果必须付出的工作量和时间,数据量和数据分布同等重要。如果你需要访问大量的数据,并且广泛地分布在数据库的不同地方,那么你的查询就不太可能执行得很快。然而,如果你采用了诸如聚簇索引之类的技术来保证数据都集中在一个相对小的空间,那么你仍然有可能快速访问大量数据。因此,你要提出的两个首要问题是:数据有多少?数据在哪里?接下来更进一步的问题是:怎么才能得到那些数据。比如你要从表中以某种条件挑出50行; 这听起来很直观,但最有效的方法是什么?你可能有如下选择:1. 一个"十分精确"的索引2. 一个相当精确的索引,它能够为你挑出100行,其中有50行你必须剔除3. 一个很"浪费"的索引,它能够为你挑出500行,其中有450行你必须剔除很重要的一点:如果你必须从第二和第三个索引中选一个,从我披露的信息中你能否判断哪个比较高效?正确的回答是“不能”。从100行中剔除50行听起来比从500行中剔除450行更高效,但是请记住:聚簇,或者说,数据的物理组织或分布,是关系重大的。假设你有一个索引,找出了10页(译者注:“页”可理解为ORACLE的“块”)的数据,每页包含10行,你要的50行在其中的5页上;而另一个索引找出了5页,每页包含100行。访问10个页并剔除50行,或者访问5个页剔除450行,哪个方法好?访问更少数据页的方法可能更好。请记住,你比优化器更加了解你的应用和数据。有时候优化器选择了一个不好的执行计划只是因为它不能像你一样了解数据,了解应用如何处理数据。如果一幅画胜过千言万语............为什么不把你的查询画出来呢?如果你有一个复杂的查询,包含了许多表,那么你确实需要有一种方法能够表达大量的信息,然后又能够被轻易接受。画图是个好主意,特别是你试图替别人的SQL查错的时候。我的方法很简单:把SQL通读一遍,把每个表画成一个方框,把每个连接(JOIN)画成方框之间的一条连线。如果你知道表连接的基数(一对一,一对多,多对多), 就在连接线“多”的那端画一个“乌鸦爪”的形状。如果你在表上有一个过滤谓词,就画一个进入方框箭头的箭头,把谓词写在旁边。如果一个“表”实际上是一个内联视图,或者是一个包含多个表的子查询,就把这组表用虚线框围起来。例如,你有一个schema定义了一个简单的订单处理系统:客户下订单,订单可以有多个订单行,一个订单行包含一种产品,产品来自多个供应商;某些产品可被其他产品代替。有一天你被要求提供一个报表:“来自伦敦的客户上周下的订单,产品来自利兹市(Leeds)的供应商并且可被其他地方的供应商替代”。假设我们只需要每个订单中此类产品的订单行的详细信息,这可以写成清单1中的查询:SELECT {list of columns}FROM customers cus INNER JOIN orders ord ON ord.id_customer = cus.id INNER JOIN order_lines orl ON orl.id_order = ord.id INNER JOIN products prd1 ON prd1.id = orl.id_product INNER JOIN suppliers sup1 ON sup1.id = prd1.id_supplierWHERE cus.location = 'LONDON' AND ord.date_placed BETWEEN dateadd(day, -7, getdate()) AND getdate() AND sup1.location = 'LEEDS' AND EXISTS ( SELECT NULL FROM alternatives alt INNER JOIN products prd2 ON prd2.id = alt.id_product_sub INNER JOIN suppliers sup2 ON sup2.id = prd2.id_supplier WHERE alt.id_product = prd1.id AND sup2.location != 'LEEDS' )清单1: 获取替代供应商的查询 很可能我对schema的语言描述不能够在SQL中立即可见,但是运用视觉方法可以把查询变成如图1所示的图像。一般要两三次尝试才能画出一幅清晰、整洁的图像,这不足为奇,特别是当你对别人的SQL进行反向工程的时候。我的第一幅草图总是会把所有的表挤在画面的一角。
图1: 我们的查询的第一幅草图在这幅图的基础上,我们开始把所需要的数值信息填进去。能详细到什么程度取决于我们对相关表的熟悉程度,和我们对基础应用的了解程度。在这个例子中,我会用orders表来演示我们所需信息的一些准则。这些信息有的来自INFORMATION_SCHEMA,有的可能来自对数据本身的查询,但理想情况是大部分都是已知的,因为我们对应用的运作和数据的特点都很熟悉:.行数: 250,000 .数据页数: 8,000 .开始基数: 2,400 .最终基数: 20 .进入orders表的现有相关索引: .(date_placed)聚簇性很好,每天大约400行 .(id_customer)聚簇性很差,每个客户 10 到 150 行.从orders表引出的现有相关索引: .order_lines (id_order, line_no)聚簇性很好,每个订单 1 to 15 行 .customers (id) primary key 主键注意我通常会把索引画成方框之间的箭头,并且标上它们的统计信息,我还会把表的统计信息写在方框里;图下方冗长的文字信息没什么用。有时候你必须需要一张大纸才能画出正确的草图,而这个网页的空间是很有限的。“开始基数”和“最终基数”必须解释一下。开始基数指的是我期望从表中获得的行数,假如这张表是我在整个查询中要访问的第一张表;换句话说,它是我在表上使用了带常量的过滤谓词之后的行数。原查询要求“上周的订单”,而我知道通常每周大约有2,400个订单。否则,我也许必须运行一个简单的查询,select count(*) from orders group by week, 来获得一个预估的数字。最终基数是指这张表会出现在最终结果集中的行数,而要估算这个数字则困难得多,除非有一个熟悉业务的人可以帮你。在本例中,我们最接近的估算数字表示开始基数和最终基数之间有巨大的差异。这说明在其中的某个步骤,我可能必须做很多工作来除掉这些额外的行,而我需要尽量让这个“丢弃”动作的工作量最小化。弄明白表里有什么,我们需要从表中得到什么,如何才能拿到,需要多少工作量,接下来我们就必须从图中选择一张表作为开始,然后不断重复这个问题:接下来我要访问哪张表?怎么才能到那里去?为了回答这些问题,你可以考虑以下的四个子问题,它们之间的优先顺序并不需要很严格:1.在访问下一张表之前,我能不能在当前结果集上做一个聚合来(大幅度地)减少数据量?2.有没有一张表我能够访问并且能够(以低廉的代价)减少数据?3.有没有一张表能够仅仅(稍微地)增加行的大小而不会增加行数?4.哪张表增加的行数最少(代价低)?如果你让这些问题来主导你对“下一张表”的选择偏向,它就会趋向于让中间数据量保持在一个低水平,从而使得工作量也在低水平。显然,在不同选择之间也有妥协,比如,是选择行大小显著增加(选项3)还是行数稍微增加(选项4),诸如此类。但是,如果你把这些选项当作灵活而不是死板的教条,并且总是提前思考几个步骤,即使你错了也差不远。在一个数据仓库,或者决策支持系统(DSS)中,你可能会发现理论上可以选择任何一张表作为分析的起点,分析几个不同路径然后找到最合适的一个,但通常的准则是选择一张能够以相对低廉的代价获取少量数据的表。在一个在线交易系统(OLTP), 通常只有一至两个很明显的开始点,假如你有相当全面的业务知识的话。在这个例子中,明显的开始点是订单(orders)表和客户(customers)表。毕竟,这个报表是关于“几个客户在一个短时期内的订单”,所以从图上的客户/订单一端开始,看起来会提供一个小的开始数据集。但是为了演示的目的,咱们不妨装傻一下,看看如果选择了供应商(suppliers)表作为起点会发生什么。从供应商(suppliers)表出发(返回Leeds的数据只有几行),接下来唯一合理的选择是利用外键索引(你可以假定有这么个索引)去产品(products)表。当然,严格来讲你可以去到任何一张表,前提是没有笛卡尔积(交叉连接)的威胁。从产品(products)表出发,我们能够去订单行(Order_lines)表或者替代品(Alternatives)表。访问Order_lines表会使得行数大量增加,而我们将不得不从一张非常大的表中挑选一些广泛分布的数据,所以我们将不得不在一个嵌套循环连接中使用一个昂贵的索引,或者用哈希连接做一个表扫描。另一方面,如果我们选择替代品(Alternatives)表,并且期望随后在子查询中访问products和suppliers表,然后回到Order_lines表,那么我们可能发现有很多产品没有替代品,从而行数会减少,因此这是一个较好的选择。等我们过滤掉不是来自利兹市(Leeds)的供应商,这个行数还会进一步减少。可是,最终我们还是会再次访问Products表剩下的数据并且连接到Order_lines表,而根据我关于Order_lines这样的表的常识,这样将会很低效地生成一个很大的数据集。一种产品很可能会出现在许多订单行之中,而且在表中的分布范围相当广泛;这不是一个好兆头。所以,仅有的合理选择是将orders和customers作为起点,并且,在访问这两张表之后,其他表的顺序为:Order_lines, Products, Suppliers, (Alternatives, Products, Suppliers).那么,我们是以Customers–Orders 还是 Orders–Customers 开始? 这就是索引会聚簇的重要之处。按现在的情况,考虑到orders表现有的相关索引,如果我们选择伦敦的客户,那么我们就得用id_customer索引来访问orders表来选择这些客户的所有订单,然后再丢弃那些在这一周的日期范围之外的订单。回过来看看统计信息,我们总共有250,000个订单,每周大约2,400个,这就说明我们的数据覆盖了两年(104周)的范围。所以,如果我们选择一个客户,然后再选择这个客户的所有订单,最终这些订单中大约有99%将被丢弃。考虑到这样的数据量,还有订单随着时间分布的方式,这将会花费大量工作收集大量数据,而这其中大部分都会被丢弃。另一种选择就是从orders表开始,利用下单日期(date_placed)索引挑出那一周的2,400个订单,然后利用customers的主键索引连接到customers表,丢弃那些不是来自伦敦的客户。订单随着时间被聚簇得很好,而且,既然最近的订单是最可能被持续处理的,它们很可能被缓存,并且还会继续留在缓存中。这说明即使我们挑选了大量订单来作为开始,我们的效率也很可能非常高。最坏的情况下,我们必须访问2,400个客户行,并且它们很可能是在customers表中随机分布的,所以这可能会有点性能(I/O)问题。但是,比起订单表这样的事务表来,一个像客户表这样的引用表可能会从合理的缓存中获益,因此我们对这样的问题可以忽略。这样我们就得到了图2中所示的草图。
图2: 表被访问的顺序一旦我们决定这是查询的正确路径,我们就可以着手实施,可能是简单方便的重新排列一下表在查询中的顺序并且加上“强制顺序”的提示。(译者注:相当于ORACLE中的ORDERED提示,这仅仅在发现优化器生成的计划不够理想时使用)另一方面,如果这个查询至关重要,而且我们早在系统设计阶段就得以介入,那么我们可能需要考虑一下架构上的特点。选择索引我们可能会在customers表上建一个聚簇索引,但如果它是一个简单的堆表带一个非聚簇的主键索引(id),我们可以考虑把地点(location)列加入到索引中,所以我们不必再访问表来检查地点。这种安排,即一个小小的主键索引带附加列,比起在表的id列上建一个聚簇索引会给我们带来更多的缓存上的好处。(译者注:聚簇索引(clustered index)是SQL SERVER和SYBASE的概念,相当于ORACLE中的索引组织表(index organized table)和索引聚簇表(index clustered table), 带附加列(included column)的主键在oracle中不支持,ORACLE中必须另外建立一个 (id,location) 上的索引)或者,如果我们在orders表的(id_customer, date_placed)上建立一个索引,我们可以考虑从customers表开始我们的查询,因为这个新索引使得我们能够利用customers表中选择出来的客户非常精确地访问orders表:虽然这个索引可能比较大,对于这个查询而言几乎没有缓存上的好处。因此也许(date_placed, id_customer)会更好。理所当然的,这又把我们带回了聚簇这个话题。在orders表上有两个候选的聚簇索引:下单日期(date_placed)和客户id(id_customer)。如果要用的话,哪一个好?或者(悄悄说)我们应该只是建一个简单的堆表?当然,这个基础设计问题应该在系统设计阶段就很明确地提出来,并且取决于我们期望按照什么来访问表:根据客户,根据日期,或者两者的组合(如本例所示)。既然数据是按照日期顺序产生的,它自己很自然地就按日期来聚簇,哪怕我们只是用了一个简单的堆表,所以按下单日期(date_placed)的聚簇索引不会带来明显的好处。另一方面,假如我们在客户id建立一个聚簇索引,数据插入将会变得昂贵得多,并且,除非我们经常查询一个客户的完整历史,当我们运行基于单个客户的查询的时候,一个(id_customer, date_placed)上的非聚簇索引已经能给我们足够好的性能。最后要说的是,在一个生产系统上修改索引肯定会有风险,为了解决SQL的性能问题,我们希望通过对代码的处理,对统计信息的调整,或者使用提示来强制更优的执行计划。画图使得你更好地看清楚和理解可供选择的手段,也使得你在作一些困难决定时更加心里有底。结论为了写出高效的查询,你必须知道你需要获取多少数据,数据又在哪里。你还必须知道你有哪些手段可以获取这些数据,为了访问你不需要的数据必须浪费多少代价,这样你才能决定访问表的顺序。对于复杂的查询,最好的办法是从画图开始,包括所有相关的表,画出表间的连接,指出相关的数据量,描述出能让你从一张表到达另一张表的所有索引。这样的一张图会使你更易于理解你的查询所有可能的访问路径的效率。