伯乐共勉

讨论。NET专区
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

Microsoft SQL Server 查询处理器内部设计和结构

Posted on 2005-05-17 14:21  伯乐共勉  阅读(1025)  评论(0编辑  收藏  举报

Microsoft SQL Server 查询处理器内部设计和结构
Hal Berenson 和 Kalen Delaney

2000 年 1 月

摘要:本文探讨如何在客户端处理 Microsoft SQL Server 查询,各种客户端如何与 SQL Server 进行交互以及 SQL Server 如何处理客户端请求。(共 26 页打印页)

介绍
Microsoft? SQL Server? 的内部设计和结构是个范围很广的主题,因此本文仅探讨开发人员比较感兴趣的领域,并着重探讨在其他资料中未深入讨论的主题。在探讨 SQL Server 结构的过程中,我们将主要了解如何在客户端处理事务,各种客户端如何与 SQL Server 进行交互以及 SQL Server 如何处理客户端请求。有些其他资料对 SQL Server 的其他方面进行了探讨。特别值得一读的 Microsoft Press 出版的、由 Ron Soukup 和 Kalen Delaney 合著的《Inside SQL Server 7.0》,该书详尽地介绍了存储引擎的机制和行为,但对查询处理器的论述并不十分深入。本文将弥补这方面的某些欠缺。

衷心希望,本文介绍的信息对您会有所受益,使您能够编写出更出色的应用程序。您将能够对性能问题有一个新的认识和理解。

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 的角度看,三层解决方案中的客户端是位于中间层的逻辑部分。该中间层直接与数据库系统进行交互。实际桌面(即瘦客户端)采用某些其他机制并通常直接与中间层(而不是直接与数据库系统)进行交互。图 1 阐释了此结构。

 

图 1:三层系统模型

客户端结构
从结构的角度而言,SQL Server Relational Server 组件本身并不真正关心客户端的运行位置。事实上,如果应用程序与 SQL Server 在同一个位置上运行,则就 SQL Server 而言,这同样是客户端/服务器模型。服务器运行一个单独的多线程进程,并向来自客户端的请求提供服务,而不管这些客户端的位置如何。客户端代码本身是在客户端应用程序内部运行的单独的 DLL,SQL Server 的真正接口是客户端与服务器之间会话的表格数据流 (TDS) 协议。

“什么是 SQL Server 的本机接口?”,这是一个经常被问及的问题。长期以来,许多开发人员不愿使用像 ODBC 这样的接口,因为他们认为 DB-Library(由 Sybase 开发的客户端 API)才是 SQL Server 的本机接口。事实上,SQL Server Relational Server 本身并不具有本机 API;它的接口是 TDS,即客户端和服务器之间的通讯流协议。TDS 封装了从客户端发送到服务器的 SQL 语句,以及从服务器返回到客户端的结果集。任何直接处理 TDS 的 API 都是本机 SQL Server 接口。

让我们看一看图 2 中显示的客户端组件。有些客户端结构部分我们将不做探讨,因为它们超出了 SQL Server 本身的范围。但是,如果您要编写应用程序,则应特别注意这些部分。人们了解最多的当属各种对象模型;如果您正在编写 ASP 或 Microsoft Visual Basic? 应用程序,则可以通过 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 语法,即具有参数标记的调用语法,该语法在所有提供程序之间是通用的。

除前面提到的限制,即 DB-Library 仍使用 SQL Server 6.5 版以外,所有 API 的 TDS 协议都相同。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 组件之间的专用合同。

目录存储过程是客户端/服务器交互的另一个重要组成部分。它们最初出现在 SQL Server 6.0 的 ODBC 中,并包含 sp_table 和 sp_column 等过程。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 向 Relational Server 中添加了对这些概念的支持,并通过新的伪系统存储过程公开了该支持。本文稍后将对这些功能以及服务器对它们提供的支持加以探讨。通过 sp_executesql 过程提供的参数化查询支持被认为对直接的 Transact-SQL 和 DB-Library 使用尤其有用,该过程已被编制成文档。准备/执行过程由 ODBC 驱动程序和 OLE DB 提供程序以独占方式使用。

总之,所有可与 SQL Server 进行通讯的客户端都基于以下三个功能集:TDS 协议、目录存储过程和伪系统存储过程。

服务器结构
SQL Server,更确切地说是 SQL Server Relational Server,经常被描述为包含两个主要部分:关系引擎和存储引擎。正如前面提到的,有许多存储引擎详细信息已在别处被论及,因此本文集中介绍关系引擎的功能。图 3 阐释了 SQL Server 关系引擎部分的主要组件。所阐释的组件可分为三组子系统。左侧是对编译查询进行寻址的组件,其中包括查询优化程序。优化程序是任何关系数据库引擎中最神秘的部分之一,同时从性能角度讲也是最关键的部分之一。查询优化程序的责任是获取以 SQL 表示的非过程请求,并将它转换为一组磁盘 I/O、筛选以及其他有效满足请求的过程逻辑。右侧的是执行结构。它实际上是一个小得多的功能集。编译组件完成了任务后,它们创建的事物可直接由引发的少数几个服务执行。

 

图 3:服务器结构

位于图形中间的是 SQL Manager。它控制 SQL Server 内部所有事物的流动。RPC 消息由 SQL Manager 处理,在 SQL Server 7.0 中,这是来自客户端的功能调用的主要部分。前面探讨的伪系统存储过程在逻辑上也是 SQL Manager 的一部分。通常作为 TDS SQL 语言消息传入的 SQL 语句在编译端被直接处理;在 SQL Server 7.0 中,这种情况比早期版本中少见,但仍比较普遍。组件在执行引擎调用 ODS 中返回结果以格式化 TDS 结果消息。

该输出的大部分来自图中的执行端,而结果实际上来自表达式服务。表达式服务库是一个执行数据转换、谓词计算(筛选)和算术计算的组件。此外,它与 ODS 层一起使用以将输出结果格式化为 TDS 消息。

还有两个组件我们将只是简要介绍一下,它们在关系引擎中提供附加的服务。目录服务组件便是其中之一,它处理像 CREATE TABLE、CREATE VIEW 等定义语句这样的数据。目录服务还处理系统表,并物化实际上是伪表的系统表。目录服务组件主要位于关系引擎中,但其中有大约三分之一实际上在存储引擎的范围内操作,因此被视为共享组件。

关系引擎的另一个组件是用户模式计划程序 (UMS),它是 SQL Server 自己的内部纤维和线程计划程序。有一个非常复杂的内部机制,它根据服务器的配置安排纤维或线程的工作,并允许 SQL Server 在 SMP 系统上的处理器间进行适当的负载平衡。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 执行。

在这个特定的查询中,我们从部件表中为具有特定部件 ID 的行选择了所有列。这是特殊 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。这将影响客户端在内部如何处理该语句和确定以消息的形式向 SQL Server 实际发送的内容。在没有问号的情况下,很显然客户端只希望以 SQL 语言 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 消息之前处理所有结果行。因此如果您取回五行,但仍有一些数据,则您仍然可以将更新继续发送到服务器。

快速只进游标在服务器上比常规游标的速度快,并使您能够指定两个附加选项。一个称为“自动提取”(autofetch),另一个称为“自动关闭”(autoclose)。“自动提取”将返回第一个行集作为对打开的响应消息的一部分。Autoclose 在读取最后一行后自动关闭游标。由于它是只进和只读的,因此无法回滚。如果 SQL Server 只返回一个包含最后一个数据集的消息,则表明游标已关闭。如果使用的是快速只进游标,则对于数目少的行,在同一个消息往返行程中即可完成通讯。如果行数很大,则必须至少为每个行块支付额外的开销。如果使用快速只进游标,游标处理就与该默认结果集很接近了。

图 4 阐释了 SQLExecDirect 模型的流程。

 

图 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,然后该语句便开始运行。对于游标,它与其他游标处理看上去完全相同,事实上,它具有相同特性,包括允许“自动提取”和“自动关闭”(如果该游标是快速只进的)。

图 5 阐释了准备/执行操作的流程。

 

图 5:准备/执行模型

调用存储过程
存储过程通常是通过向使用 ODBC 规范化调用语法调用过程的 SQL Server 发送 SQL 语句的方式,从 ODBC 和 OLE-DB 中调用。该语句看起来可能像下面这样:

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 Manager 的任何讨论。相反,您会看到几个其他组件,它们执行 SQL Manager 所执行操作的一部分。但在 SQL Server 7.0 中,这些组件已合并为一个组件,该组件实际驱动系统中的查询处理。

通常,当您请求 SQL Server 为您执行某些工作时,SQL Manager 由 RPC 消息调用。然而,当 SQL 语言语句随 SQL 消息传入并进入引擎的编译端时,也将涉及 SQL Manager。当过程或批处理包含 EXEC 语句时会涉及它,因为 EXEC 实际上是调用 SQL Manager。如果 SQL 语句传递自动参数化模板(下面将要探讨),则将调用 SQL Manager 以将查询参数化。当需要将特殊查询放置到缓存时也要调用它。

编译和执行
下面让我们看一看编译和执行通常如何在 SQL Server 内部流动。认识到编译和执行是 SQL Server 内部的两个不同阶段很重要。SQL Server 编译查询的时间和它的执行时间的间隔可以非常小,只有几微秒,也可以是几秒、几分钟、几小时甚至几天。在编译过程中(包括优化),必须区分可将哪种知识用作编译的一部分。在编译时为真的所有内容在执行时并非也都为真。必须将编译和执行看成是两个单独的活动,甚至在发送特殊 SQ 语句并立即执行它的情况中也应如此。

当 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 语句、局部变量声明,等等。它们在服务器内部的处理方式截然不同。TSQL 的过程逻辑由知道如何执行过程任务的引擎编译。

SQL 语句本身由传统的查询优化程序编译。优化程序必须将基于集合的 SQL 语句的非过程请求转换为可有效执行并返回所需结果的过程。除非另外说明,从现在起,我们所探讨的编译是指 TSQL 的编译和 SQL 语句的优化。

我们在前面曾提到,编译和执行是查询处理的两个不同阶段,因此优化程序所要做的事情之一是基于相当稳定的状态进行优化。您可能认识到 SQL Server 可以根据是否符合某个标准来重新编译语句,因此它不是一个永久稳定的状态,但也不能总是处于不断变化的状态之中。如果优化程序使用的信息变化非常大且非常频繁(并发进程数或保留的锁数),则查询必须不断地被重新编译,因而编译速度往往很低。例如,您可能有这样一个 SQL 语句,其运行速度为 1/100 秒,但编译它却要花半秒种。如果 SQL Server 可以编译该语句一次,然后允许人们执行它上千次或上百万次,而不必在每个语句上重新支付编译开销,则这样的语句更可取。

编译阶段的最终产物是查询计划,该计划被放入过程缓存中。便宜的特殊 SQL 计划不真正进入缓存,但现在这只是一个不重要的细节。我们不希望用不太可能重用的事物填满缓存,而在各种事物中,特殊 SQL 是其计划最不可能重用的一类查询。如果语句的编译时间已经很短(少于 1/100 秒),则将计划存储在缓存中并用不太可能重用的计划填满缓存是没有意义的。

计划放入缓存后,SQL Manager 回过头来就计划的执行检查逻辑,检查是否有任何更改以及计划是否需要重新编译。即使编译与执行的间隔时间只有几微秒,有些人仍可能已执行数据定义语言 (DDL) 语句将索引添加到了重要的表。人们诚然认为这是不可能的,当实际却是可能的,因此 SQL Server 必须解释此问题。只有少数几件事会导致 SQL Server 重新编译存储计划。元数据更改(如添加或删除索引)是可能性最大的原因。服务器需要确保所使用的计划反映索引的当前状态。

重新编译的另一个原因是统计更改。SQL Server 保留了很多有关处理的数据的直方图信息。如果数据分布发生重大更改,则可能需要其他的查询计划以获得最有效的执行。SQL Server 跟踪数据插入到表中或从表中删除的频率,如果更改的次数超过某个特定阈值(它随表的大小而更改),则它将回过头来根据新的分发信息重新编译计划。

图 7 显示了编译和执行的流程。

 

图 7:编译和执行

注意,实际参数值的更改不会导致重新编译计划,也不会导致环境更改,如可用内存量或已位于缓存中的所需数据量。

执行很简单,如果您所拥有的只是类似“插入一行”这样的简单查询,或是从包含唯一索引的表中进行一次选择,则处理过程非常简单。但许多查询需要大量内存才能够有效运行,或至少能够从大量内存获益。在 SQL Server 6.5 中,通过将查询的可用内存限制为 0.5 megabyte (MB) 或 1 MB 来控制它。有一个配置参数可控制查询使用的内存量,称为排序页。该名称表示此参数基本上只是可受益于大量内存的排序操作。无论执行的排序有多大,在 SQL Server 6.5 中特定操作使用的内存不得超过 1 MB。即便是在内存为 1 或 2 gigabyte (GB) 的计算机上操作,并且排序对象包含数以百万计的行亦如此。显然,这种情况对这些复杂查询很不利,因此 SQL Server 开发人员在 SQL Server 7.0 中增加了对个别查询使用大量内存的能力。

在该阶段还出现另一个问题。一旦开始允许队列使用大量内存,就必须决定如何在可能需要该内存的诸多查询中分配该内存的使用。SQL Server 以如下方式处理此问题。对查询计划进行优化时,优化程序为该查询确定两条有关内存使用的信息。首先,它选取查询有效运行所需的最小内存量,然后将该值与查询计划存储在一起。优化程序还确定查询可从中受益的最小内存量。例如,如果要排序的整个表需要 100 MB,则优化程序不会提供 2 GB 内存的排序。您实际上只需要 100 MB,因此这便是与查询计划一起存储的最大有用内存量的值。

当 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,尽管我们希望该计划可以被重用,但可能性很小。如果系统的内存压力很大,我们最好计划让特殊语句第一个出去。这样,特殊查询的计划开始有资格从缓存中被踢出。如果查询不是特殊的,则 SQL Server 将开销设置为编译查询所需的实际开销。这些开销以磁盘 I/O 为单位。当数据页从磁盘中读出时,它的开销为一个 I/O。当生成该计划时,从磁盘中读取信息,包括统计和查询本身的文本。SQL 执行其他处理,且该处理工作被标准化为 I/O 开销。现在可将生成过程的开销与执行 I/O 的开销进行比较。与管理进入数据磁盘缓存的缓存量相比,开销大量出现在管理实际进入存储过程或任何种类的查询计划的缓存量上。计算完开销后,计划被放入缓存。

图 8 显示了计算计划的开销并将计划放入缓存的流程。

 

图 8:将计划插入到缓存中

如果出现一个可以重用该计划的查询,SQL Server 将再次确定该计划的类型。如果它是一个特殊计划,则 SQL Server 将开销加 1。因此,如果特殊计划真的被重用,它们将因开销因子增加而在缓存中多保留一段时间。如果计划的重用频率很高,则开销将保持每重用一次加 1,直到达到它的实际创建开销为止。它与开销的设置一样高。但它被重用很多次;如果同一用户或其他用户一直重新提交同一个 SQL 文本,则该计划将保留在缓存中。

如果查询不是特殊的,表明它是一个存储过程、参数化查询或自动参数化查询,则每当计划被重用时,开销将被重新设置为创建开销。只要计划被重用,它就保留在缓存中。即使计划有一段时间未被使用,根据它最初的创建开销,仍可以在缓存中保留相当长一段时间。

图 9 显示了从缓存中检索计划并调整开销的流程。

 

图 9:从缓存中检索计划

惰性写入器是设置计划期限的机制,并负责在必要时从缓存中移除计划。惰性写入器实际上是存储引擎的一部分,但由于它对查询处理机制至关重要,因此在这里对其加以探讨。惰性写入器使用管理查询计划所用内存的相同机制来管理页,因为在 SQL Server 7.0 中,计划存储在普通的缓冲区缓存中。惰性写入器查看系统中的所有缓冲区标头。如果系统的内存压力很小,它的速度看起来很慢;如果内存压力增大,则惰性写入器的运行速度开始加快。在运行期间,惰性写入器查看缓冲区标头并查看该缓冲区中的页的当前开销。如果开销为零,则意味着该页自惰性写入器上次检查以来未被动用,惰性写入器将释放该页以便在系统中获得一些闲置内存用于页 I/O 或其他计划。此外,如果缓冲区包含过程计划,惰性写入器将调用 SQL Manager 执行清除。最后,缓冲区被放置在空闲列表中以便重用。

如果与缓冲区关联的开销大于零,惰性写入器将减小此开销并继续检查其他缓冲区。因此,此开销实际上表示某个事物在被移出前保留在缓存中不被重用的惰性写入器周期数。该算法(如果对象是存储过程,调用 SQL Manager 的步骤除外)在缓存中的计划和缓存中的数据或索引页之间不存在差别。惰性写入器实际上对作为过程的对象一无所知,该算法很好的平衡了磁盘 I/O 的缓存使用和过程计划的缓存使用。

您会发现如果某个事物的编译开销很大,即使它很长时间未被动用,但因其初始开销很高,所以仍保留在缓存中。任何重用频率很高的事物将在缓存中保留很长一段时间,因为每当被使用时,它的开销也将重置,惰性写入器永远不会看到它变成零。

图 10 显示了惰性写入器处理缓存的流程。

 

图 10:惰性写入器缓存处理流程

处理客户端的 SQL
现在,我们将再次探讨提交 SQL 语句时发生的处理。首先,我们将了解 RPC 事件从客户端到达 SQL Server 时发生的情况。因为 SQL Server 接收 RPC 事件,它知道该事件是某一类参数化 SQL;它可能是准备/执行模型或 EXECUTESQL。SQL Server 将需要构造一个缓存关键字来标识此特定的 SQL Server 文本。相反,如果 SQL Server 处理的是实际的存储过程,则不需要构建它自己的关键字;而只需使用过程名称。对于通过 RPC 调用进入的直接 SQL 文本,通过对 SQL 文本进行哈希计算来构造缓存关键字。此外,该关键字必须反映特定的状态信息,如某些 ANSI 设置。如果一个连接的所有 ANSI 设置为“ON”,而另一个连接的所有 ANSI 设置为“OFF”,则即使它们传入相同的查询,也不能使用同一计划。处理过程将不同。例如,如果一个连接将 concat_null_yields_null 的值设置为 ON,则即使执行同一 SQL 文本,它产生的结果与 concat_null_yields_null 设置为 OFF 的连接也可能完全不同。因此,SQL Server 可能需要在缓存中保留计划的多个副本,启用的 ANSI 设置的每个不同组合使用一个。启用的选项集是关键字的一部分,而该关键字是使用此缓存机制查找事物的关键,因此 SQL Server 生成该关键字并使用它在缓存中执行查找。如果在缓存中未发现计划,SQL Server 将编译计划(详见上文)并将它同该关键字一起放在缓存中。

SQL Server 还需要确定命令是否是准备操作,这意味着只应编译而不应执行计划。如果它是一个准备操作,SQL Server 便将句柄返回到客户端,客户端稍后将使用该句柄检索计划并执行它。如果它不是准备操作,SQL Server 将获取该计划并执行它,就好像该计划是通过原始查询在缓存中找到的一样。

准备/执行模型增加了缓存管理的复杂因素。准备发出句柄,可在稍后用于执行计划。应用程序可使该句柄在数小时或数天中保持活动状态,并定期执行计划。如果需要在缓存中为更活动的计划或数据页留出空间,则不能使该句柄无效。SQL Server 实际执行的操作是将计划放入缓存,此外,它还将 SQL 从准备操作中保存到更紧凑的空间中。现在,如果内存的使用很紧张,则可按照前面介绍的方法释放计划占用的内存,但仍存在已准备的 SQL 的副本。如果客户端准备执行准备好的 SQL,但缓存中没有计划,则 SQL Server 可以检索文本,重新编译它并将它放回到缓存中。这样便可重用缓存中用于保留计划的 16 千字节 (KB) 或更多的页,而长期保留的空间多半是存储在另一位置的 100 或 200 字节的 SQL 代码。

从客户端处理语句的另一种情况是查询作为 SQL 语言事件进入时。该流程不存在明显的差别,但有一种情况除外。这种情况下,SQL Server 试图采用一项称为自动参数化的技术。SQL 文本根据自动参数化模板进行匹配。自动参数化是一项很复杂的技术,因此其他的数据库管理产品(它们在过去能够利用共享 SQL)通常不提供该选项。它的问题是,如果 SQL Server 要自动对每个查询进行自动参数化,则其中的某些(甚至大多数)查询为后面提交的某些特定值获取的计划会很差。如果程序员将参数标记放入代码中,则假设程序员知道预期的值范围并乐于接受 SQL Server 提供的计划。但是,如果程序员实际提供了特定的值,且 SQL Server 决定将该值视为可变参数,则可能存在这样的情况,即适合某个值的任何生成的计划可能不适合后面的值。对于存储过程,程序员可通过在该过程中放入 WITH RECOMPILE 选项,强制生成新计划。而对于自动参数化,程序员没有办法指示必须为每个新值开发新的计划。

一提到自动参数化,SQL Server 变得非常保守。有一个可安全地进行自动参数化的查询模板,只有与该模板匹配的查询才应用自动参数化。例如,假设有这样一个查询,它包含一个带相等运算符、但不带联接的 WHERE 子句,该 WHERE 子句中的列上有一个唯一索引。SQL Server 知道返回的行数不可能大于 1,因此计划应始终使用该唯一索引。SQL Server 从不考虑扫描,且实际的值从不以任何方式更改计划。此类查询对自动参数化是安全的。

如果查询与自动参数化模板匹配,则 SQL Server 将文本实际替换为参数标记(例如,@p1、@p2),这便是发送到服务器中的内容,看起来像是一个 sp_executesql 调用。如果查询被 SQL Server 确定为对自动参数化不安全,客户端将以特殊 SQL 的形式向 SQL Server 发送 SQL 文本。

图 11 显示了客户端向 SQL Server 发送请求时的处理流程。

 

图 11:处理客户端的 SQL

编译
现在,我们将更详细一点地了解编译和优化。在编译过程中,SQL Server 分析语句并创建称为序列树的事物;序列树是语句的内部表示形式。这是从 SQL Server 6.5 实际保留到 SQL Server 7.0 的几个数据结构之一。然后,序列树被标准化。标准化程序的主要功能是执行绑定。绑定包括验证表和列是否存在,以及加载有关表和列的元数据。此外,还将有关所需的(隐式)转换的信息添加到序列树,例如,如果查询试图将整数 10 添加到数值中,SQL Server 将向序列树中插入隐式转换。标准化还用视图的定义替换对该视图的引用。最后,标准化执行几个基于语法的优化。如果语句是典型的 SQL 语句,SQL Server 将从序列树中提取有关查询的信息并创建一个名为查询图形的特殊结构,建立该结构是为了使优化程序可以非常有效地对其进行处理。然后,对查询图形进行优化并产生计划。

图 12 显示了编译过程的流程。

 

图 12:编译

优化
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) 阶段。快速计划阶段扩展了对好计划的查找,以包括往往有利于有一定复杂性的查询的选择。快速计划检查可能计划的范围,并在完成检查后将最佳计划的开销与第二个阈值进行比较。同事务处理阶段一样,如果找到的计划的开销小于阈值,则暂停优化并使用该计划。通常情况下,如果 SQL Server 6.5 中的查询包含的计划在 SQL Server 7.0 中也是最佳计划,则该计划往往可由普通计划优化程序找到,或在基于开销优化的前两个阶段中的某个阶段找到。特意组织了规则以设法实现上述情况。计划可能由使用单个索引和使用嵌套循环联接组成。

