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