這兩日忽然想整理些自己日常工作中比較印象深刻的點,做個記錄。
腦子里蹦出的第一個項目是自己剛畢業(yè)不久時,參與的前東家的某富媒體社交平臺,本文對該項目中對評論系統(tǒng)的緩存優(yōu)化做下記錄,不然就要忘記了。
關(guān)鍵詞:緩存、couchbase、redis、memcached、評論系統(tǒng)
背景
該項目是當時公司最重點落地戰(zhàn)略性的項目之一,離開項目組時DAU達到了6000W以上,主要做富媒體feed流。評論指的是對單條feed的評論,要求評論近實時展示。本文主要分析緩存模型。
公司當時的緩存中間件主要是couchbase,所以主要是使用了couchbase緩存。倒也不影響對緩存架構(gòu)的理解。
優(yōu)化過程
第一版評論系統(tǒng)
主要的需求是,第一可展示評論,第二可翻頁(一頁20條以內(nèi))
由于項目初期用戶量不大,評論量自然有限,同時feed的熱點數(shù)據(jù)也比較少,所以緩存設(shè)計上相對簡單。
僅緩存最新的300條評論,一個 kv緩存,緩存300條評論的所有內(nèi)容,包括id,uid,樓號,評論詳情等,json序列化。
有新的寫入,會更新300條緩存。
起初運行良好,熱點數(shù)據(jù)緩存命中率比較高,請求也基本都分布在前幾頁,穿透數(shù)據(jù)庫情況極少。
第一次瓶頸 - 讀寫壓力暴增
原因
后來加入了新功能,其中有流量大咖參與的活動類feed,帶來了大量的用戶,具有很強的熱點效應(yīng)。每條活動類feed的評論可能達到幾萬條甚至更多,同時參與活動人數(shù)可能高達數(shù)十萬。
- 現(xiàn)在遇到了什么問題
- 同時緩存300條評論,1個value大者可能就有幾十上百K,突發(fā)流量,頻繁的緩存讀取,讓內(nèi)網(wǎng)帶寬稱為瓶頸,經(jīng)常物理機帶寬報警。
- 頻繁的序列化,反序列化也增大了cpu的壓力。
- 頻繁的有新評論加入,每次都會重刷緩存數(shù)據(jù),造成大量的緩存寫入。
解決思路
我們來思考下用戶請求的場景,對于評論數(shù)據(jù),往往是一次只取10-20條數(shù)據(jù),通常都分布在前十頁。甚至于通常是隨著feed列表或者feed首頁展示第一頁。那每次從緩存取300條全量數(shù)據(jù)就顯得有些重了。
好,如何優(yōu)化?
首先我們把300數(shù)據(jù)只存索引,也即評論id,這些value數(shù)據(jù)量大幅下降,從緩存中多讀取的無用數(shù)據(jù)也變少了。我們稱作id列表緩存。
那具體的評論內(nèi)容怎么辦?評論內(nèi)容按照每條內(nèi)容一個kv緩存來處理,從索引緩存中取出id,然后再去緩存中批量取,這樣每次只需要取出一頁的數(shù)據(jù)即可,也大量的縮減了網(wǎng)絡(luò)延遲、帶寬和cpu的消耗。我們成為評論的實體緩存。
是否還能進一步優(yōu)化?當然可以。評論內(nèi)容基本沒變化,我們可以利用二級緩存機制,增加本地緩存,熱點內(nèi)容的緩存命中率很高,可以減少90%以上的集中式緩存讀取
至此一段時間內(nèi),網(wǎng)絡(luò)帶寬、cpu均保持正常,維持了一段時間的安穩(wěn)。
第二次瓶頸 - 深翻頁導致的性能下降(緩存命中率低)
原因
評論增加了蓋樓功能,運營會做一些活動,根據(jù)蓋的樓層送禮品,重點是通常在大V用戶的feed下做活動?。?br>
于是產(chǎn)生了大量用戶向下深層翻頁的情況,甚至翻閱上千頁以上。
這下麻煩了,之前僅緩存了前300條評論id,如果用戶深翻頁那必然無法命中緩存,穿透數(shù)據(jù)庫,導致了大量的評論請求超時,引起了不少客訴。
運營場景又倒逼我們做技術(shù)優(yōu)化。
解決思路
- 首先明確,我們要解決的問題是緩存命中率問題。
- 是什么的緩存命中率?id列表緩存,還是評論實體緩存?
上文提到,我們設(shè)計了2種緩存,300條最新評論id的列表緩存,和評論的實體緩存。
對于實體緩存來說,只要被訪問過,便會種一段時間緩存,熱點數(shù)據(jù)緩存命中率比較高。
而id列表緩存由于只緩存了300條,所以,深翻頁一定會穿透,這就是我們要解決的主要矛盾。 - 能否通過增加緩存的評論id條數(shù)解決問題?
不能。單純的增加緩存的id條數(shù),增加的少了僅是多支持了幾頁的翻頁,增加的多了,有會產(chǎn)生和第一次性能瓶頸一樣的問題,導致帶寬和cpu瓶頸。 - 那能不能分段緩存,比如前300個緩存一條記錄,然后后邊每300個緩存一條記錄?
bingo,思路很好。提高了擴展性,增加了緩存命中率。但是我們需要思考下細節(jié)。這么做有什么問題?
如果分頁來查找,我們怎么做?這個簡單,根據(jù)每一頁計算出這一頁落在哪個index上,取對應(yīng)index的緩存即可。比如每頁20條,第二頁是最新的21-40,落在0-300的區(qū)間內(nèi)。
考慮下新增評論,我們存了同一條feed的兩組index緩存,分別是最新的1-300條,301-600條,如果新增一條要怎么辦?如果要保證實時展示的話,我們就要刷新列表緩存,由于第一條index緩存已經(jīng)滿員了,要把多余的部分轉(zhuǎn)到下一個index區(qū)間,循環(huán)下去……天,那要刷新這條feed的所有列表緩存……緩存寫入量必然大增。如果不刷新,很難保證列表緩存實時性。
如果不刷新,把新數(shù)據(jù)單獨做個列表緩存呢?這個度不好把握,也不好判斷什么時候該取下一組index數(shù)據(jù)??傊容^復雜。 - 有沒有更好的解決辦法?
肯定有,要不然這次優(yōu)化不叫成功。
這個需求有一個很巧的地方,就是評論帶樓層。
巧在哪里?
我們之前對評論的排序都是根據(jù)id(或者創(chuàng)建時間)倒序排列,因為id是所有評論的唯一標識,所以僅根據(jù)id無法確認該評論在列表中的具體位置。但是樓層不然,是第幾樓,就意味著是該feed下的第幾個評論。
確認了位置有啥用?
第4條我們提到,主要的問題是實時更新的性能問題??吹綐菍舆@個變量,靈機一動,我們肯定是能拿到feed當前的最高樓層的,那第幾頁取哪幾層的數(shù)據(jù)也是一目了然。比如最高樓層1001,一頁20條,第2頁數(shù)據(jù)就是第981-962層的評論(小學數(shù)據(jù)題)。
那如果這樣設(shè)計列表緩存呢?將列表緩存按照固定的樓層分段,比如第1-300層一段,第301-600層一段,依次類推。如果當前最高樓層是1001,則落在901-1200的分段內(nèi)。
key value
index_300_1 第1-300層的id列表[{樓層,id},{樓層,id}]
也就是我們通過樓層就能知道去取那個分段的列表數(shù)據(jù),而且每個分段存儲的樓層是固定的。注意是固定的,固定的有什么好處?那就是如果我新來了一條評論,知道他的樓層,那就知道要往那個分段里更新數(shù)據(jù),已經(jīng)滿了的分段數(shù)據(jù)是不會變化的。嗯...好像解決了更新數(shù)據(jù)量過大的問題!
如何用
其實剛剛的思路已經(jīng)把具體的方案描述差不多了
- 根據(jù)樓層來對列表緩存分段
key value
index_300_1 第1-300層的id列表[{樓層,id},{樓層,id}]
index_600_301 第301-600層的id列表[{樓層,id},{樓層,id}] - 取最高樓層是930的首頁數(shù)據(jù),也即第930-911層
均落在index_1200_901的區(qū)間范圍內(nèi),則取該段的緩存即可 - 取最高樓層是930的第二頁數(shù)據(jù),也即第910-891層
落在index_1200_901和index_900_601,兩個分段內(nèi),則從兩個分段緩存中取出對應(yīng)id - 當前最高樓層是930,新增一條記錄,則為931層
落在index_1200_901的區(qū)間范圍內(nèi),只需更新該段緩存即可。
第三次瓶頸 - 資源消耗過大
隨著業(yè)務(wù)發(fā)展,內(nèi)容越來越廣,用戶也越來越多,誠然緩存會過期,但耐不住數(shù)據(jù)量的龐大,couchbase占用的內(nèi)存一路猛升。從一開始幾十G的物理機一直增長到了上T。
物理機資源太貴了,所以要思考怎么減少內(nèi)存占用。這里簡化過程,直接說結(jié)果。之前用的序列化方式是JSON格式,這種格式可讀性強,但是占用的內(nèi)存大、序列化性能也不理想。調(diào)研了protobuf協(xié)議,將序列化方式改為protobuf后,內(nèi)存使用率降到了之前的10%-20%,內(nèi)存利用率提高了5-10倍!極大減小了成本。同時因為更高的序列化反序列化性能,cpu使用率也得到了相應(yīng)改善。
其他優(yōu)化
經(jīng)過上面的幾次優(yōu)化,不論是性能還是資源成本的角度看,在當時都達到了不錯的效果。但是依然有很多的優(yōu)化空間。
緩存預取
上面提到對列表緩存使用樓層的進行的分段優(yōu)化,對于熱點數(shù)據(jù)深分頁的問題效果不錯。后來針對這一過程又做了進一步的優(yōu)化,緩存預取。通常情況下用戶只訪問第一段的數(shù)據(jù)就夠了,緩存命中率也很高。某些熱點feed,用戶會進行深分頁,我們從用戶開始訪問到第三段起(通常超過了300條數(shù)據(jù)),就認為該feed可能是此類熱點feed,如果緩存中不存在,那我們會同時異步預加載后邊2段數(shù)據(jù),這樣用戶一直向下翻的時候,基本都命中緩存,進一步提高了性能。分頁模式改造
通常,我們的分頁都是傳遞兩個參數(shù),pageSize,page,表示每頁多少條,取第幾頁,這種形式存在一定問題
從持久化存儲系統(tǒng)的角度來看,是有性能瓶頸的。拿mysql舉例,如果要做深分頁,會掃描該頁之前的所有數(shù)據(jù),可能是比較消耗資源的磁盤掃描(自行查閱資料嘍)。
從用戶體驗角度來看,如果用戶翻頁過程中,有新增feed,那有可能會有翻頁重復數(shù)據(jù)。比如第20條變成了第21條。
所以,對分頁方式做了個優(yōu)化。每次傳遞上一頁的最后一個元素(這里是樓層,lastFloor),和一頁取多少個數(shù)據(jù),我們以游標的形式取數(shù)據(jù)避免了重復數(shù)據(jù)的出現(xiàn),當然如果出現(xiàn)在查詢mysql的場景中,也避免了過多的磁盤掃描
總結(jié)與思考
我們?yōu)槭裁从镁彺嬉约叭绾斡谩?br> 用緩存的原因就不必多說了,就是性能。因為內(nèi)存和磁盤的性能差異,有如天壤之別,比cpu高速緩存和內(nèi)存差異還要大。
如何使用,其實我們要理解一下緩存的局部性原理就會有一種撥云見日的感覺
緩存局部性原理包括緩存的空間局部性原理和時間局部性原理。這塊知識在講硬件時可能用的比較多,尤其是cpu的寄存器、高速緩存設(shè)計。
在CPU訪問寄存器時,無論是存取數(shù)據(jù)或存取指令,都趨于聚集在一片連續(xù)的區(qū)域中,這就被稱為局部性原理。
時間局部性(temporal locality)
時間局部性指的是:被引用過一次的存儲器位置在未來會被多次引用(通常在循環(huán)中)。
空間局部性(spatial locality)
如果一個存儲器的位置被引用,那么將來他附近的位置也會被引用。
其實不只是cpu緩存,擴展到我們的軟件系統(tǒng)設(shè)計也是一個道理?;叵胂拢衔脑趺从玫木植啃栽??
訪問過的數(shù)據(jù)種緩存,供后續(xù)訪問使用,就是應(yīng)用的時間局部性原理
訪問一個分段的數(shù)據(jù),將后邊分段的數(shù)據(jù)預先加載到緩存,提高緩存命中率,就是用的空間局部性原理其他緩存中間件可以嗎?
何止是可以,甚至可以做的更好。當然萬變不離其宗,原理是一樣的。比如我們?nèi)绻胷edis的list結(jié)構(gòu),每次從緩存中取分段數(shù)據(jù),都沒必要取出整段,只取需要的一部分即可。這里只說了緩存設(shè)計,其中還有很多值得討論的其他技術(shù)架構(gòu)點,比如如何持久化評論,持久化和更新緩存的流程是怎樣的,如何保證數(shù)據(jù)一致性等,都值的我們探討。
好累,總結(jié)一次不容易。