mysql系列:加深對臟讀、臟寫、可重復讀、幻讀的理解


關於相關術語的專業解釋,請自行百度了解,本文皆本人自己結合參考書和自己的理解所做的闡述,如有不嚴謹之處,還請多多指教。

 

事務有四種基本特性,叫ACID,它們分別是:

Atomicity-原子性,Consistency-一致性,Isolation-隔離性,Durability-持久性。

 

接着關於ACID的理解和隔離性語法都是轉的網上資料,大家可以順便再了解熟悉下。

1、原子性(Atomicity):事務開始后所有操作,要么全部做完,要么全部不做,不可能停滯在中間環節。事務執行過程中出錯,會回滾到事務開始前的狀態,所有的操作就像沒有發生一樣。

2、一致性(Consistency):事務開始前和結束后,數據庫的完整性約束沒有被破壞 。比如A向B轉賬,不可能A扣了錢,B卻沒收到。

3、隔離性(Isolation):同一時間,只允許一個事務請求同一數據,不同的事務之間彼此沒有任何干擾。比如A正在從一張銀行卡中取錢,在A取錢的過程結束前,B不能向這張卡轉賬。

4、持久性(Durability):事務完成后,事務對數據庫的所有更新將被保存到數據庫,不能回滾。

 

而其中的隔離性特點,說的就是在並發的多個事務中事務之間是互不影響的這種情形。Mysql里支持四種不同的隔離級別,這也為解決並發問題提供了選擇。

 

為了更好的理解隔離級別,我們需要給每個會話設置不同的隔離級別,從而輔助自己實踐。

相關語法:

SET GLOBAL TRANSACTION ISOLATION LEVEL ;
SET SESSION TRANSACTION ISOLATION LEVEL ;
SET TRANSACTION ISOLATION LEVEL ;

上面語法設置的值選項就是mysql的四種隔離級別:

READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE

 

由於mysql默認隔離級別是可重復讀(Repeatable Read):

show variables like '%tx_isolation%';  //查詢數據庫當前的隔離級別

所以實踐過程中咱們需要給會話設置隔離級別,就如下所示:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED

 

其中全局事務(global transaction)的隔離級別設置,是對現有已經建好的會話是沒有影響的。

set global tx_isolation='READ-COMMITTED'
select @@tx_isolation;
show variables like 'tx_isolation';

注意:設置的全局默認事務隔離級別適用於從設置時起所有新建立的會話連接。現有連接不受影響。

有關實踐過程,這里不再贅述,請參考這位博主的文章:MySQL的四種事務隔離級別

 

接着主要着重幫助自己加強對臟讀、臟寫、可重復讀、更新丟失、幻讀、寫偏離等的理解。

在此先上一張隔離級別的對比圖:

(圖中紅框僅表示提醒)

 

臟讀:

如果一個事務A向數據庫寫了數據,但事務還沒提交或終止,另一個事務B就看到了事務A寫進數據庫的數據,這就是臟讀。

 

經過前面的實踐,就能得知,在讀未提交(Read Uncommitted)隔離級別下,是會出現臟讀的。

仔細體會讀未提交(Read Uncommitted)隔離級別的命名--讀取事務還未提交的數據,就會發現說的就是臟讀。

 

臟讀會導致什么問題呢?

1. 給用戶帶來數據混亂的感覺。

例如在一個多對象的事務A里,A需要生成一條郵件發送記錄,同時需要在用戶未讀取郵件的計數里+1,這里涉及兩張表的業務情形,就是對多對象的詮釋。如果事務A insert郵件發送記錄時(還沒執行計數+1這個后面的操作),就被事務B查詢了,可事務B此時看到的郵件計數還是+1之前的,這樣就會導致事務B看到的未讀取郵件條數與計數數據不一致。

 

2. 讓用戶看到根本不存在的數據。

