分庫分表中間件的高可用實踐

前言

分庫分表中間件在我們一年多的錘鍊下,基本解決了可用性和高性能的問題 (只能說基本,肯定還有隱藏的坑要填),問題自然而然的就聚焦於高可用。本文就闡述了我們在這方面做出的一些工作。

哪些高可用的問題

作爲一個無狀態的中間件,高可用問題並沒有那麼困難。但是儘量減少不可用期間的流量損失,還是需要一定的工作的。這些流量損失主要分佈在:

(1)某臺中間件所在的物理機突然宕機。
(2)中間件的升級和發佈。

由於我們的中間件是作爲數據庫的代理提供給應用的, 即應用把我們的中間件當做數據庫,如下圖所示:

所以出現上述問題後,業務上很難通過重試等操作去屏蔽這些影響。這就勢必需要我們在底層做一些操作,能夠自動的感知中間件的狀態從而有效避免流量的損失。

中間件所在物理機宕機的情況

物理機宕機其實是一種常見現象,這時候應用一瞬間就沒了響應。那麼跑在上面的 sql 肯定也是失敗了的 (準確來說是未知狀態,除非重新查詢後端數據庫,應用無法得知準確的狀態)。這部分流量我們肯定是無法挽救。我們所做的是在 client 端(Druid 數據源) 能夠快速的發現並剔除宕機的中間件節點。

發現並剔除不可用節點

通過心跳去發現不可用節點

自然而然的我們通過心跳來探查後端中間件的存活狀態。我們通過定時創建一個新連接 ping(mysql 的 ping) 一下然後立馬關閉來做心跳 (這種做法便於我們區分正常流量和心跳流量,如果通過保持一個連接然後一直髮送類似 select ‘1’的 sql 這種方式的話區分流量會稍微麻煩點)。

爲了防止網絡抖動造成的偶發性 connect 失敗,我們在三次 connect 都失敗後才判定某臺中間件處於不可用狀態。而這三次的探活卻延長了錯誤感知時間,所以我們三次 connect 的時間間隔是指數級衰減的,如下圖所示:

爲何不在第一次 connect 失敗後,連續發送兩次 connect 呢?可能考慮到網絡的抖動可能會有一個時間窗口,如果在時間窗口內連續發了 3 次,出了這個時間窗口網絡又 okay 了,那麼會錯誤的發現後端某節點不可用了, 所以我們就做了指數級衰減的折衷。

通過錯誤計數去發現不可用節點

上述的心跳感知始終有一個時間窗口,當流量很大的時候,在這個時間窗口內使用這個不可用節點的都會失敗, 所以我們可以使用錯誤計數去輔助不可用節點的感知 (當然這個手段的實現還在計劃中)。

這邊有一個注意的點是,只能通過創建連接異常來計數,並不能通過 read timeout 之類的來計算。原因是,read timeout 異常可能是慢 sql 或者後端數據庫的問題導致,只有創建連接異常才能確定是中間件的問題 (connection closed 也可能是後端關閉了這個連接, 並不代表整體不可用), 如下圖所示:

一個請求使用若干個連接導致的問題

由於我們需要保證事務儘可能小,所以在一個請求裏面多條 sql 並不使用同一個連接。在非事務 (auto-commit) 情況下,運行多少條 sql 就從連接池裏面取出多少連接,並放回。保證事務小是非常重要的,但是這在中間件宕機的時候會導致一些問題,如下圖所示:

如上圖所示,在故障發現窗口期中 (即還沒有確定某臺中間件不可用時),數據源是隨機選擇連接的。而這個連接就有一定 1/N(N 爲中間件個數) 的概率命中不可用中間件導致一條 sql 失敗進而導致整個請求失敗。我們做一個計算:

假設N爲8,一個請求有20條sql,
那麼在這個期間每個請求失敗的概率就爲(1-(7/8)的20次方)=0.93,
即有93%的概率會失敗!

更爲甚者,整個應用集羣都會經歷這個階段,即每臺應用都有 93% 的概率失敗。
一臺中間件宕機導致整個服務在十幾秒內基本所有請求基本都失敗,這是不可忍受的。

採用 sticky 數據源解決問題

由於我們不能瞬間發現並確認中間件不可用,所以這個故障發現窗口肯定存在 (當然,錯誤計數法會在很大程度上縮短髮現時間)。但理想狀況下,宕機一臺,只損失 1/N 的流量就好了。我們採用了 sticky 數據源解決了這個問題,使得在概率上大致只損失 1/N 的流量, 如下圖所示:

而配合錯誤計數的話,總流量的損失會更小 (因爲故障窗口短)
如上圖所示,只有在故障時間內隨機選擇到中間件 2(不可用) 的請求才會失敗,再讓我們看下整個應用集羣的情況。

