28 | 讀寫分離有哪些坑?

在上一篇文章中,我和你介紹了一主多從的結構以及切換流程。今天咱們就繼續聊聊一主多從架構的應用場景:讀寫分離,以及怎麼處理主備延遲致使的讀寫分離問題。html

咱們在上一篇文章中提到的一主多從的結構,其實就是讀寫分離的基本結構了。這裏,我再把這張圖貼過來,方便你理解。mysql

圖1 讀寫分離基本結構

讀寫分離的主要目標就是分攤主庫的壓力。圖1中的結構是客戶端(client)主動作負載均衡,這種模式下通常會把數據庫的鏈接信息放在客戶端的鏈接層。也就是說,由客戶端來選擇後端數據庫進行查詢。sql

還有一種架構是,在MySQL和客戶端之間有一箇中間代理層proxy,客戶端只鏈接proxy, 由proxy根據請求類型和上下文決定請求的分發路由。數據庫

圖2 帶proxy的讀寫分離架構

接下來,咱們就看一下客戶端直連和帶proxy的讀寫分離架構,各有哪些特色。後端

  1. 客戶端直連方案,由於少了一層proxy轉發,因此查詢性能稍微好一點兒,而且總體架構簡單,排查問題更方便。可是這種方案,因爲要了解後端部署細節,因此在出現主備切換、庫遷移等操做的時候,客戶端都會感知到,而且須要調整數據庫鏈接信息。
    你可能會以爲這樣客戶端也太麻煩了,信息大量冗餘,架構很醜。其實也未必,通常採用這樣的架構,必定會伴隨一個負責管理後端的組件,好比Zookeeper,儘可能讓業務端只專一於業務邏輯開發。api

  2. 帶proxy的架構,對客戶端比較友好。客戶端不須要關注後端細節,鏈接維護、後端信息維護等工做,都是由proxy完成的。但這樣的話,對後端維護團隊的要求會更高。並且,proxy也須要有高可用架構。所以,帶proxy架構的總體就相對比較複雜。session

理解了這兩種方案的優劣,具體選擇哪一個方案就取決於數據庫團隊提供的能力了。但目前看,趨勢是往帶proxy的架構方向發展的。架構

可是,不論使用哪一種架構,你都會碰到咱們今天要討論的問題:因爲主從可能存在延遲,客戶端執行完一個更新事務後立刻發起查詢,若是查詢選擇的是從庫的話,就有可能讀到剛剛的事務更新以前的狀態。負載均衡

這種「在從庫上會讀到系統的一個過時狀態」的現象,在這篇文章裏,咱們暫且稱之爲「過時讀」。異步

前面咱們說過了幾種可能致使主備延遲的緣由,以及對應的優化策略,可是主從延遲仍是不能100%避免的。

不論哪一種結構,客戶端都但願查詢從庫的數據結果,跟查主庫的數據結果是同樣的。

接下來,咱們就來討論怎麼處理過時讀問題。

這裏,我先把文章中涉及到的處理過時讀的方案彙總在這裏,以幫助你更好地理解和掌握全文的知識脈絡。這些方案包括:

  • 強制走主庫方案;
  • sleep方案;
  • 判斷主備無延遲方案;
  • 配合semi-sync方案;
  • 等主庫位點方案;
  • 等GTID方案。

強制走主庫方案

強制走主庫方案其實就是,將查詢請求作分類。一般狀況下,咱們能夠將查詢請求分爲這麼兩類:

  1. 對於必需要拿到最新結果的請求,強制將其發到主庫上。好比,在一個交易平臺上,賣家發佈商品之後,立刻要返回主頁面,看商品是否發佈成功。那麼,這個請求須要拿到最新的結果,就必須走主庫。

  2. 對於能夠讀到舊數據的請求,纔將其發到從庫上。在這個交易平臺上,買家來逛商鋪頁面,就算晚幾秒看到最新發布的商品,也是能夠接受的。那麼,這類請求就能夠走從庫。

你可能會說,這個方案是否是有點畏難和取巧的意思,但其實這個方案是用得最多的。

固然,這個方案最大的問題在於,有時候你會碰到「全部查詢都不能是過時讀」的需求,好比一些金融類的業務。這樣的話,你就要放棄讀寫分離,全部讀寫壓力都在主庫,等同於放棄了擴展性。

所以接下來,咱們來討論的話題是:能夠支持讀寫分離的場景下,有哪些解決過時讀的方案,並分析各個方案的優缺點。

Sleep 方案

主庫更新後,讀從庫以前先sleep一下。具體的方案就是,相似於執行一條select sleep(1)命令。

這個方案的假設是,大多數狀況下主備延遲在1秒以內,作一個sleep能夠有很大機率拿到最新的數據。

