高併發存儲優化篇:諸多策略,緩存爲王

本文內容概述

  1. 緩存是什麼
    1.1. 存儲宕機的致命代價
    1.2. 數據庫性能爲什麼會下降
    1.3. 緩存的類型

  2. 一線研發最頭疼的緩存問題
    2.1. 緩存穿透
    2.2. 緩存擊穿
    2.3. 緩存雪崩
    2.4. 數據漂移
    2.5. 緩存踩踏
    2.6. 緩存污染
    2.7. 熱點 key

  3. 頂級緩存架構一覽
    3.1. 微博緩存架構演進
    3.2. 知乎首頁已讀過濾緩存設計

  4. 總結

Part1 緩存是什麼

1.1 存儲宕機的致命代價 [1]

2015 年 5 月 28 號,攜程網站和 APP 全面癱瘓持續 12 小時,數據庫被物理刪除的消息在朋友圈風傳。

按上季度財報估算,此次宕機直接影響攜程營收大約 1200w 美元,攜程股價也大跌 11%。這還只是發生在互聯網剛剛普及的 2015 年。如果發生在現在。。。據公司公告是由於員工操作失誤導致。

雖然這不在我們想討論的性能原因導致異常的範圍內,但不妨礙我們得出結論,數據庫宕機對一個系統的影響是災難性的。

1.2 結構化數據庫性能爲什麼會下降

以 Mysql 爲例,我們知道,爲了調和 CPU 和磁盤的速度不匹配,MySQL 用 buffer pool 來加載磁盤數據到一段連續的內存中,供讀寫使用。一般情況下,如果緩衝池足夠大,能夠放下所有數據頁,那 mysql 操作基本不會產生讀 IO,而寫 IO 是異步的,不會影響讀寫操作。

Buffer pool 不夠大,數據頁不在裏面該怎麼辦?

去磁盤中讀取,將磁盤文件中的數據頁加載到 buffer pool 中,那麼就需要等待物理 IO 的同步讀操作完成,如果此時 IO 沒有及時響應,則會被堵塞。因爲讀寫操作需要數據頁在 buffer 中才能進行,所以必須等待操作系統完成 IO,否則該線程無法繼續後續的步驟。

熱點數據,當新的會話線程也需要去訪問相同的數據頁怎麼辦?

會等待上面的線程將這個數據頁讀入到緩存中 buffer pool。如果第一個請求該數據頁的線程因爲磁盤 IO 瓶頸,遲遲沒有將物理數據頁讀入 buffer pool, 這個時間區間拖得越長,則造成等待該數據塊的用戶線程就越多。對高併發的系統來說,將造成大量的等待。

高併發,大量請求的訪問行爲被阻塞,會造成什麼後果?

對於服務來說,大量超時會使服務器處於不可用的狀態。該臺機器會觸發熔斷。熔斷觸發後,該機器的流量會打到其他機器,其他機器發生類似的情況的可能性會提高,極端情況會引起所有服務宕機,曲線掉底。

上面是由於磁盤 IO 導致服務異常的分析邏輯,也是我們生產中最常遇到的一種數據庫性能異常的場景。除此之外,還有鎖競爭緩存命中率等異常場景也會導致服務異常。

如果單庫單表的極限存在,分庫分表等優化策略也只能緩解,不會根除

爲了避免上述情況,緩存的使用就非常有必要了。

1.3 緩存的類型

緩存的存在,是爲了調和差異。

差異有多種,比如處理器和存儲之間的速度差異、用戶對產品的使用體驗和服務處理效率的差異等等。

1.3.1 客戶端緩存

離用戶最近的 web 頁面緩存 & app 緩存。web 頁面因爲技術成熟所以問題不是太多,但 app 因爲設備的限制,在使用緩存時要多加註意。

之前經歷的某個業務,因爲客戶端緩存出現問題,發生兩次請求訂單號串單,導致業務異常。串單吶,猜是因爲緩存發生了混亂,至今比較奇怪會發生這種情況,需要對客戶端相關加深認識了。

1.3.2 單機緩存

CPU 緩存 [2]。爲了調和 CPU 和內存之間巨大的速度差異,設置了 L1/L2/L3 三級緩存,離 CPU 越近,速度越快。後面章節中介紹的知乎首頁已讀過濾的緩存架構,其靈感就是來源於此。

L1 緩存行示例

Ehcache[3]。是最流行了 Java 緩存框架之一。因爲其開源屬性,在 spring/Hibernate 等框架上被廣泛使用。支持磁盤持久化和堆外內存。緩存功能齊全。

