1. XFS 日誌設計

1.1. 引言

本文件描述了 XFS 日誌子系統所基於的設計和演算法,以便讀者熟悉 XFS 中事務處理的一般概念。

我們首先概述 XFS 中的事務,接著描述事務預留的結構和核算方式,然後探討如何為具有有限初始預留範圍的長時間執行事務保證前向進展。此時,我們需要解釋重新日誌記錄的工作原理。在涵蓋了基本概念之後,本文件將闡述延遲日誌記錄機制的設計。

1.2. 簡介

XFS 使用預寫日誌(Write Ahead Logging)來確保檔案系統元資料的更改是原子性且可恢復的。出於空間和時間效率的考慮,日誌記錄機制多種多樣且複雜,它結合了意圖、邏輯和物理日誌記錄機制,以提供檔案系統所需的必要恢復保證。

一些物件,例如 inode 和 dquot,以邏輯格式記錄日誌,其中記錄的詳細資訊由記憶體結構的變化而非磁碟結構的變化組成。其他物件——通常是緩衝區——則記錄其物理變化。長時間執行的原子修改透過意圖(intents)將各個更改連結在一起,確保日誌恢復能夠重新啟動並完成系統停止執行時只部分完成的操作。

這些差異的原因是為了儘可能減少處理被修改物件所需的日誌空間和 CPU 時間,從而儘可能降低日誌記錄開銷。有些條目修改非常頻繁,物件的一些部分比其他部分修改更頻繁,因此將元資料日誌記錄開銷保持在低水平至關重要。

在本文件範圍內,用於記錄單個條目或將修改鏈在一起的方法並不是特別重要。只需知道記錄特定物件或將修改鏈在一起的方法是不同的,並且取決於所執行的物件和/或修改。日誌記錄子系統只關心遵循某些特定規則,以保證前向進展並防止死鎖。

1.3. XFS 中的事務

XFS 有兩種高階事務型別,它們由所佔用的日誌空間預留型別定義。這兩種型別被稱為“一次性”(one shot)事務和“永久性”(permanent)事務。永久性事務預留可以跨越提交邊界,而“一次性”事務則用於單個原子修改。

預留的型別和大小必須與正在進行的修改相匹配。這意味著永久性事務可用於一次性修改,但一次性預留不能用於永久性事務。

在程式碼中,一次性事務模式大致如下所示:

tp = xfs_trans_alloc(<reservation>)
<lock items>
<join item to transaction>
<do modification>
xfs_trans_commit(tp);

當事務中的條目被修改時,這些條目中的髒區域透過事務控制代碼進行跟蹤。一旦事務被提交,所有與之關聯的資源以及在事務分配時佔用的剩餘未使用的預留空間都會被釋放。

相比之下,永久性事務由多個連結的獨立事務組成,其模式如下所示:

tp = xfs_trans_alloc(<reservation>)
xfs_ilock(ip, XFS_ILOCK_EXCL)

loop {
        xfs_trans_ijoin(tp, 0);
        <do modification>
        xfs_trans_log_inode(tp, ip);
        xfs_trans_roll(&tp);
}

xfs_trans_commit(tp);
xfs_iunlock(ip, XFS_ILOCK_EXCL);

儘管這可能看起來與一次性事務相似,但存在一個重要區別:xfs_trans_roll() 執行一個特定操作,將兩個事務連結在一起。

ntp = xfs_trans_dup(tp);
xfs_trans_commit(tp);
xfs_trans_reserve(ntp);

這導致了一系列“滾動事務”,其中 inode 在整個事務鏈中被鎖定。因此,當這一系列滾動事務執行時,其他任何操作都無法讀取或寫入 inode,這為從外部觀察者的角度來看複雜更改呈現原子性提供了一種機制。

重要的是要指出,永久性事務中的一系列滾動事務在日誌中並不構成原子性更改。雖然每個單獨的修改是原子的,但鏈不是原子的。如果我們在中途崩潰,那麼恢復將只回放迴圈中已提交到日誌的最後一個事務性修改。

這會影響長時間執行的永久性事務,因為無法預測長時間執行操作的實際恢復量,因為無法保證操作有多少已到達陳舊儲存。因此,如果一個長時間執行的操作需要多個事務才能完全完成,那麼高階操作必須使用意圖和延遲操作來保證一旦第一個事務持久化到磁碟日誌中,恢復就能完成該操作。

1.4. 事務是非同步的

在 XFS 中,所有高階事務預設都是非同步的。這意味著 xfs_trans_commit() 返回時並不保證修改已提交到穩定儲存。因此,當系統崩潰時,並非所有已完成的事務都會在恢復期間被回放。

然而,日誌記錄子系統確實提供了全域性排序保證,這意味著如果在恢復後看到某個特定更改,那麼在該更改之前提交的所有元資料修改也將可見。

對於需要立即到達穩定儲存的一次性操作,或者確保長時間執行的永久性事務在完成後完全提交,我們可以顯式地將事務標記為同步。這將觸發“日誌強制重新整理”(log force),將未完成的已提交事務重新整理到日誌中的穩定儲存,並等待其完成。

然而,同步事務很少使用,因為它們會將日誌記錄吞吐量限制在底層儲存的 IO 延遲限制之下。相反,我們傾向於僅當用戶操作需要同步點(例如 fsync)時才使用日誌強制重新整理,以確保修改已寫入穩定儲存。

1.5. 事務預留

現在已經多次提到,日誌記錄子系統需要提供前向進展保證,以確保任何修改都不會因為日誌中空間不足而無法寫入日誌而停滯。這透過在首次分配事務時進行的事務預留來實現。對於永久性事務,這些預留作為事務滾動機制的一部分進行維護。

事務預留保證在開始修改物件和條目之前,有足夠的物理日誌空間可用於將修改寫入日誌。因此,預留需要足夠大,以考慮到在最壞情況下更改可能需要記錄的元資料量。這意味著如果我們在事務中修改 btree,我們必須預留足夠的空間來記錄 btree 的完整葉到根分裂。因此,預留相當複雜,因為我們必須考慮到所有可能發生的隱藏更改。

