索引修改內幕

索引修改的大致規則

  • 對錶的任何修改操作(UDI),總會對錶上的非聚集索引執行等價的操作。某些更新操作除外。

  • 對錶的任何修改操作,都會先修改堆或者聚集索引,然後再修改非聚集索引。

  • 如果修改的數據行,正是過濾索引過濾掉的行(過濾索引的葉級頁不包含的行),則不會對過濾索引產生任何操作。


插入數據行

  對於聚集和非聚集索引的插入,新行(不管是數據行還是索引行)所包含的索引鍵列值就決定了它將被插入的位置。插入操作的可能來源有:

  • 直接的INSERT命令

  • UPDATE導致的行移動(原來的地方已經容不下被更新後的行),內部使用先DELETE,再INSERT的UPDATE策略。

  • UPDATE導致的索引鍵列變更。索引行是有序的,行的索引鍵值變更會導致行在索引中的位置變更,從而需要移動到新位置。同樣是先DELETE,再INSERT。

如果當前索引的葉級(葉級在聚集索引中是數據頁,非聚集索引中是索引頁)沒有空間存放插入的新行,則索引會發生頁拆分(Page Split)。行在索引中的位置是有序的,所以當新行將要被插入的某個特定頁沒有可用空間時,就需要分配新頁給索引。會先從已經分配的區中找是未使用的頁,如果沒有,則會分配一個新的統一區給索引,然後再使用新區中的頁。


頁拆分

 得到新頁之後,SQL Server會盡量按照”對半分“原則,拆分原來頁上的一半數據行到新頁。第一次拆分是基於頁上偏移陣列(Offset Array)來計算的。每次索引頁拆分,還要向B+樹中的父級頁添加一行。有時需要多次頁拆分才能將新行保存下來。頁拆分發生的越多,新頁也載多,需要向你級頁添加的行數載多,很有可能同時導致父級頁也發生頁拆分。

      索引樹的查找方式是從根節點向葉節點進行的,所以Insert導致的頁拆分也是從根節點向下發生的。這樣在Insert導致的拆分未完成前,索引樹需要使用閂鎖(Latch)對索引進行保護,以防止索引被其它的操作修改。當從磁盤上讀/寫頁時或者對數據頁進行操作時(如頁拆分),爲了保護頁中的數據的物理完整性,需要對頁加上閂鎖進行保護。當子節點的拆分完成並且不再需要對父節點進行更新時,索引樹中父節點的閂鎖纔會被釋放。 

      在父節點的閂鎖釋放前,SQL Server會檢測父節點頁中是否還能容納兩行新數據。如果不能,則拆分它。這種情況只會當查找索引,並且需要向索引頁中添加新行時纔會發生。這樣做的目的是當由於子級頁發生頁拆分而需要向父級頁插入新行時,父級頁總是有空間存放這些新行。


頁拆分的類型由發生拆分的頁的類型決定

根頁拆分

  當根頁發生拆分時,會分配兩個新頁給索引。原來根頁的數據會被插入到這兩個新頁中。原來的根頁仍然是索引的根頁,它上面只有兩行數據,分別指向兩個新頁。原來的根頁被保留,可以避免修改系統目錄中指向根頁的指針值。根頁拆分會導致索引增加新的一級索引層次(深度增加一級)。這種拆分很少發生。


中間級頁拆分

  中間索引頁發生拆分時,會增加一個新頁,然後根據索引鍵的中間點(Midpoint)將一半的行拆分到新頁,再往父級頁中插入一行指向新頁。這種拆分也很少發生。


