上文一主多從的結(jié)構(gòu)以及切換流程。本文我們就繼續(xù)聊聊一主多從架構(gòu)的應(yīng)用場景:讀寫分離,以及怎么處理主備延遲導(dǎo)致的讀寫分離問題。
一主多從的結(jié)構(gòu),其實(shí)就是讀寫分離的基本結(jié)構(gòu)了,如下圖所示:

讀寫分離的主要目標(biāo)就是分?jǐn)傊鲙斓膲毫?。圖中的結(jié)構(gòu)是客戶端(client)主動做負(fù)載均衡,這種模式下一般會把數(shù)據(jù)庫的連接信息放在客戶端的連接層。也就是說,由客戶端來選擇后端數(shù)據(jù)庫進(jìn)行查詢。
還有一種架構(gòu)是,在 MySQL 和客戶端之間有一個中間代理層 proxy,客戶端只連接 proxy, 由 proxy 根據(jù)請求類型和上下文決定請求的分發(fā)路由。

- 接下來,我們就看一下客戶端直連和帶 proxy 的讀寫分離架構(gòu),各有哪些特點(diǎn)。
- 客戶端直連方案,因?yàn)樯倭艘粚?proxy 轉(zhuǎn)發(fā),所以查詢性能稍微好一點(diǎn)兒,并且整體架構(gòu)簡單,排查問題更方便。但是這種方案,由于要了解后端部署細(xì)節(jié),所以在出現(xiàn)主備切換、庫遷移等操作的時候,客戶端都會感知到,并且需要調(diào)整數(shù)據(jù)庫連接信息。
你可能會覺得這樣客戶端也太麻煩了,信息大量冗余,架構(gòu)很丑。其實(shí)也未必,一般采用這樣的架構(gòu),一定會伴隨一個負(fù)責(zé)管理后端的組件,比如 Zookeeper,盡量讓業(yè)務(wù)端只專注于業(yè)務(wù)邏輯開發(fā)。 - 帶 proxy 的架構(gòu),對客戶端比較友好??蛻舳瞬恍枰P(guān)注后端細(xì)節(jié),連接維護(hù)、后端信息維護(hù)等工作,都是由 proxy 完成的。但這樣的話,對后端維護(hù)團(tuán)隊(duì)的要求會更高。而且,proxy 也需要有高可用架構(gòu)。因此,帶 proxy 架構(gòu)的整體就相對比較復(fù)雜。
- 客戶端直連方案,因?yàn)樯倭艘粚?proxy 轉(zhuǎn)發(fā),所以查詢性能稍微好一點(diǎn)兒,并且整體架構(gòu)簡單,排查問題更方便。但是這種方案,由于要了解后端部署細(xì)節(jié),所以在出現(xiàn)主備切換、庫遷移等操作的時候,客戶端都會感知到,并且需要調(diào)整數(shù)據(jù)庫連接信息。
理解了這兩種方案的優(yōu)劣,具體選擇哪個方案就取決于數(shù)據(jù)庫團(tuán)隊(duì)提供的能力了。但目前看,趨勢是往帶 proxy 的架構(gòu)方向發(fā)展的。
但是,不論使用哪種架構(gòu),你都會碰到本文要討論的問題:由于主從可能存在延遲,客戶端執(zhí)行完一個更新事務(wù)后馬上發(fā)起查詢,如果查詢選擇的是從庫的話,就有可能讀到剛剛的事務(wù)更新之前的狀態(tài)。
這種“在從庫上會讀到系統(tǒng)的一個過期狀態(tài)”的現(xiàn)象,我們暫且稱之為“過期讀”。
前面我們說過了幾種可能導(dǎo)致主備延遲的原因,以及對應(yīng)的優(yōu)化策略,但是主從延遲還是不能 100% 避免的。
不論哪種結(jié)構(gòu),客戶端都希望查詢從庫的數(shù)據(jù)結(jié)果,跟查主庫的數(shù)據(jù)結(jié)果是一樣的。接下來,我們就來討論怎么處理過期讀問題。這些方案包括:
- 強(qiáng)制走主庫方案;
- sleep 方案;
- 判斷主備無延遲方案;
- 配合 semi-sync 方案;
- 等主庫位點(diǎn)方案;
- 等 GTID 方案。
強(qiáng)制走主庫方案
- 強(qiáng)制走主庫方案其實(shí)就是,將查詢請求做分類。通常情況下,我們可以將查詢請求分為這么兩類:
- 對于必須要拿到最新結(jié)果的請求,強(qiáng)制將其發(fā)到主庫上。比如,在一個交易平臺上,賣家發(fā)布商品以后,馬上要返回主頁面,看商品是否發(fā)布成功。那么,這個請求需要拿到最新的結(jié)果,就必須走主庫。
- 對于可以讀到舊數(shù)據(jù)的請求,才將其發(fā)到從庫上。在這個交易平臺上,買家來逛商鋪頁面,就算晚幾秒看到最新發(fā)布的商品,也是可以接受的。那么,這類請求就可以走從庫。
- 你可能會說,這個方案是不是有點(diǎn)畏難和取巧的意思,但其實(shí)這個方案是用得最多的。
- 當(dāng)然,這個方案最大的問題在于,有時候你會碰到“所有查詢都不能是過期讀”的需求,比如一些金融類的業(yè)務(wù)。這樣的話,你就要放棄讀寫分離,所有讀寫壓力都在主庫,等同于放棄了擴(kuò)展性。
- 因此接下來,我們來討論的話題是:可以支持讀寫分離的場景下,有哪些解決過期讀的方案,并分析各個方案的優(yōu)缺點(diǎn)。
Sleep 方案
- 主庫更新后,讀從庫之前先 sleep 一下。具體的方案就是,類似于執(zhí)行一條 select sleep(1) 命令。
- 這個方案的假設(shè)是,大多數(shù)情況下主備延遲在 1 秒之內(nèi),做一個 sleep 可以有很大概率拿到最新的數(shù)據(jù)。
- 這個方案給你的第一感覺,很可能是不靠譜兒,應(yīng)該不會有人用吧?并且,你還可能會說,直接在發(fā)起查詢時先執(zhí)行一條 sleep 語句,用戶體驗(yàn)很不友好啊。
- 但,這個思路確實(shí)可以在一定程度上解決問題。為了看起來更靠譜兒,我們可以換一種方式。
- 以賣家發(fā)布商品為例,商品發(fā)布后,用 Ajax(Asynchronous JavaScript + XML,異步 JavaScript 和 XML)直接把客戶端輸入的內(nèi)容作為“新的商品”顯示在頁面上,而不是真正地去數(shù)據(jù)庫做查詢。
- 這樣,賣家就可以通過這個顯示,來確認(rèn)產(chǎn)品已經(jīng)發(fā)布成功了。等到賣家再刷新頁面,去查看商品的時候,其實(shí)已經(jīng)過了一段時間,也就達(dá)到了 sleep 的目的,進(jìn)而也就解決了過期讀的問題。
- 也就是說,這個 sleep 方案確實(shí)解決了類似場景下的過期讀問題。但,從嚴(yán)格意義上來說,這個方案存在的問題就是不精確。這個不精確包含了兩層意思:
- 如果這個查詢請求本來 0.5 秒就可以在從庫上拿到正確結(jié)果,也會等 1 秒;
- 如果延遲超過 1 秒,還是會出現(xiàn)過期讀。
- 看到這里,你是不是有一種“你是不是在逗我”的感覺,這個改進(jìn)方案雖然可以解決類似 Ajax 場景下的過期讀問題,但還是怎么看都不靠譜兒。別著急,接下來就和你介紹一些更準(zhǔn)確的方案。
判斷主備無延遲方案
- 要確保備庫無延遲,通常有三種做法。
- 我們知道 show slave status 結(jié)果里的 seconds_behind_master 參數(shù)的值,可以用來衡量主備延遲時間的長短。
- 第一種確保主備無延遲的方法是,每次從庫執(zhí)行查詢請求前,先判斷 seconds_behind_master 是否已經(jīng)等于 0。如果還不等于 0 ,那就必須等到這個參數(shù)變?yōu)?0 才能執(zhí)行查詢請求。
- seconds_behind_master 的單位是秒,如果你覺得精度不夠的話,還可以采用對比位點(diǎn)和 GTID 的方法來確保主備無延遲,也就是我們接下來要說的第二和第三種方法。

