天天看點

線上MySQL讀寫分離,出現寫完讀不到問題如何解決

大家好,我是曆小冰。

今天我們來詳細了解一下主從同步延遲時讀寫分離發生寫後讀不到的問題,依次講解問題出現的原因,解決政策以及 Sharding-jdbc、MyCat 和 MaxScale 等開源資料庫中間件具體的實作方案。

寫後讀不到問題

MySQL 經典的一主兩從三節點架構是大多數創業公司初期使用的主流資料存儲方案之一,主節點處理寫操作,兩個從節點處理讀操作,分攤了主庫的壓力。

但是,有時候可能會遇到執行完寫操作後,立刻去讀發現讀不到或者讀到舊狀态的尴尬場景。這是由于主從同步可能存在延遲,在主節點執行完寫操作,再去從節點執行讀操作,讀取了之前舊的狀态。

線上MySQL讀寫分離,出現寫完讀不到問題如何解決

上圖展示了此類問題出現的操作順序示意圖:

  • 用戶端首先通過代理向主節點 Master 進行了寫入操作
  • 緊接着第二步去從節點 Slave A 執行讀操作,此時 Master 和 Slave A 之間的同步還未完成,是以第二步的讀操作讀取到了舊狀态
  • 當第五步再次進行讀操作時,此時同步已經完成,是以可以從 Slave B 中讀取到正确的狀态。

下面,我們就來看一下為什麼會出現此類問題。

MySQL 主從同步

了解問題背後發生的原因,才能更好的解決問題。MySQL 主從複制的過程大緻如下圖所示,本篇文章隻講解同步過程中的流程,建立同步連接配接和失聯重傳不是重點,暫不講解,感興趣的同學可以自行了解。

線上MySQL讀寫分離,出現寫完讀不到問題如何解決

MySQL 主從複制,涉及主從兩個節點,一共四個四個線程參與其中:

  • 主節點的 Client Thread,處理用戶端請求的線程,執行如圖所示的1~5步驟,2,3,4步驟是為了保證資料的一緻性和盡量減少丢失,第三步驟時會通知 Dump Thread;
  • 主節點的 Dump Thread,接收到 Client Thread 通知後,負責讀取本地的 binlog 的資料,将 binlog 資料,binlog 檔案名 以及目前發送 binlog 的位置資訊發送給從節點;
  • 從節點的 IO Thread 負責接收 Dump Thread 發送的 binlog 資料和相關位置資訊,将其追加到本地的 relay log 等檔案中;
  • 從節點的 SQL Thread 檢測到 relay log 追加了新資料,則解析其内容(其實就是解析 binlog 檔案的内容)為可以執行的 SQL 語句,然後在本地資料執行,并記錄下目前執行的 relay log 位置。

上述是預設的異步同步模式,我們發現,從主節點送出成功到從節點同步完成,中間間隔了6,7,8,9,10多個步驟,涉及到一次網絡傳輸,多次檔案讀取和寫入的磁盤 IO 操作,以及最後的 SQL 執行的 CPU 操作。

是以,當主從節點間網絡傳輸出現問題,或者從節點性能較低時,主從節點間的同步就會出現延遲,導緻文章一開始提及的寫後讀不到的問題。在高并發場景,從節點一般要過幾十毫秒,甚至幾百毫秒才能讀到最新的狀态。

常見的解決政策

一般來講,大緻有如下方案解決寫後讀不出問題:

  • 強制走主庫
  • 判斷主備無延遲
  • 等主庫位點或 GTID 方案

強制走主庫方案最容易了解和實作,它也是最常用的方案。顧名思義,它就是強制讓部分必須要讀到最新狀态的讀操作去主節點執行,這樣就不會出現寫後讀不出問題。這種方案問題在于将一部分讀壓力給了主節點,部分破化了讀寫分離的目的,降低了整個系統的擴充性。

一般主流的資料庫中間件都提供了強制走主庫的機制,比如,在 sharding-jdbc 中,可以使用

Hint

來強制路由主庫。

HintManager hintManager = HintManager.getInstance();
hintManager.setMasterRouteOnly();
// 繼續JDBC操作      

它的原理就是在 SQL 語句前添加 Hint,然後資料庫中間件會識别出 Hint,将其路由到主節點。

下面,我們就來看一下如果要去從庫查詢,并且要避免過期讀的方案,并分析各個方案的優缺點。

第二種方案是使用 show slave status 語句結果中的部分值來判斷主從同步的延遲時間:

> show slave status
*************************** 1. row ***************************
Master_Log_File: mysql-bin.001822
Read_Master_Log_Pos: 290072815
Seconds_Behind_Master: 2923
Relay_Master_Log_File: mysql-bin.001821
Exec_Master_Log_Pos: 256529431
Auto_Position: 0
Retrieved_Gtid_Set: 
Executed_Gtid_Set: 
.....      
  • seconds_behind_master,表示落後主節點秒數,如果此值為0,則表示主從無延遲
  • Master_Log_File 和 Read_Master_Log_Pos,表示的是讀到的主庫的最新位點,Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是備庫執行的最新位點。如果這兩組值相等,則表示主從無延遲
  • Auto_Position=1 ,表示使用了 GTID 協定,并且備庫收到的所有日志的 GTID 集合 Retrieved_Gtid_Set 和 執行完成的 GTID 集合 Executed_Gtid_Set 相等,則表示主從無延遲。

