從更新丟失案例說InnoDB多版本并發(fā)控制(MVCC)

本文從一個Mysql丟失更新的案例入手,介紹InnoDB存儲引擎的非鎖定一致性讀取,多版本并發(fā)控制MVCC,事務隔離級別,以及InnoDB中的鎖策略。

例子

有一個銀行賬戶,里面有余額1000元,A,B兩個用戶同時使用兩個ATM進行余額查詢,他們都看到余額為1000元,于是A用戶轉出賬戶中的900元,銀行將余額更新為100元,B用戶轉出賬戶中的1元,銀行將余額更新為999元。由于同時操作的原因,最終該賬戶的余額有可能被更新為999元,但是賬戶卻轉出去兩筆錢,出現(xiàn)了邏輯意義上的更新丟失,模擬如下:
創(chuàng)建一個測試表,user字段為自增主鍵,cash字段表示余額:

-- ----------------------------
-- Table structure for account
-- ----------------------------
DROP TABLE IF EXISTS `account`;
CREATE TABLE `account` (
  `user` int(11) NOT NULL AUTO_INCREMENT,
  `cash` int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`user`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

插入一條數(shù)據(jù)

-- ----------------------------
-- Records of account
-- ----------------------------
INSERT INTO `account` VALUES ('1', '1000');

以時間順序展示sql以及執(zhí)行效果如下:

事務A 事務B 結果
1 start TRANSACTION;
select * FROM account WHERE user=1;
事務A查詢到一條記錄user=1,cash=1000
2 UPDATE account set cash=100 WHERE user=1;
select * FROM account WHERE user=1;
執(zhí)行成功;事務A再次查詢cash=100
3 start TRANSACTION;
select * FROM account WHERE user=1;
事務B查詢到一條記錄user=1,cash=1000
4 UPDATE account set cash=999 WHERE user=1; 等待
5 COMMIT; 事務A提交成功,事務B第4步執(zhí)行成功
6 COMMIT; 事務B提交成功
7 select * FROM account WHERE user=1; 此時再次進行查詢:user=1,cash=999

上面這個例子可以聯(lián)想到多線程中對共享數(shù)據(jù)的處理,如果多個線程同時修改共享數(shù)據(jù),那么數(shù)據(jù)將會產(chǎn)生錯亂,可以使用加鎖的方式對訪問和操作共享數(shù)據(jù)的代碼段(稱做臨界區(qū))進行加鎖,使得同一時間只能有一個線程持有鎖,達到保護共享數(shù)據(jù)的目的。

容易想到的是,InnoDB會對多個事務同時進行更改的數(shù)據(jù)進行加鎖(具體鎖的類別和不同語句的設置的鎖下文中進行說明),以避免并發(fā)問題保證同步,事實上在上面例子中user=1這行數(shù)據(jù)確實被事物A所在的線程加鎖,使用
SELECT * FROM `performance_schema`.data_locks;

可以看到如下結果:


FssqgI.png

那么為什么在事物A加鎖期間,事務B對數(shù)據(jù)的讀取依舊沒有阻塞呢?以及在第2步中事務A更新了該行數(shù)據(jù),第3步中事務B讀取到的數(shù)據(jù)卻依舊是最初的數(shù)據(jù)cash=1000呢?

一致性非鎖定讀(Consistent Nonlocking Reads)

如上所述,事務A在進行更新行數(shù)據(jù),但是其他事物的查詢操作并沒有阻塞,這是因為InnoDB的普通查詢采用了一致性非鎖定讀的特性,InnoDB在某個時間點對數(shù)據(jù)庫查詢得到的是一個快照。查詢將查看在該時間點之前提交的事務所做的更改,而不查看后續(xù)事務或未提交事務所做的更改。

InnoDB采用這種設計極大地提高了數(shù)據(jù)庫的并發(fā)性,使得對數(shù)據(jù)實時性并不是很高的查詢(可以接受一個不那么新鮮的數(shù)據(jù))不被阻塞,得到一個該行之前版本的數(shù)據(jù),因為不需要等待鎖的釋放,所以稱其為非鎖定讀。上面例子事物B之所以沒有被阻塞,是因為他獲得的是一個快照數(shù)據(jù)。

InnoDB是一個多版本的存儲引擎,多個事務可能會看到多個數(shù)據(jù)版本,這種技術就是多版本技術(Multi-Versioning),由此帶來的并發(fā)控制稱為多版本并發(fā)控制(Multiversion concurrency control)。容易看出,這種設計解決了以下問題:

  1. 事務的回滾:如果事務失敗需要回滾,那么事務可以根據(jù)快照信息構建行的早期版本,從而保證事務要么成功,要么失敗的原子性
  2. 讀的性能:當事務或者更新語句鎖住行記錄時,其他事務對行的普通讀不需要等待鎖的釋放,讀的性能得到提高
  3. 讀者過多引起的寫者饑餓問題:如果不采用MVCC,讀者對所讀的數(shù)據(jù)添加讀鎖,防止數(shù)據(jù)在讀的過程中被其他線程修改,寫者在鎖釋放之前無法進行更新操作,如果存在大量的讀者必定會使等待的寫者處于饑餓狀態(tài)。

那么InnoBD引擎是如何實現(xiàn)多版本的特性呢,我們接著往下看

undo log

由于隨機訪問硬盤的速度遠遠低于內(nèi)存,即ms和ns的差距,因此操作系統(tǒng)會將最近使用的數(shù)據(jù)加載到內(nèi)存中,這樣做的考慮是出于數(shù)據(jù)一旦被訪問,在短期內(nèi)有可能被再次訪問;程序寫操作時也會把數(shù)據(jù)先寫到內(nèi)存中,硬盤不會立即更新,而是把內(nèi)存中這些數(shù)據(jù)標記為臟頁,由回寫進程將臟頁回寫進磁盤。

InnoDB作為高效的數(shù)據(jù)庫引擎,也是采用類似的策略,它在內(nèi)存中分配緩沖池緩存表和索引等數(shù)據(jù)。經(jīng)常使用的數(shù)據(jù)直接從緩沖池中處理,數(shù)據(jù)更新時首先在內(nèi)存中更新,然后異步由線程刷新到硬盤中。

在上面例子的第2步中,事務A確實已經(jīng)將內(nèi)存中的cash的值更新為100,它自己隨后進行查詢也會返回更新后的值。那么事務B的快照信息是怎么回事呢?原來,InnoDB引擎在事務更新數(shù)據(jù)時會記錄一種叫做undo log的重做日志,這些信息存儲在名為回滾段( rollback segment)的數(shù)據(jù)結構中。InnoDB使用回滾段中的信息來執(zhí)行事務回滾所需的撤消操作。例如,如果事務進行回滾,結合undo log,對于每個插入操作,InnoDB引擎會執(zhí)行一個相反的刪除操作,對于每個更新操作,InnoDB引擎會執(zhí)行一個相反的更新操作,對于每個刪除操作,InnoDB引擎會執(zhí)行一個相反的插入操作,以此完成事務的回滾。

undo log的另一個作用就是實現(xiàn)MVCC,事務可以根據(jù)undo log“推理”出之前的行版本信息,從而實現(xiàn)非鎖定讀取,這就是例子中第3步,事務B進行查詢,得到的結果卻依舊是數(shù)據(jù)的最初版本,而不是內(nèi)存中事務A更新后值。

行記錄是如何找多回滾段的位置呢?在內(nèi)部,InnoDB向數(shù)據(jù)庫中存儲的每一行添加三個字段。一個6字節(jié)的DB_TRX_ID字段指示插入或更新行的最后一個事務的事務標識符。一個7字節(jié)的DB_ROLL_PTR字段,稱為滾動指針。滾動指針指向回滾段(rollback segment)的撤銷日志(undo log record)記錄。如果更新了行,則撤消日志記錄包含在更新行之前重建行的內(nèi)容所需的信息。一個6字節(jié)的DB_ROW_ID字段包含一個行ID,隨著插入新的行,這個行ID會單調(diào)地增加。如果InnoDB自動生成主鍵,則索引包含行ID值。否則,DB_ROW_ID列不會出現(xiàn)在任何索引中。

事務隔離級別

說到這里,我們就能清晰的理解事務的四個隔離級別之間的差異了。事務隔離是數(shù)據(jù)庫處理的基礎之一,隔離級別是在多個事務同時進行更改和執(zhí)行查詢時,對性能和可靠性、一致性和結果可重復性之間的平衡進行微調(diào)的設置。

InnoDB提供SQL:1992標準描述的所有四個事務隔離級別:未提交讀、提交讀、可重復讀和可序列化( READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, and SERIALIZABLE)。InnoDB的默認隔離級別是可重復讀。

REPEATABLE READ

這是InnoDB的默認隔離級別。在同一事務內(nèi)的讀取為第一次讀取建立的快照snapshot。這表示,如果在同一個事務中發(fā)出幾個普通(非鎖定)SELECT語句,那么這些SELECT語句彼此之間讀取到的數(shù)據(jù)是一致的。即在上面的例子中,無論事務A提交或是未提交,事務B提交前讀到的數(shù)據(jù)始終都是它在開始事物的時間點所看到的數(shù)據(jù)版本,所以稱為可重復讀。

READ COMMITTED

每個一致的讀取,甚至在同一個的事務中,都讀取最新快照。即在上面的例子中,事務A提交之前,事務B執(zhí)行普通的select看到的余額是1000元,而在事務A提交之后,事務B執(zhí)行普通的select就可以看到事務A更新后的數(shù)據(jù),即余額變?yōu)?00,由于同一個事物中同一個查詢語句每次查詢結果可能不同,所以稱為提交讀。