- 現(xiàn)在,我們就通過這個結(jié)果,來看看具體如何通過對比位點(diǎn)和 GTID 來確保主備無延遲。
- 第二種方法,對比位點(diǎn)確保主備無延遲:
- Master_Log_File 和 Read_Master_Log_Pos,表示的是讀到的主庫的最新位點(diǎn);
- Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是備庫執(zhí)行的最新位點(diǎn)。
- 如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 這兩組值完全相同,就表示接收到的日志已經(jīng)同步完成。
- 第三種方法,對比 GTID 集合確保主備無延遲:
- Auto_Position=1 ,表示這對主備關(guān)系使用了 GTID 協(xié)議。
- Retrieved_Gtid_Set,是備庫收到的所有日志的 GTID 集合;
- Executed_Gtid_Set,是備庫所有已經(jīng)執(zhí)行完成的 GTID 集合。
- 如果這兩個集合相同,也表示備庫接收到的日志都已經(jīng)同步完成。
- 可見,對比位點(diǎn)和對比 GTID 這兩種方法,都要比判斷 seconds_behind_master 是否為 0 更準(zhǔn)確。
- 在執(zhí)行查詢請求之前,先判斷從庫是否同步完成的方法,相比于 sleep 方案,準(zhǔn)確度確實(shí)提升了不少,但還是沒有達(dá)到“精確”的程度。為什么這么說呢?
- 我們現(xiàn)在一起來回顧下,一個事務(wù)的 binlog 在主備庫之間的狀態(tài):
- 主庫執(zhí)行完成,寫入 binlog,并反饋給客戶端;
- binlog 被從主庫發(fā)送給備庫,備庫收到;
- 在備庫執(zhí)行 binlog 完成。
- 我們上面判斷主備無延遲的邏輯,是“備庫收到的日志都執(zhí)行完成了”。但是,從 binlog 在主備之間狀態(tài)的分析中,不難看出還有一部分日志,處于客戶端已經(jīng)收到提交確認(rèn),而備庫還沒收到日志的狀態(tài)。

