談?wù)剬?go-redis 客戶端的一些優(yōu)化

這篇文章主要是記錄一下我們在使用 go-redis 過程中遇到的一些問題及我們的解決方案属拾,主要內(nèi)容如下:

  • 背景
  • 部署模式
  • 業(yè)務(wù)需求
  • 主要優(yōu)化點(diǎn)
  • 主從復(fù)制機(jī)制

背景

當(dāng)前我們業(yè)務(wù)上主要使用 Go 語言将谊,Redis 使用的則是開源的 go-redis 客戶端。但是當(dāng)前 go-redis 有一個很大的不足就是:在 sentinel 部署模式下渐白,它默認(rèn)總是獲取主庫連接尊浓,因此在高并發(fā)尤其是讀多寫少的場景下并不適用。具體來說纯衍,在我們的場景中栋齿,高峰期 QPS 超過 10w,其中 90% 以上都是讀請求襟诸。且不說 redis 單機(jī)能不能抗住這么大的流量瓦堵,即使暫時能抗住,考慮到業(yè)務(wù)發(fā)展或突發(fā)情況歌亲,這始終是一個極大的不確定性菇用。因此改造 go-redis 客戶端,使其支持讀寫分離勢在必行陷揪。

在這之前惋鸥,我們先簡單介紹一下 redis 的幾種部署模式。

部署模式

standalone

standalone 就是我們常說的單機(jī)模式鹅龄,所有數(shù)據(jù)和請求都指向一個 redis 實(shí)例揩慕,無法保證高可用,一般不建議用于生產(chǎn)環(huán)境扮休,因?yàn)橐坏?redis 出現(xiàn)宕機(jī)的情況,就可能導(dǎo)致數(shù)據(jù)丟失或者引起雪崩拴鸵。

cluster

為了實(shí)現(xiàn)生產(chǎn)環(huán)境的高可用玷坠,集群部署是我們最容易想到的方案,這就是我們要說的 cluster 模式劲藐。與我們常見的集群模式不同八堡,redis 的 cluster 模式類似于一種去中心化的方式。具體來說聘芜,cluster 模式下集群有 16384 個哈希槽分布在多個主節(jié)點(diǎn)上 兄渺,每個 key 通過 CRC16 校驗(yàn)后對 16384 取模來決定放在哪個槽,因此每個主節(jié)點(diǎn)僅保存整個集群的一部分?jǐn)?shù)據(jù)汰现。

基于這種去中心化的策略挂谍,再在每個主節(jié)點(diǎn)掛一個或多個從節(jié)點(diǎn)叔壤,cluster 模式就能夠保證一定程度的高可用。

MasterA口叙、MasterB炼绘、MasterC 分別保存了集群的一部分?jǐn)?shù)據(jù),三者數(shù)據(jù)并集是集群全量數(shù)據(jù)妄田。

image

但是 cluster 模式也有兩個明顯的不足:

  • cluster 不能保證數(shù)據(jù)的強(qiáng)一致性俺亮。

假設(shè)發(fā)生網(wǎng)絡(luò)分區(qū),主節(jié)點(diǎn)A疟呐、B和從節(jié)點(diǎn)A1脚曾、B1、C1在一個分區(qū)启具,而主節(jié)點(diǎn) C 和客戶端 client 在另外一個分區(qū)斟珊。此時 client 仍能夠向 C 寫入數(shù)據(jù)。如果時間較長富纸,在另外一個分區(qū)囤踩,從節(jié)點(diǎn)C1就會被選舉為主節(jié)點(diǎn),此時client 之前寫入C的數(shù)據(jù)就會丟失晓褪。

  • cluster 不支持處理多個 key堵漱。

為了保證多個命令的原子性,我們通常會使用 lua 腳本來實(shí)現(xiàn)涣仿。但 redis 要求單個 lua 腳本的所有 key 在同一個槽位勤庐。雖然 redis 也提供了相應(yīng)的解決方案,但是對于老業(yè)務(wù)來說好港,無法做到平滑遷移愉镰。

更多詳細(xì)信息,詳見官方文檔[1]

sentinel

sentinel 模式也是 redis 官方推薦的高可用方案钧汹。sentinel 模式最典型的特征是提供了一整套完整的監(jiān)控功能丈探、可以做到 7*24 小時不間斷監(jiān)控、自動故障轉(zhuǎn)移以及作為配置提供者拔莱。

MasterA碗降、MasterB 是兩個獨(dú)立的集群,它們保存了各自集群的全量數(shù)據(jù)塘秦。

image