READ UNCOMMITTED

這種隔離級別下,select語句可以讀取其他事務未提交的數(shù)據(jù),即臟讀。在上面的例子中,事務A更新余額后即使沒有提交事務,事務B的查詢也可以看到余額被更新了,而此時事務A未必能最終執(zhí)行成功,當其他事務回滾時,數(shù)據(jù)會產(chǎn)生不一致。

SERIALIZABLE

這個級別類似于可重復讀,但是比可重復讀更嚴格,InnoDB隱式地將所有普通SELECT語句轉換為 SELECT ... LOCK IN SHARE MODE,即對普通的讀取操作也進行加鎖。在上面的例子中,事務A執(zhí)行select后,事務B使用select查詢將被阻塞,直到事務A提交事務釋放鎖。

如何解決問題

到這里,我們明白了更新丟失現(xiàn)象的原因:一致性的非鎖定讀讀取到了快照數(shù)據(jù)。是時候解決問題了,在這之前,首先了解一下InnoDB提供的幾種鎖。

共享鎖和獨占鎖

InnoDB實現(xiàn)了標準行級別鎖定,其中有兩種類型的鎖、共享鎖(shared locks,簡稱s鎖)和獨占鎖(exclusive locks,簡稱x鎖)。

  • 共享鎖允許持有鎖的事務讀取行。
  • 獨占(X)鎖允許持有鎖的事務更新或刪除一行。

