幻讀:聽說我是被MVCC干掉的?
原創(chuàng)【51CTO.com原創(chuàng)稿件】我是幻讀,聽說有人認為我是 MVCC 解決的,為了讓大家更全面地理解我,只能親自來解釋一下。
圖片來自 Pexels
我是誰?
先給大家做一個簡單的自我介紹,我就是事務(wù)并發(fā)時會產(chǎn)生的三大問題之一。
我的其他倆兄弟臟讀、不可重復(fù)讀被 MVCC 在上一個回合無情的干掉了,至于上個回合發(fā)生了什么可以去看劇情回顧。
我的由來就是因為主人在操作一組數(shù)據(jù)時還有很多人也在對這組數(shù)據(jù)進行操作。
舉一個簡單的案例:根據(jù)條件在對一組數(shù)據(jù)進行過濾返回的結(jié)果為 100 個,但是在主人操作的同時其他人又新增了符合條件的數(shù)據(jù),然后主人再次進行查詢時返回結(jié)果為 101。第二次返回的數(shù)據(jù)跟第一次返回數(shù)據(jù)不一致。
于是我誕生了,大家還給我起了個很好聽的名字幻讀。為什么會給我起這個名字呢!那是因為我給人們的現(xiàn)象好像出了幻覺一樣。
為什么有人會認為我是被 MVCC 干掉的
為了演示方便,就直接使用之前的測試表來進行操作。
同時大家可以看到此表還有一些測試數(shù)據(jù),一切從頭開始,清空表。
清空表的命令:
- truncate table_name
執(zhí)行這個命令會使表的數(shù)據(jù)清空,并且自增 ID 會從 1 開始。
從執(zhí)行過程來看,truncate table 類似于 drop table 然后在 create table,這里的環(huán)境都是測試環(huán)境,千萬不要在線上進行操作,因為它繞過了 DML 方法,是不能回滾的。
進行了一點小插曲,進入正題。
根據(jù)上圖的執(zhí)行步驟,預(yù)期來說左邊事務(wù)的第一條 select 語句查詢結(jié)果為空。第二個 select 查詢結(jié)果為 1 條數(shù)據(jù),包含右邊事務(wù)提交的數(shù)據(jù)。
但在實際測試的情況下,第一次執(zhí)行 select 和第二次執(zhí)行 select 返回結(jié)果一致。
從這個案例中,可以得出結(jié)論確實在不可重復(fù)隔離級別下會解決幻讀問題(在快照讀的前提下)。
我真的是被 MVCC 解決的?
通過上述測試案例來看,貌似在 MySQL 中通過 MVCC 就解決我的引來的問題,那既然都解決了我的問題,為什么還有串行化的隔離級別呢!好疑惑啊!
帶著這個疑問繼續(xù)進行實驗,為了方便就不再使用上邊表結(jié)構(gòu)了,建立一個簡單的表結(jié)構(gòu)。
再進入一個小插曲你知道在 MySQL 終端如何清屏嗎?執(zhí)行命令 system clear 即可。
接著開始新一輪的測試:
上圖案例事務(wù) 1 幾次查詢數(shù)據(jù)都是空。此時事務(wù) 2 已經(jīng)成功將數(shù)據(jù)插入并且提交。但當(dāng)事務(wù) 1 幾次查詢數(shù)據(jù)為空之后進行數(shù)據(jù)插入時,提示主鍵重復(fù)。
再來看一個案例:
如上圖:
- step1:事務(wù) 1 開啟事務(wù)
- step2:事務(wù) 2 開啟事務(wù)
- step3:事務(wù) 1 查詢數(shù)據(jù)只有一條數(shù)據(jù)
- step4:事務(wù) 2 添加一條數(shù)據(jù)
- step5:事務(wù) 1 查詢數(shù)據(jù)為一條
- step6:事務(wù) 2 提交事務(wù)
- step7:事務(wù) 1 查詢數(shù)據(jù)為一條
- step8:事務(wù) 1 修改 name
- step9:猜想一下此時表內(nèi)數(shù)據(jù)會發(fā)生什么改變
此案例中事務(wù) 1 始終讀取數(shù)據(jù)都是一條數(shù)據(jù),但是在修改數(shù)據(jù)時影響數(shù)據(jù)行數(shù)卻是 2,再次進行查看數(shù)據(jù)時竟然出現(xiàn)了事務(wù) 2 添加的數(shù)據(jù)。這也可以看作是一種幻讀。
小結(jié):通過以上倆個案例得知在 MySQL 可重復(fù)讀隔離級別中并沒有完全解決幻讀問題,而只是解決了快照讀下的幻讀問題。
而對于當(dāng)前讀的操作依然存在幻讀問題,也就是說 MVCC 對于幻讀的解決是不徹底的。
再聊當(dāng)前讀、快照讀
在上一回合中快照讀、當(dāng)前讀已經(jīng)被消化了,為了防止消化不良這里再簡單說明一下。
①當(dāng)前讀
所有操作都加了鎖,并且鎖之間除了共享鎖都是互斥的,如果想要增、刪、改、查時都需要等待鎖釋放才可以,所以讀取的數(shù)據(jù)都是最新的記錄。
簡單來說,當(dāng)前讀就是加了鎖的,增、刪、改、查,不管鎖是共享鎖、排它鎖均為當(dāng)前讀。
在 MySQL 的 Innodb 存儲引擎下,增、刪、改操作都會默認加上鎖,所以增、刪、改操作默認就為當(dāng)前讀。
②快照讀
快照讀的出現(xiàn)旨在提高事務(wù)并發(fā)性,實現(xiàn)基于我的敵人 MVCC,簡單來說快照讀就是不加鎖的非阻塞讀,即簡單的 select 操作(select * from user)。
在 Innodb 存儲引擎下執(zhí)行簡單的 select 操作時,會記錄下當(dāng)前的快照讀數(shù)據(jù),之后的 select 會沿用第一次快照讀的數(shù)據(jù),即使有其它事務(wù)提交也不會影響當(dāng)前的 select 結(jié)果,這就解決了不可重復(fù)讀問題。
快照讀讀取的數(shù)據(jù)雖然是一致的,但有可能不是最新的數(shù)據(jù)而是歷史數(shù)據(jù)。
告訴你們吧!當(dāng)前讀的情況下,我是被 next-key locks 干掉的
第二小節(jié)中得知在快照讀下由于我引發(fā)的問題已經(jīng)被 MVCC 消滅了。但是在小節(jié)三進行案例測試發(fā)現(xiàn)在當(dāng)前讀下我又滿血復(fù)活了。
我要是那么容易被干掉還怎么被稱為打不死的小強,這不是鬧笑話呢!說歸說,鬧歸鬧如果 MVCC 把它的小弟 next-key locks 帶上那我就完了,就不再像灰太狼說經(jīng)典語錄“我一定會回來的”。
此時就要思考一個問題,在 Innodb 存儲引擎下,是默認給快照讀加 next-key locks,還是說需要手動加鎖。
通過官方文檔對于 next-key locks 的解釋:
To prevent phantoms, InnoDB uses an algorithm called next-key locking that combines index-row locking with gap locking. InnoDB performs row-level locking in such a way that when it searches or scans a table index, it sets shared or exclusive locks on the index records it encounters. Thus, the row-level locks are actually index-record locks. In addition, a next-key lock on an index record also affects the “gap” before that index record. That is, a next-key lock is an index-record lock plus a gap lock on the gap preceding the index record. If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.
大致意思,為了防止幻讀,Innodb 使用 next-key lock 算法,將行鎖(record lock)和間隙鎖(gap lock)結(jié)合在一起。
Innodb 行鎖在搜索或者掃描表索引時,會在遇到的索引記錄上設(shè)置共享鎖或者排它鎖,因此行鎖實際是索引記錄鎖。
另外, 在索引記錄上設(shè)置的鎖同樣會影響索引記錄之前的“間隙(gap)”。即 next-key lock 是索引記錄行加上索引記錄之前的“gap”上的間隙鎖定。
并且還給了一個案例:
- SELECT * FROM child WHERE id > 100 FOR UPDATE;
當(dāng) Innodb 掃描索引時,會將 id 大于 100 地上鎖,阻止任何大于 100 的數(shù)據(jù)添加。
到這里就回答了上邊問題,在 Innodb 下解決當(dāng)前讀產(chǎn)生的幻讀問題需要手動加鎖來解決。
再來看一個案例,下圖為此時的數(shù)據(jù)情況:
下圖的這個案例就解決了在第三節(jié)中第一個案例的幻讀問題。
如上圖:
- step事務(wù)1:開啟事務(wù)
- step事務(wù)2:開啟事務(wù)
- step事務(wù)1:查詢 ID 為 4 的這條數(shù)據(jù)并且加上排它鎖
- step事務(wù)2:添加 ID 為 4 的數(shù)據(jù),并且等待事務(wù) 1 釋放鎖
- step事務(wù)1:添加 ID 為 4 的數(shù)據(jù),添加成功
- step事務(wù)1:查詢當(dāng)前數(shù)據(jù)
- step事務(wù)1:提交事務(wù)
- step事務(wù)2:報錯,返回主鍵重復(fù)問題
這個案例查詢的索引列是主鍵并且是唯一的,此時 Innodb 引擎會對 next-key lock 做降級處理,也就是只鎖定當(dāng)前查詢的索引記錄行,而不是范圍鎖定。
案例二:還是使用上邊的數(shù)據(jù),但是這次我們進行一次范圍查找。
此時的數(shù)據(jù)為 1,3,5,查找的范圍為大于 3。從下圖可以看出當(dāng)事務(wù) 2 執(zhí)行添加 ID 為 2 的是可以添加成功的。
但是當(dāng)添加 ID 6 時需要等待。此時若事務(wù) 1 不提交事務(wù),事務(wù) 2 添加 ID 為 6 的這條數(shù)據(jù)就執(zhí)行不成功。
對于上述的 SQL 語句 select * from user where id > 3 for update;執(zhí)行返回的只有 5 這一行數(shù)據(jù)。
此時鎖定的范圍為 (3,5],(5,∞),所以說 id 為 2 的可以插入,ID 為 4 或者大于 5 的都是插入不了的。
以上就是在 Innodb 中解決幻讀問題最終方案。
幻讀解決方案
為了方便大家直觀了解幻讀的解決方案,這里咔咔進行簡單的總結(jié)。
通過 MVCC 解決了快照讀下的幻讀問題,為什么能解決?在第一次執(zhí)行簡單的 select 語句就生成了一個快照,并且在后邊的 select 查詢都是沿用第一次快照讀的結(jié)果。所以說快照讀查詢到的數(shù)據(jù)有可能是歷史數(shù)據(jù)。
通過 next-key lock 解決當(dāng)前讀的幻讀問題,next-key lock 是 record lock 和 gap lock 的結(jié)合,鎖定的是一個范圍,如果查詢數(shù)據(jù)為索引記錄行,則只會鎖定當(dāng)前行,也就是說降級為 record lock。
若為范圍查找時就會鎖定一個范圍,例如上例中 ID 為 1,3,5 查詢大于 3 的數(shù)據(jù),則會把 (3,5],(5,∞) 進行范圍鎖定,其它事務(wù)在鎖未釋放之前是無法插入的。
從官方文檔還可得知如果需要驗證數(shù)據(jù)唯一性只需要給查詢加上共享鎖即可,也就是給 select 語句加上 in lock share mode,如果返回結(jié)果為空,則可以進行插入,并且插入的這個值肯定是唯一的。
同樣也可以添加 next key lock 防止其他人同時插入相同數(shù)據(jù),小節(jié) 5 的所有案例就是使用的 next-key lock,從這一點可以得知 next-key lock 是可以鎖定表內(nèi)不存在的索引。
根據(jù)上述結(jié)論來看,如果想要檢測數(shù)據(jù)唯一性使用共享鎖,那么多個事務(wù)同時開啟共享鎖,又同時添加相同的數(shù)據(jù)怎么辦,會不會出現(xiàn)問題呢?
明確地說明是不會的,如果多個事務(wù)同時插入相同數(shù)據(jù)只會有一個事務(wù)添加成功,其它事務(wù)會拋出錯誤,這個就是一個新的概念“死鎖”。
擴展
事務(wù) ID 是在何時分配的?在本文或者其它資料中都能得到一個信息就是當(dāng)執(zhí)行一條簡單的 select 語句同時也會生成 read-view。
雖然快照讀、read-view 都是基于事務(wù)啟動的前提下,但是 read-veiw 是通過未提交事務(wù) ID 組成的。
①那么到底是在何時分配事務(wù) ID 的呢?
事務(wù)的啟動方式有兩種,分別為顯示啟動、另一種是設(shè)置 autocommit=0 后執(zhí)行 select 就會啟動事務(wù)。
在顯示啟動中最簡單的就是以 begin 語句開始,也可以使用 start transaction 開啟事務(wù)。
若使用 start trancaction 開啟事務(wù)也可以選擇開始只讀事務(wù)還是讀寫事務(wù)。
看了很多資料都說當(dāng)開啟一個事務(wù)時會分配一個事務(wù) ID,那么來驗證一下是這個樣子的嗎?
通過上圖可以看到當(dāng)執(zhí)行一個 begin 語句之后查詢事務(wù) ID 是空的,也就說當(dāng)執(zhí)行 begin 后并沒有分配 trx_id。
那么當(dāng)執(zhí)行 begin 后在支持 DML 語句呢!
根據(jù)文檔得知,執(zhí)行 begin 命令并不是真正開啟一個事務(wù),僅僅是為當(dāng)前線程設(shè)定標(biāo)記,表示為顯式開啟的事務(wù)。
所以要明白對數(shù)據(jù)進行了增、刪、改、查等操作后才算真正開啟了一個事務(wù),此時會去引擎層開啟事務(wù)。
②為什么事務(wù) ID 差異特別大?
上圖中查詢了當(dāng)前活躍的事務(wù) ID,但是兩個事務(wù) ID 的差異特別大。相信很多小伙伴都遇到過這個問題,有問題不害怕,害怕的是沒有問題。
事實上在這兩條數(shù)據(jù)中只有 20841 是真正的事務(wù) ID,那么第二條數(shù)據(jù)中的 ID 是什么呢!
想知道這個數(shù)字是什么的前提是知道是怎么來的。
從上圖可以看出,當(dāng)執(zhí)行 select 語句后會產(chǎn)生一個非常大的事務(wù) ID,那能不能理解為這種差異非常大的事務(wù) ID 是通過快照讀的方式才會生成的。
接著再這個事務(wù)下面在執(zhí)行一個 insert 語句,然后再查看一下事務(wù) ID 的狀態(tài)。
不可思議的是在事務(wù)中先執(zhí)行 select 語句,然后執(zhí)行 insert 語句,事務(wù) ID 發(fā)生了變化,這是什么原因呢?
經(jīng)過資料查詢得知當(dāng)執(zhí)行一個簡單的 select 語句時,被稱之為只讀事務(wù),為了避免給只讀事務(wù)分配 trx_id 帶來不必要的開銷就沒有對其分配事務(wù) ID。
只讀事務(wù)沒有分配 undo segment 也不會分配 LOCK 鎖結(jié)構(gòu),本質(zhì)上只讀事務(wù)的 trx_id 的值就是 0。
但是為了執(zhí)行 select * from information_schema.INNODB_TRX 或者 show engine innodb status 時。
就會通過 reinterpret_cast(trx) | (max_trx_id + 1) 將指針轉(zhuǎn)換為一個 64 字節(jié)非負整數(shù)然后位或 (max_trx_id + 1) 就是這么個值。
關(guān)于這個值的生成過程就不用再去深究了,只需要知道在只讀事務(wù)下是不會分配事務(wù) ID,而查詢出來的這個值只是為了顯示而存在的沒有實際意義。
但是當(dāng)你執(zhí)行 select * from information_schema.INNODB_TRX 查詢出來的事務(wù) ID,再通過 show engine innodb status 查詢是查不到的。
在 Innodb 下如果事務(wù)為只讀事務(wù)則不會在 Innodb 數(shù)據(jù)結(jié)構(gòu)中顯示,因此你是看不到的。
作者:咔咔
簡介:堅持學(xué)習(xí)、堅持寫博、堅持分享是咔咔從業(yè)以來一直所秉持的信念。希望在偌大互聯(lián)網(wǎng)中咔咔的文章能帶給你一絲絲幫助。我是咔咔,下期見。
編輯:陶家龍
征稿:有投稿、尋求報道意向技術(shù)人請?zhí)砑有【幬⑿?gordonlonglong
【51CTO原創(chuàng)稿件,合作站點轉(zhuǎn)載請注明原文作者和出處為51CTO.com】


















































