在mysql中,幻讀指當使用者讀取某一範圍的資料行時,另一個事務又在該範圍內插入了新行,當使用者再讀取該範圍的資料行時,會發現有新的“幻影”行。所謂的幻讀,就是透過SELECT查詢出來的資料集並不是真實存在的資料集,使用者透過SELECT語句查詢某筆記錄是不存在的,但是它有可能在真實的表格中是存在的。
本教學操作環境:windows7系統、mysql8版本、Dell G3電腦。
先來看看交易的隔離等級
然後,談幻讀之前,我先說說我對幻讀的理解:
所謂幻讀,重點在於「幻」這個詞,很夢幻,很玄乎,真假不定,就像蒙上了一層霧一樣,你不能真真切切的看到對方,給人以幻的感覺,這便是「幻」。而所謂的幻讀,也就是你透過SELECT查詢出來的資料集並不是真實存在的資料集,你透過SELECT語句查詢某筆記錄是不存在的,但是它有可能在真實的表中是存在的。
我是這麼理解幻讀與不可重複讀的:
幻讀
說的是存不存在的問題:原來不存在的,現在存在了,則是幻讀不可重複讀
說的是變沒變化的問題:原來是A,現在卻變為了B,則為不可重複讀幻讀,目前我了解的有兩種說法:
說法一:事務A 根據條件查詢得到了N 條數據,但此時事務B 刪除或增加了M 條符合事務A 查詢條件的數據,這樣當事務A 再次進行查詢的時候真實的數據集已經發生了變化,但是A卻查詢不出來這種變化,因此產生了幻讀。
這一種說法強調幻讀在於某一個範圍內的資料行變多或是變少了,重點說明的是資料集不一樣導致了產生了幻讀。
說法二:幻讀並不是說兩次讀取所獲得的結果集不同,幻讀著重的面向是某一次的select 操作所得到的結果所表徵的數據狀態無法支撐後續的業務操作。更具體一些:A事務select 某記錄是否存在,結果為不存在,準備插入此記錄,但執行 insert 時發現此記錄已存在,無法插入,此時就發生了幻讀。產生這樣的原因是因為有另一個交易往表中插入了資料。
我個人比較贊成第一種說法。
說法二這種情況也屬於幻讀,說法二歸根到底還是資料集發生了改變,查詢得到的資料集與真實的資料集不符。
對於說法二:當進行INSERT的時候,也需要隱式的讀取,例如插入資料時需要讀取有沒有主鍵衝突,然後再決定是否能執行插入。如果這時發現已經有這個紀錄了,就沒辦法插入。所以,SELECT 顯示不存在,但是INSERT的時候發現已存在,說明符合條件的資料行發生了變化,也就是幻讀的情況,而不可重複讀指的是同一筆記錄的內容被修改了。
舉例來說明:說法二說的是如下的情況:
有兩個事務A和B,A事務先開啟,然後A開始查詢資料集中有沒有id = 30的數據,查詢的結果顯示數據中沒有id = 30的數據。緊接著又有一個事務B開啟了,B事務往表中插入了一條id = 30的數據,然後提交了事務。然後A再開始往表中插入id = 30的數據,由於B事務已經插入了id = 30的數據,自然是不能插入,緊接著A又查詢了一次,結果發現表中沒有id = 30的數據呀,A事務就很納悶了,怎麼會插入不了資料。當A事務提交以後,再次查詢,發現表中的確存在id = 30的資料。但是A事務還沒提交的時候,卻查不出來?
其實,這便是可重複讀取
的作用。
流程如下圖所示:
上圖中操作的t表的建立語句如下:
CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) -- 创建索引 ) ENGINE=InnoDB; INSERT INTO t VALUES(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
MySQL所使用的InnoDB引擎預設的隔離等級是可重複讀取
,也就是說在同一個事務中,兩次執行同樣的查詢,得到的效果應該是一樣的。因此,儘管B事務在A事務還未結束的時候,增加了表中的數據,但是為了維護可重複讀,A事務中不管怎麼查詢,是查詢不了新增的數據的。但是對於真實的表而言,表中的數據是的確確增加了。
A查询不到这个数据,不代表这个数据不存在
。查询得到了某条数据,不代表它真的存在。这样是是而非的查询,就像是幻觉一样,似真似假,故为幻读
。
产生幻读的原因归根到底是由于查询得到的结果与真实的结果不匹配。
幻读 VS 不可重复读
幻读重点在于数据是否存在
。原本不存在的数据却真实的存在了,这便是幻读。在同一个事务中,第一次读取到结果集和第二次读取到的结果集不同。(对比上面的例子,当B事务INSERT以后,A事务中再进行插入,此次插入相当于一次隐式查询)。引起幻读的原因在于另一个事务进行了INSERT
操作。不可重复读重点在于数据是否被改变了
。在一个事务中对同一条记录进行查询,第一次读取到的数据和第二次读取到的数据不一致,这便是可重复读。引起不可重复读的原因在于另一个事务进行了UPDATE
或者是DELETE
操作。简单来说:幻读是说数据的条数发生了变化,原本不存在的数据存在了。不可重复读是说数据的内容发生了变化,原本存在的数据的内容发生了改变
。
在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在 当前读 下才会出现。
什么是快照读,什么是当前读?
快照读读取的是快照数据。不加锁的简单的 SELECT都属于快照读,比如这样:
SELECT * FROM player WHERE ...
当前读就是读取最新数据,而不是历史版本的数据。加锁的 SELECT,或者对数据进行增删改都会进行当前读。这有点像是 Java 中的 volatile 关键字,被 volatile 修饰的变量,进行修改时,JVM 会强制将其写回内存,而不是放在 CPU 缓存中,进行读取时,JVM 会强制从内存读取,而不是放在 CPU 缓存中。这样就能保证其可见行,保证每次读取到的都是最新的值。如果没有用 volatile 关键字修饰,变量的值可能会被放在 CPU 缓存中,这就导致读取到的值可能是某次修改的值,不能保证是最新的值。
说多了,我们继续来看,如下的操作都会进行 当前读。
SELECT * FROM player LOCK IN SHARE MODE; SELECT * FROM player FOR UPDATE; INSERT INTO player values ... DELETE FROM player WHERE ... UPDATE player SET ...
说白了,快照读就是普通的读操作,而当前读包括了 加锁的读取 和 DML(DML只是对表内部的数据操作,不涉及表的定义,结构的修改。主要包括insert、update、deletet) 操作。
比如在可重复读的隔离条件下,我开启了两个事务,在另一个事务中进行了插入操作,当前事务如果使用当前读 是可以读到最新的数据的。
当隔离级别为可重复读的时候,事务只在第一次 SELECT 的时候会获取一次 Read View
,而后面所有的 SELECT 都会复用这个 Read View。也就是说:对于A事务而言,不管其他事务怎么修改数据,对于A事务而言,它能看到的数据永远都是第一次SELECT时看到的数据。这显然不合理,如果其它事务插入了数据,A事务却只能看到过去的数据,读取不了当前的数据。
既然都说到 Read View 了,就不得不说 MVCC (多版本并发控制) 机制了。MVCC 其实字面意思还比较好理解,为了防止数据产生冲突,我们可以使用时间戳之类的来进行标识,不同的时间戳对应着不同的版本。比如你现在有1000元,你借给了张三 500 元, 之后李四给了你 500 元,虽然你的钱的总额都是 1000元,但是其实已经和最开始的 1000元不一样了,为了判断中途是否有修改,我们就可以采用版本号来区分你的钱的变动。
如下,在数据库的数据表中,id,name,type 这三个字段是我自己建立的,但是除了这些字段,其实还有些隐藏字段是 MySQL 偷偷为我们添加的,我们通常是看不到这样的隐藏字段的。
我们重点关注这两个隐藏的字段:
db_trx_id:操作這行資料的交易 ID,也就是最後一個對該資料進行插入或更新的交易 ID。我們每開啟一個事務,就會從資料庫中獲得一個事務 ID(也就是事務版本號),這個事務 ID 是自增長的,透過 ID 大小,我們就可以判斷事務的時間順序。
db_roll_ptr:回滾指針,指向這個記錄的 Undo Log 訊息。什麼是 Undo Log 呢?可以這麼理解,當我們需要修改某筆記錄時,MySQL 擔心以後可能會撤銷該修改,回退到之前的狀態,所以在修改之前,先把當前的資料存檔,然後再進行修改,Undo Log就可以理解為是這個存檔。這就像是我們打遊戲一樣,打到某個關卡先存檔,然後繼續往下一關挑戰,如果下一關挑戰失敗,就回到之前的存檔點,不至於從頭開始。
在MVCC(多版本並發控制) 機制中,多個交易對同一個行記錄進行更新會產生多個歷史快照,這些歷史快照保存在Undo Log 裡。如下圖所示,目前行記錄的 回滾指標 指向的是它的上一個狀態,它的上一個狀態的 回滾指標 又指向了上一個狀態的上一個狀態。這樣,理論上我們透過遍歷 回滾指標,就能找到該行資料的任一狀態。
Undo Log 示意圖
我們沒有想到,我們看到的或許只是一條數據,但MySQL卻在背後為該條數據儲存多個版本,為這條資料存了非常多的檔。那問題來了,當我們開啟事務時,我們在事務中想要查詢某條數據,但是每一條數據,都對應了非常多的版本,這時,我們需要讀取哪個版本的行記錄呢?
這時就需要用到 Read View
機制了,它幫我們解決了行的可見性問題。 Read View 保存了當前事務開啟時所有活躍(尚未提交)的事務清單。
在Read VIew 中有幾個重要的屬性:
在前面我們說過了,在每一行記錄中有一個隱藏字段db_trx_id,表示操作這行資料的事務ID ,而且事務ID 是自增長的,透過ID 大小,我們就可以判斷事務的時間順序。
當我們開啟交易以後,準備查詢某筆記錄,發現該筆記錄的 db_trx_id up_limit_id,這說明什麼呢?說明該筆記錄一定是在本次事務開啟之前就已經提交的,對於當前事務而言,這屬於歷史數據,可見,因此,我們透過 select 一定能查出這一筆記錄。
但如果發現,要查詢的這條記錄的 db_trx_id > up_limit_id。這說明什麼呢,說明我在開啟事務的時候,這條記錄肯定是還沒有的,是在之後這條記錄才被創建的,不應該被當前事務看見,這時候我們就可以通過回滾指標Undo Log 去找該記錄的歷史版本,回傳給目前事務。在本文 什麼是幻讀 ? 這章節中舉的例子。 A 事務開啟時,資料庫中還沒有(30, 30, 30)這條記錄。 A事務開啟以後,B事務往資料庫中插入了(30, 30, 30)這條記錄,這時候,A事務使用不加鎖 的select 進行快照讀 時是查詢不出這條新插入的記錄的,這符合我們的預期。對於 A事務而言,(30, 30, 30)這條記錄的 db_trx_id 必須大於 A事務開啟時的 up_limit_id,所以這條記錄不應該被A事務看見。
如果需要查詢的這條記錄的trx_id 滿足up_limit_id trx_id low_limit_id 這個條件,說明該行記錄所在的交易trx_id 在目前creator_trx_id 這個交易創建的時候,可能還處於活躍的狀態,因此我們需要在trx_ids 集合中進行遍歷,如果trx_id 存在於trx_ids 集合中,證明這個事務trx_id 還處於活躍狀態,不可見,如果該記錄有Undo Log,我們可以透過回滾指標進行遍歷,查詢該記錄的歷史版本資料。如果 trx_id 不存在於 trx_ids 集合中,證明交易 trx_id 已經提交了,該行記錄可見。
从图中你能看到回滚指针将数据行的所有快照记录都通过链表的结构串联了起来,每个快照的记录都保存了当时的 db_trx_id,也是那个时间点操作这个数据的事务 ID。这样如果我们想要找历史快照,就可以通过遍历回滚指针的方式进行查找。
最后,再来强调一遍:事务只在第一次 SELECT 的时候会获取一次 Read View
因此,如下图所示,在 可重复读 的隔离条件下,在该事务中不管进行多少次 以WHERE heigh > 2.08为条件 的查询,最终结果得到都是一样的,尽管可能会有其它事务对这个结果集进行了更改。
即便是给每行数据都加上行锁,也无法解决幻读,行锁只能阻止修改,无法阻止数据的删除。而且新插入的数据,自然是数据库中不存在的数据,原本不存在的数据自然无法对其加锁,因此仅仅使用行锁是无法阻止别的事务插入数据的。
为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)
。顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。
表 t 主键索引上的行锁和间隙锁
也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。
SELECT * FEOM t FOR UPDATE
要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (负无穷,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, 正无穷]。怎么加间隙锁呢?使用写锁(又叫排它锁,X锁)时自动生效,也就是说我们执行 SELECT * FEOM t FOR UPDATE
时便会自动触发间隙锁。会给主键加上上图所示的锁。
如下图所示,如果在事务A中执行了SELECT * FROM t WHERE d = 5 FOR UPDATE
以后,事务B则无法插入数据了,因此就避免了产生幻读。
数据表的创建语句如下
CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) -- 创建索引 ) ENGINE=InnoDB; INSERT INTO t VALUES(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
需要注意的是,由于创建数据表的时候仅仅只在c字段上创建了索引,因此使用条件WHERE id = 5
查找时是会扫描全表的。因此,SELECT * FROM t WHERE d = 5 FOR UPDATE
实际上锁住了整个表,如上图所示,产生了七个间隙,这七个间隙都不允许数据的插入。
因此当B想插入一条数据(1, 1, 1)时就会被阻塞住,因为它的主键位于位于(0, 5]这个区间,被禁止插入。
还需要注意的一点是,间隙锁和间隙锁是不会产生冲突的
。读锁(又称共享锁,S锁)和写锁会冲突,写锁和写锁也会产生冲突。但是间隙锁和间隙锁是不会产生冲突的
如下:
A事务对id = 5的数据加了读锁,B事务再对id = 5的数据加写锁则会失败,若B事务加读锁则会成功。读锁和读锁可以兼容,读锁和写锁则不能兼容。
A事务对id = 5的数据加了写锁,B事务再对id = 5的数据加写锁则会失败,若B事务加读锁同样也会失败。
在加了间隙锁以后,当A事务开启以后,并对(5, 10]这个区间加了间隙锁,那么B事务则无法插入数据了。
但是当A事务对(5, 10]加了间隙锁以后,B事务也可以对这个区间加间隙锁。
間隙鎖的目的是阻止往這個區間插入數據,因此A事務加了以後B事務繼續加間隙鎖,這並不矛盾。但是對於寫鎖和讀鎖就不一樣了。
寫鎖是不允許其它事務讀,也不允許寫,而讀鎖則是允許寫,語意上就存在衝突。自然無法同時加這兩個鎖。
而寫鎖和寫鎖也是,寫鎖不允許讀,也不允許寫,想想,A事務對數據加了寫鎖,就是完全不想讓其它事務操作該數據,那其它數據若能為這個資料加寫鎖,就相當於對該資料實施了操作,違反了寫鎖的涵義,自然不被允許。
【相關推薦:mysql影片教學】
以上是mysql幻讀是什麼的詳細內容。更多資訊請關注PHP中文網其他相關文章!