例如,使用者資料區段分配涉及從空閒空間分配一個區段,這會修改空閒空間樹。這涉及到兩個 btree。將區段插入到 inode 的區段對映中可能需要分割區段對映 btree,這又需要另一次分配,而這次分配可能會再次修改空閒空間樹。然後我們可能需要更新反向對映,這會修改另一個 btree,可能需要更多空間。諸如此類。因此,“簡單”操作可以修改的元資料量可能相當大。

這種“最壞情況”計算為我們提供了在掛載時計算的事務的靜態“單位預留”。我們必須保證在允許事務進行之前,日誌有這麼多可用空間,這樣當我們寫入髒元資料到日誌中時,才不會在寫入過程中耗盡日誌空間。

對於一次性事務,只需一個單位空間預留即可讓事務進行。然而,對於永久性事務,我們還有一個“日誌計數”(log count),它會影響要進行的預留大小。

雖然永久性事務可以使用單個單位空間預留來完成,但這樣做效率有點低,因為它要求事務滾動機制在每次事務滾動時重新預留空間。我們從永久性事務的實現中瞭解到,對於需要進行的常見修改,事務滾動可能發生的次數。

例如,一個 inode 分配通常是兩個事務——一個用於在磁碟上物理分配一個空閒 inode 塊,另一個用於從包含空閒 inode 的 inode 塊中分配一個 inode。因此,對於 inode 分配事務,我們可能會將預留日誌計數設定為 2,以表示通用/快速路徑事務將提交鏈中的兩個連結事務。每次永久性事務滾動時,它都會消耗一個完整的單位預留。

因此,當永久性事務首次分配時,日誌空間預留會從單個單位預留增加到多個單位預留。這個倍數由預留日誌計數定義,這意味著我們可以在多次滾動事務後才需要重新預留日誌空間。這確保了我們進行的常見修改只需要預留一次日誌空間。

如果永久性事務的日誌計數達到零,則需要重新預留日誌中的物理空間。這有點複雜,需要了解日誌如何核算已預留的空間。

1.6. 日誌空間核算

日誌中的位置通常被稱為日誌序列號(LSN)。日誌是迴圈的,因此日誌中的位置由迴圈號(日誌被覆蓋的次數)和日誌中的偏移量組合定義。一個 LSN 在高 32 位中攜帶迴圈,在低 32 位中攜帶偏移量。偏移量以“基本塊”(512 位元組)為單位。因此,我們可以進行相對簡單的基於 LSN 的數學運算來跟蹤日誌中的可用空間。

日誌空間核算透過一對稱為“授權頭”(grant heads)的構造完成。授權頭的位置是一個絕對值,因此日誌中可用空間的數量由授權頭位置與當前日誌尾部之間的距離定義。也就是說,在授權頭完全環繞日誌並超過尾部位置之前,可以預留/消耗多少空間。

第一個授權頭是“預留”頭。它跟蹤當前活動事務持有的預留位元組數。這純粹是記憶體中的空間預留核算,因此實際上跟蹤的是日誌中的位元組偏移量而不是基本塊。因此,它技術上沒有使用 LSN 來表示日誌位置,但為了跟蹤預留空間的目的,它仍然被視為一個分裂的 {cycle,offset} 元組。

預留授權頭用於精確核算精確的事務預留量以及修改實際產生並需要寫入日誌的精確位元組數。當預留頭到達當前尾部時,預留頭用於阻止新事務進行新的預留。它將在 FIFO 佇列中阻塞新的預留,並且隨著日誌尾部向前移動,一旦有足夠的空間可用,它將按順序喚醒這些預留。這種 FIFO 機制確保在日誌空間短缺時,沒有事務會因資源匱乏而停滯。

另一個授權頭是“寫入”頭。與預留頭不同,這個授權頭包含一個 LSN,它跟蹤日誌中的物理空間使用情況。雖然這聽起來好像它與預留授權頭核算相同的狀態——而且它在大多數情況下確實跟蹤與預留授權頭完全相同的位置——但它們之間存在關鍵的行為差異,這些差異提供了滾動永久性事務所需的前向進展保證。

當永久性事務滾動且內部“日誌計數”達到零並且初始單位預留已用盡時,就會出現這些差異。此時,我們仍然需要日誌空間預留來繼續序列中的下一個事務,但我們已經沒有剩餘的預留。我們不能在事務提交過程中休眠等待新的日誌空間可用,因為我們可能會最終處於 FIFO 佇列的末尾,並且我們在休眠期間鎖定的條目可能會在日誌中有足夠的可用空間來滿足所有待處理的預留並喚醒正在進行的事務提交之前,最終固定日誌的尾部。

要在不休眠的情況下進行新的預留,我們需要能夠在當前沒有預留空間可用時也能夠進行預留。也就是說,我們需要能夠“超額提交”日誌預留空間。正如已經詳細說明的,我們不能超額提交物理日誌空間。然而,預留授權頭不跟蹤物理空間——它只核算我們當前未完成的預留量。因此,如果預留頭超過日誌尾部,這意味著新的預留將立即受到限制,並一直受到限制,直到日誌尾部向前移動足夠遠,以消除超額提交併開始接受新的預留。換句話說,我們可以在不違反物理日誌頭尾規則的情況下超額提交預留頭。

因此,永久性事務僅在 xfs_trans_commit() 呼叫期間“重新授予”預留空間,而物理日誌空間預留(由寫入頭跟蹤)則在提交完成後透過呼叫 xfs_log_reserve() 單獨預留。一旦提交完成,我們可以休眠等待從寫入授權頭預留物理日誌空間,但前提是必須遵守一個關鍵規則:

Code using permanent reservations must always log the items they hold
locked across each transaction they roll in the chain.