- 這時,主庫上執(zhí)行完成了三個事務(wù) trx1、trx2 和 trx3,其中:
- trx1 和 trx2 已經(jīng)傳到從庫,并且已經(jīng)執(zhí)行完成了;
- trx3 在主庫執(zhí)行完成,并且已經(jīng)回復(fù)給客戶端,但是還沒有傳到從庫中。
- 如果這時候你在從庫 B 上執(zhí)行查詢請求,按照我們上面的邏輯,從庫認(rèn)為已經(jīng)沒有同步延遲,但還是查不到 trx3 的。嚴(yán)格地說,就是出現(xiàn)了過期讀。
- 那么,這個問題有沒有辦法解決呢?
配合 semi-sync
- 要解決這個問題,就要引入半同步復(fù)制,也就是 semi-sync replication。
semi-sync 做了這樣的設(shè)計(jì):- 事務(wù)提交的時候,主庫把 binlog 發(fā)給從庫;
- 從庫收到 binlog 以后,發(fā)回給主庫一個 ack,表示收到了;
- 主庫收到這個 ack 以后,才能給客戶端返回“事務(wù)完成”的確認(rèn)。
- 也就是說,如果啟用了 semi-sync,就表示所有給客戶端發(fā)送過確認(rèn)的事務(wù),都確保了備庫已經(jīng)收到了這個日志。
- 如果主庫掉電的時候,有些 binlog 還來不及發(fā)給從庫,會不會導(dǎo)致系統(tǒng)數(shù)據(jù)丟失?
答案是,如果使用的是普通的異步復(fù)制模式,就可能會丟失,但 semi-sync 就可以解決這個問題。 - 這樣,semi-sync 配合前面關(guān)于位點(diǎn)的判斷,就能夠確定在從庫上執(zhí)行的查詢請求,可以避免過期讀。
- 但是,semi-sync+ 位點(diǎn)判斷的方案,只對一主一備的場景是成立的。在一主多從場景中,主庫只要等到一個從庫的 ack,就開始給客戶端返回確認(rèn)。這時,在從庫上執(zhí)行查詢請求,就有兩種情況:
- 如果查詢是落在這個響應(yīng)了 ack 的從庫上,是能夠確保讀到最新數(shù)據(jù);
- 但如果是查詢落到其他從庫上,它們可能還沒有收到最新的日志,就會產(chǎn)生過期讀的問題。
- 其實(shí),判斷同步位點(diǎn)的方案還有另外一個潛在的問題,即:如果在業(yè)務(wù)更新的高峰期,主庫的位點(diǎn)或者 GTID 集合更新很快,那么上面的兩個位點(diǎn)等值判斷就會一直不成立,很可能出現(xiàn)從庫上遲遲無法響應(yīng)查詢請求的情況。
- 實(shí)際上,回到我們最初的業(yè)務(wù)邏輯里,當(dāng)發(fā)起一個查詢請求以后,我們要得到準(zhǔn)確的結(jié)果,其實(shí)并不需要等到“主備完全同步”。