Ehcache 架構圖

值得一說的是 ehcache 具備堆外緩存的能力,因爲堆外緩存不受 JVM 限制,所以不會引發更多的 GC 停頓,對某些場景下的 GC 停頓調優有不小的意義。但是需要注意的是堆外內存需要用 byte 來操作,要實現序列化和反序列化,並且在速度上,也要比堆內存要慢不少,所以,如果不是 GC 停頓有較大問題,且對業務影響較大,沒必要非用不可。

Guava cache。靈感來源於 ConcurrentHashMap,但具有更豐富的元素失效策略,功能沒有 ehcache 齊全,如只支持 jvm 內存,但比較輕量簡潔。之前曾用 guava cache 來緩存網關的一些配置信息,定時過期自動加載的功能還比較方便。

1.3.3 數據庫緩存

Query cache 即將查詢的結果緩存起來,開啓後生效。其可以降低查詢的執行時間,對需要消耗大量資源的查詢效果明顯。

Query cache 的合理性檢驗 [4]

1.3.4 分佈式緩存

memcached。[5]  memcached 是一個高效的分佈式內存 cache,搭建與操作使用都比較簡單,整個緩存都是基於內存的,因此響應時間很快,但是沒有持久化的能力。

memcached 存儲核心

Redis。 Redis 以優秀的性能和豐富的數據結構,以及穩定性和數據一致性的支持,被業內越來越普遍的使用。

Redis 核心對象示意

在使用 redis 的都有誰?

redis 官網羅列的 redis 用戶

看到了那個熟悉的公司 -- 微博。微博算是 redis 的重度用戶,相傳 redis 的新特性好多都是爲了微博定製的。有關微博的存儲架構在後面章節另做詳述。

本文後續的大部分內容也會基於 Redis 來敘述。

1.3.5 網絡緩存

一個簡單請求中的各緩存位置示意

CDN 服務器是建立在網絡上的內容分發網絡。佈置在各地的邊緣服務器,用戶可以經過中央渠道的負載平衡、內容分發、調度等功用模塊獲取附近所需的內容,減少網絡擁塞,提高響應速度和命中率。

Nginx 基於 Proxy Store 實現,使用 Nginx 的 http_proxy 模塊可以實現類似於 squid 的緩存功能。當啓用緩存時,Nginx 會將相應數據保存在磁盤緩存中,只要緩存數據尚未過期,就會使用緩存數據來響應客戶端的請求。

Part2 一線研發最頭疼的緩存問題

下面這些問題其實大家在很多地方都應該見過了,不過爲了內容的完整,還是羅列說明一下。

2.1 緩存穿透

查詢的是數據庫中不存在的數據,沒有命中緩存而數據庫查詢爲空,也不會更新緩存。導致每次都查庫,如果不加處理,遇到惡意攻擊,會導致數據庫承受巨大壓力,直至崩潰。

解決方案有兩種:一種是遇到查詢爲空的,就緩存一個空值到緩存,不至於每次都進數據庫。二是布隆過濾器,提前判斷是否是數據庫中存在的數據,若不在則攔截。

布隆過濾器利用多個 hash 函數標識數據是否存在,該方法讓較小的空間容納較多的數據,且衝突可控。其工作原則是,過濾器判斷不存在的數據則一定不存在。

我是動圖,請等一秒 --- 布隆過濾器原理原理

如上圖,左側爲添加元素時的 hash 槽變化,右邊爲判斷某數據是否存在時校驗的 hash 槽,可以看到,添加了 1、2 後 hash 槽位某些被佔用,判斷 2 、3 是否存在時,校驗對應 hash 槽即可。

2.2 緩存擊穿

從字面意思看,緩存起初時起作用的。發生的場景是某些熱點 key 的緩存失效導致大量熱點請求打到數據庫,導致數據庫壓力陡增,甚至宕機。

解決方案有兩種:

一種是熱點 key 不過期。有的同學在這裏提出了邏輯過期的方案,即物理上不設置過期時間,將期望的過期時間存在 value 中,在查詢到 value 時,通過異步線程進行緩存重建。

第二種是從執行邏輯上進行限制,比如,起一個單一線程的線程池讓熱點 key 排隊訪問底層存儲,以損失系統吞吐量的代價來維護系統穩定。

2.3 緩存雪崩

鑑於緩存的作用,一般在數據存入時,會設置一個失效時間,如果插入操作是和用戶操作同步進行,則該問題出現的可能性不大,因爲用戶的操作天然就是散列均勻的。