在每次事務滾動時“重新記錄”鎖定的條目,確保連線到正在滾動的事務鏈的條目始終重新定位到日誌的物理頭部,從而不會固定日誌的尾部。如果我們休眠等待寫入預留時,一個鎖定的條目固定了日誌的尾部,那麼我們將死鎖日誌,因為我們無法獲取將該條目寫回並向前移動日誌尾部以釋放寫入授權空間所需的鎖。重新記錄鎖定的條目可避免這種死鎖,並保證我們正在進行的日誌預留不會發生自死鎖。

如果所有滾動事務都遵守此規則,那麼它們都可以獨立地向前推進,因為沒有任何東西會阻礙日誌尾部向前移動,從而確保無論永久性事務滾動多少次,寫入授權空間始終(最終)對它們可用。

1.7. 重新日誌記錄的解釋

XFS 允許在任何給定時間將對單個物件的多個獨立修改儲存在日誌中。這使得日誌無需在記錄對物件的新更改之前將每個更改重新整理到磁碟。XFS 透過一種稱為“重新日誌記錄”(re-logging)的方法來實現這一點。從概念上講,這相當簡單——它只要求對物件的任何新更改都與寫入日誌的新事務中所有現有更改的新副本一起記錄。

也就是說,如果我們有一個從 A 到 F 的更改序列,並且在更改 D 之後物件被寫入磁碟,那麼我們將在日誌中看到以下一系列事務、它們的內容以及事務的日誌序列號(LSN):

Transaction             Contents        LSN
   A                       A               X
   B                      A+B             X+n
   C                     A+B+C           X+n+m
   D                    A+B+C+D         X+n+m+o
    <object written to disk>
   E                       E               Y (> X+n+m+o)
   F                      E+F             Y+p

換句話說,每次物件被重新日誌記錄時,新事務都會包含當前僅儲存在日誌中的所有先前更改的聚合。

這種重新日誌記錄技術允許物件在日誌中向前移動,從而使正在重新日誌記錄的物件不會阻止日誌尾部向前移動。這可以從上表中每個後續事務不斷變化(增加)的 LSN 中看出,正是這種技術使我們能夠實現長時間執行、多次提交的永久性事務。

滾動事務的一個典型例子是從 inode 中刪除區段,由於預留大小限制,每次事務只能刪除兩個區段。因此,滾動區段刪除事務會在每次刪除操作中修改 inode 和 btree 緩衝區時對其進行重新日誌記錄。這使得它們隨著操作的進行在日誌中向前移動,確保如果日誌迴圈,當前操作永遠不會被自身阻塞。

因此可以看出,重新日誌記錄操作對於 XFS 日誌子系統的正常工作至關重要。從上面的描述中,大多數人應該能夠明白為什麼 XFS 元資料操作會寫入如此多的日誌——對相同物件的重複操作會一次又一次地將相同的更改寫入日誌。更糟糕的是,物件在重新日誌記錄時會變得更“髒”,因此每個後續事務都會向日志寫入更多的元資料。

現在也應該很明顯,重新日誌記錄和非同步事務是相輔相成的。也就是說,事務不會被寫入物理日誌,除非日誌緩衝區被填滿(一個日誌緩衝區可以容納多個事務),或者同步操作強制將持有事務的日誌緩衝區寫入磁碟。這意味著 XFS 正在記憶體中進行事務聚合——如果你願意,可以稱之為批處理——以最大程度地減少日誌 IO 對事務吞吐量的影響。

非同步事務吞吐量的限制在於日誌管理器提供的日誌緩衝區的數量和大小。預設情況下,有 8 個日誌緩衝區可用,每個緩衝區的大小為 32kB——透過使用掛載選項,大小可以增加到 256kB。

實際上,這為我們提供了在任何給定時間對檔案系統進行未完成元資料更改的最大界限——如果所有日誌緩衝區都已滿並且正在進行 IO,那麼在當前批處理完成之前,無法提交更多事務。現在,單個當前 CPU 核心通常能夠發出足夠的事務,以使日誌緩衝區永久保持滿載並處於 IO 狀態。因此,XFS 日誌子系統可以被認為是 IO 瓶頸型的。

1.8. 延遲日誌記錄:概念

關於 XFS 使用的非同步日誌記錄結合重新日誌記錄技術的關鍵點是,我們可以在更改的物件被提交到日誌緩衝區磁碟之前對其進行多次重新日誌記錄。如果我們回到之前的重新日誌記錄示例,事務 A 到 D 完全有可能在同一個日誌緩衝區中被提交到磁碟。

也就是說,單個日誌緩衝區可能包含同一物件的多個副本,但其中只有一個副本是必需的——即最後一個“D”,因為它包含所有先前更改的全部內容。換句話說,日誌緩衝區中有一個必需的副本,以及三個只是浪費空間的陳舊副本。當我們對同一組物件進行重複操作時,這些“陳舊物件”可以佔用日誌緩衝區中超過 90% 的空間。顯然,減少寫入日誌的陳舊物件數量將大大減少我們寫入日誌的元資料量,這也是延遲日誌記錄的根本目標。

從概念上講,XFS 已經在記憶體中進行重新日誌記錄(其中記憶體 == 日誌緩衝區),只是效率極低。它使用邏輯到物理的格式化進行重新日誌記錄,因為在將事務中的更改物理格式化到日誌緩衝區之前,沒有基礎設施來跟蹤記憶體中的邏輯更改。因此,我們無法避免日誌緩衝區中堆積陳舊物件。

延遲日誌記錄是我們對在日誌緩衝區基礎設施之外,在記憶體中儲存和跟蹤物件事務性更改所取的名稱。由於重新日誌記錄概念是 XFS 日誌子系統的基礎,這實際上相對容易實現——所有對已記錄條目的更改都已在當前基礎設施中跟蹤。主要問題是如何以一致、可恢復的方式累積它們並將其寫入日誌。本文件的重點是描述這些問題以及它們是如何解決的。

延遲日誌記錄對日誌子系統操作進行的關鍵改變之一是,它將未完成的元資料更改量與可用日誌緩衝區的大小和數量分離。換句話說,在任何給定時間,記憶體中累積的事務更改量可能遠大於未寫入日誌的最大 2MB 事務更改。因此,崩潰時元資料丟失的可能性比現有日誌記錄機制大得多。