葉級頁拆分

  這是最常見,也是最需要關注的拆分類型。聚集索引數據頁和非聚集索引葉級頁的拆分機制是一樣的。雖然數據頁拆分只會發生在對聚集索引表執行Insert操作時,但是也可能是Update操作導致的內部Insert操作。前文提過了,當Update不是原地更新時,會執行先Delete再Insert的操作。

  葉級頁的拆分與中間級頁的方式類似。但是需要索引管理器決定兩頁中的誰來接收後續的新行,還要處理兩個頁面誰也存不下的大型行(Large Row)。數據頁拆分不會改變聚集索引鍵,所以相關的非聚集索引不會受到影響。

  下面通過例子觀察一下葉級頁拆分。創建一個表,定義並插入大型行,使得一個頁只能存放5行數據,然後插入第6行數據後,觀察頁拆分的情況。注意第6行的聚集鍵小於第5行。

USE test
go
CREATE TABLE bigrows
(
 a int primary key,
 b varchar(1600)
);
GO
/* Insert five rows into the table */
INSERT INTO bigrows
 VALUES (5, REPLICATE('a', 1600));
INSERT INTO bigrows
 VALUES (10, replicate('b', 1600));
INSERT INTO bigrows
 VALUES (15, replicate('c', 1600));
INSERT INTO bigrows
 VALUES (20, replicate('d', 1600));
INSERT INTO bigrows
 VALUES (25, replicate('e', 1600));
GO
--get the data page id
select allocated_page_file_id as PageFID,allocated_page_page_id as PagePID,page_type_desc
from sys.dm_db_database_page_allocations(db_id('test'),object_id('bigrows'),null,null,'Detailed')
go
dbcc traceon(3604)
dbcc page(test,1,168,1)
go
--OFFSET TABLE:
--Row - Offset                        
--4 (0x4) - 6556 (0x199c)            
--3 (0x3) - 4941 (0x134d)            
--2 (0x2) - 3326 (0xcfe)              
--1 (0x1) - 1711 (0x6af)              
--0 (0x0) - 96 (0x60)
INSERT INTO bigrows
 VALUES (21, replicate('f', 1600));
GO
select allocated_page_file_id as PageFID,allocated_page_page_id as PagePID,page_type_desc
from sys.dm_db_database_page_allocations(db_id('test'),object_id('bigrows'),null,null,'Detailed')
go
dbcc traceon(3604)
dbcc page(test,1,168,1)
dbcc page(test,1,172,1)
go
--Page 168
--OFFSET TABLE:
--Row - Offset                        
--2 (0x2) - 3326 (0xcfe)              
--1 (0x1) - 1711 (0x6af)              
--0 (0x0) - 96 (0x60)
--Page 172
--OFFSET TABLE:
--Row - Offset                        
--2 (0x2) - 1711 (0x6af)              
--1 (0x1) - 3326 (0xcfe)              
--0 (0x0) - 96 (0x60)

 

通過偏移陣列,可以看出頁拆分後原來的頁和新頁各有三行。通過觀察DBCC PAGE的輸出的行內容,可以看到:

  • 原來的頁(PID:168)保存着聚集索引鍵值爲5,10和15的行,新頁(PID:172)保存着20,21和25的行。

  • 在新頁上,21這行存儲在Slot1的位置上。根據Offset的值,21實際的物理位置卻是在25之後。

21的邏輯位置在25之前,物理位置在25之後。可以看出:行的聚集索引鍵順序是由Slot編號表示的,而不是行的物理位置。也就是聚集索引表中,某行的Slot編號小於另一行,則它的聚集索引鍵值也小於它。這其實是一種優化的設計結果:當頁上發生數據修改時,只需要修改頁上偏移陣列的值來保證行的順序,而不需要物理性的移動數據行位置來保證順序。極大地減少數據修改的開銷。所以“索引中行的物理存儲順序總是與它的索引鍵值的順序是一樣的。”這種說法是不正確的。實際上,只要偏移陣列提供了正確的邏輯順序,行可以存儲頁的任意位置。

  頁拆分的代價是很大的。頁拆分過程中對舊頁、新頁和父頁的修改操作,都需要完整寫入事務日誌。最小化業務高峯期發生頁拆分的辦法,通常有:

  • 選擇一個更合理的聚集索引鍵。比如,讓新行插入到表的末尾,而不是像GUID那樣隨機插入。

  • 對於更新變長列引起的頁拆分,可以通過減少索引的填充因子(Fill Factor),在頁上保留多一些可用空間給變長列更新使用。


