mysqldump一致性熱備原理剖析
引言
在日常數據庫運維中,經常要對數據庫進行熱備。熱備的一個關鍵點是保證數據的一致性,即在備份進行時發生的數據更改,不會在備份結果中出現。mysqldump是實際場景中最常使用的備份工具之一,通過選擇合適的選項做備份,mysqldump可以保證數據的一致性,同時盡可能保證進行中的業務不受影響。
那么mysqldump是如何實現一致性備份的?以下我將結合mysqldump過程中mysqld生成的general log與mysqldump的源碼來解釋mysqldump一致性備份的原理。
注:以下的實例基于MySQL 8.0.18,在不同版本上mysqldump的部分實現會有不同
首先用mysqldump執行一次一致性備份:
- $ mysqldump -uroot -p --skip-opt --default-character-set=utf8 --single-transaction --master-data=2 --no-autocommit -B d1> backup.sql
關鍵參數解釋:
- --single-transaction:執行一致性備份。
- --master-data=2:要求dump結果中以注釋形式保存備份時的binlog位置信息。
- -B:指定要dump的數據庫,在這里d1是一個使用InnoDB作為存儲引擎的庫,其中只有一個表t1。
執行完成后可以得到mysqld生成的general log,里面記錄了mysqldump在備份過程中傳給server的指令。
其中關鍵的步驟我用框框作了標記,具體的解釋請看下文。
mysqldump一致性備份的主要執行流程
- 連接server
- 兩次關閉所有表,第二次關表同時加讀鎖
- 設置隔離級別為“可重復讀”,開始事務并創建快照
- 獲取當前binlog位置
- 解鎖所有表
- 對指定的庫與表進行dump
下面結合SQL內容與源碼對以上主要步驟進行依次介紹。
流程剖析
1. 連接server
mysqldump首先與server建立連接,并初始化session,set一些session級的變量,對應SQL如下圖
其在main函數中對應的源碼就是一個對connect_to_db函數的調用:
- if (connect_to_db(current_host, current_user, opt_password)) {
- free_resources();
- exit(EX_MYSQLERR);
2. 兩次關閉所有表,第二次關表同時加讀鎖
連接建立后,mysqldump緊接著執行兩次關表操作,并在第二次關表同時給所有表加上讀鎖,對應SQL如下圖:
這一部分在main函數中對應的源碼為:
- if ((opt_lock_all_tables || opt_master_data ||
- (opt_single_transaction && flush_logs)) &&
- do_flush_tables_read_lock(mysql))
- goto err;
可以看到實際操作由do_flush_tables_read_lock函數進行,但是這里需要注意操作執行的前提條件,觀察代碼我們可以知道,這個關表操作只會在三種情況下進行:
- 通過--lock-all-tables選項顯式要求給所有表加鎖。
- 通過--master-data選項要求dump出來的結果中包含binlog位置。
- 通過--single-transaction指定了進行單事務的一致性備份,同時通過--flush-logs要求刷新log文件。
看到這里不難知道,除了第一種情況顯式要求加鎖之外,情況3要求刷新log前沒有其他事務在進行寫操作,自然要對所有表加上讀鎖。情況2要求dump結果中準確記錄dump進行時刻的binlog位置,為了準確地得到當前binlog的位置,自然就需要給所有的表加共享鎖,防止其他并行事務進行寫操作導致binlog更新,因此這里才有一個關表、加讀鎖的動作。
這里有一個細節,我們知道--single-transaction選項可以執行一致性備份,那么在只有--single-transaction選項時為什么不需要進行關表與加讀鎖的動作呢?這是因為--single-transaction所保證的一致性備份依賴于支持事務的存儲引擎(如InnoDB),在后面會提到,mysqldump通過執行START TRANSACTION WITH CONSISTENT SNAPSHOT會創建一個數據庫當前的快照與一個事務id,所有在該事務之后的事務所進行的數據更新都會被過濾,以此來保證備份的一致性。這種方式的優勢在于不會在進行一致性備份時干擾其他事務的正常進行,實現了所謂的“熱備”,但是缺點在于其依賴事務型存儲引擎,對于使用MyISAM等不支持事務的存儲引擎的表,--single-transaction無法保證它們的數據一致性。
接著查看do_flush_tables_read_lock函數的源碼:
- static int do_flush_tables_read_lock(MYSQL *mysql_con) {
- return (mysql_query_with_error_report(
- mysql_con, 0,
- ((opt_master_data != 0) ? "FLUSH /*!40101 LOCAL */ TABLES"
- : "FLUSH TABLES")) ||
- mysql_query_with_error_report(mysql_con, 0,
- "FLUSH TABLES WITH READ LOCK"));
- }
可以看到邏輯比較簡單,就是向server傳入執行兩個query,依先后次序分別時FLUSH TABLES和FLUSH TABLES WITH READ LOCK,這里核心的動作在于后面一個query,之所以需要前面的FLUSH TABLES是基于性能的考量,以盡可能減少加鎖對其他事務的影響。
3. 設置隔離級別為“可重復讀”,開始事務并創建快照
關表操作執行完后,mysqldump接著開啟一個新事務并創建快照,對應SQL如下圖:
這一部分在main函數中對應的源碼為:
- if (opt_single_transaction && start_transaction(mysql)) goto err;
可以看到,只有在指定--single-transaction選項時這一步驟才會執行。實際上這一步就是mysqldump實現一致性熱備的基礎,我們接著查看start_transaction函數的源碼:
- static int start_transaction(MYSQL *mysql_con) {
- // 省略部分非關鍵代碼與注釋
- return (
- mysql_query_with_error_report(mysql_con, 0,
- "SET SESSION TRANSACTION ISOLATION "
- "LEVEL REPEATABLE READ") ||
- mysql_query_with_error_report(mysql_con, 0,
- "START TRANSACTION "
- "/*!40100 WITH CONSISTENT SNAPSHOT */"));
- }
可以看到核心動作是傳給server執行的兩個query,先是SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ確保當前會話的隔離級別是“可重復讀”,然后通過START TRANSACTION /*!40100 WITH CONSISTENT SNAPSHOT */來開始一個新事務,產生一個新事務id,同時創建一個快照,dump過程中所使用的數據都基于這個快照。這樣,所有在該事務之后的事務所進行的數據更新都會被過濾,備份的數據一致性因此得以保證。
但是,這樣的熱備方法,依賴于像InnoDB這樣支持事務的存儲引擎。相反,如MyISAM這種不支持事務的存儲引擎在備份過程中的數據一致性則不能被保證。
4. 獲取當前binlog位置
隨后mysqldump執行一個SHOW MASTER STATUS的query,以獲取當前binlog的位置信息:
查看main函數中對應部分的源碼可以看到,只有在指定--master-data選項時才會去獲取、記錄當前的binlog位置:
- if (opt_master_data && do_show_master_status(mysql)) goto err;
查看do_show_master_status函數的實現,可以看到核心動作就是向server傳入執行一個SHOW MASTER STATUS的query,最后將得到的binlog位置信息寫入dump結果中。
- static int do_show_master_status(MYSQL *mysql_con) {
- MYSQL_ROW row;
- MYSQL_RES *master;
- const char *comment_prefix =
- (opt_master_data == MYSQL_OPT_MASTER_DATA_COMMENTED_SQL) ? "-- " : "";
- if (mysql_query_with_error_report(mysql_con, &master, "SHOW MASTER STATUS")) {
- return 1;
- } else {
- row = mysql_fetch_row(master);
- if (row && row[0] && row[1]) {
- print_comment(md_result_file, 0,
- "\n--\n-- Position to start replication or point-in-time "
- "recovery from\n--\n\n");
- // 寫入dump結果
- fprintf(md_result_file,
- "%sCHANGE MASTER TO MASTER_LOG_FILE='%s', MASTER_LOG_POS=%s;\n",
- comment_prefix, row[0], row[1]);
- check_io(md_result_file);
- }
- // ...
- }
- return 0;
- }
5. 解鎖所有表
在正式開始dump操作之前,mysqldump會把前面操作中可能加了鎖的表全部解鎖:
查看main函數中對應部分代碼:
- if (opt_single_transaction &&
- do_unlock_tables(mysql)) /* unlock but no commit! */
- goto err;
可以看到,只有在指定了--single-transaction選項時才會解鎖所有先前被加鎖的表,結合前面的思考可以推斷,--single-transaction下所進行的備份通過事務性質可以保證數據的一致性,沒有必要再保留對所有表所加的鎖,因此這里執行解鎖,以免阻塞其他事務的進行。
6. 對指定的庫與表進行dump
前面的準備操作進行完成后,mysqldump開始正式進行選定庫、表的dump操作:
對指定數據庫的實際dump由dump_databases函數執行(當指定了--all-databases要求dump所有庫時,則由dump_all_databases函數執行)。
查看dump_databases函數的實現:
- static int dump_databases(char **db_names) {
- int result = 0;
- char **db;
- DBUG_TRACE;
- for (db = db_names; *db; db++) {
- if (is_infoschema_db(*db))
- die(EX_USAGE, "Dumping \'%s\' DB content is not supported", *db);
- if (dump_all_tables_in_db(*db)) result = 1;
- }
- if (!result && seen_views) {
- for (db = db_names; *db; db++) {
- if (dump_all_views_in_db(*db)) result = 1;
- }
- }
- return result;
- } /* dump_databases */
邏輯比較清晰,先dump每個指定的數據庫中所有的表,之后如果存在視圖,則將對應視圖也進行dump。我們的考察重點放在對表的dump上。
實際dump一個表的操作邏輯也比較清晰,就是先獲取表的結構信息,得到表的創建語句,然后獲取表中每行的實際數據并生成對應的insert語句。
不過,前面的general log中有個值得注意的點是SAVEPOINT的出現,這一點在MySQL 5.5的mysqldump中是沒有的,查看dump_all_tables_in_db函數的實現,可以找到設置savepoint的對應代碼:
- // 創建savepoint
- if (opt_single_transaction && mysql_get_server_version(mysql) >= 50500) {
- verbose_msg("-- Setting savepoint...\n");
- if (mysql_query_with_error_report(mysql, 0, "SAVEPOINT sp")) return 1;
- }
- while ((table = getTableName(0))) {
- char *end = my_stpcpy(afterdot, table);
- if (include_table(hash_key, end - hash_key)) {
- dump_table(table, database); // 對表進行dump
- // 省略部分代碼...
- // ROLLBACK操作
- /**
- ROLLBACK TO SAVEPOINT in --single-transaction mode to release metadata
- lock on table which was already dumped. This allows to avoid blocking
- concurrent DDL on this table without sacrificing correctness, as we
- won't access table second time and dumps created by --single-transaction
- mode have validity point at the start of transaction anyway.
- Note that this doesn't make --single-transaction mode with concurrent
- DDL safe in general case. It just improves situation for people for whom
- it might be working.
- */
- if (opt_single_transaction && mysql_get_server_version(mysql) >= 50500) {
- verbose_msg("-- Rolling back to savepoint sp...\n");
- if (mysql_query_with_error_report(mysql, 0, "ROLLBACK TO SAVEPOINT sp"))
- maybe_exit(EX_MYSQLERR);
- }
可以看到創建savepoint是在dump表之前,之后遍歷庫中的每個表,每當dump完一個表之后,便執行一次ROLLBACK TO SAVEPOINT sp操作,為什么呢?其實上面代碼的注釋已經解釋清楚了:
簡單來說,當我們dump完一個表后后面都不再需要使用這個表,這時其他事務的DDL操作不會影響我們dump得到數據的正確性,增加savepoint的意義在于,假如我們要dump表A,savepoint記錄了dump表A之前尚未給表A加MDL鎖的狀態,當開始dump表A時,由于要進行一系列select操作,會給表A加上MDL鎖防止其他事務的DDL操作改變表結構導致讀動作出錯;最后當對表A的dump完成后,后續都不會再訪問表A了,此時沒有釋放的MDL鎖沒有意義,反而會阻塞其他并行事務對表A的DDL操作。
對此,MySQL的解決方法是在訪問表A前通過SAVEPOINT sp記錄一個savepoint,在dump完表A之后通過ROLLBACK TO SAVEPOINT sp回到當時的狀態,即可釋放對表A加的MDL鎖,放行其他事務對該表的DDL操作。
小結
以上是mysqldump基于MySQL 8.0的一致性備份原理介紹,相比MySQL 5.5,現如今MySQL 8.0在mysqldump的實現存在一定改進,除了上面提到的savepoint機制是一個顯著區別之外,還有諸如對GTID的支持、對column statistics的dump操作在本文中沒有提及,但總體而言,mysqldump在一致性備份上的實現原理并沒有多少改變。
拓展閱讀——Percona的實現
MySQL從出現到普及,中途也出現了其他不少優秀的發行版,MySQL中一致性備份的實現其實也并不完美,因此如果能夠考量其他發行版在這方面上的實現,也是一件有意義的事情。
Backup Lock
在前面我有提到,mysqldump中--single-transaction選項所實現的一致性備份不需要對表加鎖,但這一特性基于事務型的存儲引擎,因此只對InnoDB表或使用其他事務型存儲引擎類型的表能夠保證備份時過濾掉其他并行事務的更新操作;但對使用了MyISAM這種不支持事務的存儲引擎的表,--single-transaction無法保證其數據的一致性,即若備份過程中出現了來自其他并行事務的更新操作,其很有可能被寫入了備份中。
既然如此,若想對MyISAM的表進行備份,又想保證其一致性該怎么辦?一種方式可以是在執行mysqldump時傳入--lock-all-tables選項,這個選項會使得dump操作進行之前執行一個FLUSH TABLES WITH READ LOCK語句,并保證在dump的全程保持對所有表的讀鎖。但是無疑這是一種overkill,僅僅是為了保證一部分非事務型存儲引擎的表的一致性,就需要對所有表加鎖,進而業務上所有對server的寫操作被阻塞一段時間(若備份的數據量大,這簡直會造成一場災難)。
這一問題,我尚未在MySQL 8.0中找到相應的好的解決方式,不過Percona對此給出了一個方案:在Percona發行版的mysqldump中,執行時可以傳入一個--lock-for-backup選項,這個選項會使得mysqldump在dump之前,執行一個LOCK TABLES FOR BACKUP語句,這是一個Percona獨有的query,其主要做以下幾件事情:
- 阻塞對MyISAM, MEMORY, CSV, ARCHIVE表的更新操作;
- 阻塞對任何表的DDL操作;
- 不阻塞對臨時表與log表的更新操作。
顯然,有了以上的特性,當同時傳入--lock-for-backup與--single-transaction兩個選項同時,mysqldump可以保證所有表的數據一致性,并且盡可能保證造成最少的線上業務干擾。
這一部分邏輯可以在Percona Server 8.0中mysqldump的代碼中找到,在main函數中:
- if (opt_lock_all_tables ||
- (opt_master_data &&
- (!has_consistent_binlog_pos || !has_consistent_gtid_executed)) ||
- (opt_single_transaction && flush_logs)) {
- if (do_flush_tables_read_lock(mysql)) goto err;
- ftwrl_done = true;
- } else if (opt_lock_for_backup && do_lock_tables_for_backup(mysql))
- goto err;
細心的朋友會發現,這是對上面的“關表加讀鎖操作”進行的邏輯改寫,其增加了一個else if邏輯分支,取代了之前的FLUSH TABLES; FLUSH TABLES WITH READ LOCK;操作,主要目的是為了與--single-transaction進行的一致性備份更好地兼容,實現對線上業務盡可能少的阻塞。
接著查看do_lock_tables_for_backup函數的實現,可以看到就是簡單地向server傳入一個Percona獨有的LOCK TABLES FOR BACKUP語句:
- static int do_lock_tables_for_backup(MYSQL *mysql_con) noexcept {
- return mysql_query_with_error_report(mysql_con, 0, "LOCK TABLES FOR BACKUP");
- }
Binlog Snapshot
在MySQL 8.0的實現中,有一個常用的選項,仍然會導致“討人厭”的FLUSH TABLES WITH READ LOCK的執行,即--master-data選項。
前面提到,--master-data選項要求在dump之后的結果中存有當前備份開始時的binlog位置,為了滿足所獲得binlog位置的一致性,需要在執行SHOW MASTER STATUS前,獲取對所有表的讀鎖以阻塞所有binlog的提交事件,因此要求執行一次FLUSH TABLES WITH READ LOCK。但是有沒有更好的方式?Percona同樣給出了自己的解決方法。
在Percona Server中,新增了兩個全局status:Binlog_snapshot_file和Binlog_snapshot_pos,分別用來記錄當前的binlog文件與binlog位置,通過SHOW STATUS LIKE 'binlog_snapshot_%'即可獲取兩個status的值。那么使用這個方式,跟SHOW MASTER STATUS有什么區別?
二者的區別在于,Binlog_snapshot_file和Binlog_snapshot_pos這兩個status具有事務性,只要在執行SHOW STATUS LIKE 'binlog_snapshot_%'這個語句之前通過START TRANSACTION WITH CONSISTENT SNAPSHOT創建了新事務與一致性快照,Binlog_snapshot_file和Binlog_snapshot_pos所記錄的則正是該事務開始時的binlog文件與位置信息,進而binlog信息的一致性得到保證,而這一過程的全程都不需要FLUSH TABLES WITH READ LOCK的執行。
相對的,SHOW MASTER STATUS是不具備事務性的,每次執行該語句返回的都是當前最新的binlog位置信息,這也是為什么執行它之前需要對所有表上讀鎖。