從Memcache轉(zhuǎn)戰(zhàn)Redis,聊聊緩存使用填過的“坑”
原創(chuàng)【51CTO.com原創(chuàng)稿件】在高并發(fā)場景下,很多人都把 Cache(高速緩沖存儲器)當(dāng)做可以“續(xù)命”的靈丹妙藥,哪里高并發(fā)壓力大,哪里就上傳 Cache 來解決并發(fā)問題。
但有時候,即使使用了 Cache,卻發(fā)現(xiàn)系統(tǒng)依然卡頓宕機,是因為 Cache 技術(shù)不好嗎?非也,其實這是緩存的治理工作沒有做好。
2018 年 5 月 18-19 日,由 51CTO 主辦的全球軟件與運維技術(shù)峰會在北京召開。
在 19 日下午“高并發(fā)與實時處理”分會場,同程藝龍機票事業(yè)群 CTO 王曉波帶來了《高并發(fā)場景的緩存治理》的主題演講。
他針對如何讓緩存更適合高并發(fā)使用、如何正確使用緩存、如何通過治理化解緩存問題等熱點展開了闡述。
對于我們來說,我們是 OTA 的角色,所以有大量的數(shù)據(jù)要計算處理變?yōu)榭墒圪u商品,總的來說是“商品搬運工,而非生產(chǎn)商”。
所以面對各種大數(shù)據(jù)并發(fā)運行的應(yīng)用場景,我們需要通過各種緩存技術(shù)來提升服務(wù)的質(zhì)量。
想必大家都聽說過服務(wù)的治理和數(shù)據(jù)的治理,那么是否聽說過緩存的治理呢?誠然,在許多場景下,Cache 成了應(yīng)對各處出現(xiàn)高并發(fā)問題的一顆“銀彈”。
但是它并非是放之四海皆準(zhǔn)的,有時它反而成了一顆導(dǎo)致系統(tǒng)“掛掉”的自殺子彈。有時候這種原因的出現(xiàn)并非 Cache 本身的技術(shù)不好,而是我們沒有做好治理。
下面,我們將從三個方面來具體討論緩存的治理:
- 緩存使用中的一些痛點
- 如何用好緩存,用正確緩存
- 如何通過治理讓緩存的問題化為無形
緩存使用中的一些痛點
我們同程的業(yè)務(wù)特點是:OTA 類商品,沒有任何一個價格是固定的。像酒店,客戶今天訂、明天訂、連續(xù)訂三天、訂兩天,是否跨周末,他們最后得出的價格都是不一樣的。
價格隨著時間的變化而波動的。這些波動會引發(fā)大量的計算,進而帶來性能上的損耗。
要解決性能的損耗問題,我們勢必要插入各種 Cache,包括:價格的 Cache、時間段的 Cache、庫存的 Cache。而且這些 Cache 的寫入數(shù)據(jù)量遠(yuǎn)大于整個外部的請求數(shù)據(jù)量,即:寫多于讀。
下面介紹同程緩存使用的歷史:
- 一開始,我們僅使用一臺 Memcache 來提供緩存服務(wù)。
- 后來,我們發(fā)現(xiàn) Memcache 存在著支持并發(fā)性不好、可運維性欠佳、原子性操作不夠、在誤操作時產(chǎn)生數(shù)據(jù)不一致等問題。
- 因此,我們轉(zhuǎn)為使用 Redis,以單線程保證原子性操作,而且它的數(shù)據(jù)類型也比較多。當(dāng)有一批新的業(yè)務(wù)邏輯被寫到 Redis 中時,我們就把它當(dāng)作一個累加計數(shù)器。
當(dāng)然,更有甚者把它當(dāng)作數(shù)據(jù)庫。由于數(shù)據(jù)庫比較慢,他們就讓數(shù)據(jù)線先寫到 Redis,再落盤到數(shù)據(jù)庫中。
- 隨后,我們發(fā)現(xiàn)在單機 Redis 的情況下,Cache 成了系統(tǒng)的“命門”。哪怕上層的計算尚屬良好、哪怕流量并不大,我們的服務(wù)也會“掛掉”。于是我們引入了集群 Redis。
- 同時,我們用 Java 語言自研了 Redis 的客戶端。我們也在客戶端里實現(xiàn)了二級 Cache。不過,我們發(fā)現(xiàn)還是會偶爾出現(xiàn)錯亂的問題。
- 后來,我們還嘗試了分布式 Cache,以及將 Redis 部署到 Docker 里面。
最終,我們發(fā)現(xiàn)這些問題都是跟場景相關(guān)。如果你所構(gòu)建的場景較為紊亂,則直接會導(dǎo)致底層無法提供服務(wù)。
下面我們來看看有哪些需要治理的場景,通俗地說就是有哪些“坑”需要“填”。
早期在單機部署 Redis 服務(wù)的時代,我們針對業(yè)務(wù)系統(tǒng)部署了一套使用腳本運維的平臺。
當(dāng)時在一臺虛機上能跑六萬左右的并發(fā)數(shù)據(jù),這些對于 Redis 服務(wù)器來說基本夠用了。
但是當(dāng)大量部署,并達到了數(shù)百多臺時,我們碰到了兩個問題:
- 面對高并發(fā)的性能需求,我們無法單靠腳本進行運維。一旦運維操作出現(xiàn)失誤或失控,就可能導(dǎo)致 Redis 的主從切換失敗,甚至引起服務(wù)宕機,從而直接對整個業(yè)務(wù)端產(chǎn)生影響。
- 應(yīng)用調(diào)用的凌亂。在采用微服務(wù)化之前,我們面對的往往是一個擁有各種模塊的大系統(tǒng)。
而在使用場景中,我們常把 Redis 看成數(shù)據(jù)庫、存有各種工程的數(shù)據(jù)源。同時我們將 Cache 視為一個黑盒子,將各種應(yīng)用數(shù)據(jù)都放入其中。
例如,對于一個訂單交易系統(tǒng),你可能會把訂單積分、訂單說明、訂單數(shù)量等信息放入其中,這樣就導(dǎo)致了大量的業(yè)務(wù)模塊被耦合于此,同時所有的業(yè)務(wù)邏輯數(shù)據(jù)塊也集中在了 Redis 處。
那么就算我們?nèi)ゲ鸱治⒎?wù)、做代碼解耦,可是多數(shù)情況下緩存池中的大數(shù)據(jù)并沒有得到解耦,多個服務(wù)端仍然通過 Redis 去共享和調(diào)用數(shù)據(jù)。
一旦出現(xiàn)宕機,就算你能對服務(wù)進行降級,也無法對數(shù)據(jù)本身采取降級,從而還是會導(dǎo)致整體業(yè)務(wù)的“掛掉”。
脆弱的數(shù)據(jù)消失了。由于大家都習(xí)慣把 Redit 當(dāng)作數(shù)據(jù)庫使用(雖然大家都知道在工程中不應(yīng)該如此),畢竟它不是數(shù)據(jù)庫,沒有持久性,所以一旦數(shù)據(jù)丟失就會出現(xiàn)大的麻煩。
為了防止單臺掛掉,我們可以采用多臺 Redis。此時運維和應(yīng)用分別有兩種方案:
- 運維認(rèn)為:可以做“主從”,并提供一個浮動的虛擬 IP(VIP)地址。在一個節(jié)點出現(xiàn)問題時,VIP 地址不用變更,直接連到下一個節(jié)點便可。
- 應(yīng)用認(rèn)為:可以在應(yīng)用客戶端里寫入兩個地址,并采取“哨兵”監(jiān)控,來實現(xiàn)自動切換。
這兩個方案看似沒有問題,但是架不住 Redis 的濫用。我們曾經(jīng)碰到過一個現(xiàn)實的案例:如上圖右下角所示,兩個 Redis 根據(jù)主從關(guān)系可以互相切換。
按照需求,存有 20G 數(shù)據(jù)的主 Redis 開始對從 Redis 進行同步。此時網(wǎng)絡(luò)出現(xiàn)卡頓,而應(yīng)用正好發(fā)現(xiàn)自己的請求也相應(yīng)變慢了,因此上層應(yīng)用根據(jù)網(wǎng)絡(luò)故障采取主從切換。
然而此時由于主從 Redis 正好處于同步狀態(tài),資源消耗殆盡,那么在上次應(yīng)用看來此時主從 Redis 都是不可達的。
我們經(jīng)過深入排查,最終發(fā)現(xiàn)是在 Cache 中某個表的一個 Key 中,被存放了 20G 的數(shù)據(jù)。
而在程序?qū)用嫔?,他們并沒有控制好該 Key 的消失時間(如一周),因而造成了該 Key 被持續(xù)追加增大的狀況。
由上可見,就算我們對 Redis 進行了拆分,這個巨大的 Key 仍會存在于某一個“片”上。
如上圖所示,仍以 Redis 為例,我們能夠監(jiān)控的方面包括:
- 當(dāng)前客戶端的連接數(shù)
- 客戶端的輸出與輸入情況
- 是否出現(xiàn)堵塞
- 被分配的整個內(nèi)存總量
- 主從復(fù)制時的狀態(tài)信息
- 集群的情況
- 各服務(wù)器每秒執(zhí)行的命令數(shù)量
可以說,這些監(jiān)控的方面并不能及時地發(fā)現(xiàn)上述 20 個 G 的 Key 數(shù)據(jù)。再比如:通常系統(tǒng)是在客戶下訂單之后,才增加會員積分。
但是在應(yīng)用設(shè)計上卻將核心訂單里的核心 Key,與本該滯后增加的積分輔助進程,放在了同一個實例之中。
由于我們能夠監(jiān)控到的都是些延遲信息,因此這種將級別高的數(shù)據(jù)與級別低的數(shù)據(jù)混淆的情況,是無法被監(jiān)控到的。
上面是一段運維與開發(fā)的真實對話,曾發(fā)生在我們公司內(nèi)部的 IM 上,它反映了在 DevOps 推進之前,運維與開發(fā)之間的矛盾。
開發(fā)問:Redis 為什么不能訪問?
運維答:剛才服務(wù)器因內(nèi)存故障自動重啟了。其背后的原因是:一個 Cache 的故障導(dǎo)致了某個業(yè)務(wù)的故障。業(yè)務(wù)認(rèn)為自己的代碼沒有問題,原因在于運維的 Cache 上。
開發(fā)問:為什么我的 Cache 的延遲這么大?
運維答:發(fā)現(xiàn)開發(fā)在此處放了幾萬條數(shù)據(jù),從而影響了插入排序。
開發(fā)問:我寫進去的 Key 找不到?肯定是 Cache 出錯了。這其實是運維 Cache 與使用 Cache 之間的最大矛盾。
運維答:你的 Redis 超過最大限制了,根本就沒寫成功,或者寫進去就直接被淘汰了。這就是大家都把它當(dāng)成黑盒所帶來的問題。
開發(fā)問:剛剛為何讀取全部失???
運維答:網(wǎng)絡(luò)臨時中斷,在全同步完成之前,從機的讀取全部失敗了。這是一個非常經(jīng)典的問題,當(dāng)時運維為了簡化起見,將主從代替了集群模式。
開發(fā)問:我的系統(tǒng)需要 800G 的 Redis,何時能準(zhǔn)備好?
運維答:我們線上的服務(wù)器最大只有 256 G。
開發(fā)問:為什么 Redis 慢得像驢一樣,是否服務(wù)器出了故障?
運維答:對千萬級的 Key,使用 Keys*,肯定會慢。
由上可見這些問題既有來自運維的,也有來自開發(fā)的,同時還有當(dāng)前技術(shù)所限制的。
我們在應(yīng)對并發(fā)查詢時,只注重它給我們帶來的“快”這一性能特點,卻忽略了對 Cache 的使用規(guī)范,以及在設(shè)計時需要考慮到的各種本身缺點。
如何用好緩存,用正確緩存?
因此在某次重大故障發(fā)生之后,我們總結(jié)出:沒想到初始狀態(tài)下只有 30000 行代碼的小小 Redis 竟然能帶來如此神奇的功能。
以至于它在程序員手中變成了一把“見到釘子就想錘的錘子”,即:他們看見任何的需求都想用緩存去解決。
于是他們相繼開發(fā)出來了基于緩存的日志搜集器、倒計時、計數(shù)器、訂單系統(tǒng)等,卻忘記了它本身只是一個 Cache。一旦出現(xiàn)了故障,它們將如何去保證其本身呢?
下面我們來看看緩存故障的具體因素有哪些?
過度依賴
即:明明不需要設(shè)置緩存之處,卻非要用緩存。程序員們常認(rèn)為某處可能會在將來出現(xiàn)大的并發(fā)量,故放置了緩存,卻忘記了對數(shù)據(jù)進行隔離,以及使用的方式是否正確。
例如:在某些代碼中,一個函數(shù)會執(zhí)行一到兩百次 Cache 的讀取,通過反復(fù)的 get 操作,對同一個 Key 進行連續(xù)的讀取。
試想,一次并發(fā)會帶給 Redis 多少次操作呢?這些對于 Redis 來說負(fù)載是相當(dāng)巨大的。
數(shù)據(jù)落盤
這是一個高頻次出現(xiàn)的問題。由于大家確實需要一個高速的 KV 存儲,來實現(xiàn)數(shù)據(jù)落盤需求。
因此他們都會把整個 Cache 當(dāng)作數(shù)據(jù)庫去使用,將任何不允許丟失的數(shù)據(jù)都放在 Cache 之中。
即使公司有各種使用規(guī)范,此現(xiàn)象仍是無法杜絕。最終我們在 Cache 平臺上真正做了一個 KV 數(shù)據(jù)庫供程序員們使用,并且要求他們在使用的時候,必須聲明用的是 KV 數(shù)據(jù)庫還是 Cache。
超大容量
由于大家都知道“放到內(nèi)存里是最快的”,因此他們對于內(nèi)存的需求是無窮盡的。更有甚者,有人曾向我提出 10 個 T 容量的需求,而根本不去考慮營收上的成本。
雪崩效應(yīng)
由于我們使用的是大量依賴于緩存的數(shù)據(jù),來為并發(fā)提供支撐,一旦緩存出現(xiàn)問題,就會產(chǎn)生雪崩效應(yīng)。
即:外面的流量還在,你卻不得不重啟整個緩存服務(wù)器,進而會造成 Cache 被清空的情況。
由于斷絕了數(shù)據(jù)的來源,這將導(dǎo)致后端的服務(wù)連片“掛掉”。為了防止雪崩的出現(xiàn),我們會多寫一份數(shù)據(jù)到特定磁盤上。
其數(shù)據(jù)“新鮮度”可能不夠,但是當(dāng)雪崩發(fā)生時,它會被加載到內(nèi)存中,以防止雪崩的下一波沖擊,從而能夠順利地過渡到我們重新將“新鮮”的數(shù)據(jù)灌進來為止。
我們對上面提到的“坑”總結(jié)一下:
- 最厲害的是:使用者亂用、濫用和懶用。如前例所說,我們平時對于緩存到底在哪里用、怎么用、防止什么等方面考慮得實在太少。
- 運維數(shù)千臺毫無使用規(guī)則的緩存服務(wù)器。我們常說 DevOps 的做法是讓應(yīng)用與運維靠得更近,但是針對緩存進行運維時,由于應(yīng)用開發(fā)都不關(guān)心里面的數(shù)據(jù),又何談相互靠近呢?
- 運維不懂開發(fā),開發(fā)不懂運維。這導(dǎo)致了緩存系統(tǒng)上各自為政,無法真正地應(yīng)用好 Cache。
- 緩存在無設(shè)計、無控制的情況下被使用。一般情況下 JVM 都能監(jiān)控到內(nèi)存的爆漲,并考慮是否需要回收。但是如前例所示,在出現(xiàn)了一個 Key 居然有 20G 大小時、我們卻往往忽視了一個 Key 在緩存服務(wù)器上的爆漲。
- 開發(fā)人員能力的不同。由于不可能要求所有的開發(fā)人員都是前端工程師,那么當(dāng)你這個團隊里面有不同經(jīng)驗的人員時,如何讓他們能寫出同樣規(guī)范的代碼呢?
- 畢竟我們做的是工程,需要更多的人能夠保證寫出來的代碼不會發(fā)生上述的問題。
- 太多的服務(wù)器資源被浪費。特別是 Cache 的整體浪費是非常巨大的。無論并發(fā)量高或低,是否真正需要,大家都在使用它的內(nèi)存。
- 例如:在我們的幾千臺 Cache Server 中,最高浪費量可達 60%。一些只有幾百或幾千 KPS 要求的系統(tǒng)或數(shù)據(jù)也被設(shè)計運行在了 Cache 昂貴的內(nèi)存中。
- 而實際上它們可能僅僅是為了應(yīng)對一月一次、或一年一次促銷活動的 Cache 高峰需求。
- 懶人心理,應(yīng)對變化不夠快。應(yīng)對高并發(fā)量,十個程序員有五個會說:為數(shù)據(jù)層添加 Cache,而不會真正去為架構(gòu)做長遠(yuǎn)的規(guī)劃。
如何通過治理讓緩存的問題化為無形
那么到底緩存應(yīng)當(dāng)如何被治理呢?從真正的開發(fā)哲學(xué)角度上說,我們想要的是一個百變的魔術(shù)箱,它能夠快速地自我變化與處理,而不需要開發(fā)和運維人員擔(dān)心濫用的問題。
另外,其他需要應(yīng)對的方面還包括:應(yīng)用對緩存大小的需求就像貪吃蛇一般,一堆孤島般的單機服務(wù)器,緩存服務(wù)運維則像一個迷宮。
因此,我們希望構(gòu)建的是一種能適用各種應(yīng)用場景的緩存服務(wù),而不是冷冰冰的 Cache Server。
起初我們嘗試了各種現(xiàn)成的開源方案,但是后來發(fā)現(xiàn)它們或多或少存在著一些問題。
例如:
- Cachecloud,對于部署和運維方面欠佳。
- Codis,本身做了一個很大的集群,但是我們考慮到當(dāng)這么一個超大池出現(xiàn)問題時,整個團隊在應(yīng)對上會失去靈活性。
- 例如:我們會擔(dān)心業(yè)務(wù)數(shù)據(jù)塊可能未做隔離,就被放到了池中,那么當(dāng)一個實例“掛掉”時,所有的數(shù)據(jù)塊都會受到影響。
- Pika,雖然可以使用硬盤,但是部署方式很少。
- Twemproxy,只是代理見長,其他的能力欠佳。
后來,我們選擇自己動手,做了一個 phoenix 的方案。整個系統(tǒng)包含了客戶端、運維平臺、以及存儲擴容等方面。
在最初期的架構(gòu)設(shè)計上,我們只讓應(yīng)用端通過簡單的 SDK 去使用該系統(tǒng)。
為了避免服務(wù)端延續(xù)查找 Cache Server 的模式,我們要求應(yīng)用事先聲明其項目和數(shù)據(jù)場景,然后給系統(tǒng)分配一個 Key。SDK 籍此為應(yīng)用分配一個新的或既有的緩存?zhèn)}庫。
如上圖所示,為了加快速度,我們將緩存區(qū)分出多個虛擬的邏輯池,它們對于上層調(diào)度系統(tǒng)來說就是一個個的場景。
那么應(yīng)用就可以籍此申請包含需要存放何種數(shù)據(jù)的場景,最后根據(jù)所分配到的 Key 進行調(diào)用。
此處,底層是各種數(shù)據(jù)的復(fù)制和遷移,而兩邊則是相應(yīng)的監(jiān)控和運維。
但是在系統(tǒng)真正“跑起來”的時候,我們發(fā)現(xiàn)很難對其進行部署和擴容,因此在改造時,我們做重了整個緩存客戶端 SDK,并引入了場景的配置。
我們通過進行本地緩存的管理,添加過濾條件,以保證客戶端讀取緩存時,能夠知道具體的數(shù)據(jù)源和基本的協(xié)議,從而判斷出要訪問的是 Redis、還是 MemCache、或是其他類型的存儲。
在 Cache 客戶端做好之后,我們又碰到了新的問題:由于同程使用了包括 Java、.Net、Go,Node.js 等多種語言的開發(fā)模式,如果為每一種語言都準(zhǔn)備和維護一套 Cache 的客戶端的話,顯然非常耗費人力。
同時,對于維護來說:只要是程序就會有 Bug,只要有 Bug 就需要升級。一旦所有事業(yè)部的所有應(yīng)用都要升級 SDK,那么對于所有嵌套應(yīng)用的中間件來說,都要進行升級測試,這將會牽扯到巨大的回歸量。
可以說這樣的反復(fù)測試幾乎是不現(xiàn)實的。于是我們需要做出一個代理層,通過把協(xié)議、過濾、場景等內(nèi)容下沉到 Proxy 中,以實現(xiàn)SDK的整體輕量化。
與此同時,我們在部署時也引入了容器,將整個 Redis 都運行在容器之中,并讓容器去完成整個應(yīng)用的部署。
通過容器化的部署,集群的建立變得極其簡單,我們也大幅豐富了集群的方案。
我們實現(xiàn)了為每個應(yīng)用場景都能配有一個(或一種)Key,并且被一個(或一種)集群來服務(wù)。
眾所周知,Redis 雖然實現(xiàn)了遷移擴容,但是其操作較為復(fù)雜。因此我們自行研發(fā)了一套遷移調(diào)度系統(tǒng),自動化地實現(xiàn)了從流量擴容到數(shù)據(jù)擴容、以及從縱向到橫向的擴容。
如前所述,我們有著 Redis 和 Memcache 兩種客戶端,它們是使用不同的協(xié)議進行訪問。因此,我們通過統(tǒng)一的 Proxy 來實現(xiàn)良好的支持。
如今在我們的緩存平臺上,運維人員唯一需要做的就是:往該緩存平臺里添加一臺物理服務(wù)器、插上網(wǎng)線、然后系統(tǒng)就能夠自動發(fā)現(xiàn)新的服務(wù)器的加入,進而開啟 Redis。
而對于單場景下的 Redis 實例,我們也能夠通過控制臺,以獲取包括 Top10 的 Key、當(dāng)前訪問最多的 Key、Key 的屬主、最后由誰執(zhí)行了寫入或修改等多個監(jiān)控項。
可見,由于上下層都是自建的,因此我們擴展了原來 Redis 里沒有的監(jiān)控項。
上圖是 Topkey 使用情況的一個示例,就像程序在故障時經(jīng)常用到的 Dump 文件一樣,它能夠反映出后續(xù)的各種編排。
王曉波,同程藝龍機票事業(yè)群 CTO,專注于高并發(fā)互聯(lián)網(wǎng)架構(gòu)設(shè)計、分布式電子商務(wù)交易平臺設(shè)計、大數(shù)據(jù)分析平臺設(shè)計、高可用性系統(tǒng)設(shè)計。 設(shè)計過多個并發(fā)百萬以上平臺。 擁有十多年豐富的技術(shù)架構(gòu)、技術(shù)咨詢經(jīng)驗,深刻理解電商系統(tǒng)對技術(shù)選擇的重要性。
【51CTO原創(chuàng)稿件,合作站點轉(zhuǎn)載請注明原文作者和出處為51CTO.com】