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