转自:http://database.51cto.com/art/200512/12908.htm
本文介绍了在客户机上处理 Microsoft SQL Server 查询的方式,各种客户机与 SQL Server 的交互方式,以及 SQL Server 在处理客户机程序的请求时需要完成的工作。
AD:
简介
Microsoft(R) SQL Server(TM) 内部机制和结构是一个非常大的主题,因此本文仅限于程序开发人员感兴趣的问题,集中研究其他源中没有彻底讨论的问题。在讨论 SQL Server 的结构时,我们主要观察客户机的处理过程,研究不同的客户机程序与 SQL Server 的交互方式,以及 SQL Server 如何处理客户机的请求。还有一些讨论 SQL Server 其他方面的信息源,特别是 Microsoft Press 出版的 Inside SQL Server 7.0,作者是 Ron Soukup 和 Kalen Delaney,这本书非常详细地讨论了 SQL Server 存储引擎的内部机制和处理方法,不过对查询处理器的讨论不够深入。本文正填补了这个空白。
我们期望本文有助于读者编写出更好的应用程序。通过本文,读者会在提高程序性能方面得到新的启发,产生新的理解。
SQL Server 是一种客户机/服务器系统
多年来,SQL Server 一直被认为是一种客户机/服务器系统。事实上,Sybase DataServer(以此为基础开发了原始的 SQL Server)正是第一个作为客户机/服务器系统开发的商用关系数据库系统。那这又说明了什么呢?这不只意味着 SQL Server 是一个双层系统。从传统上看,双层系统意味着客户机应用程序运行在一台机器上,向另一台计算机上的服务器发送请求。而对于 SQL Server,客户机/服务器意味着 SQL Server 的组成部分,即客户机 API 部分,驻留在处理结构中的远端,与服务器组件本身是分开的。
在典型的双层模型中,客户机程序部分驻留在台式机上,具有大量客户机应用程序逻辑和业务逻辑,并且会直接向数据库系统发出请求。然后,客户机得到服务器响应这些请求所返回的数据。
三层系统也采用了同样的模型。多年以来,SQL Server 一直用在事务处理监视系统中,例如 BEA 的 Tuxedo 以及 Compaq 的 ACMSxp,这些系统早在二、三十年前就采用了典型的三层模型。三层模型在今天基于 Web 的应用系统中占据了支配地位,这类系统以 Microsoft 的 MTS 以及新的 COM+ 1.0 为代表。从 SQL Server 的角度看,三层解决方案中的客户机程序是放在中间层的。中间层直接与数据库交互。实际的桌面,或瘦客户机(Thin Client),使用其他机制并通常直接与中间层交互,而不是直接与数据库系统交互。图 1 描述了这种结构。
图 1. 三层系统模型
客户机结构
从结构的角度看,SQL Server 关系服务器组件本身并不真正关心客户机程序运行的位置。事实上,就 SQL Server 而言,即使在运行 SQL Server 的同一台机器上运行应用程序,仍然还是客户机/服务器模型。服务器运行一个单独的多线程进程,为来自客户机的请求提供服务,不管客户机的位置在哪里。客户机程序代码本身是单独的运行在客户机应用程序内部的 DLL,与 SQL Server 的实际接口是在客户机和服务器之间对话的“表格数据流”(Tabular Data Stream, TDS) 协议。
一个常见的问题是“什么是 SQL Server 的本机接口呢?”很长时间以来,很多开发人员一直都不愿意使用 ODBC 这样的接口,因为他们认为由 Sybase 开发的客户机 API,也就是 DB-Library,是 SQL Server 的本机接口。实际上,SQL Server 关系服务器本身并没有本机 API,它的接口就是在客户机和服务器之间的通信流协议 TDS。TDS 把客户机发送给服务器的 SQL 语句封装起来,也把服务器返回给客户机的处理结果封装起来。任何直接处理 TDS 的 API 都是 SQL Server 的本机接口。
让我们来看一下客户机的组件,如图 2 所示。客户机结构中的某些部分就不在这里讨论了,因为它们不属于 SQL Server 的范畴。但如果您在编写应用程序的话,就必须了解这些部分。大家知道得最多的应该是各种对象模型,如果您正在编写 ASP 或 Microsoft Visual Basic(R) 应用程序,就需要通过 ADO 与数据库系统交互,而不是直接调用底层的 API,例如 ODBC 或 OLE-DB。ADO 映射到 OLE-DB,而 RDO 映射到 ODBC。因此,作为这种最常用的编程模型的对象模型,并不是 SQL Server 客户机结构中的严格意义上的组件。此外,还有另外一些组件可以插接到 SQL Server 基础结构上面的这一层。OLE-DB 的“会话池服务提供程序 (Session Pooling Service Provider)”就是这种组件的一个例子。
图 2. 客户机结构
客户机接口
SQL Server 有两个接口可以认为是 SQL Server 7.0 的本机接口,即 OLE-DB 和 ODBC。DB-Library 接口也是本机的,它与 TDS 通信,但是 DB-Library 使用的是 TDS 较老的版本,需要在服务器上进行一些转换。现有的 DB-Library 应用程序仍然可以继续与 SQL Server 7.0 协同使用,但是很多新的功能和性能提高等好处只能通过 ODBC 和 OLE DB 才能利用。更新 DB-Library 使其支持 SQL Server 7.0 的新能力,将会导致与现有应用程序的很多不兼容性,因此需要修改应用程序。ODBC 在五年之前就替代了 DB-Library,是新的 SQL Server 应用程序更理想的 API,因此引入不兼容的 DB-Library 新版本并不明智。
从图 2 可以看到,所有这些客户机 API 都有三个部分。最上面的部分实现 API 的细节,例如行集和游标应该是什么样等等。TDS 格式化程序负责处理实际请求,例如 SQL 语句,并将其封装成 TDS 消息包,发送给 SQL Server,获得返回的结果,然后再把结果反馈到接口实现。
还有一些供所有提供程序使用的公共库代码。例如,BCP 设备就是 ODBC 和 OLE-DB 都可以调用的库。DTC 也是这样。第三个例子是 ODBC 规范的 SQL 语法,即带有参数标记的 CALL 语法,这些对于所有提供程序都是通用的。
除了我们在前面已经提到的局限性,即 DB-Library 仍然只能使用 SQL Server 6.5 版,TDS 协议对于所有 API 都是相同的。ODBC 和 OLE-DB 在与 SQL Server 7.0 通信时使用 SQL Server 7.0 版,但也能够与 6.5 或 6.0 服务器通信。另一个是 Net-Library,这是一个抽象层,客户机和服务器都在此层上同网络抽象接口通信,不必为 IPX 还是 TCP/IP 困扰。在这里我们将不讨论 Net-Library 的工作细节;只要知道它们的工作基本上是将来自的网络通信底层的细节隐藏起来不让软件的其他部分看到就可以了。
从客户机的角度看服务器
前面已经提到过,客户机与 SQL Server 通信的主要方法就是通过使用 TDS 消息。TDS 是一种简单协议。当 SQL Server 接收到一条消息时,可以认为是发生了一个事件。首先,客户机在一个连接上发送登录消息(或事件),并得到返回的成功或失败的响应。当您希望发送 SQL 语句时,客户机可以把 SQL 语言消息打包发送给 SQL Server。另外,当您希望调用存储过程、系统过程或虚拟系统存储过程(我们后面还要详细讨论)时,客户机可以发送 RPC 消息,这种消息相当于 SQL Server 上的一个 RPC 事件。对于上面的后两种情况,服务器会以数据令牌流的形式送回结果。Microsoft 没有把实际的 TDS 消息写入文档中,因为这被认为是 SQL Server 组件之间的私用契约。
目录存储过程是另一类关键的客户机/服务器的交互部分。这些存储过程首先在 ODBC 的 SQL Server 6.0 中出现, 包括诸如 sp_tables 和 sp_columns 等存储过程。ODBC 和 OLE-DB API 定义了描述有关数据库对象的元数据的标准方法,这些标准需要适用于所有类型的 RDBMS 服务器,而不必调整为 SQL Server 自己的系统表。不是客户机向服务器发送对系统表的多个查询,并在客户机端建立标准的元数据视图,而是创建一组存储在服务器上的系统存储过程,并对 API 返回适当格式的信息。这种方法使得通过一次通信就可以完成很多重要的元数据请求。
为 ODBC 编写的过程已经写入文档,通常适合需要从系统表中获取信息但其他机制没有提供这种方法的情况。这使得 Transact-SQL 过程和 DB-Library 应用程序可以访问元数据,而不需要编写对 SQL Server 系统表的复杂查询,并且使应用程序不受今后 Microsoft 修改系统表的影响。
OLE DB 定义了一组架构行集,它们类似于 ODBC 的元数据,但又和它不同。它创建了一组新的目录存储过程,以更有效地为这些架构行集植入数据。但是,这组新的存储过程没有写入文档,因为这些存储过程重复了早先提供的功能。通过现有的若干种方法都可以得到元数据,因此 SQL Server 开发组决定不显露这些并没有为编程模型增加新内容的对象。
客户机与服务器的交互还有第三个方面。它最初出现在 SQL Server 6.0 中,但是没有得到普遍使用。这就是虚拟系统存储过程的概念;在 SQL Server 7.0 中起很重要的作用。当第一次为 SQL Server 6.0 开发服务器端游标时,开发人员就需要选择采取什么方法管理客户机/服务器的交互。游标并不特别适合现有的 TDS 消息,因为这些消息允许逐行返回数据,不需要客户机指定额外的 SQL 语句。开发人员本来可以向 TDS 协议添加更多的消息,但是需要修改太多的其他组件。SQL Server 6.0 中的 TDS 版本还需要向 Sybase 版本靠拢,以便确保两者的可互操作性,于是开发人员选择了另外的处理机制。他们开发了外表看起来像是系统存储过程的新功能(服务器端游标),实际上是指向 SQL Server 代码的入口存储过程。它们被客户机应用程序使用标准的 RPC TDS 消息来调用。它们被称为虚拟系统存储过程,因为在客户机上,它们像其他存储过程那样被调用,和其他存储过程不同的是,它们并不是由简单的 SQL 语句组成。大多数虚拟系统存储过程都是私用的,并且没有写入文档。对于游标过程,所有 API 都显露其自有的一组游标 API 模型和它们自己的游标操作函数,因此没有必要为存储过程本身编写文档。即使是在 Transact-SQL 语言中,也有显露游标的语法,可以使用 DECLARE、OPEN、FETCH 等,所以完全没有必要为虚拟系统存储过程编写文档,例如 sp_cursor,因为这些过程只在内部使用。
ODBC 和 OLE DB 中出现了带参数的查询和准备/执行模型的概念。在 SQL Server 7.0 以前的版本中,这些概念是由客户机 API 中的代码实现的。在 SQL Server 7.0 中,Microsoft 为这些概念添加了对“关系服务器”的支持,并且通过新的虚拟系统存储过程显露了这种支持。本文后面还要介绍这些功能,以及服务器如何支持这些功能。通过 sp_executesql 过程对带参数的查询的支持,被认为对直接 Transact-SQL 和 DB-Library 的使用特别有用,所以将其写入了文档。准备/执行的过程,被 ODBC 驱动程序和 OLE DB 提供程序专用。
这样,可以与 SQL Server 通信的所有客户机程序,都建立在这三组功能之上:TDS 协议、目录存储过程和虚拟系统存储过程。
服务器结构
SQL Server,或更确切一点地说,是“SQL Server 关系服务器”,经常被说成是由两个主要部分组成,即关系引擎和存储引擎。正如前面提到过的那样,已经有很多文献介绍存储引擎的细节了,所以本文主要介绍关系引擎的功能。图 3 给出了 SQL Server 关系引擎部分的主要组件。所给出的组件可以分为三组子系统。左边的组件编译查询,包括查询优化器。查询优化器是所有关系数据库引擎中的最神秘的部分之一,从性能的角度看也是最重要的部分。查询优化器负责提取 SQL 语句中的非过程请求部分,并将其翻译成一组磁盘 I/O、过滤以及其他能够高效地满足该请求的过程逻辑。图中右侧是执行基础结构。这里实际上只有很少的功能。当编译组件的工作完成之后,所产生的结果只需用很少几个服务即可直接执行。
图 3. 服务器结构
图的中间是称为 SQL Manager 的部分。SQL Manager 控制着 SQL Server 内部的所有数据的流动。SQL Manager 控制着 RPC 消息,在 SQL Server 7.0 中,绝大多数来自客户机的功能调用都是通过 RPC 消息进行的。上一节中介绍的虚拟系统存储过程逻辑上也是 SQL Manager 的一部分。通常,作为 TDS SQL 语言消息的 SQL 语句直接在编译一端执行,与早期版本相比,SQL Server 7.0 较少使用这种方法,但还算是比较常见的。执行结果由称为 ODS 的执行引擎中的组件格式化为 TDS 执行结果消息。
绝大多数输出都来自图中的执行端,而且输出结果也真正出自表达式服务。“表达式服务”库是进行数据转换、谓词评估(过滤)以及算法计算的组件。它还利用了 ODS 层,把输出结果格式化为 TDS 消息。
还有几个组件,我们只是在这里简单地提一下,这些组件在关系引擎内部提供附加服务。这些组件中的一个是目录服务组件,用于数据定义语句,例如 CREATE TABLE、CREATE VIEW 等。目录服务组件主要放在关系引擎中,但是实际上大约有三分之一的目录服务组件是在存储引擎中运行的,所以可以看作是共享组件。
关系引擎中的另一种组件是“用户模式调度程序 (UMS)”,这是 SQL Server 自己内部的纤程和线程规划器。把任务分配给纤程或线程是一种非常复杂的内部机制,取决于对服务器如何配置,以及在 SMP 系统中允许 SQL Server 进行处理器之间的适当的负载平衡。UMS 还可以避免 SQL Server 由于同时运行太多的线程而导致性能过低。最后,还有大家熟悉的系统过程,逻辑上它们也属于关系引擎的一部分。这些组件肯定不是服务器代码,因为可以很容易地使用 sp_helptext 检查定义这些过程的 Transact-SQL 代码。但是,系统过程被作为服务器的一部分来对待,因为系统过程的用途是显露重要的服务器能力,像系统表一样,以供应用程序在更高的层次上和更适当的层次上使用。如果应用程序开发人员将较高层次的系统过程 — 更容易使用 — 作为一种接口,即使随着版本的更新,原始层次上的系统表发生变化时,应用程序仍然可以继续使用。
处理 SQL 语句时的客户机/服务器交互
下面我们将讨论当客户机应用程序与 SQL Server 交互时客户机的动作。以下是一个 ODBC 调用的例子:
SQLExecDirect(hstmt, "SELECT * FROM parts where partid = 7", SQL_NTS)
(OLE-DB 也有一个与这个调用几乎直接等价的调用,此处不再讨论这个调用,因为这个调用实际上与 ODBC 调用相同。)该 ODBC 调用取一个 SQL 语句,然后将其发送给 SQL Server 来执行。
在这个具体的查询语句中,我们从零件表中提取具有特定零件标识号的所有行。这是特定 SQL 的一个典型例子。在 SQL Server 7.0 以前的版本中,特定的 SQL 与存储过程的一个显著差别是,查询优化器所生成的计划从不缓存。查询语句要被读入、编译、执行,然后再抛弃计划。在 SQL Server 7.0 中,正如稍后还要讨论的,实际上提供了可以缓存特定查询语句的计划的机制。
在这条语句被送往 SQL Server 之前,还必须要问几个问题。所有客户机程序都要提供某种游标说明,所以客户机程序在内部必须询问的一个问题是,程序员请求的是什么样的结果集或什么样的游标。最快的类型是在文档中被称为默认结果集的游标。这种游标由于历史上的原因被称为消防站游标,有时甚至根本不把它作为游标看待。当 SQL 请求被送到服务器之后,服务器开始把结果返回给客户机,这个返回结果的过程持续进行,直到把全部数据集发送完毕为止。这就像一个将数据抽给客户机的大型消防站。
一旦客户机程序确定了这是默认结果集,则下一步就是确定是否有参数标记。使用这个 ODBC SQLExecDirect(以及 OLE-DB 中等价的调用)调用的选项之一是,不是在 WHERE 从句中给出像 7 这样的具体值,而是可以用一个问号来传递参数标记,如下所示:
SQLExecDirect(hstmt, "SELECT * FROM parts where partid =?", SQL_NTS)
请注意,您必须分别提供实际的参数值。
客户机需要知道 SQL 语句中是否有参数标记,或者它是否为真正特定的非参数化 SQL。这将影响到客户机将用这个语句在内部做什么,并确定将什么作为消息真正发送给 SQL Server。在没有问号时,很明显,客户机只想将这个请求作为 SQL Language TDS 消息发送,然后客户机将位于流水的末端,并将结果返回。然后客户机能将结果返回给基于应用程序参数的应用程序。客户机的内部处理选择会模糊一点,这取决于您通过 ODBC 或 OLE DB API 请求什么。例如,应用程序不直接请求默认结果集。相反,在 ODBC 中,如果请求一个只读的、只向前的且每次只给出一行的游标,那么对于客户机内部运行来说,这就是在定义流水游标(默认结果集)。
流水游标有一个主要问题。除非客户机已将所有的行全部接收完毕,客户机不能将任何其他 SQL 语句向下发送给服务器。因为结果集可能有很多行,所以有些应用程序使用流水游标时不能顺利运行。后面将要描述的只向前的快速游标,是 SQL Server 7.0 版的一个新特点,尤其适合于处理这种情况。
在 SQL Server 7.0 版之前,SQLExecDirect 调用在很大程度上是以相同方式处理的,而不管是否用参数标记来代替常数。如果您定义一个参数标记,客户机将实际取您通过不同调用提供的值(本节的开始示例中的值“7”),并将它插入问号处。然后,使用代替值的新语句被向下发送,作为一个特定的 SQL 语句。在服务器上使用参数化的 SQL 没有任何好处。
然而,在 SQL Server 7.0 版中,如果 SQLExecDirect 使用了参数标记,向下发送给 SQL Server 的 TDS 消息便不是 SQL 语言消息。相反,它被下发给使用 sp_executesql 过程的服务器,所以,就 TDS 协议来说,它是 RPC。在客户机上,结果基本上相同。客户机将取回数据流水。
如果您不想取回这个数据流水,则可以始终使用块游标或可滚动游标。在这种情况下,数据流变得大不相同。调用是对通过 SQL 文本中的 sp_cursoropen 输入点(这些虚拟存储过程之一)进行的。该 sp_cursoropen 利用 SQL 来增加附加逻辑,以使其滚动,它潜在地将某些结果重定向到一个临时表,然后用句柄给游标一个响应,表明游标现在是打开的。仍然在程序员的控制之外,客户机调用 sp_cursorfetch,将一行或多行转到客户机上,然后返回到用户应用程序。客户机还可使用 sp_cursor 来重新配置游标,或改变某些统计数字。在您处理完游标之后,客户机将调用 sp_cursorclose。
让我们看一个简单的情况,即只返回一行给客户机。至于默认的结果集,需要从客户机到服务器往返发送一次消息。SQL 消息(或 sp_executesql)向下发往服务器,然后结果返回来。在同一行(非流水)的游标情况下,您会看到传统情况下能用 SQL Server 看见的东西。一个往返行程用于打开,一个往返行程用于取得数据,一个往返行程用于关闭。这个过程使用消息的次数是默认结果集使用的三倍。在 SQL Server 7.0 中,有一种所谓只向前的快速游标, 它使用同样的游标结构。它与流水的表现不一样,因为在发送任何附加 SQL 消息之前,它不需要您处理全部结果行。所以,如果您带回 5 行,还有更多的数据,您仍能将更新向下发送给服务器。
一个只向前的快速游标在服务器上比常规游标更快,它让您指定两个附加选项。一个称为自动取数,另一个称为自动关闭。自动取数将返回第一个行集合,作为打开的响应消息的一部分。自动关闭在读完最后一行后自动关闭游标。因为它是只向前的和只读的,所以不能回滚。SQL Server 只传回一个带有说明游标已关闭的最后数据集的消息。如果您正在使用只向前的快速游标,则在行数少的消息里,您可向下与同一往返行程通信。如果您有很多行,则您至少还要对每一行块支付附加开销。如果您使用只向前的快速游标,那么游标处理会更加接近默认的结果集。
SQLExecDirect 模型流程如图 4 所示。
图 4. 客户机/服务器交互
准备/执行模型
除了执行直接模型(在 ODBC 中用 SQLExecDirect 调用)外,在 ODBC 和 OLE-DB 中,还有一种执行模型,称为准备/执行模型。定义要执行的 SQL,是作为一个独立于实际执行 SQL 的步骤来完成的。以下是 ODBC 中的一个例子:
SQLPrepare(hstmt, "SELECT * FROM parts where partid = ?", SQL_NTS)
SQLExecute(hstmt)
在 SQL Server 7.0 版本之前,准备/执行从来都不是 SQL Server 的本机模式。如今在 7.0 版本中,有两个提供本机接口的虚拟系统存储过程。对于准备调用,我们要再次研究游标的类型,然后调用 sp_prepare 或 sp_cursorprepare。这些过程会完成 SQL 或存储过程的编译,但不会实际执行计划。相反,虚拟系统存储过程只是返回该计划的句柄。现在,应用程序可以反复地执行 SQL 了,例如传入不同的参数值,而不需要重新编译。
在 SQL Server 6.5 中,由于没有本机接口,需要模拟准备和执行两个阶段。可以通过下面的两种方法做到这一点。在第一种方法中,不会真正出现准备阶段。只有执行部分返回元数据(有一些选项可以做到这一点),所以 SQL Server 可以把结果的格式描述返回给应用程序。在第二种方法中,SQL Server 实际上创建一个特定存储过程,这个过程是单个用户私用的,不能共享计划。这第二种方法可能会占满 tempdb 数据库的空间,因此大多数应用程序开发人员都通过 ODBC 配置对话框中的复选框,关闭此选项,以使用第二种方法。
在 SQL Server 7.0 中,准备/执行方法是 SQL Server 的本机功能。准备好 SQL 语句之后,才会执行它。至于默认的结果集,应用程序只需要调用 sp_execute,提供准备操作生成的句柄,语句就会被执行。对于游标,与其他游标处理过程看起来很相似,事实上,它也具有相同的特性,包括如果游标是快速只前向型,还可以使用 autofetch 和 toclose。
准备/执行操作的流程如图 5 所示。
图 5. 准备/执行模型
调用存储过程
存储过程一般是从 ODBC 和 OLE-DB,通过发送 SQL 语句给使用 ODBC 标准 CALL 语法调用过程的 SQL Server 来调用。其应类似于以下语句:
SQLExecDirect(hstm, "{call addorder(?)}", SQL_NTS)
对于默认结果集,这是一个简单的流,因为这正是 RPC 消息原本要处理的对象。客户机向服务器发送 RPC 消息,并获取来自存储过程的处理结果。如果是游标,则情况稍微复杂一些,客户机需要调用 Sp_cursoropen,就像其他游标一样。Sp_cursoropen 含有内部逻辑,检测该存储过程是否只包含一条 SELECT 语句。如果是,则对该 SELECT 语句打开一个游标。如果该存储过程中不是一条 SELECT 语句,则客户机会得到一个指示,说明"我们为您打开结果集,但是我们将以流水的方式返回数据流,您可以把这个数据流提供给用户"。
存储过程执行流程如图 6 所示。
图 6. 调用存储过程
SQL Manager
前面已经提到过的 SQL Manager 驱动很多服务器处理过程,它实际上是服务器的心脏。SQL Manager 处理所有调用存储过程的请求,管理过程缓存,拥有虚拟系统存储过程,在稍后要介绍的特定查询的自动参数化过程中也要涉及。如果您有与本文类似的描述 SQL 6.5 或更老版本的文章,则不会读到有关 SQL 管理器的讨论,然而,您会读到一些完成 SQL 管理器工作的一些不同的组件。但是在 SQL Server 7.0 中,这些组件被统一为 SQL 管理器,通过系统驱动查询语句的处理。
在一般情况下,当要求 SQL 管理器为您做某些工作时,通过 RPC 消息调用 SQL 管理器。但是,当通过 SQL 消息发送 SQL 语句并进入引擎编译时,也会用到 SQL 管理器。当存储过程或批处理程序包含 EXEC 语句时,也会调用 SQL 管理器,因为 EXEC 实际上就是调用 SQL 管理器。如果该 SQL 语句传送了下面就要讨论的一个自动参数化模板,则需要调用 SQL 管理器对该查询进行参数化处理。当特定查询语句需要装入缓存时,也要调用 SQL 管理器。
编译与执行
现在讨论在 SQL Server 中编译和执行的一般流程。需要注意的是编译和执行在 SQL Server 内部是两个不同的阶段。SQL Server 编译查询语句和执行该语句之间的间隔时间可能非常短,只有几个毫秒,也可能是几秒钟、几分钟、几小时甚至几天。在编译过程中(这个过程包括优化),我们必须区分什么样的知识可以用于编译。并不是所有对编译有用的知识对执行也起作用。您必须把编译和执行理解为两个不同的活动,即使您发送并立即执行的是特定 SQL 查询语句。
当 SQL Server 可以开始处理查询语句时,SQL Manager 要在缓存内进行查找,如果没有找到该语句,则必须编译该语句。编译处理要完成以下几件工作。首先,要进行分析和正常化。分析就是剖析该 SQL 语句,将其转换成更适合计算机处理的数据结构。分析还要验证语法的正确性。分析不进行表名和列名合法性等检查,这些工作在正常化阶段完成。正常化主要是解析 SQL 语句中引用的对象,转换成实际的数据库对象,检查请求的语义是否有意义。例如,试图执行一个表,这在语义上就是错误的。
下一步是编译 Transact-SQL 代码。Transact-SQL 和 SQL 本身都让人有点儿困惑,Microsoft 的开发人员也像别人一样经常互换两个词。但是,这两者之间还是有重要差别的。SQL 包括所有 DML 语句:INSERT、UPDATE、DELETE 和 SELECT。SQL Server 还有一种包括这些 DML 语句的语言,称为 Transact-SQL,也就是 TSQL。TSQL 提供过程结构:IF 语句、WHILE 语句、局部变量声明等。服务器内部对 SQL 和 TSQL 的处理方法完全不同。TSQL 的过程逻辑要由知道如何进行过程化处理的引擎来编译。
SQL 语句本身由典型的查询优化器来处理。优化器必须把基于集合的 SQL 语句的非过程化的请求,翻译成可以被高效执行并返回所需结果的过程。除非特别说明,我们在以下讨论编译时,均指 TSQL 的编译和 SQL 语句的优化。
上面已经提到,编译和执行是两个不同的查询处理阶段,因此,优化器完成的工作之一是基于相当稳定的状态进行优化。您可以注意到,SQL Server 可能会根据语句所满足的条件重新编译,所以状态并不是永远稳定的,但也不是处于不停的变化之中。如果优化器使用的信息变化太剧烈、太经常 - 并发处理器的数量和锁的数量不稳定 - 则必须不断重新进行编译,而一般来说编译是比较耗时的。例如,SQL 语句的运行时间为百分之一秒,而编译可能需要占用半秒。最理想的情况是,SQL Server 能够只编译语句一次,而执行成千上万次,不必每次执行该语句时都重新编译它。
编译阶段的最终产品是查询计划,放在过程缓存中。便宜的特定 SQL 计划并不放在缓存中,不过这只是个小问题。我们不希望缓存被不太可能重复执行的内容占满,一般来说,特定 SQL 语句的计划是最不可能反复使用的了。如果语句编译已经很便宜(小于百分之一秒),则没有必要再把计划放入缓存,用不太可能重新使用的计划占用缓存。
把计划放入缓存之后,SQL Manager 按照执行要求逻辑进行检查,确定是否有更改的内容,是否需要重新编译。即使编译到执行之间时间间隔只有几毫秒,也可能有人会执行一条数据定义语句 (DDL),为关键的表加了索引。这种可能性不大,但是确实存在,因此 SQL Server 必须考虑这一点。有几种情况 SQL Server 必须重新编译存储规划。元数据的修改,例如增加或删除索引,是重新编译的最主要的原因。服务器必须确信所使用的计划反映了索引的当前状态。
重新编译的另一种原因是统计情况发生变化。SQL Server 还维护不少数据使用频率的统计信息。如果数据使用频率分布情况变化很大,则可能需要另一个查询计划以便更有效地执行。SQL Server 跟踪表数据插入和删除的统计数据,如果数据修改的数量超过根据表的容量变化的某一阈值,则需要根据新的分布数据重新编译计划。
图 7 给出了编译和执行过程的流程。
图 7. 编译与执行
注意,实际参数的改变并不会导致重新编译,环境的改变,例如可用内存的增加或所需数据的增加,也不会导致重新编译。
执行是比较简单的,如果需要执行的查询很简单,如"插入一行",或从带有唯一索引的表中查询数据,则执行处理会非常简单。但是,很多查询都要求大量的内存以提高运行效率,或至少从所增加的内存得到好处。在 SQL Server 6.5 中,每个查询能够使用的内存限制在 0.5MB 或 1MB 以下。有一个控制查询内存使用的参数,称为排序页。顾名思义,它主要是限制可能占用大量内存的排序操作。不管要处理的排序有多大,在 SQL Server 6.5 中,内存的使用不能超过 1MB。即使您使用的机器上配置了 2GB 内存,需要对数百万行数据排序,也不能突破限制。显然,复杂的查询不能高效执行,因此 SQL Server 开发人员增加了 SQL Server 7.0 的能力,使得单个查询可以使用大量的内存。
另一个问题随之而来。一旦您开始允许查询使用大量内存,就必须确定如何把内存分配给可能需要内存的很多查询。SQL Server 按照以下方法解决这个问题。当查询计划优化之后,优化器要确定有关给该查询使用的内存的两部分信息。第一,该查询有效执行所需要的最小内存,该参数与查询计划一起存放。优化器还要确定该查询可以获益的最大的内存量。例如,如果要排序的整个表只有 100MB,分配 2GB 内存就没什么帮助了。您需要的只是 100MB,这个最大有用内存参数随查询计划一起存放。
当 SQL Server 开始执行计划时,该计划被传递给一个所谓内存授权调度程序的例程中。这个授权调度程序要完成几项有趣的工作。首先,如果授权调度程序要处理的查询在计划中没有排序或杂凑操作,则 SQL Server 知道该查询不会需要很多内存。在这种情况下,不需要内存授权调度程序进行判断。该计划会立即执行,因此典型的事务处理请求会完全旁路这种判断机制。内存授权调度程序还设有多个队列处理不同容量的请求。内存调度程序优先处理较小的请求。例如,如果有一个查询要求"提取前 10 个",并且只需要对 20 行排序,则虽然需要经过内存授权调度程序,但是要释放该查询并且很快调度。服务器需要并行或并发执行许多这种查询。
如果有很大的查询,您希望一次只运行几个查询,让它们占有所需的更多内存。SQL Server 确定一个由 4 X(系统中的 CPU 个数)得到的数。如果可能,SQL Server 会同时运行那个数量的查询,为它们分配高效运行所需要的最小内存。如果还剩有内存,则一部分查询会允许占用最大高效内存。SQL Server 试图既为查询分配尽可能多的内存,又让尽可能多的查询同时运行在系统中。
能够使用最大高效内存对某些操作很重要,例如夜间运行的批处理过程。您可能会生成很大的报表,或重新建立索引。这些查询可能使用大量内存,这种机制可以动态调整对内存的需求。因此,如果如果在队列中等待处理的查询不多,则内存授权调度程序会经常分配给查询最大需要的内存。如果白天的机器负载很重,则就不能同时运行太多的查询。这些查询会得到有效运行所需最小的内存,让内存为更多的查询共享。
一旦调度程序说现在可以为请求分配内存,则计划即被"打开",开始实际运行。计划会一直运行直到完成。如果查询使用了默认结果集模型,则计划会一直运行到检索到所有结果为止,然后把结果返回给客户机。如果使用的是游标模型,则处理过程略有不同。每个客户机请求只提取一块数据,并不是所有数据。当每个结果块返回给客户机之后,SQL Server 必须等待客户机的下一个请求。在等待时,整个计划就会睡眠。这意味着要释放一些锁,要释放一些资源,并保留一些断点信息。这些断点信息使得 SQL Server 能够返回到睡眠之前的状态,使得执行可以继续。
过程缓存
我们在前面已经多次提到 SQL Server 的过程缓存。需要注意的是,SQL Server 7.0 的过程缓存与以前的版本有很大不同。在早期的版本中,有两个有效配置值用于控制过程缓存的容量:一个是定义 SQL Server 总可用内存的固定容量,另一个是供存储查询计划使用的内存百分比(扣除满足固定需要的内存)。在老版本中,特定 SQL 语句从不存入缓存,只有存储过程计划才存入其中。在 SQL Server 7.0 中,内存的总容量是动态的,用于查询计划的空间也是经常变化的。
在处理查询时,SQL Server 7.0 首先会问的是:这个查询既是特定的又是易于编译的吗?如果是,SQL Server 就根本不会将其写入缓存中。将来重新编译这些计划比把复杂的计划或数据页推出内存更合算。如果查询不是特定的或不易于编译,则 SQL Server 会从缓存区中分配一些缓存内存存储该计划,因为该缓存区是 SQL Server 7.0 用来满足 99% 内存需求的唯一来源。在少数特殊情况下,SQL Server 会直接从操作系统中分配大块内存,但是这种情况极为罕见。SQL Server 的管理是集中式的。
写入缓存的除计划外,还有反映通过编译该查询实际创建该计划的成本的成本因子。如果这是一个特定计划,则 SQL Server 将它的成本设置为 0,表示可以立即将它撤出过程缓存。对于特定 SQL,虽然有可能被重复使用,但可能性很小,如果系统内存紧张,总是愿意首先撤出特定语句的计划。这样,特定查询的计划是最适合清出缓存的对象。如果查询不是特定的,则 SQL Server 会把该成本设置为实际编译查询的成本。这些成本是以磁盘 I/O 为单位的。如果从磁盘中读出一个数据页,则有一个 I/O 成本。在编译计划时,信息从磁盘中读出,包括统计数据和查询本身的文本。SQL 要进行附加的处理,而且这处理工作被正常化为 I/O 成本。现在,建立过程的成本可用执行 I/O 的成本表示。该成本非常恰当反映了,与打算用磁盘缓存的数据量相比,管理实际打算分配给存储过程和任何种类查询计划的缓存量的能力。该成本被计算出来之后,该计划就会被写入缓存。
如果另一个查询可以重新使用该计划,则 SQL Server 要再次判定计划的类型。如果是一个特定计划,SQL Server 会把成本加 1。这样,如果特定计划确实要被重新使用,则它会在缓存中稍作停留,停留时间越长,成本就增加越多。如果该计划经常被重新使用,则成本会一次增加一个单位地不断增长,直到增长到其实际编译成本。该成本和设置的成本一样高。不过该计划经常被重复使用;如果同一用户或其他用户不断重新提交完全一样的 SQL 文本,该计划就会留在缓存中。
如果查询不是特定的,也就是说是一个存储过程、带参数的查询或自动参数化的查询,则每次该计划被重新使用时,成本都会设置回原来的值。只要计划被重新使用,就会留在缓存中。即使有一段时间没有被使用,取决于最初的编译代价的高低,计划停留在缓存中的时间也有长短。
迟缓写入器(Lazywriter) 是使计划过时的机制,负责在必要的时候从缓存中删除计划。迟缓写入器实际上是存储引擎的一部分,但是因为迟缓写入器对于查询处理机制是如此重要,我们还是在这里进行讨论。迟缓写入器管理查询计划内存使用的机制与管理页面的机制一样,因为 SQL Server 7.0 计划存储在普通缓冲存储器中。迟缓写入器要检查系统中所有的缓冲器标题。如果系统的内存不紧张,检查的次数就很少;如果开始紧张,则迟缓写入器就会经常运行。当迟缓写入器运行时,它要检查缓冲区标题,并检查缓存区中该页面的当前成本。如果成本为 0,则意味着自从上次迟缓写入器检查以来,该页面没有被使用过,于是迟缓写入器就会释放该页面,以便为系统增加可用内存,用于页面 I/O 或其他计划。此外,如果该缓冲区包含过程计划,则迟缓写入器会调用 SQL 管理器,以完成一些清理工作。最后,该缓冲区会被放到可用内存表中供重新使用。
如果与缓冲区关联的成本大于 0,则迟缓写入器会把成本减 1,并继续检查其他缓冲区。这成本实际上反映的,某计划若是没被使用,它在缓存中还能存在多少个迟缓写入器的检查周期。这种算法,除了如果对象是存储过程则调用 SQL Manager 这一步之外,对缓存中的计划和缓存的数据或索引没有什么区别。迟缓写入器并不知道对象是否存储过程,这种算法很好地平衡了磁盘 I/O 对缓存的使用和存储过程计划对缓存的使用。
您会发现,如果计划的编译成本很高,那么即使很长一段时间都没有被重新使用,也仍然会停留在缓存中,这是因为其初始成本太高了。经常被重新使用的计划也会长期停留在缓存中,这是因为每当它被重新使用时其成本已被重新设置,迟缓写入器不会看到它的成本降为 0。
处理客户机的 SQL
下面再看看提交 SQL 语句之后的处理过程。首先,我们将研究客户机向 SQL Server 发送 RPC 事件。因为 SQL Server 收到了 RPC 事件,所以它会知道该事件是某种参数化的 SQL;它是准备/执行模型,或者是 EXECUTESQL。SQL Server 需要构建一个缓存键,以标识这个具体的 SQL Server 文本。如果 SQL Server 处理的是实际的存储过程,则不需要建立它自己的键;直接使用该过程的名称即可。对于通过 RPC 调用发来的简单 SQL 文本,则通过杂凑该 SQL 文本来建立缓存键。此外,该键还要反映一定的状态信息,如某些 ANSI 设置。使所有 ANSI 设置为 ON 的连接和另一个使所有 ANSI 设置为 OFF 的连接,即使它们来自相同的查询,也不能使用相同的计划。处理过程是不同的。例如,如果一个连接把 concat_null_yields_null 设置为 ON,另一个把 concat_null_yields_null 设置为 OFF 的连接,即使它们执行的是完全相同的 SQL 文本,但所产生的结果则完全不同。这样,SQL Server 可能需要在缓存中保存计划的多个版本,每个版本对应于一个不同的 ANSI 设置组合。启用的选项设置是键的一部分,而键字是使用这种缓存处理机制检查对象的核心,因此 SQL Server 建立这种键并用来检查缓存。如果在缓存中没有发现该计划,则 SQL Server 会按照前面介绍的方式编译该计划,并把该计划与键一起存入缓存中。
SQL Server 还需要确定该命令是否是准备操作,这意味着该计划应该只编译但不执行。如果是准备操作,则 SQL Server 会给客户机返回一个句柄,供客户机在以后检索并执行该计划。如果不是一个准备操作,则 SQL Server 提取并执行该计划,就像最初从缓存中找到该计划一样。
准备/执行模型为缓存管理增加了复杂因素。预备给出了今后能够执行该计划的句柄。应用程序可以在几小时或几天之内保持该句柄是激活的,以定期执行计划。即使需要在缓存中为更多的活动计划或数据页面腾出空间,也不能使该句柄无效。SQL Server 实际所做的就是将计划放入缓存,此外还从预备操作中将 SQL 保存到更加紧凑的空间。如果空间紧张,则可按前述的方式释放计划所占用的空间,但仍有 SQL 的副本准备着。如果客户机要执行预备的 SQL,但在缓存中没有找到计划,则 SQL Server 能够检索到该文本并编译它,再将它放回缓存中。这样,缓存中的 16 千字节 (KB) 或更多的页面用来保存可重用的计划,而长期占用的空间或许是存储在其他处的 SQL 代码的 100 或 200 字节。
处理来自客户机的语句时的另一种情况是,查询是作为 SQL 语言事件出现的。除了一点以外,此流程并无太大的差异。在这种情况下,SQL Server 试图使用称为自动参数化的技术。SQL 文本与自动参数化模板相匹配。自动参数化是个棘手的问题,因此,过去一直能够利用共享的 SQL 的其他数据库管理产品, 一般并没有提供这一选项。随之而来的问题是,如果 SQL Server 自动地参数化每个查询,那么对于随后提交的某些特定值而言,这些查询中的某些(或绝大多数)将获得非常糟糕的计划。在程序员将参数标记放在代码之中的场合下,其假定是程序员知道所期望的值的范围,并愿意接受 SQL Server 提供的计划。但当程序员实际补充一个特定的值,并且 SQL Server 决定将该值当做一个可变的参数来对待时,所产生的任何适合于某个值的计划可能不适合于后续的值。利用存储过程,通过在过程中放入 WITH RECOMPILE 选项,程序员可以强制产生新的计划。利用自动参数化,程序员无法指出必须为每一个新值开发新的计划。
当 SQL Server 处理自动参数化时,它是非常保守的。被安全地自动参数化的查询有一个模板,并且只有匹配模板的查询才能应用自动参数化。例如,假设有这样一个查询,其中包含带有等于操作符、但没有连接的 WHERE 子句,WHERE 子句中的列带有唯一的索引。SQL Server 知道绝对不会返回一行以上,而且计划将总是使用那个唯一的索引。SQL Server 绝对不会考虑扫描,实际值绝对不会以任何方式改变计划。对于自动参数化而言,这种查询是安全的。
如果查询匹配自动参数化模板,则 SQL Server 自动用参数标记(例如 @p1、@p2)代替文字,并且这就是我们发送到服务器的内容,正如它是 sp_executesql 调用一样。如果 SQL Server 认为该查询对自动参数化并不安全,则客户机将向 SQL Server 发送文字的 SQL 文本,以此作为特定的 SQL。
编译
现在让我们更详细地讨论一下编译和优化。在编译过程中,SQL Server 分析语句,并创建所谓的次序树,即语句的内部表述。这是 SQL Server 6.5 实际保留在 SQL Server 7.0 中的几个数据结构之一。该次序树是正常化的。正常化程序的主要功能是执行绑定。绑定包括检验表和列的存在,以及装载有关表和列的元数据。有关必需的(隐含的)转换信息也附加在次序树上,例如,如果查询试图向数字值添加整数 10,则 SQL Server 将向该树插入隐含的转换。正常化还用视图的定义代替对该视图的引用。最后,正常化执行一些基于语法的优化。如果该语句是传统的 SQL 语句,则 SQL Server 从关于该查询的次序树中提取信息,并创建称为查询图表的特殊结构,设置查询图表是为了使优化器工作非常有效。然后优化该查询图表,一个计划就产生了。
优化
SQL Server 优化器其实是由独立的段组成的。第一段是一个非基于成本的优化器,称为琐细计划优化。琐细计划优化的完整概念是,当 SQL 语句确实只有一个可变计划时,基于成本的优化太昂贵了。最好的例子是,带 VALUES 子句的 INSERT 语句组成的查询。它只可能有一个计划。另一个例子是,所有的列都在唯一的封面索引(且没有其他列的索引)中的 SELECT 语句。这两例中,SQL Server 只要简单地生成一个计划,用不着在多个计划选一个更好的方案。琐细计划优化器可找到真正显而易见的计划,而且通常非常便宜。所以,最简单的查询在处理的前期就趋于被清除,优化器不花很多时间来搜索一个好计划。这是好事,因为随着 SQL Server 将杂凑连接、合并连接和索引相交增加到其处理技术列表上,SQL Server 7.0 版上的潜在计划数呈天文数字增长。
如果琐细计划优化器不能找到一个计划,SQL Server 便进入优化的下一部分,称为简化。简化是查询本身的语法变换,寻找可交换的特性和可重新排列的运算。SQL Server 可进行常数合并,以及无需考虑成本或分析索引是什么但能得出更有效查询的其他运算。SQL Server 然后上载关于索引和列的统计信息,并输入优化的最后的主要部分,即基于成本的优化器。
基于成本的优化有三个阶段。第一个基于成本的阶段,称为交易处理阶段,查找简单请求的计划,即典型的交易处理系统。这些请求一般比由琐细计划优化器处理的那些请求要复杂些,并要求比较众多计划查找出成本最低的计划。当交易处理阶段完成时,SQL Server 便将找到的成本最低的计划与内部阈值进行比较。阈值用于决定是否要求进一步的优化。如果计划成本比阈值低,那么,进行附加优化比只执行已找到的计划成本要高。所以,SQL Server 不做进一步优化,并使用交易处理阶段找到的计划。
如果交易处理阶段找到的计划,仍比该阶段的阈值贵,SQL Server 便进入第二个阶段。这个阶段有时称为 QuickPlan 阶段。QuickPlan 阶段扩大搜索范围来寻找一个好计划,包括选择好的、适度复杂的查询。QuickPlan 检查可能的计划范围,完成之后,将最佳计划的成本与第二个阈值进行比较。因为在交易处理阶段,如果发现了一个成本比阈值低的计划,优化便终止,并使用那个计划。一般来说,SQL Server 6.5 版中已有的查询的计划,在 SQL Server 7.0 版中也应当是最佳的,这个计划将要么被琐细计划优化器找到,要么被基于成本的优化的头两个阶段中的一个发现。这些规则被有意地组织起来以达到这个目的。这个计划将很可能由使用单一的索引和使用嵌套循环联合组成。
优化的最后阶段,称为完全优化,旨在对复杂和非常复杂的查询产生一个好计划。对复杂的查询来说,QuickPlan 产生的计划,经常被认为比继续搜索一个更好的计划要昂贵得多,而完全优化将被执行。在完全优化中,实际上有两个适用的独立选择。如果 QuickPlan 阶段产生的最佳成本比"并行成本阈值"的配置值要高,并且如果服务器是一个多处理器机器,那么优化器的最后阶段将涉及查找一个能在多个处理器上并行运行的计划。如果 QuickPlan 阶段的最佳计划的成本比配置的"并行成本阈值"低,那么,优化器将只考虑串行计划。完全优化阶段能执行各种可能性,而且很耗时,因为在这最后阶段必须找到一个计划。优化器仍可能没有检查每个可得到的计划,因为它将任何潜在的计划成本与优化中得出此结果的成本进行比较,并且它估算继续试用不同优化的可能成本。在某些情况下,优化器可能认为,使用现有的计划比继续查找更优方案还要便宜,而且支付继续优化的附加编译成本将不具备高的成本效率比。在这最后阶段处理的各种查询的计划一般只使用一次,所以,几乎没有这样的机会:为编译和优化所付出的额外代价,会在后续执行的计划重用中一次结清。那些后续执行很可能不会发生。
找到一个计划后,该计划便变为优化器的输出,然后 SQL Server 在执行该计划之前,遍历前面已讨论过的全部缓存机制。您应该意识到,如果完全优化阶段产生了该查询的并行计划,并不一定意味着该计划将在多个处理器上执行。如果机器很忙,而且不支持在多个 CPU 上运行单一的查询,该计划则使用单一的处理器。
执行
查询处理的最后一步是执行。除了这一小段外,我们不会再讨论执行的详细过程。执行引擎采用优化器生成的计划,并执行之。处理实际执行以外,执行引擎还为要运行的处理器调度线程,并提供线程间的通信。