快取是提升系統效能的利器,但也帶來資料一致性等挑戰。本章從前端到後端,系統介紹各層快取技術。
快取的價值與代價#
為什麼需要快取#
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px'}}}%%
xychart-beta
title "回應時間對比"
x-axis ["記憶體讀取", "Redis 讀取", "MySQL 讀取", "磁盤隨機讀取"]
y-axis "時間 (對數尺度)" 0 --> 100
bar [0.0001, 1, 10, 10]| 存取類型 | 典型回應時間 |
|---|---|
| 記憶體讀取 | ~100ns |
| Redis 讀取 | ~1ms |
| MySQL 讀取 | ~10ms |
| 磁盤隨機讀取 | ~10ms |
快取帶來的問題#
| 問題 | 說明 |
|---|---|
| 資料一致性 | 快取與資料庫資料可能不同步 |
| 快取穿透 | 查詢不存在的資料,請求穿透到資料庫 |
| 快取擊穿 | 熱點資料過期瞬間,大量請求直達資料庫 |
| 快取雪崩 | 大量快取同時失效,資料庫壓力驟增 |
前端快取#
瀏覽器本地快取#
協商快取(304)#
基於 ETag 或 Last-Modified 實現:
sequenceDiagram
participant B as 瀏覽器
participant S as 伺服器
rect rgb(230, 245, 255)
Note over B,S: 第一次請求
B->>S: GET /style.css
S-->>B: 200 OK<br/>ETag: "abc123"
Note over B: 儲存資源與 ETag
end
rect rgb(230, 255, 230)
Note over B,S: 第二次請求
B->>S: GET /style.css<br/>If-None-Match: "abc123"
S-->>B: 304 Not Modified
Note over B: 使用本地快取
end強快取#
基於 Cache-Control 或 Expires 實現:
Cache-Control: max-age=3600 # 相對時間(推薦)
Expires: Wed, 21 Oct 2026 07:28:00 GMT # 絕對時間優先使用
Cache-Control,它是相對時間,不會因為用戶端時間錯誤而失效。
CDN 快取#
CDN 透過就近節點回傳靜態資源:
用戶請求 → 最近的 CDN 節點 → 命中則回傳
↓
未命中
↓
回源到原站本地快取(程序快取)#
適用場景#
- 資料量小
- 更新頻率低
- 不要求強一致性
實現方式#
1. 靜態變數#
// 簡單常數
public static final String API_URL = "https://api.example.com";
// 容器快取
private static final Map<String, Object> CACHE =
new ConcurrentHashMap<>();2. Guava Cache#
// 建立快取
Cache<String, User> cache = CacheBuilder.newBuilder()
.maximumSize(1000) // 最大條目數
.expireAfterWrite(10, TimeUnit.MINUTES) // 寫入後過期
.expireAfterAccess(5, TimeUnit.MINUTES) // 訪問後過期
.build();
// 使用快取
User user = cache.get(userId, () -> {
return userDao.findById(userId); // 快取未命中時載入
});3. Caffeine(推薦)#
比 Guava Cache 效能更好:
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats() // 開啟統計
.build();Ehcache#
支援磁盤持久化和分佈式:
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
.withCache("userCache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class, User.class,
ResourcePoolsBuilder.heap(100) // 堆記憶體 100 條
.offheap(10, MemoryUnit.MB) // 堆外記憶體 10MB
.disk(100, MemoryUnit.MB, true) // 磁盤 100MB
))
.build(true);Ehcache 分佈式模式下的一致性保證有限,不建議用於強一致性場景。
分散式快取(Redis)#
Redis 優勢#
- 純記憶體操作,讀取速度 > 10 萬次/秒
- 單線程串行,無鎖競爭
- 豐富的資料結構
- 支援持久化和集群
常用資料結構#
| 結構 | 應用場景 |
|---|---|
| String | 簡單 KV 快取、計數器 |
| Hash | 物件快取(避免序列化) |
| List | 訊息佇列、最新列表 |
| Set | 去重、交集運算 |
| ZSet | 排行榜、延時佇列 |
基本操作#
// String
redisTemplate.opsForValue().set("user:1", user, 30, TimeUnit.MINUTES);
User user = redisTemplate.opsForValue().get("user:1");
// Hash
redisTemplate.opsForHash().put("user:1", "name", "John");
redisTemplate.opsForHash().put("user:1", "age", "25");
// 設置過期時間
redisTemplate.expire("user:1", 30, TimeUnit.MINUTES);快取淘汰策略#
LRU (Least Recently Used)#
淘汰最近最少使用的資料:
stateDiagram-v2
direction LR
state "容量: 3" as cap
[*] --> A訪問: A
A訪問 --> B訪問: B
B訪問 --> C訪問: C
C訪問 --> D訪問: D (淘汰 A)
D訪問 --> A再訪問: A (移到最後)
A再訪問 --> E訪問: E (淘汰 C)
state A訪問 {
[A]
}
state B訪問 {
[A, B]
}
state C訪問 {
[A, B, C]
}
state D訪問 {
[B, C, D]
}
state A再訪問 {
[C, D, A]
}
state E訪問 {
[D, A, E]
}| 操作 | 快取狀態 | 說明 |
|---|---|---|
| A 訪問 | [A] | 加入快取 |
| B 訪問 | [A, B] | 加入快取 |
| C 訪問 | [A, B, C] | 快取已滿 |
| D 訪問 | [B, C, D] | A 被淘汰 |
| A 訪問 | [C, D, A] | A 重新訪問,移到最後 |
| E 訪問 | [D, A, E] | C 被淘汰(最久未使用) |
LFU (Least Frequently Used)#
淘汰訪問頻率最低的資料:
訪問次數:
A: 5次
B: 2次
C: 1次 ← 優先淘汰TTL (Time To Live)#
設置過期時間,到期自動刪除:
// Redis TTL
SET key value EX 3600 # 3600 秒後過期
// Guava Cache
.expireAfterWrite(1, TimeUnit.HOURS)快取一致性問題#
問題場景#
線程A: 刪除快取 → 刪除資料庫(未完成)
線程B: 查詢快取(未命中)→ 查詢資料庫(舊資料)→ 寫入快取
線程A: 資料庫刪除完成
結果: 快取中是舊資料解決方案#
方案一:延遲雙刪#
public void update(Entity entity) {
// 1. 刪除快取
cache.delete(entity.getId());
// 2. 更新資料庫
db.update(entity);
// 3. 延遲再刪一次快取
executor.schedule(() -> {
cache.delete(entity.getId());
}, 500, TimeUnit.MILLISECONDS);
}方案二:訂閱 binlog#
資料庫更新 → binlog → Canal → 更新/刪除快取如果對一致性要求非常高,不建議使用快取。快取更適合讀多寫少、允許短暫不一致的場景。
快取穿透、擊穿、雪崩#
快取穿透#
問題:查詢不存在的資料,每次都打到資料庫
解決方案:
// 方案1:快取空值
public User getUser(String id) {
User user = cache.get(id);
if (user == null) {
user = db.findById(id);
if (user == null) {
// 快取空值,設置較短過期時間
cache.set(id, EMPTY_USER, 5, TimeUnit.MINUTES);
} else {
cache.set(id, user, 30, TimeUnit.MINUTES);
}
}
return user == EMPTY_USER ? null : user;
}
// 方案2:布隆過濾器
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 預期元素數量
0.01 // 誤判率
);
public User getUser(String id) {
// 先檢查布隆過濾器
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在
}
// 可能存在,查詢快取和資料庫
return doGetUser(id);
}快取擊穿#
問題:熱點資料過期瞬間,大量請求打到資料庫
解決方案:
// 使用互斥鎖
public User getUser(String id) {
User user = cache.get(id);
if (user == null) {
// 嘗試獲取鎖
if (lock.tryLock(id)) {
try {
// 雙重檢查
user = cache.get(id);
if (user == null) {
user = db.findById(id);
cache.set(id, user);
}
} finally {
lock.unlock(id);
}
} else {
// 等待後重試
Thread.sleep(50);
return getUser(id);
}
}
return user;
}快取雪崩#
問題:大量快取同時過期或快取服務宕機
解決方案:
// 1. 過期時間加隨機值
int ttl = 3600 + random.nextInt(300); // 基礎1小時 + 0~5分鐘隨機
// 2. 多級快取
public User getUser(String id) {
// L1: 本地快取
User user = localCache.get(id);
if (user != null) return user;
// L2: Redis
user = redis.get(id);
if (user != null) {
localCache.put(id, user);
return user;
}
// L3: 資料庫
user = db.findById(id);
redis.set(id, user);
localCache.put(id, user);
return user;
}
// 3. 快取集群(Redis Cluster)快取設計最佳實踐#
設計原則#
| 原則 | 說明 |
|---|---|
| 只快取熱資料 | 冷資料不值得佔用記憶體 |
| 合理設置 TTL | 太短導致命中率低,太長導致一致性差 |
| 做好降級 | 快取不可用時,系統仍能執行 |
| 監控命中率 | 命中率低說明快取策略有問題 |
快取 Key 設計#
// 命名規範:業務:類型:ID
String key = "user:profile:12345";
String key = "order:detail:202401010001";
String key = "product:stock:SKU123";防止大 Key#
// 不推薦:存儲大列表
redis.set("user:all", allUsers); // 可能有數萬條
// 推薦:分頁存儲
redis.set("user:page:1", users1to100);
redis.set("user:page:2", users101to200);總結#
| 快取類型 | 適用場景 | 注意事項 |
|---|---|---|
| 瀏覽器快取 | 靜態資源 | 配合版本號更新 |
| CDN | 靜態資源、視頻 | 注意回源策略 |
| 本地快取 | 小量、低頻更新資料 | 分佈式環境不同步 |
| Redis | 大量、高頻訪問資料 | 處理好一致性問題 |
快取設計檢查清單
- 是否真的需要快取?
- 快取粒度是否合適?
- TTL 設置是否合理?
- 是否考慮了穿透/擊穿/雪崩?
- 是否有快取預熱機制?
- 是否有降級方案?
- 是否監控命中率?