应用索引技术优化SQL 语句(转)

原文出处

一、前言

很多数据库系统性能不理想是因为系统没有经过整体优化,存在大量性能低下的SQL 语句。这类SQL语句性能不好的首要原因是缺乏高效的索引。没有索引除了导致语句本身运行速度慢外,更是导致大量的磁盘读写操作,使得整个系统性能都受之影响而变差。解决这类系统的首要办法是优化这些没有索引或索引不够好的SQL语句。

本文讨论和索引相关的有关内容,以及通过分析语句的执行计划来讲述如何应用索引技术来优化SQL 语句。通过分析执行计划,读者可以检查索引是否有用,以及如何创建高效的索引。本文对数据库管理人员以及数据库系统开发人员都有一定参考意义。

如果读者不知道应该优化数据库系统的哪些SQL语句,那么建议读者参考笔者的另外一篇文章,《应用Profiler优化SQL Server数据库系统》。那篇文章介绍如何利用Profiler和Read80trace工具找出数据库系统中的关键的和频繁运行的SQL语句,你可以把精力花在这些最值得优化的SQL语句上面。

二、创建索引的关键

优化SQL语句的关键是尽可能减少语句的logical reads。这里说的logical reads是指语句执行时需要访问的单位为8K的数据页总数。logical reads 越少,其需要的内存和CPU时间也就越少,语句执行速度就越快。不言而喻,索引的最大好处是它可以极大减少SQL语句的logical reads数目,从而极大减少语句的执行时间。创建索引的关键是索引要能够大大减少语句的logical reads。一个索引好不好,主要看它减少的logical reads多不多。

运行set statistics io命令可以得到SQL语句的logical reads信息。举例如下:

在Query Analyzer 中运行如下的命令:

/***** Script 1 *****************************/

set statistics io on

select au_id,au_lname ,au_fname

from pubs..authors where au_lname ='Green'

set statistics io on

/********************************************/

输出结果如下:

au_id       au_lname                au_fname

----------- ---------------------------------------- --------------------

213-46-8915 Green                    Marjorie

(1 row(s) affected)

Table 'authors'. Scan count 1, logical reads 1, physical reads 0, read-ahead reads 0.

上面的logical reads 1就是指该Select语句的逻辑读总数是1。Logical reads 越少越好。如果Logical reads很大,而返回的行数很少,也即两者相差较大,那么往往意味者语句需要优化。比如语句没有索引,或索引不够好等。注意Logical reads和后面的physical reads的区别。Logical reads中包含该语句从内存数据缓冲区中访问的页数和从物理磁盘读取的页数。而physical reads表示那些没有驻留在内存缓冲区中需要从磁盘读取的数据页。Read-ahead reads是SQL Server为了提高性能而产生的预读。预读可能会多读取一些数据。 优化的时候我们主要关注Logical Reads就可以了。注意如果physical Reads或Read-ahead reads很大,那么往往意味着语句的执行时间(duration)里面会有一部分耗费在等待物理磁盘IO上。

二、单字段索引,组合索引和覆盖索引

顾名思义,单字段索引是指只有一个字段的索引,而组合索引指有多个字段构成的索引。

下面的例子讲述创建这些索引的一些技巧,以及如何结合执行计划判断SQL语句是否利用了索引。

1. 对出现在where子句中的字段加索引

先运行如下的语句创建示例所需要的表:

/**************Script 2************************************/

use tempdb

go

if exists (select * from dbo.sysobjects where id = object_id(N'[tbl1]') and OBJECTPROPERTY(id, N'IsUserTable') = 1)

drop table [tbl1]

GO

create table tbl1

(学生号 int,学生姓名 varchar(20),性别 char(2), 年龄 int,入学时间 datetime,备注 char(500))

go

declare @i int

set @i=0

declare  @j int

set @j=0

while @i<5000

begin

if (rand()*10>3) set @j=1 else set @j=0