一個 sentinel 集群可以監(jiān)控多個 master-slave 集群讼渊,每個 master-slave 集群有一個主節(jié)點(diǎn)和多個從節(jié)點(diǎn),一旦 sentinel 監(jiān)控到某個 master 宕機(jī)尊剔,會自動從它的多個 slave 節(jié)點(diǎn)中選出最合適的一個作為新的 master 節(jié)點(diǎn)爪幻。

更多詳細(xì)信息,詳見官方文檔[2]

業(yè)務(wù)需求

前面已經(jīng)簡單介紹過我們的業(yè)務(wù)背景,為了支持 12w 的QPS挨稿,必須支持讀寫分離仇轻,這個 go-redis 客戶端當(dāng)前已經(jīng)支持。但因?yàn)槲覀儤I(yè)務(wù)上大量使用到了 lua 腳本叶组,因此如果切回 cluster 模式拯田,我們沒辦法做到平滑遷移,必須刪除大量舊 key甩十。所以改造 go-redis sentinel 的客戶端實(shí)現(xiàn)船庇,使其支持讀寫分離似乎是更合適的選擇。

我們所有的改造基于 go-redis v7.3.0 版本侣监。

主要優(yōu)化點(diǎn)

支持讀寫分離

首先鸭轮,開放出一個 readOnlySentinel 參數(shù),每次 dial 的過程中橄霉,根據(jù)該參數(shù)決定獲取主節(jié)點(diǎn)地址還是從節(jié)點(diǎn)地址窃爷。

獲取從節(jié)點(diǎn)地址的主要過程如下:

  • 利用 sentinel slaves ${masterName},獲取當(dāng)前集群的所有從節(jié)點(diǎn)地址?根據(jù) flags 狀態(tài)過濾掉沒有 ready 的從節(jié)點(diǎn)
  • 從活躍的從節(jié)點(diǎn)中隨機(jī)選一個作為結(jié)果返回
  • 同時訂閱主題為 "+sdown" 的消息姓蜂,一旦某個從節(jié)點(diǎn)宕機(jī)按厘,我們可以及時從該消息中獲取相關(guān)信息,并將它從連接池中移除

但測試過程中發(fā)現(xiàn)钱慢,雖然主庫請求下來了逮京,但是所有讀請求每次都是路由到同一臺從庫上。分析發(fā)現(xiàn)束莫,這是由于 go-redis 的連接池實(shí)現(xiàn)導(dǎo)致的懒棉,具體如下:

func (p *ConnPool) popIdle() *Conn {
    if len(p.idleConns) == 0 {
        return nil
    }

    idx := len(p.idleConns) - 1
    cn := p.idleConns[idx]
    p.idleConns = p.idleConns[:idx]
    p.idleConnsLen--
    p.checkMinIdleConns()
    return cn
}

func (p *ConnPool) Put(cn *Conn) {
    if cn.rd.Buffered() > 0 {
        internal.Logger.Printf("Conn has unread data")
        p.Remove(cn, BadConnError{})
        return
    }

    if !cn.pooled {
        p.Remove(cn, nil)
        return
    }

    p.connsMu.Lock()
    p.idleConns = append(p.idleConns, cn)
    p.idleConnsLen++
    p.connsMu.Unlock()
    p.freeTurn()
}

func (c *baseClient) releaseConn(cn *pool.Conn, err error) {
    if c.opt.Limiter != nil {
        c.opt.Limiter.ReportResult(err)
    }

    if isBadConn(err, false) {
        c.connPool.Remove(cn, err)
    } else {
        c.connPool.Put(cn)
    }
}

可以看到,put() 時览绿,連接被追加在一個數(shù)組里策严,每次 get() 則從連接數(shù)組里取最后一個連接,然后每次 release() 的時候又追加在數(shù)組最后一個位置饿敲,類似于棧的實(shí)現(xiàn)妻导。因此在 QPS 不是很高的時候,客戶端永遠(yuǎn)取的都是連接池?cái)?shù)組里最后一個連接诀蓉。

連接池這么設(shè)計(jì)當(dāng)然也有它的好處栗竖,比如說可以避免連接池?cái)?shù)組的頻繁移動等等,但從負(fù)載均衡的角度來說卻說不上是一個特別好的設(shè)計(jì)渠啤。

當(dāng)時為了盡快上線,我們采取了一個比較取巧的辦法添吗,就是通過參數(shù) MaxConnAge 設(shè)置連接的存活時間為1min沥曹,之后就將它銷毀創(chuàng)建新連接。當(dāng)然這會導(dǎo)致頻繁的創(chuàng)建連接,但能暫時解決負(fù)載均衡不均勻的問題妓美,后面會提到我們對它的第二次優(yōu)化僵腺。

