大衛,攜程服務端開發經理,對應用架構設計、雲原生、代碼整潔之道有濃厚興趣。
爆款項目是2020年攜程的一個新項目,目標是將全品類、高性價比的旅行商品統一集合在一個頻道供用戶選購。出於這樣的業務定位,項目有三個特點:
1)如何應對高QPS(包括整體高QPS和個別商品的高QPS),高流量,保障C端用戶體驗?
2)在滿足第一點的情況下,如何保障信息的時效性,讓用戶儘可能看到最新的信息,避免下單時的信息和看到的信息不一致?
很顯然,要想較好的應對高QPS,高流量的前端請求,需要藉助緩存(我們使用了公司推薦的Redis,後文不再做特別說明)。但是怎麼使用好緩存解決上面兩個問題,這是需要考慮的。我們對比了本文討論的方案,和另外幾個傳統方案的優缺點,見下:
|
|
|
|
定期全量同步DB數據至Redis以及通過canal等中間件增量同步DB數據進入緩存
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
是否會週期性的遍歷DB中需要緩存的數據表從而給DB帶來額外壓力
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
綜上,本方案除了第一次實現有較高的複雜度外,帶來的其他優勢都是很可觀的。目前線上運行下來,數據訪問層應用相關的數據如下:
Redis集羣的請求量
:進行熱點key處理後,減少原本1/4的請求量
Redis集羣內存佔用
:按需進行緩存,內存佔用爲傳統方案的1/10
Redis集羣CPU使用情況
:整體平穩,未出現局部機器CPU異常
本文將從應用架構、緩存訪問組件、緩存更新平臺幾方面介紹本方案。
其中,淺綠色部分由業務實現,深綠色部分是本方案實現的。後文會詳細介紹緩存訪問組件和緩存更新平臺的設計思路。
該組件的存在主要是爲了封裝緩存的訪問。主要做了兩件事:
1)按需異步將緩存中需要增、刪、改的鍵值對通過消息傳遞給緩存更新平臺,讓其進行實際的緩存更新操作。
2)對熱點key進行本地緩存與更新,避免對某個key的大量請求直接打到緩存導致緩存雪崩。
這裏,可能大家會有一個疑問,爲什麼要將簡單的緩存操作由傳統方案中的同步操作變爲基於消息機制的異步操作呢?
這是由於我們的業務場景要求DB數據與緩存數據能夠快速最終一致而決定的。如果採取傳統的同步操作,那麼極端情況下,可能會出現下面這樣的多線程執行時序:
|
|
|
|
|
發現key沒有緩存數據,進而讀取DB,得到數據v1
|
|
|
|
|
|
|
|
|
|
發現key沒有緩存數據,進而讀取DB,得到數據v2
|
|
|
|
|
|
|
|
|
可以發現,這樣的時序執行完後,緩存中key對應的value是過期的v1而不是數據庫中最新的v2,這就會導致嚴重的用戶體驗問題,並且這個問題很難被發現。
那麼我們是不是可以採用Redis的SETNX命令來解決這個問題呢?其實也是不行的。比如,上面的時序變爲線程1先執行完,線程3再執行完,那麼實際上緩存中的數據依然會是過期的v1。因爲線程3在採用SETNX命令設置緩存時,發現key已經有對應的值了,所以線程3最終的SETNX命令不會執行成功,也就導致了該更新的緩存反而沒有更新。
不難看出,這類問題就是由於我們會有大量的並行操作同一個key導致的。所以,這裏引入消息機制來異步執行緩存操作就是爲了使同一個key的並行操作變爲串行操作。
異步操作帶來的問題
由於緩存操作由傳統方案中的同步操作變爲異步操作,那麼引入了兩個新問題:
2)業務希望數據更新成功後緩存務必更新成功,也就是說希望DB數據更新和緩存更新近乎在一個事務裏面,這該怎麼辦?
在這個組件中,我們通過引入一張存放於業務的DB的消息記錄表來解決上述兩個問題。它相當於是一個容災方案,只要消息進入這張表,緩存更新平臺就保證這條消息必然會被消費。
該組件還有一大功能就是對熱點key的處理。衆所周知,緩存熱點key在很多業務中都存在。例如若頁面中存在長列表/瀑布流,那麼第一屏的產品的訪問量肯定比第二屏的產品訪問量要高很多;又例如某些商品做活動,那麼這類商品肯定要比沒做活動的商品訪問量高很多。
而爆款業務在可預見的未來,肯定也會出現熱點key的問題。若熱點key的問題不及時解決,當對單一key的請求量足夠大時,可能導致緩存集羣中存儲該key的機器性能嚴重下降,從而導致緩存雪崩。所以在系統設計上,我們需要爲解決熱點key預留可擴展性。
通過上述流程可以看到,組件內部解決熱點key主要是要解決下面三個問題:
3.2.1 如何判斷是熱點key?
首先我們需要知道哪些key是熱點key才能解決熱點key的問題,識別熱點key採取下面兩個方案互補:
1)動態識別熱點key:主要針對部分key的訪問流量增長相對平穩沒那麼陡的場景,使應用有能力應對線上一些無法預知的突發情況。
2)預設熱點key:主要針對定點開始的活動(比如電商的秒殺),這類流量增長通常會非常陡且高峯很短暫。如果這種場景也採取方案1來主動識別通常就會導致滯後性,其實最終不會起到任何作用。所以我們就需要預設熱點key。
由於爆款業務處於起步階段,場景1的問題尚不緊急,所以目前方案1我們計劃在未來的迭代實現,這裏不做過多討論。
對於方案2,業務目前可以主動將可以判定爲熱點的key灌給緩存訪問組件。組件收到這類key後,當它在從緩存拿到這類key的內容後會主動將內容存入本地內存。後續所有的訪問,都會從本地內存讀取,從而大幅降低對遠端緩存服務器的訪問。
3.2.2 熱點key如何存儲?
在前文已經提到,針對熱點key,我們選擇將其內容存放於應用服務器的內存中,這樣做基於下面兩個原因:
1)應用服務器本身一般都是以集羣來部署,可以彈性縮擴容;
2)應用服務器的內存基本上可用空間都在50%以上;
這樣做帶來的好處是:應用服務器在基於流量變化進行橫向縮擴容時,熱點key的內存與併發量的支持也跟着一起調整了,避免了多餘的維護成本。
緩存訪問組件在進行本地緩存時,考慮到熱點key的訪問流量通常是增長快下降也快,而且極端情況下可能出現本地緩存內容和數據庫中的內容不一致,所以我們選擇在本地進行一個很短時間的緩存,便於其能夠應對突發的流量增長的同時也能在極端情況下快速與數據保持一致。
3.2.3 熱點key的內容如何更新?
前文有提到,我們希望儘可能快的將數據庫中最新的數據反映到緩存,熱點key的本地緩存也不例外。所以,我們需要建立一個廣播機制,讓本地緩存能夠知曉遠端緩存的內容變化了。
這裏,我們藉助了緩存更新平臺。由於所有的緩存更新都是發生在緩存更新平臺(見後文),所以其可以將發生變化的緩存key通過消息隊列廣播給所有緩存訪問組件,組件消費到這條消息後,若key是熱點key,則進行本地緩存的更新。極端情況下,可能會出現組件消費消息失敗從而未更新的問題。針對這種情況,前文有提到,我們採取了很短時間的本地緩存,所以即便出現這個問題,也只會在較短時間有問題,最大程度保障了用戶體驗。
由於緩存更新平臺彙總了所有的緩存更新操作,所以它能夠在緩存發生變更後,通過廣播消息及時通知業務方,業務方拿到該消息後可以判斷是否要做處理。目前這個功能主要用於解決熱點key的內容更新問題,這在前文熱點key處理的相關章節已做了詳細說明,後文不再贅述。
前文有講到,我們爲了規避並行操作同一個key導致緩存中存儲舊值而非最新值的問題,從而引入消息機制將緩存操作串行化。該緩存更新平臺就用於串行的從消息隊列消費緩存操作消息。
所以我們的核心需求是:單線程處理同一個key的緩存操作消息且不讓舊的緩存覆蓋新的緩存。
2)緩存操作的消息量級非常大(峯值情況下幾十萬條/分鐘),怎麼快速消費完?
3)怎麼知道緩存內容是新還是舊,是否該對該消息進行處理?
4.1 怎麼判斷多個消息是屬於同一個key的緩存消息?
針對這個問題,我們通過在消息中攜帶緩存的key來解決這個問題,這樣做帶來了幾個好處:
1)將業務和緩存更新平臺解耦,key的內容由業務全權決定;
2)通過首先計算key的hash值,然後對其取模,可以將相同的key分配到相同的線程處理(見後文);
3)可擴展性強,針對後續熱點key的分析和自動化加載熱點key也起到了關鍵作用(後續迭代計劃的功能);
由於核心需求是單線程的處理同一類key的消息,所以不同key的消息由不同的線程處理既能很好的解決性能問題,又不會產生邏輯問題。
同一類key通過計算其hash值,然後再對結果進行取模,可以保證它們進到同一個內存隊列和線程,從而規避並行操作同一個key的問題。通過這個架構,如果某個key的消息消費過慢,也不會影響其他key的消費進度,從而既保障了消費速度也滿足了需求。
實踐下來,目前我們僅用了兩臺機器就能做到每分鐘消費幾十萬條消息,且遠未遇到瓶頸。
4.3 怎麼知道緩存內容是新還是舊,是否該對該消息進行處理?
雖然我們做到了同一類key的單線程處理,並且,我們使用的公司的消息隊列能保障消息的有序性。但依然有個問題沒解決,那就是舊的緩存可能會覆蓋新的緩存,因爲我們沒法保障新的緩存消息一定在舊的緩存消息產生之後再產生。考慮下面這個場景:
從上面可以看到,由於線程3的key<->v2消息先產生,所以它會被先消費,此時緩存的數據會變爲v2, 然後緩存更新平臺再處理key<->v1這條消息,從而導致v1覆蓋緩存中的v2,出現舊值覆蓋新值的問題。
在這裏,我們引入了緩存版本的概念來解決這個問題,我們認爲每條緩存的數據都應該有一個版本號(業務提供,例如可以是修改數據的時間戳,只要滿足單調遞增即可)。基於此,緩存的增、刪、改操作全部基於這個版本號來進行判斷是否執行操作。具體的判斷邏輯,在後文介紹。
緩存的增、刪、改流程
刪除緩存流程
我們整個流程上是基於消息通知,這個消息生產的時機是隻要業務刪除了數據庫中的數據就可以向緩存更新平臺發送一條刪除緩存消息。
從流程上可以看到,針對該消息的處理,流程裏面並不是簡單的刪除一個key,而是將刪除的內容標記一下存入緩存。這樣做帶來了如下的好處:
如果我們簡單的刪除緩存中的內容而不是將被刪除的內容標記起來存入緩存,那麼當出現下面這個場景時,緩存中就會長期存在已經刪除的數據,從而導致數據使用方誤認爲該數據仍然有效。
首先假設現在某個key在緩存中不存在。線程先消費了刪除該key的消息且刪除的數據版本是v1,然後消費了存儲緩存key<->v1的消息,這個時候就會將key<->v1寫入緩存,但其實這個數據已經被刪除了。
但即便將刪除的內容放入緩存,考慮極端情況,仍然可能會有問題,考慮下面這個場景:
假設消費完消息1後,因爲某種原因(如平臺宕機或者消息隊列出問題等等),消息2過了很久(緩存key已經過期)才被消費到,這時在緩存中存入該消息也會導致被刪除的數據“復活”。所以針對這類情況,有兩種措施:
2)超過一定時間未處理的消息就不處理了(我們採取的方案)
關於刪除緩存消息中的版本,前文有提到,我們認爲每條緩存數據都是有版本的。所以即便業務要刪一條數據,那麼被刪的數據肯定也是有版本號的,而這個版本就是該條消息的版本。我們藉助這個版本,就知道緩存中的數據是否是更新的版本,是否可以被覆蓋並且被標記爲刪除了。
新增&修改緩存流程
新增緩存消息的處理流程和修改緩存消息的處理流程一致,見下:
流程上可以看到,當緩存更新平臺收到新增/修改緩存消息時,拿着消息中的key去查緩存,如果沒有,則直接存入緩存;如果緩存中存在,則拿着緩存中的數據版本與消息的數據版本進行對比,如果消息中的數據版本更高(即更新),那麼就可以安全覆蓋緩存中的數據;反之,則不應該覆蓋。通過這個流程,就可以很好的避免傳統緩存更新裏面經常出現的低版本數據覆蓋緩存中高版本的數據。
新增&修改緩存流程與刪除緩存流程大體一致,僅有一個區別點,如下:
刪除流程中關心的是消息中的版本是否大於等於緩存中的版本,而新增&修改緩存流程只關心消息中的版本是否大於緩存中的版本,爲什麼刪除流程要關心版本相同的情況而新增&修改流程不關心呢?
針對刪除,假設刪除的數據對應的版本是3,而緩存中正好也有這個數據且數據版本也是3,這說明刪除操作其實針對的是最新的數據,所以可以將緩存標記爲刪除態。
針對新增&修改,假設某條數據修改後,數據版本爲3。此時緩存裏面正好也有版本爲3的數據,那麼緩存中的這條數據會有下面兩種情況:
1)該數據在緩存中被標記爲刪除態了(即被業務刪除了該數據)
綜上,無論針對上述哪種情況,只要不對這條消息進行處理,就不會有任何問題。所以,修改流程只有當消息中的版本高於緩存中的版本時才設置緩存。
由於整個平臺依賴於消息隊列中間件,那麼如果消息隊列中間件出了問題(如宕機/網絡問題/消息投遞失敗等等)導致消費變得很慢或漏掉消息,怎麼辦?
前文提到,我們提供的緩存訪問組件內部會將每條消息記錄到業務DB。緩存更新平臺通過業務提供的接口增量輪詢該表,確保所有消息都被及時消費掉。通過這樣的容錯措施,確保不會因爲單點故障導致緩存來不及更新。
可以看到,通過上述的緩存訪問組件和緩存更新平臺,可以做到緩存與數據庫數據的快速一致,從而既保障了性能同時又最大程度的降低了用戶看到過期數據的可能性。
接下來,我們將繼續迭代,解決1)減少緩存訪問組件在業務代碼上的侵入性;2)在緩存更新平臺引入緩存key的分析機制,可以自動判定是否是熱點key等問題。
本文分享自微信公衆號 - 一線碼農聊技術(dotnetfly)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。