一颗蔬菜

我虽是只猫却也常常思考

博主注:本文并非博主原创,搬运自 APGC DSD Team January 10, 2011 。感谢APGC DSD Team的创作,给予读者学习的机会。

一、引言

一个凸现在很多开发者或数据库管理员面前的问题是数据库系统的性能问题。性能不理想的系统中除了一部分是因为应用程序的负载确实超过了服务器的实际处理能力外,更多的是因为系统存在大量的SQL语句需要优化。本文通过一个例子讲述如何应用简化技术来优化SQL 语句,也讨论在系统设计阶段应该考虑的和语句性能密切相关的一些问题。

如果读者不知道应该优化数据库系统的哪些SQL语句,那么建议读者参考笔者的另外一篇文章,《应用Profiler优化SQL Server数据库系统》。本文不讨论索引,建议读者参考笔者的文章《应用索引技术优化SQL语句》,因为索引技术是优化SQL语句的重要部分。

二、简化SQL语句

1.简化的原因

SQL语句越简单越好。语句越复杂,其执行性能就越不可预知。这里先说一下SQL Server 的查询优化器。SQL Server 查询优化器是基于成本的优化器。查询优化器分析可能的执行计划并选择一个估计成本最低的计划。对简单的语句而言,查询优化器会很快找到一个高效的执行计划如trivial plan或quick plan来执行语句。这是很理想的。因为对简单的执行计划而言SQL Server几乎不用耗费多少资源在它的生成上面。因为简单的缘故,这样的计划几乎都是最优的执行方式。

博主:执行计划的生成、分析都是需要消耗资源的,我等小白从未考虑过这个。

对那些复杂语句,其可能有上千个不同的执行计划。在这个情况下,查询优化器不会分析所有可能的组合,而是使用复杂算法找到一个成本与理论上的最小值相当接近的执行计划。语句全面优化是非常耗费资源的。语句越复杂,SQL Server越有可能在寻找最优计划的中途停下来,直接使用已经比较过的较优的一个计划来执行语句。如果寻找最优执行计划的时间和语句执行的时间差不多,那还不如直接执行语句。所以SQL Server产生的执行计划未必就是最优的执行计划。基于查询优化器的这种特性,为了获得稳定的执行性能,SQL语句越简单越好。对复杂的SQL语句,要设法对之进行简化。

博主:

“如果寻找最优执行计划的时间和语句执行的时间差不多,那还不如直接执行语句。所以SQL Server产生的执行计划未必就是最优的执行计划。”

确实如此,如果优化器能解决一切问题,就没有写这篇文章的必要了。

2.简化的手段

简化的手段多种多样。在系统规划阶段就必须考虑如何避免复杂查询。一个不好的设计会使你不得不在无数表之间进行多次交叉连接才能得到数据,这大大降低了性能。常见的简化规则如下:

1)不要有超过5个以上的表连接(JOIN)

2)考虑使用临时表或表变量存放中间结果。

3)少用子查询

4)视图嵌套不要过深

连接的表越多,其编译的时间和连接的开销也越大,性能越不好控制。最好是把连接拆开成较小的几个部分逐个顺序执行。优先执行那些能够大量减少结果的连接。拆分的好处不仅仅是减少SQL Server优化的时间,更使得SQL语句能够以你可以预测的方式和顺序执行。SQL Server的查询优化器不是十全十美的。使用这样的简化技术可以避免SQL Server优化器产生不是最优的执行计划。如果一定需要连接很多表才能得到数据,那么很可能意味着设计上的缺陷。

不要有过深的视图嵌套。我就曾经看到有个系统,建立了好多视图,视图里面是复杂的语句,然后在视图的上面再建立视图,系统的视图嵌套有四层之多。我并不是反对使用视图,只是视图越多,语句展开后就越复杂,优化起来就越难。一般视图嵌套不要超过2个为宜。

使用临时表或表变量存放中间结果的目标也是简化SQL语句,使得其执行顺序和执行方式得到控制。这个技术在某些时候会产生戏剧化的效果。

3.简化的例子

可以从分析SQL语句的执行计划开始进行简化。从执行计划中常可以发现非常有价值的信息。充分利用这些信息可以在优化的时候做到事半功倍。让我们看个例子。