insert into tbl1 values(@i,

char( rand()*10+100)+char( rand()*5+50)+char( rand()*3+100)+char( rand()*6+80),

@j, 20+rand()*10,convert(varchar(20), getdate()-rand()*3000,112),

char( rand()*9+100)+char( rand()*4+50)+char( rand()*2+130)+char( rand()*5+70))

set @i=@i+1

end

/**************************************************/

然后我们看如下的语句应该如何创建索引:

/********Script 3**********************************/

set statistics profile on

set statistics io on

go

select 学生姓名, 入学时间 from tbl1 where 学生号=972

go

set statistics profile off

set statistics io off

go

/****************************************************/

注意上面的set statistics profile命令将输出语句的执行计划。也许你会问,为什么不用SET SHOWPLAN_ALL呢?使用SET SHOWPLAN_ALL也是可以的。不过set statistics profile输出的是SQL 语句的运行时候真正使用的执行计划,而SET SHOWPLAN_ALL输出的是预计(Estimate)的执行计划。使用SET SHOWPLAN_ALL是后面的语句并不会真正运行。

上面script输出结果(部分)如下:

学生姓名                 入学时间

-------------------- ------------------------------------------------------

g4eQ                 2005-05-29 00:00:00.000

(1 row(s) affected)

Table 'tbl1'.Scan count 1,logical reads 385, physical reads 0,read-ahead reads 0.

Rows  Executes  StmtText

------------------------------------------------------------------------------

1     1         SELECT [学生姓名]=[学生姓名],[入学时间]=[入学时间] FROM [tbl1]

