《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景...

本节书摘来自异步社区出版社《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》一书中的第1章,第1.1节,作者: 【美】Itzik Ben-Gan,更多章节内容可以访问云栖社区“异步社区”公众号查看。

1.1 窗口函数的背景

T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数
在开始学习具体的窗口函数之前,先了解其背景和内涵,会对后续的学习有所帮助。本节先谈谈窗口函数的背景,解释基于集合方式和基于游标/迭代方式进行查询的不同,以及窗口函数如何对二者的差异进行弥补。最后,本节也提到了窗口函数的替代方法,以及为什么窗口函数会优于其替代方法。注意,尽管窗口函数能非常高效地解决很多问题,但在某些案例中,替代方法会好于窗口函数。第4章会具体谈论对窗口函数的优化,解释在什么情况下,计算可以得到优化,而在什么情况下,不能得到优化。

1.1.1 窗口函数的描述

窗口函数作用于一个数据行集合。窗口是标准SQL术语,用来描述SQL语句内用OVER子句划定的内容,这个内容就是窗口函数的作用域。以下面的查询为例:

参见文档 对示例数据库TSQL2012及相关内容的介绍请参见本书的前言。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第1张图片

下面是该查询的删减版输出:

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第2张图片

在OVER子句中,定义了窗口所覆盖的与当前行相关的数据行集、行的排序及其他相关元素。如果没有对窗口内的数据行进一步限定——如示例中的情形——则窗口内的数据行集就是查询的最终结果。

注意:
更准确地说,根据窗口函数作为输入出现在查询逻辑处理阶段的位置,窗口可以是一个数据行集或一个关系。现在解释这些可能还不够清楚,所以,为了简单化,暂时就指查询的最终结果集,以后会提供更准确的说法。
为达到排名目的,排序是必需的。在本示例中,基于val字段降序排序。

本示例中用到的函数是RANK。此函数对一组特定的数据行集,按照规定的排序,计算当前行的排名。当使用降序(如本例)时,某一数据行的排名是这样计算的:相关数据集中所有排序值大于当前行的记录数加1。在本示例查询中找一条记录做说明,如排名第5的记录。其之所以排名第5,因为基于指定的排序(根据val字段降序排序),在最终结果中有4条记录的val特性值比当前行的val特性值(11188.40)大,所以排名是4加1等于5。

尤其需要注意的是,从概念上来说,OVER子句定义了跟当前行相关的窗口,并贯穿查询结果集的所有记录。换句话说,对于每一行,OVER子句都定义一个针对于它的独立窗口。这个概念含义重大,我们需要一些时间来适应。一旦我们掌握了,就离真正了解窗口化概念、其重要性及深度不远了。如果你暂时还体会不到它有多重要,也没关系,这里只是埋一个伏笔,将来还会反复提到。

标准SQL对窗口函数的第一次支持是在SQL:1999的扩展文档里,当时,它们称为“OLAP”函数。从那以后,每次标准版本的修订都会增强对窗口函数的支持,直到现在的SQL:2003、SQL:2008和SQL:2011。最新的SQL标准版本,已经有了非常丰富和全面的窗口函数,显示出标准委员会对这一概念的坚定,以及从更多窗口函数和更多功能两个方面持续增强支持标准。

注意:
我们可以从ISO或ANSI购买标准文档。例如,从下面的链接,可以购买到ANSI针对SQL:2011标准的基础文档 ,其中涵盖函数的语法构造。http://webstore.ansi.org/RecordDetail.aspx?sku=ISO%2fIEC+9075-2%3a2011
标准SQL支持几种窗口函数类型:集合、排序、分布和偏移。记住,窗口是一个概念;所以在标准SQL将来的修订版本中,我们可能会看到新类型的窗口函数。

聚合窗口函数就是那些我们早已熟悉的聚合函数——如SUM、COUNT、MIN、MAX以及其他函数,我们已经在分组查询的上下文中很习惯地使用它们。一个聚合函数的作用域是一个记录集,这个记录集由一个分组查询或一个窗口描述来定义。SQL Server 2005开始部分支持窗口聚合函数,SQL Server 2012增加了更多的支持。

排名函数有RANK、DENSE_RANK、ROW_NUMBER和NTILE。SQL标准把前两个和后两个函数归于不同类别,稍后会解释为什么如此。为简单起见,这里把这4个函数归为一类,有些官方SQL Server文档也是如此。SQL Server 2005引入了这4个排名函数,并提供了全面的功能支持。

