1. 程式人生 > >一種基於“哨兵”的分散式快取設計

一種基於“哨兵”的分散式快取設計

14年雙11大促快取方案,今天有點閒暇時間,回顧一下當時的思路。

場景介紹:

大促活動下,對於某些產品進行整點秒殺活動。預計流量是平時峰值5+倍

商品計算邏輯比較複雜:某個最終展示的商品屬性和價格,可能需要上億次動態條件計算獲得,動態條件每時每刻都在變化,並且商品的庫存屬性屬於行業共有庫存,每時每刻都在變化。

計算模型:前端機併發去後端獲取實時計算資料,然後合併結果,根據使用者資訊給商品打屬性,排序。

倆個方向:擴容快取

擴容

擴容是最容易想到的方式,而且每年大促,根據壓測和運營活動預期,都可能有相應擴容。擴容從某種程度上說,也是最簡單的方式,如果應用規劃足夠好,沒有狀態,那麼基本不用開發介入就能完成。

但是如果應用涉及狀態資訊,那麼擴容就沒有說的那麼輕巧,擴容涉及到增加叢集狀態;活動結束後,機器下線涉及叢集減少狀態,這一增一減,增加了運維的成本和系統穩定性。

擴容還有一個不好的地方就是活動結束後,系統水位下降,閒下來4倍的機器,比較浪費。酷站網軟

快取

相對擴容,快取是一種從應用角度出發,優化系統的方案。快取的方案可細分不同粒度,分別適用不同場景。

靜態化

靜態化能最大限度降低最大限度降低後端壓力,一般靜態內容可以定時或者通過資料更像觸發生成,然後推送到CDN節點。靜態化適用於1)讀多寫少的資料,或者2)能夠容忍資料變化延遲的場景。對於本文介紹的場景,並不適合,原因在於商品不滿足前面說的兩點,並且每個登陸使用者看到的產品價格和屬性是不同的。

快取中間結果

靜態化這種”一刀切”模式,不能滿足針對每個使用者的個性化展示需求,如果把每個使用者看到的資料都靜態化,那快取的命中率有會很低,基本每個使用者請求一兩次就不會再來了。而且快取資料量巨大。

由此想到把快取粒度縮小,把快取從展示層後推到前端機上。因為前端機負責彙總後端結算結果,並根據使用者資訊給商品打屬性,排序。

快取方案嘗試

經過頭腦風暴,最終確定採用快取中間結果方式。接下來討論一下方案細節。

簡單粗暴方式

如果快取有資料,取快取資料,如果沒有,請求後端並把結果更新到快取。這是一種最簡單的快取模式,但不幸的是不適合秒殺場景,因為秒殺開始的時候,快取很能沒有資料,請求會穿透到後端。

實時快取,非同步更新方式

實時請求資料來自快取,快取資料定時非同步更新。粗看起來,這個方案不存在快取穿透的情況,因為資料不會實時從後端計算獲取,而是從快取獲取,如果快取資料存在,直接獲取即可。快取更新可以把使用者請求彙總後去重,定時更新。

上面討論的兩種方式都一個共性問題:第一批請求問題:如果第一批請求快取沒有資料怎麼辦?

簡單粗暴的方式會讓這樣的請求穿透快取,後端去處理並更新快取。這樣會給後端計算帶來壓力,秒殺開始那一剎那,很可能支撐不住。

實時快取的犧牲了這樣的請求,因為這些請求根本看不到資料,所以請求失敗。這兩種方式在本文的應用場景都不合適。

為何不提前初始化快取?

的確,上面兩個方案如果能在第一批請求到達前初始化好快取,那基本上可以滿足本文的應用場景的。而且看起來也很容易做到,提前模擬一次請求或者提前往快取放一份資料不就可以了嗎?

不幸的是,本文場景因為涉及資料範圍巨大,不能在較短時間內遍歷快取key,初始化好快取,即使採取併發方式。而且,初始化快取請求過多,也將給後端機器造成壓力。

快取失效又該怎麼辦?

根據需求,兩種方案的快取不會永久有效,如果快取失敗了怎麼辦?

對於簡單粗暴方式,如果快取失效,又會遇到第一批請求問題,一批請求發現快取失效,怎麼辦?看來即使解決了快取初始化問題,還有可能導致快取穿透。

實時快取模式也有類似問題,如果非同步更新前資料已經失效了,那麼將犧牲一批資料失效後到更新前這批使用者。因為沒有人去更新資料。

快取更新問題

不管哪種方式,分散式快取更新都存在併發問題,尤其在整點秒殺場景更為突出。對於簡單粗暴方式,可以採用分散式鎖解決:如果快取穿透的一批請求只有一個會真正打到後端是不是就可以解決了?

實時快取也有同樣的問題,只不過非同步請求可以把一段時間內的重複請求合併成一個,從側面避免了併發問題。

更好的快取方式

把上面的討論結合,可以得到一種更優雅的快取方案,既不犧牲第一批請求,也不存在快取穿透問題,同時避免併發更新問題。

哨兵

想象有這樣一個哨兵執行緒,只有它能去後端請求實時資料,並更新快取。

第一批請求場景:

image

第一批請求中,選取最早的那個請求為哨兵,這個執行緒不會去讀快取,直接去後端獲取計算結果並更新快取。其他普通執行緒則自旋+sleep等待,直到哨兵更新快取後,能拿到資料為止。

快取失效場景:

image

哨兵的作用是讓快取永不失效。哨兵執行緒提前甦醒,去後端獲取計算資料並更新快取。這樣,其他普通執行緒根本不會感知到快取已經失效,他們能一直拿到最新的快取。

例如,某個key的快取失效時間的12:00:00,那麼哨兵可能在11:59:55的時候甦醒,請求後端並於11:59:57的時候完成快取資料更新,後續請求執行緒感知不到資料的更新,一直能取到非過期的資料。

實現細節

哨兵:其實哨兵也是一個普通請求。可以用原子計數器(redis或tair)實現,一個數據有兩個key:原子計數器key和資料快取key,二者快取時間一致,但是計數器key失效時間比資料key的要早(至少提前一個後端請求RT時間,這樣能保證哨兵更新快取後,不被其他執行緒感知到)。當請求執行緒發現快取沒有資料的時候,每個執行緒去更新計數器,更新後,得到計數器為1的執行緒,被設定成哨兵執行緒,其他執行緒則等待哨兵。

普通請求沒有獲取到資料的時候,自旋+sleep應該有個超時時間,防止意外情況。如果超時了,根據業務場景選擇請求後端資料還是處理失敗。