例如事務T1持有行r上的共享(S)鎖,那么從某些不同的事務T2中請求對行r的鎖的處理如下:

  • T2對S鎖的請求可以立即授予。因此,T1和T2在r上都有S鎖。
  • T2對X鎖的請求不能立即授予。

如果事務T1在行r上持有獨占(X)鎖,則不能立即授予來自某個不同事務T2的請求,以獲取r上任意類型的鎖。相反,事務T2必須等待事務T1釋放在r行上的鎖。

Intention Locks意向鎖

InnoDB支持多粒度鎖,允許行鎖和表鎖共存。為了使多粒度級別的鎖更實用,InnoDB使用意圖鎖。意圖鎖是表級鎖,它指示在一個表中,一個事務需要稍后進行哪些類型的鎖(共享或獨占)。意圖鎖有兩種類型:

  • 一個意圖共享鎖(IS)表示一個事務打算在一個表中為單獨的行設置一個共享鎖。
  • 意圖獨占鎖(IX)指示事務打算在表中的單個行上設置獨占鎖。

意圖鎖定協(xié)議如下

  • 事務在獲取表中一行上的共享鎖之前,必須首先獲取表上的IS鎖或更強的鎖。
  • 事務在獲取表中的行上的獨占鎖之前,必須首先獲取表上的IX鎖。
    意圖鎖除了全表請求外不會阻塞任何東西(例如, LOCK TABLES ... WRITE).)。意圖鎖定的主要目的是顯示某人正在鎖定一個行,或者在表中鎖定一行。