分布函数有PERCENT_RANK、CUME_DIST、PERCENTILE_CONT和PERCENTILE_DISC。这4个函数是从SQL Server 2012开始引入的。

偏移函数是LAG、LEAD、FIRST_VALUE、LAST_VALUE和NTH_VALUE。SQL Server 2012开始支持前4个函数,但尚未支持NTH_VALUE函数。

第2章提供了各个函数的含义、目的及其他详细说明。

任何一个新的想法、设备、工具出现时,即使它们功能更强,操作更简单,也会遇到阻碍。因为新鲜事物往往难被接受。所以如果窗口函数对你而言是新事物,你想找到理由说服自己去学习、去使用,下面是我的经验之谈。

窗口函数有助于完成很多查询工作,我已经一再强调了这一点。时至今日,我在自己编写的大部分查询解决方案中,都使用了窗口函数。在学习了本书的窗口函数概念和优化之后,最后一章(第5章)展示了窗口函数的一些应用案例,其目的是让你有一个概念,了解如何使用窗口函数来完成查询任务。

分页

去重(去除重复数据)

返回每组前n条记录

计算累积合计

对时间间隔进行操作(如包装间隔),统计最大的并发会话数

找出数据差距(gap)和数据岛(island)

计算百分比

计算分布的模式

排序层次结构

数据透视

计算时效性(或计算近因)

我编写SQL查询近20年,最近几年开始广泛使用窗口函数。我可以说,虽然需要花一点时间来熟悉窗口函数概念,但熟悉之后就会发现,在很多场景下,窗口函数比其他方法更简单、直观。

窗口函数有助于代码的优化。在之后的章节中,我们能看到这一点是如何达成的。
声明性语言和优化

我们可能会好奇,在声明性语言(如SQL)中,我们仅仅从逻辑上声明我们的需求,而不是具体描述如何实现它,目的一致、形式不同的两种需求——如一个用窗口函数,另一个不用——为什么会获得不同的性能?为什么SQL的实现(如SQL Server)使用它自己的T-SQL语言,始终不能分辨出两种不同的形式代表了同一个意思,从而为两种形式解析出同一种查询执行计划?

原因有几个。第一,SQL Server的优化器并不完美。我并不想显得不知足,当我们想到这个软件组件能实现的诸多功能时,就会知道SQL Server的优化器完全是个奇迹。但同样的事实是,它并没有把所有可能的优化规则都包含在内。第二,优化器必须在设定的有限时间内给出优化结果;否则,优化所花的时间甚至会长于优化所节省的查询时间。可能有这样的荒谬情形,正常情况优化器花几十毫秒产生执行计划,虽然没有遍历所有可能的执行计划,但查询时间只用了几秒,相比较为了节省查询运行的几秒钟,要花上一年或者几年的工夫来遍历所有的执行计划。我们可以看到,从实用的角度,优化器需要根据某些因素(如查询中表的大小)来计算两个值:一个是对查询来说“足够好”的成本,另一个是花在优化上的最长时间。如果达到任何一个阀值,优化器就停止,SQL Server选中已找到执行计划中最佳的那个。

窗口函数的设计稍后会谈到,与其他达到同样目的的方式相比,通常会有更好的优化效果。
从上面的解释中,我们得到的重要信息是:要有意识地付出努力,尽可能地转向使用窗口函数,它是一个新鲜事物,我们需要时间去熟悉它。但一旦我们成功转向了,SQL窗口函数其实很简单和直观易用。想想生活中那些离不开的小工具,当初我们刚开始使用时,也觉得艰难。

1.1.2 基于集合与基于迭代/游标的编程

人们常说的T-SQL查询任务的解决方案要么基于集合,要么基于迭代/游标。T-SQL开发人员普遍认为应努力坚持前者,但后者也仍然广泛使用。这里有几个有趣的问题。为什么基于集合的方法是首选?如果它是首选,为什么这么多开发人员仍然使用迭代方法?有什么障碍阻止人们采用首选的方法?

要刨根究底的话,我们需要了解T-SQL的理论基础,以及基于集合的方式的真正含义。这样,我们才会认识到,对大多数人来说,基于集合的方式是不直观的,而基于迭代的方式是直观的。这仅仅是因为我们大脑的思维方式,我要简单解释一下这个观点。基于迭代和基于集合的思维差异颇大,虽然差异可以缩小,但并不容易。这时窗口函数可以发挥重要作用。我发现窗口函数是一个很好的工具,有助于缩小两种思维的差距,建立一个逐步过渡到基于集合的思维模式。

