15 | 答疑文章(一):日誌和索引相關問題 日誌相關 binlog(歸檔日誌)和redo log(重做日誌)配合崩潰恢復,在兩階段提交的不同瞬間,MySQL如果發生異常重啟,是怎麼保證數據完整性的? Q:這個圖不是一個update 語句的執行流程嗎,怎麼還會調用 commit 語句? A: 兩個“ ...
15 | 答疑文章(一):日誌和索引相關問題
日誌相關
binlog(歸檔日誌)和redo log(重做日誌)配合崩潰恢復,在兩階段提交的不同瞬間,MySQL如果發生異常重啟,是怎麼保證數據完整性的?
Q:這個圖不是一個update 語句的執行流程嗎,怎麼還會調用 commit 語句?
A:
兩個“commit”的概念
-
“commit 語句”是指 MySQL 語法中,用於提交一個事務的命令。一般跟begin/start transaction 配對使用。
-
而圖中用到的這個“commit 步驟”,指的是事務提交過程中的一個小步驟,也是最後一步。當這個步驟執行完成後,這個事務就提交完成了。
-
“commit 語句”執行的時候,會包含“commit 步驟”。
而這個例子裡面,沒有顯式地開啟事務,因此這個 update 語句自己就是一個事務,在執行完成後提交事務時,就會用到這個“commit 步驟“。
分析在兩階段提交的不同時刻,MySQL 異常重啟會出現什麼現象
如果在圖中時刻 A 的地方,也就是寫入 redo log 處於 prepare 階段之後、寫 binlog 之前,發生了崩潰(crash),由於此時 binlog 還沒寫,redo log 也還沒提交,所以崩潰恢復的時候,這個事務會回滾。這時候,binlog 還沒寫,所以也不會傳到備庫。
在時刻 B,也就是 binlog 寫完,redo log 還沒 commit前發生 crash,那崩潰恢復的時候 MySQL 會怎麼處理?
崩潰恢復時的判斷規則:
- 如果 redo log 裡面的事務是完整的,也就是已經有了 commit 標識,則直接提交;
- 如果 redo log 裡面的事務只有完整的 prepare,則判斷對應的事務 binlog 是否存在並完整:
a. 如果是,則提交事務;
b. 否則,回滾事務。
這裡,時刻 B 發生 crash 對應的就是 2(a) 的情況,崩潰恢復過程中事務會被提交。
Q:MySQL 怎麼知道 binlog 是完整的?
A:一個事務的 binlog 是有完整格式的:
- statement格式的binlog,最後會有COMMIT;
- row格式的binlog,最後會有一個XID event。
另外,在MySQL 5.6.2版本以後,還引入了binlog-checksum參數,用來驗證binlog內容的正確性。
對於binlog日誌由於磁碟原因,可能會在日誌中間出錯的情況,MySQL可以通過校驗checksum的結果來發現。所以,MySQL還是有辦法驗證事務binlog的完整性的。
Q:redo log 和 binlog 是怎麼關聯起來的?
A:
它們有一個共同的數據欄位,叫 XID。崩潰恢復的時候,會按順序掃描 redo log:
- 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
- 如果碰到只有 parepare、而沒有 commit 的 redo log,就拿著 XID 去 binlog 找對應的事務。
Q:處於 prepare 階段的 redo log 加上完整 binlog,重啟就能恢復,MySQL 為什麼要這麼設計?
A:這個問題和數據與備份的一致性有關。在時刻B,也就是 binlog 寫完以後 MySQL 發生崩潰,這時候 binlog 已經寫入了,之後就會被從庫(或者用這個 binlog 恢復出來的庫)使用。所以,在主庫上也要提交這個事務。採用這個策略,主庫和備庫的數據就保證了一致性。
Q:如果這樣的話,為什麼還要兩階段提交呢?乾脆先 redo log 寫完,再寫 binlog。崩潰恢復的時候,必須得兩個日誌都完整才可以。是不是一樣的邏輯?
A:
回答:其實,兩階段提交是經典的分散式系統問題,並不是MySQL獨有的。
如果必須要舉一個場景,來說明這麼做的必要性的話,那就是事務的持久性問題。
對於InnoDB引擎來說,如果redo log提交完成了,事務就不能回滾(如果這還允許回滾,就可能覆蓋掉別的事務的更新)。而如果redo log直接提交,然後binlog寫入的時候失敗,InnoDB又回滾不了,數據和binlog日誌又不一致了。
兩階段提交就是為了給所有人一個機會,當每個人都說“我ok”的時候,再一起提交。
Q :不引入兩個日誌,也就沒有兩階段提交的必要了。只用 binlog 來支持崩潰恢復,又能支持歸檔,不就可以了?
A:意思是只保留 binlog,然後可以把提交流程改成這樣:… -> “數據更新到記憶體” -> “寫 binlog” -> “提交事務”,是不是也可以提供崩潰恢復的能力?
不可以。
歷史原因: InnoDB 並不是 MySQL 的原生存儲引擎。MySQL 的原生引擎是 MyISAM,設計之初就有沒有支持崩潰恢復。InnoDB 在作為 MySQL 的插件加入 MySQL 引擎家族之前,就已經是一個提供了崩潰恢復和事務支持的引擎了。InnoDB 接入了 MySQL 後,發現既然 binlog 沒有崩潰恢復的能力,那就用 InnoDB 原有的 redo log 好了。
實現上的原因:
假設只用binlog
這樣的流程下,binlog 還是不能支持崩潰恢復的。binlog 沒有能力恢復“數據頁”。
如果在圖中標的位置,也就是 binlog2 寫完了,但是整個事務還沒有 commit 的時候,MySQL 發生了 crash。
重啟後,引擎內部事務 2 會回滾,然後應用 binlog2 可以補回來;但是對於事務 1 來說,系統已經認為提交完成了,不會再應用一次 binlog1。
但是,InnoDB 引擎使用的是 WAL 技術,執行事務的時候,寫完記憶體和日誌,事務就算完成了。如果之後崩潰,要依賴於日誌來恢複數據頁。
也就是說在圖中這個位置發生崩潰的話,事務 1 也是可能丟失了的,而且是數據頁級的丟失。此時,binlog 裡面並沒有記錄數據頁的更新細節,是補不回來的。
如果要說,優化一下 binlog 的內容,讓它來記錄數據頁的更改可以嗎?但,這其實就是又做了一個 redo log 出來。所以,至少現在的 binlog 能力,還不能支持崩潰恢復。
Q:那能不能反過來,只用 redo log,不要 binlog?
A:如果只從崩潰恢復的角度來講是可以的。你可以把 binlog 關掉,這樣就沒有兩階段提交了,但系統依然是 crash-safe 的。
但 binlog 有著 redo log 無法替代的功能。
一個是歸檔。redo log 是迴圈寫,寫到末尾是要回到開頭繼續寫的。這樣歷史日誌沒法保留,redo log 也就起不到歸檔的作用。
一個就是 MySQL 系統依賴於 binlog。binlog 作為 MySQL 一開始就有的功能,被用在了很多地方。其中,MySQL 系統高可用的基礎,就是 binlog 複製。
還有很多公司有異構系統(比如一些數據分析系統),這些系統就靠消費 MySQL 的binlog 來更新自己的數據。關掉 binlog 的話,這些下游系統就沒法輸入了。
Q:redo log 一般設置多大?
A:redo log 太小的話,會導致很快就被寫滿,然後不得不強行刷 redo log,這樣WAL 機制的能力就發揮不出來了。直接將 redo log 設置為4 個文件、每個文件 1GB 。
Q:正常運行中的實例,數據寫入後的最終落盤,是從 redo log 更新過來的還是從 buffer pool 更新過來的呢?
A:
實際上,redo log 並沒有記錄數據頁的完整數據,所以它並沒有能力自己去更新磁碟數據頁,也就不存在“數據最終落盤,是由 redo log 更新過去”的情況。
-
如果是正常運行的實例的話,數據頁被修改以後,跟磁碟的數據頁不一致,稱為臟頁。最終數據落盤,就是把記憶體中的數據頁寫盤。這個過程,甚至與 redo log 毫無關係。(指落盤的過程)
-
在崩潰恢復場景中,InnoDB 如果判斷到一個數據頁可能在崩潰恢復的時候丟失了更新,就會將它讀到記憶體,然後讓 redo log 更新記憶體內容。更新完成後,記憶體頁變成臟頁,就回到了第一種情況的狀態。
Q:redo log buffer 是什麼?是先修改記憶體,還是先寫 redo log文件?
A:
在一個事務的更新過程中,日誌是要寫多次的。比如下麵這個事務:
begin;
insert into t1 ...
insert into t2 ...
commit;
這個事務要往兩個表中插入記錄,插入數據的過程中,生成的日誌都得先保存起來,但又不能在還沒 commit 的時候就直接寫到 redo log 文件里。
所以,redo log buffer 就是一塊記憶體,用來先存 redo 日誌的。也就是說,在執行第一個insert 的時候,數據的記憶體被修改了,redo log buffer 也寫入了日誌。但是,真正把日誌寫到 redo log 文件(文件名是 ib_logfile+ 數字),是在執行 commit語句的時候做的。
這裡說的是事務執行過程中不會“主動去刷盤”,以減少不必要的 IO 消耗。但是可能會出現“被動寫入磁碟”,比如記憶體不夠、其他事務提交等情況。
業務設計問題
業務上有這樣的需求,A、B 兩個用戶,如果互相關註,則成為好友。
設計上是有兩張表,一個是 like 表,一個是 friend 表,like 表有 user_id、liker_id 兩個欄位,我設置為複合唯一索引即 uk_user_id_liker_id。語句執行邏輯是這樣的:
以 A 關註 B 為例:
第一步,先查詢對方有沒有關註自己(B 有沒有關註 A)
select * from like where user_id = B and liker_id = A;
如果有,則成為好友
insert into friend;
沒有,則只是單向關註關係
insert into like;
但是如果 A、B 同時關註對方,會出現不會成為好友的情況。因為上面第 1步,雙方都沒關註對方。第 1 步即使使用了排他鎖也不行,因為記錄不存在,行鎖無法生效。請問這種情況,在 MySQL 鎖層面有沒有辦法處理?
即:在併發場景下,同時有兩個人,設置為關註對方,就可能導致無法成功加為朋友關係。
模擬出表:
PS:業務根本就是保證“我一定會插入重覆數據,資料庫一定要要有唯一性約束”,這時直接建唯一索引。不用考慮在“業務開發保證不會插入重覆記錄”的情況下,著重要解決性能問題的時候,才建議儘量使用普通索引。
CREATE TABLE `like` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`liker_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id_liker_id` (`user_id`,`liker_id`)
) ENGINE=InnoDB;
CREATE TABLE `friend` (
id` int(11) NOT NULL AUTO_INCREMENT,
`friend_1_id` int(11) NOT NULL,
`firned_2_id` int(11) NOT NULL,
UNIQUE KEY `uk_friend` (`friend_1_id`,`firned_2_id`)
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
由於一開始 A 和 B 之間沒有關註關係,所以兩個事務裡面的 select 語句查出來的結果都是空。
因此,session 1 的邏輯就是“既然 B 沒有關註 A,那就只插入一個單向關註關係”。session 2 也同樣是這個邏輯。
這個結果對業務來說就是 bug 了。因為在業務設定裡面,這兩個邏輯都執行完成以後,是應該在 friend 表裡面插入一行記錄的。
如提問裡面說的,“第 1 步即使使用了排他鎖也不行,因為記錄不存在,行鎖無法生效”。不過,使用另外一個方法,來解決這個問題。
首先,要給“like”表增加一個欄位,比如叫作 relation_ship,並設為整型,取值 1、2、3。
值是 1 的時候,表示 user_id 關註 liker_id;
值是 2 的時候,表示 liker_id 關註 user_id;
值是 3 的時候,表示互相關註
然後,當 A 關註 B 的時候,邏輯改成如下所示的樣子:
應用代碼裡面,比較 A 和 B 的大小,如果 A<B,就執行下麵的邏輯:
begin; /* 啟動事務 */
insert into `like`(user_id, liker_id, relation_ship) values(A, B, 1) on duplicate key update relation_ship=relation_ship | 1;
select relation_ship from `like` where user_id=A and liker_id=B;
/* 代碼中判斷返回的 relation_ship,
如果是 1,事務結束,執行 commit
如果是 3,則執行下麵這兩個語句:
*/
insert ignore into friend(friend_1_id, friend_2_id) values(A,B);
commit;
如果 A>B,則執行下麵的邏輯
begin; /* 啟動事務 */
insert into `like`(user_id, liker_id, relation_ship) values(B, A, 2) on duplicate key update relation_ship=relation_ship | 2;
select relation_ship from `like` where user_id=B and liker_id=A;
/* 代碼中判斷返回的 relation_ship,
如果是 2,事務結束,執行 commit
如果是 3,則執行下麵這兩個語句:
*/
insert ignore into friend(friend_1_id, friend_2_id) values(B,A);
commit;
這個設計里,讓“like”表裡的數據保證 user_id < liker_id,這樣不論是 A 關註 B,還是B 關註 A,在操作“like”表的時候,如果反向的關係已經存在,就會出現行鎖衝突。
然後,insert … on duplicate 語句,確保了在事務內部,執行了這個 SQL 語句後,就強行占住了這個行鎖,之後的 select 判斷 relation_ship 這個邏輯時就確保了是在行鎖保護下的讀操作。
操作符 “|” 是按位或,連同最後一句 insert 語句里的 ignore,是為了保證重覆調用時的冪等性。???
這樣,即使在雙方“同時”執行關註操作,最終資料庫里的結果,也是 like 表裡面有一條關於 A 和 B 的記錄,而且 relation_ship 的值是 3, 並且 friend 表裡面也有了 A 和 B 的這條記錄。
Q:???
我們創建了一個簡單的表 t,並插入一行,然後對這一行做修改。
CREATE TABLE `t` (
`id` int(11) NOT NULL primary key auto_increment,
`a` int(11) DEFAULT NULL
) ENGINE=InnoDB;
insert into t values(1,2);
這時候,表 t 里有唯一的一行數據 (1,2)。假設,現在要執行:
update t set a=2 where id=1;
結果:
結果顯示,匹配(rows matched)了一行,修改(Changed)了0行。
僅從現象上看,MySQL內部在處理這個命令的時候,可以有以下三種選擇:
- 更新都是先讀後寫的,MySQL 讀出數據,發現 a 的值本來就是 2,不更新,直接返回,執行結束;
- MySQL 調用了 InnoDB 引擎提供的“修改為 (1,2)”這個介面,但是引擎發現值與原來相同,不更新,直接返回;
- InnoDB 認真執行了“把這個值修改成 (1,2)"這個操作,該加鎖的加鎖,該更新的更新。
你覺得實際情況會是以上哪種呢?你可否用構造實驗的方式,來證明你的結論?進一步地,可以思考一下,MySQL 為什麼要選擇這種策略呢?
A:
答案應該是選項 3,即:InnoDB 認真執行了“把這個值修改成 (1,2)"這個操作,該加鎖的加鎖,該更新的更新。
第一個選項是,MySQL 讀出數據,發現值與原來相同,不更新,直接返回,執行結束。這裡可以用一個鎖實驗來確認。
假設,當前表 t 里的值是 (1,2)。
session B 的 update 語句被 blocked 了,加鎖這個動作是 InnoDB 才能做的,所以排除選項 1。
第二個選項是,MySQL 調用了 InnoDB 引擎提供的介面,但是引擎發現值與原來相同,不更新,直接返回。用一個可見性實驗來確認。
假設當前表裡的值是 (1,2)。
session A 的第二個 select 語句是一致性讀(快照讀),它是不能看見 session B 的更新的。
現在它返回的是 (1,3),表示它看見了某個新的版本,這個版本只能是 session A 自己的 update 語句做更新的時候生成。