而另一些例如緩存預熱的情況,依賴離線任務,定時批量的進行數據更新或存儲,過期時間問題則要特別關注。

因爲離線任務會在短時間內將大批數據操作完成,如果過期時間設置的一樣,會在同一時間過期失效,後果則是上游請求會在同一時間將大量失效請求打到下游數據庫,從而造成底層存儲壓力。同樣的情況還發生在緩存宕機的時候。

解決方案

一是考慮熱點數據不過期獲取用上一節提到的邏輯過期。

二是讓過期時間離散化,如,在固定的過期時間上額外增加一個隨機數,這樣會讓緩存失效的時間分散在不同時間點,底層存儲不至於瞬間飆升。

三是用集羣主從的方式,保障緩存服務的高可用。防止全面崩潰。當然也要有相應的熔斷和限流機制來應對可能的緩存宕機。

2.4 數據漂移

數據漂移多發生在分佈式緩存使用一致性 hash 集羣模式下,當某一節點宕機,原本路由在此節點的數據,將被映射到下一個節點。

圖片來源:知乎用戶 Java 架構師

但是,當宕機的節點恢復之後,剛纔原本從新 hash 到下一個節點的數據,就全部失效, 因爲 hash 路由已經恢復到了此節點上,所以,下一個節點的數據變成冗餘數據,且,請求當前節點發現數據不存在,則會增加底層存儲調用。

這個問題,是我們使用一致性 hash 來保證緩存集羣機器宕機時不會造成緩存大量失效方案帶來的一些附加問題。因此需要保證一致性 hash 儘量的均勻 (一致性 hash 虛擬節點的運用),防止數據傾斜的節點的宕機和恢復對其他節點造成衝擊。

2.5 緩存踩踏 [6]

緩存踩踏其實只是一種緩存失效場景的提法,底層原因是緩存爲空或還未生效。關鍵是因爲上游調用超時後喚起重試,引發惡性循環。

比如,當某一名人新發布了圖片,而他們粉絲都會收到通知,大量的粉絲爭先搶後的想去看發佈了什麼,但是,因爲是新發布的圖片,服務端還沒有進行緩存,就會發生大量請求被打到底層存儲,超過服務處理能力導致超時後,粉絲又會不停的刷新,造成惡性循環。

解決方案:鎖 和 Promise。

發生這種踩踏的底層原因是對緩存這類公共資源拼搶,那麼,就把公共資源加鎖,消除併發拼搶。

但是,加鎖在解決公共資源拼搶的同時,引發了另一個問題,即沒有搶佔到鎖的線程會阻塞等待喚醒,當鎖被釋放時,所有線程被一同喚醒,大量線程的阻塞和喚醒是對服務器資源極大的消耗和浪費,即_驚羣效應_。

promise 的工作原理

promise 的原理其實是一種_代理模式_,實際的緩存值被 promise 代替,所有的線程獲取 promise 並等待 promise 返回給他們結果 , 而 promise 負責去底層存儲獲取數據,通過異步通知方式,最終將結果返回給各工作線程。

這樣,就不會發生大量併發請求同時操作底層存儲的情況。

2.6 緩存污染

緩存污染的主要表現是,正常的緩存數據總是被其他非主線操作影響,導致被替換失效,之前的一篇敘述消息隊列的文章《BAT 實際案例看消息中間件的妙用》 中對 kafka 的緩存污染及其解決方案做了詳述,有興趣的可以看下。

解決緩存污染的基本出發點,是要拆解不同消費速度的任務 (實時消費 / 定時消費)、或不同的數據生產來源 (主流程 / follower),分而治之的思路避免相互間緩存的影響。

2.7 熱點 key

熱點 key 的處理邏輯示意圖

熱點 key 的影響不再敘述,而解決熱點 key 的方法,主要在熱點 key 的發現和應對上:

可以通過監控 nginx 日誌對用戶請求進行時間窗計數、建立多級緩存、服務器本地利用 LRU 緩存熱點 key、根據業務預估熱點 key 提前預熱等等;

可以通過分散存儲來降低單個緩存節點應對熱點的壓力。

Part3 頂級緩存架構一覽

3.1 微博緩存架構演進

微博有 100T + 存儲,1000 + 臺物理機,10000+Redis 實例,那他的緩存方案是怎麼演變發展到可以抗 N 個明星同時離婚的呢?

緩存的架構演進 [7]