第一版改造完成之后,從監(jiān)控可以看到壶栋,差不多有一半的流量已成功從主節(jié)點(diǎn)分流到從節(jié)點(diǎn)(之所以主節(jié)點(diǎn)還有一半的流量辰如,是因?yàn)檫€有業(yè)務(wù)沒有適配新客戶端)。

image

優(yōu)化負(fù)載均衡

經(jīng)過第一步改造贵试,我們已經(jīng)基本上支持了讀寫分離琉兜,大大降低了主庫的壓力。但從監(jiān)控也可以看出毙玻,從庫的負(fù)載均衡仍然算不上很均勻豌蟋,同一時刻每個從庫的壓力差異較大。主要原因我們分析可能有兩個:一是單純的隨機(jī)算法無法在每個時刻都保證所有從庫的負(fù)載都非常均衡桑滩,只能保證從一個較長的時間范圍來看平均負(fù)載相對均衡梧疲;二是默認(rèn)的連接池實(shí)現(xiàn)可能導(dǎo)致在短時間內(nèi)大量請求都被分配給了連接池里最后一個連接。

為此运准,我們還需要對其進(jìn)行進(jìn)一步優(yōu)化幌氮。具體優(yōu)化點(diǎn)有兩個:

  • get() 連接時,每次取數(shù)組里的第一個胁澳,然后每次 put() 時還是追加在數(shù)組最后一個位置该互,相當(dāng)于把連接池從前面說到的的棧實(shí)現(xiàn)改成了隊(duì)列實(shí)現(xiàn)。
  • 每次 dial 獲取從庫地址時听哭,不再是從可用從庫地址列表里隨機(jī)選取一個慢洋,而是通過一個自增的 slaveIdx 對從庫實(shí)例個數(shù)取模的方式來選取從庫地址,保證每個從庫實(shí)例被選擇到的概率是相等的陆盘。

類似于這么一個環(huán)形普筹,每個客戶端依次選取第 1、2隘马、3太防、4、1 號從庫進(jìn)行連接酸员。

image

來看看二次改造之后的效果蜒车,可以看到,從庫的負(fù)載均衡明顯比之前更均勻了幔嗦。

image

效果似乎不錯酿愧,但前面也說過,由于配置了 MaxConnAge 參數(shù)邀泉,會導(dǎo)致頻繁的銷毀和創(chuàng)建連接嬉挡。那在我們修改了連接池和 dial() 實(shí)現(xiàn)后钝鸽,是否可以去掉該參數(shù)了呢?

去掉 MaxConnAge 參數(shù)后庞钢,我們發(fā)現(xiàn)從庫負(fù)載均衡結(jié)果出現(xiàn)了意料之外的結(jié)果拔恰。雖然從庫之間負(fù)載均衡的結(jié)果變得比預(yù)期的要差的多,但同時還呈現(xiàn)出另外一個特點(diǎn):不同實(shí)例的 QPS 隨全站流量平穩(wěn)波動基括,流量高的實(shí)例流量總是高颜懊,流量低的實(shí)例一直低。

image

這就很有意思了风皿,為什么會出現(xiàn)這種現(xiàn)象呢河爹?

按道理,現(xiàn)在所有從庫實(shí)例被選中的概率是相等的揪阶,連接池里的連接在不同從庫實(shí)例上的分布也就應(yīng)該是均勻的昌抠。為了驗(yàn)證這一猜測,我們對連接池里連接的從庫地址進(jìn)行了采樣統(tǒng)計(jì)鲁僚,發(fā)現(xiàn)實(shí)際情況跟我們的猜測并不相同炊苫,而是呈現(xiàn)出跟上圖的 QPS 類似的很明顯的分層特性。

進(jìn)一步分析每個客戶端上連接池里連接的分布情況冰沙,發(fā)現(xiàn)在同一個客戶端上侨艾,不同從庫實(shí)例上的連接分布是很均勻的,最多只相差1個拓挥,但是因?yàn)榫€上客戶端較多唠梨,累加的效果導(dǎo)致了最終比較大的差異。