- 如上圖所示,就是等待位點(diǎn)方案的一個 bad case。圖中備庫 B 下的虛線框,分別表示 relaylog 和 binlog 中的事務(wù)。可以看到,圖中從狀態(tài) 1 到狀態(tài) 4,一直處于延遲一個事務(wù)的狀態(tài)。
- 備庫 B 一直到狀態(tài) 4 都和主庫 A 存在延遲,如果用上面必須等到無延遲才能查詢的方案,select 語句直到狀態(tài) 4 都不能被執(zhí)行。
- 但是,其實(shí)客戶端是在發(fā)完 trx1 更新后發(fā)起的 select 語句,我們只需要確保 trx1 已經(jīng)執(zhí)行完成就可以執(zhí)行 select 語句了。也就是說,如果在狀態(tài) 3 執(zhí)行查詢請求,得到的就是預(yù)期結(jié)果了。
- 到這里,我們小結(jié)一下,semi-sync 配合判斷主備無延遲的方案,存在兩個問題:
- 一主多從的時候,在某些從庫執(zhí)行查詢請求會存在過期讀的現(xiàn)象;
- 在持續(xù)延遲的情況下,可能出現(xiàn)過度等待的問題。
- 接下來,我要和你介紹的等主庫位點(diǎn)方案,就可以解決這兩個問題。
等主庫位點(diǎn)方案
- 要理解等主庫位點(diǎn)方案,需要先和你介紹一條命令:
select master_pos_wait(file, pos[, timeout]);
- 這條命令的邏輯如下:
- 它是在從庫執(zhí)行的;
- 參數(shù) file 和 pos 指的是主庫上的文件名和位置;
- timeout 可選,設(shè)置為正整數(shù) N 表示這個函數(shù)最多等待 N 秒。
- 這個命令正常返回的結(jié)果是一個正整數(shù) M,表示從命令開始執(zhí)行,到應(yīng)用完 file 和 pos 表示的 binlog 位置,執(zhí)行了多少事務(wù)。當(dāng)然,除了正常返回一個正整數(shù) M 外,這條命令還會返回一些其他結(jié)果,包括:
- 如果執(zhí)行期間,備庫同步線程發(fā)生異常,則返回 NULL;
- 如果等待超過 N 秒,就返回 -1;
- 如果剛開始執(zhí)行的時候,就發(fā)現(xiàn)已經(jīng)執(zhí)行過這個位置了,則返回 0。
- 對于圖中先執(zhí)行 trx1,再執(zhí)行一個查詢請求的邏輯,要保證能夠查到正確的數(shù)據(jù),我們可以使用這個邏輯:
- trx1 事務(wù)更新完成后,馬上執(zhí)行 show master status 得到當(dāng)前主庫執(zhí)行到的 File 和 Position;
- 選定一個從庫執(zhí)行查詢語句;
- 在從庫上執(zhí)行 select master_pos_wait(File, Position, 1);
- 如果返回值是 >=0 的正整數(shù),則在這個從庫執(zhí)行查詢語句;
- 否則,到主庫執(zhí)行查詢語句。

