【转】记一次SQLServer的分页优化兼谈谈使用Row_Number()分页存在的问题

 

 

最近有项目反应,在服务器CPU使用较高的时候,咱们的事件查询页面很是的慢,查询几条记录居然要4分钟甚至更长,并且在翻第二页的时候也是要这么多的时间,这确定是不能接受的,也是让现场用SQLServerProfiler把语句抓取了上来。html

用ROW_NUMBER()进行分页

咱们看看现场抓上来的分页语句:node

select top 20 a.*,ag.Name as AgentServerName,,d.Name as MgrObjTypeName,l.UserName as userName from eventlog as a left join mgrobj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm left join addrnode as c on b.AddrId=c.Id left join mgrobjtype as d on b.MgrObjTypeId=d.Id left join eventdir as e on a.EventBm=e.Bm left join agentserver as ag on a.AgentBm=ag.AgentBm left join loginUser as l on a.cfmoper=l.loginGuid where a.OrderNo not in ( select top 0 OrderNo from eventlog as a left join mgrobj as b on a.MgrObjId=b.Id left join addrnode as c on b.AddrId=c.Id where 1=1 and a.AlarmTime>='2014-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' and b.AddrId in ('02109000',……,'02109002') order by AlarmTime desc ) and 1=1 and a.AlarmTime>='2014-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' and b.AddrId in ('02109000',……,'02109002') order by AlarmTime DESC

这是典型的使用两次top来进行分页的写法,原理是:先查出pageSize*(pageIndex-1)(T1)的记录数,而后再TopPageSize条不在T1中的记录,就是当前页的记录。这种查询效率不高主要是使用了not in。参考我以前文章《程序猿是如何解决SQLServer占CPU100%的》提到的:“对于不使用SARG运算符的表达式,索引是没有用的”python

那么改成使用ROW_NUMBER分页:sql

WITH cte AS(
    select a.*,ag.Name as AgentServerName,d.Name as MgrObjTypeName,l.UserName as userName,b.AddrId ,ROW_NUMBER() OVER(ORDER BY AlarmTime DESC) AS RowNo from eventlog as a WITH(FORCESEEK) left join mgrobj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm left join addrnode as c on b.AddrId=c.Id left join mgrobjtype as d on b.MgrObjTypeId=d.Id left join eventdir as e on a.EventBm=e.Bm left join agentserver As ag on a.AgentBm=ag.AgentBm left join loginUser as l on a.cfmoper=l.loginGuid where a.AlarmTime>='2014-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' AND b.AddrId in ('02109000',……,'02109002') ) SELECT * FROM cte WHERE RowNo BETWEEN 1 AND 20;

执行时间从14秒提高到5秒,这说明Row_Number分页仍是比较高效的,并且这种写法比top top分页优雅不少。数据库

“欺骗”查询引擎让查询按你的指望去查询

可是为何查询20条记录居然要5秒呢,尤为在这个表是加上了时间索引的状况下——参考《程序猿是如何解决SQLServer占CPU100%的》中提到的索引。服务器

我尝试去掉这句AND b.AddrId in ('02109000',……,'02109002'),结果不到1秒就把538条记录查询出来了,而加上地点限制这句,结果是204行。为何结果集不大,花费的时间却相差这么多呢?查看执行计划,发现走的是另外的索引,而非时间索引。微信

把这个疑问放到了SQLServer群上,很快,高桑给了回复:要想达到跟去掉地点限制这句的效果,就使用AdddrId+'' inmarkdown

什么意思?一时没看明白,是高桑没看懂个人语句?很快,有人补充,要欺骗查询引擎。“欺骗”?仍是不懂,不过我照作了,把上述cte的语句原封不动的Copy出来,而后把这句AND b.AddrId in ('02109000',……,'02109002')更改成了AND b.AddrId+'' in ('02109000',……,'02109002'),一点执行,神了!!!不到1秒就执行完了。在把执行计划一对,果真走的是时间索引:函数

