1、ACID
事務是數(shù)據(jù)庫區(qū)別于文件系統(tǒng)的重要特性之一。
InnoDB的事務完全符合ACID特性。
- 原子性(Atomicity):操作過程不可分割,要么全部成功,要么全部失敗
- 一致性(Consistency):完整性約束不被破壞
- 隔離性(Isolution):事務提交前對其他事務不可見
- 持久性(Durability):事務一旦提交,其結果就是永久性的
In弄DB中的ACID特性依靠如下機制實現(xiàn):
- 隔離性由鎖來實現(xiàn);
- 原子性和持久性由redo log來實現(xiàn);
- 一致性由undo log來實現(xiàn)。
2、隔離級別
事務的隔離性是數(shù)據(jù)庫處理數(shù)據(jù)的幾大基礎之一,而隔離級別其實就是提供給用戶用于在性能和可靠性做出選擇和權衡的配置項。
InnoDB 遵循了 SQL:1992 標準中的四種隔離級別:
-
RAED UNCOMMITED:使用查詢語句不會加鎖,可能會讀到未提交的行(Dirty Read); -
READ COMMITED:只對記錄加記錄鎖,而不會在記錄之間加間隙鎖,所以允許新的記錄插入到被鎖定記錄的附近,所以再多次使用查詢語句時,可能得到不同的結果(Non-Repeatable Read); -
REPEATABLE READ:多次讀取同一范圍的數(shù)據(jù)會返回第一次查詢的快照,不會返回不同的數(shù)據(jù)行,但是可能發(fā)生幻讀(Phantom Read); -
SERIALIZABLE:InnoDB 隱式地將全部的查詢語句加上共享鎖,解決了幻讀的問題;
MySQL 中默認的事務隔離級別就是 REPEATABLE READ,但是它通過 Next-Key 鎖也能夠在某種程度上解決幻讀的問題。
接下來,我們將數(shù)據(jù)庫中創(chuàng)建如下的表并通過個例子來展示在不同的事務隔離級別之下,會發(fā)生什么樣的問題:
CREATE TABLE test(
id INT NOT NULL,
UNIQUE(id)
);
2.1 臟讀
當事務的隔離級別為 READ UNCOMMITED 時,我們在 SESSION 2 中插入的未提交數(shù)據(jù)在 SESSION 1 中是可以訪問的。

2.2 不可重復讀
當事務的隔離級別為 READ COMMITED 時,雖然解決了臟讀的問題,但是如果在 SESSION 1 先查詢了一個范圍的數(shù)據(jù),在這之后 SESSION 2 中插入一條數(shù)據(jù)并且提交了修改,在這時,如果 SESSION 1 中再次使用相同的查詢語句,就會發(fā)現(xiàn)兩次查詢的結果不一樣。

不可重復讀的原因就是,在 READ COMMITED 的隔離級別下,存儲引擎不會在查詢記錄時添加間隙鎖,鎖定 id < 5 這個范圍。
2.3 幻讀
重新開啟了兩個會話 SESSION 1 和 SESSION 2,在 SESSION 1 中我們查詢?nèi)淼男畔?,沒有得到任何記錄;在 SESSION 2 中向表中插入一條數(shù)據(jù)并提交;由于 REPEATABLE READ 的原因,再次查詢?nèi)淼臄?shù)據(jù)時,我們獲得到的仍然是空集,但是在向表中插入同樣的數(shù)據(jù)卻出現(xiàn)了錯誤。

這種現(xiàn)象在數(shù)據(jù)庫中就被稱作幻讀,雖然我們使用查詢語句得到了一個空的集合,但是插入數(shù)據(jù)時卻得到了錯誤,好像之前的查詢是幻覺一樣。
在標準的事務隔離級別中,幻讀是由更高的隔離級別 SERIALIZABLE 解決的,但是它也可以通過 MySQL 提供的 Next-Key 鎖解決:

REPERATABLE READ 和 READ UNCOMMITED 其實是矛盾的,如果保證了前者就看不到已經(jīng)提交的事務,如果保證了后者,就會導致兩次查詢的結果不同,MySQL 為我們提供了一種折中的方式,能夠在 REPERATABLE READ 模式下加鎖訪問已經(jīng)提交的數(shù)據(jù),其本身并不能解決幻讀的問題,而是通過文章前面提到的 Next-Key 鎖來解決。
3、重做日志(redo log)
3.1 write-ahead logging
預寫式日志(write-ahead logging,WAL ),是一種實現(xiàn)事務日志的標準方法,其中心思想是對數(shù)據(jù)文件的修改必須發(fā)生在這些修改已經(jīng)記錄了日志之后。
如果遵循這個過程,那么就不需要在每次事務提交的時候都把數(shù)據(jù)頁沖刷到磁盤,因為即便出現(xiàn)宕機的情況, 我們也可以用日志來恢復數(shù)據(jù)庫:任何尚未附加到數(shù)據(jù)頁的記錄都將先從日志記錄中重做(這叫向前滾動恢復,也叫做 redo)。
使用 WAL 的第一個主要的好處就是顯著地減少了磁盤寫的次數(shù)。 因為在日志提交的時候只有日志文件需要沖刷到磁盤;而不是事務修改的所有數(shù)據(jù)文件。 在多用戶環(huán)境里,許多事務的提交可以用日志文件的一次 fsync() 來完成。而且,日志文件是順序?qū)懙?/strong>, 因此同步日志的開銷要遠比同步數(shù)據(jù)頁的開銷要小。 這一點對于許多小事務對磁盤的隨機寫入更是如此。
3.2 基本概念
MySQL作為一個存儲系統(tǒng),為了保證數(shù)據(jù)的可靠性,最終得落盤;又為了數(shù)據(jù)寫入的速度,需要引入基于內(nèi)存的“緩沖池”。也就是說,數(shù)據(jù)是先緩存在緩沖池中,然后再以某種方式刷新到磁盤,在某個時間段之內(nèi),緩存中的數(shù)據(jù)與磁盤的數(shù)是不一致的,如果這個時候宕機會導致緩存數(shù)據(jù)的丟失。
引入redo log的目的就是為了防止以上問題的發(fā)生。
當向InnoDB寫用戶數(shù)據(jù)時,先寫redo log,然后redo log根據(jù)一定的規(guī)則持久化到磁盤,變成redo log file,用戶數(shù)據(jù)則在buffer中(比如數(shù)據(jù)頁、索引頁)。如果發(fā)生宕機,重啟后則讀取磁盤上的 redo log file 進行數(shù)據(jù)的恢復。
從這個角度來說,InnoDB事務的持久性是通過 redo log 來實現(xiàn)的。
下面以一個更新事務為例,宏觀上把握redo log 流轉(zhuǎn)過程,如下圖所示:

- 第一步:先將原始數(shù)據(jù)從磁盤中讀入內(nèi)存中來,修改數(shù)據(jù)的內(nèi)存拷貝
- 第二步:生成一條重做日志并寫入redo log buffer,記錄的是數(shù)據(jù)被修改后的值
- 第三步:當事務commit時,將redo log buffer中的內(nèi)容刷新到 redo log file,對 redo log file采用追加寫的方式
- 第四步:定期將內(nèi)存中修改的數(shù)據(jù)刷新到磁盤中
redo log包括兩部分:
- 內(nèi)存中的日志緩沖(redo log buffer),該部分日志是易失性的
- 磁盤上的重做日志文件(redo log file),該部分日志是持久的
InnoDB通過force log at commit機制實現(xiàn)事務的持久性,即在事務提交的時候,必須先將該事務的所有事務日志寫入到磁盤上的redo log file和undo log file中進行持久化。
為了確保每次日志都能寫入到事務日志文件中,在每次將log buffer中的日志寫入日志文件的過程中都會調(diào)用一次操作系統(tǒng)的fsync操作(即fsync()系統(tǒng)調(diào)用)。因為MySQL是工作在用戶空間的,MySQL的log buffer處于用戶空間的內(nèi)存中。要寫入到磁盤上的log file中,中間還要經(jīng)過操作系統(tǒng)內(nèi)核空間的os buffer,調(diào)用fsync()的作用就是將OS buffer中的日志刷到磁盤上的log file中。
也就是說,從redo log buffer寫日志到磁盤的redo log file中,過程如下:

redo log buffer 刷新到磁盤的時機由參數(shù) InnoDB_flush_log_at_trx_commit 控制,默認為1:
- 0: 事務提交時不進行寫入redo log操作,而是依靠master線程周期性的刷盤來保證。由于master線程每1秒進行一次redo log的fsync操作,因此實例崩潰后最多丟失1秒鐘內(nèi)的事務
- 1: 事務提交時必須調(diào)用一次
fsync操作,這種方式即使系統(tǒng)崩潰也不會丟失任何數(shù)據(jù),但是因為每次提交都寫入磁盤,IO的性能較差。該值是InnoDB的默認值 - 2: 在事務提交時只保證將redo log buffer寫到系統(tǒng)的os buffer中,不進行
fsync操作,因此如果MySQL數(shù)據(jù)庫宕機時 不會丟失事務,但操作系統(tǒng)宕機則可能丟失事務
3.3 兩次寫
當發(fā)生數(shù)據(jù)庫宕機時,可能InnoDB存儲引擎正在寫入某個頁到表中,而這個頁只寫了一部分,比如16KB的頁,只寫了前4KB,之后就發(fā)生了宕機,這種情況被稱為部分寫失效(partial page write)。
部分寫失效的問題,依靠重做日志無法恢復,因為重做日志是基于偏移量的物理操作。例如,寫'aaaa'記錄到偏移量800的位置,如果這個頁本身已經(jīng)發(fā)生了損壞,再對其進行重做是沒有意義的。這就是說,在使用重做日志前,用戶需要一個頁的副本,當寫入失效發(fā)生時,先通過頁的副本來還原該頁,再進行重做,這就是doublewrite。
下面是兩次寫的原理圖:

doublewrite由兩部分組成:
- 內(nèi)存中的doublewrite buffer,大小為2MB
- 物理磁盤上共享表空間中連續(xù)的128個頁,即2個區(qū)(extent),大小同樣為2MB
當刷新緩沖池臟頁時,并不直接寫到數(shù)據(jù)文件中,而是按照下面的路程執(zhí)行:
- 拷貝至內(nèi)存中的兩次寫緩沖區(qū)。
- 從兩次寫緩沖區(qū)分兩次寫入磁盤共享表空間中,每次1MB
- 待第2步完成后,再將兩次寫緩沖區(qū)寫入數(shù)據(jù)文件
這樣就可以解決上文提到的部分寫失效的問題,因為在磁盤共享表空間中已有數(shù)據(jù)頁副本拷貝,如果數(shù)據(jù)庫在頁寫入數(shù)據(jù)文件的過程中宕機,在實例恢復時,可以從共享表空間中找到該頁副本,將其拷貝覆蓋原有的數(shù)據(jù)頁,再應用重做日志即可。
其中第2步是額外的性能開銷,但由于磁盤共享表空間是連續(xù)的,因此開銷不是很大??梢酝ㄟ^參數(shù)skip_innodb_doublewrite禁用兩次寫功能,默認是開啟的。
3.4 LSN
緩存中未刷到磁盤的數(shù)據(jù)稱為臟數(shù)據(jù)(dirty data)。由于數(shù)據(jù)和日志都以頁的形式存在,所以臟頁包含臟數(shù)據(jù)和臟日志。
在InnoDB中,由一系列的規(guī)則來判斷是否到達了checkpoint,到達后會將緩存中的臟數(shù)據(jù)頁和臟日志頁都刷到磁盤。
LSN稱為日志的邏輯序列號(log sequence number),隨著日志的寫入而逐漸增大。根據(jù)LSN,可以獲取到幾個有用的信息:
- 數(shù)據(jù)頁的版本信息。
- 寫入的日志總量,通過LSN開始號碼和結束號碼可以計算出寫入的日志量。
- 可知道檢查點的位置。
可以通過show engine innodb status命令查看LSN的情況:
mysql> show engine innodb status\G;
*************************** 1. row ***************************
...
---
LOG
---
Log sequence number 2611354
Log flushed up to 2611354
Pages flushed up to 2611354
Last checkpoint at 2611345
0 pending log flushes, 0 pending chkp writes
10 log i/o's done, 0.09 log i/o's/second
...
- Log sequence number表示當前的LSN,
- Log flushed up to表示刷新到重做日志文件的LSN
- Pages flushed up to表示寫入磁盤的dirty page 上的LSN
- Last checkpoint at表示寫入磁盤的checkpoint上的LSN
3.5 恢復過程
在啟動InnoDB的時候,不管上次是正常關閉還是異常關閉,總是會進行恢復操作。
因為redo log記錄的是數(shù)據(jù)頁的物理變化,因此恢復的時候速度比邏輯日志(如二進制日志)要快很多。而且,InnoDB自身也做了一定程度的優(yōu)化,讓恢復速度變得更快。
重啟InnoDB時,checkpoint表示已經(jīng)完整刷到磁盤上data page上的LSN,因此恢復時僅需要恢復從checkpoint開始的日志部分。例如,當數(shù)據(jù)庫在上一次checkpoint的LSN為10000時宕機,且事務是已經(jīng)提交過的狀態(tài)。啟動數(shù)據(jù)庫時會檢查磁盤中數(shù)據(jù)頁的LSN,如果數(shù)據(jù)頁的LSN小于日志中的LSN,則會從檢查點開始恢復。
還有一種情況,在宕機前正處于checkpoint的刷盤過程,且數(shù)據(jù)頁的刷盤進度超過了日志頁的刷盤進度。這時候一宕機,數(shù)據(jù)頁中記錄的LSN就會大于日志頁中的LSN,在重啟的恢復過程中會檢查到這一情況,這時超出日志進度的部分將不會重做,因為這本身就表示已經(jīng)做過的事情,無需再重做。
另外,事務日志具有冪等性,所以多次操作得到同一結果的行為在日志中只記錄一次。而二進制日志不具有冪等性,多次操作會全部記錄下來,在恢復的時候會多次執(zhí)行二進制日志中的記錄,速度就慢得多。例如,某記錄中id初始值為2,通過update將值設置為了3,后來又設置成了2,在事務日志中記錄的將是無變化的頁,根本無需恢復;而二進制會記錄下兩次update操作,恢復時也將執(zhí)行這兩次update操作,速度比事務日志恢復更慢。
4、回滾日志(undo log)
4.1 基本概念
重做日志記錄了事務的行為,可以很好地通過其進行"重做"。但是事務有時還需要撤銷,這是就需要undo。undo與redo正好相反,對于數(shù)據(jù)庫進行修改時,數(shù)據(jù)庫不但會產(chǎn)生redo,而且還會產(chǎn)生一定量的undo。如果因為某些原因?qū)е率聞帐』蚧貪L了,可以借助該undo進行回滾。
undo log有兩個作用:
-
提供回滾
當事務回滾時,或者數(shù)據(jù)庫崩潰后重啟,可以利用undo日志,即舊版本數(shù)據(jù),撤銷未提交事務對數(shù)據(jù)庫產(chǎn)生的影響。
-
多版本控制(MVCC)
當讀取的某一行被其他事務鎖定時,它可以從undo log中分析出該行記錄以前的數(shù)據(jù)是什么,從而提供該行版本信息,讓用戶實現(xiàn)非鎖定一致性讀取。
與redo不同的是,redo存放在重做日志文件中,undo存放在數(shù)據(jù)庫內(nèi)部的一個特殊的段(segment)中,稱為undo段(undo segment),undo段位于共享表空間內(nèi)。InnoDB對undo的管理同樣采用段的方式,稱為rollback segment。每個回滾段記錄了1024個undo段。
undo log和redo log記錄物理日志不一樣,它是邏輯日志??梢哉J為當delete一條記錄時,undo log中會記錄一條對應的insert記錄,反之亦然,當update一條記錄時,它記錄一條對應相反的update記錄。
另外,undo log也會產(chǎn)生redo log,因為undo log也要實現(xiàn)持久性保護。
4.2 purge
purge線程兩個主要作用是:清理undo頁和清除page里面帶有Delete_Bit標識的數(shù)據(jù)行。
對插入、更新、刪除操作來說,purge操作的作用如下:
-
insert
因為insert操作的記錄,只對事務本身可見,對其他事務不可見,所以不需要保證MVCC。故該undo log可以在事務提交后直接刪除,不需要進行purge操作。
-
update
該undo log可能需要服務MVCC,因此不能再事務提交時就進行刪除。提交時放入undo log鏈表,等待purge線程進行最后的刪除
-
delete
與update類似,delete的undo log也可能需要服務MVCC。在InnoDB中,事務中的delete操作實際上并不是真正的刪除掉數(shù)據(jù)行,而是做了個標記,真正的刪除工作需要后臺purge線程去完成。
5、分布式事務
InnoDB通過XA事務來支持分布式事務的實現(xiàn)。
XA事務由一個或多個資源管理器(Resource Manager)、一個事務管理器(Transaction Manager)以及一個應用程序組成:
- 資源管理器:提供訪問事務資源的方法。通常一個數(shù)據(jù)就是一個資源管理器
- 事務管理器:協(xié)調(diào)參與全局事務中的各個事務。需要和參與全局事務的所有資源管理器進行通信
- 應用程序:定義事務的邊界,指定全局事務中的操作