刪除數據行

  刪除數據行時,需要同時考慮數據頁和索引頁的變化。聚集索引表中刪除行與非聚集索引葉級中刪除行是一樣的方式。


葉級中刪除行

  當索引葉級的行被刪除時會被標記爲幻影行(Ghost Record)。行被刪除後,行頭的一個位(Bit)被修改,行就標記爲幻影,但是行還是保留於頁上。頁頭的元數據m_ghostRecCnt表示當前頁的幻影行的數量。幻影行的用途有:

  • 快速回滾。當行沒有被物理刪除時,回滾Delete操作只需要修改行頭的表示幻影行的位即可。

  • 鍵值範圍鎖定(key-Range Locking)和其它鎖定的併發優化

  • 用於行版本控制

       幻影行什麼時候被清除,由系統負載決定。SQL Server有一個叫做ghost-cleanup的後臺線程,用於清理那些不再需要被活動事務和其它功能使用的幻影行。幻影行可能很快被ghost-cleanup線程清除掉。所以爲了觀察幻影行,可以將Delete包裹在未被提交或者回滾的用戶事務中,或者使用末公開的(Undocumented)跟蹤標記661來禁用幻影行清理功能。可以使用存儲過程sp_clean_db_free_space清除整個庫的幻影行,也可以用sp_clean_db_fie_free_space清除庫中指定數據文件中的全部幻影行。

       下面的例子,刪除聚集索引表中的一行,觀察幻影行。

USE test
GO
IF object_id('dbo.smallrows') IS NOT NULL
 DROP TABLE dbo.smallrows;
GO
CREATE TABLE dbo.smallrows
(
 a int IDENTITY PRIMARY KEY,
 b char(10)
);
GO
INSERT INTO dbo.smallrows
 VALUES ('row 1');
INSERT INTO dbo.smallrows
 VALUES ('row 2');
INSERT INTO dbo.smallrows
 VALUES ('row 3');
INSERT INTO dbo.smallrows
 VALUES ('row 4');
INSERT INTO dbo.smallrows
 VALUES ('row 5');
GO
--get data page id
select allocated_page_file_id as PageFID,allocated_page_page_id as PagePID,page_type_desc
from sys.dm_db_database_page_allocations(db_id('test'),object_id('smallrows'),null,null,'Detailed')
go
DELETE FROM dbo.smallrows
WHERE a = 3;
GO
dbcc traceon(3604)
dbcc page(test,1,174,1)
go

截取DBCC PAGE輸出中與幻影行相關的內容。

  • 頁頭中(Page Header)中的 m_ghostRecCnt = 1,表示當前頁中幻影行數量是1。

  • Slot2中的Record Type = GHOST_DATA_RECORD,表示當前行是幻影行。

  • 偏移陣列中,Slot2的偏移量沒有變,其它Slot的偏移理也沒有變化。表示沒有行發生移動。

OFFSET TABLE:

Row - Offset                        

4 (0x4) - 180 (0xb4)                

3 (0x3) - 159 (0x9f)                

2 (0x2) - 138 (0x8a)                

1 (0x1) - 117 (0x75)                

0 (0x0) - 96 (0x60)   

可以通過sys.dm_db_index_physical_stats查看錶中的幻影行總數。

中間級中刪除行

  中間級中刪除行與從堆表中刪除行類似。中間級頁中被刪除行,不會被標記爲幻影行,所佔用的頁空間也不會立刻釋放,當有新索引行需要頁面上的空間時才被釋放和重用。


頁回收

  數據頁上所有行被刪除後,這個頁會被ghost-cleanup線程回收(Dealocated)。堆表是個例外。如果表只有一個數據頁,此頁也不會被回收。當數據頁被刪除,指向這些數據頁的索引行會被刪除。中間級頁上索引行全被刪除,不會馬上回收,而是會在頁上保留一行,這一行稍後會被移動到鄰近的有空閒空間的頁上,然後回收原來的空頁。


