MySQL升級WRITE_SET後的一次死鎖分析

java06051515發表於2020-02-12

背景

MySQL在推出MGR的時候使用了WRITE_SET, 借用這個思想, MySQL在5.7.22版本引入了基於WRITE_SET的並行複製方案[1]。在原先的主從複製技術中,同一批次的事物能進入事物的prepare階段說明那批事物沒有衝突,所以可以併發執行。我們都知道innodb是基於行鎖的資料庫,所以如果能夠按照行級別的粒度來併發的回放資料會對效能有很大的提高。採用這套方案的效能優點就有很多方面了,其中一個可以簡單看到的好處就是:我們在回放的時候就不用依賴於主上事物提交的情況了,正所謂less is more。減少了依賴,並行從宏觀上也能按照邏輯行這樣的來回放,所以效能肯定有很大的提升[2]. 故而,我們資料庫這邊在一些例項上啟用了這個並行回放特性。

導致我們死鎖的現象是: 我們發現開啟了write_set並行回放的例項從庫上死鎖的機率比以前高了不少, 並且發生死鎖的例項都是在進行xtrabackup備份。本文主要分析這些資料庫例項上發生死鎖的原因。

場景

我們知道MySQL事物會設計到很多的鎖,比如MDL鎖,innodb的行鎖,意向鎖,latch 鎖等等。不同的隔離級別鎖的行為也有很多的差異。從死鎖理論的角度:死鎖就是有向圖中存在環,從而造成相互等待。要解決死鎖只要簡單的破壞任何一條邊,來打破環行等待。當然實際的實現可能會因各個環節點的權重不同而有所最佳化,選擇代價最小的。但之前的重點肯定是找出這個“環”。而這些鎖有些是運維的時候可以看到有些是看不到的。比如latch鎖一般對使用者看不到。因為效能原因,我們的MDL鎖和INNODB鎖的詳細資訊並未收集。如果開啟了,就可以透過performance_schema.metadata_lock這個表來查詢MDL鎖的相關資訊,透過show engine innodb status來檢視詳細innodb的加鎖資訊。

透過簡單的分析,我們鎖定是MDL死鎖。所以在這樣的場景下,我們只能透過show full processlist來檢視到當時的狀態,如下圖:

case1:  photo 1 圖1

case2:  photo 2-1 圖2-1

photo 2-2 圖2-2

===

為了方便大家理解, 我畫了一個示意圖[圖3]來解釋這兩個case的死鎖情況:  photo 3 圖3

case1 死鎖分析:

可以看到在work執行緒組中,有一個work處理的事物先到達了事物的提交狀態, 但是事物在提交前需要進行 order_commit判斷,因為我們設定了slave_preserve_commit_order ,要保證事物是按照主庫上的提交順序來提交的。所以這個時候必須等待之前的事物要提交才可以進行。所以看到這個執行緒的狀態是: "Waiting for preceding transaction to commit"。當那個"靠前"的事物準備提交的時候要去拿mdl::commit_lock這把鎖,發現要不到。形成如上的“環等待”。

透過分析可以知道,這個時候同時執行了 FTWRL (flush table with read lock), 而這個操作會獲取到MDL的一個共享鎖。但是同樣沒有版本獲取mdl::commit_lock 而等待。這個等待會造成新來的更新請求被阻塞,因為更新的語句是排他型別的鎖。由於篇幅的原因,不細說MDL鎖相容細節。這裡只給出結論,會阻塞部分更新的語句,進而會影響到業務。

===  photo 4 圖4

case2 死鎖分析:

順便提一句: 同樣可以看到,這種情況下新的請求被阻塞主。注意,這也正是備份的核心思想。阻塞新來的請求,阻塞同批次的提交。保證在備份的時候沒有新的資料插入

一開始一個比較"靠後"的事物獲取了mdl::commit_lock,在準備提交的時候,發現系統配置了slave_preserve_commit_order,同時該事物的前面還有事物未提交,需要等待前面的事物先執行完成後才能繼續。然後FTWRL先獲取了mdl::global_read_lock鎖,但是沒有辦法獲取mdl::commit_lock鎖。

這個時候如果這個“前面的事物”是更新操作,那麼就跟mdl::global_read_lock鎖互斥,故而形成上面的死鎖。

驗證

由於這樣的死鎖,是機率出現的。為了高效的復現問題,我們打算使用mysql的測試框架來驗證. 第一個步驟是:透過上面的分析,修改核心原始碼加大死鎖的機率。證明我們的猜想確實能夠出現死鎖。但是這個出現的死鎖並不一定就是線上真是環境的死鎖。故而需要我們把修改的原始碼在實際場景下面驗證。當然我們沒有辦法在生產環境來驗證。我們可以透過第一步修改的原始碼,然後使用備份的資料來模擬。如果使用備份的資料 + 我們修改的原始碼資料庫例項復現了,才能客觀的判斷我們的死鎖研判。當然讀者可能說我們修改原始碼破壞了之前的環境,這裡當然是有前提的。這個前提就是:只修改並行回放執行緒組中的某一個執行緒,不改變原有邏輯,只是單純的讓它支援慢一點來提高死鎖的機率,作證我們的死鎖研判。

首先我們的第一步就是要:在主庫上產生兩個事物(當然我們也可以使用蠻力,迴圈,不過可能效果差,甚至可能無法復現),使用MySQL的測試框架,祥見如下的程式碼:

57 #  ===========================
58 # 在master上建立兩個連結master和master1
59 --source include/rpl_connection_master.inc
60 send SET DEBUG_SYNC='waiting_in_the_middle_of_flush_stage SIGNAL w WAIT_FOR b';
61
62 --source include/rpl_connection_master1.inc
63 send SET DEBUG_SYNC= 'now WAIT_FOR w';
64
65 --source include/rpl_connection_master.inc
66 --reap
67 show master status;
68 send insert into test.t1 values(1);
69
70 --source include/rpl_connection_master1.inc
71 --reap
72 SET DEBUG_SYNC= 'bgc_after_enrolling_for_flush_stage SIGNAL b';
73 insert into test.t1 values(1000);

如何驗證我們的主庫上這兩個事物屬於同一個批次呢?當然是binlog啦。結果如下:

show master status;
File	Position	Binlog_Do_DB	Binlog_Ignore_DB	Executed_Gtid_Set
master-bin.000001	849#200107  9:26:14 server id 1  end_log_pos 219 CRC32 0x059fa77a 	Anonymous_GTID	last_committed=0	sequence_number=1	rbr_only=no#200107  9:26:24 server id 1  end_log_pos 408 CRC32 0xa1a6ea99 	Anonymous_GTID	last_committed=1	sequence_number=2
	rbr_only=yes#200107  9:26:24 server id 1  end_log_pos 661 CRC32 0x2b0fc8a5 	Anonymous_GTID	last_committed=1	sequence_number=3	rbr_only=yes


可以看到last_commit這個欄位我們一共產生了兩組binlog, 一個是0 這裡是create table 語句。另外一個是1, 就是我們上面的兩條insert 語句。


接下來就是就是要修改MySQL的原始碼了,這裡主要是要考慮到MTS的並行複製邏輯。因為我們在主庫上透過DEBUG_SYNC讓大的事物先執行,所以比如是大的事物先分配到woker執行緒組中的第一個。所以我們在binlog回放的關鍵路徑上: Xid_apply_log_event::do_apply_event_worker 這個函式中讓第一個worker sleep足夠多的時間讓我們執行FTWRL。

直接修改原始碼編譯需要來回的編譯,我們這邊使用systemstap 這個工具,JIT在執行時注入一段程式碼來改變某些worker的行為。在執行注入前先執行指令碼驗證下能否注入:

41 --exec sudo stap -L 'process("$MYSQLD").function("pop_jobs_item")'
42 --exec sudo stap -L 'process("$MYSQLD").function("*Xid_apply_log_event::do_apply_event_worker")'

需要注意的是,因為stap的架構原理的原因,詳細可參考下面的連結[3],需要root許可權。下面是注入的程式碼:

stap -v -g -d $MYSQLD --ldd -e 'probe process($server_pid).function("Xid_apply_log_event    ::Xid_apply_log_event
") {printf("hit in do_apply_log_event\n") if ($w->id ==0) { mdelay(30000)} }'
stap -v -g -d $MYSQLD --ldd -e 'probe process($server_pid).function("pop_jobs_item") { printf("hit in
pop_jobs_item") if ($worker->id == 0) { mdelay(3000)} }'

大致的意思就是: 讓複製執行緒組的第一個執行緒sleep 3s。這樣有足夠的時間來執行FTWRL。最終的執行結果:

show full processlist;
Id	User	Host	db	Command	Time	State	Info
3	root	localhost:10868	test	Sleep	83		NULL
4	root	localhost:10870	test	Sleep	84		NULL
7	root	localhost:10922	test	Query	61	Waiting for commit lock	flush table with read lock
8	root	localhost:10926	test	Query	0	starting	show full processlist
9	system user		NULL	Connect	82	Waiting for master to send event	NULL
10	system user		NULL	Connect	61	Slave has read all relay log; waiting for more updates	NULL
11	system user		NULL	Connect	71	Waiting for global read lock	NULL
12	system user		NULL	Connect	71	Waiting for preceding transaction to commit	NULL
13	system user		NULL	Connect	82	Waiting for an event from Coordinator	NULL
14	system user		NULL	Connect	81	Waiting for an event from Coordinator	NULL

可以看到,我們的猜想完整的復現了死鎖。大致解釋下:

我們在構造這個死鎖的時候,因為我們控制 的worker會sleep 3s。故而我們可以查詢worker的狀態,當worker處於 Waiting for preceding transaction to commit 這個狀態的時候,立馬執行FTWRL。然後可以看到FTWRL會block在commit_lock。然後另外一個更新自然是要等待: global read lock, 而形成死鎖。

總結

首先對於不太理解備份原理的同學,應該可以從這兩個死鎖等待圖中清楚的看到FTWRL的作用。它是透過兩把GLOBAL READ LOCK 和COMMIT_LOCK鎖來控制備份的一致性。這裡不詳細討論。 解決死鎖問題,透過死鎖理論,肯定是要打破有向圖中的環。

在我們的這個死鎖case中透過分析可以知道可以操作的兩條邊只有: 

1. slave_preserve_commit_order
2. FTWRL 顯然:對於那些可以接受在從庫上事物的提交可以“亂序”的,我們只要關閉這個配置選項就可以解除死鎖

而如果是要強制要求有序的,那麼我們只能關閉備份的執行緒(圖中的節點,及相關的邊) 同樣可以破解死鎖。在死鎖出現的時候,個人覺得關閉備份執行緒程式碼是更小的。如果關閉worker執行緒的話,從庫複製會出錯誤。

參考

  1. https://dev.mysql.com/doc/relnotes/mysql/5.7/en/news-5-7-22.html

作者:龍利劍


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559758/viewspace-2675225/,如需轉載,請註明出處,否則將追究法律責任。

相關文章