例如事務A 是轉賬業務,由喬峰轉給段譽,在更新段譽賬戶余額時(假定此時事務A還未提交),事務B下段譽正查詢自己賬戶余額,發現喬峰給自己轉賬了。可是事務A下喬峰突然意識段譽是大理太子,家底豐實得很,於是撤銷了轉賬(事務A回退或終止)。整個過程,段譽就看到了根本不存在的轉賬記錄。本以為錢來了,結果還沒眨眼就沒了,你說用戶是段譽,生不生氣。

 

臟寫:

當兩個事務同時嘗試去更新某一條數據記錄時,就肯定會存在一個先一個后。而當事務A更新時,事務A還沒提交,事務B就也過來進行更新,覆蓋了事務A提交的更新數據,這就是臟寫。

 

文上提到的4種隔離級別下,都不存在臟寫情況。因為在這些隔離級別下,當兩個事務A和B嘗試去更新同一條數據時,假定A先更新數據,會對更新的數據行記錄加上排他鎖(也叫寫鎖,悲觀鎖),除非事務A提交或終止從而釋放排他鎖,否則事務B都是無法更新數據的。(設計數據密集型應用只是說讀提交隔離級別一定可以杜絕臟寫問題,並未提到讀未提交隔離級別,經過實踐,讀未提交下事務B的更新操作也是需要等待事務A的排他鎖釋放,才得以執行)

 

臟寫會帶來什么問題呢?

臟寫是會導致更新丟失的一種情形,具體會帶來什么問題,可看后面的更新丟失這塊內容。

 

可重復讀:

我本來認為,不可重復讀之下的結果也正是所謂的正確結果,也就沒必要去避諱。就如下圖里的Alice,只要再查詢下Account1下的余額,就可以拼出正確的總額600+400 = 1000。

 

如圖所示,假設轉賬行為是由銀行操控的,Alice一開始看兩個賬戶的總額是500+500,后又無意看到Account2賬戶變為了400,這種Alice納罕總額怎么從1000變為了900的現象,我們就稱之為讀偏離(read skew)。但我們知道,Alice的Account2下的錢的的確確是400,並沒有說少了的100元被誰給私吞了。所以說,這種現象勉強還是可以接受的,畢竟Alice的錢也沒變少,只要再查詢一次Account1,就能釋疑了。

 

那Mysql為啥默認級別是可重復讀呢,不是讀提交呢,說明可重復讀還是有非常的必要。通過以下幾點可以看出:

1. mysql分布式,多節點同步數據時,可重復讀可以保證多個節點數據的一致性。具體請參考下圖:

image

圖中有兩個從庫A和B,主庫同步數據時,會有多個事務並發的執行,由於不可重復讀的特點,就會導致從庫A同步到的數據里我的余額是100元,而從庫B里我的余額數據是90元,從而導致AB兩個從庫之間以及主庫和從庫A之間數據的不一致。

 

2. 備份數據庫時,不可重復讀會導致備份一部分是舊數據一部分是更新后的新數據,從這樣的備份來恢復數據,就會導致數據的不一致(例如錢變少了,此點本人也不是很清楚,大概了解即可)。

 

3.對於分析查詢,需要的就是遍歷大量數據來進行分析和數據的完整性檢查。如果是不可重復讀,就會導致一前一后數據不一致,影響到分析結果。

 

更新丟失:

當多個事務並發寫同一數據時,先執行的事務所寫的數據會被后寫的覆蓋,這也就是更新丟失。前面的臟寫情形,就屬於會導致更新丟失問題的一種情形。

除了這個,更新丟失主要發生在read-modify-write類型的事務當中:就是要先查詢數據,然后計算新的數據,最后寫回新的數據。下面是幾個具體的情形例子:

1. 數值更新,例如計數或賬戶余額更新(先要查詢當前值,再計算出要更新的值,最后執行更新操作寫進數據庫)

2. 更新一個復雜的數據,例如要往json對象里添加數據。(先查詢獲取json對象數據進行解析,再添加數據得到新的數據寫回數據庫)

3. 兩個用戶同時編輯Wiki保存wiki內容。

 

結合讀未提交和讀提交的區別就可知道,帶來更新丟失的根本原因:

在讀提交以及更高級的隔離級別下,只要事務A沒有提交,事務B永遠也無法查到事務A所做的更新,從而事務B在計算要更新的數據時,必定忽略掉了事務A所產生的變更。

在讀未提交下實踐,只要事務B的查詢操作是發生在事務A的更新操作之后,就不會有更新丟失問題。但前提是要保證,事務B的查詢操作是發生在事務A的更新操作之后。這很難控制,所以說讀未提交下也是需要應對更新丟失問題的。

 

針對這個問題,數據庫給了一些解決方法:

1.原子寫操作。

就是將上面的read-modify-write情形下的3步驟,直接轉化為1個步驟來執行。下面就是兩種情形的mysql語句對比:

原子寫操作(atomic write operations):
update news set counter = counter + 1 where id = 1;
查詢-計算-更新(read-modify-write):
select counter from news;
new_counter = counter + 1 //此行邏輯由程序語言代碼(php,java等)執行
update news set counter = new_counter where id = 1;

用過PHP框架的就知道,框架原本支持的都采用查詢-計算-更新這種方式,下面是phalcon框架的例子:

use PhalconMvcModelTransactionFailed as TxFailed;
use PhalconMvcModelTransactionManager as TxManager;

$m = new TxManager();
$t = $m->get();

$model_wallet = new Wallet();
$row = $model_wallet->findFirst("user_id='".$uid."'");

$model_wallet->setTransaction($t);
$row->money = $row->money + 100;
$row->operation_time = date("Y-m-d H:i:s");

if(!$row->save()){
$t->rollback();
}else{
$t->commit();
}

所以使用框架時,就要注意這種寫法在並發情形下帶來的潛在數據更新丟失的問題。

 

2.加鎖(Explicit locking,顯示鎖定)

通過for update來給即將更新的數據記錄添加鎖。也就是說事務A下執行查詢時,用select for update,那么事務B下的select for update就無法進行,只有等待事務A提交或終止,事務B才得以進行。這樣就相當於將並發的兩個事務給串行化了,事務B查詢的結果一定是在事務A提交之后,從而解決了數據更新丟失問題。

 

mysql下有select ... for update 和select ... lock in share mode兩種顯示加鎖的語句,具體用途這里就不拓展了。

 

3.自動檢測

方法1和2實質上都是將事務給串行化了。自動檢測說的就是當檢測到事務A造成更新丟失問題,就立即終止事務A,讓事務A再一次嘗試查詢-計算-更新的流程,事務仍然是並行執行的。

PostgreSQL的可重復讀,Oracle的串行以及SQL Server的快照隔離能夠自動檢測更新丟失,但Mysql的Innodb引擎下的可重復讀沒有此功能。

 

4.CAS,比較和設置(compare and set)

說簡單點,就是在sql更新語句里加一個判斷原本舊數據的條件,例如:

update news set counter = 30 where id = 1 and counter = 29;  //29是一開始的文章點贊數

如果兩個事務A和B,現在都要對id=1的新聞文章進行點贊操作,只要其中任何一個事務執行了更新操作,另一個事務執行時counter=29的條件都不會滿足,從而就規避了更新丟失的問題。

 

但是有的數據庫where條件里counter獲取的本就是舊的快照數據,即不是某一個事務更新后的新數據,那這里的更新丟失問題還是要發生的了。

經過實踐,mysql下可重復讀隔離級別下,使用此方法,的確可以避免更新丟失問題。一旦事務A提交(對counter做更新),事務B里的類似counter=29的判斷就不滿足了,如此一來,事務B的寫入操作就沒有執行成功,就更不用說造成數據的更新丟失了。(實踐里,事務A不提交,事務B里的更新操作會一直等待事務A的排它鎖釋放,否則是不會執行的)

 

好啦,最后就來說說幻讀的問題。

 

幻讀(phantom):

看網上很多博客,都對幻讀的理解不太准確。他們的理解可見此鏈接進行了解:MySQL的InnoDB的幻讀問題

