前言:
在前面一文中,已經提到了三類常見的索引問題,那么問題來了,當系統出現這些問題時,該如何應對? 簡單而言,需要分析現有系統的行為,然后針對性地對索引進行處理:
o 對於索引不足的情況:檢查缺少索引的情況,也需要檢查現有索引定義是否有問題。
o 對於索引過多的情況:分析每一個索引的使用情況,判斷是否有存在的必要或者可合並、可修改的可能。
o 對於索引不合理的情況:也要分析每個索引的定義,及其使用情況,確定索引是否有存在必要,如果有,是否能很好地支持查詢並且對現有系統的影響也不大。
從上面描述可知,我們的步驟是:

注:這個步驟並不是必須的,也不是固定的,視實際情況而定才是最佳方案。下面來介紹整個流程。
起因:
我們為什么要維護索引?大家都知道——因為性能有問題了。為什么性能有問題呢?索引不合理了唄!絕大部分系統和IT從業人員都很難在一開始就做好性能規划。特別在國內這種趕項目進度,上線了再說的國情下,即使你知道這個功能有性能問題,但是修改會帶來嚴重的項目延期的前提下,所有人都不會允許你做改動的。所以大部分性能問題都在系統運行到一定程度或者數據量突發增長或持續增長時才出現。甚至很多領導層認為:系統能用是最重要的,性能問題可以推一下。在這一些背景下,對開發、設計人員過多地指責他們沒有做好前期工作是沒必要的,大家將心比心,多點理解,對后面優化工作也有幫助,畢竟別人不會那么抵觸。
那么在系統運行一段時間后出現性能問題或者運維壓力時,你就要介入進行性能優化。性能優化的第一步並不是盲足亂搞,而是找瓶頸,找到瓶頸才能做相應的處理,否則只能聽天由命,誤打誤撞的幾率其實很小。下面我們假定系統的性能問題已經是索引引起的,那么我們就從定位瓶頸着手。
收集系統行為:
我們知道,除非硬件BUG,否則一個靜止的系統不會出現性能問題。所以系統的性能問題本質上是因為系統的行為導致的。因此,我們首先需要收集系統行為來定位瓶頸。
系統行為各式各樣,又彼此關聯,我們很難輕易地定位所有問題。但是Windows、SQL Server作為成熟的軟件,在使用了十幾年之后,業界已經有了一套比較成熟和現成的方案,所以我們不妨根據這些方案來收集。大概流程如下:

由於本文不是專門討論如何偵測和處理系統性能問題的文章,所以非數據庫部分只簡略介紹。 首先,我們要做的是對基礎的檢測:服務器及操作系統的檢查。服務器和操作系統是軟件系統的支撐部分,並且一個軟件系統的實際運行離不開對它們的准確、高效運作。上圖中列出了“服務器型號”的檢查,因為論壇上曾經有這么個帖子,一個新服務器安裝SQL Server之后,服務一啟動內存馬上占滿,期間沒有任何操作。最后發現IBM x3650這款型號的服務器對SQL Server存在問題,換了其他型號之后就消失了。另外,對服務器特別是硬件的檢查也是必要的,剛接手系統時,老是說卡,用性能計數器檢測之后發現服務器的個別盤IO問題很嚴重,檢查數據庫文件存放路徑之后發現,雖然服務器上有SSD盤,但是數據庫依舊運行在服務器自帶的SAS盤上,后來把用戶庫、TempDB移到SSD之后,雖然沒有突飛猛漲的性能提升,但是再次檢查可以得知大部分盤的IO使用情況已經趨向正常。我們知道,服務器對數據庫性能影響最大的不是內存大小,而是IO。由於數據庫不直接操作磁盤,而是把數據從磁盤加載到內存,所以磁盤的IO應該越快越好。簡單來說,在操作系統和硬件配置合理的前提下,數據庫文件應該按照各自行為存放在盡可能快的硬盤中。由於本文的主題在索引上,所以這里不多說。 對於操作系統配置,有幾個點需要注意: o 盤符划分、RAID配置、命名規則等問題。 o 需要提供一個本地管理員組的帳號用於SQL Server服務的啟動帳號,否則使用不到SQL 2005開始引入的“即時文件初始化”功能,該功能的具體描述可見:https://msdn.microsoft.com/zh-cn/library/ms175935(SQL.105).aspx o 網絡配置、機器命名:本人維護的系統中,供應商在安裝好OS后馬上安裝SQL Server,送貨到機房之后,運維人員根據內部需要重命名機器名,導致SQL Server某些功能無法識別administrator,比如復制功能。如有可能,建議先完成操作系統的配置再安裝SQL Server,若無法實現,可以用以下腳本,修改SQL Server的配置,但本腳本不能完全處理這類問題: [sql] view plain copy print? 1. --檢查是否一致 2. use master 3. go 4. select @@servername 5. select serverproperty('servername') 6. 7. --如果不一致,執行下面的語句 8. if serverproperty('servername') <> @@servername 9. begin 10. declare @server sysname 11. set @server = @@servername 12. exec sp_dropserver @server = @server 13. set @server = cast(serverproperty('servername') as sysname) 14. exec sp_addserver @server = @server , @local = 'LOCAL' 15. end 16. 17. /*************************************** 18. 說明:其實就是刪除舊的服務器名servername,再添加新的服務器名 19. sp_dropserver '舊的服務器名' 20. sp_addserver '新的服務器名' , 'LOCAL' 21. 3、重啟SQL SERVER 22. 4、再運行以下腳本驗證一下。 23. ***************************************/ 24. 25. use master 26. go 27. select @@servername 28. select serverproperty('servername') 下面進入重點部分,也就是對數據庫系統的偵測。收集系統行為信息還有一個重要的原因就是了解系統讀寫行為,讀多還是寫多。讀寫比例直接影響表設計、數據類型特別是定長和變長的選擇,也影響索引填充因子的配置等。 但是本文集中在索引行為上,所以不打算花費太大篇幅在上面,后續再整理專題。從大范圍來說,服務器行為可以通過分析應用程序的結構、性能計數器、服務器端SQL Trace、存儲過程、函數、視圖讀寫次數及索引的使用情況來綜合分析,但是無論哪一種方式,要做充分的分析都是耗時、工作量大的體力和腦力活。 可是我們沒有必要總是全部收集,一個一個分析。我們可以使用“大膽假設,小心求證”的方式去應對。下面來點干貨: 需要收集的信息: 在實操之前,需要先了解我們的操作對象——本系列中的索引。簡單而言,就是要對表上的索引進行信息收集,索引的信息很多,比如有多少數據頁、葉子節點包含了什么數據、索引層級、鎖升級等等,但是大部分對處理常規問題而言並不必要,所以我們可以重點針對索引的某些明顯指標進行收集: o 索引的讀、寫次數。 o 索引定義 o 索引被使用的具體情況(本文的重點) o 索引碎片 o 缺少索引(missingindex)的相關信息 需要注意的是,你要收集的系統應該運行了足夠長的時間,比如數周甚至數月,除了讓緩存能充分表現系統行為之外,也可以加大覆蓋系統行為的可能性,因為某些功能確實只在特定時間(如月結及其報表)才會發生,或者在異常時才會觸發,如果系統運行了幾個小時就開始收集信息,那么信息的准確度可能不足以支撐系統分析。 網上有類似的文章,但是我覺得個人的方法也不錯,所以這里我不打算根據網上的方法來介紹,而是介紹本人自己的方法,如有不妥或者漏洞,歡迎指出和分享你們的方法。 實操: 對於索引問題,我要思考的是:現在的索引是否合理?如果合理,那么性能問題可能是別的地方,當然,寫這篇文章證明是不合理的,那么如何發現和定義呢?需要監控和分析。由於本人負責的系統是SQL 2008 R2,雖然已經支持擴展事件(Extent Events,xEvents),但是由於從SQL 2012開始才有圖形化界面,而且2008聽說還存在一定的bug,所以在這里並沒有使用,個人還是挺看好這個功能,后續我會嘗試使用,也歡迎大家分享。 既然xEvents不可用,那么還是來點傳統方式吧——計划緩存(Plan cache)和DMO(DMVs 和DMFs,動態管理對象)。需要注意的是計划緩存存儲的是預估執行計划,有些程序的實際行為是不同的。所以預估執行計划只能作為入門。 在確定工具之后,接下來就要思考如何使用。前面提到的指標中,除了“索引被使用的具體情況”之外,其他都能用各種DMO獲取。但是基於連貫性原因,我邊描述操作邊簡要介紹各種DMO。 通常來說,一個系統有大量的對象(存儲過程、動態SQL、函數、視圖等),除非問題非常特殊,一眼就能定位,否則我會按照下面原則來檢查: 1、 從SSMS中的報表獲取LongRunning 。 2、 用語句獲取LongRunning對象。(1、2兩個我將單獨起文介紹) 3、 通過與開發人員的溝通獲取可能的性能瓶頸。 4、 對大表和索引很多的表進行優先分析。 5、 當然還有其他,不過這些多多少少跟運氣有關,說不定誤打正着碰對了瓶頸。 在本次Troubleshooting中,我按上面順序進行操作,最后發現第四個原則的效果明顯,所以我重點討論第四個原則。 查找索引定義: 在這次維護索引中,我選擇了對大表進行優先分析,當然對於很多系統來說,這些表一點都不大,不過別在意細節。首先我從最大的表開始,逐個分析每個表的索引。找表的行數太容易了,這里就不說了。當我找到最大表時,我們可以很輕易地從SSMS中找到表上有多少索引,然后呢? 在SQL 2000時代,很多sp_xxxx系統存儲過程都能獲取一定的信息,比如sp_helpindex 表名這種方式可以獲取表上索引的定義,但是這個系統存儲過程並不支持SQL 2005及后續版本出現的新功能,如包含索引的描述,所以你只能看到索引名、定義在INCLUDE關鍵字前的那些列(假設它們是包含索引),對於包含索引中的包含列,卻沒有顯示。這種方式有一個風險,很多人通過這個存儲過程看到某些索引的前面幾列完全相同,就直接刪除其中重復索引,其實這些索引是包含索引,對某些程序的支持有作用,這種魯莽行為可能導致系統性能突然猛降,所以要“大膽假設、小心求證!!!”。 我們可以使用DMO來實現這種需要,注意替換表名: [sql] view plain copy print? 1. DECLARE @tblnvarchar(265) 2. SELECT @tbl = '表名' 3. 4. SELECT o.name,i.index_id, i.name, i.type_desc, 5. substring(ikey.cols, 3, len(ikey.cols))AS key_cols, 6. substring(inc.cols, 3, len(inc.cols)) ASincluded_cols, 7. stats_date(o.object_id, i.index_id) ASstats_date, 8. i.filter_definition 9. FROM sys.objects o 10. JOIN sys.indexes i ON i.object_id = o.object_id 11. CROSS APPLY (SELECT ', ' + c.name + 12. CASE ic.is_descending_key 13. WHEN 1 THEN ' DESC' 14. ELSE '' 15. END 16. FROM sys.index_columns ic 17. JOIN sys.columns c ON ic.object_id = c.object_id 18. ANDic.column_id = c.column_id 19. WHERE ic.object_id = i.object_id 20. AND ic.index_id = i.index_id 21. AND ic.is_included_column = 0 22. ORDER BY ic.key_ordinal 23. FOR XML PATH('')) AS ikey(cols) 24. OUTER APPLY (SELECT ', ' + c.name 25. FROM sys.index_columns ic 26. JOIN sys.columns c ON ic.object_id = c.object_id 27. ANDic.column_id = c.column_id 28. WHERE ic.object_id = i.object_id 29. AND ic.index_id = i.index_id 30. AND ic.is_included_column = 1 31. ORDER BY ic.index_column_id 32. FOR XML PATH('')) AS inc(cols) 33. WHERE o.name = @tbl 34. AND i.type IN (1, 2) 35. ORDER BY o.name, i.index_id 結果如下:
可以比較直觀地看到索引定義及其統計信息更新時間(這個極其重要,但是不是本文的重點,所以也不詳細描述)。獲取索引定義是為了分析設計是否合理、是否可修改,如果不知道你要操作的對象是什么樣子的,也就不可能有下面的步驟。 每個表上索引的使用情況: 當你知道有多少個索引,索引是怎樣的時候,就可以開始收集索引使用情況,這里分兩步,但是可以同時進行: 1.獲取索引的讀寫情況: 2.獲取索引的被使用信息,這里的被使用是指:從服務器啟動開始(這個很重要,因為你讀的是緩存),這個索引在系統中被什么對象(動態SQL、存儲過程、函數等,包含了對象的文本信息)使用過,使用了多少次,對應的計划緩存是怎樣的。 對於第一步,我們可以用簡單的DMV來得到: [sql] view plain copy print? 1. SELECT OBJECT_NAME(ddius.[object_id]) AS [Table Name] , 2. i.name AS [Index Name] , 3. i.index_id , 4. user_updatesAS [Total Writes], 5. user_seeks+ user_scans + user_lookups AS [Total Reads] , 6. user_updates-( user_seeks + user_scans + user_lookups ) AS [Difference] 7. FROM sys.dm_db_index_usage_stats ASddius WITH ( NOLOCK ) 8. INNER JOIN sys.indexes AS i WITH ( NOLOCK ) ON ddius.[object_id] = i.[object_id] 9. AND i.index_id = ddius.index_id 10. WHERE OBJECTPROPERTY(ddius.[object_id], 'IsUserTable') = 1 11. AND ddius.database_id = DB_ID() 12. AND OBJECT_NAME(ddius.[object_id])='表名' 13. AND i.index_id > 1 --非聚集索引 14. ORDER BY [Difference] DESC , 15. [Total Writes]DESC , 16. [Total Reads]ASC; 我們這里主要關注非聚集索引,因為絕大部分情況下,聚集索引是主鍵,在系統運行了一段時間后,你能修改主鍵的可能已經大大降低,並且主鍵一般問題不大。下面是語句執行的大概樣子:
得到了這些信息之后,就開始做初步分析,對於大部分系統而言,讀操作遠大於寫操作,所以如果你的系統也是類似的,那么可以選出上圖中【Total Reads】遠小於【Total Writes】的那些索引進行優先分析對象,如上圖的第五個索引。 某個索引被使用的具體情況: 再次說明,很多方法可以實現這種分析,在不需要深入研究的情況下,夠用就好。本人通過改寫國外大牛的一個關於查找“並行執行語句”的腳本,實現獲取某個索引自實例啟動依賴被使用的具體情況。原腳本如下: [sql] view plain copy print? 1. --執行計划中使用了並行操作的語句: 2. SET TRANSACTION ISOLATIONLEVEL READ UNCOMMITTED; 3. WITH XMLNAMESPACES(DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan') 4. SELECT COALESCE(DB_NAME(p.dbid) 5. , p.query_plan.value('(//RelOp/OutputList/ColumnReference/@Database)[1]','nvarchar(128)')) 6. AS database_name 7. ,DB_NAME(p.dbid) + '.' + OBJECT_SCHEMA_NAME(p.objectid, p.dbid) 8. + '.' + OBJECT_NAME(p.objectid, p.dbid) AS object_name 9. ,cp.objtype 10. ,p.query_plan 11. ,cp.UseCounts AS use_counts 12. ,cp.plan_handle 13. ,CAST('<?query --' + CHAR(13) + q.text + CHAR(13) + '--?>' AS XML) AS sql_text 14. FROM sys.dm_exec_cached_plans cp 15. CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) p 16. CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) AS q 17. WHERE cp.cacheobjtype = 'Compiled Plan' 18. AND p.query_plan.exist('//RelOp[@Parallel = "1"]') = 1 19. ORDER BY COALESCE(DB_NAME(p.dbid), p.query_plan.value('(//RelOp/OutputList/ColumnReference/@ 20. Database)[1]','nvarchar(128)')), UseCountsDESC 下面是本人改寫后的腳本: [sql] view plain copy print? 1. --獲取某個索引被使用的情況 2. SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; 3. WITH XMLNAMESPACES(DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan') 4. SELECT COALESCE(DB_NAME(p.dbid) 5. ,p.query_plan.value('(//RelOp/OutputList/ColumnReference/@Database)[1]','nvarchar(128)')) 6. ASdatabase_name 7. ,DB_NAME(p.dbid) + '.' + OBJECT_SCHEMA_NAME(p.objectid, p.dbid) 8. +'.' + OBJECT_NAME(p.objectid, p.dbid) AS OBJECT_NAME, 9. cast ('索引名' as varchar(64)) AS IndexName 10. ,cp.objtype 11. ,p.query_plan 12. ,cp.UseCounts AS use_counts 13. ,cp.plan_handle 14. ,CAST('<?query --' + CHAR(13) + q.text + CHAR(13) + '--?>' AS XML) AS sql_text INTO xxx.xxx.xxx表 15. FROM sys.dm_exec_cached_plans cp 16. CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) p 17. CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) AS q 18. WHERE cp.cacheobjtype = 'Compiled Plan' 19. AND p.query_plan.exist('//Object[@Index = "[索引名]"]') = 1 20. ORDER BY UseCounts DESC,COALESCE(DB_NAME(p.dbid), p.query_plan.value('(//RelOp/OutputList/ColumnReference/@ 21. Database)[1]','nvarchar(128)')) 腳本有兩個注意的地方: 1.需要手動替換語句中的“索引名”三個字,共兩處地方。另外要注意的是,這個語句查詢的是XML格式的執行計划,XML是大小寫區分的,所以要嚴格按照索引名(在SSMS中查到的名字)來替換,否則查詢不出來。另外針對索引名,本系統就出現過不同的表使用了相同的索引名(如index1),這種極其亂來的命名規則應該避免。這種情況也導致了你分析的索引可能是另外一個表的,所以需要再次檢查。 2.腳本中有INTO xxx.xxx.xxx表的部分。這個是本人的監控習慣,在同一個服務器上,若有空間和條件,建議創建一個獨立的數據庫(簡單模式即可),存儲所有你感興趣的數據庫運維和性能信息,以便后續之用。這個如果不需要存儲,記得注釋掉。 下面是腳本結果的示例:
接上圖

現在來解釋一下這個結果: • Database_name:指使用這個索引的對象(如存儲過程)所在的數據庫,本系統存在跨庫操作的行為,所以一個索引可能被多個數據庫的對象使用。這個可以用於找到對象所在的數據庫,如果有必要可以對這個數據庫做進一步分析,如這個數據庫的配置情況等。 • OBJECT_NAME:這個是使用索引的對象名,如果是數據庫內存儲的對象(如存儲過程、函數、視圖等),這里會有結果,如果是動態SQL,此處為NULL。 • IndexName:索引名字,一般僅用來后續統計之用。 • Objtype:使用索引的對象類型:Proc為存儲過程,Prepared為預定義語句,詳細類型說明可以查看聯機叢書關於sys.dm_exec_cached_plans的說明中的objtype。 • Query_plan:使用索引的對象的執行計划,點開后是圖形化執行計划。這是語句中最重要的信息之一。 • Use_counts:對象的執行次數。 • Plan_handle:在這里它的作用不大。 • Sql_text:這也是XML片段,它是引用到索引的那部分代碼,特別是對動態SQL,我們可以一下子就找到它。 以上圖為例,下面來看看如何使用這些結果。 首先,我們抓重點,看use_counts列,對於那些運行了幾個月的系統,這列還是2、3次的,其實沒有多大關注必要,除非你要做極限優化。所以我們的切入點是這個列的數據,先挑執行次數最多的來看。注意腳本中已經對use_counts做了排序操作,讀者可以按需要修改排序。 找到需要分析的對象之后,點一下最后一列,看看語句情況:注意由於某些存儲過程可能多個地方引用或者存儲過程本身比較小,所以這個並不是必要步驟,不過看一下大概語句也沒壞處,畢竟語句的寫法直接影響性能。 如果語句看不出什么問題,再點開執行計划,有些存儲過程執行計划的內容很大、很多步驟,所以直接讀也不見得是高效。基於本主題,我們希望找到的是索引使用不合理的地方,所以我們還是直接定位索引使用情況。如何定位?查XML。 右鍵圖形化存儲過程,選擇【顯示執行計划XML(X)】,會在新窗口打開執行計划的XML文本。
第一次打開XML格式的執行計划時可能會被嚇一跳,不過不要緊,我們並不是做深入研究,此時只要用普通的查找文本方法找到索引出現的地方即可。使用CTRL+F快捷鍵,然后把索引名貼進去就可以收縮到索引所在的地方:
如果你還是讀不懂XML執行計划,那就返回圖形化里面找:

把鼠標移到這個圖標上即可看到一些我們所需的信息。 按照上面的方式把需要分析的索引分析一遍(分析辦法下面會介紹),就可以知道這個索引是否合理,是否可以刪除,是否可以合並。 提醒:有些核心表的核心索引可能被幾千個對象應用,這些對象主要是動態SQL,只是不同參數而已,在研究參數嗅探時是有價值的,關於這部分另起文章討論,文章完成后會加上鏈接。對於使用上面腳本查出來的結果中,若有成百上千行時,一個一個分析顯然不合理,此時use_counts又起了一定的作用——找次數足夠多的來研究,同時結合sql_text列,找出語句幾乎一樣僅參數不一樣的那些,可以只挑一個研究。一個索引往往就被幾個單獨的對象應用。如果有大量應用,考慮是否要按業務拆分(所謂的垂直拆分表和業務)。 其他信息收集: 下面給出幾個收集其他信息的腳本: 缺少索引: [sql] view plain copy print? 1. --丟失索引 2. SELECT user_seeks * avg_total_user_cost *( avg_user_impact *0.01 ) AS [index_advantage] , 3. dbmigs.last_user_seek , 4. dbmid.[statement] AS [Database.Schema.Table], 5. dbmid.equality_columns , 6. dbmid.inequality_columns , 7. dbmid.included_columns , 8. dbmigs.unique_compiles , 9. dbmigs.user_seeks , 10. dbmigs.avg_total_user_cost , 11. dbmigs.avg_user_impact 12. FROM sys.dm_db_missing_index_group_stats AS dbmigs WITH ( NOLOCK ) 13. INNER JOIN sys.dm_db_missing_index_groupsAS dbmig WITH ( NOLOCK ) ON dbmigs.group_handle = dbmig.index_group_handle 14. INNER JOIN sys.dm_db_missing_index_detailsAS dbmid WITH ( NOLOCK ) ON dbmig.index_handle = dbmid.index_handle 15. WHERE dbmid.[database_id] = DB_ID() 16. ORDER BY index_advantage DESC; 索引碎片: [sql] view plain copy print? 1. --索引上的碎片超過%並且索引體積較大(超過頁)的索引。 2. SELECT '[' + DB_NAME() + '].[' + OBJECT_SCHEMA_NAME(ddips.[object_id], 3. DB_ID())+ '].[' 4. + OBJECT_NAME(ddips.[object_id], DB_ID()) + ']' AS [statement] , 5. i.[name] AS [index_name] , 6. ddips.[index_type_desc] , 7. ddips.[partition_number] , 8. ddips.[alloc_unit_type_desc], 9. ddips.[index_depth] , 10. ddips.[index_level] , 11. CAST(ddips.[avg_fragmentation_in_percent]AS SMALLINT) AS [avg_frag_%] , 12. CAST(ddips.[avg_fragment_size_in_pages]AS SMALLINT) AS [avg_frag_size_in_pages] , 13. ddips.[fragment_count] , 14. ddips.[page_count] 15. FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'limited') ddips 16. INNER JOIN sys.[indexes] i ON ddips.[object_id] = i.[object_id] 17. AND ddips.[index_id] = i.[index_id] 18. WHERE ddips.[avg_fragmentation_in_percent] > 15 19. AND ddips.[page_count] > 500 20. ORDER BY ddips.[avg_fragmentation_in_percent] , 21. OBJECT_NAME(ddips.[object_id], DB_ID()) , 22. i.[name] 注意:檢查碎片的前提是表有一定的規模,對於那些小表,即使99%的碎片也不影響什么,還是那句:挑重點。另外本腳本針對碎片率15%的索引做檢索,這個比例沒有絕對值,但是作為建議,10%~15%以上的碎片率就需要開始重視。 索引分析: 根據前文所述,我把索引問題主要拆分為三類:索引不合理、索引不足、索引過多。通過上面的信息收集,我們已經得到了足夠的信息。 索引不合理: 首先我們檢查索引定義,如下圖:
從定義中,我們發現幾個問題: 1.索引個數很多:加上聚集索引總共有15個!!!! 2.索引命名:這索引命名足夠讓人奔潰。不多說。 3.看key_cols列中紅圈和黃圈部分,我們一般集中注意力在索引的首列,我們可以看到這里有四個索引是可以列入“可合並”的范疇。對於這個列表,我們需要挑出三類索引: a)第一種是上圖所示首列相同的索引(甚至好幾列相同)。 b)第二種是從名字上看來可能是選擇度很低的列,假設某個索引用了Gender(中國人大多用Sex)作為首列,我們知道性別通常只有兩個值(最多三個:保密或…),這種列做索引的首列是很低效的,所以也應該列出來作為重點研究對象。 c)第三種有點難度,比較費精力,找出key_cols和Included_cols(包含列)中反復出現的列,這些索引可能對相同的列做索引,但是列的順序不同而已,也需要研究是否有修改的必要。 例子演示: 如下面圖中這兩個索引,我們看到首列相同,並且索引列只有一列,但是第二個索引的包含列為NULL,經過下一步檢查讀寫次數之后,基本上可以得知index_id=37的那個是否可以刪除了。這種是典型的“重復索引”,可以歸納到索引過多的范疇。
下面來看看索引讀寫情況,為了減少篇幅,這里我們只查這兩個索引的情況:

可以看到index_id=31的的索引的讀的次數比37的索引接近6倍之多。如無意外index_id=37的索引是可以刪除的,但是作為嚴謹考慮,我們需要再檢查這兩個索引的具體使用情況。其實經驗表明,這種索引定義中,index_id=37的索引存在的唯一優點是“索引體積較小”,但是我們只要研究一下include列的數據類型就會知道會不會大很多,而且索引維護是一個權衡的過程,沒有既提高讀性能,又提高寫性能的索引,這一點要時刻記住。 接下來就是抓索引被使用的對象情況。為了節省時間,我們可以用兩個窗口,分別、同時獲取兩個索引的信息。下面是id=31的索引的信息:

對於database_name為null的數據,我們不必在意太多,畢竟這不是深入研究,腳本也並不是微軟官方提供的,從圖中可以看到這個索引被1708個對象使用過。 下圖是id=37的索引的信息:

這個索引被772個對象使用了。 對比兩個結果,id=37的索引前四行可以看看,后面的只使用了幾次的索引不看也行。先點開第一個的執行計划看看,貌似有點多,那么看XML,並搜索索引被引用的地方:
整個執行計划里面就這個地方使用了這個索引,同時留意紅框地方,UPDATE/Clustered Index Update。這是一個典型的“無用索引”,可以同時納入“索引過多”和“索引不合理”的范疇。從這里看出,這個索引並沒有實際被用到,僅僅因為UPDATE語句,觸發了聚集索引更新,從而連帶引發表上非聚集索引的更新。簡單來說,它沒有為性能帶來好處,反而因為更新時多了這個索引,所以更新速度和開銷更慢。並且我們回顧再上一個圖,它被執行了2080825次,這種頻率所帶來的影響不可忽視。那么我門先標注一下:這個索引可以被刪除。建議讀者開一個excel,列出這些信息,並且包含刪除、修改、保留等理由,也可以再加一列:刪除可能性,每檢查一個存儲過程/SQL語句,如果可刪除,那么加1,最后對比這個值是否最高,就可以判斷是否優先刪除。因為有些索引可能不合理,但是也不見得完全沒用。這些索引是可以短時保留的。 用同樣方法檢查第二、第三個,情況一樣,再檢查第四個:
我們看到這次它被一個索引查找使用了,證明它可以協助查詢,然后我們分析一下這個操作符,結合語句,發現它在查詢中,WHERE條件的用到了這個索引的首列,同時輸出列表中的列是主鍵,這里即聚集索引,而非聚集索引葉節點是包含了聚集索引鍵值,所以這個非聚集索引包含了WHERE和SELECT中所需的數據,所以優化器使用這個索引來協助查詢。但是由於這個索引和id=31的那個幾乎一樣,所以我們完全可以認為這個索引是可以刪除的,然優化器使用id=31的那個索引,只是查詢所需的資源會略微增加而已。 使用同樣的方法檢查id=31的索引,以便驗證我們的想法,這里就不一一截圖。 通過這個方法,我們可以判斷索引是否可以刪除。並且如果你足夠細心,可以在分析的過程中連帶發現其他表的索引問題、語句是否合理等一些列的問題。當然,會很累。 上面的例子可以用於研究索引過多、索引不合理的情況。索引不合理主要是通過定義是否重復或者可合並、執行計划中是否出現了索引/聚集索引掃描或者其他需要注意的操作符(說明:每個操作符的出現有其原因和特定背景,並沒有哪個操作符好,哪個不好。需要具體問題具體分析)。但是有些情況是很明顯有問題的,比如:一個百萬行的表,我只需要查詢10條數據,並且SELECT中只用到少數幾列,經過查詢索引定義,某個索引包含了SELECT/WHERE/JOIN ON中的這些列。那么按道理來說,通常會進行查找操作,可以你在執行計划中發現它使用掃描操作。那么這就值得注意,這種情況通常是有問題的。常見的幾種原因是: • 統計信息過時:統計信息過時會導致優化器錯誤選擇索引和索引的訪問方式,可以通過上面查看索引定義語句中的stats_date列發現是否離現在很遙遠。如果是,不妨更新一下統計信息。具體語句可以查看聯機叢書的說明。 • 索引定義不合理:上一篇文章已經演示過,如果首列定義錯誤,本來可以進行索引查找的操作會變成索引掃描。 • 非SARG寫法:如果WHERE條件中的列使用了標量函數、隱式轉換等非SARG寫法,也會導致“索引無效”。何為非SARG寫法,這里不累贅,讀者可以自行搜索。 • 索引碎片過高:通常碎片問題會導致優化器不選擇一個本來很好的索引,當索引碎片足夠高時,假設表上只有這個索引可用,那么本來的查找操作會變成掃描操作。 建議:如果性能問題不是非常嚴重,在做完上面步驟后,可以先不刪除索引,把信息記錄到一個表,過一段時間后再重新操作一次,看看信息的變化情況。若確實不合理,那么把索引的定義保存起來,然后刪了吧。 索引不足: 在三大索引問題中,現在已經解決了兩個,剩下一個——索引不足。簡單來說,這個問題主要就是找出哪些列需要建索引,為什么要建。但是建議這一步要在最后操作,因為索引過多和索引不合理的處理結果可能就是經過調整后,索引已經能滿足性能要求,不需要再增加索引了。如果你把順序反過來,那么可能在沒有研究是否有多余索引之前,又加了一系列的索引,增加了研究的工作量和復雜度,從上面可以看出其實上面兩步非常耗時。 這一步同樣需要像上面那樣收集同樣的信息,所以建議用一些excel或者實體表存儲過程信息。在個人經驗中,查找哪些列需要加索引,有兩類手段,建議同時使用: 1. SQL Server自帶的缺少索引功能:從SQL 2005開始引入,但是2005的圖形化執行計划並沒顯示缺少索引的提示。 2. 和上面步驟不一樣,上面步驟研究的是非聚集索引,那么我們是否要研究一下聚集索引呢?因為沒有索引的列,如果查詢中使用到,那么除非是堆表,否則會訪問聚集索引,所以從聚集索引的使用情況可以粗略得出哪些列需要索引化。 缺少索引: 缺少索引的文章也有很多,本人的書《SQLServer性能優化與管理的藝術》中也有描述,下面挑出一些重點來介紹。 缺少索引是有SQL Server在運行查詢過程中,根據統計信息和索引情況記錄在一系列DMO中的信息。這部分的DMOs是一些列的對象,由查詢引擎在執行過程中收集的數據,當優化器編譯一個執行計划是,會決定用什么索引及如何使用,如果索引不存在,會把這部分的信息存放到DMOs中。 這部分的DMOs和其他性能監控工具不一樣,無法通過配置去管理,它是自動運作的。在使用這些信息時,有下面的注意事項: 1、隊列的大小,這一點很多人都忽略了,SQLServer只會收集最多500個缺失索引組,一旦到達這個限制,就會停止收集新的缺失索引信息。缺失索引組其實是一系列的缺失索引信息,后面會提到。對於這個限制,只能通過周期性監控並盡快處理,讓優化器能夠報告更多的缺失索引信息。 2、分析深度,SQLServer會報告缺失索引的信息,並給出它的建議,但是要注意分析的深度,這些建議僅針對當前的執行計划,有時候根據建議添加索引后,會出現新的缺失索引信息,而且給出的建議中,可能不會考慮列的順序,所以當查看這些信息時,需要做足夠的測試。 3、准確度,當查詢使用不等於這種限定詞,比如在where中使用了A<>B這樣的寫法,缺失索引給出的信息准確度就沒有使用等於這種限定詞高。 4、索引類型,缺失索引對聚集索引、XML、空間或者列存儲索引不可用。 除此之外,當表的元數據改變時,缺失索引的信息也會消失,比如增加新列這些操作。這部分的DMOs包含:sys.dm_db_missing_index_details、sys.dm_db_missing_index_columns、sys.dm_db_missing_index_group_stats、sys.dm_db_missing_index_groups 但是需要說明的是:別看到SQLServer提示了就加,這是導致索引過多的主要原因,要分析、要測試! 分析聚集索引: 現在換一個環境,用AdventureWorks2008R2作為演示。使用前文創建的dbo.person表,並刪除除主鍵外的所有索引。
現在在表上沒有任何非聚集索引。那么我們還是使用前文中的查詢語句: [sql] view plain copy print? 1. select Title,FirstName,MiddleName,LastName 2. from dbo.Person 3. where FirstName like 'o%'
為了讓SQL Server能收集足夠的信息,我們重復執行這個語句10次。然后用前面檢查索引被使用情況的語句檢查這個聚集索引:

點開第一個執行計划可以看到:

結合語句可知,它僅僅使用了四列,由於FirstName不是主鍵且上面沒索引,所以走的是聚集索引掃描。如果聚集索引建在FirstName,可以看到走的是聚集索引查找,因為鼠標移到箭頭上可以看到實際上返回了164行,而全表有19972行數據,這種比例是可以進行查找操作的。不過主鍵畢竟需要唯一非NULL,所以這里就不演示了。
通過分析,我們初步判斷可以通過對這四列進行索引化,並且以FirstName為索引首列來提高性能,但是為了驗證想法,我們用上面給出的缺少索引的語句來驗證,也可以用DTA來檢驗,記住,DTA有很多限制和不足的地方,不要盲目相信:

很多時候缺少索引的DMOs記錄的恰恰就是DTA的提示,但是這些DMOs更加完善,由於這里是演示環境,系統並沒有收集足夠的信息,所以DMOs沒有顯示。
這種分析確實比較累人,建議結合缺少索引的提示和DTA來分析。切記要分析和測試。
總結:
本系列文章通過一些工具,粗暴但不失有效地檢測和分析系統常見的索引問題。另外,本方法確實不是什么精密的、能覆蓋所有可能的方法,如果需要精密嚴謹地分析,需要借助很多工具、長時間收集和反復監控。但是作為實踐所得,本人覺得這兩篇文章還是有很重要的用處。
處理過索引相關問題之后,有這么一個深刻體會:建一個索引很容易,也很快。說不好聽,可以說完全不需要負責任。但是證明一個索引可以刪除、可以合並,其實你需要花費大量的精力和時間,並且毫不誇張地說,你需要勇氣。像本人管理的系統中,有500多個索引,拋開接近200個主鍵(聚集索引),如果要每個都檢查,沒有個把月專門做這事情是不現實的。