7.2. undo 日志
7.2.1. 事務回滾的需求
我們說過事務需要保證原子性,也就是事務中的操作要么全部完成,要么什么也不做。但是偏偏有時候事務執行到一半會出現一些情況,比如:
情況一:事務執行過程中可能遇到各種錯誤,比如服務器本身的錯誤,操作系統錯誤,甚至是突然斷電導致的錯誤。
情況二:程序員可以在事務執行過程中手動輸入 ROLLBACK 語句結束當前的事務的執行。
這兩種情況都會導致事務執行到一半就結束,但是事務執行過程中可能已經修改了很多東西,為了保證事務的原子性,我們需要把東西改回原先的樣子,這個過程就稱之為回滾(英文名:rollback),這樣就可以造成這個事務看起來什么都沒做,所以符合原子性要求。
每當我們要對一條記錄做改動時(這里的改動可以指 INSERT、DELETE、UPDATE),都需要把回滾時所需的東西都給記下來。比方說:
你插入一條記錄時,至少要把這條記錄的主鍵值記下來,之后回滾的時候只需要把這個主鍵值對應的記錄刪掉。
你刪除了一條記錄,至少要把這條記錄中的內容都記下來,這樣之后回滾時再把由這些內容組成的記錄插入到表中。
你修改了一條記錄,至少要把修改這條記錄前的舊值都記錄下來,這樣之后回滾時再把這條記錄更新為舊值。
這些為了回滾而記錄的這些東西稱之為撤銷日志,英文名為 undo log/undo日志。這里需要注意的一點是,由于查詢操作(SELECT)并不會修改任何用戶記錄,所以在查詢操作執行時,并不需要記錄相應的 undo 日志。
當然,在真實的 InnoDB 中,undo 日志其實并不像我們上邊所說的那么簡單,不同類型的操作產生的 undo 日志的格式也是不同的。
7.2.2. 事務 id
7.2.2.1. 給事務分配 id 的時機
一個事務可以是一個只讀事務,或者是一個讀寫事務:
我們可以通過 START TRANSACTION READ ONLY 語句開啟一個只讀事務。
在只讀事務中不可以對普通的表(其他事務也能訪問到的表)進行增、刪、改操作,但可以對用戶臨時表做增、刪、改操作。
我們可以通過 START TRANSACTION READ WRITE 語句開啟一個讀寫事務,或者使用 BEGIN、START TRANSACTION 語句開啟的事務默認也算是讀寫事務。
在讀寫事務中可以對表執行增刪改查操作。
如果某個事務執行過程中對某個表執行了增、刪、改操作,那么 InnoDB 存儲引擎就會給它分配一個獨一無二的事務 id,分配方式如下:
對于只讀事務來說,只有在它第一次對某個用戶創建的臨時表執行增、刪、改操作時才會為這個事務分配一個事務 id,否則的話是不分配事務 id 的。
我們前邊說過對某個查詢語句執行 EXPLAIN 分析它的查詢計劃時,有時候在Extra 列會看到 Using temporary 的提示,這個表明在執行該查詢語句時會用到內部臨時表。這個所謂的內部臨時表和我們手動用 CREATE TEMPORARY TABLE 創建的用戶臨時表并不一樣,在事務回滾時并不需要把執行 SELECT 語句過程中用到的內部臨時表也回滾,在執行 SELECT 語句用到內部臨時表時并不會為它分配事務 id。
對于讀寫事務來說,只有在它第一次對某個表(包括用戶創建的臨時表)執行增、刪、改操作時才會為這個事務分配一個事務 id,否則的話也是不分配事務id 的。
有的時候雖然我們開啟了一個讀寫事務,但是在這個事務中全是查詢語句,并沒有執行增、刪、改的語句,那也就意味著這個事務并不會被分配一個事務 id。
上邊描述的事務 id 分配策略是針對 MySQL 5.7 來說的,前邊的版本的分配方式可能不同。
7.2.2.2. 事務 id 生成機制
這個事務 id 本質上就是一個數字,它的分配策略和我們前邊提到的對隱藏列 row_id(當用戶沒有為表創建主鍵和 UNIQUE 鍵時 InnoDB 自動創建的列)的分配策略大抵相同,具體策略如下:
服務器會在內存中維護一個全局變量,每當需要為某個事務分配一個事務 id時,就會把該變量的值當作事務 id 分配給該事務,并且把該變量自增 1。
每當這個變量的值為 256 的倍數時,就會將該變量的值刷新到系統表空間的頁號為 5 的頁面中一個稱之為 Max Trx ID 的屬性處,這個屬性占用 8 個字節的存儲空間。
當系統下一次重新啟動時,會將上邊提到的 Max Trx ID 屬性加載到內存中,將該值加上 256 之后賦值給我們前邊提到的全局變量(因為在上次關機時該全局變量的值可能大于 Max Trx ID 屬性值)。
這樣就可以保證整個系統中分配的事務 id 值是一個遞增的數字。先被分配id 的事務得到的是較小的事務 id,后被分配 id 的事務得到的是較大的事務 id。
7.2.3. trx_id 隱藏列
我們在學習 InnoDB 記錄行格式的時候重點強調過:聚簇索引的記錄除了會保存完整的用戶數據以外,而且還會自動添加名為 trx_id、roll_pointer 的隱藏列,
如果用戶沒有在表中定義主鍵以及 UNIQUE 鍵,還會自動添加一個名為 row_id的隱藏列。
其中的 trx_id 列就是某個對這個聚簇索引記錄做改動的語句所在的事務對應的事務 id 而已(此處的改動可以是 INSERT、DELETE、UPDATE 操作)。至于roll_pointer 隱藏列我們后邊分析。
7.2.4. undo 日志的格式
為了實現事務的原子性,InnoDB 存儲引擎在實際進行增、刪、改一條記錄時,都需要先把對應的 undo 日志記下來。一般每對一條記錄做一次改動,就對應著一條 undo 日志,但在某些更新記錄的操作中,也可能會對應著 2 條 undo日志。
一個事務在執行過程中可能新增、刪除、更新若干條記錄,也就是說需要記錄很多條對應的 undo 日志,這些 undo 日志會被從 0 開始編號,也就是說根據生成的順序分別被稱為第 0 號 undo 日志、第 1 號 undo 日志、…、第 n 號 undo日志等,這個編號也被稱之為 undo no。
這些 undo 日志是被記錄到類型為 FIL_PAGE_UNDO_LOG 的頁面中。這些頁面可以從系統表空間中分配,也可以從一種專門存放 undo 日志的表空間,也就是所謂的 undo tablespace 中分配。先來看看不同操作都會產生什么樣子的 undo日志。
7.2.4.1. INSERT 操作對應的 undo 日志
當我們向表中插入一條記錄時最終導致的結果就是這條記錄被放到了一個
數據頁中。如果希望回滾這個插入操作,那么把這條記錄刪除就好了,也就是說在寫對應的 undo 日志時,主要是把這條記錄的主鍵信息記上。InnoDB 的設計了一個類型為 TRX_UNDO_INSERT_REC 的 undo 日志。
如果記錄中的主鍵只包含一個列,那么在類型為 TRX_UNDO_INSERT_REC 的undo 日志中只需要把該列占用的存儲空間大小和真實值記錄下來,如果記錄中的主鍵包含多個列,那么每個列占用的存儲空間大小和對應的真實值都需要記錄下來。
當我們向某個表中插入一條記錄時,實際上需要向聚簇索引和所有的二級索引都插入一條記錄。不過記錄 undo 日志時,我們只需要考慮向聚簇索引插入記錄時的情況就好了,因為其實聚簇索引記錄和二級索引記錄是一一對應的,我們在回滾插入操作時,只需要知道這條記錄的主鍵信息,然后根據主鍵信息做對應的刪除操作,做刪除操作時就會順帶著把所有二級索引中相應的記錄也刪除掉。
后邊說到的 DELETE 操作和 UPDATE 操作對應的 undo 日志也都是針對聚簇索引記錄而言的。
roll_pointer 的作用
roll_pointer 本質上就是一個指向記錄對應的 undo 日志的一個指針。比方說我們向表里插入了 2 條記錄,每條記錄都有與其對應的一條 undo 日志。記錄被存儲到了類型為 FIL_PAGE_INDEX 的頁面中(就是我們前邊一直所說的數據頁),undo 日志被存放到了類型為FIL_PAGE_UNDO_LOG 的頁面中。roll_pointer 本質就是一個指針,指向記錄對應的 undo 日志。
7.2.4.2. DELETE 操作對應的 undo 日志
我們知道插入到頁面中的記錄會根據記錄頭信息中的 next_record 屬性組成一個單向鏈表,我們把這個鏈表稱之為正常記錄鏈表;被刪除的記錄其實也會根據記錄頭信息中的 next_record 屬性組成一個鏈表,只不過這個鏈表中的記錄占用的存儲空間可以被重新利用,所以也稱這個鏈表為垃圾鏈表。Page Header 部分有一個稱之為 PAGE_FREE 的屬性,它指向由被刪除記錄組成的垃圾鏈表中的頭節點。
假設此刻某個頁面中的記錄分布情況是這樣的
我們只把記錄的 delete_mask 標志位展示了出來。從圖中可以看出,正常記錄鏈表中包含了 3 條正常記錄,垃圾鏈表里包含了 2 條已刪除記錄。頁面的 PageHeader 部分的 PAGE_FREE 屬性的值代表指向垃圾鏈表頭節點的指針。
假設現在我們準備使用 DELETE 語句把正常記錄鏈表中的最后一條記錄給刪除掉,其實這個刪除的過程需要經歷兩個階段:
階段一:將記錄的delete_mask標識位設置為1,這個階段稱之為delete mark。
可以看到,正常記錄鏈表中的最后一條記錄的 delete_mask 值被設置為 1,但是并沒有被加入到垃圾鏈表。也就是此時記錄處于一個中間狀態。在刪除語句所在的事務提交之前,被刪除的記錄一直都處于這種所謂的中間狀態。
為啥會有這種奇怪的中間狀態呢?其實主要是為了實現一個稱之為 MVCC的功能,稍后再介紹。
階段二:當該刪除語句所在的事務提交之后,會有專門的線程后來真正的把記錄刪除掉。所謂真正的刪除就是把該記錄從正常記錄鏈表中移除,并且加入到垃圾鏈表中,然后還要調整一些頁面的其他信息,比如頁面中的用戶記錄數量PAGE_N_RECS、上次插入記錄的位置PAGE_LAST_INSERT、垃圾鏈表頭節點的指針 PAGE_FREE、頁面中可重用的字節數量 PAGE_GARBAGE、還有頁目錄的一些信息等等。這個階段稱之為 purge。
把階段二執行完了,這條記錄就算是真正的被刪除掉了。這條已刪除記錄占用的存儲空間也可以被重新利用了。
從上邊的描述中我們也可以看出來,在刪除語句所在的事務提交之前,只會經歷階段一,也就是 delete mark 階段(提交之后我們就不用回滾了,所以只需考慮對刪除操作的階段一做的影響進行回滾)。InnoDB 中就會產生一種稱之為TRX_UNDO_DEL_MARK_REC 類型的 undo 日志。
版本鏈
同時,在對一條記錄進行 delete mark 操作前,需要把該記錄的舊的 trx_id和 roll_pointer 隱藏列的值都給記到對應的 undo 日志中來,就是我們圖中顯示的old trx_id 和 old roll_pointer 屬性。這樣有一個好處,那就是可以通過 undo 日志的 old roll_pointer 找到記錄在修改之前對應的 undo 日志。比方說在一個事務中,我們先插入了一條記錄,然后又執行對該記錄的刪除操作,這個過程的示意圖就是這樣:
從圖中可以看出來,執行完delete mark操作后,它對應的undo日志和INSERT操作對應的 undo 日志就串成了一個鏈表。這個鏈表就稱之為版本鏈。
7.2.4.3. UPDATE 操作對應的 undo 日志
在執行 UPDATE 語句時,InnoDB 對更新主鍵和不更新主鍵這兩種情況有截然不同的處理方案。
不更新主鍵的情況
在不更新主鍵的情況下,又可以細分為被更新的列占用的存儲空間不發生變化和發生變化的情況。
就地更新(in-place update)
更新記錄時,對于被更新的每個列來說,如果更新后的列和更新前的列占用的存儲空間都一樣大,那么就可以進行就地更新,也就是直接在原記錄的基礎上修改對應列的值。再次強調一邊,是每個列在更新前后占用的存儲空間一樣大,有任何一個被更新的列更新前比更新后占用的存儲空間大,或者更新前比更新后占用的存儲空間小都不能進行就地更新。
先刪除掉舊記錄,再插入新記錄
在不更新主鍵的情況下,如果有任何一個被更新的列更新前和更新后占用的存儲空間大小不一致,那么就需要先把這條舊的記錄從聚簇索引頁面中刪除掉,然后再根據更新后列的值創建一條新的記錄插入到頁面中。
請注意一下,我們這里所說的刪除并不是 delete mark 操作,而是真正的刪除掉,也就是把這條記錄從正常記錄鏈表中移除并加入到垃圾鏈表中,并且修改頁面中相應的統計信息(比如 PAGE_FREE、PAGE_GARBAGE 等這些信息)。由用戶線程同步執行真正的刪除操作,真正刪除之后緊接著就要根據各個列更新后的值創建的新記錄插入。
這里如果新創建的記錄占用的存儲空間大小不超過舊記錄占用的空間,那么可以直接重用被加入到垃圾鏈表中的舊記錄所占用的存儲空間,否則的話需要在頁面中新申請一段空間以供新記錄使用,如果本頁面內已經沒有可用的空間的話,那就需要進行頁面分裂操作,然后再插入新記錄。
針對 UPDATE 不更新主鍵的情況(包括上邊所說的就地更新和先刪除舊記錄再插入新記錄),InnoDB 設計了一種類型為TRX_UNDO_UPD_EXIST_REC 的 undo日志。
更新主鍵的情況
在聚簇索引中,記錄是按照主鍵值的大小連成了一個單向鏈表的,如果我們更新了某條記錄的主鍵值,意味著這條記錄在聚簇索引中的位置將會發生改變,比如你將記錄的主鍵值從 1 更新為 10000,如果還有非常多的記錄的主鍵值分布在 1 ~ 10000 之間的話,那么這兩條記錄在聚簇索引中就有可能離得非常遠,甚至中間隔了好多個頁面。針對 UPDATE 語句中更新了記錄主鍵值的這種情況,InnoDB 在聚簇索引中分了兩步處理:
將舊記錄進行 delete mark 操作
也就是說在 UPDATE 語句所在的事務提交前,對舊記錄只做一個 delete mark操作,在事務提交后才由專門的線程做 purge 操作,把它加入到垃圾鏈表中。這里一定要和我們上邊所說的在不更新記錄主鍵值時,先真正刪除舊記錄,再插入新記錄的方式區分開!
之所以只對舊記錄做 delete mark 操作,是因為別的事務同時也可能訪問這條記錄,如果把它真正的刪除加入到垃圾鏈表后,別的事務就訪問不到了。這個功能就是所謂的 MVCC。
創建一條新記錄
根據更新后各列的值創建一條新記錄,并將其插入到聚簇索引中(需重新定位插入的位置)。
由于更新后的記錄主鍵值發生了改變,所以需要重新從聚簇索引中定位這條記錄所在的位置,然后把它插進去。
針對 UPDATE 語句更新記錄主鍵值的這種情況,在對該記錄進行 delete mark操作前,會記錄一條類型為 TRX_UNDO_DEL_MARK_REC 的 undo 日志;之后插入新記錄時,會記錄一條類型為 TRX_UNDO_INSERT_REC 的 undo 日志,也就是說每對一條記錄的主鍵值做改動時,會記錄 2 條 undo 日志。
7.2.5. FIL_PAGE_UNDO_LOG 頁面
我們前邊說明表空間的時候說過,表空間其實是由許許多多的頁面構成的,頁面默認大小為 16KB。這些頁面有不同的類型,比如類型為 FIL_PAGE_INDEX 的頁面用于存儲聚簇索引以及二級索引,類型為 FIL_PAGE_TYPE_FSP_HDR 的頁面用于存儲表空間頭部信息的,還有其他各種類型的頁面,其中有一種稱之為FIL_PAGE_UNDO_LOG 類型的頁面是專門用來存儲 undo 日志的。