- MySQL高可用實踐
- 王雪迎
- 6236字
- 2021-03-26 23:06:29
第1章 異步復制
1.1 MySQL異步復制簡介
這里所說的復制,就是將來自一個MySQL數據庫服務器(主庫)的數據復制到一個或多個MySQL數據庫服務器(從庫)。傳統的MySQL復制提供了一種簡單的Primary-Secondary(主-從)復制方法,默認情況下,復制是單向異步的。MySQL支持兩種復制方式:基于行的復制和基于語句的復制。這兩種方式都是通過在主庫上記錄二進制日志(binlog)和在從庫重放中繼日志(relay-log)的方式來實現異步的數據復制。二進制日志或中繼日志中的記錄被稱為事件。所謂異步包含兩層含義:一是主庫二進制日志的寫入操作與將其發送到從庫的操作是異步進行的;二是從庫獲取與重放日志事件是異步進行的。這意味著,在同一個時間點從庫上的數據更新可能落后于主庫,并且無法保證主庫和從庫之間的延遲間隔。
復制主庫而增加的系統開銷主要體現在啟用二進制日志帶來的I/O,但是增加的開銷并不大,MySQL官方文檔中稱開啟二進制日志會產生1%的性能損耗。為了保證對歷史事務的備份以及從介質失敗中可以恢復過來,這點系統開銷是非常必要的。除此之外,每個從庫也會增加主庫的一些負載(即系統開銷),例如網絡和I/O。當從庫讀取主庫的二進制日志時,就會產生一定的I/O開銷。如果從一個主庫復制到多個從庫,喚醒多個復制線程發送二進制日志內容的開銷就會累加。不過,所有這些復制帶來的額外系統開銷相對于各種應用對MySQL服務器造成的高負載來說都是微不足道的。
1.1.1 復制的用途
復制的用途主要體現在以下五個方面:
1. 橫向擴展
通過復制可以將讀操作指向從庫來獲得更好的讀擴展。所有寫入和更新都在主庫上進行,但讀取可能發生在一個或多個從庫上。在這種讀寫分離模型中,主庫專用于更新,顯然比同時進行讀寫操作會有更好的寫性能。需要注意的是,寫操作并不適合通過復制來擴展。在“一主多從”架構中,寫操作會被執行多次,正如“木桶效應”,整個系統的寫入性能取決于寫入最慢的那部分操作。
2. 負載均衡
通過MySQL復制可以將讀操作分布到多個服務器上,實現對讀密集型應用的優化。對于小規模的應用,可以簡單地對機器名進行硬編碼或者使用DNS輪詢(將一個機器名指向多個IP地址)。當然也可以使用復雜的方法,例如使用LVS網絡負載均衡器等,就能夠很好地將負載分配到不同的MySQL服務器上。
3. 提高數據安全性
提高數據安全性可以從兩方面來理解:其一,因為數據被復制到從庫,并且從庫可以暫停復制過程,所以可以在從庫上執行備份操作而不會影響對應的主庫;其二,當主庫出現問題時,還有從庫的數據可以被訪問。但是,對備份來說,復制僅僅是一項有意義的技術補充,它既不是備份,也不能夠取代備份。例如,當用戶誤刪了一個表,而且此操作已經在從庫上被復制執行,這種情況下只能用備份來恢復。
4. 提升高可用性
復制可以幫助應用程序避免MySQL單點故障,一個包含復制且設計良好的故障切換系統能夠顯著縮短宕機的時間。
5. 滾動升級
比較普遍的做法是,使用一個高版本的MySQL作為從庫,保證在升級全部數據庫實例之前,數據的查詢能夠在從庫上按預期執行。在測試沒有問題后,將高版本的MySQL切換為主庫,并將應用連接至該主庫,然后重新搭建高版本的從庫。
1.1.2 復制如何工作
如前所述,MySQL復制依賴二進制日志(binlog),想要理解復制的工作事項,就先要了解MySQL的二進制日志。
1. 二進制日志
二進制日志包含描述數據庫更改的事件,如建表操作或對表數據的更改等。開啟二進制日志有兩個重要目的:
- 用于復制。主庫上的二進制日志提供了要發送到從庫的數據更改記錄。主庫將其二進制日志中包含的事件發送到從庫,從庫執行這些事件以對其本地數據進行相同的更改。
- 用于恢復。當出現介質錯誤,如磁盤故障時,數據恢復操作需要使用二進制日志。還原備份后,重新執行備份之后記錄的二進制日志中的事件,最大限度地減少數據丟失。
不難看出,MySQL二進制日志所起的作用與Oracle的歸檔日志類似。二進制日志只記錄更新數據的事件,不記錄SELECT或SHOW等語句。通過設置log-bin系統變量來開啟二進制日志,MySQL 8中這個系統變量默認是開啟的。
二進制日志有STATEMENT、ROW、MIXED三種格式,通過binlog-format系統變量來設置:
- STATEMENT格式,基于SQL語句的復制(Statement-Based Replication,SBR)。每一條會修改數據的SQL語句都會被記錄到binlog中。這種格式的優點是不需要記錄每行的數據變化,這樣二進制日志會比較少,減少了磁盤I/O,提高了性能。缺點是在某些情況下會導致主庫與從庫中的數據不一致,例如last_insert_id()、now()等非確定性函數,以及用戶自定義函數(User-Defined Function,UDF)等易出現問題。
- ROW格式,基于行的復制(Row-Based Replication,RBR)。該格式不記錄SQL語句的上下文信息,僅記錄哪條數據被修改了,修改成了什么樣子,能清楚地記錄每一行數據的修改細節。這種格式的優點是不會出現某些特定情況下的存儲過程、函數或觸發器的調用和觸發無法被正確復制的問題。缺點是通常會產生大量的日志,尤其像大表上執行alter table操作時會讓日志暴漲。
- MIXED格式,混合復制(Mixed-Based Replication,MBR)。它是STATEMENT和ROW這兩種格式的混合體,默認使用STATEMENT格式保存二進制日志,對于STATEMENT格式無法正確復制的操作,會自動切換到基于ROW格式的復制操作,MySQL會根據執行的SQL語句選擇日志保存方式。
MySQL 8默認使用ROW格式。二進制日志的存放位置最好設置到與MySQL數據目錄不同的磁盤分區,以降低磁盤I/O的競爭,提升性能,并且在數據磁盤發生故障時還可以利用備份和二進制日志來恢復數據。
2. 復制步驟
總體來說,MySQL復制有五個步驟:
步驟01 在主庫上把數據更改事件記錄到二進制日志中。
步驟02 從庫上的I/O線程向主庫詢問二進制日志中的事件。
步驟03 主庫上的二進制日志轉儲(Binlog dump)線程向I/O線程發送二進制事件。
步驟04 從庫上的I/O線程將二進制日志事件復制到自己的中繼日志中。
步驟05 從庫上的SQL線程讀取中繼日志中的事件,并將其重放到從庫上。
圖1-1詳細描述了復制的細節。
第一步,是在主庫上記錄二進制日志。每次準備提交事務完成數據更新前,主庫將數據更新的事件記錄到二進制日志中。MySQL會按事務提交的順序而非每條語句的執行順序來記錄二進制日志。在記錄二進制日志后,主庫會告訴存儲引擎可以提交事務了。
第二步,從庫將主庫的二進制日志復制到其本地的中繼日志中。首先,從庫會啟動一個工作線程,稱為I/O線程。I/O線程與主庫建立一個普通的客戶端連接,然后在主庫上啟動一個特殊的二進制日志轉儲(Binlog dump)線程,它會讀取主庫上二進制日志中的事件,但不會對事件進行輪詢。如果該線程追趕上了主庫,它將進入睡眠狀態,直到主庫發送信號通知該線程有新的事件時才會被喚醒,從庫I/O線程會將接收到的事件記錄到中繼日志中。

