多級緩存架構:深度解析與實踐
一、多級緩存架構概述
- 在互聯網電商場景中,由于讀、寫請求量級大且響應時間(RT)要求低,多級緩存架構旨在提升讀請求性能。
- 緩存本質是數據冗余,將數據逐層冗余放置在離用戶更近、速度更快但容量更小、價格更貴的存儲系統上,以提高系統訪問性能。
- 常見的多級緩存架構為本地緩存 + 分布式緩存(Redis)+ 數據庫(DB),足以滿足大部分場景;若需支撐更高量級查詢,可將緩存進一步前置。
性能瓶頸
- DB層:性能瓶頸在于磁盤讀取的磁盤IO,將數據移至內存中的Redis可提升性能。
- Redis層:性能受限于網絡IO,JVM應用請求Redis時需發起IO請求,使用JVM本地緩存可減少此消耗。
- JVM本地緩存層:性能受限于Tomcat服務器,單個服務器處理并發請求有限,可通過增加Tomcat服務數量(如K8s多Pod部署)或把緩存前置到Nginx提升性能。
- Nginx層:Nginx是高性能Web服務器,單機處理并發請求量可達數萬,可選擇在Nginx本地內存存儲部分數據或通過Lua腳本訪問Redis數據;使用兩層Nginx架構,接入層Nginx負責流量分發,應用層Nginx處理業務邏輯和熱點緩存讀取。
- 緩存層次并非越多越好,需根據實際情況選擇,互聯網常用多級緩存為JVM本地緩存 + Redis緩存,對于極熱點數據可采用Nginx + JVM本地緩存 + Redis緩存三級架構。
- JVM緩存存放熱點數據,避免其使Redis分片崩潰,但僅在查詢Redis數據時將數據放入本地緩存會導致命中率不高,通常需要熱點探測系統將熱點數據放入JVM本地緩存,許多大廠都有自研熱點探測系統,如得物的Burning、京東的hotkey。
二、多級緩存請求流程
圖片
- 用戶請求進入接入層Nginx后,會被負載均衡算法分發到各應用層Nginx。
- 在應用層Nginx上,先讀取本地緩存,減少對后端服務沖擊;
- 若未命中,則讀取Redis緩存,以減輕Tomcat集群壓力;
- 若Redis緩存未命中,進入JVM應用層,先讀取JVM本地內存;
- 若JVM本地內存未命中,訪問Redis集群;
- 若Redis集群未命中,最后訪問DB。
多級緩存架構應用可根據實際情況逐步完善,初始使用DB,DB有瓶頸時添加Redis,Redis無法支撐時將熱數據放至JVM本地內存,JVM內存也無法支撐時,將緩存前置到Nginx層。
簡單示例:
public class MultiLevelCache {
private Cache<String, String> jvmCache;
private Jedis redis;
public MultiLevelCache() {
jvmCache = Caffeine.newBuilder()
.maximumSize(100)
.build();
redis = new Jedis("localhost", 6379);
}
public String getProductName(String productId) {
// 首先嘗試從 JVM 本地緩存獲取
String productName = jvmCache.getIfPresent("product:" + productId + ":name");
if (productName == null) {
// 若 JVM 本地緩存未命中,嘗試從 Redis 緩存獲取
productName = redis.get("product:" + productId + ":name");
if (productName!= null) {
// 將 Redis 中命中的數據更新到 JVM 本地緩存
jvmCache.put("product:" + productId + ":name", productName);
} else {
// 若 Redis 緩存未命中,從數據庫獲取
productName = fetchFromDatabase(productId);
if (productName!= null) {
// 將數據存儲到 Redis 緩存
redis.set("product:" + productId + ":name", productName);
// 同時存儲到 JVM 本地緩存
jvmCache.put("product:" + productId + ":name", productName);
}
}
}
return productName;
}
private String fetchFromDatabase(String productId) {
// 模擬從數據庫讀取數據,這里使用 MySQL 示例代碼
MySQLDataAccess mySQLDataAccess = new MySQLDataAccess();
return mySQLDataAccess.fetchProductName(productId);
}
public static void main(String[] args) {
MultiLevelCache cache = new MultiLevelCache();
String productName = cache.getProductName("1");
System.out.println("Product Name: " + productName);
}
}
class MySQLDataAccess {
private static final String JDBC_URL = "jdbc:mysql://localhost:3306/your_database";
private static final String JDBC_USER = "username";
private static final String JDBC_PASSWORD = "password";
public String fetchProductName(String productId) {
String productName = null;
try (Connection connection = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
String query = "SELECT name FROM products WHERE id =?";
try (PreparedStatement preparedStatement = connection.prepareStatement(query)) {
preparedStatement.setInt(1, Integer.parseInt(productId));
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (resultSet.next()) {
productName = resultSet.getString("name");
}
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return productName;
}
}
三、負載均衡算法的選擇
- 常用負載均衡算法:
輪詢:優勢在于負載均衡,但相同請求會被轉發到不同節點,節點增多時緩存命中率會降低。
一致性哈希:相同請求會路由到同一臺機器,節點宕機僅會使少量緩存數據失效,但會導致大量請求集中在某臺機器。
- 算法選擇依據:負載較低時,更追求緩存命中率,可使用一致性哈希;負載較高時,對熱點數據訪問多,更希望請求平均分散,不建議使用一致性哈希,可選擇輪詢,避免如Redis Cluster中使用一致性哈希時節點掛掉引發的雪崩問題。
四、應用層Nginx本地緩存實現
圖片
- 可使用Lua Shared Dict實現,這是OpenResty提供的功能,OpenResty集成了Nginx和Lua,可開發業務邏輯進行熱點數據的查詢和獲取。
- 通過Nginx + Lua可從Nginx本地內存或遠程Redis獲取數據。
- 應用層Nginx處理第一層數據并存儲熱點數據,會將請求上報到熱點發現系統進行統計,熱點數據還可放在JVM本地緩存。
- 互聯網公司的熱點數據探測系統,如京東的hotkey,主要用于探測MySQL、Redis中頻繁訪問數據,避免因惡意攻擊、爬蟲請求、機器人導致的熱點數據使Redis分片癱瘓。以往使用JVM本地緩存 + Redis緩存的二級緩存方式,對于熱點數據命中率較低,因此需要統一熱點key探測方案,將熱點數據推送到JVM本地緩存。
- 京東 hotkey:https://mp.weixin.qq.com/s/xOzEj5HtCeh_ezHDPHw6Jw
- 得物 Burning:https://tech.dewu.com/article?id=23
簡單示例:
-- 在 Nginx 配置文件中
http {
lua_shared_dict my_cache 10m; -- 定義一個 10MB 的共享字典
server {
location /cache {
content_by_lua_block {
local cache = ngx.shared.my_cache
local key = ngx.var.arg_key
local value = cache:get(key)
if value == nil then
-- 從 Redis 獲取數據
local redis = require "resty.redis"
local red = redis:new()
red:connect("localhost", 6379)
value = red:get(key)
red:close()
if value ~= nil then
cache:set(key, value) -- 將數據存儲到本地緩存
end
end
ngx.say(value)
}
}
}
}
五、數據緩存優化
- 緩存數據過期時間:
設置過期時間:適合熱點、易更新數據,如庫存數據,可短時間允許不一致。
不設置過期時間:適合非熱點、長期訪問數據,如用戶信息、店鋪信息等。
- 緩存數據淘汰:
- 對于設置過期時間的數據到期自動刪除,不設置過期時間的數據,當緩存空間滿時,需通過淘汰策略刪除。
- 淘汰策略有LRU(根據訪問時間淘汰最久未訪問數據,但大批量數據訪問會使命中率下降)、LFU(根據訪問頻率淘汰最不常訪問數據,數據訪問內容變化大時命中率會下降)、ARC(結合LRU和LFU優點)。
- 緩存加載和更新:可使用緩存旁路模式,先寫數據庫,再寫緩存;更新時先更新數據庫,再刪除緩存。
- 增量化緩存重建:對于復雜數據,緩存重建成本高,可通過維度劃分和根據增量數據變更僅重建對應維度緩存,降低重建成本,如對商品的不同維度信息(基礎信息、圖片等)進行劃分,按更新維度重建緩存。
六、緩存數據一致性保證
- 多級緩存的特性差異:分布式緩存Redis中一份數據只存儲一次,可存儲較多數據;JVM緩存中熱點數據在每個節點存儲,且本地緩存容量小,存儲少量數據,因此更新策略不同。
- Redis數據一致性:
同步刪除緩存數據 - 旁路緩存策略:讀場景:先從緩存讀取,命中則返回,未命中從數據庫讀取;寫場景:先更新數據庫,再失效緩存,能滿足大部分場景的數據一致性,但在極端情況(讀寫操作時序錯亂)下會出現數據不一致問題。
同步刪除緩存數據 - 延時雙刪:兩次刪除緩存,第一次快速達到最終一致性,第二次延時刪除可能的臟數據;需考慮延時時間設置(大于讀操作時間 + 數據庫主從同步延時時間),可使用異步線程執行第二次刪除,但存在延時時間不準確和性能消耗問題。
異步刪除緩存數據 - 基于binlog實現:如阿里巴巴的Canal監聽binlog實現緩存更新,Canal模擬MySQL從庫,接收主庫binlog,Server解析存儲,客戶端與Server通信獲取binlog;
Canal 1.1.1版本后支持將binlog投遞至MQ,可多客戶端消費,實現大量數據的緩存更新;但對于大多數場景,使用緩存旁路策略已足夠,引入Canal會使架構復雜且增加維護成本。
簡單示例:
public class RedisCacheConsistency {
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
String key = "product:1:price";
// 先更新數據庫(這里省略數據庫更新代碼)
// 立即刪除緩存
jedis.del(key);
// 延時 500 毫秒后再次刪除緩存,確保臟數據清除
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(500);
jedis.del(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
jedis.close();
}
}
- 本地緩存數據一致性:
對于少量熱點數據,可采用主動更新 + 過期時間的方式刷新本地緩存。
對于不同更新頻率的數據,可設置不同過期時間并配合定時任務刷新,如不易變化的數據可設置過期時間為30min,配合1min更新一次的定時任務;變化頻繁的數據可設置過期時間為1min,配合30ms更新一次的定時任務。
可使用本地雙緩存策略,創建兩份過期時間不同的本地緩存,第一份寫后30min過期,第二份讀寫后40min過期;讀寫操作以第一份緩存為主,第一份過期后可從第二份讀取,同時更新第一份緩存,第二份作為備用。
本地雙緩存刷新通過定時任務完成,檢查緩存數據失效后從遠程獲取數據并放入雙緩存。
- 緩存最終一致性的保證:設置緩存過期時間,最終會刪除緩存,達到最終一致性;軟件工程中無法保證絕對一致性,如Linux的PageCache在服務器異常關機時會有數據丟失,軟件層面更難避免數據不一致。
簡單示例:
public class LocalCacheConsistency {
// 本地緩存
private Cache<String, String> cache;
// 模擬的數據庫訪問服務
private DatabaseService databaseService;
// 定時任務執行器
private ScheduledExecutorService executorService;
public LocalCacheConsistency() {
// 初始化本地緩存,設置最大容量和過期時間
cache = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
databaseService = new DatabaseService();
// 創建定時任務執行器
executorService = Executors.newSingleThreadScheduledExecutor();
// 啟動定時刷新任務
startCacheRefreshTask();
}
// 從本地緩存獲取數據
public String get(String key) {
return cache.getIfPresent(key);
}
// 存儲數據到本地緩存
public void put(String key, String value) {
cache.put(key, value);
}
// 從數據庫獲取數據并更新本地緩存
public void refreshCache(String key) {
String value = databaseService.fetchData(key);
if (value!= null) {
cache.put(key, value);
}
}
// 啟動定時刷新緩存任務
private void startCacheRefreshTask() {
executorService.scheduleAtFixedRate(() -> {
// 這里可以根據實際情況選擇要刷新的緩存鍵,例如全部刷新或只刷新部分熱點鍵
// 這里假設我們要刷新所有緩存鍵,你可以修改為更具體的邏輯
refreshAllCacheKeys();
}, 0, 30, TimeUnit.SECONDS); // 每 30 秒刷新一次
}
// 刷新所有緩存鍵
private void refreshAllCacheKeys() {
// 假設緩存鍵的前綴是 "product:",這里可以根據具體業務進行修改
for (int i = 1; i <= 100; i++) {
String key = "product:" + i;
refreshCache(key);
}
}
// 模擬的數據庫服務
private static class DatabaseService {
public String fetchData(String key) {
// 這里可以實現具體的數據庫查詢邏輯,這里僅作模擬
System.out.println("Fetching data from database for key: " + key);
return"Value for " + key;
}
}
}
七、最后
在實際使用中,需從業務場景、數據不一致時間、實現成本和維護成本等多方面評估并選擇合適方案,通過壓測和實驗對比優劣。