后来回味了一下,记起以前看到的查询引擎优化原理,若是你的条件中带有运算符或者使用函数等,则查询引擎会放弃优化,而执行表扫描。脑壳忽然转过来了,在使用b.AddrId+''前查询引擎尝试把mgrObj表加入一块儿作优化,那么两个表联查,会致使预估的记录数大大增长,而使用了b.AddrId+'',查询引擎则会先按时间索引把记录刷选出来,这样就达到了效果,即强制先作cte在执行in条件,而不是在cte中进行in条件刷选。原来如此!有时候,查询引擎过分的优化,会致使相反的效果,而你若是可以知道优化的原理,那么就能够经过一些小的技巧让查询引擎按你的指望去进行优化post

ROW_NUMBER()分页在页数较大时的问题

事情到这里,还没完。后面同事又跟我反应,查询到后面的页数,又卡了!what?我从新执行上述语句,把时间范围放到2011-12-01到2014-12-26,记录数限制为为19981到20000,果真,查询要30秒左右,查看执行计划,都是同样的,为何?

高桑怀疑是key lookup过多致使的,建议先分页取出rid 再作key lookup。不懂这么一句是什么意思。把执行计划和IO打印出来:

看看IO,很明显,主要是越到后面的页数,其余的几个关联表读取的页数就越多。我推测,在Row_Number分页的时候,若是有表链接,则按排序一致到返回的记录数位置,前面的记录都是要参与表链接的,这就致使了越到后面的分页,就越慢,由于要扫描的关联表就越多。

难道就没有了办法了吗?这个时候宋桑英勇的站了出来:“你给表后加一个forceseek提示可破”。这真是犹如天籁之音,立刻进行尝试。

使用forceseek提示能够强制表走索引

查了下资料:

SQL Server2008中引入的提示ForceSeek,能够用它将索引查找来替换索引扫描

那么,就在eventlog表中加上这句看看会怎样?

果真,查询计划变了,开始提示,缺乏了包含索引。赶忙加上,果真,按这个方式进行查询以后查询时间变为18秒,有进步!可是查看IO,跟上面同样,并无变少。不过,总算学会了一个新的技能,而宋桑也很热心说晚上再帮忙看看。

把其余没参与where的表放到cte外面

根据上面的IO,很快,又有人提到,把其余left join的表放到cte外面。这是个办法,因而把除eventlogmgrobjaddrnode的表放到外面,语句以下:

WITH cte AS(
    select a*,b.AddrId,b.Name as MgrObjName,b.MgrObjTypeId ,ROW_NUMBER() OVER(ORDER BY AlarmTime DESC) AS RowNo from eventlog as a left join mgrobj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm left join addrnode as c on b.AddrId=c.Id where a.AlarmTime>='2011-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' AND b.AddrId+'' in ('02109000',……,'02109002') ) SELECT a.* ,ag.Name as AgentServerName ,d.Name as MgrObjTypeName,l.UserName as userName FROM cte a left join eventdir as e on a.EventBm=e.Bm left join mgrobjtype as d on a.MgrObjTypeId=d.Id left join agentserver As ag on a.AgentBm=ag.AgentBm left join loginUser as l on a.cfmoper=l.loginGuid WHERE RowNo BETWEEN 19980 AND 20000;

果真有效,IO大大减小了,而后速度也提高到了16秒。

'loginuser'。扫描计数 1,逻辑读取 63 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'agentserver'。扫描计数 1,逻辑读取 1617 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'mgrobjtype'。扫描计数 1,逻辑读取 126 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'eventdir'。扫描计数 1,逻辑读取 42 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'addrnode'。扫描计数 1,逻辑读取 119997 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'eventlog'。扫描计数 1,逻辑读取 5027 次,物理读取 3 次,预读 5024 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'mgrobj'。扫描计数 1,逻辑读取 24 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。

咱们看到,addrNode表仍是扫描计数很大。那还能不能提高,这个时候,我想到了,先把addrNodemgrobjmgrobjtype三个表联合查询,放到一个临时表,而后再和eventloginner join,而后查询结果再和其余表作left join,这样还能减小IO。

使用临时表存储分页记录在进行表链接减小IO

