由于我从 Sales.Customer 表中返回的只是 CustomerID 列,而此列又是 Sales.SalesOrderHeader 表中的外键,因此,我能够使用以下代码从查询中完全消除生成的 Customer表,并且不必更改查询所生成的逻辑意义或结果:
SELECT oh.CustomerID, SUM(LineTotal)
FROM Sales.SalesOrderDetail od JOIN Sales.SalesOrderHeader oh
ON od.SalesOrderID=oh.SalesOrderID
GROUP BY oh.CustomerID
这样就产生了另一个执行计划,如图 4所示。
图 4从查询中去除了 Customer 表的执行计划 (单击该图像获得较小视图)
以下两个操作被完全消除:Customer 表的聚集索引扫描以及 Customer 与 SalesOrderHeader 之间的合并联接,并且哈希匹配联接将由更高效的合并联接取代。但是,为在 SalesOrderHeader 和 SalesOrderDetail 表之间使用“合并联接”,必须返回经过联接列 SalesOrderID排序的两个表中的所有行。为实现此目的,查询优化器决定对 SalesOrderHeader表执行聚集索引扫描,而不使用非聚集索引扫描,这样在涉及的 I/O方面开销较低。此示例很好地说明了查询优化器的实际工作原理:由于更改执行联接操作的物理方式所节省的开销要比聚集索引扫描生成的附加 I/O开销多,因此,查询优化器选择得出的运算符组合,因为它产生的总估计执行开销可能会是最低的。在我的便携式计算机上,尽管逻辑读取数量上升了(已升至1,941),但所耗费的 CPU 时间实际上却少了,并且此查询的估计执行开销也下降了大约 13% (2,89548)。
假设我希望进一步改进此查询的性能。现在我要看一下 SalesOrderHeader表的聚集索引扫描,此运算符已成为该执行计划中开销最大的运算符了。由于我只需要使用此表中的两列来填充查询,因此我可以创建一个只包含这两列的非聚集索引,这样,即可通过对小得多的非聚集索引进行扫描来替代对整个表的扫描。索引定义可能会如下所示:
CREATE INDEX IDX_OrderDetail_OrderID_TotalLine
ON Sales.SalesOrderDetail (SalesOrderID) INCLUDE (LineTotal)
请注意,我创建的索引包含一个计算列。并不一定总会这样,具体情况取决于对计算列的定义。
创建此索引并执行同样的查询后,我得到一个新的执行计划,如图 5所示。
我在 SalesOrderDetail上创建的索引是所谓的“覆盖索引”的一个示例。它是一个包含填充查询所需的所有列的非聚集索引,无需使用表扫描运算符或聚集索引扫描运算符扫描整个表。此索引实际上是表的一个小型副本,包含该表中所有列的一个子集。只有需要响应查询的那些列才包含在该索引中,换句话说,该索引仅包含需要“覆盖”查询的列。
为最常执行的查询创建覆盖索引是查询优化中使用的最简单最常见的一种技术。当表中包含很多列,但查询最常引用的只有很少几列时,此类索引尤其适用。通过创建一个或多个覆盖索引,可以大大改进受影响的查询的性能,因为这些索引要访问的数据量小得多,从而使产生的 I/O开销更少。但在执行数据修改操作(插入、更新和删除)期间,还存在维护附加索引所需的隐含开销。您应根据您的环境以及 SELECT查询与数据修改之间的比例,仔细判断此附加索引的维护开销相对于查询性能的改进是否值得。
不必害怕创建多列索引(相对于单列索引而言)。多列索引往往比单列索引有用得多,查询优化器更有可能使用它们来覆盖查询。大多数的覆盖索引都是多列索引。
在我的示例查询中,仍然有改进的余地,将覆盖索引置于 SalesOrderHeader表中可以进一步优化此查询。这将消除聚集索引扫描,而采用非聚集索引扫描。我将此留给您进行练习。请尝试给出索引定义 —要成为此查询的覆盖索引,应包含哪些列,以及索引定义中的列顺序对性能是否有不同影响。要查看解答,请参见“客户订单查询练习”边栏。
索引视图
如果我的示例查询的性能非常重要,我可以多执行一个步骤来创建可实际存储查询的具体结果的索引视图。索引视图存在某些先决条件和限制,但如果能使用索引视图,可以显著改善性能。请记住,与标准索引相比,索引视图需要的维护开销较高。因此,应仔细斟酌在何时使用索引视图。在本例中,索引定义如下所示:
CREATE VIEW vTotalCustomerOrders
WITH SCHEMABINDING
AS
SELECT oh.CustomerID, SUM(LineTotal) AS OrdersTotalAmt, COUNT_BIG(*) AS TotalOrderLines
FROM Sales.SalesOrderDetail od
JOIN Sales.SalesOrderHeader oh
ON od.SalesOrderID=oh.SalesOrderID
GROUP BY oh.CustomerID
请注意 WITH SCHEMABINDING 选项,此选项是在此类视图上创建索引的先决条件;还要注意 COUNT_BIG(*) 函数,如果我们的索引定义中包含聚合函数(在此示例中是 SUM),则此函数是必需的。创建此视图后,可以在其上创建一个索引,如下所示:
CREATE UNIQUE CLUSTERED INDEX CIX_vTotalCustomerOrders_CustomerID
ON vTotalCustomerOrders(CustomerID)
创建此索引时,包含在视图定义中的查询结果已具体化,并以索引形式实际存储在磁盘中。请注意,对基础表执行的所有数据修改操作接着会根据其定义自动更新视图中的值。
如果我现在返回查询,会产生什么结果取决于我运行的 SQL Server 版本。在 Enterprise Edition 或 Developer Edition 中,查询优化器会自动将此查询与索引视图定义匹配,并使用索引视图,而不是查询所涉及的基表。图 6显示了本示例中生成的执行计划。此计划仅由一个操作组成:对我在视图上创建的索引进行聚集索引扫描。估计执行开销仅为 0,09023,并且只执行 92 次逻辑读取。
图 6使用索引视图时的执行计划 (单击该图像获得较小视图)
在 SQL Server 的其他版本中,仍然可以创建并使用此索引视图,但为达到同样的效果,必须使用 NOEXPAND 提示将查询更改为直接参考该视图,如下所示:
SELECT CustomerID, OrdersTotalAmt
FROM vTotalCustomerOrders WITH (NOEXPAND)
您可以看到,如果使用得当,索引视图可以成为一项非常强大的功能。它们在优化对大量数据进行聚合的查询时最为有用。如果用在 Enterprise Edition 中,这些视图对许多查询都有用,并且不要求更改代码。 识别要优化的查询
我怎样识别值得优化的查询?我要查找执行得最为频繁的查询,单独执行这些查询的开销可能并不高,但执行这些查询的总计开销可能要比很少执行的大型查询的开销高得多。我并不是说您不应该优化大型查询,只是觉得应先关注最常执行的查询。那么,您如何识别这些查询呢?
遗憾的是,最可靠的方法有点复杂,涉及到跟踪对您的服务器执行的所有查询,然后根据其签名进行分组(即,带有实际参数值的查询文本将由占位符取代,以识别相同的查询类型,即便查询是使用不同的参数值执行的也一样)。这是一个复杂的过程,因为查询签名很难生成。Itzik Ben-Gan在其《Microsoft SQL Server 2005: T-SQL Querying》一书中介绍了一种解决方案,此解决方案使用 CLR用户定义的函数和正则表达式。
还有一种比较简单的方法,但可靠性较差。您可以依赖在执行计划缓存中为所有查询保留的统计数据,并使用动态管理视图查询这些数据。图 7中包含一个示例查询,此示例向您显示缓存中累计逻辑读取次数最高的 20个查询的文本和执行计划。对于快速识别会产生最高逻辑读取次数的查询而言,此查询非常便利,但也存在一些限制。即,此查询只显示在运行查询时缓存了其计划的那些查询。如果有未缓存的内容,这些内容就会丢失。
识别这些拙劣的查询后,您可以查看其查询计划,并利用我在本文中介绍的一些索引技术来寻找改进其性能的方法。如果能够成功完成,表示您的时间没有白费。
快乐优化!
Maciej Pilecki是 Solid Quality Mentors 的准顾问,这是一家专门致力于培训、指导和咨询的全球性组织。他是 Microsoft 认证培训师 (MCT) 和 SQL Server 最有价值专家 (MVP),并经常讲授关于 SQL Server 和应用程序开发的诸多方面的课程并在讨论此类内容的会议上讲话。