繼續(xù)觀察侥啤,我們還發(fā)現(xiàn)了一個特點(diǎn)当叭,就是按照不同實(shí)例連接數(shù)從多到少排序,發(fā)現(xiàn)這個順序與 sentinel slaves masterName 返回的從庫實(shí)例順序一致盖灸∫媳睿看到這里,我們這才恍然大悟:原來針對每一個客戶端赁炎,我們總是按前面說的1醉箕、2、3徙垫、4 的順序來選取從實(shí)例讥裤,如果連接個數(shù)剛好是從庫實(shí)例的整數(shù)倍時,那么此時連接在不同實(shí)例間的分布當(dāng)然就是均勻的姻报〖河ⅲ可這么巧合的情況終究是比較少的,絕大多數(shù)時候并不是這樣吴旋,而且連接池內(nèi)連接的數(shù)量一直都在動態(tài)變化中剧辐,所以當(dāng)連接總數(shù)不是從庫實(shí)例的整數(shù)倍時(比如說如果是6個連接)寒亥,那么按照我們的順序邮府,1荧关、2號從庫就會比3、4號從庫多一個連接褂傀。線上所有客戶端加起來忍啤,就會導(dǎo)致 sentinel slaves masterName 返回結(jié)果中越靠前的從庫,其上面的連接就越多仙辟。

說起來有點(diǎn)繞同波,舉個例子可能更好理解。假設(shè) redis 集群有 4 個從庫叠国,同時我們有 3 個 客戶端未檩,每個客戶端上分別有 5、6粟焊、7 個連接冤狡,那么按照我們前面的負(fù)載均衡算法,3 個客戶端上連接池的連接分布情況分別是:

  • 1项棠、2悲雳、3、4香追、1
  • 1合瓢、2、3透典、4晴楔、1、2
  • 1峭咒、2税弃、3、4讹语、1钙皮、2、3

單獨(dú)看一個客戶端顽决,連接的分布式較為均勻的短条,但是 3 個客戶端累加之后,不難發(fā)現(xiàn) 1才菠、2茸时、3、4 號從庫上的連接數(shù)就變成了 6赋访、5可都、4缓待、3,如果客戶端數(shù)量更多渠牲,這個差異就更大旋炒。而如果 3 個客戶端分別從 2、3签杈、4 號從庫開始建立連接瘫镇,那么 3 個客戶端上的連接分布情況將變?yōu)椋?/p>

  • 2、3答姥、4铣除、1、2
  • 3鹦付、4尚粘、1、2敲长、3郎嫁、4
  • 4、1潘明、2行剂、3、4钳降、1厚宰、2

此時1、2遂填、3铲觉、4號從庫上的連接總數(shù)分別為:4、5吓坚、4撵幽、5,顯然礁击,這比之前的6盐杂、5、4哆窿、3要更為均勻链烈,即使客戶端數(shù)量再多也不受影響。

發(fā)現(xiàn)這一點(diǎn)挚躯,這個問題要解決起來也就簡單了强衡。即在每次選擇從庫時,不再固定的每次從 1 號從庫開始码荔,而是每次隨機(jī)從其中一個從庫 k 開始漩勤,然后按照 k, k+1, k+2, ..., 1, 2, ..., k, ... 這種順序來路由就可以了感挥。再來看看按照這個思路改造后的結(jié)果:

  • 不同實(shí)例間連接總數(shù)分布較為均勻了,最多只相差了 3 個連接
image
  • 從庫 QPS 也從明顯的分層開始收斂越败,最終不同實(shí)例 QPS 差異從 1.5k 左右下降為 0.3k 左右
image

支持平滑擴(kuò)容

就這樣線上穩(wěn)定運(yùn)行了幾個月触幼,前段時間為了提高我們 redis 集群的高可用性,計(jì)劃對線上 redis 集群進(jìn)行擴(kuò)容眉尸。原本以為是很簡單的一個操作域蜗,結(jié)果在每個從庫實(shí)例加入集群的1min內(nèi),線上出現(xiàn)了大量這樣的 error 日志:

LOADING Redis is loading the dataset in memory

跟 DBA 確認(rèn)噪猾,是由于從庫雖然加入了集群,但是數(shù)據(jù)同步需要時間筑累,在完成數(shù)據(jù)同步之前并不能對外提供服務(wù)睦霎。但此時反映在 flags 字段里的從庫狀態(tài)與正常的從庫無異筏勒,因此在我們 RandomSlaveAddr() 方法就可能會選中正在同步的從庫地址。

為了做進(jìn)一步確認(rèn),我們在本地進(jìn)行了模擬晨缴。具體步驟如下:

1.在本地搭建一個一主兩從的 sentinel 集群
2.啟動 sentinel 節(jié)點(diǎn)
3.啟動主節(jié)點(diǎn)
4.向主節(jié)點(diǎn)寫入 100w 個 key,占用內(nèi)存 1.6G 左右
5.啟動一個從節(jié)點(diǎn)
6.通過 sentinel slaves mymaster 查看從節(jié)點(diǎn)狀態(tài)茵乱,正常
7.此時通過從庫訪問任意一個 key桨吊,都會提示"LOADING Redis is loading the dataset in memory",大概 20s 后恢復(fù)正常