分布式事務使用兩段式提交(two-phase commit)的方式。
- 第一個階段,所有參與全局事務的節(jié)點都開始準備,告訴事務管理器它們準備好提交了。
- 第二個階段,事務管理器告訴資源管理器執(zhí)行rollback或者commit,如果任何一個節(jié)點顯示不能commit,那么所有的節(jié)點就得全部rollback。
在MySQL中還存在一種內(nèi)部XA事務,存在于存儲引擎與插件之間,又或者存儲引擎與存儲引擎之間。
最常見的內(nèi)部XA事務是binlog與InnoDB之間。 由于復制的需要,因此目前絕大多數(shù)的數(shù)據(jù)庫都開啟了 binlog 功能。在事務提交時,先寫二進制日志,再寫 InnoDB 存儲引擎的重做日志。對上述兩個操作的要求也是原子的,即二進制日志和重做日志必須同時寫入。若二進制日志先寫了,而在寫入 InnoDB 存儲引擎時發(fā)生了者機,那么 slave 可能會接收到 master 傳過去的二進制日志并執(zhí)行,最終導致了主從不一致的情況。

在上圖中,如果執(zhí)行完第二步,未執(zhí)行第三步時 MySQL 數(shù)據(jù)庫發(fā)生了宕機 ,則會發(fā)生主從不一致的情況。為了解決這個問題,MySQL 數(shù)據(jù)庫在 binlog 與 InnoDB 存儲引擎之間采用XA事務。當事務提交時,InnoDB 存儲引擎會先做一個PREPARE操作,將事務的xid寫入,接著進行二進制日志的寫入。

如果在 InnoDB 存儲引擎提交前,MySQL 數(shù)據(jù)庫宕機了,那么 MySQL 數(shù)據(jù)庫在重啟后會先檢查準備的 UXID 事務是否已經(jīng)提交,若沒有,則在存儲引擎層再進行一次提交操作。