只有 sticky 到中間件 2 的請求流量纔有損失,由於是隨機選擇,所以這個流量的損失應用在 1/N。

中間件升級發佈過程中的高可用

分庫分表中間件的升級發佈不可避免。例如 bug fix 以及新功能添加等都需要重啓中間件。而重啓的時間也會導致不可用,與物理機宕機的情況相比是其不可用的時間點是可知的,重啓的動作也是可控的,那麼我們就可以利用這些信息去做到流量的平滑無損。

讓 client 端感知即將下線

在筆者所知的很多做法中,讓 client 端感知下線是引入一個第三方協調者 (例如 zookeeper/etcd)。而我們並不想引入第三方的組件去做這個操作,因爲這又會引入 zookeeper 的高可用問題,而且會讓 client 端的配置更加複雜。平滑無損的大致思路(狀態機) 如下圖所示:

讓心跳流量感知下線而正常流量保持

我們可以複用之前 client 端檢測不可用的邏輯,即讓心跳的新建連接失敗,而正常請求的新建連接成功。這樣,client 端就會認爲 Server 不可用,而在內部剔除調這個 server。由於我們只是模擬不可用,所以已經建立的連接和正常新建的連接 (非心跳) 都是正常可用的,如下圖所示:

心跳連接的創建在 server 端可以通過其第一條執行的是 mysql 的 ping 而正常流量第一條執行的是一條 sql 來區分 (當然我們採用的 Druid 連接池在新建連接成功以後也會 ping 一下,所以採用了另一種方式區分, 這個細節在這裏就不闡述了)。

三次心跳失敗後,client 端判定 Server1 失敗,需要將連接到 server1 的連接銷燬。其思路是,業務層用完連接返回連接池的時候, 直接給 close 掉 (當然這個是簡單的描述,實際操作到 Druid 數據源也是有細微的差別的)。

由於配置了一個 connection 最長保持時間, 所以在這個時間之後肯定會對 Server1 的連接數爲 0
由於線上流量也不低, 這個收斂時間是比較快的 (進一步的做法,其實是主動去銷燬, 不過我們尚未做這個操作)。

如何判定下線 Server 再也沒有流量

在上述小心翼翼的操作之後,在 Server1 下線的過程中,是不會有流量損失的。但是我們在 Server 端還需要判定何時不會再有新的流量, 這個判定標準即是 Server1 沒有任何一個 client 端的連接。
這也是上面我們在執行完 sql 後銷燬連接從而可以讓連接數變爲 0 的原因,如下圖所示:

當連接數爲 0 後,我們就可以重新發布 Server1(分庫分表中間件) 了。對於這一點,我們寫了個腳本, 其僞代碼如下所示:

while(true){
    count =`netstat -anp | grep port | grep ESTABLISHED | wc -l`
    if(0 == count){
        // 流量已經爲0,關掉服務器
        kill Server
        // 發佈升級服務器
        public Server
        break
    }else{
        Sleep(30s)
    }
}

將這個腳本接入發佈平臺,即可進行滾動式上下線了。
現在可以解釋下 recover_time 爲何要較長了,因爲新建連接也會導致腳本計算出來的 connection count 數量增加,所以需要一個時間窗口不去建立心跳,從而能讓這個腳本順利運行。

recover_time 其實是非必要的

如果我們將心跳創建的端口號和正常流量的端口號分開,是不需要 recover_time 的,如下圖所示:

採用這種方案的話,會在很大程度上降低我們 client 端代碼的複雜度。
但是這樣無疑又給 client 端增加了一個新的配置,對使用人員就又多了一個負擔,還得在網絡上多一次開牆的操作,所以我們採取了 recover_time 的方案。

中間件的啓動順序問題

前面的過程是一個優雅下線的過程,但我們發現我們的中間件才上線的時候在某些情況下也不會優雅。即在中間件啓動時候,如果對後端數據庫剛建立的連接建立上去後由於某些原因斷開了,會導致中間件的 reactor 線程卡住一分鐘左右,這段時間無法服務,造成流量損失。所以我們在後端數據庫連接全部創建成功後,再啓動 reactor 的 accept 線程從而接收新的流量,從而解決這一問題,如下圖所示:

總結

筆者個人感覺高可用比高性能還要複雜。因爲高性能可以在線下反覆的去壓測,通過壓測的數據去分析瓶頸,提高性能。而高可用需要應付線上各種千奇百怪的現象,本篇博客講述的高可用方案只是我們工作的一小部分,還有很大一部分精力是處理中間件本身的問題上。但只要不放過任何一個點,將問題都能夠分析清楚並解決,就會讓系統越來越好。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KD1IzbQeAqtI3WosJ-efzw