至此缅茉,基本確認(rèn)了問題來源嘴脾,要解決這個問題大概有這么幾種思路:

1.修改 redis 源碼,在從節(jié)點(diǎn)完成數(shù)據(jù)同步之前蔬墩,設(shè)置其狀態(tài)為同步中译打,并將其設(shè)置到 flags 字段,這樣我們就可以根據(jù)該狀態(tài)過濾掉在同步中的 slave 節(jié)點(diǎn)(事實(shí)上拇颅,目前對于從庫在同步中奏司、同步完成的狀態(tài),redis 都有相應(yīng)的事件發(fā)出來樟插,但我們無法根據(jù)事件來剔除還未完成同步的節(jié)點(diǎn))
2.每次拿到連接之后先 ping 一下韵洋,看是否可以收到 pong 的響應(yīng),如果正常黄锤,則繼續(xù)執(zhí)行搪缨,否則丟棄該連接
3.每次通過 dial 方法獲取從節(jié)點(diǎn)時,先 ping 一下看是否可以收到 pong 的響應(yīng)猜扮,如果正常勉吻,則繼續(xù)執(zhí)行,否則丟棄該節(jié)點(diǎn)

方案 1 因?yàn)樯婕靶薷?redis 源碼旅赢,需要我們對 redis 底層實(shí)現(xiàn)十分了解齿桃,同時熟悉 C 語言開發(fā)惑惶,這個我們自問還無法做到十足的把握,風(fēng)險(xiǎn)太大短纵。

方案 2 每次拿到連接之后加一個 ping() 操作带污,會直接導(dǎo)致線上 QPS 翻倍,因此也不考慮香到。

方案 3 似乎是可行的鱼冀,可以在我們最開始支持讀寫分離獲取從節(jié)點(diǎn)的代碼中,通過 ping 操作剔除壞節(jié)點(diǎn)悠就。但是我們唯一的擔(dān)心在于:由于此時只拿到了節(jié)點(diǎn)地址千绪,為了能夠執(zhí)行 ping 操作,我們需要每次都新建一個客戶端對象梗脾,如果新建連接的操作比較頻繁荸型,可能就會創(chuàng)建大量的對象導(dǎo)致內(nèi)存占用飆升(雖然說這些對象會被 GC 回收,但終究是個隱患炸茧,如果線上驗(yàn)證不會引發(fā)內(nèi)存飆升瑞妇,這個方案其實(shí)是可行的)。

那有沒有更好的辦法梭冠,不用執(zhí)行 ping 命令就能識別出節(jié)點(diǎn)狀態(tài)呢辕狰?答案當(dāng)然是可以的,這還需要從 redis 主從復(fù)制機(jī)制說起控漠。具體復(fù)制機(jī)制見文末有詳細(xì)介紹蔓倍,這里我們只需要知道主從同步完成之后,從 sentinel 獲取到的從庫狀態(tài)中 master-link-status 字段會被設(shè)置為 ok 就可以了润脸,通過該字段是否為 ok柬脸,我們就可以知道該從庫是否完成了與主庫的同步。

因此這就解決了我們前面 3 種備選方案的所有問題:

  • 不需要修改 redis 源碼毙驯,只需要修改 go-redis 客戶端即可
  • 不需要執(zhí)行 ping 命令倒堕,無需擔(dān)心線上 QPS 大幅增加
  • 不需要新建客戶端,無需擔(dān)心內(nèi)存飆升

事實(shí)上爆价,go-redis 還有另外一個鉤子方法 OnConnect() 也可以幫助我們實(shí)現(xiàn)平滑擴(kuò)容垦巴。在每次有新連接加入連接池之后,都會回調(diào)該方法中自定義的操作铭段,如果發(fā)生異常則清除掉該連接骤宣。只不過其默認(rèn)是個空實(shí)現(xiàn),因此我們可以在該方法中加入一個 ping 操作:

if option.OnConnect == nil {
    option.OnConnect = func(c *Conn) error {
        return c.conn.Ping().Err()
    }
}

主從復(fù)制機(jī)制

在支持平滑擴(kuò)容一節(jié)序愚,我們說到可以通過 master-link-status 狀態(tài)來過濾掉還沒完成主從同步的節(jié)點(diǎn)憔披。具體原因還需要從 redis 的主從復(fù)制機(jī)制說起,下面將對該機(jī)制進(jìn)行簡單的介紹。

狀態(tài)機(jī)

建立主從關(guān)系可以通過 slaveof [masterip] [masterport] 來實(shí)現(xiàn)芬膝,該命令入口函數(shù)是 slaveofCommand()望门。整個復(fù)制過程維護(hù)了一個比較復(fù)雜的狀態(tài)機(jī),該狀態(tài)機(jī)的狀態(tài)如下:

/* Slave replication state. Used in server.repl_state for slaves to remember
 * what to do next. */
#define REPL_STATE_NONE 0 /* No active replication */
#define REPL_STATE_CONNECT 1 /* Must connect to master */
#define REPL_STATE_CONNECTING 2 /* Connecting to master */
/* --- Handshake states, must be ordered --- */
#define REPL_STATE_RECEIVE_PONG 3 /* Wait for PING reply */
#define REPL_STATE_SEND_AUTH 4 /* Send AUTH to master */
#define REPL_STATE_RECEIVE_AUTH 5 /* Wait for AUTH reply */
#define REPL_STATE_SEND_PORT 6 /* Send REPLCONF listening-port */
#define REPL_STATE_RECEIVE_PORT 7 /* Wait for REPLCONF reply */
#define REPL_STATE_SEND_IP 8 /* Send REPLCONF ip-address */
#define REPL_STATE_RECEIVE_IP 9 /* Wait for REPLCONF reply */
#define REPL_STATE_SEND_CAPA 10 /* Send REPLCONF capa */
#define REPL_STATE_RECEIVE_CAPA 11 /* Wait for REPLCONF reply */
#define REPL_STATE_SEND_PSYNC 12 /* Send PSYNC */
#define REPL_STATE_RECEIVE_PSYNC 13 /* Wait for PSYNC reply */
/* --- End of handshake states --- */
#define REPL_STATE_TRANSFER 14 /* Receiving .rdb from master */
#define REPL_STATE_CONNECTED 15 /* Connected to master */

很顯然锰霜,這么復(fù)雜的操作必須異步化筹误。狀態(tài)機(jī)狀態(tài)設(shè)置為 REPL_STATE_CONNECT 后直接返回,后續(xù)操作在serverCron() 里實(shí)現(xiàn)癣缅。redis 里一些需要異步化的操作都會放在這個函數(shù)里厨剪,其中從庫的同步操作每秒執(zhí)行一次。

/* Replication cron function -- used to reconnect to master,
     * detect transfer failures, start background RDB transfers and so forth. */
run_with_period(1000) replicationCron();

在 replicationCron() 方法里友存,如果發(fā)現(xiàn)狀態(tài)機(jī)狀態(tài)是 REPL_STATE_CONNECT祷膳,就會向主庫發(fā)起連接,更新狀態(tài)機(jī)狀態(tài)為 REPL_STATE_CONNECTING爬立。

 /* Check if we should connect to a MASTER */
if (server.repl_state == REPL_STATE_CONNECT) {
    serverLog(LL_NOTICE,"Connecting to MASTER %s:%d",
        server.masterhost, server.masterport);
    if (connectWithMaster() == C_OK) {
        serverLog(LL_NOTICE,"MASTER <-> SLAVE sync started");
    }
}

主從握手

之后是從庫和主庫之間的一系列握手操作钾唬,具體包括:

  • 發(fā)送 ping 命令給主庫,等待 pong 響應(yīng)(REPL_STATE_RECEIVE_PONG)
  • 權(quán)限認(rèn)證(REPL_STATE_SEND_AUTH侠驯、REPL_STATE_RECEIVE_AUTH)
  • 發(fā)送從庫端口信息給主庫(REPL_STATE_SEND_PORT、REPL_STATE_RECEIVE_IP)?發(fā)送從庫 IP 信息給主庫(REPL_STATE_SEND_IP奕巍、REPL_STATE_RECEIVE_IP)
  • 通知主庫吟策,當(dāng)前從庫已經(jīng) ready,具備了處理 RDB 文件的能力(REPL_STATE_SEND_CAPA的止、REPL_STATE_RECEIVE_CAPA)
  • 從庫向主庫發(fā)起同步請求(REPL_STATE_SEND_PSYNC檩坚、REPL_STATE_RECEIVE_PSYNC)

至此,從庫和主庫之間的握手操作完成诅福。然后根據(jù)握手結(jié)果匾委,決定進(jìn)行增量同步還是全量同步。

主從同步

在 slaveTryPartialResynchronization 方法里氓润,從庫會將自己關(guān)聯(lián)主庫的 runid 和自己當(dāng)前的偏移量 offset 發(fā)送給主庫:

reply = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"PSYNC",psync_replid,psync_offset,NULL);

然后嘗試進(jìn)行增量同步

if (!strncmp(reply,"+FULLRESYNC",11)) {
    /* FULL RESYNC, parse the reply in order to extract the run id and the replication offset. */
    ...

  return PSYNC_FULLRESYNC;
}

if (!strncmp(reply,"+CONTINUE",9)) {
    /* Partial resync was accepted. */
  ...
  // 開啟增量同步
  replicationResurrectCachedMaster(fd)
  return PSYNC_CONTINUE;
}
  • 如果增量同步成功赂乐,會在 replicationResurrectCachedMaster() 方法里將狀態(tài)機(jī)設(shè)置為 REPL_STATE_CONNECTED,主從同步完成
  • 如果是全量同步咖气,從庫接收主庫發(fā)送過來的 rdb 文件挨措,更新狀態(tài)機(jī)為 REPL_STATE_TRANSFER
psync_result = slaveTryPartialResynchronization(fd,1);
if (psync_result == PSYNC_WAIT_REPLY) return; /* Try again later... */

if (psync_result == PSYNC_CONTINUE) {
    serverLog(LL_NOTICE, "MASTER <-> SLAVE sync: Master accepted a Partial Resynchronization.");
    return;
}

...
  
/* Prepare a suitable temp file for bulk transfer */
while(maxtries--) {
    snprintf(tmpfile,256,
        "temp-%d.%ld.rdb",(int)server.unixtime,(long int)getpid());
    dfd = open(tmpfile,O_CREAT|O_WRONLY|O_EXCL,0644);
    if (dfd != -1) break;
    sleep(1);
}

/* Setup the non blocking download of the bulk file. */
if (aeCreateFileEvent(server.el,fd, AE_READABLE,readSyncBulkPayload,NULL)== AE_ERR)
{
    serverLog(LL_WARNING,
        "Can't create readable event for SYNC: %s (fd=%d)", strerror(errno),fd);
    goto error;
}
...
  
server.repl_state = REPL_STATE_TRANSFER;

其中 readSyncBulkPayload() 是從庫接收 rdb 文件的處理邏輯,處理成功后會將狀態(tài)機(jī)更新為 REPL_STATE_CONNECTED崩溪,這也標(biāo)志著主從同步的完成浅役。

與此同時,從庫會將自己 info 命令中 Replication 的 mater-link-status 字段更新為 up:

info = sdscatprintf(info,
    "master_host:%s\r\n"
    "master_port:%d\r\n"
    "master_link_status:%s\r\n"
    "master_last_io_seconds_ago:%d\r\n"
    "master_sync_in_progress:%d\r\n"
    "slave_repl_offset:%lld\r\n"
    ,server.masterhost,
    server.masterport,
    (server.repl_state == REPL_STATE_CONNECTED) ?
        "up" : "down",
    server.master ?
    ((int)(server.unixtime-server.master->lastinteraction)) : -1,
    server.repl_state == REPL_STATE_TRANSFER,
    slave_repl_offset
);

sentinel 則在返回的 slave 信息中將 master-link-status 字段更新為 "ok"伶唯,這也是我們前面提到支持平滑擴(kuò)容時可以根據(jù)該字段狀態(tài)來識別壞節(jié)點(diǎn)的原因觉既。

 /* master_link_status:<status> */
if (sdslen(l) >= 19 && !memcmp(l,"master_link_status:",19)) {
    ri->slave_master_link_status =
        (strcasecmp(l+19,"up") == 0) ?
        SENTINEL_MASTER_LINK_STATUS_UP :
        SENTINEL_MASTER_LINK_STATUS_DOWN;
}
addReplyBulkCString(c,
   (ri->slave_master_link_status == SENTINEL_MASTER_LINK_STATUS_UP) ? "ok" : "err");

那么問題來了:主庫是如何判斷一個從庫到底能不能進(jìn)行增量同步的呢?

這是由于主庫維護(hù)了一個復(fù)制積壓緩沖區(qū) repl_backlog,這是一個 1M 大小的循環(huán)隊(duì)列瞪讼,所有對主庫寫入的內(nèi)容都會同時寫入該隊(duì)列钧椰。從庫發(fā)送同步請求后,主庫會優(yōu)先進(jìn)行增量同步的嘗試尝艘,如果從庫申請同步的 offset 在該隊(duì)列范圍內(nèi)演侯,說明可以進(jìn)行增量同步,否則表示有數(shù)據(jù)丟失背亥,必須進(jìn)行全量同步秒际。

