聊聊分頁列表緩存

開源中國的紅薯哥寫了很多關於緩存的文章,其中多級緩存思路,分頁列表緩存這些知識點給了我很大的啓發性。

寫這篇文章,我們聊聊分頁列表緩存,希望能幫助大家提升緩存技術認知。

1 直接緩存分頁列表結果

這是最簡單易懂的方案,我們按照不同的分頁條件查詢出結果後,直接緩存分頁結果 。

僞代碼如下:

public List<Product> getPageList(String param,int page,int size) {
  String key = "productList:page:" + page + "size:" + size + 
               "param:" + param ;
  List<Product> dataList = cacheUtils.get(key);
  if(dataList != null) {
    return dataList;
  }
  dataList = queryFromDataBase(param,page,size);
  if(dataList != null) {
       cacheUtils.set(key , dataList , Constants.ExpireTime);
  }
}

這種方案的優點是工程簡單,性能也快,但是有一個明顯的缺陷基因:列表緩存的顆粒度非常大

假如列表中數據發生增刪,爲了保證數據的一致性,需要修改分頁列表緩存。

有兩種方式 :

1、依靠緩存過期來惰性的實現 ,但業務場景必須包容;

2、使用 Redis 的 keys 找到該業務的分頁緩存,執行刪除指令。但 keys 命令對性能影響很大,會導致 Redis 很大的延遲 。

生產環境使用 keys 命令比較危險,發生事故的幾率高,非常不推薦使用

2 查詢對象 ID 列表,再緩存每個對象條目

直接緩存分頁結果雖然好用,但緩存的顆粒度太大,保證數據一致性比較麻煩。

所以我們的目標是更細粒度的控制緩存

我們先查詢出商品分頁對象 ID 列表,然後爲每一個商品對象創建緩存 ,  通過商品 ID 和商品對象緩存聚合成列表返回給前端。

僞代碼如下:

核心流程:

1、從數據庫中查詢分頁 ID 列表

// 從數據庫中查詢分頁商品 ID 列表
List<Long> productIdList = queryProductIdListFromDabaBase(
                           param, 
                           page, 
                           size);

對應的 SQL 類似:

SELECT id FROM products
ORDER BY id ASC  
LIMIT (page - 1) * size , size

2、批量從緩存中獲取商品對象

Map<Long, Product> cachedProductMap = cacheUtils.mget(productIdList);

假如我們使用本地緩存,直接一條一條從本地緩存中聚合也極快。

假如我們使用分佈式緩存,Redis 天然支持批量查詢的命令 ,比如 mget ,hmget 。

3、組裝沒有命中的商品 ID

List<Long> noHitIdList = new ArrayList<>(cachedProductMap.size());
for (Long productId : productIdList) {
     if (!cachedProductMap.containsKey(productId)) {
         noHitIdList.add(productId);
     }
}

因爲緩存中可能因爲過期或者其他原因導致緩存沒有命中的情況,所以我們需要找到哪些商品沒有在緩存裏。

4、批量從數據庫查詢未命中的商品信息列表,重新加載到緩存

首先從數據庫裏批量查詢出未命中的商品信息列表 ,請注意是批量

List<Product> noHitProductList = batchQuery(noHitIdList);

參數是未命中緩存的商品 ID 列表,組裝成對應的 SQL,這樣性能更快 :

SELECT * FROM products WHERE id IN
                         (1,
                          2,
                          3,
                          4);

然後這些未命中的商品信息存儲到緩存裏 , 使用 Redis 的 mset 命令。

//將沒有命中的商品加入到緩存裏
Map<Long, Product> noHitProductMap =
         noHitProductList.stream()
         .collect(
           Collectors.toMap(Product::getId, Function.identity())
         );
cacheUtils.mset(noHitProductMap);
//將沒有命中的商品加入到聚合map裏
cachedProductMap.putAll(noHitProductMap);

5、 遍歷商品 ID 列表,組裝對象列表

for (Long productId : productIdList) {
    Product product = cachedProductMap.get(productId);
    if (product != null) {
       result.add(product);
    }
}

當前方案裏,緩存都有命中的情況下,經過兩次網絡 IO ,第一次數據庫查詢 IO ,第二次 Redis 查詢 IO ,  性能都會比較好。

所有的操作都是批量操作,就算有緩存沒有命中的情況,整體速度也較快。

查詢對象 ID 列表,再緩存每個對象條目 “ 這個方案比較靈活,當我們查詢對象 ID 列表,可以不限於數據庫,還可以是搜索引擎,Redis 等等。

下圖是開源中國的搜索流程:

精髓在於:搜索的分頁結果只包含業務對象 ID  ,對象的詳細資料需要從緩存 + MySQL 中獲取。

3 緩存對象 ID 列表, 同時緩存每個對象條目

筆者曾經重構過類似朋友圈的服務,進入班級頁面 ,瀑布流的形式展示班級成員的所有動態。

我們使用推模式將每一條動態 ID 存儲在 Redis  ZSet 數據結構中 。Redis ZSet 是一種類型爲有序集合的數據結構,它由多個有序的唯一的字符串元素組成,每個元素都關聯着一個浮點數分值。

ZSet 使用的是 member -> score 結構 :

如上圖所示:ZSet 存儲動態 ID 列表  ,  member 的值是動態編號 , score 值是創建時間

通過 ZSet 的 ZREVRANGE 命令就可以實現分頁的效果。

ZREVRANGE 是 Redis 中用於有序集合(sorted set)的命令之一,它用於按照成員的分數從大到小返回有序集合中的指定範圍的成員。

爲了達到分頁的效果,傳遞如下的分頁參數 :

通過 ZREVRANGE 命令,我們可以查詢出動態 ID 列表。

查詢出動態 ID 列表後,還需要緩存每個動態對象條目,動態對象包含了詳情,評論,點贊,收藏這些功能數據 ,我們需要爲這些數據提供單獨做緩存配置。

無論是查詢緩存,還是重新寫入緩存,爲了提升系統性能,批量操作效率更高。

若**緩存對象結構簡單,使用 mget 、hmget 命令;若結構複雜,可以考慮使用 pipleline,Lua 腳本模式 。**筆者選擇的批量方案是 Redis 的 pipleline 功能。

我們再來模擬獲取動態分頁列表的流程:

  1. 使用 ZSet 的 ZREVRANGE 命令 ,傳入分頁參數,查詢出動態 ID 列表 ;

  2. 傳遞動態 ID 列表參數,通過 Redis 的 pipleline 功能從緩存中批量獲取動態的詳情,評論,點贊,收藏這些功能數據 ,組裝成列表 。

4 總結

本文介紹了實現分頁列表緩存的三種方式:

  1. 直接緩存分頁列表結果

  2. 查詢對象 ID 列表,只緩存每個對象條目

  3. 緩存對象 ID 列表,同時緩存每個對象條目

這三種方式是一層一層遞進的,要訣是:細粒度的控制緩存批量加載對象


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