簡介
之前我已經寫了一個關於SQL Server日志的簡單系列文章。本篇文章會進一步挖掘日志背后的一些概念,原理以及作用。
數據庫的可靠性
在關系數據庫系統中,我們需要數據庫可靠,所謂的可靠就是當遇見如下兩種情況之一時保證數據庫的一致性:
- 在系統崩潰/故障等情況下,保證數據庫的一致性
- 數據不能在多個DML語句同時修改數據的情況下,導致不一致或數據損壞
實際上,上述第二種情況就是並發性所需要解決的問題,傳統關系數據庫中,我們用鎖來解決這個問題,而對於內存數據庫或帶有樂觀並發控制的數據庫系統,通過多版本並發控制(MVCC)來解決這個問題。因為本篇文章的主旨是討論日志而不是並發,因此對於上述第二種情況不會詳細解釋。
我們上面還多次提到了一致性(Consistence),在開始了解日志如何維持一致性之前,我們首先要明白什么是一致性。一致性在數據庫系統中所指的內容比較廣,一致性不僅僅需要數據庫中的數據滿足各種約束,比如說唯一約束,主鍵約束等,還需要滿足數據庫設計者心中的隱式約束,簡單的業務約束比如說性別這列只允許男或女,這類隱式約束通常使用觸發器或約束來實現,或是在數據庫所服務的應用程序中進行約束。
下面我們把一致性的范圍縮減到事務一致性,事務一致性的概念學術上的解釋為:
如果事務執行期間沒有出現系統錯誤或其他事務錯誤,並且數據庫在事務開始期間是數據一致的,那么在該事務結束時,我們認為數據庫仍然保證了一致性。
因此,引申出來事務必須滿足原子性,也就是事務不允許部分執行。事務的部分執行等同於將數據庫置於不一致的境地之下。此外多事務並發執行也可能導致數據庫不一致,除非數據庫系統對並發進行控制。
關於上面的顯式約束,由數據庫系統來實現,比如說違反了一致性約束的語句會導致數據庫系統報錯並拒絕執行。但一些隱式的事務約束,比如說寫語句的開發人員對系統設計者所設計的規則並不了解,導致了違反業務規則的數據修改,這種情況在數據庫端很難探查。但是這種問題通常可以規則到權限控制的領域,我們認為授予某個用戶修改特定數據的權限,就認為這個用戶應該了解數據庫中隱式和顯式的規則。
除去這些業務上的數據不一致之外,我們需要在系統崩潰等情況下保證數據的一致性,而可能導致這類數據不一致的情況包括但不限於下面這些情況:
- 存儲系統損壞,比如說磁盤上字節級別的損壞,這類問題通常可以通過磁盤上的奇偶校驗發現,另外還有一些大一些的問題,比如說整個存儲系統崩潰。這類問題的修復手段取決於前期工作,比如說備份策略,高可用性架構,SAN Replication等技術。
- 機房整體損壞,這類問題比較極端,只有異地機房容災可以解決。
- 系統故障,修改數據的進程都需要事務作為上下文,和其他概念一樣,事務也是有狀態的。而事務狀態通常存儲在易丟失的主存中,因此,當出現系統故障、進程崩潰等系統失敗時,可能導致事務狀態的丟失,此時,我們就無法得知事務中的哪部分已經執行而哪部分還未執行,重新運行事務並不會解決這類問題,因為有可能導致事務中某部分的重復執行。因此解決這類問題的方式就是將事務的狀態以及對數據庫修改的詳細步驟與內存中的數據分開存放,並存儲於磁盤等穩定的介質中,當系統故障等情況下,我們可以通過這些記錄來將系統恢復到一致性的狀態之下,我們對這類存儲,稱之為日志。
SQLServer中的日志
SQL Server中靠日志來維護一致性(當然,日志的作用非常多,但一致性是日志的基本功能,其他功能可以看作是額外的功能)。通常我們創建數據庫的時候,會附帶一個擴展名為ldf的日志文件。日志文件其實本質上就是日志記錄的集合。在SQL Server中,我們可以通過DBCC LOGINFO來看這個日志的信息,如圖1所示。
圖1.DBCC LOGINFO
該命令可以從VLF的角度從一個比較高的層級看日志。其中值得注意的列是VLF大小,狀態(2表示使用,0表示從未使用過),偏移量。對於這些信息對我們規划VLF數量的時候很有幫助,因為VLF過多可能引起嚴重的性能問題,尤其是在復制等Scale-Out或HA環境下。
然后,事務對數據庫中每次修改都會分解成多個多個原子層級的條目被記錄到持久存儲中,這些條目就是所謂的日志記錄(Log Record),我們可以通過fn_dblog來查看這些條目。如圖2所示。
圖2.Fn_dblog
每個日志記錄都會被背賦予一個唯一的順序編號,這個編號大小為10字節,由三部分組成,分別為:
- VLF順序號(4字節)
- Log Block順序號(4字節)
- Log Block內的順序編號(2字節)
因此,由於VLF是不斷遞增的(同一個VLF被復用會導致編號改變),因此LSN序號也是不斷遞增的。因此,通過上面的LSN結構不難發現,如果比VLF更小的粒度並不是直接對應LOG RECORD,而是LOG Block。Log Block是日志寫入持久化存儲的最小單位,Log Block的大小從512字節到60K不等,這取決於事務的大小,那些在內存還未被寫入持久化存儲的Log Block也就是所謂的In-Flight日志。以下兩個因素決定Log Block的大小:
- 事務提交或回滾
- Log Block滿60K會強制Flush到持久化存儲,以保證WAL
因此當一個事務很大時(比如說大面積update),每60K就會成為一個Log Block寫入持久化存儲。而對於很多小事務,提交或回滾就會稱為一個Block寫入持久化存儲,因此根據事務的大小,LOG Block的大小也會不同。值得疑惑的是,因為磁盤上分配單元的大小是2的N次方,因此最接近LOG BLOCK的大小應該是64K,而SQL Server為什么不把Log Block設定為64K呢。這樣可以更優化IO。
VLF和Log Block和Log Record的關系如圖3所示。
圖3.三者之間的關系
從比較高的層級了解了日志之后,我們再仔細了解日志中應該存儲的關鍵信息,每條Log Record中都包含下面一部分關鍵信息:
- LSN
- Log Record的Context
- Log Record所屬的事務ID(所有的用戶事務都會存在事務ID)
- Log Record所占的字節
- 同一個事務中上一條Log Record的LSN(用於Undo)
- 為Undo所保留的日志空間
當然,這些僅僅是日志的一小部分內容。通過Log Record所記錄的內容,就能夠精確的記錄對數據庫所做的修改。
日志用於Undo
在了解為了Undo,日志所起的作用之前,我們首先可以了解一下為什么需要事務存在回滾:
- 因為事務可能失敗,或者死鎖等原因,如果希望事務不違反原子性而造成數據庫不一致的話,則需要通過回滾將已經部分執行的事務回滾掉。
- 根據業務需求,如果在某些關聯業務失敗等情況下,回滾數據。
因此,Log Record會為這些列保存一些字節來執行數據庫回滾,最簡單的例子莫過於執行插入后Rollback事務,則日志會產生一條所謂的Compensation Log Record來反操作前面已經插入的事務,如圖4所示。
圖4.Compensation Log示例
圖4執行的是一個簡單的Insert語句,然后回滾。我們看到,SQL Server生成了一個Compensation Log Record來執行反向操作,也就是Delete操作。值得注意的是,為了防止這些回滾操作,SQL Server會保留一些空間用於執行回滾,我們看到LOP_INSERT_ROWS保留的74字節空間被下面的Compensation Log Record所消耗。Compensation Log record還有一個指向之前LSN的列,用於回滾,直至找到LOP_BEGIN_XACT的事務開始標記。另外,Compenstion Log Record只能夠用於Redo,而不能用於Undo。
那假設我們某一個事務中刪除了多條數據怎么辦?比如說,某一個事務中一個Delete語句刪除了10行,則需要在Log Record對應10個LOP_DELETE_ROWS(引申一下,由此我們可以看出某一個語句可能導致N個Log Record,這么多Log Record在復制,鏡像時都需要在另一端Redo,因此需要額外的開銷),如果我們此時RollBack了該事務,則Redo的順序是什么呢,如圖5所示。
圖5.回滾事務
圖5中,刪除3條數據后,進行回滾,首先從刪除3開始,生成對應的反向Compensation Log Record,並指向刪除2,再對應刪除2生成反向Compensation Log Record並指向刪除1,以此類推,最終回滾事務指回開始事務。
日志用於Redo
與Undo不同,在計算機存儲體系中,輔助存儲通常是帶有磁頭的磁盤。這類存儲系統的IOPS非常低,因此如果對於事務對數據庫執行的修改操作,我們積累到一定量再寫入磁盤,無疑會提高IO的利用率。但是在數據在主存還沒有持久化的輔助存儲的期間,如果遭遇系統故障,則這部分數據的丟失則可能導致數據庫的不一致狀態。
因此,使用日志使得該問題得到解決。與日志Undo方面的不同之處在於:Undo用於解決事務未完成和事務回滾的情況,而Redo則是為了保證已經提交的事務所做的修改持久化到輔助存儲。
Redo則引申出了WAL,即事務日志會在COMMIT或COMMIT之前寫入持久化存儲中,然后事務對數據本身的修改才能生效。因此就能夠保證在系統故障時可以通過讀取日志來Redo日志的持久化操作。因此對於最終用戶可以顯示事務已經提交而暫時不用將所修改的數據寫入持久化存儲。由於數據在日志未寫入持久化存儲之前無法持久化,則需要更大的主存作為BUFFER空間。
因為日志既要用於Undo,又要用於Redo,因此為了能夠成功生成Compensation Log Record,需要日志既記錄被修改前的數據,又記錄被修改后的數據,比如我們在圖6中做一個簡單的更新。
圖6.記錄更新之前和之后的數據
值得注意的是,如果修改的值是聚集索引鍵,則由於修改該數據會導致存儲的物理位置改變,所以SQL Server並不會像這樣做即席更新,而是刪除數據再插入數據,從而導致成本的增加,因此盡量不要修改聚集索引鍵。
Undo/Redo Recovery
當SQL Server非正常原因關閉時,也就是在沒有走CheckPoint(會在下面提到)時關閉了數據庫,此時數據庫中數據本身可能存在不一致的問題。因此在數據庫再次啟動的時候,會去掃描日志,找出那些未提交卻寫入持久化存儲的數據,或已提交卻未寫入持久化存儲的數據,來進行Undo和Redo來保證事務的一致性。Undo/Redo Recovery遵循以下規則:
- 按照由早到晚的順序Redo該已提交卻未寫入持久化存儲的數據
- 按照由晚到早的順序Undo未提交,卻寫入持久化存儲的數據
圖7中,我們進行一個簡單測試,在啟動過程中,首先禁用了CheckPoint以防止自動CheckPoint,然后我們修改數據,不提交,並持久化到磁盤。另一個線程修改數據並提交,但未持久化到磁盤。為了簡單起見,我把兩個線程寫到一個窗口中。
圖7.需要Undo和Redo的兩個事務
此時我們強制殺死SQL Server進程,導致數據本身不一致,此時在SQL Server的重啟過程中,會自動的Redo和Undo上面的日志,如圖8所示。
圖8.實現Redo和Undo
那么,什么是CheckPoint?
圖8給出的簡單例子足以說明Recovery機制。但例子過於簡單,假如一個非常繁忙的數據庫可能存在大量日志,一個日志如果全部需要在Recovery過程中被掃描的話,那么Recovery過程所導致的宕機時間將會成為噩夢。因此,我們引入一個叫CheckPoint的機制,就像其名稱那樣,CheckPoint就是一個存檔點,意味着我們可以從該點繼續開始。
在Undo/Redo機制的數據庫系統中,CheckPoint的機制如下:
1.將CheckPoint標記寫入日志(標記中包含當前數據庫中活動的事務信息),並將Log Block寫入持久化存儲
2.將Buffer Pool中所有的臟頁寫入磁盤,所有的臟頁包含了未提交事務所修改的數據
3.將結束CKPT標記寫入日志,並將Log Block寫入持久化存儲
我們在日志中可以看到的CheckPoint標記如圖9所示。
圖9.CheckPoint標記
其中,這些Log Record會包含CheckPoint的開始時間,結束時間以及MinLSN,用於復制的LSN等。由圖9中我們還可以看到一個LOP_XACT_CKPT操作的Log Record,該操作符的上下文如果為NULL的話,則意味着當前:
- 包含未提交事務
- 該Log Record記錄包含未提交事務的個數
- 包含未提交的事務所涉及的LSN
由CheckPoint的機制可以看出,由於內存中的數據往往比持久化存儲中的數據更新,而CheckPoint保證了這部分數據能夠被持久化到磁盤,因此CheckPoint之前的數據一定不會再需要被Redo。而對於未提交的事物所修改的數據寫入持久化存儲,則可以通過Undo來回滾事務(未提交的事物會導致CheckPoint無法截斷日志,因此這部分日志可以在Recovery的時候被讀取到,即使這部分日志在CheckPoint之前)。
此時,我們就可以100%的保證,CheckPoint之前的日志就可以被安全刪除(簡單恢復模式)或歸檔了(完整恢復模式),在Recovery時,僅僅需要從CheckPoint開始掃描日志,從而減少宕機時間。
小結
本篇文章深入挖掘了數據庫中日志為保護數據一致性的的作用、實現原理。日志在這些功能之外,也是為了用於實現高可用性,因此了解這些原理,可以更好的幫助我們在搭建高可用性拓撲以及設計備份計划時避免一些誤區。