一個(gè)包含10節(jié)點(diǎn)的Redis集群實(shí)踐案例
Redis 通常不會(huì)被用作主要的數(shù)據(jù)存儲(chǔ),但它在存儲(chǔ)和訪問(wèn)可容忍丟失的臨時(shí)數(shù)據(jù)(如度量指標(biāo)、會(huì)話狀態(tài)、緩存)方面卻獨(dú)有長(zhǎng)處,并且速度非常快,不僅提供了最佳性能,還內(nèi)置了一組非常有用的數(shù)據(jù)結(jié)構(gòu)。它是現(xiàn)代技術(shù)棧中最常見(jiàn)的主要部件之一。
Stripe(一家做支付的硅谷創(chuàng)業(yè)公司)的速率限定器就是基于 Redis 構(gòu)建的,這些限速器運(yùn)行在一個(gè) Redis 實(shí)例上。Redis 主服務(wù)器有一些用于失效備援的追隨者,不過(guò)在任何時(shí)候,都只有一個(gè)節(jié)點(diǎn)在處理讀寫(xiě)操作。
各種消息來(lái)源聲稱(chēng),一個(gè) Redis 節(jié)點(diǎn)每秒可以處理百萬(wàn)次操作。盡管我們的操作沒(méi)有那么多,但也不會(huì)很少。每個(gè)速率限定器都需要運(yùn)行多個(gè) Redis 命令,而每個(gè) API 請(qǐng)求都要通過(guò)很多個(gè)速率限定器。所以,每個(gè)節(jié)點(diǎn)每秒鐘需要處理數(shù)萬(wàn)次到數(shù)十萬(wàn)次的操作。
如果節(jié)點(diǎn)出現(xiàn)飽和,就會(huì)不斷出現(xiàn)故障。我們的服務(wù)可以容忍 Redis 的不可用,因此大多數(shù)情況下是沒(méi)有問(wèn)題的,但在某些情況下,問(wèn)題的嚴(yán)重程度會(huì)升級(jí)。我們最后通過(guò)遷移到包含 10 節(jié)點(diǎn)的 Redis 集群來(lái)解決這個(gè)問(wèn)題。對(duì)性能的影響可以忽略不計(jì),重要的是現(xiàn)在我們可以實(shí)現(xiàn)水平可伸縮。
改造前后的錯(cuò)誤率比較:
使用 Redis 集群后錯(cuò)誤率明顯降低
在更換系統(tǒng)之前,應(yīng)該先了解導(dǎo)致原始故障的原因。
雖說(shuō) Redis 使用了單線程模型,但也并非那么嚴(yán)格,因?yàn)楹笈_(tái)還是使用了其他線程來(lái)處理一些操作,比如刪除對(duì)象,不過(guò)所有正在執(zhí)行的操作還是會(huì)阻塞在單個(gè)控制點(diǎn)上。
要理解這些并非難事——Redis 操作(無(wú)論是單一命令、MULTI 還是 EXEC)的原子性保證源于它一次只執(zhí)行一個(gè)操作。即便如此,Redis 還是有可能會(huì)采用并行機(jī)制,F(xiàn)AQ 中的一些內(nèi)容表明,5.0 之后的版本有可能考慮采用多線程設(shè)計(jì)。
單線程模型確實(shí)是我們的瓶頸所在,在登錄到原始節(jié)點(diǎn)時(shí)可以看到,單核的使用率達(dá)到了 100%。
我們發(fā)現(xiàn),即使開(kāi)啟了最大容量,Redis 也會(huì)自動(dòng)優(yōu)雅地降級(jí)。主要表現(xiàn)是,與 Redis 發(fā)生交互的節(jié)點(diǎn)的基線連接性錯(cuò)誤率在增加——為了容忍發(fā)生故障的 Redis,它們受到連接和讀取超時(shí)(約 0.1 秒)方面的限制,并且無(wú)法在給定時(shí)間內(nèi)建立用于執(zhí)行操作的連接。
這種情況在大多數(shù)時(shí)候是沒(méi)有問(wèn)題的。只有當(dāng)合法用戶成功通過(guò)身份驗(yàn)證并在底層數(shù)據(jù)庫(kù)上進(jìn)行昂貴的操作時(shí)(也就是說(shuō),數(shù)量級(jí)超過(guò)允許的范圍),它才會(huì)成為問(wèn)題。這種昂貴的操作是相對(duì)而言的——從列表中返回一組對(duì)象比用 401 錯(cuò)誤來(lái)拒絕請(qǐng)求或用 429 錯(cuò)誤來(lái)告知超制都要昂貴得多。這些昂貴的操作通常都是因?yàn)橛脩暨\(yùn)行高并發(fā)程序而導(dǎo)致的。
這些流量高峰會(huì)導(dǎo)致錯(cuò)誤率成比例增加,并且很多流量將被允許通過(guò)限速器,因?yàn)樵诎l(fā)生錯(cuò)誤時(shí),限速器默認(rèn)允許請(qǐng)求通過(guò)。這會(huì)給后端數(shù)據(jù)庫(kù)帶來(lái)更大的壓力,而且這種壓力所帶來(lái)的故障不會(huì)像 Redis 的過(guò)載故障那么優(yōu)雅。我們可以看到,分區(qū)幾乎完全不可操作,并且大量請(qǐng)求出現(xiàn)超時(shí)。
Redis 集群的分片模型
Redis 的核心價(jià)值是速度,而 Redis 集群的分布式結(jié)構(gòu)不會(huì)對(duì)此產(chǎn)生任何影響。與其他分布式模型不同的是,Redis 集群的操作不需要通過(guò)多個(gè)節(jié)點(diǎn)的確認(rèn),它看起來(lái)更像是一組獨(dú)立的 Redis 實(shí)例在分擔(dān)工作負(fù)載。這就是通過(guò)犧牲可用性來(lái)?yè)Q取速度——與 Redis 獨(dú)立實(shí)例相比,Redis 群集操作的額外開(kāi)銷(xiāo)可以忽略不計(jì)。
鍵空間總共被分為 16384 個(gè)槽,槽是通過(guò)穩(wěn)定的散列函數(shù)計(jì)算出來(lái)的,所有客戶端都知道該如何使用這個(gè)散列函數(shù):
- HASH_SLOT = CRC16(key) mod 16384
例如,如果我們想執(zhí)行 GET foo,會(huì)得到 foo 的槽號(hào):
- HASH_SLOT = CRC16("foo") mod 16384 = 12182
集群中的每個(gè)節(jié)點(diǎn)將處理 16384 個(gè)槽中的一部分,具體取決于節(jié)點(diǎn)數(shù)量。節(jié)點(diǎn)間通過(guò)彼此交互來(lái)調(diào)節(jié)槽的數(shù)量、進(jìn)行可用性轉(zhuǎn)移和再均衡。
分布在集群各個(gè)節(jié)點(diǎn)上的槽
客戶端使用 CLUSTER 系列命令來(lái)查詢(xún)集群的狀態(tài)。CLUSTER NODES 是一個(gè)常見(jiàn)的操作,用于獲取槽到節(jié)點(diǎn)的映射,其結(jié)果通常緩存在本地。
- 127.0.0.1:30002 master - 0 1426238316232 2 connected 5461-10922
- 127.0.0.1:30003 master - 0 1426238318243 3 connected 10923-16383
- 127.0.0.1:30001 myself,master - 0 0 1 connected 0-5460
上面的輸出經(jīng)過(guò)了簡(jiǎn)化,最重要的部分是第一列的主機(jī)地址和最后一列的數(shù)字。5461-10922 表示該節(jié)點(diǎn)處理從 5461 到 10922 的槽。
MOVED重定向
如果 Redis 群集中的某個(gè)節(jié)點(diǎn)接收到一個(gè)無(wú)法處理的命令,并不會(huì)嘗試將該命令轉(zhuǎn)發(fā)給其他節(jié)點(diǎn)。相反,客戶會(huì)被告知向其他節(jié)點(diǎn)嘗試發(fā)送該命令。這是通過(guò) MOVED 響應(yīng)來(lái)實(shí)現(xiàn)的,MOVED 響應(yīng)消息包含了新的目標(biāo)地址:
- GET foo
- -MOVED 3999 127.0.0.1:6381
在集群進(jìn)行再均衡期間,槽從一個(gè)節(jié)點(diǎn)遷移到另一個(gè)節(jié)點(diǎn),而 MOVED 是服務(wù)器用于告訴客戶端,槽到節(jié)點(diǎn)的映射已經(jīng)發(fā)生了變化。
一個(gè)槽從一個(gè)節(jié)點(diǎn)遷移到另一個(gè)節(jié)點(diǎn)
每個(gè)節(jié)點(diǎn)都知道當(dāng)前的映射關(guān)系,理論上,當(dāng)一個(gè)節(jié)點(diǎn)在接收到無(wú)法處理的操作時(shí),可以向正確的節(jié)點(diǎn)請(qǐng)求結(jié)果,并將結(jié)果轉(zhuǎn)發(fā)回客戶端,但 MOVED 其實(shí)是一種有意的設(shè)計(jì)。它通過(guò)將一些額外的復(fù)雜性交給客戶端去實(shí)現(xiàn),以便換取更快的速度。只要客戶端的映射是最新的,請(qǐng)求操作總能在一個(gè) hop 之內(nèi)完成。由于再均衡相對(duì)較少出現(xiàn),因此在群集的使用期間,花在協(xié)調(diào)上的開(kāi)銷(xiāo)可以忽略不計(jì)。
除了 MOVED 之外,Redis 集群還有其他一些特定的機(jī)制,但為了簡(jiǎn)潔起見(jiàn),我將跳過(guò)它們。完整的規(guī)范(https://redis.io/topics/cluster-spec)是深入了解 Redis 集群工作原理的重要資源。
客戶端如何發(fā)送請(qǐng)求
Redis 客戶端需要一些額外的功能來(lái)支持 Redis 群集,其中最重要的是要支持鍵的散列算法和用于維護(hù)槽到節(jié)點(diǎn)映射的方案,這樣它們就知道往哪里發(fā)送命令。
一般來(lái)說(shuō),客戶端會(huì)這樣操作:
- 在啟動(dòng)時(shí),連接到一個(gè)節(jié)點(diǎn)并獲得一個(gè) CLUSTER NODES 的映射表。
- 正常執(zhí)行命令,根據(jù)槽和槽映射定位服務(wù)器。
- 如果收到 MOVED,返回到第一步。
我們可以在客戶端使用多線程進(jìn)行優(yōu)化,在收到 MOVED 時(shí)將映射表標(biāo)記為過(guò)時(shí),一些線程向新的服務(wù)器發(fā)送命令,同時(shí)讓后臺(tái)線程異步刷新映射表。實(shí)際上,即使發(fā)生了再均衡,大多數(shù)槽也不需要移動(dòng),因此該模型允許大多數(shù)命令在沒(méi)有額外開(kāi)銷(xiāo)的情況下繼續(xù)執(zhí)行。
使用散列標(biāo)簽本地化多鍵操作
在 Redis 中,通過(guò) EVAL 命令和自定義 Lua 腳本來(lái)運(yùn)行多鍵操作是很常見(jiàn)的。這是實(shí)現(xiàn)速率限定器的一個(gè)特別重要的特性,因?yàn)橥ㄟ^(guò)單個(gè) EVAL 命令分派的操作是原子性的。我們因此能夠正確計(jì)算剩余配額,即使存在可能會(huì)發(fā)生沖突的并發(fā)操作。
分布式模型會(huì)讓這種多鍵操作變得十分困難。由于每個(gè)鍵對(duì)應(yīng)的槽都是通過(guò)散列來(lái)計(jì)算的,因此不能保證相關(guān)鍵都會(huì)被映射到同一個(gè)槽。比如,user123.first_name 和 user123.last_name 顯然應(yīng)該是要放在一起的,但最終可能會(huì)分布在兩個(gè)完全不同的節(jié)點(diǎn)上。
舉例來(lái)說(shuō),我們有一個(gè) EVAL 操作,將姓和名連接起來(lái)組合成一個(gè)人的全名:
- # Gets the full name of a user
- EVAL "return redis.call('GET', KEYS[1]) .. ' ' .. redis.call('GET', KEYS[2])"
- 2 "user123.first_name" "user123.last_name"
調(diào)用示例:
- > SET "user123.first_name" William
- > SET "user123.last_name" Adama
- > EVAL "..." 2 "user123.first_name" "user123.last_name"
- "William Adama"
如果 Redis 集群沒(méi)有提供這種方式,該腳本將無(wú)法正常運(yùn)行。幸運(yùn)的是,我們通過(guò)使用哈希標(biāo)簽來(lái)運(yùn)行腳本。
對(duì)于需要跨節(jié)點(diǎn)操作的 EVAL,Redis 集群會(huì)禁止它們(這樣做也是出于速度方面的考慮)。所以,用戶需要確保 EVAL 中的鍵屬于相同的槽,可以通過(guò)散列標(biāo)簽來(lái)獲得鍵的散列值。散列標(biāo)簽就是鍵名字中的花括號(hào),表示只有花括號(hào)部分用于散列。
我們對(duì)鍵進(jìn)行重新定義,只對(duì) user123 進(jìn)行散列處理:
- > EVAL "..." 2 "{user123}.first_name" "{user123}.last_name"
計(jì)算其中一個(gè)槽:
- HASH_SLOT = CRC16("{user123}.first_name") mod 16384
- = CRC16("user123") mod 16384
- = 13438
.first_name 和{user123}.last_name 現(xiàn)在映射到了相同的槽,那么就可以執(zhí)行 EVAL 操作了。這是一個(gè)簡(jiǎn)單的例子,不過(guò)相同的概念可被用于實(shí)現(xiàn)復(fù)雜的速率限定器。
遷移到 Redis 集群非常順利,最困難的部分是如何構(gòu)建一個(gè)可用于生產(chǎn)環(huán)境的 Redis 集群客戶端。即使到了今天,Redis 客戶端的質(zhì)量也是參差不齊,可能是因?yàn)?Redis 速度足夠快,以至于大多數(shù)人直接使用單個(gè)實(shí)例。
從設(shè)計(jì)方面看,Redis 集群的設(shè)計(jì)有很多值得一提的地方——簡(jiǎn)單但功能強(qiáng)大。特別是當(dāng)涉及到分布式系統(tǒng)時(shí),許多實(shí)現(xiàn)過(guò)程非常復(fù)雜,而在生產(chǎn)環(huán)境中遇到極端錯(cuò)誤時(shí),復(fù)雜程度可能是災(zāi)難性的。Redis 集群具備了可伸縮性,卻沒(méi)有那么多令人難以理解的組件,即使像我這樣的門(mén)外漢也能明白它的原理。它的設(shè)計(jì)文檔也很好理解,很接地氣。
在搭建集群之后的幾個(gè)月,盡管每時(shí)每刻都有相當(dāng)大的負(fù)載,我也沒(méi)有再去碰過(guò)它。如此高質(zhì)量的集群實(shí)屬罕見(jiàn)。我們需要更多像 Redis 這樣的構(gòu)建塊,讓它們做它們?cè)撟龅氖?,無(wú)需我們多作操心。