圖1-1 復制如何工作
從庫的SQL線程執行最后一步,該線程從中繼日志中讀取事件并在從庫上執行,從而實現從庫數據的更新。當SQL線程追趕I/O線程時,中繼日志通常已經在系統緩存中,所以讀取中繼日志的開銷很低。SQL線程執行的事件也可以通過log_slave_updates系統變量來決定是否寫入其自己的二進制日志中,這可以用于級聯復制的場景。
這種復制架構實現了獲取事件和重放事件的解耦,允許這兩個過程異步進行。也就是說I/O線程的執行能夠獨立于SQL線程的執行。但是,這種架構也限制了復制的過程,其中最重要的一點是在主庫上并發更新的查詢,到從庫上通常只能串行化執行了,因為系統默認只有一個SQL線程來重放中繼日志中的事件。在MySQL 5.6版本以后已經可以通過配置slave_parallel_workers等系統變量進行并行復制,在第4章討論復制性能問題時會介紹并行復制的相關細節。
現在我們已經了解了MySQL復制是以二進制日志為基礎,但是像InnoDB這樣的事務引擎有自己的事務日志,如ib_logfile,這些事務日志通常被稱為重做日志(redo log)。作為背景知識,簡單介紹一下InnoDB重做日志的作用。
對InnoDB的任何修改操作都會首先在稱為緩沖池(InnoDB Buffer Pool)的內存頁面上進行,然后這樣的頁面將被標記為臟頁,并被放到專門的刷新列表上,后續將由主線程(Master Thread)或專門“刷臟頁”的線程階段性地將這些頁面寫入磁盤。這樣做的好處是避免每次的寫庫操作都要操作磁盤,從而導致大量的隨機I/O操作,階段性地“刷臟頁”可以將多次對頁面的修改合并成一次I/O操作,同時異步寫入也降低了訪問時延。然而,如果在臟頁還未刷入到磁盤時服務器就非正常關閉了,那么這些修改操作將會丟失,如果寫入操作正在進行,甚至會由于損壞數據文件導致數據庫不可用。為了避免上述問題的發生,InnoDB將所有對頁面的修改寫入一個專門的文件,并在數據庫啟動時從此文件進行實例恢復,這個文件就是重做日志文件。每當有更新操作時,在數據頁變更之前將操作寫入重做日志,這樣當發生掉電之類的情況時,系統可以在重啟后繼續工作。這就是所謂的預寫日志(Write-Ahead Logging,WAL)。這種技術推遲了緩沖區頁面的刷新,從而提升了數據庫的吞吐量。同時由于重做日志的寫操作是順序I/O,相對于寫數據文件的隨機I/O要快得多。大多數數據庫系統都采用類似的技術實現。
聰明如你可能已經有了這樣的疑問,在復制中二進制日志和重做日志如何協同工作?假設InnoDB寫完重做日志后,服務異常關閉了。主庫能夠根據重做日志恢復數據,但由于二進制日志沒寫入,會導致從庫同步時少了這個事務么?或者反之,二進制日志寫成功,而重做日志沒有寫完,是否會導致從庫執行事務而主庫不執行?這些情況會不會造成主從數據不一致的問題呢?解決這些問題是MySQL的核心需求,讓我們從MySQL基本架構說起。圖1-2是MySQL的邏輯架構圖。
在圖1-2中,最上層用于處理客戶端連接、授權認證、安全,等等。第二層架構是MySQL服務器層。大多數MySQL的核心功能都在這一層,包括查詢解析、分析、優化、緩存以及全部內置函數,所有跨存儲引擎的功能,如存儲過程、觸發器、視圖等都在這一層實現。不出所料,二進制日志也在這一層實現。第三層包含了存儲引擎,負責MySQL中數據的存儲和提取。服務器通過API與存儲引擎進行通信,存儲引擎只是簡單地響應上層服務器的請求。顯然InnoDB的重做日志在這一層實現。

