快取是提升系統效能的利器,但也帶來資料一致性等挑戰。本章從前端到後端,系統介紹各層快取技術。

快取的價值與代價#

為什麼需要快取#

%%{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)#

基於 ETagLast-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-ControlExpires 實現:

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 設置是否合理?
  • 是否考慮了穿透/擊穿/雪崩?
  • 是否有快取預熱機制?
  • 是否有降級方案?
  • 是否監控命中率?