更新行

  SQL Server會自動選擇最優的數據更新策略。基於受影響行數,訪問數據的方式和是否需要修改索引鍵來選擇最優的策略。更新實現方式包括:直接將舊值原地修改爲新值;刪除舊行再插入新行。


行移動

  發生行移動的兩種情況:

當行中的變長列被更新後,原來位置無法再存儲它時

因爲行的邏輯順序由索引鍵決定,所以當聚集索引或者非聚集索引的鍵列發生修改後,行的邏輯順序發生改變時。例如當在lastname列上建立聚集索引,lastname爲Abel的行存儲在接近表開始的位置,如果將Abel修改爲Zek,則將會被移動到接近表結束的位置。

 非聚集索引的葉級中包含指向表中每一行的行定位器。聚集索引表中,行定位器就是聚集索引鍵。所以僅當聚集索引鍵被修改時,非聚集索引纔會被修改。因此選擇聚集索引鍵列時,儘量選擇非易失性的列(數據修改率極低,如Identity)。聚集索引表中,就算表的物理位置發生改變,也不會導致索引鍵改變,所心非聚集索引也不會被修改。

 堆表中,行定位器是行的物理地址。行移動不會導致非聚集索引修改,因爲它在行的原地址放置一個轉發指針指向新地址,非聚集索引仍然引用原來的行地址,通過轉發指針做重定向而已。但是堆表物理位置改變,會導致所有非聚集索引被修改。


原地更新

 原地更新行是SQL Server的更新規則。每一個原地更新操作都會在向事務日誌寫入一行,除非表上有更新觸發器或者被標記爲複製時。

如果原地更新需要修改索引鍵,則每個操作會向事務日誌先寫入一條DELETE記錄,然後再寫入一樣INSERT記錄。

   原地更新的場景:

  • 更新堆,被更新的頁有足夠的空間存放更新後的行。

  • 更新聚集索引表,且聚集索引列沒有被更新。

  • 更新聚集索引表的聚集索引列,但是更新後的行不會發生移動。


非原地更新

  非原地更新發生在更新聚集索引的索引鍵時。更新會變成先刪除再插入兩個操作。更新索引鍵也有可能是混合更新,即有些行是原地更新,其它行是非原地更新。更新聚集索引鍵時,SQL Server會生成一個包含刪除和插入操作涉及到的所有行的列表。這個列表較小就存在內存,較大就存在tempdb。然後根據鍵值和操作符(刪除或者插入)對列表排序。接下來分種情況:

  1. 如果索引鍵值非唯一,則先刪除再插入。

  2. 如果索引鍵值唯一,則會將刪除和插入這兩個操作合併成一個更新操作。這樣更高效。


表級修改vs.索引級修改

  在多索引的表上修改多行數據時,SQL Server提供兩種索引維護策略:表級修改和索引級修改。表級修改也叫做"一次一行"(row-at-a-time),索引級修改也叫做”一次一索引“(index-at-a-time)。

  在表級修改中,每一行數據被修改時,所有的索引都需要被維護一次。如果更新流是無序的,則SQL Server每更新一行就需要訪問一次索引,這樣就增加很多的隨機訪問。如果更新流是有序的,因爲只能按一種條件排序,所以最多有一個索引不需要隨機訪問。

  在索引級修改中,SQL Server將所有將要被修改的行彙總起來,並針對索引進行排序(有幾個索引,就會有幾次排序)。然後將所有的修改彙總再應用到每個索引上。可以看出,這個過程中每個索引頁最多被訪問一次。

  修改大表和索引上中的很少部分數據,SQL Server一般採用表級修改。如果修改的量非常大,則一般會選擇索引級修改。通過執行計劃可以看出採用的哪種修改方式:每個受影響的索引前都有一個UPDATE操作符,則是索引級。如果只有UPDATE操作符,則是表級。




發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章