從上面的幾張緩存演進的架構圖中可以看到,微博的緩存架構其實大部分都是在應對熱點數據,比如,用 HA 層而不用一致性 hash,是因爲微博有典型的跟隨者踩踏效應,一致性 hash 在踩踏效應下某節點的宕機,會引發下游一系列節點的異常。在比如 L1 緩存的引入,則是因爲微博的流量在時間上存在一些衰減規律,越新的一段越熱,所以,用小的熱點分片來擋住發生的少但流量大的情況。

只是上面這些還不夠,一些系統化的問題不容忽視:

CacheService 緩存服務 [8]

爲了解決上述問題資源微博對緩存進行了服務化,提供一個分佈式的 CacheService 架構,簡化業務開發方的使用,實現系統的動態伸縮容、容災、多層 Cache 等相關功能。

可以看到,在 cache 池上層,被封裝了一層 proxy 邏輯,包括異步事件處理器用來管理數據連接、接收數據請求,processer 用來進行數據解析,Adapter 用來適配底層存儲協議,Router 用來路由請求到對應的資源分片,LRU_cache 用來優化性能、緩解 proxy 性能損耗,Timer 用來進行健康狀態探測。

某次機緣巧合和微博架構組的總監簡單聊了幾句瞭解到,現在的整個 cacheService 服務的易用性已經非常高,服務器節點的彈性伸縮依賴檢測體系全部自動進行,極大的減少了運維和維護成本,可能微博同學們曾經哪些加班喫瓜的歡樂日子已經一去不復返了。

Redis 在微博的極致運用 [9][10]

從 2010 年引入 redis,至今已有十多個年頭。有非常多的使用經驗和定製化需求,不然也不會被 redis 官網列在使用者名單前三的位置。

$ 單線程下 bgsave 重操作卡頓問題

bgsave 因爲是非常重的操作,發生時會出現明顯的卡頓,造成業務波動;在故障宕機後恢復時主從速度慢,經常出現帶寬洪峯

$ redis 完全替代 mysql 實現存儲落地

在 Redis 替代 MySQL 存儲落地的過程中,微博對 Redis 也進行很多定製化改造:

$ longset 定製化數據結構

針對千億級別的關係類存儲,爲了減少成本,放棄了原生的 Hash 結構(比較佔內存),內存降爲原來的 1/10。

$ 計數功能優化

爲了方便計數,將 redis 的 KV 改成了定長的 KV ,通過預先分配內存,知道了總數,會極大的降低計數的操作開銷。

10 年的深度依賴,微博在 redis 的使用上積累了大量的經驗和技巧,值得我們學習參考。

3.2 知乎首頁已讀過濾緩存設計 [11]

知乎社區擁有 2.2 億用戶、38 萬的話題量、2800 萬問題、1.3 億回答,而個性化的首頁,需要過濾已讀並長期存儲以展示豐富的內容,對系統的性能和穩定性有着極高的要求。

$ 早期方案

$ 優化方案

來源見參考文獻

大家有沒有發現這個架構思路很熟,是的,就是 CPU 的多級緩存架構。通過緩存攔截、副本擴展、壓縮降壓的方式,其實基本都是對前面章節敘述的緩存問題的整體應對,以達到低延遲且穩定的緩存服務效果。

Part4 總結

本篇文章,通過底層存儲的極限理論,論證了緩存存在的必要性;對緩存場景的一些典型問題做了分析了闡述,最後,用微博和知乎兩個頂級的緩存架構實例,對上面的內容進行了呼應。原創不易,如有感覺有所幫助,歡迎讀者朋友的幫助轉發分享,畢竟,匯仁牌腎寶,大家好纔是真的好~

參考資料

[1]

環球旅訊: https://www.traveldaily.cn/article/92559

[2]

cpu 緩存: https://manybutfinite.com/post/intel-cpu-caches/

[3]

ehcache 官網: https://www.ehcache.org/

[4]

深入分佈式緩存: 機械工業出版社

[5]

memcached 官網: https://memcached.org/

[6]

Facebook 史上最嚴重的宕機事件分析: https://www.infoq.cn/article/Bb2YC0yHVSz4qVwdgZmO

[7]

百億級日訪問量的應用如何做緩存架構設計: https://my.oschina.net/JKOPERA/blog/1921089

[8]

微博 CacheService 架構淺析: https://www.infoq.cn/article/weibo-cacheservice-architecture

[9]

萬億級日訪問量下 Redis 在微博的 9 年優化歷程: https://cloud.tencent.com/developer/news/462944

[10]

微博 Redis 定製化之路: https://developer.aliyun.com/article/62598

[11]

知乎首頁已讀數據萬億規模下的查詢系統架構設計: Qcon 大會分享

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RTPUT-5IxbBOaGXVWxXK0Q