髒讀、幻讀和不可重複讀

AYSAML發表於2020-08-20

一、引言

髒讀、不可重複讀和幻讀是資料庫中由於併發訪問導致的資料讀取問題。當多個事務同時進行時可以通過修改資料庫事務的隔離級別來處理這三個問題。

二、問題解釋

1、髒讀(讀取未提交的資料)

髒讀又稱無效資料的讀出,是指在資料庫訪問中,事務 A 對一個值做修改,事務 B 讀取這個值,但是由於某種原因事務 A 回滾撤銷了對這個值得修改,這就導致事務 B 讀取到的值是無效資料。

2、不可重複讀(前後資料多次讀取,結果集內容不一致)

不可重複讀即當事務 A 按照查詢條件得到了一個結果集,這時事務 B 對事務 A 查詢的結果集資料做了修改操作,之後事務 A 為了資料校驗繼續按照之前的查詢條件得到的結果集與前一次查詢不同,導致不可重複讀取原始資料。

3、幻讀(前後資料多次讀取,結果集數量不一致)

幻讀是指當事務 A 按照查詢條件得到了一個結果集,這時事務 B 對事務 A 查詢的結果集資料做新增操作,之後事務 A 繼續按照之前的查詢條件得到的結果集平白無故多了幾條資料,好像出現了幻覺一樣。

三、事務隔離

在併發條件下會出現上述問題,如何著手解決他們保證我們程式執行的正確性是非常重要的。資料庫提供了 Read uncommitted 、Read committed 、Repeatable read 、Serializable 四種事務隔離級別來解決髒讀、幻讀和不可重複讀問題,同時容易想到,可以通過加鎖的方式實現事務隔離。

在資料庫的增刪改查操作中,insert 、delete 、update 都會加排他鎖,排它鎖會阻止其他事務對其加鎖的資料加任何型別的鎖。而 select 只有顯示宣告才會加鎖。

  • Read uncommitted

    讀未提交,說的是一個事務可以讀取到另一個事務未提交的資料修改。

    讀若不顯式宣告是不加鎖的,可以直接讀取到另一個事務對資料的操作,沒有避免髒讀、不可重複讀、幻讀。

  • Read committed

    讀已提交,說的是一個事務只能讀取到另一個事務已經提交的資料修改。

    很明顯,這種隔離級別避免了髒讀,但是可能會出現不可重複讀、幻讀。

  • Repeatable read

    可重複讀,保證了同一事務下多次讀取相同的資料返回的結果集是一樣的。

    這種隔離級別解決了髒讀和不可重複讀問題,但是扔有可能出現幻讀。

  • Serializable

    序列化,對同一資料的讀寫全加鎖,即對同一資料的讀寫全是互斥了,資料可靠行很強,但是併發效能不忍直視。

    這種隔離級別雖然解決了上述三個問題,但是犧牲了效能。

總結如下表: √ 代表可能出現,× 代表不會出現。

隔離級別髒讀不可重複讀幻讀
Read uncommitted
Read committed×
Repeatable read××
Serializable×××

四、MySQL 事務隔離級別的實現

在 MySQL 中只有 InnoDB 儲存引擎支援事務,但是在日常使用 MySQL 時我們好像沒有怎麼關心過上述三個問題啊...

原因很簡單,MySQL 預設 Repeatable read 隔離級別,使用了 MVCC 技術,並且解決了幻讀問題。

MVCC


MVCC 全名多版本併發控制,使用它可以保證 InnoDB 儲存引擎下讀操作的一致性。使用 MVCC 可以查詢被另一個事務修改的行資料,並且可以檢視這些行被更新之前的資料,值得注意的是使用 MVCC 增加了多事務的併發效能,但是並沒有解決幻讀問題

1、原理

MVCC 是通過儲存資料在某個時間點的快照來實現的。也就是說在同一個事務的生命週期中,資料的快照始終是相同的;而在多個事務中,由於事務的時間點很可能不相同,資料的快照也不盡相同。

2、實現細節

  • 每行資料都存在一個版本,每次資料更新時都更新該版本。
  • 修改時Copy出當前版本隨意修改,各個事務之間互不干擾。
  • 儲存時比較版本號,如果成功(commit),則覆蓋原記錄;失敗則放棄copy(rollback)。

通過上面特點我們可以看出,MVCC 其實就是類似樂觀鎖的一種實現。

3、InnoDB 中 MVCC 實現

在 InnoDB 中為每行增加兩個隱藏的欄位,分別是該行資料建立時的版本號刪除時的版本號,這裡的版本號是系統版本號(可以簡單理解為事務的 ID),每開始一個新的事務,系統版本號就自動遞增,作為事務的 ID 。通常這兩個版本號分別叫做建立時間和刪除時間。

下面通過具體的例子來幫助理解 InnoDB 中 MVCC 實現,

首先建立一個表:

create table info( 
id int primary key auto_increment, 
name varchar(20));

INSERT
InnoDB 為新插入的每一行儲存當前系統版本號作為版本號。現在假設事務的版本號從 1 開始。

第一個事務 ID為1;

start transaction;
insert into info values(NULL,'a');
insert into info values(NULL,'b');
insert into info values(NULL,'c');
commit;

對應在資料中的表如下(後面兩列是隱藏列,也就是版本號)

idname建立版本(事務ID)刪除版本(事務ID)
1a1undefined
2b1undefined
3c1undefined
SELECT

InnoDB 會根據下面兩個條件檢查每行記錄:

  • 只會查詢版本早於當前事務版本的資料行(行的系統版本號小於或等於事務的系統版本號),這樣可以確保事務讀取的行,要麼是在事務開始前已經存在的,要麼是事務自身插入或者修改過的
  • 行的刪除版本要麼未定義,要麼大於當前事務版本號,這可以確保事務讀取到的行,在事務開始之前未被刪除

只有 a, b 同時滿足的記錄,才能返回作為查詢結果.


DELETE

InnoDB會為刪除的每一行儲存當前系統的版本號(事務的ID)作為刪除標識.
看下面的具體例子分析:

第二個事務 ID為2;

start transaction;
select * from info;  //(1)
select * from info;  //(2)
commit;
  • 假設1
    假設在執行這個事務 ID 為 2 的過程中,剛執行到 (1) ,這時,有另一個事務 ID 為 3 往這個表裡插入了一條資料;

第三個事務ID為3;

start transaction;
insert into info values(NULL,'d');
commit;

這時表中的資料如下:

idname建立版本(事務ID)刪除版本(事務ID)
1a1undefined
2b1undefined
3c1undefined
4d3undefined

然後接著執行 事務2 中的 (2) ,由於 id=4 的資料的建立時間(事務 ID 為 3 ),執行當前事務的 ID 為 2 ,而 InnoDB 只會查詢事務 ID 小於等於當前事務 ID 的資料行,所以 id=4 的資料行並不會在執行 事務2 中的 (2) 被檢索出來,在 *事務2 *中的兩條 select 語句檢索出來的資料都只會如下表:

idname建立版本(事務ID)刪除版本(事務ID)
1a1undefined
2b1undefined
3c1undefined
  • 假設2
    假設在執行這個事務 ID 為 2 的過程中,剛執行到 (1) ,假設事務執行完 事務3 後,接著又執行了 事務4 ;

第四個事務:

start   transaction;  
delete from info where id=1;
commit;

此時資料庫中的表資料如下:

idname建立版本(事務ID)刪除版本(事務ID)
1a14
2b1undefined
3c1undefined
4d3undefined

接著執行事務 ID 為 2 的 事務(2),根據 SELECT 檢索條件可以知道,它會檢索建立時間(建立事務的 ID )小於當前事務 ID 的行和刪除時間(刪除事務的 ID )大於當前事務的行,而 id=4 的行上面已經說過,而 id=1 的行由於刪除時間(刪除事務的 ID )大於當前事務的 ID ,所以 事務2 的 (2) select * from info 也會把 id=1 的資料檢索出來。所以,事務2 中的兩條 select 語句檢索出來的資料都如下:

idname建立版本(事務ID)刪除版本(事務ID)
1a14
2b1undefined
3c1undefined

UPDATE

InnoDB 執行 UPDATE,實際上是新插入了一行記錄,並儲存其建立時間為當前事務的 ID ,同時儲存當前事務 ID 到要 UPDATE 的行的刪除時間。

  • 假設3
    假設在執行完 事務2 的 (1) 後又執行,其它使用者執行了事務 3和 4,這時,又有一個使用者對這張表執行了 UPDATE 操作:

第五個事務:

start  transaction;
update info set name='b' where id=2;
commit;

根據update的更新原則:會生成新的一行,並在原來要修改的列的刪除時間列上新增本事務ID,得到表如下:

idname建立版本(事務ID)刪除版本(事務ID)
1a14
2b15
3c1undefined
4d3undefined
2b5undefined

繼續執行 事務2 的 (2) ,根據 select 語句的檢索條件,得到下表:

idname建立版本(事務ID)刪除版本(事務ID)
1a14
2b15
3c1undefined

還是和 事務2 中 (1) select 得到相同的結果。

❀ 總結:

  • SELECT
    讀取建立版本號小於或等於當前事務版本號,並且刪除版本號為空或大於當前事務版本號的記錄。如此可以保證在事務在讀取之前記錄是存在的。
  • INSERT
    將當前事務的版本號儲存至插入行的建立版本號。
  • UPDATE
    新插入一行,並以當前事務的版本號作為新行的建立版本號,同時將原記錄行的刪除版本號設定為當前事務版本號。
  • DELETE
    將當前事務的版本號儲存至行的刪除版本號。

例子參考:https://blog.csdn.net/whoamiy...


4、 InnoDB 如何解決幻讀問題

在 InnoDB 中分為快照讀當前讀。快照讀讀的是資料的快照,也就是資料的歷史版本;當前讀就是讀的最新版本的資料,並且在讀的時候加鎖,其他事務都不能對當前行做修改。

  • 快照讀:簡單的 select 操作,屬於快照讀,不加鎖。
    select * from table where ?;
  • 當前讀:特殊的讀操作,插入、更新、刪除操作,屬於當前讀,需要加鎖。
    select * from table where ? lock in share mode;
    select * from table where ? for update;
    insert into table values (…);
    update table set ? where ?;
    delete from table where ?;

對於上面當前讀的語句,第一條讀取記錄加共享鎖,其他的全部加排它鎖。

也就是說在做資料的修改操作時,都會使用當前讀的方式,當前讀是通過行鎖和間隙鎖控制的,此時是加了排他鎖的,所有其他的事務都不能動當前的事務,所以避免了出現幻讀的可能。

而為了防止幻讀,行鎖和間隙鎖扮演了重要角色,下面簡單說一下:

  • 行鎖
    字面意思簡單理解對資料行加鎖,注意 InnoDB 行鎖是通過給索引上的索引項加鎖來實現的,也就是說只有通過索引條件檢索資料,InnoDB才使用行級鎖,否則,InnoDB將使用表鎖!
  • 間隙鎖
    間隙鎖就是用來為資料行之間的間隙來進行加鎖。

舉個例子:

select * from info where id > 5;

上面 SQL 中,其中 id 是主鍵,假設在一個 事務 A 中執行這個查詢,第一次查詢為一個 結果集 1 。在做第二次查詢時,另一個 事務 B 在 info 表進行了插入資料 7 和 10 的操作。在 事務 A 再次執行此查詢查詢出 結果集 2 的時候,發現多了幾條記錄,如此便產生了幻讀。

  • 結果集1
6,8,9
  • 結果集2
6,7,8,9,10

所以試想為了防止幻讀,我們不但要現存的 id > 5 的資料行(6,8,9)上面加鎖(行鎖),還要在它們的間隙加鎖(間隙鎖)。

我們以區間來表示要加鎖物件:

(5,6]
(6,8]
(8,9]
(9,+∞)

其中區間的右閉即為要加的行鎖,而區間的範圍即是要加的間隙鎖。

五、結語

關於髒讀、不可重複讀和幻讀的理解便記錄到這裡了,因筆者水平有限,如有錯誤歡迎指正。

歡迎訪問個人部落格 獲取更多知識分享。

相關文章