2017年4月29日 星期六

SQL Server 的 Waits

SQL命令(SQL Statement)是由CPU來執行,在執行的過程中,難以避免的會因為資源的調用、鎖定等的因素,造成執行過程中可能發生多次的「等待waits)」。因此一個命令的執行,從開始到完成,其回應時間,可以簡單的用下列公式來表示:

response time = service timeCPU + total wait time
回應時間 = 服務時間 + 合計等待時間服務時間指CPU真正用於執行命令的時間

SQLSERVER效能調校,最終無非是為了加快回應速度,降低回應時間。一個命令的執行,要不就是在CPU中運算,要不就是在「等待(Waits)」。瞭解系統到底在等什麼,是系統效能問題處理的基本。但它卻不常被人理解並且應用於效能問題排除。

我想先就個人所知,解釋一下SQLSERVER命令執行過程的狀態變化..

一個thread在未執行任何命令時,其狀態(status)會是sleeping。當執行命令時,系統會分配CPU資源給thread執行命令。當一個thread正在使用CPU時,狀態為RUNNING。直到它需要等待資源(如I/O,或將頁面從磁碟中讀取到buffer pool中,或遇到block需等待),它會被移動到一個無序的waiter list中,此時thread狀態將被標示為SUSPENDED。一個狀態為SUSPENDEDthread,代表它正在等候某個資源。

另外在系統中有個FIFO(先進先出)的隊列(Queue),其中存放著所有在等待CPUthread,這些存在於Queue中的thread,其狀態為RUNNABLE
當一個thread由於執行過程需要等待其它資源,將會被移至「waiter list」,並將其狀態標示為SUSPENDED,於此同時,處於隊列中(Queue)的下一個thread被賦予CPU並且變為RUNNING。如果SUSPENDED列表上的thread被通知資源可用,則它將變為RUNNABLE,並放在RUNNABLE隊列的後面,加入排隊。整個執行過程是螺旋狀的從RUNNINGSUSPENDEDRUNNABLE,直到任務完成為止。

