博客 / 詳情

返回

社區收藏緩存設計重構實戰

一、背景

社區收藏業務是一個典型的讀多寫少的場景,社區各種核心Feeds流都需要依賴用户是否收藏的數據判斷,早期緩存設計時由於流量不是很大,未體現出明顯的問題,近期通過監控平台等相關手段發現了相關的一些問題,因此我們針對這些問題對緩存做了重構設計,以保障收藏業務的性能和穩定性。

二、問題分析定位

2.1 接口RT偏大

通過監控平台查看「判斷是否收藏接口」的RT在最高在8ms左右,該接口的主要作用是判斷指定單個用户是否已收藏一批內容,其實如果緩存命中率高的話,接口RT就應該趨近於Redis的RT水平,也就是1-2ms左右。

(圖中有單根尖刺,這個具體問題要具體分析優化,我們這裏主要闡述整體水平的優化)

圖片

圖片

2.2 Redis&MySQL訪問QPS偏高

通過監控平台可以看到從上游服務過來的收藏查詢QPS相對訪問Redis緩存的QPS放大了15倍,並且MySQL查詢的最高QPS佔上游訪問量接近37%,這説明緩存並沒有很高的命中率,導致回表查詢的概率還是很大。

QPS訪問量見下圖:

Redis訪問量

圖片

MySQL訪問量

圖片

基於以上分析我們現在有了明確的優化切入點,接下來我們來看下具體的找下原因是什麼。
接下來我們來看一下偽代碼的實現:

//判斷用户是否對指定的動態收藏
func IsLightContent(userId uint64,contentIds []uint64){
    index := userId%20
    cacheKey := key + "_" + fmt.Sprintf("%d", index)
    pipe := redis.GetClient().Pipeline()
    for _, item := range contentIds {
        InitCache(userId, contentId)
        pipe.SisMember(cacheKey, userId)
    }
    pipe.Exec()
    //......
}

//緩存初始化判斷,不存在則初始化數據緩存
func InitCache(userId uint64,contentId uint64){
    index := userId%20
    cacheKey := key + "_" + fmt.Sprintf("%d", index)
    ttl,_ := redis.GetClient().TTL(cacheKey)
    if ttl <= 0{//key不存在或者未設置過期時間
        // query from db
        // sql := "select userId from trendFav where userId%20 = index and content_id = contentId"
        // save to redis
    }else{
       redis.GetClient().Expire(cacheKey,time.Hour()*48)
    }
}

從上面的偽代碼中,我們能夠很清晰的看到,該方法會遍歷內容id集合,然後對每個內容去查詢緩存下來的用户集合,判斷該當前用户是否收藏。也就是説緩存設計是按照內容維度和用户1:N來設計的,將單個動態下所有收藏過內容的用户id查出來緩存起來。並且基於大Key的考慮,代碼又將用户集合分片成20組。這無疑又再次放大了Redis緩存Key的數量。並且每個Key都使用TTL命令來判斷是否過期。這樣一來Redis的QPS和緩存Key就會被放大很多倍。

正是由於分片策略+緩存時效短,導致了MySQL查詢的QPS居高不下

三、解決方案

基於以上對問題的分析定位,我們思考的解決思路就是一次接口請求降低Redis查詢操作,儘可能減少放大的情況,初步判斷有如下兩個實現路徑:

  • 去掉遍歷內容查詢,改為一次性查詢
  • 去掉用户集分片存儲,改為單Key存儲

上游的調用參數用户和內容是一對多的關係,因此要實現的Redis查詢也是要滿足一對多的關係,那麼顯而易見我們的緩存應該是按照用户的維度來存儲已經收藏過的內容集合。

用户收藏的內容比較少的話,我們很簡單的就可以從數據庫全部查詢出來放在緩存,但如果用户收藏的內容比較多呢,那也會可能造成大Key問題,如果繼續分片存儲的話又會回到了原來的方案。我們討論出以下兩種方案:

方案1. 處理大數據大部分常規思路就是要麼分片,要麼冷熱分離