圖1-2 MySQL服務器邏輯架構圖
由于MySQL的事務日志包含二進制日志和重做日志,當發生崩潰恢復時,MySQL主庫通過重做日志進行恢復,而在主從復制的環境下,從庫依據主節點的二進制日志進行數據同步。這樣的架構對兩種日志有兩個基本要求:第一,保證二進制日志中存在的事務一定在重做日志中存在,也就是二進制日志里不會比重做日志里的事務多(可以少,因為重做日志里面記錄的事務可能有部分沒有提交,這些事務最終可能會被回滾);第二,兩種日志中事務的順序一致,這也是很重要的一點。假設兩者記錄的事務順序不一致,那么會出現類似于主庫事務執行的順序是ta、tb、tc和td,但是二進制日志中記錄的是ta、tc、tb和td,也就是被復制到從庫后主從數據不一致了。為了達到上述兩個基本要求,MySQL使用內部XA來實現。XA是eXtended Architecture的縮寫,是X/Open分布式事務定義的事務中間件與數據庫之間的接口規范,其核心是兩階段提交(Two Phase Commit,2PC)。
1.1.3 兩階段提交
在兩階段提交協議中一般分為事務管理器(協調者)和若干事務執行器(參與者)兩種角色。在MySQL內部實現的兩階段提交中,二進制日志充當了協調者角色,由它來通知InnoDB執行準備、提交或回滾。從實現角度分析,事務提交由準備階段和提交階段構成。提交流程和代碼框架分別如圖1-3和圖1-4所示。