IF OBJECT_ID('tmpMgrObj') IS NOT NULL DROP TABLE tmpMgrObj SELECT m.Id,AddrId,MgrObjTypeId,AgentBM,m.Name,a.Name AS AddrName INTO tmpMgrObj FROM dbo.mgrobj m INNER JOIN dbo.addrnode a ON a.Id=m.AddrId WHERE AddrId IN('02109000',……,'02109002'); WITH cte AS( select a.*,b.AddrId,b.MgrObjTypeId ,ROW_NUMBER() OVER(ORDER BY AlarmTime DESC) AS RowNo ,ag.Name as AgentServerName ,d.Name as MgrObjTypeName,l.UserName as userName from eventlog as a INNER join tmpMgrObj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm left join mgrobjtype as d on b.MgrObjTypeId=d.Id left join agentserver As ag on a.AgentBm=ag.AgentBm left join loginUser as l on a.cfmoper=l.loginGuid WHERE AlarmTime>'2011-12-01 00:00:00' AND AlarmTime<='2014-12-26 23:59:59' ) SELECT * FROM cte WHERE RowNo BETWEEN 19980 AND 20000 IF OBJECT_ID('tmpMgrObj') IS NOT NULL DROP TABLE tmpMgrObj

此次查询仅用了10秒。咱们来看看IO:

'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'mgrobj'。扫描计数 1,逻辑读取 24 次,物理读取 2 次,预读 23 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'addrnode'。扫描计数 1,逻辑读取 6 次,物理读取 3 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 ---------- 表 'loginuser'。扫描计数 0,逻辑读取 24 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'eventlog'。扫描计数 93,逻辑读取 32773 次,物理读取 515 次,预读 1536 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'tmpMgrObj'。扫描计数 1,逻辑读取 3 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'mgrobjtype'。扫描计数 1,逻辑读取 6 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'agentserver'。扫描计数 1,逻辑读取 77 次,物理读取 2 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。

除了eventlog以外,其余的表的IO大大减小,有木有?

强制使用hash join

经网友提示,在大的页数时,能够强制使用hash join来减小IO,并且通过尝试,能够经过创建两个子查询来避免使用临时表。通过调整,最终优化的SQL语句以下:

SELECT * ,ag.Name AS AgentServerName , l.UserName AS userName FROM ( SELECT a.*,ROW_NUMBER() OVER (ORDER BY AlarmTime DESC) AS RowNo , b.AddrName , b.Name AS MgrObjName FROM (SELECT * FROM eventlog WHERE AlarmTime>= '2011-12-01 00:00:00' AND AlarmTime< '2014-12-26 23:59:59') AS a INNER HASH JOIN ( SELECT m.Id,AddrId,MgrObjTypeId,AgentBM,m.Name,a.Name AS AddrName,t.Name AS MgrObjTypeName FROM dbo.mgrobj m INNER JOIN dbo.addrnode a ON a.Id=m.AddrId INNER JOIN dbo.mgrobjtype t ON m.MgrObjTypeId=t.Id WHERE AddrId IN('02109000',……,'02109002') ) AS b ON a.MgrObjId=b.Id AND a.AgentBM=b.AgentBm ) tmp LEFT JOIN agentserver AS ag ON tmp.AgentBm = ag.AgentBm LEFT JOIN eventdir AS e ON tmp.EventBm = e.Bm LEFT JOIN loginUser AS l ON tmp.cfmoper = l.loginGuid WHERE tmp.RowNo BETWEEN 190001 AND 190020

在大的分页的时候,经过hash查询,没必要扫描前面的页数,能够大大减小IO,可是,因为hash join是强制性的,因此使用的时候要注意,我这里应该是个特例。

查询分析器的提示:“警告: 因为使用了本地联接提示,联接次序得以强制实施。”

咱们来看看对应状况下的IO:

'eventlog'。扫描计数 5,逻辑读取 5609 次,物理读取 34 次,预读 5636 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'Worktable'。扫描计数 3,逻辑读取 375 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'mgrobj'。扫描计数 5,逻辑读取 24 次,物理读取 8 次,预读 40 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'mgrobjtype'。扫描计数 1,逻辑读取 6 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'addrnode'。扫描计数 3,逻辑读取 18 次,物理读取 6 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'loginuser'。扫描计数 1,逻辑读取 60 次,物理读取 2 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。 表 'eventdir'。扫描计数 1,逻辑读取 40 次,物理读取 0 次,预读 0 次,lob 逻辑读取 30 次,lob 物理读取 0 次,lob 预读 0 次。 表 'agentserver'。扫描计数 1,逻辑读取 1540 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。

此次的IO表现很是的好,没有由于查询后面的页数增大而致使较大的IO,查询时间从没有使用hash join的50秒提高为只需12秒,查询时间的开销应该耗费了在hash查找上了。

再看看对应的查询计划,这个时候,主要是由于排序的开销较大。

咱们再看看他的预估的和执行的区别,为何会让排序占如此大的开销?

很明显,预估的时候只需对刷选的结果排序,可是实际执行是对前面全部的页数进行了排序,最终排序占了大部分的开销。那么,这种状况能破吗?请留下您的回复!

其余优化参考

在另外的群上讨论时,发现使用ROW_NUMBER分页查询到后面的页数会愈来愈慢的这个问题的确困扰了很多的人。

有的人提出,谁会这么无聊,把页数翻到几千页之后?一开始我也是这么想的,可是跟其余人交流以后,发现确实有这么一种场景,咱们的软件提供了最后一页这个功能,结果……固然,一种方法就是在设计软件的时候,就去掉这个最后一页的功能;另一种思路,就是查询页数过半以后,就反向查询,那么查询最后一页其实也就是查询第一页。

还有一些人提出,把查询出来的内容,放到一个临时表,这个临时表中的加入自增Id的索引,这样,能够经过辨别Id来进行快速刷选记录。这也是一种方法,我打算稍后尝试。可是这种方法也是存在问题的,就是没法作到通用,必须根据每一个表进行临时表的构建,另外,在超大数据查询时,插入的记录过多,由于索引的存在也是会慢的,并且每次都这么作,估计CPU也挺吃紧。可是无论怎么样,这是一种思路。

你有什么好的建议?不妨把你的想法在评论中提出来,一块儿讨论讨论。

总结

如今,咱们来总结下在此次优化过程当中学习到什么内容:

  • 在SQLServer中,ROW_NUMBER的分页应该是最高效的了,并且兼容SQLServer2005之后的数据库
  • 经过“欺骗”查询引擎的小技巧,能够控制查询引擎部分的优化过程
  • ROW_NUMBER分页在大页数时存在性能问题,能够经过一些小技巧进行规避
    • 尽可能经过cte利用索引
    • 把不参与where条件的表放到分页的cte外面
    • 若是参与where条件的表过多,能够考虑把不参与分页的表先作一个临时表,减小IO
    • 在较大页数的时候强制使用hash join能够减小io,从而得到很好的性能
  • 使用with(forceseek)能够强制查询所以进行索引查询

最后,感谢SQLServer群的高桑、宋桑、肖桑和其余群友的大力帮助,这个杜绝吹水的群很是的棒,让我这个程序猿学到了不少数据库的知识!

注:经网友提示,2015-01-07 09:15作如下更新

  • 能够在记录数超过10000条,则采用hash join强制进行hash链接,减小IO(感谢27楼riccc)
  • 去掉最早给定的结果中采用left join而不是inner join的链接——left join的结果至关于没有用上addrId in ()的条件(感谢32楼夏浩)

参考文章



若是您以为阅读本文对您有帮助,请点一下“推荐”按钮,您的“推荐”将是我最大的写做动力!若是您想持续关注个人文章,请扫描二维码,关注马非码的微信公众号,我会将个人文章推送给您,并和您一块儿分享我平常阅读过的优质文章。

本文版权归做者和博客园共有,来源网址:http://www.cnblogs.com/marvin/欢迎各位转载,可是未经做者本人赞成,转载文章以后必须在文章页面明显位置给出做者和原文链接,不然保留追究法律责任的权利。
相关文章
相关标签/搜索