因為業務邏輯的特點,推薦流下用户看到的內容絕大部份基本都是一年以內的,我們可以緩存用户一年以內的收藏內容,這樣就限制了用户收藏的極端數量。如果看到的內容發佈超過一年時間,可以用MySQL直接查詢,這種場景的case概率是很小的。但仔細考慮了下實現,這個需要依賴業務方,我們需要去查詢內容的發佈時間,以此來判斷是否在我們的緩存內,這樣會加重整個接口的邏輯,反而得不償失,因此該思路很快就被否定了。

方案2. 既然不能依賴第三方,就是要從自身擁有的信息上,來能夠緩存一部分最熱的數據,使得查詢能夠大範圍落到這些數據

我們目前只有內容id,而內容id都是純數字,數字本身的話可以按照大小來排列。業務查詢本身都是最近一段時間的內容,所以查詢的內容id都是近期較大的id。那我們可以按照內容id降序排列,取用户收藏過的若干條數據來緩存。只要查詢的id都比緩存最小的id大,那麼我們就可以只通過緩存來判斷出用户是否收藏這些內容了。

示例:
初始化緩存時我們按照內容id降序排列,拿到前5000個內容id:

  1. 如果查詢結果不滿5000,那麼這個用户緩存了全部收藏記錄,此時小緩存的內容id為0
  2. 如果大於等於5000,説明還有部分未緩存的記錄,此時最小緩存的內容id為第5000個內容ID

等到查詢判斷時,將查詢的內容id數組和緩存的最小內容id對比,如果全部大於,則説明都在緩存範圍內,如果有小於,則是超過緩存範圍,屆時單獨去數據庫判斷,當然這種概率在業務上的發生機率是比較小的。

這裏緩存的數量的抉擇顯得尤為重要,如果太小,那緩存的命中率不高,導致MySQL回表查詢概率變大,如果太大,則初始化時比較耗費時間,或產生大Key問題。經過分析線上數據,目前以5000這個數字能夠比較好的權衡。

下面是查詢緩存判斷流程圖:

圖片

緩存方式由原來的set結構,改為Hash結構,TTL延長到7 * 24 hour。
圖片

這樣一來,原來的獨立調用的TTL和sismember命令,可以合併成一個Hmget命令,減少了一半的Redis訪問次數,這個改進收益是相當可觀的。

四、優化成果

截止本文撰寫時,我們對收藏的功能進行了優化改造並上線,取得了很不錯的進展。所有數據為最近7天的數據4.14 - 4.20,優化效果在4.15號17點左右開始。

4.1 RPC接口響應RT降低

1 IsCollectionContent

RPC接口,判斷動態是否緩存。平均RT提高了接近3倍。並且RT比較穩定

圖片

4.2 Redis負載降低

1 TTL 查詢

查詢Key有效期,用來判斷延長Key有效期。QPS直接降到0

圖片

2 SISMEMBER查詢

原來舊的收藏緩存查詢,已經改為HMGET查詢QPS降低到0

圖片

3 HMGET查詢

新的收藏緩存查詢QPS數量和上游過來查詢的QPS正好能對應上

圖片

4 Redis 內存降低

新的緩存較舊緩存在佔用內存和Key數量這2個指標均降低了3倍左右

4.3 MySQL負載降低

1 content_collection表select查詢降低

QPS降低了24倍左右並且保持在一個比較穩定的水位

圖片

2 MySQL連接併發數降低

查詢QPS的減少也降低了併發連接數,大概降低了3倍左右,最終也降低了等待連接次數

圖片

圖片

五、總結

經過對本次問題的分析和解決,不難看出一個良好的緩存設計對於服務來説是多麼的重要。好的緩存設計不僅能夠提升性能,同時可以降低資源使用,整體提升了資源利用率。同時下游的流量和上游基本持平,在流量上升時,不會對下游造成很大的壓力,這樣服務整體的抗併發能力也提升了很多。

*文/Sky
 @得物技術公眾號

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.