情境模擬
我們先來破冰一下,幫大家再回憶一次什麼是Phantom Read(幻讀)。假設今天水果行採用了一個庫存系統,其中商品庫存資料表如下:
CREATE TABLE `items` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(16)), `remaining` INT NOT NULL DEFAULT 0, `is_enabled` INT NOT NULL DEFAULT 0 PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1;
插入幾筆水果庫存的測試資料:
INSERT INTO `items` (`name`, `remaining`, `is_enabled`) VALUES ('apple', 20, 1), ('banana', 10, 0), ('cherry', 0, 0), ('guava', 0, 0), ('peach', 30, 1);
偷看一下資料表:
mysql> SELECT * FROM items; +----+--------+-----------+------------+ | id | name | remaining | is_enabled | +----+--------+-----------+------------+ | 1 | apple | 20 | 1 | | 2 | banana | 10 | 0 | | 3 | cherry | 0 | 0 | | 4 | guava | 0 | 0 | | 5 | peach | 30 | 1 | +----+--------+-----------+------------+ 5 rows in set (0.00 sec)
某天,老闆在貨車抵達門口進完貨後,走回自己辦公桌的電腦前面,想看看目前上架的水果狀態。
(水果行老闆為什麼會SQL不是本篇重點)
老闆(交易ID=1) | 工讀生小明(交易ID=2) |
---|---|
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; | |
START TRANSACTION; | |
START TRANSACTION; | |
SELECT * FROM items WHERE is_enabled = 1; |
此外,老闆知道等等可能會對資料修改,所以還特別用transaction包起來,並指定隔離層級為REPEATABLE READ。
+----+-------+-----------+------------+ | id | name | remaining | is_enabled | +----+-------+-----------+------------+ | 1 | apple | 20 | 1 | | 5 | peach | 30 | 1 | +----+-------+-----------+------------+ 2 rows in set (0.00 sec)
老闆看了看架上的庫存,突然想到蘋果與桃子其實很充足,畢竟剛剛蘋果與桃子才各進貨了一箱,於是想將架上販售的蘋果與桃子庫存修改為50
。
而就在此時,在店鋪裡剛補完貨的工讀生小明也正好在操作資料庫,將今天熱騰騰剛上架的香蕉設定為公開販售中。
老闆(交易ID=1) | 工讀生小明(交易ID=2) |
---|---|
UPDATE items SET is_enabled = 1 WHERE name = ‘banana’; | |
COMMIT; |
老闆在操作前為了避免自己下錯SQL,於是再SELECT一次確認資料無誤。
老闆(交易ID=1) | 工讀生小明(交易ID=2) |
---|---|
SELECT * FROM items WHERE is_enabled = 1; |
+----+-------+-----------+------------+ | id | name | remaining | is_enabled | +----+-------+-----------+------------+ | 1 | apple | 20 | 1 | | 5 | peach | 30 | 1 | +----+-------+-----------+------------+ 2 rows in set (0.00 sec)
(因為隔離層級為REPEATABLE READ,所以就算工讀生小明已經更改了香蕉的上架狀態,對老闆的transaction來說是看不見的)
此時老闆放心的將架上販售的蘋果與桃子庫存修改為50
。修改後,老闆又再SELECT一次看看更新後的結果。
老闆(交易ID=1) | 工讀生小明(交易ID=2) |
---|---|
UPDATE items SET remaining = 50 WHERE is_enabled = 1; | |
SELECT * FROM items WHERE is_enabled = 1; |
+----+--------+-----------+------------+ | id | name | remaining | is_enabled | +----+--------+-----------+------------+ | 1 | apple | 50 | 1 | | 2 | banana | 50 | 1 | | 5 | peach | 50 | 1 | +----+--------+-----------+------------+ 3 rows in set (0.00 sec)
老闆揉了揉眼睛,抓了抓沒剩幾根毛的頭髮,實在想不透為什麼明明在隔離層級為REPEATABLE READ的交易下,下了兩次SELECT後緊接著一個UPDATE,最後再SELECT一次出來的東西會跟前兩次不一致,而且是在還沒commit的狀態,也就是還在REPEATABLE READ保護的交易內發生,其實這就是典型幻讀的情境。
以上故事是我自己腦補的小劇場,如果略過文字故事的操作大致是這樣,有興趣的可以操作看看:
步驟 | 老闆(交易ID=1) | 工讀生小明(交易ID=2) |
---|---|---|
1 | SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; | |
2 | START TRANSACTION; | |
3 | START TRANSACTION; | |
4 | SELECT * FROM items WHERE is_enabled = 1; | |
5 | UPDATE items SET is_enabled = 1 WHERE name = ‘banana’; | |
6 | COMMIT; | |
7 | SELECT * FROM items WHERE is_enabled = 1; | |
8 | UPDATE items SET remaining = 50 WHERE is_enabled = 1; | |
9 | SELECT * FROM items WHERE is_enabled = 1; |
底層講解
故事說完了,我們先來想想老闆跟工讀生小明為什麼會下SQL,都知道在REPEATABLE READ的隔離層級下解決了不可重複讀的問題,可是既然都解決了不可重複讀的問題,為什麼我們在MySQL裡對這些重複讀出的資料做更新結果最後會發生幻讀呢?
這個問題其實疑惑了我好幾個月,直到最近在看《資料庫解剖學:從內部深解MySQL運作原理》這本書時,讀到書中快結束的第22.4.1章才突然豁然開郎。為了要回答這個問題,我們必須理解MySQL記錄中的trx_id
欄位與ReadView概念。
trx_id欄位
在採用InnoDB類型的表內的每筆記錄中的都會有trx_id
這個隱藏欄位給MySQL底層使用的,我們app層下的SQL存取不到。而這個trx_id
存的值是最後一次修改該row的交易ID,所以實際上我們的table會長這樣:
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 20 | 1 | 0 | | 2 | banana | 10 | 0 | 0 | | 3 | cherry | 0 | 0 | 0 | | 4 | guava | 0 | 0 | 0 | | 5 | peach | 30 | 1 | 0 | +----+--------+-----------+------------+----------+
我們假設那五條row是伴隨創表時插入的,沒有被水果行操作過,所以一開始初始為0
。
ReadView
MySQL為了在REPEATABLE READ的隔離層級下為了做到可重複讀,會在交易的第一個SELECT語句執行當下產生一個ReadView。其中,ReadView包含以下幾個重要欄位:
m_ids
: 當前活躍的交易ID清單min_trx_id
: 當前活躍的交易ID清單內的最小的交易ID(交易ID有遞增的特性,也就是當前活躍交易中最早的交易)max_trx_id
: MySQL打算分配給下一個交易的交易IDcreator_trx_id
: 創建此ReadView的交易ID
這個ReadView也就是網路上很多文章把他解釋成的snapshot,記錄了目前當下交易的狀態,並且活存至整個交易commit或rollback結束。從ReadView產生後的所有SELECT語句,都會先透過以下4個判斷,決定每一條row該不該被當前交易看到:
- 如果該row記錄的
trx_id
與ReadView中的creator_trx_id
相同,表示當前交易在存取它自己修政過的記錄,所以該row可以被當前交易存取。 -
如果該row記錄的
trx_id
小於ReadView中的min_trx_id
,表示生成該row的交易在當前交易生成ReadView前已經提交,所以該row可以被當前交易存取。 -
如果該row記錄的
trx_id
大於或等於ReadView中的max_trx_id
,表示生成該row的交易在當前交易生成ReadView後才開啟,所以該row不可以被當前交易存取。 -
如果該row記錄的
trx_id
在ReadView的min_trx_id
和max_trx_id
之間,則需要判斷trx_id
是否在m_ids
清單中。如果存在,說明創建ReadView時生成該row的交易還是活躍的,該row不可以被存取;如果不存在,說明創建ReadView時生成該row的交易己經被提交,該row可以被存取。
(以上截錄自書中第21.3.2章)
幻讀是如何發生的
介紹完trx_id
欄位與ReadView包含的資訊以及SELECT怎麼使用ReadView來獲取該顯示的row後,我們終於具備回答為什麼REPEATABLE READ的隔離層級下還是會發生幻讀的知識了。
在上面步驟4老闆的交易1內第一次下了SELECT語句,一個新的ReadView被創建了,此時ReadView記錄了以下資訊:
m_ids
: [交易ID=1, 交易ID=2]min_trx_id
: 交易ID=1max_trx_id
: 交易ID=3creator_trx_id
: 交易ID=1
然後SELECT語句取得了符合條件is_enabled = 1
的兩條記錄。
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 20 | 1 | 0 | | 5 | peach | 30 | 1 | 0 | +----+--------+-----------+------------+----------+ 2 rows in set (0.00 sec)
步驟5,工讀生小明更新了剛上架的香蕉設定is_enabled = 1
,此時真實的table如下:
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 20 | 1 | 0 | | 2 | banana | 10 | 1 | 2 | | 3 | cherry | 0 | 0 | 0 | | 4 | guava | 0 | 0 | 0 | | 5 | peach | 30 | 1 | 0 | +----+--------+-----------+------------+----------+
可以注意到香蕉的那條row除了is_enabled
被更新外,隱藏的trx_id
欄位也一併被更新成工讀生小明的交易ID=2。
步驟6,小明提交了該交易持久化了香蕉的那條row的變動。
步驟7,老闆在交易1內第二次下了SELECT語句,此時對交易1來說已經有現成的ReadView了,所以SELECT會參考前次創建的ReadView來使用。(題外話: 如果隔離層級小於REPEATABLE READ則會每次SELECT都創建新的ReadView,聰明的你可以想想看這跟ReadView的哪個特性有關係。)
前次創建的ReadView:
m_ids
: [交易ID=1, 交易ID=2]min_trx_id
: 交易ID=1max_trx_id
: 交易ID=3creator_trx_id
: 交易ID=1
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 20 | 1 | 0 | | 2 | banana | 10 | 1 | 2 | <-- | 3 | cherry | 0 | 0 | 0 | | 4 | guava | 0 | 0 | 0 | | 5 | peach | 30 | 1 | 0 | +----+--------+-----------+------------+----------+
當MySQL搜尋到這條row時,雖然他有符合查詢條件,但並沒有符合ReadView要讀出該row的判斷4
:
trx_id
:2 在min_trx_id
: 1與max_trx_id
: 3之間,且trx_id
不在m_ids
清單中
所以該條香蕉的row不會被MySQL抓出來,其餘的蘋果與桃子因為trx_id
是0
,符合了判斷2
讀出的條件,這也是為什麼MySQL可以在步驟7實現REPEATABLE READ。
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 20 | 1 | 0 | | 5 | peach | 30 | 1 | 0 | +----+--------+-----------+------------+----------+ 2 rows in set (0.00 sec)
步驟8,老闆想利用is_enabled = 1
這個條件一次更新符合的row(蘋果與桃子)的庫存為50
,但因為ReadView顧名思義只會在read時作動判斷,不會阻止UPDATE或DELETE改動那筆新符合is_enabled = 1
的香蕉的row,所以實際上MySQL的UPDATE語句影響了三筆記錄,並且將三筆記錄隱藏的trx_id
欄位也一併被更新成老闆目前的交易ID=1。
(步驟6中小明已經commit了將香蕉設定為公開販售中,所以步驟8的UPDATE並不會造成blocking)
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 50 | 1 | 1 | <-- | 2 | banana | 50 | 1 | 1 | <-- | 3 | cherry | 0 | 0 | 0 | | 4 | guava | 0 | 0 | 0 | | 5 | peach | 50 | 1 | 1 | <-- +----+--------+-----------+------------+----------+
步驟9,老闆在交易1內第三次下了SELECT語句,MySQL依照過往模式將先前建立過的ReadView拿出來加入判斷。只不過這次蘋果、香蕉跟桃子這三筆記錄都符合ReadView要讀出記錄的判斷1
條件,這就是步驟9為什麼會發生幻讀的理由。
+----+--------+-----------+------------+----------+ | id | name | remaining | is_enabled | (trx_id) | +----+--------+-----------+------------+----------+ | 1 | apple | 50 | 1 | 1 | | 2 | banana | 50 | 1 | 1 | | 5 | peach | 50 | 1 | 1 | +----+--------+-----------+------------+----------+ 3 rows in set (0.00 sec)
結論
透過上面一步步的DB操作流程分解,我們了解了為什麼MySQL隔離層級設為REPEATABLE READ時還是會發生幻讀。至於為什麼UPDATE或DELETE也不使用ReadView加入判斷書中並沒有提到,不過我自己覺得可能是REPEATABLE READ顧名思義要防的是不可重複讀,至於其他情境因為規範沒有要求所以MySQL只做到規範的標準就好。如果有大神知道MySQL當初設計的哲學的話歡迎跟大家分享,我還滿想知道:)
最後還是要推薦一下《資料庫解剖學:從內部深解MySQL運作原理》這本書,從去年2021/07上市當月就買了,但因為這段時間工作及出差的緣故,一直到這週才把這本書看完。書中用敘述+圖表的方式介紹完MySQL底層的資料結構及運作原理,很適合對於想更深一層了解資料庫底層但又不想翻c++原始碼的人。
Nicely spoken certainly!