http://mysql.taobao.org/monthly/2016/03/06/
背景
InnoDB buffer pool中的page管理牽涉到兩個鏈表,一個是lru鏈表,一個是flush 臟塊鏈表,由於數據庫的特性:
- 臟塊的刷新,是異步操作;
- page存在兩個版本,一個是ibd文件的持久化版本,和buffer pool內存中的當前版本。
所以在對table對象進行ddl變更的時候,要維護兩個版本之間的一致性,有一些操作需要同步進行page緩存的管理。例如以下三種ddl操作:
1. flush table t for export
這是MySQL 5.6提供的InnoDB transportable tablespace功能,用於在不同實例之間進行表傳輸。由於需要透明的在物理層面遷移ibd文件,所以需要保證buffer pool中的page和ibd文件中的page的一致性。其操作步驟如下:
- 持有t表的MDL鎖,保證在t表上沒有活躍事務,即buffer pool中的臟page都是已提交事務;
- 掃描buffer pool中的flush list,同步刷下臟塊;
- 記錄數據字典信息到cfg文件,用於目標端的表結構匹配和驗證,最后在目標端import的時候,變更page的space,max_lsn等。
2. drop table t
在對表進行刪除的時候,需要清理掉buffer pool中的page,但如果表比較大,占用過多的buffer pool,清理的動作會影響到在線的業務,所以MySQL提供了lazy drop table的方式。
-
同步方式: 掃描lru鏈表,如果page屬於t表,就從lru鏈表,hash表, flush list中刪除,回收block到free list中。
-
lazy方式: 掃描lru鏈表,如果page屬於t表,就給page設置一個
space_was_being_deleted
屬性,等lru置換或者checkpoint flush dirty block的時候進行清理。
3. alter table t rename to t1
rename table name操作,雖然是DDL,但rename操作只是變更了數據字典中的table name和文件系統的ibd文件名稱,所以,在rename的過程中,不存在對buffer pool中屬於t表的page的同步操作,但由於要變更表名,即需要同步對文件的IO操作。
而今天要講的主題,就發生在這里,由於rename table進行IO操作同步的過程中,產生的死鎖。
問題現象
在MySQL 5.5版本上,error日志大量報出以下的錯誤信息:
InnoDB: fil_sys open file LRU len 0 InnoDB: Warning: too many (300) files stay open while the maximum InnoDB: allowed value would be 300. InnoDB: You may need to raise the value of innodb_open_files in my.cnf. ... InnoDB: Warning: problems renaming 'db_1/#sql-xxx_xxx' to 'db_1/xxx', 1000 iterations InnoDB: Warning: tablespace './db_1/#sql-xxx_xxx.ibd' has i/o ops stopped for a long time 1000
查看操作日志,是一個普通的rename語句操作,但持續很久,因為rename只是數據字典的變更,除了MDL鎖阻塞以外
不應該持續這么長時間,pstack查看線程棧信息:
Thread 5 (Thread 0x50ad7940 (LWP 25047)): #0 0x000000364aacced2 in select () from /lib64/libc.so.6 #1 0x00002aaab2e595fb in os_thread_sleep () #2 0x00002aaab2e1a3e2 in fil_rename_tablespace () #3 0x00002aaab2e0672b in dict_table_rename_in_cache () #4 0x00002aaab2e86af5 in row_rename_table_for_mysql () #5 0x00002aaab2e316db in ha_innodb::rename_table () #6 0x00000000006bea6c in mysql_rename_table () #7 0x00000000006c77ff in mysql_alter_table () #8 0x00000000005c6a8e in mysql_execute_command () #9 0x00000000005cd371 in mysql_parse () #10 0x00000000005cd773 in dispatch_command () #11 0x00000000005cea04 in do_command () #12 0x00000000005bf0d7 in handle_one_connection () #13 0x000000364b6064a7 in start_thread () from /lib64/libpthread.so.0 #14 0x000000364aad3c2d in clone () from /lib64/libc.so.6 Thread 100 (Thread 0x42945940 (LWP 3870)): #0 0x000000364b60ab99 in pthread_cond_wait@@GLIBC_2.3.2 () #1 0x00002aaab2e589a5 in os_event_wait_low () #2 0x00002aaab2e57dd4 in os_aio_simulated_handle () #3 0x00002aaab2e14ccc in fil_aio_wait () #4 0x00002aaab2ea2418 in io_handler_thread () #5 0x000000364b6064a7 in start_thread () from /lib64/libpthread.so.0 #6 0x000000364aad3c2d in clone () from /lib64/libc.so.6 Thread 120 (Thread 0x40da6940 (LWP 3882)): #0 0x000000364aacced2 in select () from /lib64/libc.so.6 #1 0x00002aaab2e595fb in os_thread_sleep () #2 0x00002aaab2e18838 in fil_mutex_enter_and_prepare_for_io () #3 0x00002aaab2e18aa5 in fil_io () #4 0x00002aaab2df5b63 in buf_flush_buffered_writes () #5 0x00002aaab2df6048 in buf_flush_batch () #6 0x00002aaab2ea13d8 in srv_master_thread () #7 0x000000364b6064a7 in start_thread () from /lib64/libpthread.so.0 #8 0x000000364aad3c2d in clone () from /lib64/libc.so.6
這里我只列了有意義的三個線程:
- 用戶線程Thread 5
用戶線程確實在進行rename操作,但阻塞在fil_rename_tablespace
函數中。 - master線程Thread 120
InnoDB的master線程阻塞在fil_mutex_enter_and_prepare_for_io
函數中。 - IO線程Thread 100
InnoDB的IO線程一共有8個,4個讀,4個寫線程,發現都在os_event_wait_low
中,也就是都空閑着等待condition中。
從上面的調用棧來看,線程之間長時間維持在這種狀態下,明顯發生了死鎖,在我們解這個死鎖之前,我們先來回顧一點背景知識,然后再說明死鎖的真正原因。
InnoDB背景
checkpoint
由於對數據庫的數據操作也遵循read-update-write的方式,所以數據的更新,會把buffer pool中的page變成臟塊,由於write-ahead logs機制保證事務的完整性,臟塊的write可以變成異步的,但又由於buffer pool的大小終究有限,而且對於recovery的時間的要求,又要求臟塊的flush又要持續保證。
MySQL 5.5的版本由master thread來承擔dirty flush的角色, dirty flush的過程就稱為making checkpoint,lsn的推進保證了recovery的時間不被持續的變長。刷新的策略,受到當前IO pending的情況,double write-buffer是否打開,buffer pool中dirty page所占的比例,以及innodb_max_dirty_pages_pct
參數的設置,進行靈活刷新,具體的代碼細節,這里就不展開了。
異步IO
由於dirty flush是異步的,所以,master thread只負責提交IO請求,真正的IO操作是由IO helper thread來完成的。InnoDB使用的simulate AIO和native AIO會有一些差別,我們這里以simulate AIO為例進行說明。假設double write-buffer是打開的:
- 首先master thread搜集dirty pages,同步寫入double write-buffer;
- 由於double write-buffer的方式是buffered write,所以等double write-buffer寫滿了之后;
- 同步把double write-buffer的page順序寫入到ibdata系統表空間中,如果完成之后系統crash,可以使用持久化的double write-buffer進行page恢復;
- 開始把 double write-buffer中的page,寫入真正的ibd文件中。依次提交異步IO操作,提交IO操作的步驟分為:
- 持有fil_system mutex,判斷當前tablespace是否可用,
- 判斷當前fil_space的stop_io標示,如果設置就循環等待
- 如果stop_io沒有標示,就打開fil_space對應的ibd文件句柄,然后遞增 fil_space->n_pending
- 提交IO請求
- 等double write-buffer中的pages提交完所有的IO請求,使用
os_aio_simulated_wake_handler_threads
來喚醒IO helper thread來完成IO操作。
Rename 操作
接下來我們來看下rename操作的步驟:
- 首先在server層hold MDL鎖;
- 進入InnoDB層,首先使用自治事務變更數據字典,包括SYS_TABLES,SYS_FOREIGN;
- 變更數據字典的內存對象,包括table, index, foreign list等;
- 變更fil_space對象以及對應的ibd數據文件名稱,其中變更文件系統名稱的時候:
- 設置當前的fil_space的stop_io,阻止再進行IO操作
- 判斷當前是否有IO pending,如果有,就等IO pending結束
- 如果沒有IO pending,就關閉opened的句柄,並rename文件名稱
- 恢復stop_io標示
- 提交自治事務。
有了這些操作的具體步驟,我們就可以清晰的分析出死鎖的原因。
死鎖原因
兩個線程,一個是master thread,需要提交flush dirty block的異步IO請求;一個是user thread,需要進行rename操作。
Rename操作,只變更數據字典和ibd文件名,並不需要同步buffer pool中的page,唯一需要同步的就是IO操作,通俗一點說,也就是在user thread進行rename table需要變更ibd文件名的時候,其它線程暫時不要對這個文件進行IO操作,等rename完成后,可以重新打開這個ibd文件,接着進行IO操作。
InnoDB使用兩個標識來進行IO同步操作,即stop_io,n_pending。
stop_io:user thread要進行rename操作,提前設置這個標識,表示IO操作可以先hold暫停。
n_pending:master thread要進行flush操作,我已經提交了IO請求,user thread要進行rename可以先hold,等IO完成。
假設下面的時序:
- master thread提交了1個IO請求,設置了n_pending;
- rename操作設置stop_io,判斷n_pending>0 就等待;
- master thread需要提交剩下的幾個IO,發現stop_io已設置,就等待;
- 由於master thread沒有提交完這批IO,沒有喚醒IO helper thread,導致第1個IO請求無法完成,n_pending一直等於1;
- rename操作因為n_pending一直等於1,陷入了死等;
- master thread發現stop_io等於true,陷入了死等。
具體的代碼可以參考:
1. master thread
fil0fil.cc: fil_mutex_enter_and_prepare_for_io
space = fil_space_get_by_id(space_id);
if (space != NULL && space->stop_ios) { /* We are going to do a rename file and want to stop new i/o's for a while */ if (count2 > 20000) { fputs("InnoDB: Warning: tablespace ", stderr); ut_print_filename(stderr, space->name); fprintf(stderr, " has i/o ops stopped for a long time %lu\n", (ulong) count2); } mutex_exit(&fil_system->mutex); os_thread_sleep(20000); count2++; goto retry; }
2. user thread
fil0fil.cc: fil_rename_tablespace
/* We temporarily close the .ibd file because we do not trust that operating systems can rename an open file. For the closing we have to wait until there are no pending i/o's or flushes on the file. */ space->stop_ios = TRUE; ut_a(UT_LIST_GET_LEN(space->chain) == 1); node = UT_LIST_GET_FIRST(space->chain); if (node->n_pending > 0 || node->n_pending_flushes > 0) { /* There are pending i/o's or flushes, sleep for a while and retry */ mutex_exit(&fil_system->mutex); os_thread_sleep(20000); goto retry;
修復方法
修復的方法也比較簡單,在fil_rename_tablespace
的時候,如果發現node->n_pending > 0的時候,在sleep之前,發起一次喚醒動作,即os_aio_simulated_wake_handler_threads
,IO helper thread去完成master thread已經提交的IO請求,這樣n_pending就會降到0,死鎖就解開了。