最近有項目反應,在服務器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)的記錄數,而後再Top
出PageSize
條不在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+'' in
。markdown
什麼意思?一時沒看明白,是高桑沒看懂個人語句?很快,有人補充,要欺騙查詢引擎。「欺騙」?仍是不懂,不過我照作了,把上述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外面。這是個辦法,因而把除eventlog
、mgrobj
、addrnode
的表放到外面,語句以下:
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表仍是掃描計數很大。那還能不能提高,這個時候,我想到了,先把addrNode
、mgrobj
、mgrobjtype
三個表聯合查詢,放到一個臨時表,而後再和eventlog
作inner 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作如下更新:
參考文章
若是您以爲閱讀本文對您有幫助,請點一下「推薦」按鈕,您的「推薦」將是我最大的寫做動力!若是您想持續關注個人文章,請掃描二維碼,關注馬非碼的微信公衆號,我會將個人文章推送給您,並和您一塊兒分享我平常閱讀過的優質文章。
本文版權歸做者和博客園共有,來源網址:http://www.cnblogs.com/marvin/歡迎各位轉載,可是未經做者本人贊成,轉載文章以後必須在文章頁面明顯位置給出做者和原文鏈接,不然保留追究法律責任的權利。