在進行讀操作前,先根據上述方式來判斷主從是否有延遲,如果有延遲,則一直等待到無延遲後執行。但是這類方案在判斷是否有延遲時存在着假陽和假陰的問題:

  • 判斷無延遲,其他延遲了。因為上述判斷是基于從節點的狀态,當主節點的 Dump Thread 尚未将最新狀态發送給從節點的 IO SQL 時,從節點可能會錯誤的判斷自己和主節點無延遲。
  • 判斷有延遲,但是讀操作讀取的最新狀态已經同步。因為 MySQL 主從複制是一直在進行的,寫後直接讀的同時可能還有其他無關寫操作,雖然主從有延遲,但是對于第一次寫操作的同步已經完成,是以讀操作已經可以讀到最新的狀态。

對于第一個問題,需要使用主從複制的 semi-sync 模式,上文中講解介紹的是預設的異步模式,semi-sync 模式的流程如下圖所示:

線上MySQL讀寫分離,出現寫完讀不到問題如何解決
  • 當主節點事務送出的時候,Dump Thread 把 binlog 發給從節點;
  • 從節點的 IO Thread 收到 binlog 以後,發回給主節點一個 ack,表示收到了;
  • 主節點的 Dump Thread 收到這個 ack 以後,再通知 Client Thread ,此時才能給用戶端傳回執行成功的響應。

這樣,寫操作執行後,就確定從節點已經讀取到主節點發送的 binglog 資料,即 Master_Log_File、 Read_Master_Log_Pos 或 Retrieved_Gtid_Set 是最新的,這樣才能與執行的相關資料進行對比,判斷是否有延遲。

可惜的是,上述 semi-sync 模式隻需要等待一個從節點的ACK,是以一主多從的模式該方案将會無效。

雖然該方案有種種問題,但是對于一緻性要求不那麼高的場景也能适用,比如 MyCat 就是用 seconds_behind_master 是否落後主節點過多,如果超過一定門檻值,就将其從有效從節點清單中删除,不再将讀請求路由到它身上。

在 MyCAT 的用于監聽從節點狀态,發送心跳的 MySQLDetector 類中,它會讀取從節點的 seconds_behind_master,如果其值大于配置的 slaveThreshold,則将列印日志,并将延遲時間設定到心跳資訊中。

String Seconds_Behind_Master = resultResult.get( "Seconds_Behind_Master");          
if (null == Seconds_Behind_Master ){
    MySQLHeartbeat.LOGGER.warn("Master is down but its relay log is clean.");
    heartbeat.setSlaveBehindMaster(0);
}else if(!"".equals(Seconds_Behind_Master)) {
    int Behind_Master = Integer.parseInt(Seconds_Behind_Master);
    if ( Behind_Master >  source.getHostConfig().getSlaveThreshold() ) {
        MySQLHeartbeat.LOGGER.warn("found MySQL master/slave Replication delay !!! "
                + heartbeat.getSource().getConfig() + ", binlog sync time delay: " + Behind_Master + "s" );
    }           
    heartbeat.setSlaveBehindMaster( Behind_Master );
}      

下面,我們就介紹能夠解決第二個問題的方案,即判斷有延遲,但是讀操作讀取的特定最新狀态已經同步。

等GTID 方案

首先介紹一下 GTID,也就是全局事務 ID,是一個事務在送出的時候生成的,是這個事務的唯一辨別。它由MySQL 執行個體的uuid和一個整數組成,該整數由該執行個體維護,初始值是 1,每次該執行個體送出事務後都會加一。

MySQL 提供了一條基于 GTID 的指令,用于在從節點上執行,等待從庫同步到了對應的 GTID(binlog檔案中會包含 GTID),或者逾時傳回。

select wait_for_executed_gtid_set(gtid_set, timeout);      

MySQL 在執行完事務後,會将該事務的 GTID 會給用戶端,然後用戶端可以使用該指令去要執行讀操作的從庫中執行,等待該 GTID,等待成功後,再執行讀操作;如果等待逾時,則去主庫執行讀操作,或者再換一個從庫執行上述流程。

MariaDB 的 MaxScale 就是使用該方案,MaxScale 是 MariaDB 開發的一個資料庫智能代理服務(也支援 MySQL),允許根據資料庫 SQL 語句将請求轉向目标一個到多個伺服器,可設定各種複雜程度的轉向規則。

線上MySQL讀寫分離,出現寫完讀不到問題如何解決

MaxScale 在其 readwritesplit.hh 頭檔案和 rwsplit_causal_reads.cc 檔案中的 add_prefix_wait_gtid 函數中使用了上述方案。

#define MYSQL_WAIT_GTID_FUNC   "WAIT_FOR_EXECUTED_GTID_SET"
static const char gtid_wait_stmt[] =
    "SET @maxscale_secret_variable=(SELECT CASE WHEN %s('%s', %s) = 0 "
    "THEN 1 ELSE (SELECT 1 FROM INFORMATION_SCHEMA.ENGINES) END);";
GWBUF* RWSplitSession::add_prefix_wait_gtid(uint64_t version, GWBUF* origin) {
    ....
    snprintf(prefix_sql, prefix_len, gtid_wait_stmt, wait_func, gtid_position.c_str(),  gtid_wait_timeout);
    ....
}      

舉個例子,原來要執行讀操作的 SQL 和添加了字首的 SQL 如下所示:

SELECT * FROM `city`;
SET @maxscale_secret_variable=(SELECT CASE WHEN WAIT_FOR_EXECUTED_GTID_SET('232-1-1', 10) = 0 THEN 1 ELSE (SELECT 1 FROM INFORMATION_SCHEMA.ENGINES) END); SELECT * FROM `city`;      

當 WAIT_FOR_EXECUTED_GTID_SET 執行失敗後,原 SQL 就不會再執行,而是将該 SQL 去主節點執行。

後記

感覺大家一直讀到文末,後續小冰會繼續為大家奉上高品質的文章,也希望大家繼續關注。

個人部落格,歡迎來玩

參考