需要指出的是,這並沒有改變日誌恢復將導致檔案系統一致的保證。它確實意味著,就已恢復的檔案系統而言,可能存在數千個因崩潰而根本未發生的事務。這使得關心其資料的應用程式在需要確保應用程式級資料完整性時使用 fsync() 變得更加重要。

需要指出的是,延遲日誌記錄並非一項需要嚴格證明其正確性的創新概念。在將更改寫入日誌之前,在記憶體中累積更改一段時間的方法已在包括 ext3 和 ext4 在內的許多檔案系統中有效使用。因此,本文件不花時間試圖說服讀者該概念是可靠的。相反,它被簡單地認為是一個“已解決的問題”,因此在 XFS 中實現它純粹是軟體工程方面的一項實踐。

XFS 中延遲日誌記錄的基本要求很簡單:

  1. 將寫入日誌的元資料量至少減少一個數量級。

  2. 提供足夠的統計資料來驗證要求 #1。

  3. 提供足夠的新追蹤基礎設施,以便能夠除錯新程式碼中的問題。

  4. 無磁碟格式更改(元資料或日誌格式)。

  5. 透過掛載選項啟用和停用。

  6. 同步事務工作負載無效能退化。

1.9. 延遲日誌記錄:設計

1.9.1. 儲存更改

在邏輯層面累積更改(即僅使用現有日誌項髒區域跟蹤)的問題是,當將更改寫入日誌緩衝區時,我們需要確保我們正在格式化的物件在此過程中不會發生更改。這需要鎖定物件以防止併發修改。因此,將邏輯更改重新整理到日誌將需要我們鎖定每個物件,對其進行格式化,然後再解鎖。

這為與已執行事務的死鎖引入了很大的可能性。例如,一個事務鎖定了物件 A 並對其進行了修改,但需要延遲日誌記錄跟蹤鎖來提交事務。然而,重新整理執行緒已經持有了延遲日誌記錄跟蹤鎖,並試圖獲取物件 A 上的鎖以將其重新整理到日誌緩衝區。這似乎是一個無法解決的死鎖條件,而解決這個問題是長期以來阻礙實現延遲日誌記錄的障礙。

解決方案相對簡單——只是花了很長時間才認識到。簡單來說,當前的日誌記錄程式碼將每個條目的更改格式化為一個指向條目中已更改區域的向量陣列。日誌寫入程式碼在事務提交期間,當條目在事務中被鎖定時,簡單地將這些向量指向的記憶體複製到日誌緩衝區中。我們可以不使用日誌緩衝區作為格式化程式碼的目標,而是使用一個足夠大以容納格式化向量的已分配記憶體緩衝區。

如果我們隨後將向量複製到記憶體緩衝區中,並將向量重寫為指向記憶體緩衝區而不是物件本身,那麼我們現在就有了一份以與日誌緩衝區寫入程式碼相容的格式儲存的更改副本,並且不需要我們鎖定條目即可訪問。這種格式化和重寫都可以在事務提交期間物件被鎖定時完成,從而生成一個事務一致的向量,並且無需鎖定所有者條目即可訪問。

因此,當我們需要將未完成的非同步事務重新整理到日誌時,我們避免了鎖定條目的需要。現有格式化方法與延遲日誌記錄格式化之間的差異可見下圖。

當前格式的日誌向量

Object    +---------------------------------------------+
Vector 1      +----+
Vector 2                    +----+
Vector 3                                   +----------+

格式化後

Log Buffer    +-V1-+-V2-+----V3----+

延遲日誌記錄向量

Object    +---------------------------------------------+
Vector 1      +----+
Vector 2                    +----+
Vector 3                                   +----------+

格式化後

Memory Buffer +-V1-+-V2-+----V3----+
Vector 1      +----+
Vector 2           +----+
Vector 3                +----------+

記憶體緩衝區和相關向量需要作為一個單一物件傳遞,但仍然需要與父物件關聯,這樣如果物件被重新日誌記錄,我們可以用包含最新更改的新記憶體緩衝區替換當前記憶體緩衝區。

在我們格式化記憶體緩衝區之後保留向量的原因是為了正確支援跨日誌緩衝區邊界的向量分割。如果我們不保留向量,我們就不知道條目中的區域邊界在哪裡,因此在日誌緩衝區寫入時,我們需要一種新的區域封裝方法(即雙重封裝)。這將是磁碟格式的更改,因此是不希望看到的。這也意味著我們必須在格式化階段寫入日誌區域頭,這很麻煩,因為在日誌寫入期間需要將每個區域的狀態放入頭中。

因此,我們需要保留向量,但透過將記憶體緩衝區附加到它並重寫向量地址以指向記憶體緩衝區,我們最終得到一個自描述物件,該物件可以傳遞給日誌緩衝區寫入程式碼,以與現有日誌向量完全相同的方式進行處理。因此,我們避免了需要新的磁碟格式來處理記憶體中已重新日誌記錄的條目。

1.9.2. 跟蹤更改

既然我們可以在記憶體中以不受限制的形式記錄事務性更改,我們就需要能夠跟蹤和累積它們,以便稍後將它們寫入日誌。日誌項是儲存此向量和緩衝區的自然位置,並且作為跟蹤已提交物件的物件也很有意義,因為它一旦包含在事務中就會一直存在。

日誌項已經用於跟蹤已寫入日誌但尚未寫入磁碟的日誌項。此類日誌項被視為“活動”項,因此儲存在活動項列表(AIL)中,這是一個按 LSN 排序的雙向連結串列。條目在日誌緩衝區 IO 完成期間插入此列表,之後它們被解除固定並可以寫入磁碟。AIL 中的物件可以重新日誌記錄,這會導致物件再次被固定,然後在該事務的日誌緩衝區 IO 完成時在 AIL 中向前移動。