我们有如下的SQL语句:

 select t1.PID 'PR Number',rsdt 'Request date',per.ename 'Requestor',ped.ename 'Deliver to',dest 'Destination', pcat.cdesc 'Category','Claimable'= Case Claim When '1' Then 'Yes' else 'No' end,'NRE'= case nre WHEN '1' THEN 'Yes' else 'No' End,'PMCal' = case PmCal when '1' then 'Yes' else 'No' End, 'Cap Reld' = case caprel WHEN '1' then 'Yes' else 'No' End, 'Type' = Pt.TDesc, 'Section' = PSec.SectDesc, 
tr(t1.prvalue,15,4) 'PR Value', d.vndid 'Vendor Code', t1.status, pes.ename 'PR is/with'

from PrMer t1 

Left outer join P_view per on per.ecode = t1.reqid And per.CMpcode = t1.reqidCMpno

Left outer join P_view ped on ped.ecode = t1.dlyid And ped.CMpcode = t1.dlyidCMpno 

Left outer join P_view pes on pes.ecode = t1.status And pes.CMpcode = t1.statusCMpno 

Left outer join PRcg pcat on pcat.catid = t1.catid And pcat.catidCMpno = t1.catidCMpno 

Left outer Join PRte Pt on Pt.Typeid = t1.Type 

Left outer Join PRst PSec on PSec.SectPRCode = t1.BuRelated 

left outer join PRdtl d on t1.PID = d.PID and t1.CMpno = d.CMpno and d.itmno = '1' 
where
( t1.type = '1')  and
(
t1.reqid = '22101' and t1.reqidCMpno = 'P'
or (
   t1.PID in 
   ( select distinct(PID) from Pra1 where apPID = '22101' and apPIDCMpno = 'P' ) 
   and ( t1.CMpno in 
   ( select CMpno from Pra1 where apPID = '22101' and apPIDCMpno = 'P'))
  )
)
and 
t1.PID like '%/0%'

or t1.PID like '%/1%'

or t1.PID like '%/2%'

or t1.PID like '%/3%'

or t1.PID like '%/4%'

or t1.PID like '%/5%'

or t1.PID like '%/6%'

or t1.PID like '%/7%'

or t1.PID like '%/8%'

or t1.PID like '%/9%' 
order by t1.PID

Table 'Pra1'. Scan count 2, logical reads 13522, physical reads 5, read-ahead reads 13631.

Table 'Worktable'. Scan count 178595, logical reads 1114272, physical reads 0, read-ahead reads 0.

Table 'PrCM'. Scan count 1, logical reads 2986, physical reads 2, read-ahead reads 2999.

Table 'Pre2'. Scan count 3, logical reads 1659, physical reads 13, read-ahead reads 369.

Table 'Gb_mp'. Scan count 3, logical reads 5496, physical reads 0, read-ahead reads 1834.

Table 'Gb_ml'. Scan count 3, logical reads 81, physical reads 0, read-ahead reads 27.

Table 'PRcg'. Scan count 1, logical reads 4, physical reads 2, read-ahead reads 2.

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

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

Table 'PRdtl'. Scan count 1, logical reads 9904, physical reads 3, read-ahead reads 9947.

相应的执行计划(部分)如下:

77    1   |--Filter(WHERE:(((((((((like([t1].[PrId], '%/1%', NULL) OR like([t1].[PrId], '%

89668  1     |--Nested Loops(Left Semi Join, WHERE:(((((((((((like([t1].[PrId], '%/1%',

89668  1        |--Sort(ORDER BY:([t1].[CompNo] ASC))                

89668  1        |  |--Merge Join(Left Semi Join, MANY-TO-MANY MERGE:([t1].[PrId])=([

89668  1        |     |--Filter(WHERE:(((((((((([t1].[Type]='1' AND like([t1].[PrI

121820  1        |     |  |--Clustered Index Scan(OBJECT:([PR].[dbo].[PrCM].[PK_P

1131725 1        |     |--Clustered Index Scan(OBJECT:([PR].[dbo].[Pra1].[PK_PrApp

89591  89591      |--Row Count Spool                          

1    1          |--Filter(WHERE:([Pra1].[ApprIdCompno]='P'))          

1    1             |--Bookmark Lookup(BOOKMARK:([Bmk1025]), OBJECT:([PR].[dbo].

26    1               |--Index Seek(OBJECT:([PR].[dbo].[Pra1].[idx_PrApprova

先不说执行计划如何。光从语句本身本我发现了以下这些问题:

1)连接JOIN太多,有7个之多,还不包括视图里面可能包含的连接。要设法减少连接的个数。

2)连接是outer join,非常不好。因为outer join意味着必须对左表或右表查询所有行。如果表很大而没有相应的where语句,那么outer join很容易导致table scan或index scan。要尽量使用inner join避免scan整个表。

博主:

inner join为什么不用scan整个表呢?不管是outer join还是inner join不都是需要逐行匹配吗?

3)不相关子查询的使用有待斟酌。

4)Like语句使用了前置百分号,而Or子句大大增加了使用scan表的几率。

再看看statistics IO的输出,根据logical reads判断那些是最昂贵的表。一个是Pra1,logical reads 13522,,比较大。另一个是worktable,logical reads是1114272。Logical reads高的表应该优先优化。再来看执行计划。我从计划中发现了如下的问题:

博主:

Worktable:
是sqlserver引擎需要使用中间数据时,临时建立的中间表,进行相关的逻辑操作。
Worktable被用于group by、order by、union、count、游标、连接等操作时,需保存中间对象而使用的表。

例:
order by [列]: [列]信息上未建相应的索引,此时sql引擎需借助workTable结果对中间结果进行缓存,然后排序,输出结果。
workTable表是一种系统临时表,生成的结果被保存在tempdb数据库中,使用完毕后,会被释放掉

逻辑读( Logical Reads )表示 CPU 需要的数据在内存中被找到,数据被直接从内存中传入 CPU 执行,即逻辑读是从内存中读取。

1)那些like语句对应的PID 字段的index果然没有使用,而是在全部数据查询出来后再作Filter,所以这些like完全不能减少IO。

2)使用了Row Count Spool,这导致了worktable大量的logical reads。 SQL Server使用row count spool来存放中间结果,这个spool对应tempdb中的worktable。如果再仔细一点,你会发现,这个spool是因为SQL Server对一个小表进行了89591次nest loop scan所导致的。优化的关键是要避免这么多次的loop scan。

3)有两处使用了clustered index scan。Clustered index scan相当于全表的table scan。估计是语句的where语句条件不够强或索引不够好所致。

4) 一个最关键的地方是,返回的行数是77行,但logical reads却有上百万。我们前面说过,如果结果行数和statistics IO的输出相差太大,那么意味着某个地方缺少优化。究竟是什么地方呢?是LIKE语句。这是因为只有like语句的那个Filter才大大减少了返回的行数,但是like语句却因为前置百分号而无法使用索引。

博主:

之前看到很多文章说使用模糊查询会导致全表扫描,而几乎没有人提起其原因是无法使用索引。

根据上面的分析,可以得出如下的优化建议:

1)使用临时表存放t1表的结果,共77行。还记得吗,能大大减少logical reads(或返回行数)的操作要优先执行。所以我们要首先试图得到这77行数据。 仔细分析语句,你会发现where中的条件全是针对表t1的,所以直接使用上面的where子句查询表t1,然后把结果存放再临时表#t1中:

Select t1….. into #tt1 from t1 where…(和上面的where一样)

2)再把#tt1和其他表进行连接:

 Select #t1…

Left outer join …

Left outer join…

还记得拆分语句的好处吗?语句现在以我们能够预测的顺序和方式执行了。

3)修改程序,去掉前置百分号。

4)从系统设计的角度修改语句,去掉outer join。

5)考虑组合索引或覆盖索引消除clustered index scan。

上面1和2点建议立即消除了worktable,性能提高了几倍以上,效果非常明显。

三、优化SQL语句的系统设计原则

仅仅简化还是不够。SQL语句的优化在系统的设计阶段就要通盘考虑。系统设计越合理,需要优化或后期返工的地方就越少。系统逻辑流程如果不合理,那么常会导致本来需要一个操作就可以解决的问题却要作好几个操作才能实现,反映在数据库上就是发出过多或过复杂的SQL语句 。所以数据库系统设计是系统高性能运行的首要关键。

很多系统开发者更关心功能的实现而不是数据库系统的整体运行性能。相当多的开发者对数据库性能优化技术不是很了解,写出来的SQL语句往往缺乏效率。下面让我们罗列一些在设计阶段就应该考虑的和性能密切相关的一些原则。

1)限制结果集

要尽量减少返回的结果行,包括行数和字段列数。返回的结果越大,意味着相应的SQL语句的logical reads 就越大,对服务器的性能影响就越甚。一个很不好的设计就是返回表的所有数据:

Select * from tablename

即使表很小也会导致并发问题。更坏的情况是,如果表有上百万行的话,那后果将是灾难性的。它不但可能带来极重的磁盘IO,更有可能把数据库缓冲区中的其他缓存数据挤出,使得这些数据下次必须再从磁盘读取。没有索引能够优化没有where子句的语句。在这样的语句运行的时候,大量别的小语句会出现超时或缓慢的现象。一个系统只要有几条这样的大语句不定时运行,你几乎肯定会注意到系统性能的不稳定性。所以,必须设计良好的SQL语句,使得其有where语句或TOP语句来限制结果集大小。它通常应该是这个样子的:

Select col1,col2,……from table1 where colx=… and coly=…

没有where语句,或不能预知where语句会返回多少行,是开发者常忽略的地方。程序测试的时候是没有问题的,因为那个时候表的数据还少,不能暴露性能问题。但随着程序部署到实际环境当中,表数据越来越多,问题就会越来越突出。一个稳定优秀的系统应该能够考虑到数据的增长而预知SQL语句会返回多少数据,进而作相应的处理。如果你实在不能知道SQL语句会返回多少数据,那么可以使用TOP n来限制结果集,比如:

Select TOP 100 col1,col2,……from table1 where colx=… and coly=…

其中n不要过大。我就看到有系统采用n=20000的做法,似乎n大了点。你得想一想,我的程序确实需要返回这么多的数据吗?程序的使用者会看这么多的数据吗?

如果语句结果确实很多,可以考虑将结果集进行分页。分页是限制结果集的一种有效手段。比如说,先使用TOP n方式返回头100条数据。只有用户点击下一页的时候才再发出查询获取下100行。

2)合理的表设计

在表的设计中,比较关键的问题是如何处理表的历史数据。表数据会越来越大。你一定需要考虑表的数据增长问题。比如预先考虑一天,一个星期,或一个月内表的数据变化。常见的做法是安排作业定时把表的数据导出到别处,使得数据库保持一定的大小,从而获得稳定一致的性能。也有的系统根据时间设计表,比如说根据月份设计表,如2005一月表,2005二月表,2006三月表等。这样做的好处是每个月表的大小基本稳定一致,性能也可以保证。不好的地方是管理复杂些,程序也要设计成能够根据时间访问不同的月份表。

一个非常令人兴奋的消息是SQL Server 2005将支持表分区技术。利用表分区技术可以实现数据表的流动窗口功能。在流动窗口中可以轻易的把历史数据移出,把新的数据加入,从而使表的大小基本保持稳定。

另外,表的设计未必需要非常范式化。有一定的字段冗余可以增加SQL语句的效率,减少JOIN的数目,提高语句的执行速度。

3)OLAP和OLTP模块要分开

OLAP和OLTP类型的语句是截然不同的。前者往往需要扫描整个表做统计分析,索引对这样的语句几乎没有多少用处。索引只能够加快那些如sum,group by之类的聚合运算。因为这个原因,几乎很难对OLAP类型的SQL语句进行优化。而OLTP语句则只需要访问表的很小一部分数据,而且这些数据往往可以从内存缓存中得到。为了避免OLAP 和OLTP语句相互影响,这两类模块需要分开运行在不同服务器上。因为OLAP语句几乎都是读取数据,没有更新和写入操作,所以一个好的经验是配置一台standby 服务器,然后OLAP只访问standby服务器。

常常有客户咨询我说数据库系统一到月底或一个月的某个时段就变得很慢。你猜猜为什么呢?原来月底是系统生成OLAP报表的时候。报表意味着对几乎全部表数据进行扫描统计,服务器负担自然很重,系统当然比平时慢。我听说某些ERP系统生成一个报表需要好几个小时呢。

博主在工作中跑过最时间最长的SQL大概在1个小时左右。在一次比赛中,由于比赛的技术支持方不太给力,写出来的简单sql都跑不动,大概也就是200人同时使用他们公司的服务器打比赛。

4)使用存储过程

可以考虑使用存储过程封装那些复杂的SQL语句或商业逻辑,这样做有几个好处。一是存储过程的执行计划可以被缓存在内存中较长时间,减少了重新编译的时间。二是存储过程减少了客户端和服务器的繁复交互。三是如果程序发布后需要做某些改变你可以直接修改存储过程而不用修改程序,避免需要重新安装部署程序。

四、结束语

读完本文,你应该知道简化SQL语句的技术以及系统设计要考虑到的一些原则。应用这些技术能够提高数据库系统的整体性能。数据库系统优化是个很大的话题,本文只是罗列一些有用的经验,更多的需要你的实践。

原文入口

发表评论

正在加载 Emoji