- 這里我們假設(shè),這條 select 查詢最多在從庫上等待 1 秒。那么,如果 1 秒內(nèi) master_pos_wait 返回一個大于等于 0 的整數(shù),就確保了從庫上執(zhí)行的這個查詢結(jié)果一定包含了 trx1 的數(shù)據(jù)。
- 步驟 5 到主庫執(zhí)行查詢語句,是這類方案常用的退化機(jī)制。因?yàn)閺膸斓难舆t時間不可控,不能無限等待,所以如果等待超時,就應(yīng)該放棄,然后到主庫去查。
- 你可能會說,如果所有的從庫都延遲超過 1 秒了,那查詢壓力不就都跑到主庫上了嗎?確實(shí)是這樣。
- 但是,按照我們設(shè)定不允許過期讀的要求,就只有兩種選擇,一種是超時放棄,一種是轉(zhuǎn)到主庫查詢。具體怎么選擇,就需要業(yè)務(wù)開發(fā)同學(xué)做好限流策略了。
GTID 方案
- 如果你的數(shù)據(jù)庫開啟了 GTID 模式,對應(yīng)的也有等待 GTID 的方案。
MySQL 中同樣提供了一個類似的命令:
select wait_for_executed_gtid_set(gtid_set, 1);
- 這條命令的邏輯是:
- 等待,直到這個庫執(zhí)行的事務(wù)中包含傳入的 gtid_set,返回 0;
- 超時返回 1。
- 在前面等位點(diǎn)的方案中,我們執(zhí)行完事務(wù)后,還要主動去主庫執(zhí)行 show master status。而 MySQL 5.7.6 版本開始,允許在執(zhí)行完更新類事務(wù)后,把這個事務(wù)的 GTID 返回給客戶端,這樣等 GTID 的方案就可以減少一次查詢。
這時,等 GTID 的執(zhí)行流程就變成了:- trx1 事務(wù)更新完成后,從返回包直接獲取這個事務(wù)的 GTID,記為 gtid1;
- 選定一個從庫執(zhí)行查詢語句;
- 在從庫上執(zhí)行 select wait_for_executed_gtid_set(gtid1, 1);
- 如果返回值是 0,則在這個從庫執(zhí)行查詢語句;
- 否則,到主庫執(zhí)行查詢語句。
- 跟等主庫位點(diǎn)的方案一樣,等待超時后是否直接到主庫查詢,需要業(yè)務(wù)開發(fā)同學(xué)來做限流考慮。

- 在上面的第一步中,trx1 事務(wù)更新完成后,從返回包直接獲取這個事務(wù)的 GTID。問題是,怎么能夠讓 MySQL 在執(zhí)行事務(wù)后,返回包中帶上 GTID 呢?
你只需要將參數(shù) session_track_gtids 設(shè)置為 OWN_GTID,然后通過 API 接口 mysql_session_track_get_first 從返回包解析出 GTID 的值即可。 - MySQL 并沒有提供這類接口的 SQL 用法,是提供給程序的 API,比如,為了讓客戶端在事務(wù)提交后,返回的 GITD 能夠在客戶端顯示出來

- 這樣,就可以看到語句執(zhí)行完成,顯示出 GITD 的值。

- 這只是一個例子。你要使用這個方案的時候,還是應(yīng)該在你的客戶端代碼中調(diào)用 mysql_session_track_get_first 這個函數(shù)。
小結(jié)
- 本文介紹了一主多從做讀寫分離時,可能碰到過期讀的原因,以及幾種應(yīng)對的方案。
- 這幾種方案中,有的方案看上去是做了妥協(xié),有的方案看上去不那么靠譜兒,但都是有實(shí)際應(yīng)用場景的,你需要根據(jù)業(yè)務(wù)需求選擇。
- 即使是最后等待位點(diǎn)和等待 GTID 這兩個方案,雖然看上去比較靠譜兒,但仍然存在需要權(quán)衡的情況。如果所有的從庫都延遲,那么請求就會全部落到主庫上,這時候會不會由于壓力突然增大,把主庫打掛了呢?
- 其實(shí),在實(shí)際應(yīng)用中,這幾個方案是可以混合使用的。
- 比如,先在客戶端對請求做分類,區(qū)分哪些請求可以接受過期讀,而哪些請求完全不能接受過期讀;然后,對于不能接受過期讀的語句,再使用等 GTID 或等位點(diǎn)的方案。
- 但話說回來,過期讀在本質(zhì)上是由一寫多讀導(dǎo)致的。在實(shí)際應(yīng)用中,可能會有別的不需要等待就可以水平擴(kuò)展的數(shù)據(jù)庫方案,但這往往是用犧牲寫性能換來的,也就是需要在讀性能和寫性能中取權(quán)衡。