臟讀、幻讀,要想搞懂不容易!
本文轉載自微信公眾號「小姐姐味道」,作者小姐姐養的狗02號。轉載本文請聯系小姐姐味道公眾號。
臟讀、幻讀、不可重復讀、當前讀、快照讀,這些名詞經常搞的讓人頭暈。因為一般人大腦的主線就是單線程的,并不能一次性處理多個事務。
要想記憶深刻,我們得借助幾個實例。讀完本文,你一定會豁然開朗,忍不住三連走起。
但在這之前,我們需要看一下當前的數據庫隔離級別,到底是什么。比如MySQL。
- select @@tx_isolation;
MySQL就包含4種隔離級別,隔離的當然是數據。要修改隔離級別的話,可以使用下面的SQL語句。
- set session transaction isolation level read uncommitted;
- set session transaction isolation level read committed;
- set session transaction isolation level repeatable read;
- set session transaction isolation level serializable;
ok,我們創建一張小小的測試表,來看一下并發環境下的魔幻效果。
- CREATE TABLE `xjjdog_tx` (
- `id` INT(11) NOT NULL,
- `name` VARCHAR(50) NOT NULL COLLATE 'utf8_general_ci',
- `money` BIGINT(20) NOT NULL DEFAULT '0',
- PRIMARY KEY (`id`) USING BTREE
- )
- COLLATE='utf8_general_ci'
- ENGINE=InnoDB
- ;
- INSERT INTO `xjjdog_tx` (`id`, `name`, `money`) VALUES (2, 'xjjdog1', 100);
- INSERT INTO `xjjdog_tx` (`id`, `name`, `money`) VALUES (1, 'xjjdog0', 100);
1. 臟讀
臟讀,意思就是讀出了臟數據。啥叫臟數據?就是另外一個事務還沒有提交的數據。在read uncommitted隔離級別下,就會出現臟讀。比如下面這個時序
- 事務 A:set session transaction isolation level read uncommitted;
- 事務 B:set session transaction isolation level read uncommitted;
- 事務 A:START TRANSACTION ;
- 事務 B:START TRANSACTION ;
- 事務 A:UPDATE xjjdog_tx SET money=money+100 WHERE NAME='xjjdog0';
- 事務 B:UPDATE xjjdog_tx SET money=money+100 WHERE NAME='xjjdog0';
- 事務 A:ROLLBACK ;
- 事務 B:COMMIT ;
- 事務 B:SELECT * FROM xjjdog_tx ;
在這個場景下,money的原始值為100,分別在兩個session中進行了加100的操作,然后回滾了其中的一個session事務。結果,經過查詢,發現money的值保持100不變。也就是其中一次加100的操作被覆蓋掉了。
所以臟讀發生有幾個條件。
- 高并發場景,在一個事務A開始之后還沒結束之前,有另外一個事務參與了事務A所涉及的數據行讀寫
- 事務隔離級別處于最低的讀未提交
- 在你使用到這些數據之后,事務A回滾,造成你之前拿到的數據已經不再存在
解決方式,只需要設置成隔離級別比read uncommitted高即可。
2. 不可重復讀
把隔離級別設置成read committed即可避免臟讀,這其實非常好理解。臟讀產生的根本原因就是在事務的執行期間有別的操作亂入,這個隔離級別要求事務A提交之后,修改后的值,才能被事務B讀到,所以臟讀是不可能會發生的,從根本上杜絕了。
但read commited會發生不可重復讀的情況。
顧名思義,就是在一個事務周期內,對于一個值的讀取,產生了兩個結果。
不可重復讀,證明了世界并不是總圍繞著你轉的。在你的事務執行期間,會有無數的其他事務執行,如果你的事務持續時間超過了這些事務,那么你就可能讀到兩個或者更多的值。
讓我來給你講一個故事。
從前,有一顆桃樹,長了12棵桃子。有一只猴子,叫做xjjdog,它想吃上面的桃子,但桃子還不熟。
第二天去看的時候,它發現桃子少了一個,變成了11個,經過仔細打聽,原來是被猴子A搶先吃掉一個。
第二天去看的時候,桃子又少了一個,變成了10個,原來是被饞嘴的猴子B吃掉一個。
如此這般,桃子一天天少了下去,只剩下最后的2個了,但桃子還是沒熟。
再不摘桃子就沒了,xjjdog摘下了最后的2個桃子,正打算大快朵頤,結果跳出一只猴子X,說我盯著這些桃子已經1年了...
在這故事中,猴子A、B的事務持續周期是1天;xjjdog的事務持續周期是直到桃子成熟;猴子X的持續周期更長,可能是一年。它們每天看到的桃子,并不總是12個。今天的桃子,可能被其他的猴子(事務)給吃掉了,造成了觀測的結果是不一樣的,這就是不可重復讀的概念。
有時候,即使讀到的值是一樣的,也不能證明沒問題。比如有財務挪用了2億去炒股,然后在月底把2億還了回來,雖然最終的金額都是一致的,但由于你的對賬周期長,就發現不了這種差異。
如何解決不可重復讀呢?先要看一下不可重復讀是不是問題。
有的系統,要求的就是這樣的邏輯,每次在事務中讀取到不一樣的值,它是可以忍受的。但如果你想要在桃子成熟之前,桃子的數量都在你的掌控之中,那不可重復讀就是一種問題。
一種非常好的方式,就是xjjdog一直站在桃樹地下。當有別的猴子想要摘桃,就把它趕走。這種方式可行,但在數據庫中非常低效,這是serializable級別的做法。
MySQL有一個默認的事務隔離級別,叫做repeatable read,使用了MVCC的方式(innodb),要更輕量級一些。
3. 可重復讀
這就是MVCC(Multi-Version Concurrency Control)的功勞了,它有三個特點。
每行數據都存在一個版本,每次數據更新時都更新該版本
修改時,拷貝一份,當前版本隨意修改,事務之間無干擾
保存時比較版本號,如果成功commit覆蓋原記錄,失敗則rollback
MVCC在InnoDB中的實現主要是為了提高數據庫并發性能,用更好的方式去處理讀-寫沖突,做到即使有讀寫沖突時,也能做到不加鎖,非阻塞并發讀。它的實現關鍵也有三項技術:
- 3個隱式字段:DB_TRX_ID,最近修改它的事務ID;DB_ROLL_PTR,回滾指針,指向上一個版本;DB_ROW_ID,隱藏主鍵
- undo日志:的對同一記錄的修改,會生成針對此記錄的版本變更鏈表
- read view:快照讀操作的時候,產生的讀視圖。除了使用上面的額外信息,它也會維護一個活躍的事務ID集合
一切的關鍵,就在于快照這兩個字上面。
比如事務A對某個記錄進行了快照讀,那么在快照讀的這一刻,就生成了一個Read View。在這一刻,事務B和C,還沒有commit,事務D和E,在建立ReadView那一刻之前,commit完成,那么這個Read View,就不能夠讀到B和C的修改。
但可惜的是,可重復讀,只能解決快照讀的不可重復讀,快照讀的時機,也會影響讀取的準確程度。請看下面兩種情況。
下面這種情況讀到的是500。
事務A | 事務B |
---|---|
開啟事務 | 開啟事務 |
快照讀(無影響)查詢金額為500 | 快照讀查詢金額為500 |
更新金額為400 | |
提交事務 | |
select 快照讀 金額為500 |
|
select lock in share mode當前讀 金額為400 |
下面這種情況讀到的是400。
事務A
事務B
開啟事務
開啟事務
快照讀(無影響)查詢金額為500
更新金額為400
提交事務
select
快照讀
金額為400
select lock in share mode
當前讀
金額為400
(表格來自[SnailMann]的博客)。
4. 幻讀
幻讀,這個詞本身就非常的迷幻。在RU、RC、RR級別下,都會出現幻讀。
拿一個最簡單的例子來說。讓你select一條記錄是否存在然后打算進行后續插入時,如果這條記錄不存在,然后你執行了插入操作,但在實際執行插入操作的時候,結果卻報錯了,這條記錄已經存在了,這就是幻讀。
首先,確認目前時可重復讀級別。如果不是,則修改之。
- SELECT @@tx_isolation
- # set session transaction isolation level repeatable read
讓我們來看一下這個靈異過程。
有5個步驟,我都給你標好了。下面一一介紹。
- 事務A使用begin開啟一個事務,然后查詢id為3的記錄,此時不存在。但由于快照讀開啟了一個針對于id為3的記錄的read view,所以在這個事務自始至終都不能夠讀到為3的記錄。很好,這就是我們不可重復讀所需要的
- 接下來,事務B插入了一條id為3的記錄,并提交成功
- 事務A此時也想插入這條記錄,于是執行了相同的插入操作,結果數據庫報錯,顯示這條記錄已經存在
- 事務A此時一臉懵逼,想看一下這條記錄到底是啥,但當它再次執行select語句的時候,卻查不到這條記錄
- 但在其他事務中,是可以看到這條記錄的,因為它已經正確提交
這就是幻讀。
5. 如何解決幻讀
幻讀有錯么?多數情況下沒錯,就是報錯怪異了些。要防止幻讀,需要開啟FOR UPDATE這樣高強度的鎖定,實際情況是非常少用。
為什么上面的操作,insert能報錯,但select卻無法查到數據呢?這就不得不提一下數據庫讀的兩種模式:
快照讀:普通的select操作,是從read view中讀取數據,讀取的可能是歷史數據
當前讀:insert、update、delete、select..for update這種操作,讀取的總是當前的最新數據
對于當前讀,你讀取的行,以及行的間隙都會被加鎖,直到事務提交時才會釋放,其他的事務無法進行修改,所以也不會出現不可重復讀、幻讀的情形。所以insert能夠發現沖突,而普通select卻不可以。要想解決幻讀,就需要加X鎖。在上面這種情況,就可以在事務A中執行:
- SELECT * FROM xjjdog_tx WHERE id=3 FOR UPDATE
當這么做的時候,即使id為3的記錄不存在,它也會創建鎖(在背后可能根據記錄的存在與否加行X鎖或者next-key lock間隙x鎖)。
6. 總結
下面簡單總結一下。
臟讀,就是一個事務讀取到另一個事務還沒有提交的記錄。當其他事務發生回滾的時候,就會出現問題。
不可重復讀,意思是在同一個事務里,讀多次可能會獲得不一致的結果。這是因為在事務執行期間,有別的事務修改了這些記錄。
MySQL默認是可重復讀,但會發生幻讀的情況。幻讀是由于快照讀和當前讀的差別產生的。
要想解決幻讀,就需要加鎖(X鎖,Gap鎖等),比如for update,全部改成當前讀直到事務結束,自然沒有問題。
所謂的最高級別serializable,不過是全部搞成了當前讀而已,在高并發的環境下效率,可想而知。所以幾乎沒有用的。
作者簡介:小姐姐味道 (xjjdog),一個不允許程序員走彎路的公眾號。聚焦基礎架構和Linux。十年架構,日百億流量,與你探討高并發世界,給你不一樣的味道。我的個人微信xjjdog0,歡迎添加好友,進一步交流。