在之前的博客中,我寫了一系列的文章,比較系統的學習了 MySQL 的事務、隔離級別、加鎖流程以及死鎖,我自認為對常見 SQL 語句的加鎖原理已經掌握的足夠了,但看到熱心網友在評論中提出的一個問題,我還是徹底被問蒙了。
他的問題是這樣的:
加了插入意向鎖后,插入數據之前,此時執行了 select…lock in share mode 語句(沒有取到待插入的值),然后插入了數據,下一次再執行 select…lock in share mode(不會跟插入意向鎖沖突),發現多了一條數據,於是又產生了幻讀。會出現這種情況嗎?
這個問題初看上去很簡單,在 RR 隔離級別下,假設要插入的記錄不存在,如果先執行 select…lock in share mode 語句,很顯然會在記錄間隙之間加上 GAP 鎖,而 insert 語句首先會對記錄加插入意向鎖,插入意向鎖和 GAP 鎖沖突,所以不存在幻讀;如果先執行 insert 語句后執行 select…lock in share mode 語句,由於 insert 語句在插入記錄之后,會對記錄加 X 鎖,它會阻止 select…lock in share mode 對記錄加 S 鎖,所以也不存在幻讀。
兩種情況如下所示:
先執行 INSERT 后執行 SELECT:
先執行 SELECT 后執行 INSERT:
但是我們仔細想一想就會發現哪里有點不對勁,我們知道 insert 語句會先在插入間隙上加上插入意向鎖,然后開始寫數據,寫完數據之后再對記錄加上 X 記錄鎖。
那么問題就來了,如果在 insert 語句加插入意向鎖之后,寫數據之前,執行了 select…lock in share mode 語句,這個時候 GAP 鎖和插入意向鎖是不沖突的,查詢出來的記錄數為 0,然后 insert 語句寫數據,加 X 記錄鎖,因為記錄鎖和 GAP 鎖也是不沖突的,所以 insert 成功插入了一條數據,這個時候如果事務提交,select…lock in share mode 語句再次執行查詢出來的記錄數就是 1,豈不是就出現了幻讀?
整個流程如下所示(我們把 insert 語句的執行分成兩個階段,INSERT 1 加插入意向鎖,還沒寫數據,INSERT 2 寫數據,加記錄鎖):
一、INSERT 加鎖的困惑
在得出上面的結論時,我也感到很驚訝。按理是不可能出現這種情況的,只可能是我對這兩個語句的加鎖過程還沒有想明白。
於是我又去復習了一遍 MySQL 官方文檔,Locks Set by Different SQL Statements in InnoDB 這篇文檔對各個語句的加鎖有詳細的描述,其中對 insert 的加鎖過程是這樣說的(這應該是網絡上介紹 MySQL 加鎖機制被引用最多的文檔,估計也是被誤解最多的文檔):INSERT sets an exclusive lock on the inserted row. This lock is an index-record lock, not a
這里講到了 insert 會對插入的這條記錄加排他記錄鎖,在加記錄鎖之前還會加一種 GAP 鎖,叫做插入意向鎖,如果出現唯一鍵沖突,還會加一個共享記錄鎖。這和我之前的理解是完全一樣的,那么究竟是怎么回事呢?難道 MySQL 的 RR 真的會出現幻讀現象?
在 Google 上搜索了很久,並沒有找到 MySQL 幻讀的問題,百思不得其解之際,遂決定從 MySQL 的源碼中一探究竟。
二、編譯 MySQL 源碼
編譯 MySQL 的源碼非常簡單,但是中間也有幾個坑,如果能繞過這幾個坑,在本地調試 MySQL 是一件很容易的事(當然能調試源碼是一回事,能看懂源碼又是另一回事了)。
我的環境是 Windows 10 x64,系統上安裝了 Visual Studio 2012,如果你的開發環境和我不一樣,編譯步驟可能也會不同。
在開始之前,首先要從官網下載 MySQL 源碼:
這里我選擇的是 5.6.40 版本,操作系統下拉列表里選 Source Code,OS Version 選擇 Windows(Architecture Independent),然后就可以下載打包好的 zip 源碼了。
將源碼解壓縮到 D:\mysql-5.6.40 目錄,在編譯之前,還需要再安裝幾個必要軟件:
- CMake:CMake 本身並不是編譯工具,它是通過編寫一種平台無關的 CMakeList.txt 文件來定制編譯流程的,然后再根據目標用戶的平台進一步生成所需的本地化 Makefile 和工程文件,如 Unix 的 Makefile 或 Windows 的 Visual Studio 工程;
- Bison:MySQL 在執行 SQL 語句時,必然要對 SQL 語句進行解析,一般來說語法解析器會包含兩個模塊:詞法分析和語法規則。詞法分析和語法規則模塊有兩個較成熟的開源工具 Flex 和 Bison 分別用來解決這兩個問題。MySQL 出於性能和靈活考慮,選擇了自己完成詞法解析部分,語法規則部分使用了 Bison,所以這里我們還要先安裝 Bison。Bison 的默認安裝路徑為 C:\Program Files\GnuWin32,但是千萬不要這樣,一定要記得選擇一個不帶空格的目錄,譬如 C:\GnuWin32 要不然在后面使用 Visual Studio 編譯 MySQL 時會卡死;
- Visual Studio:沒什么好說的,Windows 環境下估計沒有比它更好的開發工具了吧。
安裝好 CMake 和 Bison 之后,記得要把它們都加到 PATH 環境變量中。做好准備工作,我們就可以開始編譯了,首先用 CMake 生成 Visual Studio 的工程文件:
cmake 的 -G 參數用於指定生成哪種類型的工程文件,這里是 Visual Studio 2012,可以直接輸入 cmake -G 查看支持的工程類型。如果沒問題,會在 project 目錄下生成一堆文件,其中 MySQL.sln 就是我們要用的工程文件,使用 Visual Studio 打開它。
打開 MySQL.sln 文件,會在 Solution Explorer 看到 130 個項目,其中有一個叫 ALL_BUILD,這個時候如果直接編譯,編譯會失敗,在這之前,我們還要對代碼做點修改:
- 首先是 sql\sql_locale.cc 文件,看名字就知道這個文件用於國際化與本土化,這個文件里有各個國家的語言字符,但是這個文件卻是 ANSI 編碼,所以要將其改成 Unicode 編碼;
- 打開 sql\mysqld.cc 文件的第 5239 行,將 DBUG_ASSERT(0) 改成 DBUG_ASSERT(1),要不然調試時會觸發斷言;
現在我們可以編譯整個工程了,選中 ALL_BUILD 項目,Build,然后靜靜的等待 5 到 10 分鍾,如果出現了 Build: 130 succeeded, 0 failed 這樣的提示,那么恭喜,你現在可以盡情的調試 MySQL 了。
我們將 mysqld 設置為 Startup Project,然后加個命令行參數 –console,這樣可以在控制台里查看打印的調試信息:
另外 client\Debug\mysql.exe 這個文件是對應的 MySQL 的客戶端,可以直接雙擊運行,默認使用的用戶為 ODBC@localhost,如果要以 root 用戶登錄,可以執行 mysql.exe -u root,不需要密碼。
三、調試 INSERT 加鎖流程
首先我們創建一個數據庫 test,然后創建一個測試表 t,主鍵為 id,並插入測試數據:
然后我們開兩個客戶端會話,一個會話執行 insert into t(id) value(30),另一個會話執行 select * from t where id = 30 lock in share mode。很顯然,如果我們能在 insert 語句加插入意向鎖之后寫數據之前下個斷點,再在另一個會話中執行 select 就可以模擬出這種場景了。
那么我們來找下 insert 語句是在哪加插入意向鎖的。第一次看 MySQL 源碼可能會有些不知所措,調着調着就會迷失在深深的調用層級中,我們看 insert 語句的調用堆棧,一開始時還比較容易理解,從 mysql_parse -> mysql_execute_command -> mysql_insert -> write_record -> handler::ha_write_row -> innobase::write_row -> row_insert_for_mysql,這里就進入 InnoDb 引擎了。
然后繼續往下跟:row_ins_step -> row_ins -> row_ins_index_entry_step -> row_ins_index_entry -> row_ins_clust_index_entry -> row_ins_clust_index_entry_low -> btr_cur_optimistic_insert -> btr_cur_ins_lock_and_undo -> lock_rec_insert_check_and_lock。
一路跟下來,都沒有發現插入意向鎖的蹤跡,直到 lock_rec_insert_check_and_lock 這里:
這里是檢查是否有和插入意向鎖沖突的其他鎖,如果有沖突,就將插入意向鎖加到鎖等待隊列中。這很顯然是先執行 select … lock in share mode 語句再執行 insert 語句時的情景,插入意向鎖和 GAP 沖突。但這不是我們要找的點,於是繼續探索,但是可惜的是,直到 insert 執行結束,我都沒有找到加插入意向鎖的地方。
跟代碼非常辛苦,我擔心是因為我跟丟了某塊的邏輯導致沒看到加鎖,於是我看了看加其他鎖的地方,發現在 InnoDb 里行鎖都是通過調 lock_rec_add_to_queue(沒有鎖沖突) 或者 lock_rec_enqueue_waiting(有鎖沖突,需要等待其他事務釋放鎖) 來實現的,於是在這兩個函數上下斷點,執行一條 insert 語句,依然沒有斷下來,說明 insert 語句沒有加任何鎖!
到這里我突然想起之前做過的 insert 加鎖的實驗,執行 insert 之后,如果沒有任何沖突,在 show engine innodb status 命令中是看不到任何鎖的,這是因為 insert 加的是隱式鎖。什么是隱式鎖?隱式鎖的意思就是沒有鎖!
所以,根本就不存在之前說的先加插入意向鎖,再加排他記錄鎖的說法,在執行 insert 語句時,什么鎖都不會加。這就有點意思了,如果 insert 什么鎖都不加,那么如果其他事務執行 select … lock in share mode,它是如何阻止其他事務加鎖的呢?
答案就在於隱式鎖的轉換。
InnoDb 在插入記錄時,是不加鎖的。如果事務 A 插入記錄且未提交,這時事務 B 嘗試對這條記錄加鎖,事務 B 會先去判斷記錄上保存的事務 id 是否活躍,如果活躍的話,那么就幫助事務 A 去建立一個鎖對象,然后自身進入等待事務 A 狀態,這就是所謂的隱式鎖轉換為顯式鎖。
我們跟一下執行 select 時的流程,如果 select 需要加鎖,則會走:sel_set_rec_lock -> lock_clust_rec_read_check_and_lock -> lock_rec_convert_impl_to_expl,lock_rec_convert_impl_to_expl 函數的核心代碼如下:
首先判斷事務是否活躍,然后檢查是否已存在排他記錄鎖,如果事務活躍且不存在鎖,則為該事務加上排他記錄鎖。而本事務的鎖是通過 lock_rec_convert_impl_to_expl 之后的 lock_rec_lock 函數來加的。
到這里,這個問題的脈絡已經很清晰了:
- 執行 insert 語句,判斷是否有和插入意向鎖沖突的鎖,如果有,加插入意向鎖,進入鎖等待;如果沒有,直接寫數據,不加任何鎖;
- 執行 select … lock in share mode 語句,判斷記錄上是否存在活躍的事務,如果存在,則為 insert 事務創建一個排他記錄鎖,並將自己加入到鎖等待隊列;
所以不存在網友所說的幻讀問題。那么事情到此結束了么?並沒有。
細心的你會發現,執行 insert 語句時,從判斷是否有鎖沖突,到寫數據,這兩個操作之間還是有時間差的,如果在這之間執行 select … lock in share mode 語句,由於此時記錄還不存在,所以也不存在活躍事務,不會觸發隱式鎖轉換,這條語句會返回 0 條記錄,並加上 GAP 鎖;而 insert 語句繼續寫數據,不加任何鎖,在 insert 事務提交之后,select … lock in share mode 就能查到 1 條記錄,這豈不是還有幻讀問題嗎?
為了徹底搞清楚這中間的細節,我們在 lock_rec_insert_check_and_lock 檢查完鎖沖突之后下個斷點,然后在另一個事務中執行 select … lock in share mode,如果它能成功返回 0 條記錄,加上 GAP 鎖,說明就存在幻讀。不過事實上,這條 SQL 語句執行的時候卡住了,並不會返回 0 條記錄。從 show engine innodb status 的 TRANSACTIONS 里我們看不到任何行鎖沖突的信息,但是我們從 RW-LATCH INFO 中卻可以看出一些端倪:
這里列出了 3 個 RW-LOCK:000002C97F62FC70、000002C976A3B998、000002C976A3B8A8。其中可以看到最后一個 RW-LOCK 有其他線程在等待其釋放(Waiters for the lock exist)。下面列出了所有等待該鎖的線程,Thread 10304 has waited at btr0cur.cc line 256 for 26.00 seconds the semaphore,這里的 Thread 10304 就是我們正在執行 select 語句的線程,它卡在了 btr0cur.cc 的 256 行,我們查看 Thread 10304 的堆棧:
btr0cur.cc 的 256 行位於 btr_cur_latch_leaves 函數,如下所示,通過 btr_block_get 來加鎖,看起來像是在訪問 InnoDb B+ 樹的葉子節點時卡住了:
這里的 latch_mode == BTR_SEARCH_LEAF,所以加鎖的 mode 為 RW_S_LATCH。
這里要介紹一個新的概念,叫做 Latch,一般也把它翻譯成 “鎖”,但它和我們之前接觸的行鎖表鎖(Lock)是有區別的。這是一種輕量級的鎖,鎖定時間一般非常短,它是用來保證並發線程可以安全的操作臨界資源,通常沒有死鎖檢測機制。Latch 可以分為兩種:MUTEX(互斥量)和 RW-LOCK(讀寫鎖),很顯然,這里我們看到的是 RW-LOCK。
我們回溯一下 select 語句的調用堆棧:ha_innobase::index_read -> row_search_for_mysql -> btr_pcur_open_at_index_side -> btr_cur_latch_leaves,從調用堆棧可以看出 select … lock in share mode 語句在訪問索引,那么為什么訪問索引會被卡住呢?
接下來我們看看這個 RW-LOCK 是在哪里加上的?從日志里可以看到 Locked: thread 2820 file D:\mysql-5.6.40\storage\innobase\btr\btr0cur.cc line 256 X-LOCK,所以這個鎖是線程 2820 加上的,加鎖的位置也在 btr0cur.cc 的 256 行,查看函數引用,很快我們就查到這個鎖是在執行 insert 時加上的,函數堆棧為:row_ins_clust_index_entry_low -> btr_cur_search_to_nth_level -> btr_cur_latch_leaves。
我們看這里的 row_ins_clust_index_entry_low 函數(無關代碼已省略):
這里是執行 insert 語句的關鍵,可以發現執行插入操作的前后分別有一行代碼:mtr_start() 和 mtr_commit()。這被稱為 迷你事務(mini-transaction),既然叫做事務,那這個函數的操作肯定是原子性的,事實上確實如此,insert 會在檢查鎖沖突和寫數據之前,會對記錄所在的頁加一個 RW-X-LATCH 鎖,執行完寫數據之后再釋放該鎖(實際上寫數據的操作就是寫 redo log(重做日志),將臟頁加入 flush list,這個后面有時間再深入分析了)。這個鎖的釋放非常快,但是這個鎖足以保證在插入數據的過程中其他事務無法訪問記錄所在的頁。mini-transaction 也可以包含子事務,實際上在 insert 的執行過程中就會加多個 mini-transaction。
每個 mini-transaction 會遵守下面的幾個規則:
- 修改一個頁需要獲得該頁的 X-LATCH;
- 訪問一個頁需要獲得該頁的 S-LATCH 或 X-LATCH;
- 持有該頁的 LATCH 直到修改或者訪問該頁的操作完成。
所以,最后的最后,真相只有一個:insert 和 select … lock in share mode 不會發生幻讀。整個流程如下:
- 執行 insert 語句,對要操作的頁加 RW-X-LATCH,然后判斷是否有和插入意向鎖沖突的鎖,如果有,加插入意向鎖,進入鎖等待;如果沒有,直接寫數據,不加任何鎖,結束后釋放 RW-X-LATCH;
- 執行 select … lock in share mode 語句,對要操作的頁加 RW-S-LATCH,如果頁面上存在 RW-X-LATCH 會被阻塞,沒有的話則判斷記錄上是否存在活躍的事務,如果存在,則為 insert 事務創建一個排他記錄鎖,並將自己加入到鎖等待隊列,最后也會釋放 RW-S-LATCH;
參考:
- Locks Set by Different SQL Statements in InnoDB
- Installing MySQL from Source
- CMake 入門實戰
- MySQL · 源碼分析 · 一條insert語句的執行過程
- [MySQL源碼] 一條簡單insert語句的調用棧
- MySQL5.7 : 對隱式鎖轉換的優化
- InnoDB事務鎖之行鎖-insert加鎖-隱式鎖加鎖原理
- InnoDB事務鎖之行鎖-判斷是否有隱式鎖原理圖
- InnoDB事務鎖之行鎖-隱式鎖轉換顯示鎖舉例理解原理
- MySQL系列:innodb源碼分析之mini transaction
原文:https://www.cnblogs.com/javastack/archive/2021/11/18/15571691.html