這個方案給你的第一感受,極可能是不靠譜兒,應該不會有人用吧?而且,你還可能會說,直接在發起查詢時先執行一條sleep語句,用戶體驗很不友好啊。

但,這個思路確實能夠在必定程度上解決問題。爲了看起來更靠譜兒,咱們能夠換一種方式。

以賣家發佈商品爲例,商品發佈後,用Ajax(Asynchronous JavaScript + XML,異步JavaScript和XML)直接把客戶端輸入的內容做爲「新的商品」顯示在頁面上,而不是真正地去數據庫作查詢。

這樣,賣家就能夠經過這個顯示,來確認產品已經發布成功了。等到賣家再刷新頁面,去查看商品的時候,其實已通過了一段時間,也就達到了sleep的目的,進而也就解決了過時讀的問題。

也就是說,這個sleep方案確實解決了相似場景下的過時讀問題。但,從嚴格意義上來講,這個方案存在的問題就是不精確。這個不精確包含了兩層意思:

  1. 若是這個查詢請求原本0.5秒就能夠在從庫上拿到正確結果,也會等1秒;

  2. 若是延遲超過1秒,仍是會出現過時讀。

看到這裏,你是否是有一種「你是否是在逗我」的感受,這個改進方案雖然能夠解決相似Ajax場景下的過時讀問題,但仍是怎麼看都不靠譜兒。彆着急,接下來我就和你介紹一些更準確的方案。

判斷主備無延遲方案

要確保備庫無延遲,一般有三種作法。

經過前面的第25篇文章,咱們知道show slave status結果裏的seconds_behind_master參數的值,能夠用來衡量主備延遲時間的長短。

因此第一種確保主備無延遲的方法是,每次從庫執行查詢請求前,先判斷seconds_behind_master是否已經等於0。若是還不等於0 ,那就必須等到這個參數變爲0才能執行查詢請求。

seconds_behind_master的單位是秒,若是你以爲精度不夠的話,還能夠採用對比位點和GTID的方法來確保主備無延遲,也就是咱們接下來要說的第二和第三種方法。

如圖3所示,是一個show slave status結果的部分截圖。

圖3 show slave status結果

如今,咱們就經過這個結果,來看看具體如何經過對比位點和GTID來確保主備無延遲。

第二種方法,對比位點確保主備無延遲:

  • Master_Log_File和Read_Master_Log_Pos,表示的是讀到的主庫的最新位點;
  • Relay_Master_Log_File和Exec_Master_Log_Pos,表示的是備庫執行的最新位點。

若是Master_Log_File和Relay_Master_Log_File、Read_Master_Log_Pos和Exec_Master_Log_Pos這兩組值徹底相同,就表示接收到的日誌已經同步完成。

第三種方法,對比GTID集合確保主備無延遲:

  • Auto_Position=1 ,表示這對主備關係使用了GTID協議。
  • Retrieved_Gtid_Set,是備庫收到的全部日誌的GTID集合;
  • Executed_Gtid_Set,是備庫全部已經執行完成的GTID集合。

若是這兩個集合相同,也表示備庫接收到的日誌都已經同步完成。

可見,對比位點和對比GTID這兩種方法,都要比判斷seconds_behind_master是否爲0更準確。

在執行查詢請求以前,先判斷從庫是否同步完成的方法,相比於sleep方案,準確度確實提高了很多,但仍是沒有達到「精確」的程度。爲何這麼說呢?

咱們如今一塊兒來回顧下,一個事務的binlog在主備庫之間的狀態:

  1. 主庫執行完成,寫入binlog,並反饋給客戶端;

  2. binlog被從主庫發送給備庫,備庫收到;

  3. 在備庫執行binlog完成。

咱們上面判斷主備無延遲的邏輯,是「備庫收到的日誌都執行完成了」。可是,從binlog在主備之間狀態的分析中,不難看出還有一部分日誌,處於客戶端已經收到提交確認,而備庫還沒收到日誌的狀態。

如圖4所示就是這樣的一個狀態。

圖4 備庫還沒收到trx3

這時,主庫上執行完成了三個事務trx一、trx2和trx3,其中:

  1. trx1和trx2已經傳到從庫,而且已經執行完成了;

  2. trx3在主庫執行完成,而且已經回覆給客戶端,可是尚未傳到從庫中。

若是這時候你在從庫B上執行查詢請求,按照咱們上面的邏輯,從庫認爲已經沒有同步延遲,但仍是查不到trx3的。嚴格地說,就是出現了過時讀。

那麼,這個問題有沒有辦法解決呢?

配合semi-sync

要解決這個問題,就要引入半同步複製,也就是semi-sync replication。

semi-sync作了這樣的設計:

  1. 事務提交的時候,主庫把binlog發給從庫;

  2. 從庫收到binlog之後,發回給主庫一個ack,表示收到了;

  3. 主庫收到這個ack之後,才能給客戶端返回「事務完成」的確認。