因此,我首先解释基于集合的方式如何完成T-SQL查询任务。T-SQL是标准SQL(既是ISO也是ANSI标准)的变种,SQL是基于(或试图基于)关系型模型的,这是最初由E. F. Codd在20世纪60年代末为数据管理提出和制定的数学模型。关系模型基于两个数学基础:集合理论和预测逻辑。计算的许多情形是基于直觉的,而这些直觉变化得如此之快——某种程度上,我们像是在转圈追赶自己的尾巴。关系模型在计算领域是一个特例,因为它扎根于强大的基础——数学理论,有些人甚至认为数学是最终真理。基于如此强大的数学基础,关系模型完美而且稳定,虽然它也在进化,但不像其他计算方式那样变化多端。经过几十年到现在,关系模型保持了它的优势,也仍然是领先的数据库平台的基础——我们称为关系数据库管理系统(RDBMS)。

SQL是基于关系模型产生的,但它并不完美,实际上在某些方面,它甚至背离了关系模型,但同时它提供了多种工具,使得我们一旦理解了关系模型,我们就可以使用SQL来处理关系模型数据。它当之无愧是当前RDBMS的主流操作语言。

然而,如前所述,对许多人来说,关系思维方式并不直观,使得人们难以用关系术语进行思考,这就是基于迭代方式和基于集合方式的关键区别。尤其对那些曾经使用过程编程语言的程序员来说,与文件中数据的交互方式使用的是迭代方式,如下面的伪代码所演示的:

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第3张图片

数据在文件中是以特定的顺序存放的(或更准确地说,以索引顺序访问方法,即ISAM文件)。我们从文件中获取记录,也是依照这个顺序。同时,我们每次都是以记录为单位获取数据。所以,在我们的思维定式中数据是这样的:顺序存放,每次处理一条记录。这与在T-SQL中操作游标很类似;因此,对那些曾经使用过程编程语言的程序员来说,使用游标或其他直观的处理形式,仿佛就是他们已有技能的延伸。

而一个关系型、基于集合的数据处理方式则完全不同。为了了解它们的不同,让我们首先看看集合概念的提出者——Georg Cantor是如何对集合进行定义的:

“集合”指的是由满足定义的m个对象(称为元素)构成的一个组合M。

──Joseph W. Dauben, Georg Cantor (普林斯顿大学出版社 1990)
这个集合定义的内涵很丰富,如果展开解释会占用不少篇幅。出于讨论的目的,我只侧重两个要点——明显的要点和隐含的要点。

整体 请注意整体这个词。一个集合应该当做一个整体来被感知和操作。我们的关注点应该放在集合这个整体上,而不是其中的各个元素。而迭代操作则正好相反,因为文件中的记录或游标都是逐个处理的。一个表在SQL中代表(尽管不是完全成功)关系模型中的一种关系,一种关系是一组相似的元素(也就是说,有相同的特性)。当用基于集合的查询与数据库中的表进行交互时,我们是与整个表进行交互,而不是与表中的单个记录(关系的元组)进行交互——无论是SQL请求中的语句写法,还是目的及思维模式。这种典型的思维模式很多人觉得难以真正接纳。
顺序 我们注意到,在集合的定义中没有任何地方提到元素的顺序问题。其原因在于集合中没有对元素顺序进行规定。这是另一个让许多人感到难以接受的地方。文件和游标中的记录都有特定的顺序,当每次提取一条记录时,我们知道它们的提取就是按照这个顺序。表中的行是没有顺序的,因为一张表就是一个集合。有些人没有认识到这一点,所以他们经常混淆数据模型的逻辑表达和物理层面的实现语言。他们认为,如果表中有特定的索引,那就意味着,当对表进行查询时,表总是按照索引的顺序访问的。有时他们甚至将解决方案建立在这个假设的正确性上。然而,SQL Server没有这方面的任何保证。例如,唯一确保记录按特定顺序展示的方法是在查询中使用ORDER BY子句。如果我们增加这个子句,就应该知道,我们得到的结果不是关系型的,因为结果有确定的顺序。
如果我们需要编写SQL查询,并且想要了解我们使用的语言,我们的思维就要基于集合的概念进行。这也是为什么窗口函数可以在基于迭代的思维(每次一条,以确定顺序)和基于集合的思维(集合是一个整体,没有顺序)之间架起桥梁,帮助我们从一种思维模式转换到另一种思维模式,这是窗口函数的的独创构思。

