一文講清楚MySQL事務(wù)隔離級別和實現(xiàn)原理,開發(fā)人員必備知識點
經(jīng)常提到數(shù)據(jù)庫的事務(wù),那你知道數(shù)據(jù)庫還有事務(wù)隔離的說法嗎,事務(wù)隔離還有隔離級別,那什么是事務(wù)隔離,隔離級別又是什么呢?本文就幫大家梳理一下。
MySQL 事務(wù)
本文所說的 MySQL 事務(wù)都是指在 InnoDB 引擎下,MyISAM 引擎是不支持事務(wù)的。
數(shù)據(jù)庫事務(wù)指的是一組數(shù)據(jù)操作,事務(wù)內(nèi)的操作要么就是全部成功,要么就是全部失敗,什么都不做,其實不是沒做,是可能做了一部分但是只要有一步失敗,就要回滾所有操作,有點一不做二不休的意思。
假設(shè)一個網(wǎng)購付款的操作,用戶付款后要涉及到訂單狀態(tài)更新、扣庫存以及其他一系列動作,這就是一個事務(wù),如果一切正常那就相安無事,一旦中間有某個環(huán)節(jié)異常,那整個事務(wù)就要回滾,總不能更新了訂單狀態(tài)但是不扣庫存吧,這問題就大了。
事務(wù)具有原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、持久性(Durability)四個特性,簡稱 ACID,缺一不可。今天要說的就是隔離性。
概念說明
以下幾個概念是事務(wù)隔離級別要實際解決的問題,所以需要搞清楚都是什么意思。
臟讀
臟讀指的是讀到了其他事務(wù)未提交的數(shù)據(jù),未提交意味著這些數(shù)據(jù)可能會回滾,也就是可能最終不會存到數(shù)據(jù)庫中,也就是不存在的數(shù)據(jù)。讀到了并一定最終存在的數(shù)據(jù),這就是臟讀。
可重復(fù)讀
可重復(fù)讀指的是在一個事務(wù)內(nèi),最開始讀到的數(shù)據(jù)和事務(wù)結(jié)束前的任意時刻讀到的同一批數(shù)據(jù)都是一致的。通常針對數(shù)據(jù)**更新(UPDATE)**操作。
不可重復(fù)讀
對比可重復(fù)讀,不可重復(fù)讀指的是在同一事務(wù)內(nèi),不同的時刻讀到的同一批數(shù)據(jù)可能是不一樣的,可能會受到其他事務(wù)的影響,比如其他事務(wù)改了這批數(shù)據(jù)并提交了。通常針對數(shù)據(jù)**更新(UPDATE)**操作。
幻讀
幻讀是針對數(shù)據(jù)**插入(INSERT)**操作來說的。假設(shè)事務(wù)A對某些行的內(nèi)容作了更改,但是還未提交,此時事務(wù)B插入了與事務(wù)A更改前的記錄相同的記錄行,并且在事務(wù)A提交之前先提交了,而這時,在事務(wù)A中查詢,會發(fā)現(xiàn)好像剛剛的更改對于某些數(shù)據(jù)未起作用,但其實是事務(wù)B剛插入進來的,讓用戶感覺很魔幻,感覺出現(xiàn)了幻覺,這就叫幻讀。
事務(wù)隔離級別
SQL 標準定義了四種隔離級別,MySQL 全都支持。這四種隔離級別分別是:
- 讀未提交(READ UNCOMMITTED)
- 讀提交 (READ COMMITTED)
- 可重復(fù)讀 (REPEATABLE READ)
- 串行化 (SERIALIZABLE)
從上往下,隔離強度逐漸增強,性能逐漸變差。采用哪種隔離級別要根據(jù)系統(tǒng)需求權(quán)衡決定,其中,可重復(fù)讀是 MySQL 的默認級別。
事務(wù)隔離其實就是為了解決上面提到的臟讀、不可重復(fù)讀、幻讀這幾個問題,下面展示了 4 種隔離級別對這三個問題的解決程度。
只有串行化的隔離級別解決了全部這 3 個問題,其他的 3 個隔離級別都有缺陷。
一探究竟
下面,我們來一一分析這 4 種隔離級別到底是怎么個意思。
如何設(shè)置隔離級別
我們可以通過以下語句查看當前數(shù)據(jù)庫的隔離級別,通過下面語句可以看出我使用的 MySQL 的隔離級別是 REPEATABLE-READ,也就是可重復(fù)讀,這也是 MySQL 的默認級別。
- # 查看事務(wù)隔離級別 5.7.20 之后
- show variables like 'transaction_isolation';
- SELECT @@transaction_isolation
- # 5.7.20 之后
- SELECT @@tx_isolation
- show variables like 'tx_isolation'
- +---------------+-----------------+
- | Variable_name | Value |
- +---------------+-----------------+
- | tx_isolation | REPEATABLE-READ |
- +---------------+-----------------+
稍后,我們要修改數(shù)據(jù)庫的隔離級別,所以先了解一下具體的修改方式。
修改隔離級別的語句是:set [作用域] transaction isolation level [事務(wù)隔離級別], SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL READ UNCOMMITTED READ COMMITTED | REPEATABLE READ | SERIALIZABLE。
其中作用于可以是 SESSION 或者 GLOBAL,GLOBAL 是全局的,而 SESSION 只針對當前回話窗口。隔離級別是 READ UNCOMMITTED READ COMMITTED | REPEATABLE READ | SERIALIZABLE 這四種,不區(qū)分大小寫。
比如下面這個語句的意思是設(shè)置全局隔離級別為讀提交級別。
- mysql> set global transaction isolation level read committed;
MySQL 中執(zhí)行事務(wù)
事務(wù)的執(zhí)行過程如下,以 begin 或者 start transaction 開始,然后執(zhí)行一系列操作,最后要執(zhí)行 commit 操作,事務(wù)才算結(jié)束。當然,如果進行回滾操作(rollback),事務(wù)也會結(jié)束。
需要注意的是,begin 命令并不代表事務(wù)的開始,事務(wù)開始于 begin 命令之后的第一條語句執(zhí)行的時候。例如下面示例中,select * from xxx 才是事務(wù)的開始,
- begin;
- select * from xxx;
- commit; -- 或者 rollback;
另外,通過以下語句可以查詢當前有多少事務(wù)正在運行。
- select * from information_schema.innodb_trx;
好了,重點來了,開始分析這幾個隔離級別了。
接下來我會用一張表來做一下驗證,表結(jié)構(gòu)簡單如下:
- CREATE TABLE `user` (
- `id` int(11) NOT NULL AUTO_INCREMENT,
- `name` varchar(30) DEFAULT NULL,
- `age` tinyint(4) DEFAULT NULL,
- PRIMARY KEY (`id`)
- ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
初始只有一條記錄:
- mysql> SELECT * FROM user;
- +----+-----------------+------+
- | id | name | age |
- +----+-----------------+------+
- | 1 | 古時的風(fēng)箏 | 1 |
- +----+-----------------+------+
讀未提交
MySQL 事務(wù)隔離其實是依靠鎖來實現(xiàn)的,加鎖自然會帶來性能的損失。而讀未提交隔離級別是不加鎖的,所以它的性能是最好的,沒有加鎖、解鎖帶來的性能開銷。但有利就有弊,這基本上就相當于裸奔啊,所以它連臟讀的問題都沒辦法解決。
任何事務(wù)對數(shù)據(jù)的修改都會第一時間暴露給其他事務(wù),即使事務(wù)還沒有提交。
下面來做個簡單實驗驗證一下,首先設(shè)置全局隔離級別為讀未提交。
- set global transaction isolation level read uncommitted;
設(shè)置完成后,只對之后新起的 session 才起作用,對已經(jīng)啟動 session 無效。如果用 shell 客戶端那就要重新連接 MySQL,如果用 Navicat 那就要創(chuàng)建新的查詢窗口。
啟動兩個事務(wù),分別為事務(wù)A和事務(wù)B,在事務(wù)A中使用 update 語句,修改 age 的值為10,初始是1 ,在執(zhí)行完 update 語句之后,在事務(wù)B中查詢 user 表,會看到 age 的值已經(jīng)是 10 了,這時候事務(wù)A還沒有提交,而此時事務(wù)B有可能拿著已經(jīng)修改過的 age=10 去進行其他操作了。在事務(wù)B進行操作的過程中,很有可能事務(wù)A由于某些原因,進行了事務(wù)回滾操作,那其實事務(wù)B得到的就是臟數(shù)據(jù)了,拿著臟數(shù)據(jù)去進行其他的計算,那結(jié)果肯定也是有問題的。
順著時間軸往表示兩事務(wù)中操作的執(zhí)行順序,重點看圖中 age 字段的值。
讀未提交,其實就是可以讀到其他事務(wù)未提交的數(shù)據(jù),但沒有辦法保證你讀到的數(shù)據(jù)最終一定是提交后的數(shù)據(jù),如果中間發(fā)生回滾,那就會出現(xiàn)臟數(shù)據(jù)問題,讀未提交沒辦法解決臟數(shù)據(jù)問題。更別提可重復(fù)讀和幻讀了,想都不要想。
讀提交
既然讀未提交沒辦法解決臟數(shù)據(jù)問題,那么就有了讀提交。讀提交就是一個事務(wù)只能讀到其他事務(wù)已經(jīng)提交過的數(shù)據(jù),也就是其他事務(wù)調(diào)用 commit 命令之后的數(shù)據(jù)。那臟數(shù)據(jù)問題迎刃而解了。
讀提交事務(wù)隔離級別是大多數(shù)流行數(shù)據(jù)庫的默認事務(wù)隔離界別,比如 Oracle,但是不是 MySQL 的默認隔離界別。
我們繼續(xù)來做一下驗證,首先把事務(wù)隔離級別改為讀提交級別。
- set global transaction isolation level read committed;
之后需要重新打開新的 session 窗口,也就是新的 shell 窗口才可以。
同樣開啟事務(wù)A和事務(wù)B兩個事務(wù),在事務(wù)A中使用 update 語句將 id=1 的記錄行 age 字段改為 10。此時,在事務(wù)B中使用 select 語句進行查詢,我們發(fā)現(xiàn)在事務(wù)A提交之前,事務(wù)B中查詢到的記錄 age 一直是1,直到事務(wù)A提交,此時在事務(wù)B中 select 查詢,發(fā)現(xiàn) age 的值已經(jīng)是 10 了。
這就出現(xiàn)了一個問題,在同一事務(wù)中(本例中的事務(wù)B),事務(wù)的不同時刻同樣的查詢條件,查詢出來的記錄內(nèi)容是不一樣的,事務(wù)A的提交影響了事務(wù)B的查詢結(jié)果,這就是不可重復(fù)讀,也就是讀提交隔離級別。
每個 select 語句都有自己的一份快照,而不是一個事務(wù)一份,所以在不同的時刻,查詢出來的數(shù)據(jù)可能是不一致的。
讀提交解決了臟讀的問題,但是無法做到可重復(fù)讀,也沒辦法解決幻讀。
可重復(fù)讀
可重復(fù)是對比不可重復(fù)而言的,上面說不可重復(fù)讀是指同一事物不同時刻讀到的數(shù)據(jù)值可能不一致。而可重復(fù)讀是指,事務(wù)不會讀到其他事務(wù)對已有數(shù)據(jù)的修改,及時其他事務(wù)已提交,也就是說,事務(wù)開始時讀到的已有數(shù)據(jù)是什么,在事務(wù)提交前的任意時刻,這些數(shù)據(jù)的值都是一樣的。但是,對于其他事務(wù)新插入的數(shù)據(jù)是可以讀到的,這也就引發(fā)了幻讀問題。
同樣的,需改全局隔離級別為可重復(fù)讀級別。
- set global transaction isolation level repeatable read;
在這個隔離級別下,啟動兩個事務(wù),兩個事務(wù)同時開啟。
首先看一下可重復(fù)讀的效果,事務(wù)A啟動后修改了數(shù)據(jù),并且在事務(wù)B之前提交,事務(wù)B在事務(wù)開始和事務(wù)A提交之后兩個時間節(jié)點都讀取的數(shù)據(jù)相同,已經(jīng)可以看出可重復(fù)讀的效果。
可重復(fù)讀做到了,這只是針對已有行的更改操作有效,但是對于新插入的行記錄,就沒這么幸運了,幻讀就這么產(chǎn)生了。我們看一下這個過程:
事務(wù)A開始后,執(zhí)行 update 操作,將 age = 1 的記錄的 name 改為“風(fēng)箏2號”;
事務(wù)B開始后,在事務(wù)執(zhí)行完 update 后,執(zhí)行 insert 操作,插入記錄 age =1,name = 古時的風(fēng)箏,這和事務(wù)A修改的那條記錄值相同,然后提交。
事務(wù)B提交后,事務(wù)A中執(zhí)行 select,查詢 age=1 的數(shù)據(jù),這時,會發(fā)現(xiàn)多了一行,并且發(fā)現(xiàn)還有一條 name = 古時的風(fēng)箏,age = 1 的記錄,這其實就是事務(wù)B剛剛插入的,這就是幻讀。
要說明的是,當你在 MySQL 中測試幻讀的時候,并不會出現(xiàn)上圖的結(jié)果,幻讀并沒有發(fā)生,MySQL 的可重復(fù)讀隔離級別其實解決了幻讀問題,這會在后面的內(nèi)容說明
串行化
串行化是4種事務(wù)隔離級別中隔離效果最好的,解決了臟讀、可重復(fù)讀、幻讀的問題,但是效果最差,它將事務(wù)的執(zhí)行變?yōu)轫樞驁?zhí)行,與其他三個隔離級別相比,它就相當于單線程,后一個事務(wù)的執(zhí)行必須等待前一個事務(wù)結(jié)束。
MySQL 中是如何實現(xiàn)事務(wù)隔離的
首先說讀未提交,它是性能最好,也可以說它是最野蠻的方式,因為它壓根兒就不加鎖,所以根本談不上什么隔離效果,可以理解為沒有隔離。
再來說串行化。讀的時候加共享鎖,也就是其他事務(wù)可以并發(fā)讀,但是不能寫。寫的時候加排它鎖,其他事務(wù)不能并發(fā)寫也不能并發(fā)讀。
最后說讀提交和可重復(fù)讀。這兩種隔離級別是比較復(fù)雜的,既要允許一定的并發(fā),又想要兼顧的解決問題。
實現(xiàn)可重復(fù)讀
為了解決不可重復(fù)讀,或者為了實現(xiàn)可重復(fù)讀,MySQL 采用了 MVVC (多版本并發(fā)控制) 的方式。
我們在數(shù)據(jù)庫表中看到的一行記錄可能實際上有多個版本,每個版本的記錄除了有數(shù)據(jù)本身外,還要有一個表示版本的字段,記為 row trx_id,而這個字段就是使其產(chǎn)生的事務(wù)的 id,事務(wù) ID 記為 transaction id,它在事務(wù)開始的時候向事務(wù)系統(tǒng)申請,按時間先后順序遞增。
按照上面這張圖理解,一行記錄現(xiàn)在有 3 個版本,每一個版本都記錄這使其產(chǎn)生的事務(wù) ID,比如事務(wù)A的transaction id 是100,那么版本1的row trx_id 就是 100,同理版本2和版本3。
在上面介紹讀提交和可重復(fù)讀的時候都提到了一個詞,叫做快照,學(xué)名叫做一致性視圖,這也是可重復(fù)讀和不可重復(fù)讀的關(guān)鍵,可重復(fù)讀是在事務(wù)開始的時候生成一個當前事務(wù)全局性的快照,而讀提交則是每次執(zhí)行語句的時候都重新生成一次快照。
對于一個快照來說,它能夠讀到那些版本數(shù)據(jù),要遵循以下規(guī)則:
- 當前事務(wù)內(nèi)的更新,可以讀到;
- 版本未提交,不能讀到;
- 版本已提交,但是卻在快照創(chuàng)建后提交的,不能讀到;
- 版本已提交,且是在快照創(chuàng)建前提交的,可以讀到;
利用上面的規(guī)則,再返回去套用到讀提交和可重復(fù)讀的那兩張圖上就很清晰了。還是要強調(diào),兩者主要的區(qū)別就是在快照的創(chuàng)建上,可重復(fù)讀僅在事務(wù)開始是創(chuàng)建一次,而讀提交每次執(zhí)行語句的時候都要重新創(chuàng)建一次。
并發(fā)寫問題
存在這的情況,兩個事務(wù),對同一條數(shù)據(jù)做修改。最后結(jié)果應(yīng)該是哪個事務(wù)的結(jié)果呢,肯定要是時間靠后的那個對不對。并且更新之前要先讀數(shù)據(jù),這里所說的讀和上面說到的讀不一樣,更新之前的讀叫做“當前讀”,總是當前版本的數(shù)據(jù),也就是多版本中最新一次提交的那版。
假設(shè)事務(wù)A執(zhí)行 update 操作, update 的時候要對所修改的行加行鎖,這個行鎖會在提交之后才釋放。而在事務(wù)A提交之前,事務(wù)B也想 update 這行數(shù)據(jù),于是申請行鎖,但是由于已經(jīng)被事務(wù)A占有,事務(wù)B是申請不到的,此時,事務(wù)B就會一直處于等待狀態(tài),直到事務(wù)A提交,事務(wù)B才能繼續(xù)執(zhí)行,如果事務(wù)A的時間太長,那么事務(wù)B很有可能出現(xiàn)超時異常。如下圖所示。
加鎖的過程要分有索引和無索引兩種情況,比如下面這條語句
- update user set age=11 where id = 1
id 是這張表的主鍵,是有索引的情況,那么 MySQL 直接就在索引數(shù)中找到了這行數(shù)據(jù),然后干凈利落的加上行鎖就可以了。
而下面這條語句
- update user set age=11 where age=10
表中并沒有為 age 字段設(shè)置索引,所以, MySQL 無法直接定位到這行數(shù)據(jù)。那怎么辦呢,當然也不是加表鎖了。MySQL 會為這張表中所有行加行鎖,沒錯,是所有行。但是呢,在加上行鎖后,MySQL 會進行一遍過濾,發(fā)現(xiàn)不滿足的行就釋放鎖,最終只留下符合條件的行。雖然最終只為符合條件的行加了鎖,但是這一鎖一釋放的過程對性能也是影響極大的。所以,如果是大表的話,建議合理設(shè)計索引,如果真的出現(xiàn)這種情況,那很難保證并發(fā)度。
解決幻讀
上面介紹可重復(fù)讀的時候,那張圖里標示著出現(xiàn)幻讀的地方實際上在 MySQL 中并不會出現(xiàn),MySQL 已經(jīng)在可重復(fù)讀隔離級別下解決了幻讀的問題。
前面剛說了并發(fā)寫問題的解決方式就是行鎖,而解決幻讀用的也是鎖,叫做間隙鎖,MySQL 把行鎖和間隙鎖合并在一起,解決了并發(fā)寫和幻讀的問題,這個鎖叫做 Next-Key鎖。
假設(shè)現(xiàn)在表中有兩條記錄,并且 age 字段已經(jīng)添加了索引,兩條記錄 age 的值分別為 10 和 30。
此時,在數(shù)據(jù)庫中會為索引維護一套B+樹,用來快速定位行記錄。B+索引樹是有序的,所以會把這張表的索引分割成幾個區(qū)間。
如圖所示,分成了3 個區(qū)間,(負無窮,10]、(10,30]、(30,正無窮],在這3個區(qū)間是可以加間隙鎖的。
之后,我用下面的兩個事務(wù)演示一下加鎖過程。
在事務(wù)A提交之前,事務(wù)B的插入操作只能等待,這就是間隙鎖起得作用。當事務(wù)A執(zhí)行update user set name='風(fēng)箏2號’ where age = 10; 的時候,由于條件 where age = 10 ,數(shù)據(jù)庫不僅在 age =10 的行上添加了行鎖,而且在這條記錄的兩邊,也就是(負無窮,10]、(10,30]這兩個區(qū)間加了間隙鎖,從而導(dǎo)致事務(wù)B插入操作無法完成,只能等待事務(wù)A提交。不僅插入 age = 10 的記錄需要等待事務(wù)A提交,age<10、10<age<30 的記錄頁無法完成,而大于等于30的記錄則不受影響,這足以解決幻讀問題了。
這是有索引的情況,如果 age 不是索引列,那么數(shù)據(jù)庫會為整個表加上間隙鎖。所以,如果是沒有索引的話,不管 age 是否大于等于30,都要等待事務(wù)A提交才可以成功插入。
總結(jié)
MySQL 的 InnoDB 引擎才支持事務(wù),其中可重復(fù)讀是默認的隔離級別。
讀未提交和串行化基本上是不需要考慮的隔離級別,前者不加鎖限制,后者相當于單線程執(zhí)行,效率太差。
讀提交解決了臟讀問題,行鎖解決了并發(fā)更新的問題。并且 MySQL 在可重復(fù)讀級別解決了幻讀問題,是通過行鎖和間隙鎖的組合 Next-Key 鎖實現(xiàn)的。