圖1-3 MySQL兩階段提交流程

圖1-4 commit命令的MySQL代碼框架
(1)先調用binglog_hton和innobase_hton的prepare方法完成第一階段,binlog_hton的prepare方法實際上什么也沒做,InnoDB的prepare持有prepare_commit_mutex,將重做日志刷磁盤,并將事務狀態設為TRX_PREPARED。
(2)如果事務涉及的所有存儲引擎的prepare都執行成功,則調用TC_LOG_BINLOG::log_xid將事務(STATEMENT格式或ROW格式)寫到二進制日志中,此時,事務已經肯定要提交了。否則,調用ha_rollback_trans回滾事務,而事務實際上也不會寫到二進制日志中。
(3)最后,調用引擎的commit完成事務的提交。實際上binlog_hton->commit什么也不會做(上一步已經將二進制日志寫入磁盤),innobase_hton->commit則會清除回滾信息,向重做日志中寫入COMMIT標記,釋放prepare_commit_mutex,并將事務設為TRX_NOT_STARTED狀態。
如果數據庫系統發生崩潰,當重啟數據庫時會進行崩潰恢復操作。具體到代碼層面,InnoDB在恢復的時候,不同狀態的事務,會進行不同的處理:
- 對于TRX_COMMITTED_IN_MEMORY的事務,清除回滾段后,將事務設為TRX_NOT_STARTED。
- 對于TRX_NOT_STARTED的事務,表示事務已經提交,跳過。
- 對于TRX_PREPARED的事務,要根據二進制日志來決定事務是否提交,暫時跳過。
- 對于TRX_ACTIVE的事務,回滾。
簡單來講,當發生崩潰恢復時,數據庫根據重做日志進行數據恢復,逐個查看每條重做條目的事務狀態。根據圖1-3的流程,如果已進行到TRX_NOT_STARTED階段,也就是存儲引擎commit階段,那么說明重做日志和二進制日志是一致的,正常根據重做條目進行恢復即可。如果事務狀態為TRX_ACTIVE,沒寫到二進制日志中,就直接回滾。如果事務狀態為TRX_PREPARED,要分兩種情況,先檢查二進制日志是否已寫入成功,如果沒寫入成功,那么就算是TRX_PREPARED狀態也要回滾。如果寫入成功了,那么就進行最后一步,調用存儲引擎commit,更改事務狀態為TRX_NOT_STARTED,也就是真正提交狀態,可以用作數據恢復。
由此可見,MySQL是以二進制日志的寫入與否作為事務提交成功與否的標志,通過這種方式讓InnoDB重做日志和MySQL服務器的二進制日志中的事務狀態保持一致。兩階段提交很好地保持了數據一致性和事務順序性。
了解了所有這些技術細節后,當初的疑問自然也就有了答案。假設在準備階段(階段1)結束之后程序異常,此時沒有寫入二進制日志,則從庫不會同步這個事務。主庫上,崩潰恢復時重做日志中這個事務沒有trx_commit,因此會被回滾。邏輯上主從庫都不會執行這個事務。假設在提交階段(階段2)結束后程序異常,此時二進制日志已經寫入,則從庫會同步這個事務。主庫上,根據重做日志能夠正常恢復此事務。也就是說,若二進制日志寫入完成,那么主從庫都會正常提交事務,反之則主從庫都回滾事務,都不會出現主從不一致的問題。
MySQL通過innodb_support_xa系統變量控制InnoDB是否支持XA事務的2PC,默認是TRUE。如果關閉,則InnoDB在prepare階段就什么也不做,這可能會導致二進制日志的順序與InnoDB提交的順序不一致,繼而導致在恢復時或者從庫上產生不同的數據。在MySQL 8中,innodb_support_xa系統變量已被移除,也就是始終啟用InnoDB對XA事務中兩階段提交的支持,就不再交由用戶來選擇了。
上述的MySQL兩階段提交流程并不是天衣無縫的,主從數據是否一致還與重做日志和二進制日志的寫盤方式有關。innodb_flush_log_at_trx_commit和sync_binlog系統變量分別控制兩者的落盤策略。
- innodb_flush_log_at_trx_commit:有0、1、2共三個可選值。0表示每秒進行一次刷新,但是每次事務提交不進行任何操作。每秒調用fsync使數據落到磁盤,不過這里需要注意如果底層存儲有高速緩存(Cache),比如RAID Cache,那么這時也不會真正落盤。由于一般RAID卡都帶有備用電源,因此一般都認為此時數據是安全的。1代表每次事務提交都會進行刷新,這是最安全的模式;2表示每秒刷新,每次事務提交時不刷新,而是調用write將重做日志緩沖區中的內容刷到操作系統頁面緩存中。從數據安全性和性能角度來比較這三種策略的優劣為:策略1,因為每次事務提交都是重做日志落盤,所以最安全,但是由于fsync的次數增多會導致性能下降比較嚴重;策略0,表示每秒刷新,每次事務提交都不進行任何操作,所以MySQL或操作系統崩潰時最多丟失一秒的事務;策略2,相對于策略0來說多了每次事務提交時的一個write操作,此時數據雖然沒有落盤,但是只要操作系統沒有崩潰,即使MySQL崩潰,那么事務也不會丟失。
- sync_binlog:MySQL在提交事務時調用MYSQL_LOG::write執行寫二進制日志的操作,并根據sync_binlog決定是否進行刷新。默認值是0,即不刷新,從而把控制權交給操作系統。如果設置為1,則每次提交事務都會進行一次磁盤刷新。
這兩個參數不同值的組合會帶來不同的效果。兩者都設置為1,數據最安全,能保證主從一致,這也是MySQL 8的默認設置。innodb_flush_log_at_trx_commit設置為非1時,假設在二進制日志寫入完成后系統崩潰,則可能出現這樣的情況:從庫能夠執行事務,但主庫中trx_prepare的日志沒有被寫入到重做日志中,導致主庫不執行事務,就出現了主從不一致的情況。同理,若sync_binlog設置為非1時,則可能導致二進制日志丟失(如操作系統異常崩潰),從而與InnoDB層面的數據不一致,體現在復制上,從庫可能丟失事務。在數據一致性要求很高的場景下,建議就使用全部設置為1的默認設置。
- 演進式架構(原書第2版)
- Flutter開發實戰詳解
- Developing Middleware in Java EE 8
- Selenium Design Patterns and Best Practices
- Cassandra Design Patterns(Second Edition)
- 前端架構:從入門到微前端
- INSTANT Mercurial SCM Essentials How-to
- 深度強化學習算法與實踐:基于PyTorch的實現
- 零基礎學單片機C語言程序設計
- ScratchJr趣味編程動手玩:讓孩子用編程講故事
- 超簡單:用Python讓Excel飛起來(實戰150例)
- Applied Deep Learning with Python
- 高性能PHP 7
- Flask開發Web搜索引擎入門與實戰
- 數據結構與算法詳解