表級鎖類型兼容性總結如下矩陣,如果請求事務與現(xiàn)有鎖兼容,則授予鎖,但與現(xiàn)有鎖沖突則不授予鎖。事務等待直到?jīng)_突的現(xiàn)有鎖被釋放。


屏幕快照 2018-12-26 19.14.06.png

不同SQL語句設置的鎖

在最開始的示例中我們查看了事務A執(zhí)行更新后數(shù)據(jù)庫實例中存在的鎖,其中在表上有一個IX鎖,行記錄上有一個X鎖,易于想到一種避免丟失更新的解決方案:在查詢的時候對數(shù)據(jù)行也加上X鎖,不使用非鎖定讀取。普通的SELECT ... FROM使用多版本不加鎖讀取數(shù)據(jù)庫快照,除非事務隔離級別設置為 SERIALIZABLE可序列化。

MYSQL提供了加鎖的讀取方式: SELECT ... FOR UPDATE 會為符合條件的行添加排他鎖,SELECT ... LOCK IN SHARE MODE會為符合條件的行添加共享鎖。因此,我們使用SELECT ... FOR UPDATE語句優(yōu)化示例執(zhí)行如下:

事務A 事務B 結果
1 start TRANSACTION;
select * FROM account WHERE user=1 for UPDATE;
事務A查詢到一條記錄user=1,cash=1000,并給該條行添加X鎖
2 UPDATE account set cash=100 WHERE user=1;
select * FROM account WHERE user=1;
執(zhí)行成功;事務A再次查詢cash=100
3 start TRANSACTION;
select * FROM account WHERE user=1 for UPDATE;
等待,事務A此時占有行的X鎖,事務B同樣請求X鎖,因此被阻塞
4 COMMIT; 事務A提交成功,事務B第3步執(zhí)行成功,返回cash=100
5 UPDATE account set cash=99 WHERE user=1; 事務B將余額減1,余額更新為99
6 COMMIT; 事務B提交成功
7 select * FROM account WHERE user=1; 此時再次進行查詢:user=1,cash=99

可以看到當事務A使用 SELECT ... FOR UPDATE語句后,為符合條件的記錄添加X鎖,B事務不采用非鎖定讀取獲得快照數(shù)據(jù),同樣使用SELECT ... FOR UPDATE語句進行查詢,在事務A提交事務釋放鎖后,事務B獲取鎖得到余額,并對余額進行計算更新成正確的值,問題解決。

小結

本文通過一個更新丟失的例子介紹了InnoDB中事務回滾的原理,涉及一致性讀取,多版本并發(fā)控制,事務隔離級別以及鎖等內(nèi)容。由于InnoDB中多版本并發(fā)控制及背后原理是比較難以理解的地方,因此主要對這部分內(nèi)容做了介紹。限于篇幅,對redo log的深入解析以及對InnoDB中其他方面的內(nèi)容并未提及,例如InnoDB插入緩沖,雙寫,二進制日志等特性,InnoDB中的索引,InnoDB間隙鎖與下一個鍵鎖等其他鎖等。如對其他內(nèi)容有興趣或想深入了解InnoDB實現(xiàn),推薦閱讀Mysql官方文檔以及《MySQL技術內(nèi)幕 InnoDB存儲引擎 》(第2版)

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容