本質上,這表明 AIL 中的條目仍然可以被修改和重新日誌記錄,因此任何跟蹤都必須獨立於 AIL 基礎設施。因此,我們不能重用 AIL 列表指標來跟蹤已提交的條目,也不能在任何受 AIL 鎖保護的欄位中儲存狀態。因此,已提交條目跟蹤需要在日誌項中擁有自己的鎖、列表和狀態欄位。

與 AIL 類似,已提交條目的跟蹤透過一個名為已提交項列表(CIL)的新列表進行。該列表跟蹤已提交併附加有格式化記憶體緩衝區的日誌項。它按照事務提交順序跟蹤物件,因此當一個物件被重新日誌記錄時,它會從列表中的原位置移除並重新插入到尾部。這完全是任意的,目的是為了方便除錯——列表中最後的條目是最近修改的。CIL 的排序對於事務完整性不是必需的(如下一節所討論),因此排序是為了開發人員的方便/健全性。

1.9.3. 延遲日誌記錄:檢查點

當我們發生日誌同步事件(通常稱為“日誌強制重新整理”)時,CIL 中的所有條目必須透過日誌緩衝區寫入日誌。我們需要按照它們在 CIL 中存在的順序寫入這些條目,並且它們需要作為原子事務寫入。所有物件都必須作為原子事務寫入的需求源於重新日誌記錄和日誌重放的要求——給定事務中所有物件的所有更改必須要麼在日誌恢復期間完全重放,要麼根本不重放。如果一個事務因為在日誌中不完整而沒有被重放,那麼後續的事務也不應該被重放。

為了滿足此要求,我們需要將整個 CIL 寫入單個日誌事務。幸運的是,XFS 日誌程式碼對事務大小沒有固定限制,日誌回放程式碼也沒有。唯一的根本限制是事務不能大於日誌大小的一半。這個限制的原因是,為了找到日誌的頭部和尾部,在任何給定時間日誌中必須至少有一個完整的事務。如果一個事務大於日誌的一半,那麼在寫入此類事務期間發生崩潰可能會部分覆蓋日誌中唯一完整的先前事務。這將導致恢復失敗和檔案系統不一致,因此我們必須強制檢查點的最大大小略小於日誌的一半。

除了這個大小要求,檢查點事務看起來與任何其他事務沒有什麼不同——它包含一個事務頭、一系列格式化的日誌項和一個位於尾部的提交記錄。從恢復的角度來看,檢查點事務也沒有什麼不同——只是更大,包含更多的條目。最壞情況的影響是,我們可能需要調整恢復事務物件雜湊的大小。

因為檢查點只是另一個事務,並且所有對日誌項的更改都儲存為日誌向量,所以我們可以使用現有的日誌緩衝區寫入程式碼將更改寫入日誌。為了高效地做到這一點,我們需要最小化在寫入檢查點事務時鎖定 CIL 的時間。當前的日誌寫入程式碼透過將事務內容(日誌向量)的寫入與事務提交記錄分開,使我們能夠輕鬆地做到這一點,但跟蹤這需要我們有一個按檢查點劃分的上下文,該上下文貫穿日誌寫入過程直至檢查點完成。

因此,檢查點有一個上下文,用於跟蹤當前檢查點從啟動到完成的狀態。在啟動檢查點事務的同時,會初始化一個新的上下文。也就是說,當我們在檢查點操作期間從 CIL 中移除所有當前項時,我們會將所有這些更改移動到當前檢查點上下文。然後我們初始化一個新的上下文並將其附加到 CIL,以便聚合新的事務。

這使我們能夠在傳輸所有已提交項後立即解鎖 CIL,並有效允許在新事務進行格式化檢查點到日誌時發出新事務。它還允許在日誌強制重新整理(log force)密集型工作負載情況下,併發檢查點寫入日誌緩衝區,就像現有事務提交程式碼所做的那樣。然而,這要求我們嚴格排序日誌中的提交記錄,以便在日誌回放期間保持檢查點序列順序。

為確保我們能夠在寫入檢查點事務中的一個條目的同時,另一個事務修改該條目並將其插入新的 CIL,那麼檢查點事務提交程式碼不能使用日誌項來儲存需要寫入事務的日誌向量列表。因此,日誌向量需要能夠連結在一起,以便它們可以從日誌項中分離出來。也就是說,當 CIL 被重新整理時,附加到每個日誌項的記憶體緩衝區和日誌向量需要附加到檢查點上下文,以便可以釋放日誌項。以圖表形式表示,CIL 在重新整理前將是這樣的:

CIL Head
   |
   V
Log Item <-> log vector 1       -> memory buffer
   |                            -> vector array
   V
Log Item <-> log vector 2       -> memory buffer
   |                            -> vector array
   V
......
   |
   V
Log Item <-> log vector N-1     -> memory buffer
   |                            -> vector array
   V
Log Item <-> log vector N       -> memory buffer
                                -> vector array

重新整理後,CIL 頭部為空,檢查點上下文日誌向量列表將如下所示:

Checkpoint Context
   |
   V
log vector 1    -> memory buffer
   |            -> vector array
   |            -> Log Item
   V
log vector 2    -> memory buffer
   |            -> vector array
   |            -> Log Item
   V
......
   |
   V
log vector N-1  -> memory buffer
   |            -> vector array
   |            -> Log Item
   V
log vector N    -> memory buffer
                -> vector array
                -> Log Item

一旦完成此傳輸,CIL 即可解鎖,新的事務可以開始,同時檢查點重新整理程式碼會處理日誌向量鏈以提交檢查點。

一旦檢查點寫入日誌緩衝區,檢查點上下文將連同完成回撥一起附加到寫入提交記錄的日誌緩衝區。日誌 IO 完成將呼叫該回調,然後該回調可以對日誌向量鏈中的日誌項執行事務提交處理(即插入 AIL 並解除固定),然後釋放日誌向量鏈和檢查點上下文。