通過提交事務B之后來發現事務A出現莫名奇妙的數據遺失或數據增多,這個實踐中已經涉及到事務B的提交操作,這也就已經確定了對幻讀的誤解。

 

當事務A和B各自寫入一筆數據(不像更新丟失里事務A和B是往同一筆記錄寫入數據),破壞了潛在的競爭條件,造成的結果我們稱之為“寫偏離”(write skew),而造成這種結果的事務B里的查詢操作的結果,才是我們所說的幻讀。(至於幻讀的前提是不是得一定導致了寫偏離,這個待確定,現姑且當做是的)

 

由上可見,幻讀出現的前提是出現了寫偏離,而出現寫偏離是只有當事務A和B都是read-write型事務時才會出現的,這也是為何有的書上說快照隔離級別下read-only型事務是沒有幻讀的,read-write型事務才導致幻讀。

 

什么樣的結果是寫偏離?

如圖所示,一個醫院每個班次必須有一個醫生值班,所以每個醫生請假的前提是當下該班次必須至少有兩個醫生值班,可是圖中Alice和Bob很遺憾的同時點擊了提交請假的按鈕,導致最終1234班次無人守班,這就是破壞了潛在的競爭條件----必須至少要有一人值班,造成的這種結果,就是我們上述所說的寫偏離。

 

假設Alice下的事務是先執行的,那么Bob下執行的查詢1234班次在班的醫生人數結果導致Bob也能請假,這就對潛在的競爭條件造成了破壞,我們就稱Bob下事務的查詢操作帶來了幻讀現象。

 

我們再總結一下寫偏離和更新丟失的區別:

更新丟失是多個事務並發寫同一筆數據記錄造成的。 而寫偏離是多個事務並發寫不同數據記錄影響到了潛在的競爭條件而造成的。

寫偏離的情景還有:

1. 搶注用戶名,兩個用戶同時搶注某一個用戶名並且都成功了,破壞了用戶名必須唯一性的潛在競爭條件。

2. 游戲里多人移動不同人物到同一位置,破壞了某一時刻某一位置只允許一個人物的潛在競爭條件。

 

那針對這些寫偏離問題,該如何解決呢?

上文中的醫生請假問題,我們可以用select...for update,就保證了第二個事務執行更新操作時必須先等待第一個事務釋放排它鎖。

可是這方法對於游戲移動人物位置就不適用了,因為select查詢有結果才能用for update來加排它鎖,而游戲里移動人物select操作結果是要保證在某一時刻某一位置必須沒有人物,也就是select查詢根本沒有結果,就更不用談加排它鎖了。

 

針對這種情形,有一種物化沖突(Materialiing conflicts)的解決方法。

就是既然select查詢沒有結果供添加排它鎖來保證串行執行,那我就想方法讓select查詢有結果。

針對多人游戲這個例子,假設畫面是1280*720且由1*1的像素組成的屏幕,游戲人物有貂蟬、呂布、虞姬和項羽,時間維度以秒為單位,游戲開始時間從0開始計時。如此下來,我們就可以先創建一張表的數據如下:

時間 x軸 y軸 英雄
1 1 1 貂蟬
1 1 1 呂布
1 1 1 虞姬
1 1 1 項羽
2 1 1 貂蟬
2 1 1 呂布
2 1 1 虞姬

將任意秒任意位置可能出現的英雄情形全都列舉出來,在多個事務並發移動英雄人物時,就給某一時間某一位置的記錄加上for update,以上表只提供添加排它鎖,不做實際修改和更新。例如:

//如此就能保證在第1秒(1,1)這個同一位置絕對不會出現多個英雄
select * from table_name where time =1 and x = 1 and y =1 for update

當然解決這個問題,還有一種方法就是采用串行化隔離級別了,也是最高的隔離級別,簡單理解就是嚴格確保了事務串行執行,避免了臟讀幻讀現象,但是由於性能問題,實際生產環境很少用到。這個我以后再好好了解,本文就不細說了。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM