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

