討論點:我不確定日誌項是否是跟蹤向量最有效的方式,儘管它看起來是自然而然的方式。我們遍歷日誌項(在 CIL 中)只是為了連結日誌向量並斷開日誌項與日誌向量之間的連結,這意味著我們會因為日誌項列表修改而發生一次快取行命中,然後又因為日誌向量連結而發生另一次快取行命中。如果我們透過日誌向量進行跟蹤,那麼我們只需要斷開日誌項與日誌向量之間的連結,這意味著我們只應該使日誌項快取行髒化。通常我不會擔心一個還是兩個髒快取行的問題,但我在一個檢查點事務中看到過超過 80,000 個日誌向量。我猜測這是一種“測量和比較”的情況,可以在開發樹中實現並審查工作版本後進行。

1.9.4. 延遲日誌記錄:檢查點排序

XFS 事務子系統的一個關鍵方面是它使用事務提交的日誌序列號標記已提交的事務。這允許事務非同步發出,即使將來可能存在在該事務完全提交到日誌之前無法完成的操作。在極少數情況下,如果發生依賴操作(例如,為資料區段重用已釋放的元資料區段),可以發出特殊的最佳化日誌強制重新整理,以立即將依賴事務強制寫入磁碟。

為此,事務需要記錄事務提交記錄的 LSN。此 LSN 直接來自事務寫入的日誌緩衝區。雖然這對於現有事務機制工作良好,但它不適用於延遲日誌記錄,因為事務不直接寫入日誌緩衝區。因此,需要其他方法來對事務進行排序。

正如檢查點部分所討論的,延遲日誌記錄使用每個檢查點的上下文,因此為每個檢查點分配一個序列號很簡單。由於檢查點上下文的切換必須原子地完成,因此可以很容易地確保每個新上下文都分配有一個單調遞增的序列號,而無需外部原子計數器——我們只需取當前上下文序列號併為其新上下文加一。

然後,在提交期間,我們可以分配當前檢查點序列,而不是將日誌緩衝區 LSN 分配給事務提交 LSN。這使得跟蹤尚未完成事務的操作能夠知道需要提交哪個檢查點序列才能繼續。因此,現在強制日誌到特定 LSN 的程式碼需要確保日誌強制到特定檢查點。

為了確保我們能做到這一點,我們需要跟蹤當前所有正在向日志提交的檢查點上下文。當我們重新整理一個檢查點時,該上下文會被新增到可供搜尋的“正在提交”列表中。當一個檢查點提交完成時,它會從“正在提交”列表中移除。因為檢查點上下文記錄了檢查點提交記錄的 LSN,我們也可以在包含提交記錄的日誌緩衝區上等待,從而使用現有的日誌強制重新整理機制來執行同步強制重新整理。

需要指出的是,同步強制重新整理可能需要用與當前日誌緩衝區程式碼類似的緩解演算法進行擴充套件,以便在已有同步事務正在重新整理時,允許聚合多個同步事務。在做出任何決定之前,需要對當前設計的效能進行調查。

日誌強制重新整理的主要關注點是,確保在我們需要等待的檢查點之前,所有先前的檢查點也都已提交到磁碟。因此,在等待我們需要完成的檢查點之前,我們需要檢查“正在提交”列表中所有先前的上下文是否也已完成。我們在日誌強制重新整理程式碼中執行此同步,這樣就不需要在其他任何地方等待此類序列化——它只在執行日誌強制重新整理時才重要。

唯一剩下的複雜性是,日誌強制重新整理現在還必須處理強制序列號與當前上下文相同的情況。也就是說,我們需要重新整理 CIL 並可能等待它完成。這只是對現有日誌強制重新整理程式碼的一個簡單新增,用於檢查序列號並在需要時進行推送。事實上,將當前序列檢查點重新整理放置在日誌強制重新整理程式碼中,使得當前發出同步事務的機制保持不變(即,提交一個非同步事務,然後在該事務的 LSN 處強制重新整理日誌),因此無論是否使用延遲日誌記錄,更高級別的程式碼行為都相同。

1.9.5. 延遲日誌記錄:檢查點日誌空間核算

檢查點事務的最大問題是該事務的日誌空間預留。我們事先不知道檢查點事務會有多大,也不知道寫入它需要多少日誌緩衝區,更不知道會使用多少個拆分的日誌向量區域。我們可以在向提交項列表新增項時跟蹤所需的日誌空間量,但我們仍然需要在日誌中為檢查點預留空間。

典型的事務會在日誌中預留足夠的空間,以應對事務最壞情況下的空間使用。預留考慮了日誌記錄頭、事務和區域頭、分割區域頭、緩衝區尾部填充等,以及事務中所有已更改元資料的實際空間。雖然其中一部分是固定開銷,但大部分取決於事務的大小以及正在記錄的區域數量(事務中的日誌向量數量)。

差異的一個例子是記錄目錄更改與記錄 inode 更改。如果你修改了大量的 inode 核心(例如 chmod -R g+w *),那麼會有很多事務只包含一個 inode 核心和一個 inode 日誌格式結構。也就是說,兩個向量總共大約 150 位元組。如果我們修改 10,000 個 inode,我們將有大約 1.5MB 的元資料需要寫入 20,000 個向量。每個向量 12 位元組,因此要記錄的總量約為 1.75MB。相比之下,如果我們要記錄完整的目錄緩衝區,它們通常每個 4KB,因此在 1.5MB 的目錄緩衝區中,我們將有大約 400 個緩衝區,並且每個緩衝區都有一個緩衝區格式結構——大約 800 個向量或總共 1.51MB 的空間。由此可見,靜態日誌空間預留並非特別靈活,並且難以針對所有工作負載選擇“最佳值”。

此外,如果我們要使用靜態預留,那麼它覆蓋了整個預留的哪一部分?我們透過跟蹤 CIL 中物件當前使用的空間,然後計算物件重新日誌記錄時空間使用的增加或減少來核算事務預留所使用的空間。這使得檢查點預留只需核算日誌緩衝區元資料(例如日誌頭記錄)所使用的空間。

