MySQL 之隔離級別:可重複讀

UioSun發表於2022-06-21
鄙人希望能寫一些對萌新有所幫助的文章,若有謬誤,還望不吝賜教。

在 MySQL 中,當我們將隔離等級明確為可重複讀*(實際上是 MySQL 的預設事務隔離級別),接著執行一個事務,再開啟另一個事務:

A 事務首次查詢;
B 事務接著新增;
A 事務隨後包含 B 事務的新行進行更新,並再次查詢;
結果出現變化,這似乎沒有避免直覺上的幻讀。

既然如此,本篇文章就是來探究 MySQL 可重複讀時,可解決的幻讀到底是怎樣的幻讀?

宣告:任何 SQL 系統中,可重複讀的隔離級別都不要求解決幻讀。我們只是在探尋:MySQL 為了在這個層級解決幻讀*(或部分幻讀),做了哪些工作。

什麼是幻讀

首先是直覺定義:當某個事務未完成前,儘管可能有其他事務在執行,但我在本事務每次讀取的結果,應當是一致的。

但隨後我們閱讀 Wikipedia 的定義:當讀取的 WHERE 條件並未加範圍鎖時,由於另一事務對該範圍的資料進行增刪改操作,導致本事務在兩次讀取時,結果不一致的情況。

會發現一個明顯的差異:當 WHERE 沒有加範圍鎖,這句話意味著什麼:

  1. 如果要規避幻讀,則必須加範圍鎖——鎖死本事務用到的一切資料——降低效能瓶頸;
  2. 有沒有加範圍鎖或通過類似機制來保障本事務的相關資料一致性,成為幻讀的一大判斷依據。

理清了定義,我們來看看 MySQL 做了哪些工作。

MySQL 的一些機制

首先,MySQL 在執行讀取時,會發生兩種讀取方式。

快照讀(一致性的非鎖定讀取)

根據 MySQL 的定義,在我們進行查詢時,由 InnoDB 向查詢呈現資料庫某個時間點的快照,從而達成 一致性的非鎖定讀取機制

而當隔離級別升格到可重複讀時(MySQL 的事務預設隔離級別),整個事務過程中,都以事務開始時所用的快照為準。

試試 MySQL 官方給的栗子:

# 事件A
SET autocommit=0;

# 事件B
SET autocommit=0;

# 事件A
SELECT * FROM t;
# 空結果

# 事件B
INSERT INTO t VALUES (1, 2);

# 事件A
SELECT * FROM t;
# 空結果

# 事件B
INSERT INTO t VALUES (1, 2);

# 事件A
SELECT * FROM t;
# 空結果

# 事件B
COMMIT;

# 事件A
SELECT * FROM t;
# 空結果

COMMIT;

SELECT * FROM t;
# 有結果

聽起來一口氣就解決了幻讀問題,但我們繼續閱讀更多資訊:

儘管一致性讀取聽起來很好,但對某些 DML 語句會發生不一樣的效果:

首先,快照適用於事務中的 SELECT 語句,當 A 事務更新了 由其他事務提交的、在事務期間產生 的新行,即便它們並不在快照內,也會變得可見。

除此之外,當你在 DML 語句中執行一些子語句時:

預設情況下,InnoDB 使用更健壯的鎖來處理這些語句,並且這些讀取語句像是活躍在已提交讀的級別一樣,儘管仍處在同一個事務,每個語句的讀取也會導致設定和更新自己的快照。

讓我們將官方的栗子稍作修改,假設兩個列為 idval

# 事件A
SET autocommit=0;

# 事件B
SET autocommit=0;

# 事件A
SELECT * FROM t;
# 一個結果

# 事件B
INSERT INTO t VALUES (2, 3);

# 事件A
SELECT * FROM t;
# 一個結果

# 事件B
COMMIT;

# 事件A
SELECT * FROM t;
# 一個結果

INSERT INTO t VALUES (3, 4);
SELECT * FROM t;
# 兩個結果,快照的一條 + 新增一條

UPDATE t SET val = 0 WHERE id > 0;
# 注意語句的影響行數
SELECT * FROM t;
# 三個結果——因為 DML 語句影響(發現)了一些額外的行,快照被更新了,事務尚未提交的一條也包含在內

這裡還有一個 MVVC(多版本併發控制)的概念,有興趣可以自行研究:InnoDB Multi-Versioning - MySQL Doc

(因為 MVVC 涉及到更多其他內容,至少目前我對其知之甚少,無法在這裡描述它的概念和實現情況——每個現代化的資料庫系統都有基於自身定位的 MVVC 實現)

當前讀(鎖定讀取)

針對快照讀的問題,鎖定讀取則提供了鎖機制,以供你安全的去操作/即將操作目標資料。

  1. 共享鎖:SELECT ... FOR SHARE,其他事務可以讀取這些資料,但不能修改它們。
  2. 更新鎖:SELECT ... FOR UPDATE,鎖定這些資料相關的行、索引和關聯索引資訊,保證在共享鎖和其他隔離級別中,均無法修改本事務相關的資料。

有一些小小的注意事項:

  • 僅有禁用自動提交才能使用鎖定讀取;
  • 子查詢並不會被關聯鎖定,如果你想鎖定子查詢的資料,記得將對應的子語句也放到裡面;
  • 如果不想等待其他事務結束鎖,可以使用 SELECT ... FOR ... SKIP LOCKED,這會忽略掉被鎖定的行,返回剩餘結果;或者是 SELECT ... FOR ... NOWAIT,直接返回被鎖的錯誤。
  • 在事務結束時,所有鎖定讀取的鎖都會被釋放。

最後留一個栗子:

# 事件A
SET autocommit=0;

# 事件B
SET autocommit=0;

# 事件A
SELECT * FROM t FOR SHARE;
# 一個結果,沒有 WHERE 條件,SHARE 將鎖加到全表

# 事件B
INSERT INTO t VALUES (2, 3);
# 被阻塞

間隙鎖

行鎖保證了修改的一致性,而當資料進行新增行為時,行鎖就無能為力了。

間隙鎖(Gap Lock)用於鎖住範圍的索引,保證目標位置關聯的間隙牢固,於是,當我們想要新增 val = 5 的行時,只需要鎖住 5 左右的索引,即可保證新增的一致性。

# 事件A
SET autocommit=0;

# 事件B
SET autocommit=0;

# 事件A
SELECT * FROM t WHERE id = 5 FOR SHARE;# 這會鎖定 5,沒有範圍的成本
# 或
# SELECT * FROM t WHERE id > 4 FOR SHARE;# 這會鎖定 5 ~ ∞ 的範圍

# 事件B
INSERT INTO t VALUES (5, 3);

# 事件A
INSERT INTO t VALUES (5, 12);
# 成功

稍微補充:如果我們對 ID = 5 做操作時,會直接走記錄鎖(鎖定某一個記錄),畢竟沒必要 Gap 了。

我們確認了間隙鎖對索引的行為,如果條件列根本不屬於索引,會發生什麼?

If id is not indexed or has a nonunique index, the statement does lock the preceding gap.

如果 id 列不具備索引或只有非唯一索引,均會鎖定鄰近範圍,這是文件的解釋。這裡就不留程式碼了,大家有興趣可以去嘗試一下。

Next-Key 鎖

當我們將間隙鎖和記錄鎖組合使用,就得到了 Next-Key 鎖。

可重複讀級別中,InnoDB 會採用這個鎖,從而保證相關的增改將阻塞其他事務的同目標增改,當此時僅有我們能進行該操作時,自然規避了幻讀。

最後,讓我們回到最初的栗子:

# 事件A
SET autocommit=0;

# 事件B
SET autocommit=0;

# 事件A
UPDATE t SET val = 0 WHERE id > 0;
# 成功,同時 Next-Key 鎖鎖住所有 WHERE 條件相關的記錄

# 事件B
INSERT INTO t VALUES (2, 3);
# 被阻塞

結論

最終可知,MySQL 的可重複讀隔離級別,解決的是同類讀時,產生的幻讀問題,但並未解決非同類讀導致的幻讀問題——當然我們們閱讀百科也能看到:對於可重複讀級別的定義上,規避幻讀不是該級別必須處理的事項。

引用

本文參考或引用以下資料,在此致謝:

Repeatable-read isolation violated in UPDATE - MySQL Bug Report
Consistent Nonlocking Reads - MySQL Document
Innodb中的事務隔離級別和鎖的關係 - 美團技術團隊
資料庫核心月報 - 2017 / 06 - 阿里雲PolarDB-資料庫核心組
Isolation (database systems) - Wikipedia
《高效能 MySQL》 - O'Reilly 系列叢書

引申:還有幾個共享/排他/意向/鎖,各有用處,我英文不是太好,看的有些累了(和本文主題好像也不是太相關),大家有興趣自取哈:InnoDB Locking - MySQL Document
(如果有關係,請留言,我會補充一下它們的概念和與本文的關係所在)

相關文章