KV 存儲發(fā)展歷程
美團(tuán)第一代的分布式 KV 存儲如下圖左側(cè)的架構(gòu)所示,相信很多公司都經(jīng)歷過這個階段。在客戶端內(nèi)做一致性哈希,在后端部署很多的 Memcached 實(shí)例,這樣就實(shí)現(xiàn)了最基本的 KV 存儲分布式設(shè)計。但這樣的設(shè)計存在很明顯的問題:比如在宕機(jī)摘除節(jié)點(diǎn)時,會丟數(shù)據(jù),緩存空間不夠需要擴(kuò)容,一致性哈希也會丟失一些數(shù)據(jù)等等,這樣會給業(yè)務(wù)開發(fā)帶來的很多困擾。
隨著 Redis 項(xiàng)目的成熟,美團(tuán)也引入了 Redis 來解決我們上面提到的問題,進(jìn)而演進(jìn)出來如上圖右側(cè)這樣一個架構(gòu)。大家可以看到,客戶端還是一樣,采用了一致性哈希算法,服務(wù)器端變成了 Redis 組成的主從結(jié)構(gòu)。當(dāng)任何一個節(jié)點(diǎn)宕機(jī),我們可以通過 Redis 哨兵完成 Failover,實(shí)現(xiàn)高可用。但有一個問題還是沒有解決,如果擴(kuò)縮容的話,一致性哈希仍然會丟數(shù)據(jù),那么這個問題該如何解決呢?
這個時候,我們發(fā)現(xiàn)有了一個比較成熟的 KV 存儲開源項(xiàng)目:阿里 Tair 。2014年,我們引入了 Tair 來滿足業(yè)務(wù) KV 存儲方面的需求。Tair 開源版本的架構(gòu)主要分成三部分:上圖下邊是存儲節(jié)點(diǎn),存儲節(jié)點(diǎn)會上報心跳到它的中心節(jié)點(diǎn),中心節(jié)點(diǎn)內(nèi)部有兩個配置管理節(jié)點(diǎn),會監(jiān)控所有的存儲節(jié)點(diǎn)。當(dāng)有任何存儲節(jié)點(diǎn)宕機(jī)或者擴(kuò)容時,它會做集群拓?fù)涞闹匦聵?gòu)建。當(dāng)客戶端啟動時,它會直接從中心節(jié)點(diǎn)拉來一個路由表。這個路由表簡單來說就是一個集群的數(shù)據(jù)分布圖,客戶端根據(jù)路由表直接去存儲節(jié)點(diǎn)讀寫。針對之前 KV 的擴(kuò)容丟數(shù)據(jù)問題,它也有數(shù)據(jù)遷移機(jī)制來保證數(shù)據(jù)的完整性。
但是,我們在使用的過程中,還遇到了一些其他問題,比如中心節(jié)點(diǎn)雖然是主備高可用的,但實(shí)際上它沒有類似于分布式仲裁的機(jī)制,所以在網(wǎng)絡(luò)分割的情況下,它是有可能發(fā)生“腦裂”的,這個也給我們的業(yè)務(wù)造成過比較大的影響。另外,在容災(zāi)擴(kuò)容時,也遇到過數(shù)據(jù)遷移影響到業(yè)務(wù)可用性的問題。另外,我們之前用過 Redis ,業(yè)務(wù)會發(fā)現(xiàn) Redis 的數(shù)據(jù)結(jié)構(gòu)特別豐富,而 Tair 還不支持這些數(shù)據(jù)結(jié)構(gòu)。雖然我們用 Tair 解決了一些問題,但是 Tair 也無法完全滿足業(yè)務(wù)需求。畢竟,在美團(tuán)這樣一個業(yè)務(wù)規(guī)模較大和業(yè)務(wù)復(fù)雜度較高的場景下,很難有開源系統(tǒng)能很好地滿足我們的需求。最終,我們決定在已應(yīng)用的開源系統(tǒng)之上進(jìn)行自研。
剛好在2015 年, Redis 官方正式發(fā)布了集群版本 Redis Cluster。所以,我們緊跟社區(qū)步伐,并結(jié)合內(nèi)部需求做了很多開發(fā)工作,演進(jìn)出了全內(nèi)存、高吞吐、低延遲的 KV 存儲 Squirrel。另外,基于 Tair,我們還加入了很多自研的功能,演進(jìn)出持久化、大容量、數(shù)據(jù)高可靠的 KV 存儲 Cellar 。因?yàn)?Tair 的開源版本已經(jīng)有四五年沒有更新了,所以,Cellar 的迭代完全靠美團(tuán)自研,而 Redis 社區(qū)一直很活躍??偟膩碚f,Squirrel 的迭代是自研和社區(qū)并重,自研功能設(shè)計上也會盡量與官方架構(gòu)進(jìn)行兼容。后面大家可以看到,因?yàn)檫@些不同,Cellar 和 Squirrel 在解決同樣的問題時也選取了不同的設(shè)計方案。
這兩個存儲其實(shí)都是 KV 存儲領(lǐng)域不同的解決方案。在實(shí)際應(yīng)用上,如果業(yè)務(wù)的數(shù)據(jù)量小,對延遲敏感,我們建議大家用 Squirrel ;如果數(shù)據(jù)量大,對延遲不是特別敏感,我們建議用成本更低的 Cellar 。目前這兩套 KV 存儲系統(tǒng)在美團(tuán)內(nèi)部每天的調(diào)用量均已突破萬億,它們的請求峰值也都突破了每秒億級。
內(nèi)存 KV Squirrel 架構(gòu)和實(shí)踐
在開始之前,本文先介紹兩個存儲系統(tǒng)共通的地方。比如分布式存儲的經(jīng)典問題:數(shù)據(jù)是如何分布的?這個問題在 KV 存儲領(lǐng)域,就是 Key 是怎么分布到存儲節(jié)點(diǎn)上的。這里 Squirrel 跟 Cellar 是一樣的。當(dāng)我們拿到一個 Key 后,用固定的哈希算法拿到一個哈希值,然后將哈希值對 Slot 數(shù)目取模得到一個Slot id,我們兩個 KV 現(xiàn)在都是預(yù)分片16384個 Slot 。得到 Slot id 之后,再根據(jù)路由表就能查到這個 Slot 存儲在哪個存儲節(jié)點(diǎn)上。這個路由表簡單來說就是一個 Slot 到存儲節(jié)點(diǎn)的對照表。
KV 數(shù)據(jù)分布介紹
接下來講一下對高可用架構(gòu)的認(rèn)知,個人認(rèn)為高可用可以從宏觀和微觀兩個角度來看。從宏觀的角度來看,高可用就是指容災(zāi)怎么做。比如說掛掉了一個節(jié)點(diǎn),你該怎么做?一個機(jī)房或者說某個地域的一批機(jī)房宕機(jī)了,你該怎么做?而從微觀的角度看,高可用就是怎么能保證端到端的高成功率。我們在做一些運(yùn)維升級或者擴(kuò)縮容數(shù)據(jù)遷移的時候,能否做到業(yè)務(wù)請求的高可用?本文也會從宏觀和微觀兩個角度來分享美團(tuán)做的一些高可用工作。
Squirrel 架構(gòu)
上圖就是我們的 Squirrel 架構(gòu)。中間部分跟 Redis 官方集群是一致的。它有主從的結(jié)構(gòu), Redis 實(shí)例之間通過 Gossip 協(xié)議去通信。我們在右邊添加了一個集群調(diào)度平臺,包含調(diào)度服務(wù)、擴(kuò)縮容服務(wù)和高可用服務(wù)等,它會去管理整個集群,把管理結(jié)果作為元數(shù)據(jù)更新到 ZooKeeper。我們的客戶端會訂閱 ZooKeeper 上的元數(shù)據(jù)變更,實(shí)時獲取到集群的拓?fù)錉顟B(tài),直接在 Redis 集群進(jìn)行讀寫操作。
Squirrel 節(jié)點(diǎn)容災(zāi)
然后再看一下 Squirrel 容災(zāi)怎么做。對于 Redis 集群而言,節(jié)點(diǎn)宕機(jī)已經(jīng)有完備的處理機(jī)制了。官方提供的方案,任何一個節(jié)點(diǎn)從宕機(jī)到被標(biāo)記為 FAIL 摘除,一般需要經(jīng)過 30 秒。主庫的摘除可能會影響數(shù)據(jù)的完整性,所以,我們需要謹(jǐn)慎一些。但是對于從庫呢?我們認(rèn)為這個過程完全沒必要。另一點(diǎn),我們都知道內(nèi)存的 KV 存儲數(shù)據(jù)量一般都比較小。對于業(yè)務(wù)量很大的公司來說,它往往會有很多的集群。如果發(fā)生交換機(jī)故障,會影響到很多的集群,宕機(jī)之后去補(bǔ)副本就會變得非常麻煩。為了解決這兩個問題,我們做了 HA 高可用服務(wù)。
它的架構(gòu)如下圖所示,它會實(shí)時監(jiān)控集群的所有節(jié)點(diǎn)。不管是網(wǎng)絡(luò)抖動,還是發(fā)生了宕機(jī)(比如說 Redis 2 ),它可以實(shí)時更新 ZooKeeper ,告訴 ZooKeeper 去摘除 Redis 2 ,客戶端收到消息后,讀流量就直接路由到 Redis 3上。如果 Redis 2 只是幾十秒的網(wǎng)絡(luò)抖動,過幾十秒之后,如果 HA 節(jié)點(diǎn)監(jiān)控到它恢復(fù)后,會把它重新加回。
Squirrel—節(jié)點(diǎn)容災(zāi)
如果過了一段時間,HA 判斷它屬于一個永久性的宕機(jī),HA 節(jié)點(diǎn)會直接從 Kubernetes 集群申請一個新的 Redis 4 容器實(shí)例,把它加到集群里。此時,拓?fù)浣Y(jié)構(gòu)又變成了一主兩從的標(biāo)準(zhǔn)結(jié)構(gòu),HA 節(jié)點(diǎn)更新完集群拓?fù)渲?,就會去?ZooKeeper 通知客戶端去更新路由,客戶端就能到 Redis 4 這個新從庫上進(jìn)行讀操作。
通過上述方案,我們把從庫的摘除時間從 30 秒降低到了 5 秒。另外,我們通過 HA 自動申請容器實(shí)例加入集群的方式,把宕機(jī)補(bǔ)副本變成了一個分鐘級的自動操作,不需要任何人工的介入。
Squirrel 跨地域容災(zāi)
我們解決了單節(jié)點(diǎn)宕機(jī)的問題,那么跨地域問題如何解決呢?我們首先來看下跨地域有什么不同。第一,相對于同地域機(jī)房間的網(wǎng)絡(luò)而言,跨地域?qū)>€很不穩(wěn)定;第二,跨地域?qū)>€的帶寬是非常有限且昂貴的。而集群內(nèi)的復(fù)制沒有考慮極端的網(wǎng)絡(luò)環(huán)境。假如我們把主庫部署到北京,兩個從庫部署在上海,同樣一份數(shù)據(jù)要在北上專線傳輸兩次,這樣會造成巨大的專線帶寬浪費(fèi)。另外,隨著業(yè)務(wù)的發(fā)展和演進(jìn),我們也在做單元化部署和異地多活架構(gòu)。用官方的主從同步,滿足不了我們的這些需求?;诖?,我們又做了集群間的復(fù)制方案。
如上圖所示,這里畫出了北京的主集群以及上海的從集群,我們要做的是通過集群同步服務(wù),把北京主集群的數(shù)據(jù)同步到上海從集群上。按照流程,首先要向我們的同步調(diào)度模塊下發(fā)“在兩個集群間建立同步鏈路”的任務(wù),同步調(diào)度模塊會根據(jù)主從集群的拓?fù)浣Y(jié)構(gòu),把主從集群間的同步任務(wù)下發(fā)到同步集群,同步集群收到同步任務(wù)后會扮成 Redis 的 Slave,通過 Redis 的復(fù)制協(xié)議,從主集群上的從庫拉取數(shù)據(jù),包括 RDB以及后續(xù)的增量變更。同步機(jī)收到數(shù)據(jù)后會把它轉(zhuǎn)成客戶端的寫命令,寫到上海從集群的主節(jié)點(diǎn)里。
通過這樣的方式,我們把北京主集群的數(shù)據(jù)同步到了上海的從集群。同樣的,我們要做異地多活也很簡單,再加一個反向的同步鏈路,就可以實(shí)現(xiàn)集群間的雙向同步。
接下來我們講一下如何做好微觀角度的高可用,也就是保持端到端的高成功率。對于 Squirrel ,主要講如下三個影響成功率的問題:
數(shù)據(jù)遷移造成超時抖動。
持久化造成超時抖動。
熱點(diǎn) Key 請求導(dǎo)致單節(jié)點(diǎn)過載。
Squirrel 智能遷移
對于數(shù)據(jù)遷移,我們主要遇到三個問題:
Redis Cluster 雖然提供了數(shù)據(jù)遷移能力,但是對于要遷哪些 Slot,Slot 從哪遷到哪,它并不管。
做數(shù)據(jù)遷移的時候,大家都想越快越好,但是遷移速度過快又可能影響業(yè)務(wù)正常請求。
Redis 的 Migrate 命令會阻塞工作線程,尤其在遷移大 Value 的時候會阻塞特別久。
為了解決這些問題,我們做了全新的遷移服務(wù)。
下面我們按照工作流,講一下它是如何運(yùn)行的。首先生成遷移任務(wù),這步的核心是“就近原則”,比如說同機(jī)房的兩個節(jié)點(diǎn)做遷移肯定比跨機(jī)房的兩個節(jié)點(diǎn)快。遷移任務(wù)生成之后,會把任務(wù)下發(fā)到一批遷移機(jī)上。遷移機(jī)遷移的時候,有這樣幾個特點(diǎn):
會在集群內(nèi)遷出節(jié)點(diǎn)間做并發(fā),比如同時給 Redis 1、Redis 3 下發(fā)遷移命令。
每個 Migrate 命令會遷移一批 Key。
我們會用監(jiān)控服務(wù)去實(shí)時采集客戶端的成功率、耗時,服務(wù)端的負(fù)載、QPS 等,之后把這個狀態(tài)反饋到遷移機(jī)上。遷移數(shù)據(jù)的過程就類似 TCP 慢啟動的過程,它會把速度一直往上加,若出現(xiàn)請求成功率下降等情況,它的速度就會降低,最終遷移速度會在動態(tài)平衡中穩(wěn)定下來,這樣就達(dá)到了最快速的遷移,同時又盡可能小地影響業(yè)務(wù)的正常請求。
接下來,我們看一下大 Value 的遷移,我們實(shí)現(xiàn)了一個異步 Migrate 命令,該命令執(zhí)行時,Redis 的主線程會繼續(xù)處理其他的正常請求。如果此時有對正在遷移 Key 的寫請求過來,Redis 會直接返回錯誤。這樣最大限度保證了業(yè)務(wù)請求的正常處理,同時又不會阻塞主線程。
Squirrel 持久化重構(gòu)
Redis 主從同步時會生成 RDB。生成 RDB 的過程會調(diào)用 Fork 產(chǎn)生一個子進(jìn)程去寫數(shù)據(jù)到硬盤,F(xiàn)ork 雖然有操作系統(tǒng)的 COW 機(jī)制,但是當(dāng)內(nèi)存用量達(dá)到 10 G 或 20 G 時,依然會造成整個進(jìn)程接近秒級的阻塞。這對在線業(yè)務(wù)來說幾乎是無法接受的。我們也會為數(shù)據(jù)可靠性要求高的業(yè)務(wù)去開啟 AOF,而開 AOF 就可能因 IO 抖動造成進(jìn)程阻塞,這也會影響請求成功率。對官方持久化機(jī)制的這兩個問題,我們的解決方案是重構(gòu)持久化機(jī)制。
上圖是我們最新版的 Redis 持久化機(jī)制,寫請求會先寫到 DB 里,然后寫到內(nèi)存 Backlog,這跟官方是一樣的。同時它會把請求發(fā)給異步線程,異步線程負(fù)責(zé)把變更刷到硬盤的 Backlog 里。當(dāng)硬盤 Backlog 過多時,我們會主動在業(yè)務(wù)低峰期做一次 RDB ,然后把 RDB 之前生成的 Backlog 刪除。
如果這時候我們要做主從同步,去尋找同步點(diǎn)的時候,該怎么辦?第一步還是跟官方一樣,我們會從內(nèi)存 Backlog 里找有沒有要求的同步點(diǎn),如果沒有,我們會去硬盤 Backlog 找同步點(diǎn)。由于硬盤空間很大,硬盤 Backlog 可以存儲特別多的數(shù)據(jù),所以很少會出現(xiàn)找不到同步點(diǎn)的情況。如果硬盤 Backlog 也沒有,我們就會觸發(fā)一次類似于全量重傳的操作,但這里的全量重傳是不需要當(dāng)場生成 RDB 的,它可以直接用硬盤已存的 RDB 及其之后的硬盤 Backlog 完成全量重傳。通過這個設(shè)計,我們減少了很多的全量重傳。
另外,我們通過控制在低峰區(qū)生成 RDB ,減少了很多 RDB 造成的抖動。同時,我們也避免了寫 AOF 造成的抖動。不過,這個方案因?yàn)閷?AOF 是完全異步的,所以會比官方的數(shù)據(jù)可靠性差一些,但我們認(rèn)為這個代價換來了可用性的提升,這是非常值得的。
Squirrel 熱點(diǎn) Key
下面看一下 Squirrel 的熱點(diǎn) Key 解決方案。如下圖所示,普通主、從是一個正常集群中的節(jié)點(diǎn),熱點(diǎn)主、從是游離于正常集群之外的節(jié)點(diǎn)。我們看一下它們之間怎么發(fā)生聯(lián)系。
當(dāng)有請求進(jìn)來讀寫普通節(jié)點(diǎn)時,節(jié)點(diǎn)內(nèi)會同時做請求 Key 的統(tǒng)計。如果某個 Key 達(dá)到了一定的訪問量或者帶寬的占用量,會自動觸發(fā)流控以限制熱點(diǎn) Key 訪問,防止節(jié)點(diǎn)被熱點(diǎn)請求打滿。同時,監(jiān)控服務(wù)會周期性的去所有 Redis 實(shí)例上查詢統(tǒng)計到的熱點(diǎn) Key。如果有熱點(diǎn),監(jiān)控服務(wù)會把熱點(diǎn) Key 所在 Slot 上報到我們的遷移服務(wù)。遷移服務(wù)這時會把熱點(diǎn)主從節(jié)點(diǎn)加入到這個集群中,然后把熱點(diǎn) Slot 遷移到這個熱點(diǎn)主從上。因?yàn)闊狳c(diǎn)主從上只有熱點(diǎn) Slot 的請求,所以熱點(diǎn) Key的處理能力得到了大幅提升。通過這樣的設(shè)計,我們可以做到實(shí)時的熱點(diǎn)監(jiān)控,并及時通過流控去止損;通過熱點(diǎn)遷移,我們能做到自動的熱點(diǎn)隔離和快速的容量擴(kuò)充。
持久化 KV Cellar 架構(gòu)和實(shí)踐
下面看一下持久化 KV Cellar 的架構(gòu)和實(shí)踐。下圖是我們最新的 Cellar 架構(gòu)圖。
跟阿里開源的 Tair 主要有兩個架構(gòu)上的不同。第一個是OB,第二個是 ZooKeeper。我們的 OB 跟 ZooKeeper 的 Observer 是類似的作用,提供 Cellar 中心節(jié)點(diǎn)元數(shù)據(jù)的查詢服務(wù)。它可以實(shí)時與中心節(jié)點(diǎn)的 Master 同步最新的路由表,客戶端的路由表都是從 OB 去拿。這樣做的好處主要有兩點(diǎn),第一,把大量的業(yè)務(wù)客戶端跟集群的大腦 Master 做了天然的隔離,防止路由表請求影響集群的管理。第二,因?yàn)?OB 只供路由表查詢,不參與集群的管理,所以它可以進(jìn)行水平擴(kuò)展,極大地提升了我們路由表的查詢能力。另外,我們引入了 ZooKeeper 做分布式仲裁,解決我剛才提到的 Master、Slave 在網(wǎng)絡(luò)分割情況下的“腦裂”問題,并且通過把集群的元數(shù)據(jù)存儲到 ZooKeeper,我們保證了元數(shù)據(jù)的高可靠。
Cellar 節(jié)點(diǎn)容災(zāi)
介紹完整體的架構(gòu),我們看一下 Cellar 怎么做節(jié)點(diǎn)容災(zāi)。一個集群節(jié)點(diǎn)的宕機(jī)一般是臨時的,一個節(jié)點(diǎn)的網(wǎng)絡(luò)抖動也是臨時的,它們會很快地恢復(fù),并重新加入集群。因?yàn)楣?jié)點(diǎn)的臨時離開就把它徹底摘除,并做數(shù)據(jù)副本補(bǔ)全操作,會消耗大量資源,進(jìn)而影響到業(yè)務(wù)請求。所以,我們實(shí)現(xiàn)了 Handoff 機(jī)制來解決這種節(jié)點(diǎn)短時故障帶來的影響。
如上圖所示 ,如果 A 節(jié)點(diǎn)宕機(jī)了,會觸發(fā) Handoff 機(jī)制,這時候中心節(jié)點(diǎn)會通知客戶端 A節(jié)點(diǎn)發(fā)生了故障,讓客戶端把分片 1 的請求也打到 B 上。B 節(jié)點(diǎn)正常處理完客戶端的讀寫請求之后,還會把本應(yīng)該寫入 A 節(jié)點(diǎn)的分片 1&2 數(shù)據(jù)寫入到本地的 Log 中。
如果 A 節(jié)點(diǎn)宕機(jī)后 3~5 分鐘,或者網(wǎng)絡(luò)抖動 30~50 秒之后恢復(fù)了,A 節(jié)點(diǎn)就會上報心跳到中心節(jié)點(diǎn),中心節(jié)點(diǎn)就會通知 B 節(jié)點(diǎn):“ A 節(jié)點(diǎn)恢復(fù)了,你去把它不在期間的數(shù)據(jù)傳給它?!边@時候,B 節(jié)點(diǎn)就會把本地存儲的 Log 回寫到 A 節(jié)點(diǎn)上。等到 A 節(jié)點(diǎn)擁有了故障期間的全量數(shù)據(jù)之后,中心節(jié)點(diǎn)就會告訴客戶端,A 節(jié)點(diǎn)已經(jīng)徹底恢復(fù)了,客戶端就可以重新把分片 1 的請求打回 A 節(jié)點(diǎn)。
通過這樣的操作,我們可以做到秒級的快速節(jié)點(diǎn)摘除,而且節(jié)點(diǎn)恢復(fù)后加回,只需補(bǔ)齊少量的增量數(shù)據(jù)。另外如果 A 節(jié)點(diǎn)要做升級,中心節(jié)點(diǎn)先通過主動 Handoff 把 A 節(jié)點(diǎn)流量切到 B 節(jié)點(diǎn),A 升級后再回寫增量 Log,然后切回流量加入集群。這樣通過主動觸發(fā) Handoff 機(jī)制,我們就實(shí)現(xiàn)了靜默升級的功能。
Cellar 跨地域容災(zāi)
下面我介紹一下 Cellar 跨地域容災(zāi)是怎么做的。Cellar 跟 Squirrel 面對的跨地域容災(zāi)問題是一樣的,解決方案同樣也是集群間復(fù)制。以下圖一個北京主集群、上海從集群的跨地域場景為例,比如說客戶端的寫操作到了北京的主集群 A 節(jié)點(diǎn),A 節(jié)點(diǎn)會像正常集群內(nèi)復(fù)制一樣,把它復(fù)制到 B 和 D 節(jié)點(diǎn)上。同時 A 節(jié)點(diǎn)還會把數(shù)據(jù)復(fù)制一份到從集群的 H 節(jié)點(diǎn)。H 節(jié)點(diǎn)處理完集群間復(fù)制寫入之后,它也會做從集群內(nèi)的復(fù)制,把這個寫操作復(fù)制到從集群的 I 、K 節(jié)點(diǎn)上。通過在主從集群的節(jié)點(diǎn)間建立這樣一個復(fù)制鏈路,我們完成了集群間的數(shù)據(jù)復(fù)制,并且這個復(fù)制保證了最低的跨地域帶寬占用。同樣的,集群間的兩個節(jié)點(diǎn)通過配置兩個雙向復(fù)制的鏈路,就可以達(dá)到雙向同步異地多活的效果。
Cellar 強(qiáng)一致
我們做好了節(jié)點(diǎn)容災(zāi)以及跨地域容災(zāi)后,業(yè)務(wù)又對我們提出了更高要求:強(qiáng)一致存儲。我們之前的數(shù)據(jù)復(fù)制是異步的,在做故障摘除時,可能因?yàn)楣收瞎?jié)點(diǎn)數(shù)據(jù)還沒復(fù)制出來,導(dǎo)致數(shù)據(jù)丟失。但是對于金融支付等場景來說,它們是不容許數(shù)據(jù)丟失的。面對這個難題,我們該怎么解決?目前業(yè)界主流的解決方案是基于 Paxos 或 Raft 協(xié)議的強(qiáng)一致復(fù)制。我們最終選擇了 Raft 協(xié)議。主要是因?yàn)?Raft 論文是非常詳實(shí)的,是一篇工程化程度很高的論文。業(yè)界也有不少比較成熟的 Raft 開源實(shí)現(xiàn),可以作為我們研發(fā)的基礎(chǔ),進(jìn)而能夠縮短研發(fā)周期。
下圖是現(xiàn)在 Cellar 集群 Raft 復(fù)制模式下的架構(gòu)圖,中心節(jié)點(diǎn)會做 Raft 組的調(diào)度,它會決定每一個 Slot 的三副本存在哪些節(jié)點(diǎn)上。
大家可以看到 Slot 1 在存儲節(jié)點(diǎn) 1、2、4 上,Slot 2 在存儲節(jié)點(diǎn)2、3、4上。每個 Slot 組成一個 Raft 組,客戶端會去 Raft Leader 上進(jìn)行讀寫。由于我們是預(yù)分配了 16384 個 Slot,所以,在集群規(guī)模很小的時候,我們的存儲節(jié)點(diǎn)上可能會有數(shù)百甚至上千個 Slot 。
這時候如果每個 Raft 復(fù)制組都有自己的復(fù)制線程、 復(fù)制請求和 Log等,那么資源消耗會非常大,寫入性能會很差。所以我們做了 Multi Raft 實(shí)現(xiàn), Cellar 會把同一個節(jié)點(diǎn)上所有的 Raft 復(fù)制組寫一份 Log,用同一組線程去做復(fù)制,不同 Raft 組間的復(fù)制包也會按照目標(biāo)節(jié)點(diǎn)做整合,以保證寫入性能不會因 Raft 組過多而變差。Raft 內(nèi)部其實(shí)是有自己的選主機(jī)制,它可以控制自己的主節(jié)點(diǎn),如果有任何節(jié)點(diǎn)宕機(jī),它可以通過選舉機(jī)制選出新的主節(jié)點(diǎn)。
那么,中心節(jié)點(diǎn)是不是就不需要管理 Raft 組了嗎?不是的。這里講一個典型的場景,如果一個集群的部分節(jié)點(diǎn)經(jīng)過幾輪宕機(jī)恢復(fù)的過程, Raft Leader 在存儲節(jié)點(diǎn)之間會變得極其不均。而為了保證數(shù)據(jù)的強(qiáng)一致,客戶端的讀寫流量又必須發(fā)到 Raft Leader,這時候集群的節(jié)點(diǎn)流量會很不均衡。所以我們的中心節(jié)點(diǎn)還會做 Raft 組的 Leader 調(diào)度。比如說 Slot 1 存儲在節(jié)點(diǎn) 1、2、4,并且節(jié)點(diǎn) 1 是 Leader。如果節(jié)點(diǎn) 1 掛了,Raft 把節(jié)點(diǎn) 2 選成了 Leader。然后節(jié)點(diǎn) 1 恢復(fù)了并重新加入集群,中心節(jié)點(diǎn)這時會讓節(jié)點(diǎn) 2 把 Leader 還給節(jié)點(diǎn) 1 。這樣,即便經(jīng)過一系列宕機(jī)和恢復(fù),我們存儲節(jié)點(diǎn)之間的 Leader 數(shù)目仍然能保證是均衡的。
接下來,我們看一下 Cellar 如何保證它的端到端高成功率。這里也講三個影響成功率的問題。Cellar 遇到的數(shù)據(jù)遷移和熱點(diǎn) Key 問題與 Squirrel 是一樣的,但解決方案不一樣。這是因?yàn)?Cellar 走的是自研路徑,不用考慮與官方版本的兼容性,對架構(gòu)改動更大些。另一個問題是慢請求阻塞服務(wù)隊(duì)列導(dǎo)致大面積超時,這是 Cellar 網(wǎng)絡(luò)、工作多線程模型設(shè)計下會遇到的不同問題。
Cellar 智能遷移
上圖是 Cellar 智能遷移架構(gòu)圖。我們把桶的遷移分成了三個狀態(tài)。第一個狀態(tài)就是正常的狀態(tài),沒有任何遷移。如果這時候要把 Slot 2 從 A 節(jié)點(diǎn)遷移到 B節(jié)點(diǎn),A 會給 Slot 2 打一個快照,然后把這個快照全量發(fā)到 B 節(jié)點(diǎn)上。在遷移數(shù)據(jù)的時候, B 節(jié)點(diǎn)的回包會帶回 B 節(jié)點(diǎn)的狀態(tài)。B 的狀態(tài)包括什么?引擎的壓力、網(wǎng)卡流量、隊(duì)列長度等。A 節(jié)點(diǎn)會根據(jù) B 節(jié)點(diǎn)的狀態(tài)調(diào)整自己的遷移速度。像 Squirrel 一樣,它經(jīng)過一段時間調(diào)整后,遷移速度會達(dá)到一個動態(tài)平衡,達(dá)到最快速的遷移,同時又盡可能小地影響業(yè)務(wù)的正常請求。
當(dāng) Slot 2 遷移完后, 會進(jìn)入圖中 Slot 3 的狀態(tài)??蛻舳诉@時可能還沒更新路由表,當(dāng)它請求到了 A 節(jié)點(diǎn),A 節(jié)點(diǎn)會發(fā)現(xiàn)客戶端請求錯了節(jié)點(diǎn),但它不會返回錯誤,它會把請求代理到 B 節(jié)點(diǎn)上,然后把 B 的響應(yīng)包再返回客戶端。同時它會告訴客戶端,需要更新一下路由表了,此后客戶端就能直接訪問到 B 節(jié)點(diǎn)。這樣就解決了客戶端路由更新延遲造成的請求錯誤。
Cellar 快慢列隊(duì)
下圖上方是一個標(biāo)準(zhǔn)的線程隊(duì)列模型。網(wǎng)絡(luò)線程池接收網(wǎng)絡(luò)流量解析出請求包,然后把請求放到工作隊(duì)列里,工作線程池會從工作隊(duì)列取請求來處理,然后把響應(yīng)包放回網(wǎng)絡(luò)線程池發(fā)出。
我們分析線上發(fā)生的超時案例時發(fā)現(xiàn),一批超時請求當(dāng)中往往只有一兩個請求是引擎處理慢導(dǎo)致的,大部分請求,只是因?yàn)樵陉?duì)列等待過久導(dǎo)致整體響應(yīng)時間過長而超時了。從線上分析來看,真正的慢請求占超時請求的比例只有 1/20。
我們的解法是什么樣?很簡單,拆線程池、拆隊(duì)列。我們的網(wǎng)絡(luò)線程在收到包之后,會根據(jù)它的請求特點(diǎn),是讀還是寫,快還是慢,分到四個隊(duì)列里。讀寫請求比較好區(qū)分,但快慢怎么分開?我們會根據(jù)請求的 Key 個數(shù)、Value大小、數(shù)據(jù)結(jié)構(gòu)元素數(shù)等對請求進(jìn)行快慢區(qū)分。然后用對應(yīng)的四個工作線程池處理對應(yīng)隊(duì)列的請求,就實(shí)現(xiàn)了快慢讀寫請求的隔離。這樣如果我有一個讀的慢請求,不會影響另外三種請求的正常處理。不過這樣也會帶來一個問題,我們的線程池從一個變成四個,那線程數(shù)是不是變成原來的四倍?其實(shí)并不是的,我們某個線程池空閑的時候會去幫助其它的線程池處理請求。所以,我們線程池變成了四個,但是線程總數(shù)并沒有變。我們線上驗(yàn)證中這樣的設(shè)計能把服務(wù) TP999 的延遲降低 86%,可大幅降低超時率。
Cellar 熱點(diǎn) Key
上圖是 Cellar 熱點(diǎn) Key 解決方案的架構(gòu)圖。我們可以看到中心節(jié)點(diǎn)加了一個職責(zé),多了熱點(diǎn)區(qū)域管理,它現(xiàn)在不只負(fù)責(zé)正常的數(shù)據(jù)副本分布,還要管理熱點(diǎn)數(shù)據(jù)的分布,圖示這個集群在節(jié)點(diǎn) C、D 放了熱點(diǎn)區(qū)域。我們通過讀寫流程看一下這個方案是怎么運(yùn)轉(zhuǎn)的。如果客戶端有一個寫操作到了 A 節(jié)點(diǎn),A 節(jié)點(diǎn)處理完成后,會根據(jù)實(shí)時的熱點(diǎn)統(tǒng)計結(jié)果判斷寫入的 Key 是否為熱點(diǎn)。
如果這個 Key 是一個熱點(diǎn),那么它會在做集群內(nèi)復(fù)制的同時,還會把這個數(shù)據(jù)復(fù)制有熱點(diǎn)區(qū)域的節(jié)點(diǎn),也就是圖中的 C、D 節(jié)點(diǎn)。同時,存儲節(jié)點(diǎn)在返回結(jié)果給客戶端時,會告訴客戶端,這個 Key 是熱點(diǎn),這時客戶端內(nèi)會緩存這個熱點(diǎn) Key。當(dāng)客戶端有這個 Key 的讀請求時,它就會直接去熱點(diǎn)區(qū)域做數(shù)據(jù)的讀取。通過這樣的方式,我們可以做到只對熱點(diǎn)數(shù)據(jù)做擴(kuò)容,不像 Squirrel ,要把整個 Slot 遷出來做擴(kuò)容。有必要的話,中心節(jié)點(diǎn)也可以把熱點(diǎn)區(qū)域放到集群的所有節(jié)點(diǎn)上,所有的熱點(diǎn)讀請求就能均衡的分到所有節(jié)點(diǎn)上。另外,通過這種實(shí)時的熱點(diǎn)數(shù)據(jù)復(fù)制,我們很好地解決了類似客戶端緩存熱點(diǎn) KV 方案造成的一致性問題。
發(fā)展規(guī)劃和業(yè)界趨勢
最后,一起來看看我們項(xiàng)目的規(guī)劃和業(yè)界的技術(shù)趨勢。這部分內(nèi)容會按照服務(wù)、系統(tǒng)、硬件三層來進(jìn)行闡述。首先在服務(wù)層,主要有三點(diǎn):
Redis Gossip 協(xié)議優(yōu)化。大家都知道 Gossip 協(xié)議在集群的規(guī)模變大之后,消息量會劇增,它的 Failover 時間也會變得越來越長。所以當(dāng)集群規(guī)模達(dá)到 TB 級后,集群的可用性會受到很大的影響,所以我們后面會重點(diǎn)在這方面做一些優(yōu)化。
我們已經(jīng)在 Cellar 存儲節(jié)點(diǎn)的數(shù)據(jù)副本間做了 Raft 復(fù)制,可以保證數(shù)據(jù)強(qiáng)一致,后面我們會在 Cellar 的中心點(diǎn)內(nèi)部也做一個 Raft 復(fù)制,這樣就不用依賴于 ZooKeeper 做分布式仲裁、元數(shù)據(jù)存儲了,我們的架構(gòu)也會變得更加簡單、可靠。
Squirrel 和 Cellar 雖然都是 KV 存儲,但是因?yàn)樗鼈兪腔诓煌拈_源項(xiàng)目研發(fā)的,所以 API 和訪問協(xié)議不同,我們之后會考慮將 Squirrel 和 Cellar 在 SDK 層做整合,雖然后端會有不同的存儲集群,但業(yè)務(wù)側(cè)可以用一套 SDK 進(jìn)行訪問。
在系統(tǒng)層面,我們正在調(diào)研并去落地一些 Kernel Bypass 技術(shù),像 DPDK、SPDK 這種網(wǎng)絡(luò)和硬盤的用戶態(tài) IO 技術(shù)。它可以繞過內(nèi)核,通過輪詢機(jī)制訪問這些設(shè)備,可以極大提升系統(tǒng)的 IO 能力。存儲作為 IO 密集型服務(wù),性能會獲得大幅的提升。
在硬件層面,像支持 RDMA 的智能網(wǎng)卡能大幅降低網(wǎng)絡(luò)延遲和提升吞吐;還有像 3D XPoint 這樣的閃存技術(shù),比如英特爾新發(fā)布的 AEP 存儲,其訪問延遲已經(jīng)比較接近內(nèi)存了,以后閃存跟內(nèi)存之間的界限也會變得越來越模糊;最后,看一下計算型硬件,比如通過在閃存上加 FPGA 卡,把原本應(yīng)該 CPU 做的工作,像數(shù)據(jù)壓縮、解壓等,下沉到卡上執(zhí)行,這種硬件能在解放 CPU 的同時,也可以降低服務(wù)的響應(yīng)延遲。
作者簡介
澤斌,美團(tuán)點(diǎn)評高級技術(shù)專家,2014 年加入美團(tuán)。
編輯:hfy
評論