有一点,必要时窗口函数也支持用ORDER BY子句限定的顺序。但请注意,函数支持顺序限定,并不说明它违反了关系型概念。查询的输入是关系型的,没有对于顺序的期待,查询的输出也是关系型的,并不保证顺序。排序仅仅作为查询中计算描述的一部分,在结果中作为结果的一个特性产生。窗口函数并不保证能返回同样顺序的结果行,实际上,同一个查询中不同的窗口函数可以指定不同的排序。至少在概念上,这种排序与查询结果的展示顺序无关。图1-1演示的是:即使窗口函数包含排序的限定,其查询的输入和输出都是关系型的。利用图1-1中的椭圆框,以及数据行在输入和输出中的不同位置,我尽力表达出行的排列顺序无关紧要这个观点。

窗口函数还有一个方面可以将我们的思维逐渐从基于迭代、排序过渡到基于集合上来。当老师开始教一个新的主题时,有时他们不得不在解释主题的时候“撒谎”。假定我们是老师,我们知道如果一下子讲得很深入,学生的思维还没法接受。这时如果我们先讲得简单些,学生的接受效果会更好,尽管简单的说法不完全正确,但它推动学生开始思考。当学生的思维准备好接受“真理”时,我们可以再把话题谈得更深入、更准确。

我们可以用上面的方式来理解窗口函数的计算概念。先用一种简单的方法进行说明,虽然从概念上说它不完全正确,但它可以把我们引向正确!简单的方法是:每次一行,以排序的方式。随后,我会用更深入、更正确的方式进行解释,但大家的头脑必须要进入一个成熟的状态,才能够真正理解。深入的方法就是基于集合的方法。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第4张图片

为了阐述上述观点,请看下面的查询:

image

下面是查询结果的部分输出(请注意,这里不保证输出结果的顺序):

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第5张图片

从概念上说,排名值计算方法的基本思路见下面的示例(如伪代码所示):

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第6张图片

图1-2用图形化的方法描述了这种类型的思路。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第7张图片

再提醒一次,虽然这样的思考方式得出了正确的结果,但它本身不是完全正确的。事实上,它使我的观点变得更难于理解了,因为刚刚描述的过程与SQL Server如何从物理上处理排名计算非常类似,但目前我的重心不在于物理实现,而是侧重在概念层面——语言和逻辑模型。我所说的“不正确的思维方式”指的是概念方面,从语言的角度,计算的思维方式是不同的,应以基于集合的方式思考,而不是基于迭代方式。请记住,语言与数据库引擎的物理实现无关。物理层的责任是以尽可能快的方法,正确地实现逻辑层的需求。

现在,我来解释前面所说的更深入、更正确地对窗口函数进行语言认知。函数从逻辑上定义(对于查询结果集中的每一行)一个单独的、独立的窗口。如果在窗口描述中未做限制,则每个窗口都包含查询结果集合中从头开始的所有行。但可以在窗口描述中增加一些元素(如分区、框架等,后面会讲得更细)来进一步限定每个窗口中的行集。图1-3图形化地描述如何将RANK函数应用于查询。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第8张图片

从概念上来说,针对每个窗口函数和查询结果集中的每一行,OVER子句都创建一个单独的窗口。在该查询中,我们没有用任何方式限定窗口描述,而仅仅是定义计算的排序方式。因此,在该示例中,所有窗口都由结果集中的所有行组成,它们同时共存。对于每行,排名是这样计算的:数据集中所有特性val值大于当前值的记录数加1。

我们可能会意识到,对许多人来说,把它理解成数据按一定的顺序,以迭代的方式,每次处理一条,会更为直观。那也没关系,因为我们刚开始接触窗口函数,需要正确地写出查询——至少是简单的查询。随着时间的流逝,我们可以逐渐过渡对窗口函数的设计概念有更深的理解,并开始用基于集合的方式思考。

1.1.3 窗口函数替代方案的不足之处

与能获得同样结果的传统计算方式(如分组查询、子查询等)相比,窗口函数有许多优势。这里举几个直观的例子。除了优势之外,还有其他几个重要的差别,在这里进行罗列,但现在对这些进行讨论还为时尚早。

首先从传统的分组查询开始。分组查询以聚合的形式,提供一些新的信息,代价是我们也有所失——详细信息。

当对数据进行分组时,不得不把计算应用到组中的所有内容上。但如果我们想在计算中涉及聚合信息和详细信息,该么办?例如,如果我们想查询Sales.OrderValues视图,计算每笔订单占当前客户订单总金额的百分比,以及与客户订单平均金额的差异。当前订单金额是详细信息,客户订单总金额和平均金额是聚合值。如果按照客户来对数据进行分组,就获取不到每笔订单的金额。传统的分组查询对这种需求的处理方式是:建立一个查询,按客户对数据进行分组,根据这个查询定义一个表表达式,然后把这个表表达式与基表相联合,以便对详细信息和聚合信息进行匹配。下面是实现该方法的示例。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第9张图片