也就是說,若是啓用了semi-sync,就表示全部給客戶端發送過確認的事務,都確保了備庫已經收到了這個日誌。

第25篇文章的評論區,有同窗問到:若是主庫掉電的時候,有些binlog還來不及發給從庫,會不會致使系統數據丟失?

答案是,若是使用的是普通的異步複製模式,就可能會丟失,但semi-sync就能夠解決這個問題。

這樣,semi-sync配合前面關於位點的判斷,就可以肯定在從庫上執行的查詢請求,能夠避免過時讀。

可是,semi-sync+位點判斷的方案,只對一主一備的場景是成立的。在一主多從場景中,主庫只要等到一個從庫的ack,就開始給客戶端返回確認。這時,在從庫上執行查詢請求,就有兩種狀況:

  1. 若是查詢是落在這個響應了ack的從庫上,是可以確保讀到最新數據;

  2. 但若是是查詢落到其餘從庫上,它們可能尚未收到最新的日誌,就會產生過時讀的問題。

其實,判斷同步位點的方案還有另一個潛在的問題,即:若是在業務更新的高峯期,主庫的位點或者GTID集合更新很快,那麼上面的兩個位點等值判斷就會一直不成立,極可能出現從庫上遲遲沒法響應查詢請求的狀況。

實際上,回到咱們最初的業務邏輯裏,當發起一個查詢請求之後,咱們要獲得準確的結果,其實並不須要等到「主備徹底同步」。

爲何這麼說呢?咱們來看一下這個時序圖。

圖5 主備持續延遲一個事務

圖5所示,就是等待位點方案的一個bad case。圖中備庫B下的虛線框,分別表示relaylog和binlog中的事務。能夠看到,圖5中從狀態1 到狀態4,一直處於延遲一個事務的狀態。

備庫B一直到狀態4都和主庫A存在延遲,若是用上面必須等到無延遲才能查詢的方案,select語句直到狀態4都不能被執行。

可是,其實客戶端是在發完trx1更新後發起的select語句,咱們只須要確保trx1已經執行完成就能夠執行select語句了。也就是說,若是在狀態3執行查詢請求,獲得的就是預期結果了。

到這裏,咱們小結一下,semi-sync配合判斷主備無延遲的方案,存在兩個問題:

  1. 一主多從的時候,在某些從庫執行查詢請求會存在過時讀的現象;

  2. 在持續延遲的狀況下,可能出現過分等待的問題。

接下來,我要和你介紹的等主庫位點方案,就能夠解決這兩個問題。

等主庫位點方案

要理解等主庫位點方案,我須要先和你介紹一條命令:

select master_pos_wait(file, pos[, timeout]);

這條命令的邏輯以下:

  1. 它是在從庫執行的;

  2. 參數file和pos指的是主庫上的文件名和位置;

  3. timeout可選,設置爲正整數N表示這個函數最多等待N秒。

這個命令正常返回的結果是一個正整數M,表示從命令開始執行,到應用完file和pos表示的binlog位置,執行了多少事務。

固然,除了正常返回一個正整數M外,這條命令還會返回一些其餘結果,包括:

  1. 若是執行期間,備庫同步線程發生異常,則返回NULL;

  2. 若是等待超過N秒,就返回-1;

  3. 若是剛開始執行的時候,就發現已經執行過這個位置了,則返回0。

對於圖5中先執行trx1,再執行一個查詢請求的邏輯,要保證可以查到正確的數據,咱們可使用這個邏輯:

  1. trx1事務更新完成後,立刻執行show master status獲得當前主庫執行到的File和Position;

  2. 選定一個從庫執行查詢語句;

  3. 在從庫上執行select master_pos_wait(File, Position, 1);

  4. 若是返回值是>=0的正整數,則在這個從庫執行查詢語句;

  5. 不然,到主庫執行查詢語句。

我把上面這個流程畫出來。

圖6 master_pos_wait方案

這裏咱們假設,這條select查詢最多在從庫上等待1秒。那麼,若是1秒內master_pos_wait返回一個大於等於0的整數,就確保了從庫上執行的這個查詢結果必定包含了trx1的數據。

步驟5到主庫執行查詢語句,是這類方案經常使用的退化機制。由於從庫的延遲時間不可控,不能無限等待,因此若是等待超時,就應該放棄,而後到主庫去查。

你可能會說,若是全部的從庫都延遲超過1秒了,那查詢壓力不就都跑到主庫上了嗎?確實是這樣。

可是,按照咱們設定不容許過時讀的要求,就只有兩種選擇,一種是超時放棄,一種是轉到主庫查詢。具體怎麼選擇,就須要業務開發同窗作好限流策略了。

GTID方案

若是你的數據庫開啓了GTID模式,對應的也有等待GTID的方案。

MySQL中一樣提供了一個相似的命令:

select wait_for_executed_gtid_set(gtid_set, 1);

這條命令的邏輯是:

  1. 等待,直到這個庫執行的事務中包含傳入的gtid_set,返回0;

  2. 超時返回1。

在前面等位點的方案中,咱們執行完事務後,還要主動去主庫執行show master status。而MySQL 5.7.6版本開始,容許在執行完更新類事務後,把這個事務的GTID返回給客戶端,這樣等GTID的方案就能夠減小一次查詢。

這時,等GTID的執行流程就變成了:

  1. trx1事務更新完成後,從返回包直接獲取這個事務的GTID,記爲gtid1;

  2. 選定一個從庫執行查詢語句;

  3. 在從庫上執行 select wait_for_executed_gtid_set(gtid1, 1);

  4. 若是返回值是0,則在這個從庫執行查詢語句;

  5. 不然,到主庫執行查詢語句。

跟等主庫位點的方案同樣,等待超時後是否直接到主庫查詢,須要業務開發同窗來作限流考慮。

我把這個流程圖畫出來。

圖7 wait_for_executed_gtid_set方案

在上面的第一步中,trx1事務更新完成後,從返回包直接獲取這個事務的GTID。問題是,怎麼可以讓MySQL在執行事務後,返回包中帶上GTID呢?

你只須要將參數session_track_gtids設置爲OWN_GTID,而後經過API接口mysql_session_track_get_first從返回包解析出GTID的值便可。

在專欄的第一篇文章中,我介紹mysql_reset_connection的時候,評論區有同窗留言問這類接口應該怎麼使用。

這裏我再回答一下。其實,MySQL並無提供這類接口的SQL用法,是提供給程序的API(https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html)。

好比,爲了讓客戶端在事務提交後,返回的GITD可以在客戶端顯示出來,我對MySQL客戶端代碼作了點修改,以下所示:

圖8 顯示更新事務的GTID--代碼

這樣,就能夠看到語句執行完成,顯示出GITD的值。

圖9 顯示更新事務的GTID--效果

固然了,這只是一個例子。你要使用這個方案的時候,仍是應該在你的客戶端代碼中調用mysql_session_track_get_first這個函數。

小結

在今天這篇文章中,我跟你介紹了一主多從作讀寫分離時,可能碰到過時讀的緣由,以及幾種應對的方案。

這幾種方案中,有的方案看上去是作了妥協,有的方案看上去不那麼靠譜兒,但都是有實際應用場景的,你須要根據業務需求選擇。

即便是最後等待位點和等待GTID這兩個方案,雖然看上去比較靠譜兒,但仍然存在須要權衡的狀況。若是全部的從庫都延遲,那麼請求就會所有落到主庫上,這時候會不會因爲壓力忽然增大,把主庫打掛了呢?

其實,在實際應用中,這幾個方案是能夠混合使用的。

好比,先在客戶端對請求作分類,區分哪些請求能夠接受過時讀,而哪些請求徹底不能接受過時讀;而後,對於不能接受過時讀的語句,再使用等GTID或等位點的方案。

但話說回來,過時讀在本質上是由一寫多讀致使的。在實際應用中,可能會有別的不須要等待就能夠水平擴展的數據庫方案,但這每每是用犧牲寫性能換來的,也就是須要在讀性能和寫性能中取權衡。

最後 ,我給你留下一個問題吧。

假設你的系統採用了咱們文中介紹的最後一個方案,也就是等GTID的方案,如今你要對主庫的一張大表作DDL,可能會出現什麼狀況呢?爲了不這種狀況,你會怎麼作呢?

你能夠把你的分析和方案設計寫在評論區,我會在下一篇文章跟你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一塊兒閱讀。

上期問題時間

上期給你留的問題是,在GTID模式下,若是一個新的從庫接上主庫,可是須要的binlog已經沒了,要怎麼作?

@某、人同窗給了很詳細的分析,我把他的回答略作修改貼過來。

  1. 若是業務容許主從不一致的狀況,那麼能夠在主庫上先執行show global variables like ‘gtid_purged’,獲得主庫已經刪除的GTID集合,假設是gtid_purged1;而後先在從庫上執行reset master,再執行set global gtid_purged =‘gtid_purged1’;最後執行start slave,就會從主庫現存的binlog開始同步。binlog缺失的那一部分,數據在從庫上就可能會有丟失,形成主從不一致。

  2. 若是須要主從數據一致的話,最好仍是經過從新搭建從庫來作。

  3. 若是有其餘的從庫保留有全量的binlog的話,能夠把新的從庫先接到這個保留了全量binlog的從庫,追上日誌之後,若是有須要,再接回主庫。

  4. 若是binlog有備份的狀況,能夠先在從庫上應用缺失的binlog,而後再執行start slave。

相關文章
相關標籤/搜索