MySql(四) InnoDB事務淺析

湖人總冠軍發表於2018-12-22
在寫上一篇MySql鎖機制的時候就一直在想關於InnoDb事務的問題,一直拖到了現在才寫這篇部落格。一方面是時間問題,另一方面是事務系統實在是太複雜了,查閱了很多資料梳理了很久,有很多零碎生澀的概念。文中有些地方只是粗略的帶過,講得不清楚或者是錯誤的希望大家包容並指出?

事務的四個條件

事務滿足的4個條件(ACID):原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、永續性(Durability)
  1. 原子性:一個事務(transaction)中的所有操作,要麼全部完成,要麼全部不完成。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態。
  2. 一致性:指的是在任何時刻,包括資料庫正常提供服務的時候,資料庫從異常中恢復過來的時候,資料都是一致的,保證不會讀到中間狀態的資料。
  3. 隔離性:允許多個併發事務同時對其資料進行讀寫和修改的能力,隔離性可以防止多個事務併發執行時由於交叉執行而導致資料的不一致。事務隔離分為不同級別,包括讀未提交(Read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和序列化(Serializable)。
  4. 永續性:指的是事務commit的資料在任何情況下都不能丟。
實現:InnoDB通過undolog保證rollback的時候能找到之前的資料保證了原子性;通過crash recovery和double write buffer的機制保證資料的一致性。通過redolog保證永續性。隔離性則由鎖和mvcc保證。

重要結構體的概念

  • undo segments:回滾段(資料頁的修改鏈),連結串列最前面的是最老的一次修改,最後面的最新的一次修改,從連結串列尾部逆向操作可以恢復到資料最老的版本。與之相關的還有undo tablespace, undo segment, undo slot, undo log這幾個概念。undo log是最小的粒度,所在的資料頁稱為undo page,然後若干個undo page構成一個undo slot。一個事務最多可以有兩個undo slot,一個是insert undo slot, 用來儲存insert undo log,裡面主要記錄了主鍵的資訊,方便在回滾的時候快速找到這一行。另外一個是update undo slot,用來儲存這個事務delete/update產生的undo log,裡面詳細記錄了被修改之前每一列的資訊,便於在讀請求需要的時候構造。1024個undo slot構成了一個undo segment。然後若干個undo segemnt構成了undo tablespace。
  • history list:insert undo可以在事務提交/回滾後直接刪除,沒有事務會要求查詢新插入資料的歷史版本,但是update undo則不可以,因為其他讀請求可能需要使用update undo構建之前的歷史版本。因此,在事務提交的時候,會把update undo加入到一個全域性連結串列(history list)中,連結串列按照事務提交的順序排序,保證最先提交的事務的update undo在前面,這樣Purge執行緒就可以從最老的事務開始做清理。
  • trx_t:每個連線持有一個,在建立連線後執行第一個事務開始被初始化,後續這個連線的所有事務一直複用裡面的資料結構,直到這個連線斷開。事務啟動後會把這個結構體加入到全域性事務連結串列中(mysql_trx_list),如果是讀寫事務,還會加入到全域性讀寫事務連結串列中(rw_trx_list)。在事務提交的時候加入到全域性提交事務連結串列中(trx_serial_list)。
    • state欄位記錄了事務四種狀態:TRX_STATE_NOT_STARTED, TRX_STATE_ACTIVE, TRX_STATE_PREPARED, TRX_STATE_COMMITTED_IN_MEMORY
    •  id欄位是在事務剛建立的時候分配的(只讀事務永遠為0,讀寫事務通過一個全域性id產生器產生),目的就是為了區分不同的事務(只讀事務通過指標地址來區分)。
    • 而no欄位是在事務提交前,通過同一個全域性id生產器產生的,主要是為了確定事務提交的順序,保證加入到history list中的update undo有序,方便purge執行緒清理。 
    • read_view(檢視)用來表示當前事務的可見範圍(檢視)。
    • insert undo slot和update undo slot。
    • read_only表示是否是隻讀事務。
  • trx_sys_t:用來維護系統的事務資訊,全域性唯一,在資料庫啟動的時候初始化。
    • max_trx_id,表示系統當前還未分配的最小事務id,如果有一個新的事務,直接把這個值作為新事務的id,然後這個欄位遞增。
    • descriptors,這個是一個陣列,裡面存放著當前所有活躍的讀寫事務id,當需要開啟一個readview的時候,就從這個欄位裡面拷貝一份,用來判斷記錄的對事務的可見性。
    • rw_trx_list,這個主要是用來存放當前系統的所有讀寫事務,按照事務id排序。
    • mysql_trx_list,這裡面存放所有使用者建立的事務,系統的事務和奔潰恢復後的事務不會在這個連結串列上,但是這個連結串列上可能會有還沒開始的使用者事務。
    • trx_serial_list,按照事務no排序的已經提交的事務。
    • rseg_array,這個指向系統所有可以用的回滾段(undo segments),當某個事務需要回滾段的時候,就從這裡分配。
    • rseg_history_len, 所有提交事務的update undo的長度,也就是上文提到的歷史連結串列的長度。
    • view_list,這個是系統當前所有的readview, 所有開啟的readview的事務都會把自己的readview放在這個上面,按照事務no排序。
  • read_view_t:InnoDB為了判斷某條記錄是否對當前事務可見,需要對此記錄進行可見性判斷,這個結構體就是用來輔助判斷的
    • low_limit_no,這個主要是給purge執行緒用,readview建立的時候,會把當前最小的提交事務id賦值給low_limit_no,這樣Purge執行緒就可以把所有已經提交的事務的undo日誌給刪除。
    • low_limit_id, 建立readview時的max_trx_id,即一定大於descriptors中的最大值。所有大於等於此值的記錄都不應該被此readview看到。
    • up_limit_id, 是descriptors中最小的值,所有小於此值的記錄都可以被readview看到
    • descriptors, 裡面存了readview建立時候當前所有活躍的讀寫事務id,除了事務自己做的變更外,此readview應該看不到descriptors中事務所做的變更。
    • view_list,每個readview都會被加入到trx_sys中的全域性readview連結串列中。
  • trx_rseg_t:undo segment記憶體中的結構體。每個undo segment都對應一個。
    • update_undo_list表示已經被分配出去的正在使用的update undo連結串列,
    • insert_undo_list表示已經被分配出去的正在使用的insert undo連結串列。
    • update_undo_cached和insert_undo_cached表示為了快速使用而快取起來的undo連結串列。

事務開啟

InnoDB 提供了多種方式來開啟一個事務,所有顯式開啟事務的行為都會隱式的將上一條事務提交掉。
  • BEGIN、BEGIN WORK、START TRANSACTION:執行BEGIN命令並不會真的去引擎層(InnoDB)開啟一個事務,僅僅是為當前執行緒設定標記,表示為顯式開啟的事務。
  • START TRANSACTION READ ONLY:為當前執行緒的thd->tx_read_only設定為true。當Server層接受到任何資料更改的SQL時,都會直接拒絕請求,返回錯誤不會進入引擎層。
  • START TRANSACTION READ WRITE:允許super使用者在read_only引數為true的情況下啟動讀寫事務。
  • START TRANSACTION WITH CONSISTENT SNAPSHOT:這種啟動方式會進入引擎層層,並開啟一個readview。只有在RR隔離級別下,這種操作才有效,否則會報錯。
除了with consistent snapshot的方式會進入InnoDB層,其他所有的方式都只是在Server層做個標記,沒有進入InnoDB做標記,在InnoDB看來所有的事務在啟動時候都是隻讀狀態,只有接受到修改資料的SQL後才把只讀事務提升為讀寫事務。讀寫事務需要分配事務id,分配回滾段,加入到全局讀寫事務連結串列(rw_trx_list),把事務id加入到活躍讀寫事務陣列中(descriptors)

例項分析1(RR級別)

在book表中有一條記錄id:1,name:book1;按順序執行下列語句
事務1:BEGIN;(1)
SELECT * FROM book_book WHERE id = 1;(5)
COMMIT;(6)
事務2:BEGIN;(2)
UPDATE book_book SET name = 'book2' WHERE id = 1;(3)
COMMIT;(4)複製程式碼

分析:結果(5)查出來的name為book2,明明事務1比事務2先,為什麼事務1讀到了2中提交的內容?這實際上涉及到了readview一致性讀的問題,在RR級別下事務1開始時並不會去給事務系統trx_sys打快照生成readview,而是在第一條SQL語句執行時生成的readview。這時事務2已經提交,事務2id在readview中up-low之間且descriptors中不存在事務2id,所以事務1能讀到事務2修改的資料。如果事務1是用with consistent snapshot方式開啟事務那麼便不能讀到事務2修改的資料,因為此時readview中的low_limit_id等於事務2id。此外在RC級別下每次執行SQL都會生成readview。

這裡講到了一致性讀(又稱快照讀)即普通select,對於加鎖的select和delete、update、insert成為當前讀。

Undo log

undo log作用:提供回滾和MVCC。在資料修改的時候,記錄了相對應的undo,如果事務失敗或回滾了,可以藉助該undo進行回滾。 undo log是邏輯日誌,記錄更改前的映象。當需要當前讀的時候,它可以從undo log中分析出該行記錄以前的資料提供版本資訊。 另外undo log也會產生redo log,因為undo log也要實現永續性保護。

事務提交

  1.  使用全域性事務id產生器生成事務no,然後把事務trx_t加入到trx_serial_list。
  2. 標記undo,如果這個事務只使用了一個undopage且使用量小於四分之三個page,則把這個page標記為(TRX_UNDO_CACHED)。如果不滿足且是insert undo則標記為TRX_UNDO_TO_FREE,否則undo為update undo則標記為TRX_UNDO_TO_PURGE。標記為TRX_UNDO_CACHED的undo會被回收,方便下次重新利用。 
  3. 把update undo放入所在undo segment的history list,並遞增rseg_history_len(全域性)。同時更新page上的TRX_UNDO_TRX_NO, 如果刪除了資料,則重置delete_mark。
  4.  把undate undo從update_undo_list中刪除,如果被標記為TRX_UNDO_CACHED,則加入到update_undo_cached佇列中。
  5.  mtr_commit(日誌undo/redo寫入公共緩衝區),至此,在檔案層次事務提交。這個時候即使crash,重啟後依然能保證事務是被提交的。接下來要做的是記憶體資料狀態的更新(trx_commit_in_memory)。 
  6. 只讀事務只需要把readview從全域性readview連結串列中移除,然後重置trx_t結構體裡面的資訊即可。讀寫事務首先需要是設定事務狀態為TRX_STATE_COMMITTED_IN_MEMORY,其次,釋放所有行鎖,接著,trx_t從rw_trx_list中移除,readview從全域性readview連結串列中移除,另外如果有insert undo則在這裡移除(update undo在事務提交前就被移除,主要是為了保證新增到history list的順序),如果有update undo,則喚醒Purge執行緒進行垃圾清理,最後重置trx_t裡的資訊,便於下一個事務使用。

事務回滾

  1. 如果是隻讀事務,則直接返回。
  2. 判斷當前是回滾整個事務還是部分事務,如果是部分事務,則記錄下需要保留多少個undolog,多餘的都回滾掉。
  3. 從update undo和insert undo中找出最後一條undo,從這條undo開始回滾。
  4. 如果是update undo則將標記為刪除的記錄清理標記,更新過的資料回滾到最老的版本。如果是insert undo則直接刪除聚集索引和二級索引。
  5. 如果所有undo都已經被回滾或者回滾到了指定的undo則停止,並把undolog刪除(由於不需要使用undo構建歷史版本)。

例項分析2

在book表中有一條記錄id:1,name:book1;按順序執行下列語句
事務一:BEGIN;(1)
SELECT * FROM book_book WHERE id = 1;(3)
SELECT * FROM book_book WHERE id = 1;(6)
UPDATE book_book SET `name` = 'book3' WHERE id = 1 AND `name` = 'book2';(7)
SELECT * FROM book_book WHERE id = 1;(8)
COMMIT;(9)
事務二:BEGIN;(2)
UPDATE book_book SET `name` = 'book2' WHERE id = 1;(4)
COMMIT;(5)複製程式碼

分析:3、6查出來name為'book1',7更新成功,8查詢name為'book3'。事務一中3、6查詢都是name為'book1',為什麼7能更新成功呢?這是因為在3時生成了read view對事務二是不可見的,6還是快照讀依舊對事務二中的修改不可見,7是當前讀會去通過history list中的undolog構建歷史版本,從而看到事務二修改的name為book2,8還是快照讀對當前事務可見所以查詢結果name為book3;

事務提交流程簡要分析

(1)BEGIN;
(2)UPDATE book_book SET `name` = 'book2' WHERE id = 1; undolog、redolog
(3)INSERT INTO `book_book` (`id`, `name`) VALUES ('2', 'JAVA物件導向程式設計'); undolog、redolog
(COMMIT)
(4)undo log buffer中undolog寫入磁碟
(5)redo log buffer中redolog寫入磁碟
(6)dbbuffer中資料寫入磁碟複製程式碼

關於redolog(物理日誌記錄資料頁的變化,保證事務的永續性用來在crash後恢復事務)和binlog(邏輯日誌記錄資料或者sql)等日誌有興趣的同學可以深入瞭解



MySQL系列其他文章:

MySql(一) 淺析MySql索引

MySQL(二) MySql常用優化

MySql(三) MySql中的鎖機制



參考文章:https://dev.mysql.com/doc/refman/5.7/en/innodb-undo-logs.html
https://www.aliyun.com/jiaocheng/1107450.html?spm=5176.100033.2.5.31613561FIMCB3
http://mysql.taobao.org/monthly/2017/12/01/?spm=a2c4e.11153940.blogcont560506.19.4b32720fTYF6D3
複製程式碼


相關文章