然而,即使僅為日誌元資料使用靜態預留也存在問題。通常,日誌記錄頭每消耗 1MB 日誌空間至少使用 16KB 日誌空間(每 32KB 消耗 512 位元組),並且預留需要足夠大以處理任意大小的檢查點事務。此預留需要在檢查點啟動之前進行,並且我們需要能夠在不休眠的情況下預留空間。對於一個 8MB 的檢查點,我們需要大約 150KB 的預留空間,這不是一個微不足道的量。

靜態預留需要操作日誌授權計數器——我們可以對空間進行永久預留,但我們仍然需要確保在每次檢查點事務完成後重新整理寫入預留(事務可用的實際空間)。不幸的是,如果所需空間不可用,那麼重新授權程式碼將休眠等待。

這樣做的問題在於可能導致死鎖,因為我們可能需要提交檢查點才能釋放日誌空間(有關示例,請參閱滾動事務的描述)。因此,如果我們要使用靜態預留,我們必須始終在日誌中保留可用空間,而這非常困難和複雜。這是可以做到的,但有更簡單的方法。

更簡單的方法是跟蹤 CIL 中各項所使用的整個日誌空間,並使用此資訊動態計算日誌元資料所需的日誌空間量。如果日誌元資料空間因事務提交將新的記憶體緩衝區插入 CIL 而發生變化,則所需的空間差異將從導致更改的事務中移除。此級別的事務在其預留中始終有足夠的空間來滿足此要求,因為它們已經預留了所需的日誌元資料空間的最大量,並且這種增量預留將始終小於或等於預留中的最大量。

因此,我們可以隨著項被新增到 CIL 中,動態地增長檢查點事務預留,並避免了預先預留和重新授予日誌空間的需要。這避免了死鎖,並消除了檢查點重新整理程式碼中的阻塞點。

如前所述,事務不能增長到超過日誌大小的一半。因此,作為預留增長的一部分,我們還需要檢查預留大小是否超過最大允許事務大小。如果達到最大閾值,我們需要將 CIL 推送到日誌。這實際上是一種“後臺重新整理”,並按需執行。這與日誌強制重新整理觸發的 CIL 推送相同,只是無需等待檢查點提交完成。此後臺推送由事務提交程式碼檢查並執行。

如果事務子系統在 CIL 中仍有專案時進入空閒狀態,它們將被 xfssyncd 發出的週期性日誌強制重新整理。這個日誌強制重新整理會將 CIL 推送到磁碟,如果事務子系統保持空閒,則允許空閒日誌被覆蓋(有效地標記為乾淨),這與現有日誌記錄方法的操作方式完全相同。一個討論點是,這個日誌強制重新整理是否需要比當前每 30 秒一次的頻率更頻繁地執行。

1.9.6. 延遲日誌記錄:日誌項固定

目前,日誌項在事務提交期間,當它們仍然被鎖定時,會被固定。這發生在條目格式化之後,儘管可以在條目解鎖之前的任何時間完成。這種機制的結果是,每個提交到日誌緩衝區的事務都會導致日誌項被固定一次。因此,在日誌緩衝區中重新日誌記錄的項,對於它們所涉及的每個未完成事務,都會有一個固定計數。當這些事務中的每一個完成時,它們將解除該項的固定一次。因此,只有當所有事務完成且沒有待處理事務時,該項才會被解除固定。因此,日誌項的固定和解除固定是對稱的,因為事務提交和日誌項完成之間存在 1:1 的關係。

然而,對於延遲日誌記錄,我們擁有不對稱的事務提交到完成關係。每次在 CIL 中重新日誌記錄一個物件時,它都會經歷提交過程而沒有相應的完成記錄。也就是說,我們現在在事務提交和日誌項完成之間存在多對一的關係。其結果是,如果我們保留“事務提交時固定,事務完成時解除固定”的模型,日誌項的固定和解除固定將變得不平衡。

為了保持固定/解除固定的對稱性,演算法需要更改為“插入 CIL 時固定,檢查點完成時解除固定”。換句話說,固定和解除固定在檢查點上下文周圍變得對稱。我們必須在物件首次插入 CIL 時固定它——如果在事務提交期間它已經存在於 CIL 中,則我們不再固定它。因為可能存在多個未完成的檢查點上下文,我們仍然會看到升高的固定計數,但隨著每個檢查點完成,固定計數將根據其上下文保留正確的值。

為了使事情稍微複雜化,這個檢查點級別的固定計數上下文意味著項的固定必須在 CIL 提交/重新整理鎖下進行。如果我們在該鎖之外固定物件,我們無法保證固定計數與哪個上下文相關聯。這是因為固定項取決於該項是否存在於當前 CIL 中。如果我們不在檢查和固定物件之前先固定 CIL,那麼在檢查和固定之間(或者不固定,視情況而定),我們會遇到 CIL 被重新整理的競爭條件。因此,我們必須持有 CIL 重新整理/提交鎖,以確保我們正確固定專案。

1.9.7. 延遲日誌記錄:併發可擴充套件性

CIL 的一個基本要求是,透過事務提交的訪問必須能夠擴充套件到許多併發提交。當前的事務提交程式碼即使在同時有來自 2048 個處理器的事務時也不會崩潰。當前的事務程式碼不會比只有一個 CPU 使用它時更快,但也不會變慢。

因此,延遲日誌記錄事務提交程式碼需要從頭開始設計以支援併發。顯然,設計中存在序列化點——三個重要的序列化點是:

  1. 在重新整理 CIL 時鎖定新的事務提交

  2. 向 CIL 新增項並更新項空間核算

  3. 檢查點提交排序

從事務提交和 CIL 重新整理互動來看,很明顯這裡存在多對一的互動。也就是說,對同時嘗試提交的併發事務數量的唯一限制是日誌中可用於其預留的空間量。實際的限制是對於一個 128MB 的日誌,併發事務數量約為數百個,這意味著通常一臺機器中每個 CPU 一個。

