開源中國的紅薯哥寫了很多關於快取的文章,其中多級快取思路,分頁列表快取這些知識點給了我很大的啟發性。
寫這篇文章,我們聊聊分頁列表快取,希望能幫助大家提升快取技術認知。
顯而易見,這是最簡單易懂的方式。
我們按照不同的分頁條件來快取分頁結果 ,虛擬碼如下:
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 命令比較危險,發生事故的機率高,非常不推薦使用。
快取分頁結果雖然好用,但快取的顆粒度太大,保證資料一致性比較麻煩。
所以我們的目標是更細粒度的控制快取 。
我們查詢出商品分頁物件ID列表,然後為每一個商品物件建立快取 , 通過商品ID和商品物件快取聚合成列表返回給前端。
虛擬碼如下:
核心流程:
1、從資料庫中查詢分頁 ID 列表
// 從資料庫中查詢分頁商品 ID 列表
List<Long> productIdList = queryProductIdListFromDabaBase(
param,
page,
size);
對應的 SQL 類似:
SELECT id FROM products
ORDER BY id
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 中獲取。
筆者曾經重構過類似朋友圈的服務,進入班級頁面 ,瀑布流的形式展示班級成員的所有動態。
我們使用推模式將每一條動態 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 功能。
我們再來模擬獲取動態分頁列表的流程:
本文介紹了實現分頁列表快取的三種方式:
直接快取分頁列表結果
查詢物件ID列表,只快取每個物件條目
快取物件ID列表,同時快取每個物件條目
這三種方式是一層一層遞進的,要訣是:
細粒度的控制快取和批次載入物件。
如果我的文章對你有所幫助,還請幫忙點贊、在看、轉發一下,你的支援會激勵我輸出更高質量的文章,非常感謝!