記一次MySQL主從雙寫導致的資料丟失問題
記一次MySQL主從雙寫導致的資料丟失問題
目錄
1. 問題起源
不久前使用者反饋部門的MySQL資料庫發生了資料更新丟失。為了解決這個問題,當時對使用者使用的場景進行了分析。發現可能是因為使用者在兩臺互為主從的機器上都進行了寫入導致的資料丟失。
如圖所示,是正常和異常情況下應用寫入資料庫的示例。隨後在更加深入調查問題的過程中,DBA發現了故障引起資料丟失的原因:
如圖1-2 所示為故障具體過程的還原。從圖中可以看出在第3步DP上的寫入操作,在恢復DA到DP的同步之後,覆蓋了第4步DA上的寫入。因此導致了最終兩臺機器資料不一致,並且有一部分資料更新丟失。
在這裡相信讀者都會有一個疑問, 在第4步之後資料變成了(id : 1 ,name : name4),那麼第3步操作的時候寫入的語句是update t20200709 set name = 'name3' where id =1 and name='name2',在第5步恢復同步的時候這條語句在DA上重放應該不會被成功執行,畢竟where條件都不匹配了。而且在DP產生的binlog中,確實也記錄了SQL語句的where條件,無論從哪個角度上來看第3步的SQL語句都不應該被重放成功。
### UPDATE `test`.`t20200709` ### WHERE ### @1=1 /* INT meta=0 nullable=0 is_null=0 */ ### @2='name2' /* VARSTRING(255) meta=255 nullable=1 is_null=0 */ ### SET ### @1=1 /* INT meta=0 nullable=0 is_null=0 */ ### @2='name3' /* VARSTRING(255) meta=255 nullable=1 is_null=0 */ # at 684315240
那麼這個問題難道是MySQL自身的Bug,抑或是MySQL在某些特殊引數或者條件下的正常表現?對於這個問題,本文將可能的給出這個問題的詳細解釋和分析。
2. Row格式下RelayLog的重放
2.1 BEFOR IMAGE && AFTER IMAGE && binlog_row_image 引數
在最後解釋本文最初提出的問題前,需要先來看下RelayLog是怎麼被重放的。一般情況下,當有DML語句變更資料庫中的資料的時候,Binlog會記錄下事件描述資訊、BEFORE IMAGE和AFTER IMAGE等資訊。在這裡有一個概念BEFORE IMAGE和AFTER IMAGE需要先介紹下:
- BEFORE IMAGE : 前映象,既資料修改前的樣子。
- AFTER IMAGE : 後鏡像,既資料修改後的樣子。
為了方便理解,這裡貼一個Binlog的例子。假設當前有表t20200709,然後表中資料如下:
mysql> select * from t20200709 ;
+----+-------+
| id | name |
+----+-------+
| 1 | name4 |
+----+-------+
1 rows in set (0.00 sec)
之後執行SQL語句update t20200709 set name =1 where id = 1;
mysql> update t20200709 set name =1 where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
然後來看下Binlog中的記錄:
#200715 17:28:28 server id 15218 end_log_pos 400 CRC32 0xe4dedec0 Update_rows: table id 4034114356 flags: STMT_END_F
### UPDATE `test`.`t20200709`
### WHERE
### @1=1 /* INT meta=0 nullable=0 is_null=0 */
### @2='name4' /* VARSTRING(255) meta=255 nullable=1 is_null=0 */
### SET
### @1=1 /* INT meta=0 nullable=0 is_null=0 */
### @2='1' /* VARSTRING(255) meta=255 nullable=1 is_null=0 */
# at 400
可以見得,在修改之前name欄位的值是'name4',在Binlog中用Where條件@2='name4'來指明,而修改後的name的值是'1',在Binlog中就是@2='1'來指明。因此BEFORE IMAGE就是Binlog中WHERE到SET的部分。而AFTER IMAGE就是SET之後的部分。
那麼DELETE,UPDATE和INSERT語句被記錄在Binlog中的時候,是否都有BEFORE IMAGE和AFTER IMAGE?其實不是所有的DML事件型別都擁有兩個IMAGE的,參見圖2-2可知只有UPDATE語句,會同時擁有BEFORE IMAGE和AFTER IMAGE。
BEFOR IMAGE 和AFTER IMAGE預設會記錄所有的列的變更,因此會導致Binlog的內容變得很大。那麼有沒有引數可以控制IMAGE(對於BEFOR IMAGE 和AFTER IMAGE以下合併簡稱為IMAGE)的行為?MySQL5.7之後引入了一個新的引數binlog_row_image
用於控制IMAGE的行為。binlog_row_image引數的值有三個:
- full: Log all columns in both the before image and the after image. 既所有的列的值的變更,都會在IMAGE中記錄。系統預設是full。
- minimal: Log only those columns in the before image that are required to identify the row to be changed; log only those columns in the after image where a value was specified by the SQL statement, or generated by auto-increment. BEFOR IMAGE 只記錄哪些能夠唯一標識資料的列,比如主鍵,唯一鍵等。AFTER IMAGE 只記錄了變更的列。可以看出,minimal會有效的減少Binlog的大小。
- noblob : Log all columns (same as full), except for
BLOB
andTEXT
columns that are not required to identify rows, or that have not changed. 對於其他列的行為都和full引數一樣。但是對於BLOB 和TEXT,在不是可以標識資料行或者有變更的情況下不做記錄。
可以看出binlog_row_image可以有效控制Binlog的大小,但是如果要保證資料的一致性,最好的值就是設定為full。
2.2 slave_rows_search_algorithms 引數
前文提到了IMAGE 與binlog_row_image 相關的內容。本節開始將主要介紹Relay Log的重放的時候,對於被重放的記錄的查詢邏輯。對於DELETE和UPDATE操作,需要先對資料庫中的記錄進行檢索以確定需要執行Binlog重放的資料。如果從庫的表上沒有主鍵或唯一鍵時,則需要根據每一個行記錄BEFOR IMAGE在所有資料中進行一次全表掃描。在大多數情況下這種開銷非常巨大,會導致從庫和主庫的巨大延遲。從MySQL5.6 開始提供了引數slave_rows_search_algorithms
用於控制在Relay Log 執行重放的時候對於記錄的檢索行為。其基本的思路是收集每條記錄的BEFOR IMAGE資訊,然後根據BEFOR IMAGE的資訊在被重放的表中檢索對應的記錄。根據MySQL的文件,檢索資料的方式有如下的幾種:
- INDEX_SCAN
- TABLE_SCAN
- HASH_SCAN
如上三個方式可以兩兩組合並賦值給slave_rows_search_algorithms 引數。MySQL文件也給出瞭如下的說明:
Index used / option value INDEX_SCAN,HASH_SCAN INDEX_SCAN,TABLE_SCAN Primary key or unique key Index scan Index scan (Other) Key Hash scan over index Index scan No index Hash scan Table scan
- The default value is
INDEX_SCAN,TABLE_SCAN
, which means that all searches that can use indexes do use them, and searches without any indexes use table scans.- To use hashing for any searches that do not use a primary or unique key, set
INDEX_SCAN,HASH_SCAN
. SpecifyingINDEX_SCAN,HASH_SCAN
has the same effect as specifyingINDEX_SCAN,TABLE_SCAN,HASH_SCAN
, which is allowed.- Do not use the combination
TABLE_SCAN,HASH_SCAN
. This setting forces hashing for all searches. It has no advantage overINDEX_SCAN,HASH_SCAN
, and it can lead to “record not found” errors or duplicate key errors in the case of a single event containing multiple updates to the same row, or updates that are order-dependent.
INDEX_SCAN,TABLE_SCAN
: 可以看出在預設的情況下,既INDEX_SCAN,TABLE_SCAN
如果有主鍵或者唯一鍵,則通過主鍵或者唯一鍵來查詢資料並重放AFTER IMAGE。如果沒有主鍵或者唯一鍵,則通過二級索引完成這個工作。如果什麼都沒有,則使用全表掃描的方式。
- INDEX_SCAN,HASH_SCAN : 在表有主鍵或者唯一鍵的情況下, INDEX_SCAN,HASH_SCAN 配置也是使用的主鍵或者唯一鍵去定位資料。在表有二級索引或者完全沒有索引的情況下會使用HASH_SCAN的方法。
可以見得Slave檢索需要重放的資料的時候,三個檢索方式的優先順序是Index Scan > Hash Scan > Table Scan
相信讀者在這裡會有2個疑問:
- Hash Scan 的原理是什麼?它和Table Scan以及Index Scan有什麼區別?文件中還提到了Hash scan over index 這個和Index有什麼關係?
- 前文提到在表沒有主鍵或者唯一鍵的時候,會通過二級索引來定位資料。假設表中有N個二級索引(包括單列索引和聯合索引),哪個二級索引會被選中?
2.3 Hash Scan && Table Scan && Index Scan 實現
分析MySQL原始碼可知最後決定使用哪個檢索方式是在函式Rows_log_event::decide_row_lookup_algorithm_and_key 裡面實現的。
9745 void
9746 Rows_log_event::decide_row_lookup_algorithm_and_key()
9747 {
9748
... ...
9781 /* PK or UK => use LOOKUP_INDEX_SCAN */
9782 this->m_key_index= search_key_in_table(table, cols, (PRI_KEY_FLAG | UNIQUE_KEY_FLAG));
9783 if (this->m_key_index != MAX_KEY)
9784 {
9785 DBUG_PRINT("info", ("decide_row_lookup_algorithm_and_key: decided - INDEX_SCAN"));
9786 this->m_rows_lookup_algorithm= ROW_LOOKUP_INDEX_SCAN;
9787 goto end;
9788 }
... ...
9790 TABLE_OR_INDEX_HASH_SCAN:
... ...
9808 TABLE_OR_INDEX_FULL_SCAN:
... ...
9827 end:
... ...
在9782行會先檢索表中是否有主鍵和唯一鍵。之後在TABLE_OR_INDEX_HASH_SCAN和TABLE_OR_INDEX_FULL_SCAN決定最後使用哪種檢索方式。在do_apply_event函式中,會根據decide_row_lookup_algorithm_and_key的結果去呼叫函式:
11286 switch (m_rows_lookup_algorithm)
11287 {
11288 case ROW_LOOKUP_HASH_SCAN:
11289 do_apply_row_ptr= &Rows_log_event::do_hash_scan_and_update;
11290 break;
11291
11292 case ROW_LOOKUP_INDEX_SCAN:
11293 do_apply_row_ptr= &Rows_log_event::do_index_scan_and_update;
11294 break;
11295
11296 case ROW_LOOKUP_TABLE_SCAN:
11297 do_apply_row_ptr= &Rows_log_event::do_table_scan_and_update;
11298 break;
11299
11300 case ROW_LOOKUP_NOT_NEEDED:
11301 DBUG_ASSERT(get_general_type_code() == binary_log::WRITE_ROWS_EVENT);
11302
11303 /* No need to scan for rows, just apply it */
11304 do_apply_row_ptr= &Rows_log_event::do_apply_row;
11305 break;
11306
11307 default:
11308 DBUG_ASSERT(0);
11309 error= 1;
11310 goto AFTER_MAIN_EXEC_ROW_LOOP;
11311 break;
11312 }
可以見得:
- do_hash_scan_and_update: 對應hash_scan方式。
- do_index_scan_and_update: 對應index_scan方式。
- do_table_scan_and_update:對應table_scan方式。
接下來分別介紹下這三個函式所完成的內容。
2.3.1 do_hash_scan_and_update
do_hash_scan_and_update函式主要實現了Hash Scan檢索資料的功能。在實現方式上又可以分為H --> Hash Scan和Hi --> Hash over Index兩種方式。首先來看下Hash Scan的實現方法,圖2-5給出Hash Scan的實現邏輯。
可以見得Binlog中的BI在Slave上會被處理到一個Hash表中。因為沒有合適的索引可以使用,所以使用全表掃描的方式每獲取一條記錄就根據記錄的值計算一個hash值,然後在BI的Hash表中匹配。如果匹配到了BI,則重放並刪除Hash表中的記錄。
如果test06表中id列上有索引,那麼在Slave重放的使用會使用Hi --> Hash over index的方式。如圖2-6所示給出了 Hash over Index方式(以下均簡稱Hi)的實現邏輯。
可以見得如果通過Hi方式進行重放,則會對使用的二級索引生成一個m_distinct_keys結構,這個結構存放著這個BI中這個索引所有的去重值。然後對於Slave上的test06表通過m_distinct_keys中的每一個值在二級索引上進行遍歷,遍歷獲取的記錄與m_hash中的結果對比並執行重放邏輯。
ps : 對於Hash Scan 方式還要一個比較迷惑的特性,讀者可以參考下這篇文章技術分享 | HASH_SCAN BUG 之迷惑行為大賞
2.3.2 do_index_scan_and_update
Index Scan方式會通過索引檢索Slave上需要重放的資料。通過索引檢索資料的方式又可以分為:
- 通過主鍵/唯一鍵索引檢索資料。
- 通過二級索引檢索資料。
在通過主鍵或者唯一鍵索引檢索資料的時候會呼叫do_index_scan_and_update函式,在函式邏輯中直接通過主鍵/唯一鍵索引返回了記錄然後重放Binlog。
而在通過二級索引檢索資料的時候,會對二級索引返回的資料與BI中每一條記錄做比較,如果一致就會重放Binlog。
至此可以發現Index Scan下對於主鍵/唯一鍵和二級索引的實現邏輯有一些不同。對於主鍵/唯一鍵,對於索引到的記錄並不會和BI中的每一個列做比較,而二級索引獲取到的資料會與BI中每一個列做比較,如果不一致而不會重放並報錯。
2.3.3 do_table_scan_and_update
Table Scan的實現相對簡單,如果沒有任何的索引可以使用,只能通過全表掃描的方式獲取每一行資料並和BI中的每一行做比較。因此如果Slave上的資料和Master上的資料不一致,也會如圖2-9中所示一樣報錯。關於Table Scan更加具體的實現方式,讀者可以參考MySQL原始碼 sql/log_event.cc檔案中的do_table_scan_and_update函式,在這裡就不過多的展開。
2.3.4 小結
至此可以回答本文之前提出的這個問題了:
Hash scan 方法的原理是什麼?它和Table scan以及Index scan有什麼區別?文件中還提到了Hash scan over index 這個和Index 又有什麼關係?
可以見得,Hash Scan 的原理是將BI每一行的內容都放入一個Hash表中。如果可以使用二級索引(既Hash scan over index這個方式),則額外的對BI中二級索引的值生成一個Hash結構,並且將BI中二級索引列的去重值放入這個Hash結構中。之後不管是通過全表掃描還是索引的方式獲取資料,都會通過Hash結構去定位BI中的資料。對於Table Scan 和Index Scan 在獲取表中的每一行之後,都需要去和BI中的記錄做一次查詢和比較(有主鍵或者唯一鍵的時候不做比較),而BI的每一行並沒有生成類似於Hash的結構,因此從演算法的時間複雜度效率上來說是屬於O(n^2)的。而Hash Scan 在獲取一條記錄之後也需要根據BI生成的Hash結構中查詢記錄,但是對於Hash結構的查詢來說效率是O(1),因此可以忽略不計。由此可以看出,在沒有主鍵的情況下Hi 和Ht 方式的效率是會比Table Scan 和Index Scan來的高一些。
同時到這裡,也可以回答本文開頭的問題,為什麼當前表中的記錄有一列值已經和BI中的記錄不一致了,Binlog中的操作還會重放。原因就是因為在預設的INDEX_SCAN,TABLE_SCAN方式下,對於有主鍵/唯一鍵的表不會去比較BI中的記錄是否和檢索到的資料一致。
2.4 Hash Scan Over Index && Index Scan 中二級索引的選擇
前文提到了在有二級索引的情況下,Hash Scan和Index Scan都會選擇二級索引進行掃描。如果表中存在多個二級索引,MySQL會選擇哪個?通過原始碼分析,最後驚訝的發現,在binlog_row_image引數是Full的情況下,如果表中存在多個二級索引,MySQL會預設選擇使用第一個索引進行重放。在decide_row_lookup_algorithm_and_key函式中,除了決定了使用哪種方式檢索資料以外(例如使用Hash Scan還是Table Scan),也決定了後續使用哪個索引。
如圖2-10給出了選擇二級索引的時候的邏輯。可以發現如果在遍歷的過程中,找到了第一個所有的列都在BI中key,則會使用這個key。給出一個例子,test06 的表結構和表中資料如下:
*************************** 1. row ***************************
Table: test06
Create Table: CREATE TABLE `test06` (
`id` int(11) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`c1` int(11) DEFAULT NULL,
KEY `k1` (`id`),
KEY `k2` (`id`,`name`),
KEY `k3` (`c1`,`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.13 sec)
mysql> select * from test06 ;
+------+-------+------+
| id | name | c1 |
+------+-------+------+
| 2582 | name3 | 1 |
| 2582 | name4 | 1 |
| 1 | name1 | 0 |
| 1 | name2 | 0 |
| 1 | name3 | 0 |
+------+-------+------+
5 rows in set (0.00 sec)
在Master上執行SQL,同時Master上的執行計劃如下:
delete from test06 where id = 1 and name ='name3' and c1=0;
mysql> explain delete from test06 where id = 1 and name ='name3' and c1=0;
+----+-------------+--------+------------+-------+---------------+------+---------+-------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------------+-------+---------------+------+---------+-------------+------+----------+-------------+
| 1 | DELETE | test06 | NULL | range | k1,k2,k3 | k2 | 772 | const,const | 1 | 100.00 | Using where |
+----+-------------+--------+------------+-------+---------------+------+---------+-------------+------+----------+-------------+
1 row in set (0.00 sec)
可以見得,在Master上優化器選擇了K2這個聯合索引。通過GDB跟蹤Slave的程序,在log_event.cc 第9733行打斷點:
9714 if (key_type & MULTIPLE_KEY_FLAG && table->s->keys)
9715 {
9716 DBUG_PRINT("debug", ("Searching for K."));
9717 for (key=0,keyinfo= table->key_info ;
9718 (key < table->s->keys) && (res == MAX_KEY);
9719 key++,keyinfo++)
9720 {
9721 /*
9722 - Skip innactive keys
9723 - Skip unique keys without nullable parts
9724 - Skip indices that do not support ha_index_next() e.g. full-text
9725 - Skip primary keys
9726 */
9727 if (!(table->s->keys_in_use.is_set(key)) ||
9728 ((keyinfo->flags & (HA_NOSAME | HA_NULL_PART_KEY)) == HA_NOSAME) ||
9729 !(table->file->index_flags(key, 0, true) & HA_READ_NEXT) ||
9730 (key == table->s->primary_key))
9731 continue;
9732
9733 res= are_all_columns_signaled_for_key(keyinfo, bi_cols) ?
9734 key : MAX_KEY;
9735
9736 if (res < MAX_KEY)
9737 DBUG_RETURN(res);
9738 }
9739 DBUG_PRINT("debug", ("Not all columns signaled for K."));
9740 }
可以觀察到這時候m_key_index 的值是0,並且觀察keyinfo變數的值為:
(gdb) print *keyinfo
$4 = {key_length = 4, flags = 0, actual_flags = 0, user_defined_key_parts = 1, actual_key_parts = 1, unused_key_parts = 0, usable_key_parts = 1, block_size = 0, algorithm = HA_KEY_ALG_UNDEF, {
parser = 0x0, parser_name = 0x0}, key_part = 0x7f2f4c015a00, name = 0x7f2f4c012bb1 "k1", rec_per_key = 0x7f2f4c012bc0, m_in_memory_estimate = -1, rec_per_key_float = 0x7f2f4c012bf8, handler = {
bdb_return_if_eq = 0}, table = 0x7f2f4c92d1a0, comment = {str = 0x0, length = 0}}
接下來,刪除k1這個索引,再來觀察下m_key_index和keyinfo的值。
(gdb) print *keyinfo
$7 = {key_length = 772, flags = 64, actual_flags = 64, user_defined_key_parts = 2, actual_key_parts = 2, unused_key_parts = 0, usable_key_parts = 2, block_size = 0, algorithm = HA_KEY_ALG_UNDEF, {
parser = 0x0, parser_name = 0x0}, key_part = 0x7f2f4c92b680, name = 0x7f2f4c92e7d1 "k2", rec_per_key = 0x7f2f4c92e7d8, m_in_memory_estimate = -1, rec_per_key_float = 0x7f2f4c92e808, handler = {
bdb_return_if_eq = 0}, table = 0x7f2f4ca9fd90, comment = {str = 0x0, length = 0}}
可以發現刪除了k1之後,Slave上就選擇K2 這個索引,和Master上的執行計劃選擇的索引一致了。通過前面的原始碼分析和除錯跟蹤可以發現,MySQL在Slave重放資料的時候(沒有主鍵和唯一鍵的情況),選擇的索引是第一個所有的列都在BI中存在的索引。因此可能存在Slave上選擇的索引不是最優的導致Slave和Master有巨大延遲。
3. 總結
至此前文提出的幾個問題都基本清楚了,可以總結出如下的幾點內容:
- 在有主鍵或者唯一鍵的情況下,Slave 重放Binlog並不會去比較檢索到的記錄的每一列是否和BI相同,因此如果Slave和Master存在資料不一致,會直接覆蓋Slave的資料而不會報錯。
- 在沒有主鍵或者唯一鍵的情況下,Hash Scan / Hash Scan Over Index 的執行效率 在理論上分析高於 Table Scan 和Index Scan 。
- 在沒有主鍵或者唯一鍵的情況下,Slave選擇的二級索引是第一個所有的列都在BI中存在的索引,不一定是Master執行計劃所選擇的索引。
最後本文所有分析的原始碼都是基於mysql-5.7.28版本。限於作者的水平有限,如果文章中有錯誤之處,望大家不吝指正。
4. 參考文獻
[1]. 從庫資料的查詢和引數slave_rows_search_algorithms. https://cloud.tencent.com/developer/article/1492071
[2]. MySQL無主鍵延遲優化(slave_rows_search_algorithms). https://www.centos.bz/2018/01/mysql無主鍵延遲優化(slave_rows_search_algorithms)
[3]. MySQL 5.7貼心引數之binlog_row_image. https://www.cnblogs.com/gomysql/p/6155160.html
[4]. 技術分享 | delete大表slave回放巨慢的問題分析. https://zhuanlan.zhihu.com/p/73822875
[5]. Causes and Workarounds for Slave Performance Too Slow with Row-Based Events. https://www.percona.com/blog/2018/05/03/slave-performance-too-slow-with-row-based-events-causes-and-workarounds/
[6]. 技術分享 | 從庫資料的查詢和引數 slave_rows_search_algorithms. https://zhuanlan.zhihu.com/p/82160262
[7]. 技術分享 | HASH_SCAN BUG 之迷惑行為大賞. https://opensource.actionsky.com/20190531-hash_scan-bug/