事務提交需要延遲重新整理的時間相對較長——日誌項的固定需要在我們延遲 CIL 重新整理時完成,因此目前這意味著它在物件格式化為記憶體緩衝區期間(即在 memcpy() 進行期間)被持有。最終,可以使用一種兩階段演算法,其中格式化與物件固定分開進行,以減少事務提交側的持有時間。

由於潛在的事務提交側持有者數量眾多,該鎖確實需要是一個休眠鎖——如果 CIL 重新整理獲取了該鎖,我們不希望機器中的其他所有 CPU 都不斷嘗試獲取 CIL 鎖。考慮到重新整理 CIL 可能涉及遍歷數萬個日誌項的列表,它將被持有很長時間,因此自旋爭用是一個重大問題。防止大量 CPU 空轉是選擇休眠鎖的主要原因,儘管事務提交或 CIL 重新整理側都沒有在持有鎖的情況下休眠。

還需要指出的是,與非同步事務工作負載的事務提交相比,CIL 重新整理也是一個相對不頻繁的操作——只有時間才能證明,使用讀寫訊號量進行互斥是否會因鎖在讀取側的快取行跳動而限制事務提交併發性。

第二個序列化點在事務提交側,即條目被插入 CIL 的地方。由於事務可以併發進入此程式碼,因此 CIL 需要獨立於上述提交/重新整理互斥進行保護。它也需要是一個獨佔鎖,但它只被持有很短的時間,因此自旋鎖在這裡是合適的。這個鎖有可能成為一個爭用點,但考慮到每個事務只持有很短的時間,我認為爭用不太可能發生。

最終的序列化點是檢查點提交記錄排序程式碼,它是檢查點提交和日誌強制重新整理排序的一部分。觸發 CIL 重新整理的程式碼路徑(即觸發日誌強制重新整理的任何操作)在將所有日誌向量寫入日誌緩衝區之後、但在寫入提交記錄之前,將進入一個排序迴圈。此迴圈遍歷正在提交的檢查點列表,需要阻塞等待檢查點完成其提交記錄寫入。因此,它需要一個鎖和一個等待變數。日誌強制重新整理排序也需要相同的鎖、列表遍歷和阻塞機制來確保檢查點的完成。

儘管這兩種排序操作等待的事件不同,但它們可以使用相同的機制。檢查點提交記錄排序需要等待檢查點上下文包含一個提交 LSN(透過完成提交記錄寫入獲得),而日誌強制重新整理排序需要等待先前的檢查點上下文從提交列表中移除(即它們已完成)。一個簡單的等待變數和廣播喚醒(雷鳴般的喚醒群)已被用於實現這兩個序列化佇列。它們也使用與 CIL 相同的鎖。如果 CIL 鎖上出現過多爭用,或由於廣播喚醒導致過多的上下文切換,這些操作可以放在一個新的自旋鎖下,並給予單獨的等待列表,以減少鎖爭用和因錯誤事件喚醒的程序數量。

1.9.8. 生命週期變化

現有日誌項的生命週期如下:

1. Transaction allocate
2. Transaction reserve
3. Lock item
4. Join item to transaction
        If not already attached,
                Allocate log item
                Attach log item to owner item
        Attach log item to transaction
5. Modify item
        Record modifications in log item
6. Transaction commit
        Pin item in memory
        Format item into log buffer
        Write commit LSN into transaction
        Unlock item
        Attach transaction to log buffer

<log buffer IO dispatched>
<log buffer IO completes>

7. Transaction completion
        Mark log item committed
        Insert log item into AIL
                Write commit LSN into log item
        Unpin log item
8. AIL traversal
        Lock item
        Mark log item clean
        Flush item to disk

<item IO completion>

9. Log item removed from AIL
        Moves log tail
        Item unlocked

本質上,步驟 1-6 獨立於步驟 7 執行,而步驟 7 也獨立於步驟 8-9。在步驟 7 發生的同時,一個項可以在步驟 1-6 或步驟 8-9 中被鎖定,但只能同時發生步驟 1-6 或 8-9。如果日誌項在 AIL 中或在步驟 6 和 7 之間,並且步驟 1-6 再次進入,則該項將被重新日誌記錄。只有當步驟 8-9 進入並完成時,該物件才被認為是乾淨的。

使用延遲日誌記錄,生命週期中插入了新的步驟:

1. Transaction allocate
2. Transaction reserve
3. Lock item
4. Join item to transaction
        If not already attached,
                Allocate log item
                Attach log item to owner item
        Attach log item to transaction
5. Modify item
        Record modifications in log item
6. Transaction commit
        Pin item in memory if not pinned in CIL
        Format item into log vector + buffer
        Attach log vector and buffer to log item
        Insert log item into CIL
        Write CIL context sequence into transaction
        Unlock item

<next log force>

7. CIL push
        lock CIL flush
        Chain log vectors and buffers together
        Remove items from CIL
        unlock CIL flush
        write log vectors into log
        sequence commit records
        attach checkpoint context to log buffer

<log buffer IO dispatched>
<log buffer IO completes>

8. Checkpoint completion
        Mark log item committed
        Insert item into AIL
                Write commit LSN into log item
        Unpin log item
9. AIL traversal
        Lock item
        Mark log item clean
        Flush item to disk
<item IO completion>
10. Log item removed from AIL
        Moves log tail
        Item unlocked

由此可見,兩種日誌記錄方法之間唯一的生命週期差異在於生命週期的中間部分——它們仍然具有相同的開始、結束和執行約束。唯一的區別在於日誌項提交到日誌本身以及完成處理。因此,延遲日誌記錄不應引入任何目前不存在的對日誌項行為、分配或釋放的約束。

由於這種對延遲日誌記錄基礎設施的零影響“插入”以及內部結構設計避免了磁碟格式更改,我們基本上可以透過掛載選項在延遲日誌記錄和現有機制之間進行切換。從根本上講,日誌管理器沒有理由不能根據負載特性自動透明地切換方法,但如果延遲日誌記錄按設計工作,這應該不是必需的。