1     1           |--Table Scan(OBJECT:([tempdb].[dbo].[tbl1]), WHERE:([tbl1].

(2 row(s) affected)

从上面输出结果可以看到,这条语句执行时候使用了Table Scan,也就是对整个表进行了全表扫描。全表扫描的性能通常是很差的,要尽量避免。如果上面的select语句是数据库系统经常运行的关键语句, 那么应该对它创建相应的索引。创建索引的技巧之一是对经常出现在where条件中的字段创建索引。所以对上面的select语句,应该在学生号字段上建立单字段索引idx_学生号:

create nonclustered index idx_学生号 on tbl1(学生号)

然后再运行Script 3,部分结果如下:

Table 'tbl1'. Scan count 1, logical reads 3, physical reads 0, read-ahead reads 0.

Rows Executes StmtText

---------------------------------------------------------------------------------------------

1    1        SELECT [学生姓名]=[学生姓名],[入学时间]=[入学时间] FROM [tbl1] WHERE [学生号]=@

1    1          |--Bookmark Lookup(BOOKMARK:([Bmk1000]), OBJECT:([tempdb].[dbo].[tbl1]))

1    1               |--Index Seek(OBJECT:([tempdb].[dbo].[tbl1].[idx_学生号]), SEEK:([tbl1].

上面的结果显示我们刚刚创建的idx_学生号这个索引确实被使用到了。语句的logical reads极大减少,从没有索引前的385减少到3,Table Scan也变成了Index Seek,性能极大提高。从上面的例子可以知道,如果你在执行计划中看到Table Scan或聚集索引的Index Scan(聚集索引的Index Scan相当于Table Scan), 而且对应的logical reads相当大,那么就要设法使之变成Index seek。设法避免Table scan或Index scan是优化SQL 语句使用的常用技巧。通常Index Seek需要的logical reads比前两者要少得多。

2.组合索引

如果where语句中有多个字段,那么可以考虑创建组合索引。例子如下:

/*****Script 4******************************************/

set statistics profile on

set statistics io on

go

select学生姓名, 入学时间 from tbl1

where  入学时间>='20050301' and 入学时间<'20050305' and 年龄>24

go

set statistics profile off

set statistics io off

go

/*******************************************************/

为了提高该语句的性能,可以在入学时间和年龄上建立一个组合索引如下:

create nonclustered index idx_入学时间年龄 on tbl1(入学时间,年龄)

你也许会问,如果把入学时间和年龄字段换个位置建立如下的组合索引如何?

create nonclustered index idx_年龄入学时间 on tbl1(年龄,入学时间)

这个索引没有前面的好。分析这两个字段的唯一性:

select count(*) from tbl1 group by 入学时间

select count(*) from tbl1 group by 年龄

部分输出结果如下:

distinct_value_of 入学时间

(2426 row(s) affected)

distinct_value_of 年龄

(10 row(s) affected)

结果显示入学时间字段有2426个唯一值,而年龄字段只有10个。也就是说入学时间字段的唯一性比年龄字段高得多。对于上面的两个索引分别运行Script 4,你会发现对第一个索引语句的logical reads是8 而第二个索引导致的logical reads为16,相差了一倍。如果表很大那么性能的差异可想而知。所以,组合索引中字段的顺序是非常重要的,越是唯一的字段越是要靠前。另外,无论是组合索引还是单个列的索引,尽量不要选择那些唯一性很低的字段。比如说,在只有两个值0和1的字段上建立索引没有多大意义。

有时候你要决定为每个相关字段单独建立索引还是建立一个组合索引。比如说如果下面的语句经常执行:

Select c1, c2,c3 from tblname where c1='abc' and c2=3

Select c1, c3 from tblname where c1='b'

Select c1, c2 from tblname where c2=10

应该如何建立索引呢?这取决于各语句的比例。如果大部分语句总是根据c1和c2查询,那么一个组合索引(c1+c2)或者一个覆盖索引是非常有用的,然后多加一个单独对c3创建的索引。反之,如果第一个语句运行次数非常少,大部分语句是后面两种,那么当然要对c1和c2分别建立索引。你也许会问,对第一种语句,分别对c1和c2建立索引可以吗?可以。对某些语句SQL Server 可能会分别使用两个索引(即索引交叉技术)查询数据然后取其交集得到结果。但有时候SQL Server 未必会使用你建立的全部的单字段索引。所以如果对单字段进行索引,建议使用set statistics profile来验证索引确实被充分使用。logical reads越少的索引越好。

3.覆盖索引

对于script 4中的select语句,有没有更好的索引呢?有的。那就是使用覆盖索引(covered index)。覆盖索引能够使得语句不需要访问表仅仅访问索引就能够得到所有需要的数据。因为聚集索引叶子节点就是数据所以无所谓覆盖与否,所以覆盖索引主要是针对非聚集索引而言。不知大家注意到没有,我们前面讨论的执行计划中除了index seek外,还有一个Bookmark Lookup关键字。Bookmark Lookup表示语句在访问索引后还需要对表进行额外的Bookmark Lookup操作才能得到数据。也就是说为得到一行数据起码有两次IO,一次访问索引,一次访问基本表。如果语句返回的行数很多,那么Bookmark Lookup操作的开销是很大的。覆盖索引能够避免昂贵的Bookmark Lookup操作,减少IO的次数,提高语句的性能。

覆盖索引需要包含select子句和WHERE子句中出现的所有字段。Where语句中的字段在前面,select中的在后面。就script 5中的select语句而言,覆盖索引如下:

create nonclustered index idx_covered on tbl1(入学时间,年龄,学生姓名)

然后再运行script 4,输出结果如下:

Table 'tbl1'. Scan count 1, logical reads 2, physical reads 0, read-ahead reads 0.

Rows Executes StmtText

------------------------------------------------------------------------------------------------------

6  1  SELECT [学生姓名]=[学生姓名],[入学时间]=[入学时间] FROM [tbl1] WHERE [入学时间]>=@1 AND [入学时间]<@2

6  1   |--Index Seek(OBJECT:([tempdb].[dbo].[tbl1].[idx_covered]), SEEK:(([tbl1].[入学时间], [tbl1].[年龄])

比较一下上面的logical reads,是大大减少了。Bookmark Lookup操作也消失了。所以创建覆盖索引是减少logical reads提升语句性能的非常有用的优化技巧。

实际上索引的创建原则是比较复杂的。有时候你无法在索引中包含了Where子句中所有的字段。在考虑索引是否应该包含一个字段时,应考虑该字段在语句中的作用。比如说如果经常以某个字段作为where条件作精确匹配返回很少的行,那么就绝对值得为这个字段建立索引。再比如说,对那些非常唯一的字段如主键和外键,经常出现在group by,order by中的字段等等都值得创建索引。因篇幅有限,这里不再进行展开了。SQL Server的联机手册中有很好的相关内容,请读者自行参考。

四、分析执行计划创建索引

根据语句的执行计划来判断应该对什么表创建什么索引,是常用优化技巧。其实文章前面的例子已经告诉读者如何结合statistics profile 和statistics IO语句的输出来创建索引。这里分析一个稍微复杂一些的例子。

SQL语句如下:

SELECT CurrentseNo FROM v_ptdata_edss WHERE MRN = @P1

Statistics IO的输出如下:

Table 'ptseoutpat'. Scan count 2, logical reads 8, physical reads 0, read-ahead reads 0.

Table 'ptdata'. Scan count 1, logical reads 3218, physical reads 0, read-ahead reads 0.

部分执行计划如下:

Rows    Executes StmtText

------  -------- -----------------------------------------------------------------------------------------------

0       1        SELECT CurrentseNo FROM v_ptdata_edss WHERE MRN = @P1

0       1          |--Nested Loops(Inner Join, OUTER REFERENCES:([ptdata].[CurrentseNo]))

1       1               |--Bookmark Lookup(BOOKMARK:([Bmk1000]), OBJECT:([TTSH_Neon_ADT].[dbo].[ptdata]))

1       1               |    |--Filter(WHERE:(Convert([ptdata].[PatExtID])=[@P1]))

571955  1               |         |--Index Scan(OBJECT:([TTSH_Neon_ADT].[dbo].[ptdata].[PK_ptdata]))

0       1               |--Nested Loops(Inner Join, OUTER REFERENCES:([Expr1009], [Expr1010], [Expr1011]))

2       1                    |--Merge Interval

2       1                    |    |--Sort(TOP 2, ORDER BY:([Expr1012] DESC, [Expr1013] ASC, [Expr1009] ASC, [Exp

2       1                    |         |--Compute Scalar(DEFINE:([Expr1012]=4&[Expr1011]=4 AND NULL=[Expr1009],

2       1                    |              |--Concatenation

1       1                    |                   |--Compute Scalar(DEFINE:([Expr1006]=NULL, [Expr1007]=NULL, [Ex

1       1                    |                   |    |--Constant Scan

1       1                    |                   |--Compute Scalar(DEFINE:([Expr1009]='Jan  1 1900 12:00AM', [Ex

1       1                    |                        |--Constant Scan

0       2                    |--Index Seek(OBJECT:([TTSH_Neon_ADT].[dbo].[ptseoutpat].[ptseoutpat1]), SEEK:([pts

分析的关键是:

步骤1)找出最昂贵的表(也就是logical reads最多的表),是'ptdata' 表。

步骤2)从执行计划中找出对ptdata表的相应的操作,通常是左边行数最多的那一行如上图中的标志行。对表的操作是index scan操作。

步骤3)根据操作判断如何创建index或如何改写语句。从执行计划中我们看到index scan之后的操作也就是下面的filter操作把数据大大减少了:

Filter(WHERE:(Convert([ptdata].[PatExtID])=[@P1]))

一般情况下,对这个字段建立索引问题就解决了。但对我们的例子语句而言还不够。实际上PatExtID字段已经有索引了。那么为什么用index scan而不用index seek呢? 后来发现原因是传递的参数@P1和表字段PatExtID的类型是不一致的。@P1是nvarchar类型,而PatExtID是varchar类型。这导致了SQL Server 产生了对索引字段进行index scan的Convert操作。解决方法很简单,把传递的参数改成varchar或把表字段类型改成nvarchar,使得它们类型一致就可以了。

五.语句的写法影响SQL Server 能否利用索引

仅仅有索引是不够的。语句的写法会影响SQL Server 对索引的选择。比如下面的语句:

select  学生姓名, 入学时间 from tbl1 where DATEDIFF(mm,'20050301',入学时间)=1

理所当然,需要在入学时间字段上建立索引:

create nonclustered index idx_入学时间 on tbl1(入学时间)

然后运行如下script 5看看该索引是否有用:

/******Script 5***********************************/

set statistics profile on

set statistics io on

go

select  学生姓名, 入学时间 from tbl1 where DATEDIFF(mm,'20050301',入学时间)=1

go

set statistics profile off

set statistics io off

/*************************************************/

语句的部分输出如下:

Table 'tbl1'. Scan count 1, logical reads 385, physical reads 0, read-ahead reads 0.

Rows  Executes    StmtText

----------- ----------- ----------------------------------------------------------------------

56    1          select  学生姓名, 入学时间 from tbl1 where DATEDIFF(mm,'20050301',入学

56    1             |--Table Scan(OBJECT:([tempdb].[dbo].[tbl1]), WHERE:(datediff(month,

不幸的是,是Table Scan,刚建立的索引并没有被使用。这是因为WHERE语句中的DATEDIFF函数引起的。因为函数作用在索引字段上, SQL Server 无法直接利用索引定位数据,必须对该字段所有的值运算该函数才能得知函数结果是否满足where条件。在这种情况下,Table Scan是最好的选择。为了使用索引���可以把语句改成如下的样子:

select  学生姓名, 入学时间  from tbl1

where 入学时间>='20050401' and 入学时间<'20050501'

把该语句替换script 5中select语句然后运行该script,结果如下:

Table 'tbl1'. Scan count 1, logical reads 58, physical reads 0, read-ahead reads 0.

Rows Executes StmtText

-----------------------------------------------------------------------------------------

56   1  SELECT [学生姓名]=[学生姓名],[入学时间]=[入学时间] FROM [tbl1] WHERE [入学时间]>=

56   1    |--Bookmark Lookup(BOOKMARK:([Bmk1000]), OBJECT:([tempdb].[dbo].[tbl1]) WITH PR

56   1         |--Index Seek(OBJECT:([tempdb].[dbo].[tbl1].[idx_入学时间]), SEEK:([tbl1].

可以看到Table Scan变成了Index seek, Logical Reads 也减少到58。从上面的例子可以知道,为了利用索引,不要对where语句中的字段直接使用各种函数或表达式。要尽量把函数或表达式放在操作符的右边。

再多举一些例子,下面的where语句写法是不好的:

Where substring(colum1,1,4)>'ddd'

Where convert(varchar(200),column1)>'aaa'

如果你实在无法避免上面的情况,而相关的语句又是数据库系统的关键语句,那么建议你从系统设计的高度来考虑问题。比方说,改变表的结构等,使得不再需要在where子句中的字段上直接使用函数或表达式等。

使用前置百分号或不等号也是不好的Where写法:

Where column1 like ‘%abc%’

Where column1 <> 'bb'

第一个where语句中因为第一个百分号会导致SQL Server 进行索引扫描(index scan)或Table Scan。要尽量不使用前置百分号。比方说改成如下的语句就会好得多:

Where column1 like ‘abc%’

再多看一个例子:

Where column1 =2 OR column2=30

这个where语句中如果column1 和column2中任何一个字段没有索引,那么整条语句就会导致全表扫描。(想一想为什么?)所以在有OR的where语句要特别注意OR两边的字段都要有必要的索引。

六、有关索引的几个问题

问题1,是否值得在identity字段上建立聚集索引。答案取决于identity 字段如何在语句中使用。如果你经常根据该字段搜索返回很少的行,那么在其上建立索引是值得的。反之如果identity字段根本很少在语句中使用,那么就不应该对其建立任何索引。

问题2,一个表应该建立多少索引合适。如果表的80%以上的语句都是读操作,那么索引可以多些。但是不要太多。特别是不要对那些更新频繁的表其建立很多的索引。很少表有超过5个以上的索引。过多的索引不但增加其占用的磁盘空间,也增加了SQL Server 维护索引的开销。

问题4:为什么SQL Server 在执行计划中没有使用你认为应该使用的索引?原因是多样的。一种原因是该语句返回的结果超过了表的20%数据,使得SQL Server 认为scan比seek更有效。

另一种原因可能是表字段的statistics过期了,不能准确反映数据的分布情况。你可以使用命令UPDATE STATISTICS tablename with FULLSCAN来更新它。只有同步的准确的statistics才能保证SQL Server 产生正确的执行计划。过时的老的statistics常会导致SQL Server生成不够优化的甚至愚蠢的执行计划。所以如果你的表频繁更新,而你又觉得和之相关的SQL语句运行缓慢,不妨试试UPDATE STATISTIC with FULLSCAN 语句。

你甚至可以使用Index hint比较不同索引的性能差异。比如对上面script 4提到的两个索引,可以这样比较,

select 学生姓名, 入学时间 from tbl1 with (index= idx_年龄入学时间)

where  ……

或者:

select 学生姓名, 入学时间 from tbl1 with (index= idx_入学时间年龄)

where  ……

如果强制使用你的索引后logical reads大大减少,那么就需要进一步研究为什么SQL Server 不使用正确的索引。注意,不要总是将使用索引等同于好的性能,反之亦然。SQL Server只在索引能提高性能时才使用索引检索。有时候使用Table scan的性能比使用某个索引反而要好。

问题5、什么使用聚集索引,什么时候使用非聚集索引

在SQL Server 中索引有聚集索引和非聚集索引两种。它们的主要差别是前者的索引叶子就是数据本身,而后者的叶子节点包含的是指向数据的书签(即数据行号或聚集索引的key)。

在上面的例子中我全部使用非聚集索引,原因是对一个表而言聚集索引只能有一个,而非聚集索引可以有多个。如果你把上面例子中的非聚集索引换成聚集索引,效果也是类似的,只是聚集索引没有Bookmark Lookup操作。什么时候应该使用聚集索引,什么时候使用非聚集索引取决于应用程序的访问模式。我的建议是在那些关键的字段上使用聚集索引。一个表一般都需要建立一个聚集索引。对于什么时候使用聚集索引,SQL Server 2000联机手册中有如下描述:

在创建聚集索引之前,应先了解您的数据是如何被访问的。可考虑将聚集索引用于:

  • 包含大量非重复值的列。
  • 使用下列运算符返回一个范围值的查询:BETWEEN、>、>=、< 和 <=。
  • 被连续访问的列。
  • 返回大型结果集的查询。
  • 经常被使用联接或 GROUP BY 子句的查询访问的列;一般来说,这些是外键列。对 ORDER BY 或 GROUP BY 子句中指定的列进行索引,可以使 SQL Server 不必对数据进行排序,因为这些行已经排序。这样可以提高查询性能。
  • OLTP 类型的应用程序,这些程序要求进行非常快速的单行查找(一般通过主键)。应在主键上创建聚集索引。

聚集索引不适用于:

  • 频繁更改的列

这将导致整行移动(因为 SQL Server 必须按物理顺序保留行中的数据值)。这一点要特别注意,因为在大数据量事务处理系统中数据是易失的。

  • 宽键

来自聚集索引的键值由所有非聚集索引作为查找键使用,因此存储在每个非聚集索引的叶条目内。

七、结束语

如何使一个性能缓慢的系统运行更快更高效,不但需要整体分析数据库系统,找出系统的性能瓶颈,更需要优化数据库系统发出的SQL 语句。一旦找出关键的SQL 语句并加与优化,性能问题就会迎刃而解。读完本文,你应该知道创建索引的关键是什么,以及如何分析SQL语句的执行计划来创建索引。在优化了索引后大部分数据库系统的性能都能够得到不同程度的改善,有的系统甚至能够获得好几倍以上的性能提升。本文并不能解决你在优化SQL语句中碰到的所有问题,但其中讨论的内容或技巧对许多性能问题都有一定的参考意义。

上一篇:TCP的那些事儿(上)


下一篇:js点击事件防止用户重复点击执行