以下是查询结果的部分输出。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第10张图片

现在想象一下,如果还需要计算占总合计的百分比以及与总平均金额的差异该怎么办?要得到这两项数据,还需要增加一个表表达式,如下所示。

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第11张图片

下面是查询结果的输出。
《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第12张图片

我们可以看到,查询变得原来越复杂,引入了更多的表表达式和联接。

另一种类似的计算方法是:对于每个计算使用独立的子查询。下面是替代方案,将最后两个分组查询改为子查询:

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第13张图片

子查询方式有两大问题。第一,代码太长,过于复杂。第二,SQL Server的优化器目前还未编码,以对这样的情况进行识别:多个子查询需要获取同样的数据行集,因此,对于每个子查询,它需要单独地对数据进行访问。这意味着,子查询越多,就需要越多的数据访问。与第一个问题不同的是,第二个问题与语言无关,而与SQL Server对子查询的优化有关。

请记住,窗口函数背后的理念是要定义一个函数的操作窗口或行集。聚合函数也应该作用于行集,因此窗口概念可以与使用分组查询或子查询的替代方案配合工作。同时当计算聚合窗口函数时,不会丢失详细信息。使用OVER子句来定义该函数的窗口。例如,为计算查询结果集中的合计值,可以简单用下面的子句:

image

如果不限定窗口(即OVER后的圆括号为空),合计的起点从查询结果集开始。

为了在查询结果集中根据当前行的客户代码计算总合计金额,可以借助窗口函数的分区能力(以后再详细解释),根据custid进行分区,如下所示:

image

请注意,分区这个词的含义是过滤,而不是分组。

下面利用窗口函数满足详细信息和客户聚合信息的请求,返回当前订单占此客户订单总金额的百分比,以及与客户订单平均金额的差异(窗口函数部分用粗体表示):
image

下面的另一个查询,增加了每笔订单相对于总合计金额的百分比及与总平均金额的差异:
image

可以看到,使用了窗口函数的代码是多么简单和清晰。同样,从优化的角度来看,SQL Server的优化器已为不同窗口函数使用相同的窗口描述做了优化设定。如果发现不同的函数使用相同的窗口,SQL Server对窗口内的数据只访问一次(不管采用何种数据扫描方式)。例如,在上面的查询中,SQL Server在用前两个函数(根据custid分区取合计和平均值)进行计算时,只对数据进行一次访问,同样,当计算后两个函数(不再分区的总合计和平均值)值时,再进行一次数据访问。第4章会对此优化概念进行阐述。

窗口函数优于子查选的另一点是添加限制前的初始窗口是查询的结果集,即应用了表操作符(如,联接)、筛选、分组等之后的结果集。得到如此结果的原因是窗口函数在查询的逻辑处理阶段就得到了评估考虑(本章稍后会再详述)。相反,一个子查询不得不从头开始——而不是从外查询的结果集开始,即如果我们希望子查询的作用域与其外查询作用域相同,则它必须重复设定外部查询所用的限定。举个例子,如果我们希望查看2007年的单笔订单金额占总订单金额的百分比以及与平均订单金额的差异,若使用窗口函数,则只须在查询中增加一个筛选器,如下:

《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第14张图片

窗口函数的起始点是应用了过滤项之后的结果集。但如果使用子查询,就必须从头开始了,因此不得不在所有的子查询中重复添加筛选器,如下。
《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景..._第15张图片

当然,我们也可使用变通的方法,如首先定义一个公共表表达式(Common Table Expression,CTE),此表达式基于应用了筛选器的查询,然后,外部查询和子查询都指向这个CTE。当然,如果有了窗口函数,就无须采用这种变通的方法了,因为窗口函数是作用在查询结果之上的。1.4节会提供关于窗口函数设计在这方面的更多细节。

正如之前所提到的,窗口函数也有助于良好的优化,经常的情况是,窗口函数的替代方法无法得到如此的优化,我们至少可以这样说。当然,在某些场景下,情况也可能倒过来。第4章将介绍窗口函数的优化,第5章会提供足够的示例来介绍如何充分使用它们。

本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

你可能感兴趣的:(《T-SQL性能调优秘笈——基于SQL Server 2012 窗口函数》——1.1 窗口函数的背景...)