优化的最后阶段称为完全优化,旨在为复杂以及非常复杂的查询生成一个好计划。对于复杂查询,快速计划生成的计划通常被认为比继续查找更好的计划所花费的开销大得多,因此将执行完全优化。完全优化中其实有两个单独的选项。如果快速计划阶段所能提供的最佳开销大于“并行化开销阈值”的配置值,并且如果服务器是多处理器计算机,那么优化程序的最后阶段将涉及查找一个能够在多处理器上并行运行的计划。如果快速计划阶段中的最佳计划的开销小于配置的“并行化开销阈值”,则优化程序将只考虑一个连续的计划。因为在最后阶段必须找到一个计划,所以完全优化阶段可能会穷举所有可能的计划,并且消耗大量的时间。优化程序仍然可能不对所有可能的计划进行检查,因为它将任何可能的计划开销与达到此优化阶段的开销进行比较,并估计继续尝试不同的优化可能需要的开销。在某个阶段,优化程序可能会决定使用当前计划比继续查找更好计划的开销低,在继续优化上支付额外的编译开销不是很划算。此最终阶段处理的查询种类通常是其计划只使用一次的查询,因此在后面的执行过程中重用该计划时,曾经为编译和优化付出的额外开销几乎不能得到偿还。那些后面的执行可能不会发生。

找到计划后,该计划成为优化程序的输出,然后在执行该计划前,SQL Server 将经过前面已探讨的所有缓存机制。应注意,如果完全优化阶段为该查询提供了并行计划,并不一定意味着计划将在多个处理器上执行。如果计算机非常繁忙,无法支持在多个 CPU 上运行单个查询,则计划将使用单个处理器。

图 13 显示了优化程序的处理流程。

 

图 13:优化

执行
查询处理的最后一步是执行。除这一小段外,我们不对执行进行更详细的探讨。执行引擎获取优化程序已生成的计划并执行它。在实际执行的同时,执行引擎为进程的运行安排线程,并提供线程间通讯。

摘要
正如前面提到的,SQL Server 的内部机理和结构是一个范围很广的主题,远不是这篇文章所能完全涵盖的。我们重点介绍了 SQL Server 如何与客户端交互以及 SQL Server 关系引擎如何处理来自客户端的请求。衷心希望,通过了解 SQL Server 如何处理查询以及如何和何时编译它们或重编译它们,您能够编写出更出色的应用程序,充分利用 SQL Server 7.0 的强大功能和先进技术。