mysqldump一致性熱備原理剖析
引言
在日常數(shù)據(jù)庫運(yùn)維中,經(jīng)常要對數(shù)據(jù)庫進(jìn)行熱備。熱備的一個關(guān)鍵點(diǎn)是保證數(shù)據(jù)的一致性,即在備份進(jìn)行時發(fā)生的數(shù)據(jù)更改,不會在備份結(jié)果中出現(xiàn)。mysqldump是實(shí)際場景中最常使用的備份工具之一,通過選擇合適的選項(xiàng)做備份,mysqldump可以保證數(shù)據(jù)的一致性,同時盡可能保證進(jìn)行中的業(yè)務(wù)不受影響。
那么mysqldump是如何實(shí)現(xiàn)一致性備份的?以下我將結(jié)合mysqldump過程中mysqld生成的general log與mysqldump的源碼來解釋mysqldump一致性備份的原理。
注:以下的實(shí)例基于MySQL 8.0.18,在不同版本上mysqldump的部分實(shí)現(xiàn)會有不同
首先用mysqldump執(zhí)行一次一致性備份:
- $ mysqldump -uroot -p --skip-opt --default-character-set=utf8 --single-transaction --master-data=2 --no-autocommit -B d1> backup.sql
關(guān)鍵參數(shù)解釋:
- --single-transaction:執(zhí)行一致性備份。
- --master-data=2:要求dump結(jié)果中以注釋形式保存?zhèn)浞輹r的binlog位置信息。
- -B:指定要dump的數(shù)據(jù)庫,在這里d1是一個使用InnoDB作為存儲引擎的庫,其中只有一個表t1。
執(zhí)行完成后可以得到mysqld生成的general log,里面記錄了mysqldump在備份過程中傳給server的指令。
其中關(guān)鍵的步驟我用框框作了標(biāo)記,具體的解釋請看下文。
mysqldump一致性備份的主要執(zhí)行流程
- 連接server
- 兩次關(guān)閉所有表,第二次關(guān)表同時加讀鎖
- 設(shè)置隔離級別為“可重復(fù)讀”,開始事務(wù)并創(chuàng)建快照
- 獲取當(dāng)前binlog位置
- 解鎖所有表
- 對指定的庫與表進(jìn)行dump
下面結(jié)合SQL內(nèi)容與源碼對以上主要步驟進(jìn)行依次介紹。
流程剖析
1. 連接server
mysqldump首先與server建立連接,并初始化session,set一些session級的變量,對應(yīng)SQL如下圖
其在main函數(shù)中對應(yīng)的源碼就是一個對connect_to_db函數(shù)的調(diào)用:
- if (connect_to_db(current_host, current_user, opt_password)) {
- free_resources();
- exit(EX_MYSQLERR);
2. 兩次關(guān)閉所有表,第二次關(guān)表同時加讀鎖
連接建立后,mysqldump緊接著執(zhí)行兩次關(guān)表操作,并在第二次關(guān)表同時給所有表加上讀鎖,對應(yīng)SQL如下圖:
這一部分在main函數(shù)中對應(yīng)的源碼為:
- if ((opt_lock_all_tables || opt_master_data ||
- (opt_single_transaction && flush_logs)) &&
- do_flush_tables_read_lock(mysql))
- goto err;
可以看到實(shí)際操作由do_flush_tables_read_lock函數(shù)進(jìn)行,但是這里需要注意操作執(zhí)行的前提條件,觀察代碼我們可以知道,這個關(guān)表操作只會在三種情況下進(jìn)行:
- 通過--lock-all-tables選項(xiàng)顯式要求給所有表加鎖。
- 通過--master-data選項(xiàng)要求dump出來的結(jié)果中包含binlog位置。
- 通過--single-transaction指定了進(jìn)行單事務(wù)的一致性備份,同時通過--flush-logs要求刷新log文件。
看到這里不難知道,除了第一種情況顯式要求加鎖之外,情況3要求刷新log前沒有其他事務(wù)在進(jìn)行寫操作,自然要對所有表加上讀鎖。情況2要求dump結(jié)果中準(zhǔn)確記錄dump進(jìn)行時刻的binlog位置,為了準(zhǔn)確地得到當(dāng)前binlog的位置,自然就需要給所有的表加共享鎖,防止其他并行事務(wù)進(jìn)行寫操作導(dǎo)致binlog更新,因此這里才有一個關(guān)表、加讀鎖的動作。
這里有一個細(xì)節(jié),我們知道--single-transaction選項(xiàng)可以執(zhí)行一致性備份,那么在只有--single-transaction選項(xiàng)時為什么不需要進(jìn)行關(guān)表與加讀鎖的動作呢?這是因?yàn)?-single-transaction所保證的一致性備份依賴于支持事務(wù)的存儲引擎(如InnoDB),在后面會提到,mysqldump通過執(zhí)行START TRANSACTION WITH CONSISTENT SNAPSHOT會創(chuàng)建一個數(shù)據(jù)庫當(dāng)前的快照與一個事務(wù)id,所有在該事務(wù)之后的事務(wù)所進(jìn)行的數(shù)據(jù)更新都會被過濾,以此來保證備份的一致性。這種方式的優(yōu)勢在于不會在進(jìn)行一致性備份時干擾其他事務(wù)的正常進(jìn)行,實(shí)現(xiàn)了所謂的“熱備”,但是缺點(diǎn)在于其依賴事務(wù)型存儲引擎,對于使用MyISAM等不支持事務(wù)的存儲引擎的表,--single-transaction無法保證它們的數(shù)據(jù)一致性。
接著查看do_flush_tables_read_lock函數(shù)的源碼:
- 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傳入執(zhí)行兩個query,依先后次序分別時FLUSH TABLES和FLUSH TABLES WITH READ LOCK,這里核心的動作在于后面一個query,之所以需要前面的FLUSH TABLES是基于性能的考量,以盡可能減少加鎖對其他事務(wù)的影響。
3. 設(shè)置隔離級別為“可重復(fù)讀”,開始事務(wù)并創(chuàng)建快照
關(guān)表操作執(zhí)行完后,mysqldump接著開啟一個新事務(wù)并創(chuàng)建快照,對應(yīng)SQL如下圖:
這一部分在main函數(shù)中對應(yīng)的源碼為:
- if (opt_single_transaction && start_transaction(mysql)) goto err;
可以看到,只有在指定--single-transaction選項(xiàng)時這一步驟才會執(zhí)行。實(shí)際上這一步就是mysqldump實(shí)現(xiàn)一致性熱備的基礎(chǔ),我們接著查看start_transaction函數(shù)的源碼:
- static int start_transaction(MYSQL *mysql_con) {
- // 省略部分非關(guān)鍵代碼與注釋
- 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執(zhí)行的兩個query,先是SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ確保當(dāng)前會話的隔離級別是“可重復(fù)讀”,然后通過START TRANSACTION /*!40100 WITH CONSISTENT SNAPSHOT */來開始一個新事務(wù),產(chǎn)生一個新事務(wù)id,同時創(chuàng)建一個快照,dump過程中所使用的數(shù)據(jù)都基于這個快照。這樣,所有在該事務(wù)之后的事務(wù)所進(jìn)行的數(shù)據(jù)更新都會被過濾,備份的數(shù)據(jù)一致性因此得以保證。
但是,這樣的熱備方法,依賴于像InnoDB這樣支持事務(wù)的存儲引擎。相反,如MyISAM這種不支持事務(wù)的存儲引擎在備份過程中的數(shù)據(jù)一致性則不能被保證。
4. 獲取當(dāng)前binlog位置
隨后mysqldump執(zhí)行一個SHOW MASTER STATUS的query,以獲取當(dāng)前binlog的位置信息:
查看main函數(shù)中對應(yīng)部分的源碼可以看到,只有在指定--master-data選項(xiàng)時才會去獲取、記錄當(dāng)前的binlog位置:
- if (opt_master_data && do_show_master_status(mysql)) goto err;
查看do_show_master_status函數(shù)的實(shí)現(xiàn),可以看到核心動作就是向server傳入執(zhí)行一個SHOW MASTER STATUS的query,最后將得到的binlog位置信息寫入dump結(jié)果中。
- 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結(jié)果
- 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函數(shù)中對應(yīng)部分代碼:
- if (opt_single_transaction &&
- do_unlock_tables(mysql)) /* unlock but no commit! */
- goto err;
可以看到,只有在指定了--single-transaction選項(xiàng)時才會解鎖所有先前被加鎖的表,結(jié)合前面的思考可以推斷,--single-transaction下所進(jìn)行的備份通過事務(wù)性質(zhì)可以保證數(shù)據(jù)的一致性,沒有必要再保留對所有表所加的鎖,因此這里執(zhí)行解鎖,以免阻塞其他事務(wù)的進(jìn)行。
6. 對指定的庫與表進(jìn)行dump
前面的準(zhǔn)備操作進(jìn)行完成后,mysqldump開始正式進(jìn)行選定庫、表的dump操作:
對指定數(shù)據(jù)庫的實(shí)際dump由dump_databases函數(shù)執(zhí)行(當(dāng)指定了--all-databases要求dump所有庫時,則由dump_all_databases函數(shù)執(zhí)行)。
查看dump_databases函數(shù)的實(shí)現(xiàn):
- 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每個指定的數(shù)據(jù)庫中所有的表,之后如果存在視圖,則將對應(yīng)視圖也進(jìn)行dump。我們的考察重點(diǎn)放在對表的dump上。
實(shí)際dump一個表的操作邏輯也比較清晰,就是先獲取表的結(jié)構(gòu)信息,得到表的創(chuàng)建語句,然后獲取表中每行的實(shí)際數(shù)據(jù)并生成對應(yīng)的insert語句。
不過,前面的general log中有個值得注意的點(diǎn)是SAVEPOINT的出現(xiàn),這一點(diǎn)在MySQL 5.5的mysqldump中是沒有的,查看dump_all_tables_in_db函數(shù)的實(shí)現(xiàn),可以找到設(shè)置savepoint的對應(yīng)代碼:
- // 創(chuàng)建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); // 對表進(jìn)行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);
- }
可以看到創(chuàng)建savepoint是在dump表之前,之后遍歷庫中的每個表,每當(dāng)dump完一個表之后,便執(zhí)行一次ROLLBACK TO SAVEPOINT sp操作,為什么呢?其實(shí)上面代碼的注釋已經(jīng)解釋清楚了:
簡單來說,當(dāng)我們dump完一個表后后面都不再需要使用這個表,這時其他事務(wù)的DDL操作不會影響我們dump得到數(shù)據(jù)的正確性,增加savepoint的意義在于,假如我們要dump表A,savepoint記錄了dump表A之前尚未給表A加MDL鎖的狀態(tài),當(dāng)開始dump表A時,由于要進(jìn)行一系列select操作,會給表A加上MDL鎖防止其他事務(wù)的DDL操作改變表結(jié)構(gòu)導(dǎo)致讀動作出錯;最后當(dāng)對表A的dump完成后,后續(xù)都不會再訪問表A了,此時沒有釋放的MDL鎖沒有意義,反而會阻塞其他并行事務(wù)對表A的DDL操作。
對此,MySQL的解決方法是在訪問表A前通過SAVEPOINT sp記錄一個savepoint,在dump完表A之后通過ROLLBACK TO SAVEPOINT sp回到當(dāng)時的狀態(tài),即可釋放對表A加的MDL鎖,放行其他事務(wù)對該表的DDL操作。
小結(jié)
以上是mysqldump基于MySQL 8.0的一致性備份原理介紹,相比MySQL 5.5,現(xiàn)如今MySQL 8.0在mysqldump的實(shí)現(xiàn)存在一定改進(jìn),除了上面提到的savepoint機(jī)制是一個顯著區(qū)別之外,還有諸如對GTID的支持、對column statistics的dump操作在本文中沒有提及,但總體而言,mysqldump在一致性備份上的實(shí)現(xiàn)原理并沒有多少改變。
拓展閱讀——Percona的實(shí)現(xiàn)
MySQL從出現(xiàn)到普及,中途也出現(xiàn)了其他不少優(yōu)秀的發(fā)行版,MySQL中一致性備份的實(shí)現(xiàn)其實(shí)也并不完美,因此如果能夠考量其他發(fā)行版在這方面上的實(shí)現(xiàn),也是一件有意義的事情。
Backup Lock
在前面我有提到,mysqldump中--single-transaction選項(xiàng)所實(shí)現(xiàn)的一致性備份不需要對表加鎖,但這一特性基于事務(wù)型的存儲引擎,因此只對InnoDB表或使用其他事務(wù)型存儲引擎類型的表能夠保證備份時過濾掉其他并行事務(wù)的更新操作;但對使用了MyISAM這種不支持事務(wù)的存儲引擎的表,--single-transaction無法保證其數(shù)據(jù)的一致性,即若備份過程中出現(xiàn)了來自其他并行事務(wù)的更新操作,其很有可能被寫入了備份中。
既然如此,若想對MyISAM的表進(jìn)行備份,又想保證其一致性該怎么辦?一種方式可以是在執(zhí)行mysqldump時傳入--lock-all-tables選項(xiàng),這個選項(xiàng)會使得dump操作進(jìn)行之前執(zhí)行一個FLUSH TABLES WITH READ LOCK語句,并保證在dump的全程保持對所有表的讀鎖。但是無疑這是一種overkill,僅僅是為了保證一部分非事務(wù)型存儲引擎的表的一致性,就需要對所有表加鎖,進(jìn)而業(yè)務(wù)上所有對server的寫操作被阻塞一段時間(若備份的數(shù)據(jù)量大,這簡直會造成一場災(zāi)難)。
這一問題,我尚未在MySQL 8.0中找到相應(yīng)的好的解決方式,不過Percona對此給出了一個方案:在Percona發(fā)行版的mysqldump中,執(zhí)行時可以傳入一個--lock-for-backup選項(xiàng),這個選項(xiàng)會使得mysqldump在dump之前,執(zhí)行一個LOCK TABLES FOR BACKUP語句,這是一個Percona獨(dú)有的query,其主要做以下幾件事情:
- 阻塞對MyISAM, MEMORY, CSV, ARCHIVE表的更新操作;
- 阻塞對任何表的DDL操作;
- 不阻塞對臨時表與log表的更新操作。
顯然,有了以上的特性,當(dāng)同時傳入--lock-for-backup與--single-transaction兩個選項(xiàng)同時,mysqldump可以保證所有表的數(shù)據(jù)一致性,并且盡可能保證造成最少的線上業(yè)務(wù)干擾。
這一部分邏輯可以在Percona Server 8.0中mysqldump的代碼中找到,在main函數(shù)中:
- 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;
細(xì)心的朋友會發(fā)現(xiàn),這是對上面的“關(guān)表加讀鎖操作”進(jìn)行的邏輯改寫,其增加了一個else if邏輯分支,取代了之前的FLUSH TABLES; FLUSH TABLES WITH READ LOCK;操作,主要目的是為了與--single-transaction進(jìn)行的一致性備份更好地兼容,實(shí)現(xiàn)對線上業(yè)務(wù)盡可能少的阻塞。
接著查看do_lock_tables_for_backup函數(shù)的實(shí)現(xiàn),可以看到就是簡單地向server傳入一個Percona獨(dú)有的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的實(shí)現(xiàn)中,有一個常用的選項(xiàng),仍然會導(dǎo)致“討人厭”的FLUSH TABLES WITH READ LOCK的執(zhí)行,即--master-data選項(xiàng)。
前面提到,--master-data選項(xiàng)要求在dump之后的結(jié)果中存有當(dāng)前備份開始時的binlog位置,為了滿足所獲得binlog位置的一致性,需要在執(zhí)行SHOW MASTER STATUS前,獲取對所有表的讀鎖以阻塞所有binlog的提交事件,因此要求執(zhí)行一次FLUSH TABLES WITH READ LOCK。但是有沒有更好的方式?Percona同樣給出了自己的解決方法。
在Percona Server中,新增了兩個全局status:Binlog_snapshot_file和Binlog_snapshot_pos,分別用來記錄當(dāng)前的binlog文件與binlog位置,通過SHOW STATUS LIKE 'binlog_snapshot_%'即可獲取兩個status的值。那么使用這個方式,跟SHOW MASTER STATUS有什么區(qū)別?
二者的區(qū)別在于,Binlog_snapshot_file和Binlog_snapshot_pos這兩個status具有事務(wù)性,只要在執(zhí)行SHOW STATUS LIKE 'binlog_snapshot_%'這個語句之前通過START TRANSACTION WITH CONSISTENT SNAPSHOT創(chuàng)建了新事務(wù)與一致性快照,Binlog_snapshot_file和Binlog_snapshot_pos所記錄的則正是該事務(wù)開始時的binlog文件與位置信息,進(jìn)而binlog信息的一致性得到保證,而這一過程的全程都不需要FLUSH TABLES WITH READ LOCK的執(zhí)行。
相對的,SHOW MASTER STATUS是不具備事務(wù)性的,每次執(zhí)行該語句返回的都是當(dāng)前最新的binlog位置信息,這也是為什么執(zhí)行它之前需要對所有表上讀鎖。