int masterTryPartialResynchronization(client *c) {
  ...
  /* We still have the data our slave is asking for? */
  if (!server.repl_backlog ||
      psync_offset < server.repl_backlog_off ||
      psync_offset > (server.repl_backlog_off + server.repl_backlog_histlen)) {
    
        ...
  }
}

這里的 backlog 其實(shí)跟 MySQL 的 redolog 很類似,都具備容量有限狡汉、循環(huán)寫入的特點(diǎn)娄徊。區(qū)別在于在 MySQL 中, redolog 有多個文件盾戴,一旦所有 redolog 文件寫滿寄锐,MySQL 將不得不停下所有更新操作來刷臟頁,而這里的 backlog 則是直接覆蓋前面的內(nèi)容尖啡。

命令傳播

主從同步完成之后橄仆,后續(xù)由于主庫數(shù)據(jù)的更新將會通過命令傳播的方式同步到從庫,當(dāng)然這也是一個異步操作衅斩。

/* Propagate the specified command (in the context of the specified database id)
 * to AOF and Slaves.
 */
void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc,
               int flags)
{
    if (server.aof_state != AOF_OFF && flags & PROPAGATE_AOF)
        feedAppendOnlyFile(cmd,dbid,argv,argc);
    if (flags & PROPAGATE_REPL)
        replicationFeedSlaves(server.slaves,dbid,argv,argc);
}

主從心跳

replicationCron 這個定時任務(wù)里面還維持了主從之間的心跳機(jī)制 :

  • 主庫定期向所有從庫發(fā)送 ping 命令盆顾,具體周期通過參數(shù) repl_ping_slave_period(5.0 之后的版本更名為 repl-ping-replica-period) 控制,默認(rèn) 10s
  • 從庫通過 replicationSendAck 每隔 1s 向主庫發(fā)送 ack畏梆,上報(bào)自己當(dāng)前的復(fù)制偏移量
/* Send ACK to master from time to time.

全文完您宪,感謝閱讀。

References

[1] 官方文檔: https://redis.io/topics/cluster-tutorial
[2] 官方文檔: https://redis.io/topics/sentinel

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末奠涌,一起剝皮案震驚了整個濱河市宪巨,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌溜畅,老刑警劉巖捏卓,帶你破解...
    沈念sama閱讀 218,122評論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異达皿,居然都是意外死亡天吓,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,070評論 3 395
  • 文/潘曉璐 我一進(jìn)店門峦椰,熙熙樓的掌柜王于貴愁眉苦臉地迎上來龄寞,“玉大人,你說我怎么就攤上這事汤功∥镆兀” “怎么了?”我有些...
    開封第一講書人閱讀 164,491評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長色解。 經(jīng)常有香客問我茂嗓,道長,這世上最難降的妖魔是什么科阎? 我笑而不...
    開封第一講書人閱讀 58,636評論 1 293
  • 正文 為了忘掉前任述吸,我火速辦了婚禮,結(jié)果婚禮上锣笨,老公的妹妹穿的比我還像新娘蝌矛。我一直安慰自己,他們只是感情好错英,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,676評論 6 392
  • 文/花漫 我一把揭開白布入撒。 她就那樣靜靜地躺著,像睡著了一般椭岩。 火紅的嫁衣襯著肌膚如雪茅逮。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,541評論 1 305
  • 那天判哥,我揣著相機(jī)與錄音献雅,去河邊找鬼。 笑死塌计,一個胖子當(dāng)著我的面吹牛惩琉,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播夺荒,決...
    沈念sama閱讀 40,292評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼良蒸!你這毒婦竟也來了技扼?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,211評論 0 276
  • 序言:老撾萬榮一對情侶失蹤嫩痰,失蹤者是張志新(化名)和其女友劉穎剿吻,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體串纺,經(jīng)...
    沈念sama閱讀 45,655評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡丽旅,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,846評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了纺棺。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片榄笙。...
    茶點(diǎn)故事閱讀 39,965評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖祷蝌,靈堂內(nèi)的尸體忽然破棺而出茅撞,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 35,684評論 5 347
  • 正文 年R本政府宣布米丘,位于F島的核電站剑令,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏吁津。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,295評論 3 329
  • 文/蒙蒙 一堕扶、第九天 我趴在偏房一處隱蔽的房頂上張望碍脏。 院中可真熱鬧急黎,春花似錦、人聲如沸侧到。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,894評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽匠抗。三九已至故源,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間汞贸,已是汗流浹背绳军。 一陣腳步聲響...
    開封第一講書人閱讀 33,012評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留矢腻,地道東北人门驾。 一個月前我還...
    沈念sama閱讀 48,126評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像多柑,于是被迫代替她去往敵國和親奶是。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,914評論 2 355