最近要在公司內做一次技術分享,思來想去不知道該分享些什么,最后在朋友的提示下,准備分享一下MySQL
的InnoDB
引擎下的事務幻讀問題與解決方案--LBCC
&MVCC
。經過好幾天的熬夜通宵,終於把這部分的內容捋清楚了。至於為什么說是InnoDB
呢?因為MyISAM
引擎是不支持事務的。
事務
概念
一個事情由n個單元組成,這n個單元在執行過程中,要么同時成功,要么同時失敗,這就把n個單元放在了一個事務之中。舉個簡單的例子:在不考慮試題正確與否的前提下,一張試卷由多個題目構成,當你答完題交給老師的時候是將一整張試卷交給老師,而不是將每道題單獨交給老師,在這里試卷就可以理解成一個事務。
事務的特性:ACID
A:原子性(Atomicity
),原子性是指事務是一個不可分割的工作單位,事務中的操作,要么都發生,要么都不發生。
例:假設你在購物車里添加了兩件衣服:上衣和褲子,當你把兩件衣服作為一個訂單提交支付的時候,要么兩件衣服一起支付成功,要么都失敗,不可能存在上衣付完錢了,褲子還沒付完的情況,反之亦然。
C:一致性(Consistency
),在一個事務中,事務前后數據的完整性必須保持一致。
例:假設用戶A和用戶B兩者的錢加起來一共是200,那么不管A和B之間如何轉賬,轉幾次賬,事務結束后兩個用戶的錢相加起來應該還得是200,這就是事務的一致性。
I:隔離性(Isolation
),存在於多個事務中,事務的隔離性是指多個用戶並發訪問數據庫時,一個用戶的事務不能被其它用戶的事務所干擾,多個並發事務之間數據要相互隔離。
例:對於任意兩個並發的事務T1和T2,在事務T1看來,T2要么在T1開始之前就已經結束,要么在T1結束之后才開始,這樣每個事務都感覺不到有其他事務在並發地執行。
D:持久性(Durability
),持久性是指一個事務一旦被提交,它對數據庫中數據的改變就是永久性的,接下來即使數據庫發生故障也不應該對其有任何影響。
例:我們在操作數據庫時,事務提交或者回滾都會直接改變數據庫中的值。
事務的操作
在使用事務之前,首先我們要開啟事務,我們可以通過start
或者begin
命令開啟事務;如果我們想提交事務可以手動執行commit
命令,如果我們想回滾事務,可以執行rollback
命令。
注:在MySQL
中事務的提交是默認開啟的,可以執行show variables like 'autocommit'
命令查看,如果是ON
則證明自動提交已經開啟,如果為OFF
則需要手動提交。
隔離性引發的並發問題
1)臟讀:B事務讀取到了A事務尚未提交的數據;
2)不可重復讀:B事務讀到了A事務已經提交的數據,即B事務在A事務提交之前和提交之后讀取到的數據內容
不一致(AB事務操作的是同一條數據);
3)幻讀/虛讀:B事務讀到了A事務已經提交的數據,即A事務執行插入操作,B事務在A事務前后讀到的數據數量
不一致。
事務的隔離級別
為了解決以上隔離性引發的並發問題,數據庫提供了事物的隔離機制。
- read uncommitted(讀未提交): 一個事務還沒提交時,它做的變更就能被別的事務看到,讀取尚未提交的數據,哪個問題都不能解決;
- read committed(讀已提交):一個事務提交之后,它做的變更才會被其他事務看到,讀取已經提交的數據,可以解決臟讀 ----
oracle
默認的; - repeatable read(可重復讀):一個事務執行過程中看到的數據,總是跟這個事務在啟動時看到的數據是一致的,可以解決臟讀和不可重復讀 ---
mysql
默認的; - serializable(串行化):顧名思義是對於同一行記錄,“寫”會加“寫鎖”,“讀”會加“讀鎖”。當出現讀寫鎖沖突的時候,后訪問的事務必須等前一個事務執行完成,才能繼續執行。可以解決臟讀、不可重復讀和虛讀---相當於鎖表。
雖然serializable
級別可以解決所有的數據庫並發問題,但是它會在讀取的每一行數據上都加鎖,這就可能導致大量的超時和鎖競爭問題,從而導致效率下降。所以我們在實際應用中也很少使用serializable
,只有在非常需要確保數據的一致性而且可以接受沒有並發的情況下,才考慮采用該級別。
LBCC&MVCC
InnoDB
默認的事務隔離級別是repeatable read
(后文中用簡稱RR),它為了解決該隔離級別下的幻讀的並發問題,提出了LBCC
和MVCC
兩種方案。其中LBCC
解決的是當前讀情況下的幻讀,MVCC
解決的是普通讀(快照讀)的幻讀。至於什么是當前讀,什么是快照讀,將在下文中給出答案。
LBCC
LBCC
是Lock-Based Concurrent Control
的簡稱,意思是基於鎖的並發控制。在InnoDB
中按鎖的模式來分的話可以分為共享鎖(S)、排它鎖(X)和意向鎖,其中意向鎖又分為意向共享鎖(IS)和意向排它鎖(IX)(此處先不做介紹,后期會專門出篇文章講一下InnoDB
和Myisam
引擎的鎖);如果按照鎖的算法來分的話又分為記錄鎖(Record Locks
)、間隙鎖(Gap Locks
)和臨鍵鎖(Next-key Locks
)。其中臨鍵鎖就可以用來解決RR下的幻讀問題。那么什么是臨鍵鎖呢?繼續往下看。
我們將數據庫中存儲的每一行數據稱為記錄。則上圖中1、5、9、11分別代表id為當前數的記錄。對於鍵值在條件范圍內但不存在的記錄,叫做間隙(GAP)。則上圖中的(-∞,1)、(1,5)...(11,+∞)為數據庫中存在的間隙。而(-∞,1]、(1,5]...(11,+∞)我們稱之為臨鍵,即左開右閉的集合。
記錄鎖(Record Locks)
對表中的行記錄加鎖,叫做記錄鎖,簡稱行鎖。可以使用sql
語句select ... for update
來開啟鎖,select
語句必須為精准匹配(=),不能為范圍匹配,且匹配列字段必須為唯一索引或者主鍵列。也可以通過對查詢條件為主鍵索引或唯一索引的數據行進行UPDATE
操作來添加記錄鎖。
記錄鎖存在於包括主鍵索引在內的唯一索引中,鎖定單條索引記錄。
間隙鎖(GAP Locks)
對上面說到的間隙加鎖即為間隙鎖。間隙鎖是對范圍加鎖,但不包括已存在的索引項。可以使用sql
語句select ... for update
來開啟鎖,select
語句為范圍查詢,匹配列字段為索引項,且沒有數據返回;或者select
語句為等值查詢,匹配字段為唯一索引,也沒有數據返回。
間隙鎖有一個比較致命的弱點,就是當鎖定一個范圍鍵值之后,即使某些不存在的鍵值也會被無辜的鎖定,而造成在鎖定的時候無法插入鎖定鍵值范圍內的任何數據。在某些場景下這可能會對性能造成很大的危害。以下是加鎖之后,插入操作的例子:
select * from user where id > 15 for update;
//插入失敗,因為id20大於15,不難理解
insert into user values(20,'20');
//插入失敗,原因是間隙鎖鎖的是記錄間隙,而不是sql,也就是說`select`語句的鎖范圍是(11,+∞),而13在這個區間中,所以也失敗。
insert into user values(13,'13');
GAP Locks
只存在於RR隔離級別下,它鎖住的是間隙內的數據。加完鎖之后,間隙中無法插入其他記錄,並且鎖的是記錄間隙,而非sql
語句。間隙鎖之間都不存在沖突關系。
打開間隙鎖設置: 以通過命令show variables like 'innodb_locks_unsafe_for_binlog';
來查看 innodb_locks_unsafe_for_binlog
是否禁用。innodb_locks_unsafe_for_binlog
默認值為OFF,即啟用間隙鎖。因為此參數是只讀模式,如果想要禁用間隙鎖,需要修改 my.cnf
(windows是my.ini
) 重新啟動才行。
#在 my.cnf 里面的[mysqld]添加
[mysqld]
innodb_locks_unsafe_for_binlog = 1
臨鍵鎖(Next-Key Locks)
當我們對上面的記錄和間隙共同加鎖時,添加的便是臨鍵鎖(左開右閉的集合加鎖)。為了防止幻讀,臨鍵鎖阻止特定條件的新記錄的插入,因為插入時要獲取插入意向鎖,與已持有的臨鍵鎖沖突。可以使用sql
語句select ... for update
來開啟鎖,select
語句為范圍查詢,匹配列字段為索引項,且有數據返回;或者select
語句為等值查詢,匹配列字段為索引項,不管有沒有數據返回。
插入意向鎖並非意向鎖,而是一種特殊的間隙鎖。
總結
- 如果查詢沒有命中索引,則退化為表鎖;
- 如果等值查詢唯一索引且命中唯一一條記錄,則退化為行鎖;
- 如果等值查詢唯一索引且沒有命中記錄,則退化為臨近結點的間隙鎖;
- 如果等值查詢非唯一索引且沒有命中記錄,退化為臨近結點的間隙鎖(包括結點也被鎖定);如果命中記錄,則鎖定所有命中行的臨鍵鎖,並同時鎖定最大記錄行下一個區間的間隙鎖。
- 如果范圍查詢唯一索引或查詢非唯一索引且命中記錄,則鎖定所有命中行的臨鍵鎖 ,並同時鎖定最大記錄行下一個區間的間隙鎖。
- 如果范圍查詢索引且沒有命中記錄,退化為臨近結點的間隙鎖(包括結點也被鎖定)。
當前讀
當前讀(Locking Read
)也稱鎖定讀,讀取當前數據的最新版本,而且讀取到這個數據之后會對這個數據加鎖,防止別的事務更改即通過next-key
鎖(行鎖+gap鎖)來解決當前讀的問題。在進行寫操作的時候就需要進行“當前讀”,讀取數據記錄的最新版本,包含以下SQL
類型:select ... lock in share mode
、select ... for update
、update
、delete
、insert
。
MVCC
LBCC
是基於鎖的並發控制,因為鎖的粒度過大,會導致性能的下降,因此提出了比LBCC
性能更優越的方法MVCC
。MVCC
是Multi-Version Concurremt Control
的簡稱,意思是基於多版本的並發控制協議,通過版本號,避免同一數據在不同事務間的競爭,只存在於InnoDB
引擎下。它主要是為了提高數據庫的並發讀寫性能,不用加鎖就能讓多個事務並發讀寫。MVCC
的實現依賴於:三個隱藏字段、Undo log
和Read View
,其核心思想就是:只能查找事務id小於等於當前事務ID的行;只能查找刪除時間大於等於當前事務ID的行,或未刪除的行。接下來讓我們從源碼級別來分析下MVCC
。
隱藏列
MySQL
中會為每一行記錄生成隱藏列,接下來就讓我們了解一下這幾個隱藏列吧。
(1)DB_TRX_ID:事務ID,是根據事務產生時間順序自動遞增的,是獨一無二的。如果某個事務執行過程中對該記錄執行了增、刪、改操作,那么InnoDB
存儲引擎就會記錄下該條事務的id。
(2)DB_ROLL_PTR:回滾指針,本質上就是一個指向記錄對應的undo log
的一個指針,大小為 7 個字節,InnoDB
便是通過這個指針找到之前版本的數據。該行記錄上所有舊版本,在undo log
中都通過鏈表的形式組織。
(3)DB_ROW_ID:行標識(隱藏單調自增 ID
),如果表沒有主鍵,InnoDB 會自動生成一個隱藏主鍵,大小為 6 字節。如果數據表沒有設置主鍵,會以它產生聚簇索引。
(4)實際還有一個刪除flag隱藏字段,既記錄被更新或刪除並不代表真的刪除,而是刪除flag變了。
undo log
每當我們要對一條記錄做改動時(這里的改動可以指INSERT、DELETE、UPDATE),都需要把回滾時所需的東西記錄下來, 比如:
- Insert undo log :插入一條記錄時,至少要把這條記錄的主鍵值記下來,之后回滾的時候只需要把這個主鍵值對應的記錄刪掉就好了。
- Delete undo log:刪除一條記錄時,至少要把這條記錄中的內容都記下來,這樣之后回滾時再把由這些內容組成的記錄插入到表中就好了。
- Update undo log:修改一條記錄時,至少要把修改這條記錄前的舊值都記錄下來,這樣之后回滾時再把這條記錄更新為舊值就好了。
InnoDB
把這些為了回滾而記錄的這些東西稱之為undo log
。這里需要注意的一點是,由於查詢操作(SELECT
)並不會修改任何用戶記錄,所以在查詢操作執行時,並不需要記錄相應的undo log
。
每次對記錄進行改動都會記錄一條undo日志,每條undo日志也都有一個DB_ROLL_PTR
屬性,可以將這些undo日志都連起來,串成一個鏈表,形成版本鏈。版本鏈的頭節點就是當前記錄最新的值。
例
先插入一條記錄,假設該記錄的事務id為80,那么此刻該條記錄的示意圖如下所示
實際上insert undo
只在事務回滾時起作用,當事務提交后,該類型的undo日志就沒用了,它占用的Undo Log Segment
也會被系統回收。接着繼續執行sql操作
其版本鏈如下
很多人以為
undo log
用於將數據庫物理的恢復到執行語句或者事務之前的樣子,其實並非如此,undo log
是邏輯日志,只是將數據庫邏輯的恢復到原來的樣子。因為在多並發系統中,你把一個頁中的數據物理的恢復到原來的樣子,可能會影響其他的事務。
Read View
在可重復讀隔離級別下,我們可以把每一次普通的select
查詢(不加for update
語句)當作一次快照讀,而快照便是進行select
的那一刻,生成的當前數據庫系統中所有未提交的事務id數組(數組里最小的id
為min_id
)和已經創建的最大事務id
(max_id
)的集合,即我們所說的一致性視圖readview
。在進行快照讀的過程中要根據一定的規則將版本鏈中每個版本的事務id
與readview
進行匹配查詢我們需要的結果。
快照讀是不會看到別的事務插入的數據的。因此,幻讀在“當前讀”下才會出現。快照讀的實現是基於多版本並發控制,即MVCC
,可以認為MVCC
是行鎖的一個變種,但它在很多情況下,避免了加鎖操作,降低了開銷;既然是基於多版本,即快照讀可能讀到的並不一定是數據的最新版本,而有可能是之前的歷史版本。MVCC
只在 READ COMMITTED
和 REPEATABLE READ
兩個隔離級別下工作,其他兩個隔離級別不和MVCC
不兼容。因為READ UNCOMMITTED
總是讀取最新的數據行,而不是符合當前事務版本的數據行,而SERIALIZABLE
則會對所有讀取的行都加鎖。事務的快照時間點(即下文中說到的Read View
的生成時間)是以第一個select
來確認的。所以即便事務先開始,但是select
在后面的事務的update
之類的語句后進行,那么它是可以獲取前面的事務的對應的數據。
RC和RR隔離級別下的快照讀和當前讀:RC隔離級別下,快照讀和當前讀結果一樣,都是讀取已提交的最新;RR隔離級別下,當前讀結果是其他事務已經提交的最新結果,快照讀是讀當前事務之前讀到的結果。RR下創建快照讀的時機決定了讀到的版本。
對於使用RC和RR隔離級別的事務來說,都必須保證讀到已經提交了的事務修改過的記錄,也就是說假如另一個事務已經修改了記錄但是尚未提交,是不能直接讀取最新版本的記錄的。核心問題就是:需要判斷一下版本鏈中的哪個版本是當前事務可見的。為此,InnoDB
提出了一個Read View
的概念。
Read View
就是事務進行快照讀(普通select
查詢)操作的時候生產的一致性讀視圖,在該事務執行的快照讀的那一刻,會生成數據庫系統當前的一個快照,它由執行查詢時所有未提交的事務id數組(數組里最小的id為min_id
)和已經創建的最大事務id(max_id
)組成,查詢的數據結果需要跟read view
做對比從而得到快照結果。
版本鏈比對規則:
- 如果落在綠色部分(trx_id<min_id),表示這個版本是已經提交的事務生成的,這個數據是可見的;
- 如果落在紅色部分(trx_id>max_id),表示這個版本是由將來啟動的事務生成的,是肯定不可見的;
- 如果落在黃色部分(min_id<=trx_id<=max_id),那就包含兩種情況:
a.若row的trx_id在數組中,表示這個版本是由還沒提交的事務生成的,不可見;如果是自己的事務,則是可見的;
b.若row的trx_id不在數組中,表示這個版本是已經提交了的事務生成的,可見。
光說不練假把式,接下來就讓我們用例子來演示一下:首先我們要准備兩張表,一張test
和一張account
表,然后我們以account
的undo log
來畫版本鏈,准備數據和原始記錄圖如下
//test表中數據
id=1,c1='11';
id=5,c1='22';
//account表數據
id=1,name=‘lilei’;
如下圖,我們將按照里面的順序執行sql
當我們執行到第7行的select
的語句時,會生成readview[100,200],300
,版本鏈如圖所示:
此時我們查詢到的數據為lilei300
。我們首先要拿最新版本的數據trx_id=300
來readview
中匹配,落在黃色區間內,一看該數據已經提交了,所以是可見的。繼續往下執行,當執行到第10行的select
語句時,因為trx_id=100
並未提交,所以版本鏈依然為readview[100,200],300
,版本鏈如圖所示:
此時我們查詢到的數據為lilei300
。我們按上邊操作,從最新版本依次往下匹配,我們首先要拿最新版本的數據trx_id=100
來readview
中匹配,落在黃色區間內,一看該數據在未提交的數組中,且不是自己的事務,所以是不可見的;然后我們選擇前一個版本的數據,結果同上;繼續向上找,當找到trx_id=300
的數據時,會落在黃色區間,且是提交的,所以數據可見。繼續往下執行,當執行到第13行的select
語句時,此時盡管trx_id=100
已經提交了,因為是InnoDB
的RR模式,所以readview
不會更改,仍為readview[100,200],300
,版本鏈如圖所示:
此時我們查詢到的數據為lilei300
。原因同上邊的步驟,不再贅述。
當執行
update
語句時,都是先讀后寫的,而這個讀,是當前讀,只能讀當前的值,跟readview
查找時的快照讀區分開。
剛才演示的是InnoDB
下的RR模式,接下來我們簡單說一下RC模式,上文中提到的RC模式的數據讀都是讀最新的即當前讀,所以readview是實時生成的,執行語句如圖所示:
當我們執行到第13行的select
的語句時,會生成readview[200],300
,版本鏈還和之前一樣,此時我們查詢到的數據為lilei2
。原因和上邊講的RR模式下的比對規則相同。
此處我們演示的是update
的情況,對於刪除的情況可以認為是update
的特殊情況,會將版本鏈上最新的數據復制一份,然后將trx_id
改成刪除操作的trx_id
,同時在該條記錄的頭信息(record header
)里的(deleted_flag
)標記位上寫上true
,來表示當前記錄已經被刪除,在查詢時按照上邊的規則查到對應的記錄,如果delete_flag
標記位為true
,意味着記錄已被刪除,則不返回數據。
大家應該還關心一個問題,即undo log
什么時候刪除呢?系統會判斷,沒有比這個undo log
更早的read view
的時候,undo log
會被刪除。所以這里也就是為什么我們建議你盡量不要使用長事務的原因。長事務意味着系統里面會存在很老的事務視圖。由於這些事務隨時可能訪問數據庫里面的任何數據,所以這個事務提交之前,數據庫里面它可能用到的回滾記錄都必須保留,這就會導致大量占用存儲空間。
以上就是今天的全部內容了,如果你感興趣的話,可以關注微信公眾號“阿Q說代碼”!也可以加我微信qingqing-4132,期待你的到來!