SQLSERVER會給一個處於RUNNINGthread一個限量的時間(4ms),如果這個thread在這個限量的時間內沒有執行完畢。如果這個thread不需等待任何資源,則會將這個thread退回到Queue中(RUNNABLE,即使目前沒有其它的thread在等侯CPU也是一樣;然後系統會從Queue中取得下一個thread並執行。

如果一個thread4ms的限量執行時間內,因等待資源而無法繼續執行,那麼它會被標示為SUSPENDED並移到waiter list中,之後SQLSERVER一樣會從Queue中取得下一個thread執行。當那個等待的資源通知完成後,這個waiter listthread將被移至Queue隊列中等待.

一個thread
RUNNING所花費的時間(稱為:執行時間service time
RUNNABLE隊列上等待的時間(稱為:信號等待時間signal wait time
SUSPENDED列表中等待的時間(稱為:資源等待時間resource wait time

SQLSERVERR會永久性的跟蹤程紀錄執行過程中的「等待」資訊,包含「等待什麼(wait type)」及「等待時間(wait time)

一個thread的等待時間可以用下列公式表示:
total wait time = resource wait time + signal wait time
一個thread的回應時間可以用下列公式表示:
response time = service time + total wait time

可以透過sys.dm_os_waiting_taskssys.dm_exec_requests中查詢thread目前在等待什麼(wait type)及等待時間(wait time。當這個wait event結束後,該等待的資訊會從上述兩個DMV中移除(在sys.dm_exec_requests中會被轉移成lastwaittype),並將這個等待的型態及時間,加入sys.dm_os_wait_stats的統計資料中。
註:SOS_SCHEDULER_YIELD不會顯示在sys.dm_os_waiting_tasks中。

而任何的等待歷史,均會被紀錄在sys.dm_os_wait_stats。透過這個DMV,可以查詢各種等待類型(wait type)的累積等待時間,包含了訊號等待及資源等待。
sys.dm_os_wait_stats會在SQLSERVER啟動時歸零,或者我們也可以透過下列指令將其歸零:

dbcc sqlperf('sys.dm_os_wait_stats', clear)

在進行系統層級效能診斷時,可以查詢sys.dm_os_wait_stats,以取得系統自上次歸零以來至今的等待情況統計,根據這些最常發生的等待資訊,可以瞭解系統是否有資源使用上的瓶頸,並進行相對的優化。

在進行會話層級(session level)的效能診斷時可以使用sessiod_id查詢sys.dm_os_waiting_task或者sys.dm_exec_requests,以取得「目前」session的等待資訊。不過由於wait event結束後,該等待資訊即會從上述DMV中移除,所以只能看到目前的等待資訊(或者最後一次的等待資訊)。
註:SQLSERVER 2016有一個新的DMVsys.dm_exec_session_wait_stats」,可以紀錄session的各種等待資訊的彙總。

SQLSERVER 2016以前的版本,如果要知道session在執行命令過程中,究竟遇到了哪些等待,最佳方法是透過extented event來擷取session執行過程中的等待資訊,詳細的script,可以參考下列網址:
https://www.sqlskills.com/blogs/paul/capturing-wait-stats-for-a-single-operation/


回應時間 = 服務時間 + 合計等待時間服務時間指CPU真正用於執行命令的時間

瞭解WaitsSQLSERVER效能調校重要的一環,但它卻經常被人所忽略。我個人的學習歷程是先知道waits的一些概念後,再逐步的去瞭解一些重要的wait type及其背後的含義,在面臨SQLSERVER系統效能問題時,可以先查出系統的等待統計資訊,如果有正確的理解,這些等待資訊將會告訴你系統瓶頸的所在。

SQLSERVER隨著版本的昇級,其內建的等待類(wait type)數目也跟著增加,以SQL2012而言,應該有5-6佰個之多,我們很難每個等待類型都瞭解。但有些重要且常見的等待類型,建議從事DBA工作者們必需要知道,並且最好能深入的理解。

2017年4月15日 星期六

索引前導鍵(Leading-column)的選擇

如果有ABC三個欄位要據以建立一個索引,那麼建立的方式共有六種:
create index idx1 on TABLE(A,B,C)
create index idx1 on TABLE(A,C,B)
create index idx1 on TABLE(B,A,C)
create index idx1 on TABLE(B,C,A)
create index idx1 on TABLE(C,A,B)
create index idx1 on TABLE(C,B,A)

~~你會用哪一種?
~~索引鍵的順序不同,對搜尋效率有影響嗎?
~~如果有影響,那麼你的考量原則是什麼?

早期的我,由於不知道有什麼原則可以遵循,因此當時我會將每個索引都建一次,然後執行相應的SQL語句,觀察所耗用的時間及執行計畫的成本,最後選擇耗用時間或成本最少的索引。

~~我想大多數的DBA應該跟我一樣,曾經面臨這樣的問題~~


「索引」是排序過的資料,透過索引可以加快資料的搜尋速度。當索引建立的正確並有適當的維護時,它可以顯著的提高SQL語法執行的效能。索引可以想像成一般書籍的目錄,有這種目錄,你就可以快速的找到你要找的主題。如果沒有,那麼你可能要翻遍整本書才能找到你所需要的訊息。

一般會考量匹配前端AP常用SQL語法來據以建立索引,以加快SQL語法的執行速度。如果SQL語法的where子句只有一個欄位查詢條件,那麼就沒有鍵值順序的問題,只有這個欄位適不適合單獨建立索引的問題。

如果SQL語法的where子句有多個欄位查詢條件,通常我們會用這些欄位來建立一個「複合式索引」。

複合式索引,由多個欄位組成,這些欄位在索引中的先後順序,常見的建議是以每個欄位的「選擇性(Selectivity)」來決定其排列的順序。選擇性越高的,排在越前面。這樣的原則在B-Tree的搜尋上,可以最大程度上的過濾掉不需要的分支。加快葉層級掃瞄定位的速度。

選擇性」可以定義為:「不同紀錄與總紀錄的百分比」。例如:table共有1000筆資料,其中有個性別欄位[Gender],它的欄位值只有兩種「男、女」,所以Gender欄位的選擇性等於2/1000=0.002

選擇性越高,表示資料重覆的比例越低。

一個欄位的選擇性,可以簡單地使用下列SQL語法得知
select convert(float,count(distinct 欄位名))/count(*) from TABLE

舉個簡單的例子,假設有一句經常執行的SQL如下
select count(*) from TABLE where LoginDate='2017-03-17' and LoginName='Phil'

如果要匹配這句SQL語法建立index,有兩種建法
(1)  Create index idx1 on TABLE(LoginDate,LoginName)
(2)  Create index idx1 on TABLE(LoginName,LoginDate)

假設這兩個欄位的選擇性:
LoginDate0.02
LoginName0.0004

LoginDate的選擇性高於LoginName,因此(1),會是我們建立索引的較佳選擇。

在複合式索引中,除了欄位的排列順序外,對於索引前導鍵leading-column)的選擇尤為重要。那是因為在一個多欄位組成的索引中,只有第一個欄位是有序的。

索引的前導鍵必需出現在where條件子句中,否則這個索引是無法被搜尋的。
例如上面的索引:
Create index idx1 on TABLE(LoginDate,LoginName)
如果查詢語句為:
select count(*) from TABLE where LoginName='Phil'
索引前導鍵沒有出現在where條件中,這句查詢將無法使用到這個索引的搜尋。

~~然而,僅只考慮欄位的「選擇性」,並不足以決定最佳的索引策略

where條件子句,是由一些「比較運算符」組成,如「=<>betweenlike」等等,我們可把這些比較運算符歸類為兩種
(1) 等式(equality):即「=
(2) 非等式(inequality):凡不是「=」者,皆為非等式。

我們再看下面的例子
select * from TABLE where ID between 'Axxxxx' and 'Bxxxx' and Gender='M'
ID:身份證號
Gender:性別

查詢身份證號在某一範圍內的男性。其中ID欄位為「非等式(inequality)」,而Gender欄位為「等式(equality)

若要匹配此語法建立叢集索引,有兩種建法
create unique clustered index idx1 on TABLE(ID,Gender→身份證欄位在前
VS
create unique clustered index idx1 on TABLE(Gender, ID性別欄位在前

假設TABLE20萬筆紀錄,身份證號在此TABLE中是唯一的,那麼這兩個欄位的選擇性如下:
(1) ID1
(2) Gender2/200000=0.00001
在建立索引時,很自然地,我們會選擇ID做為前導鍵:
create unique clustered index idx1 on TABLE(ID,Gender) →身份證欄位在前

但是,只考慮選擇性,而不考慮where條件是「等式(equality)」或「非等式(inequality)」,這樣的索引真的會比較有效率嗎?

我們透過少量的資料,來解釋一下….
TableTestIndex」,假設有10筆資料,我們對它執行一個查詢:
select count(*) from TestIndex where ID between 2 and 6 and Gender='M'

分兩種情況來看
一、以ID,Gender來排序
如下表所示,在B-Tree葉層級(leaf level)共需掃瞄5筆資料後,可得到確定的結果

二、以Gender,ID來排序
如下表所示,在B-Tree葉層級(leaf level)共需搜尋比對3筆資料後,即可得到結果

~~這代表著什麼意思呢?

select count(*) from TestIndex where ID between 2 and 6 and Gender='M'     
首先在這句查詢條件中,ID是「非等式(inequality)」,Gender是「等式(equality)」

SQLSERVER的索引結構,使用「查詢條件值」,與B-Tree的根節點及中間節點內的鍵值進行比對,逐層確定要掃瞄範圍。當到達葉層級(leaf level)後,即開始進行掃瞄,若一頁掃瞄完成尚未超過條件值的範圍,則根據頁鏈再往下一頁掃瞄,直到發現第一筆超過範圍的紀錄為止。

多重欄位的複合式索引,一般的建議是將「選擇性」最高的欄位放在最前面做為前導鍵,這個主要是為了在根節點及中間節點的比對上,消除更多的分支,這會節省一些時間在確定掃瞄範圍上。

但一旦到達leaf level開始掃瞄,如果前導鍵是「非等式inequality」,可能導致在葉層級的掃瞄範圍擴大(如上面少量資料的說明)。

使用選擇性高的「非等式inequality」欄位做為索引的前導鍵,雖然可以加快「掃瞄範圍定位」的時間,但也會使得在葉層級的掃瞄頁數擴大許多。

掃瞄範圍的定位在根節點及中間節點中確定,即使是在大型的索引,可能也只有6~7個層級,因此它定位節省的成本有限。
反而是在葉層級的資料掃瞄上,使用「非等式inequality」做為索引的前導鍵,有可能擴大掃瞄的範圍成百上千頁。因此在索引建立時,關於葉層級的掃瞄成本應列為優先考量

以下截錄一段SQLCAT Team的說明,請參考
However, once the leaf level is reached and scanning begins on the requested range, placing the inequality column first can result in the reading of hundreds or even thousands of extra pages to satisfy the same query. Clearly, the tradeoff at the leaf level can quickly overwhelm the advantages gained at the root and intermediate levels on queries that return ranges of values.
Google英翻中(有修飾):
一旦達到葉級並且開始掃描所需要的範圍,則不等式欄位(inequality column)為前導鍵,可以導致讀取數百甚至數千個額外的頁面,以滿足相同的查詢。 顯然,在葉級別上的權衡可以很快地壓倒根節點和中間層節點對返回符合範圍的查詢所獲得的優勢。

多重欄位索引,使用「等式(equality)」欄位為前導鍵,會比用「非等式(inequality)欄位做為前導鍵更有效率。即使等式的欄位選擇性非常的低,即使它是像「性別」這種只有兩種資料的欄位。

多重欄位索引鍵的考量優先順序應為:
(1)等式(equality)欄位
(2)非等式(inequality)欄位
(3)若等式(或非等式)欄位有多個,則使用這些欄位的選擇性由高到低順序排列。

因此,在上面的例子中
select count(*) from TestIndex where ID between 2 and 6 and Gender='M'  
若要建立複合式(叢集或非叢集)索引以匹配這樣的查詢,應該以等式欄位Gender為前導鍵:
create unique clustered index idx1 on TestIndex(Gender,ID)

==========================================================

我們來做一個實驗,以實做來瞭解上面的說法並確認其是否正確

首先建立一個 Table
身份證號ID,用identity來模擬,Gender為性別,另加一個固定長度的住址欄位,目地是讓一筆資料夠長,令整個Table多一些pages,以利評估。

create table TestIndex(ID int identity ,Gender char(1),Addr char(200))

新增男女各10萬筆資料
declare @x int =1
while (@x<=100000)
  begin
    insert into TestIndex values('M' , 'This is a Address...')
    insert into TestIndex values('F' , 'This is a Address...')
       set @x=@x+1
  end

首先執行一次全表掃瞄查詢
set statistics io,time on
select count(*) from TestIndex

資料表 'testindex'。掃描計數 1邏輯讀取 5406,實體讀取 0,讀取前讀取 0LOB 邏輯讀取 0LOB 實體讀取 0LOB 讀取前讀取 0
SQL Server 執行次數:
CPU 時間 = 94 ms,經過時間 = 113 ms

上面執行後的統計結果意思是,SQLSERVER完成此次查詢,總共讀取了5406頁,花費CPU時間94 ms,總花費時間(含IO等)113 ms


首先我們建立叢集索引,使用選擇性高但條件是inequality的欄位「ID」做為前導鍵
create unique clustered index C_idx_ID on TestIndex(ID,Gender)

執行條件式查詢
select count(*) from TestIndex where ID between 100 and 300 and Gender='M'
從結果中可以看到,索引使用ID做為前導鍵,邏輯讀取為10

接著我們再來測試,使用性別做為前導鍵
建立叢集索引,使用選擇性低,但條件是equality欄位「Gender做為前導鍵
drop index TestIndex. C_idx_ID
create unique clustered index C_idx_Gender on TestIndex(Gender,ID)

執行同樣的查詢
dbcc freeproccache
select count(*) from TestIndex where ID between 100 and 300 and Gender='M'
從結果來看,反而是選擇性低的「性別」欄位在前的索引,效能更好。邏輯讀取由10降到了7
註:
通常我們使用「邏輯讀取」來衡量SQL的效能,它越小越好。我們不會使用CPU Timeelapsed time(經過時間),因為這兩個時間值有可能是「不可測量的小(immeasurably small)」,例如你可能經常看到CPU time = 0 ms
另外一個elapsed time還包含了I/O子系統所花費的時間,而I/O子系統有可能因為其它因素忙碌,使得elapsed time是會變動的,故不適合做為評估基準。
使用「邏輯讀取」數,可以衡量同一句SQL走不同index的成本以評估哪個index更佳。或者用在索引碎片重整前後或統計值更新前後的效能指標。

從上面的實驗,我們初步證明了:
多重欄位索引,使用「等式(equality)」欄位為前導鍵,會比用「非等式(inequality)欄位做為前導鍵更有效率。

~~然而SQLSERVER的優化器,它知道這個原則嗎?

我們再來測試
刪除叢集索引
drop index TestIndex.C_idx_Gender

分別使用IDGneder做為前導鍵,建立兩個非叢集索引
create index idx_ID on TestIndex(ID,Gender)
create index idx_Gender on TestIndex(Gender,ID)

執行同樣的查詢
dbcc freeproccache
select count(*) from TestIndex where ID between 100 and 300 and Gender='M'

從結果的執行計畫中可以看到,系統優化器選擇走「idx_Gender」這個索引,而這索引是用equality選擇性低的性別(Gender)欄位為前導鍵的。這表明SQLSEVER優化器知道equality欄位為前導鍵的索引是更好的選擇。

~~接下來,我們感興趣的是,SQLSERVER是如何地判斷哪個index是更好的選擇?

反對使用低選擇性的欄位做為索引前導鍵的主要理由是:「統計資料只會針對索引的第一個欄位建立統計訊息」,如果前導鍵只有男、女兩種選擇,那麼據以建立的統計訊息,是不太具有參考價值的。

對於「直方(Histogram)」而言,這種說法是正確的。

但在多where條件下,優化器評估要走哪個index,直方圖並不是唯一的參考因子。如「身份證號」跟「性別」的例子,這個索引無論哪個欄位當做前導鍵,SQLSERVER仍然會在產生執行計畫時,評估第二個欄位的統計訊息。

~~我們以上面的範例,繼續說明

首先刪除相關的index及該table的所有統計訊息,並重建相同的兩個索引
create index idx_ID on TestIndex(ID,Gender)
create index idx_Gender on TestIndex(Gender,ID)

查看TABLE目前有幾個統計訊息
select * from sys.stats where object_id = OBJECT_ID('testindex')

上圖可以看到目前textindex這個Table,有兩組統計資料。

執行同樣的查詢
dbcc freeproccache
select count(*) from TestIndex where ID between 100 and 300 and Gender='M'

優化器選擇走idx_Gender,而且有正確估計到資料列數目為100

檢查索引的使用狀況
檢查索引的使用狀況,我們確認只有idx_Genderseek1次,另一索引idx_ID並沒有被使用過。

再次查看TABLE目前有幾個統計訊息
select * from sys.stats where object_id = OBJECT_ID('testindex')
執行完查詢後,還是一樣,有兩組統計資料。
  
我們檢查一下idx_Gender的統計資料
dbcc show_statistics(TestIndex,idx_Gender)
看紅框處,系統確實僅使用前導鍵「Gender/性別」做為直方圖。在這個直方圖中,只有FM兩種資料。每種資料各有10萬筆。

~~~那麼,問題來了~~

系統走idx_Gender這個索引,依照這索引的統計資料,系統是憑什麼「估計」出符合條件的有100 .!??

我們先刪除掉另一個索引
drop index testindex.idx_ID

查看TABLE目前有幾個統計訊息
select * from sys.stats where object_id = OBJECT_ID('testindex')
確定只剩下一組統計資料
  
執行同樣的查詢
dbcc freeproccache
select count(*) from TestIndex where ID between 100 and 300 and Gender='M'
我們可以發現,系統仍然選擇使用相同的索引idx_Gender,但「估計」的資料列數目,卻由原本的「100」變成了「1」。這個估計值跟實際的資料列數目,誤差頗大。

執行計畫所使用的index沒變,但估計的資料列數目變了。這表示某個用於產生執行計畫的統計資料發生了改變。而這個改變了的「統計資料」,必然不是idx_Gender這個索引的統計值。而是另有其它。因為idx_Gender並沒有任何的變動。

查看TABLE目前有幾個統計訊息
select * from sys.stats where object_id = OBJECT_ID('testindex')
我們可以看到,在執行查詢後,系統竟然自動增加了一組統計資料「auto_created = 1」。

我們檢查一下,看看這個新增的統計資料,它到底統計了什麼
dbcc show_statistics(TestIndex,_WA_Sys_00000001_744F2D60)
由上圖可以發現,系統自動產生了「ID」這個欄位的統計資訊。

自動產生的統計資料跟建立索引時產生的統計資料,兩者最大的不同點在於自動產生的統計資訊係採取抽樣方式建立,所以統計資料會有較大誤差(上圖共有200000筆資料,抽樣數為54954)。

而建立索引時所產生的統計資訊則是fullscan,故不會有誤差。

之所以會自動產生統計資料,是因為查詢的語句中,有用到ID欄位為條件,但優化器搜遍TABLE所有的索引,找不到ID欄位的直方圖(histogram)統計值,系統便會去檢查資料庫的「自動建立統計資料」的選項是否為true(如下圖),若是true,則自動產生ID的統計資料。

我們手動更新一下自動建立的統計訊息
update statistics testindex _WA_Sys_00000001_744F2D60 with fullscan

執行同樣的查詢
dbcc freeproccache
select count(*) from TestIndex where ID between 100 and 300 and Gender='M'

更新自動建立的統計資料後,可以發現估計的資料列數目回到正常。

綜上所述,多重欄位索引中無論哪個欄位做為前導鍵,SQL Server在確定使用哪個索引之前,會以SQL語法的where條件,去評估每個欄位的統計資料。僅僅只有前導鍵的統計訊息並不足夠。會在產生查詢計劃時搜尋其它欄位的統計資訊。

SQL Server首先依where條件的欄位,查看有沒有索引是用這些欄位當前導鍵的。如果有,SQL Server將會使用該索引的統計資料。接著搜尋該索引有無下一個where條件欄位的統計訊息。如果沒有,則檢查其它索引是否包含這欄位的統計訊息,如果也找不到,且該資料庫已啟用了AUTO_CREATE_STATISTICS。就會自動建立該欄位的統計訊息,並且在產生查詢計劃時使用這些自動創建的統計資料。
  
結論
在「自動建立統計資料」及「自動更新統計資料」兩選項為true的前提下。
多重欄位索引的建立,僅僅檢查欄位的基數或選擇性,並不足以決定最佳的索引策略。
SQL語法的查詢條件,包含等式(equality)和非等式(inequality)的欄位時,應首先考慮等式(equality)欄位 - 即使這個欄位的選擇性非常的低。即使這個欄位像是性別欄位(只有兩種型態資料)也是如此。

請注意,將非等式(inequality)欄位做為前導鍵,永遠不會比等式(equality)欄位更有效。

索引鍵欄位的排列順序,考量因素應為:
(1)等式(equality)欄位
(2)非等式(inequality)欄位
(3)若等式(或非等式)欄位有多個,則使用這些欄位的選擇性由高到低順序排列。

寫在最後
我記得有人說過,DBA依工作性質,可以分成兩類:
一、AP-DBA:主要是與AP開發人員協同工作,經常與開發人員接觸。
二、DB-DBA:專職負責維持並保障DB的穩定服務,較少與AP接觸。

如果你是一位所謂的AP-DBA,那麼理解及掌握索引的知識,將非常有助於工作的推展。

實務上,AP所執行的SQL語法,除了where條件外,還經常會包含有group by/order by..等條件。而本篇,主要著重在索引「前導鍵」的選擇,對於當where / group /order 同時出現時的索引策略,以及clustered vs non-clustered indexkey column vs include column等議題,則不在討論範圍。

索引是影響資料庫效能的重要關鍵因素,但卻少有書本或文章去談論它建立時應考量的原則及策略。這篇文章主要在說明複合式索引的欄位順序該如何決定,特別著墨在前導鍵的選擇上。文中做了比較多的實驗,或許讀起來比較凌亂,但其中包含了一些重要的觀念。

我自己跌跌撞撞的摸索多年,才算是對它有初步的瞭解。

希望這篇文章有助於跟我一樣正在DBA這條路上前行的朋友們,在面對多重欄位索引時,不會再猶疑不定,也可以少走一些彎路。