設計 LRU/LFU Cache — 雜湊表與雙向鏈結串列的 O(1) 快取實作 | 資料結構與演算法

2026/07/24
設計 LRU/LFU Cache — 雜湊表與雙向鏈結串列的 O(1) 快取實作 | 資料結構與演算法

Cache(快取) 是系統設計中最關鍵的效能優化手段之一。當快取容量有限時,需要 淘汰策略(Eviction Policy) 決定哪些資料該被移除。本文將深入實作兩種最經典的淘汰策略—— LRU(Least Recently Used)LFU(Least Frequently Used) Cache,從需求分析、方案設計到完整的 JavaScript/TypeScriptC++ 雙語言實作,並涵蓋 分散式快取架構 與生產環境的最佳實踐,帶你徹底掌握 O(1) 快取的設計與實戰。

前言

在上一篇文章中,我們實作了

Rate Limiter(限流器)

,學會如何用資料結構保護後端服務免受流量衝擊。今天我們要解決系統設計中另一個核心問題——如何讓系統跑得更快

答案是 Cache(快取)

快取的核心思想很簡單:將計算代價高昂或延遲高的資料,儲存在一個存取速度更快的媒介中。記憶體的讀取速度大約是磁碟的 100,000 倍,是網路請求的 1,000 倍。一個設計良好的快取層可以將系統回應時間從 100ms 降到 1ms 以內

但問題是——記憶體是有限的。當快取已滿,新的資料要進來時,必須決定淘汰誰。這個決策機制就是 淘汰策略(Eviction Policy),而 LRULFU 是最經典的兩種策略。更重要的是,它們都要求 getput 操作在 O(1) 時間內完成——這正是面試中最常考的設計難題。

本文你將學到:

  • 為什麼快取是系統效能的關鍵,以及核心需求分析
  • LRU 與 LFU 的資料結構設計原理——HashMap + Doubly Linked List
  • 完整可運行的 JavaScript/TypeScript 與 C++ 雙語言實作
  • 與 FIFO、LRU-K、ARC、W-TinyLFU 等策略的比較
  • 分散式快取架構與三大快取問題(穿透、擊穿、雪崩)的解法
  • 5 道 LeetCode 經典快取題目解析

1. 需求分析

功能性需求(Functional Requirements)

  • 支援 get(key) 操作:若 key 存在於快取中,回傳其 value;否則回傳 -1
  • 支援 put(key, value) 操作:新增或更新 key-value pair
  • 當快取已達容量上限,在插入新資料前自動淘汰特定資料
  • LRU 策略淘汰 最久未被使用 的資料
  • LFU 策略淘汰 使用頻率最低 的資料(頻率相同時淘汰最久未用的)
  • 支援任意型別的 key/value

非功能性需求(Non-Functional Requirements)

  • 時間複雜度getput 均為 O(1)
  • 記憶體可控:固定容量上限,記憶體使用可預測
  • 執行緒安全(生產環境):支援並行存取
  • TTL 支援(生產環境):支援設定過期時間
  • 監控指標:提供命中率(Hit Rate)等統計資訊

規模估算

假設為一個高流量 Web 服務設計應用層 Cache:

指標數值說明
應用 QPS50,000每秒請求數
快取命中目標80%80% 請求從快取回應
平均資料大小1 KB每個快取條目
容量上限10,000 條約 10 MB 記憶體
TTL5 分鐘預設過期時間
回源 QPS(Cache Miss)10,00020% × 50,000

LRU 與 LFU 記憶體對比:

結構每個節點額外開銷10,000 條目
LRU(HashMap + DLL)key + value + 2 指標 ≈ +32 B資料 + 320 KB
LFU(2 個 HashMap + DLL)同 LRU + freq 欄位 ≈ +40 B資料 + 400 KB

兩者的記憶體差距不大,額外開銷相對於資料本身都很小。選擇哪種策略主要取決於資料的 存取模式,而非記憶體成本。


2. 方案設計

2.1 LRU — HashMap + Doubly Linked List

核心洞見: 我們需要一個資料結構,能在 O(1) 時間內完成三件事:

  1. 查找 任意 key 的 value
  2. 移動 任意節點到「最近使用」的位置
  3. 找到並移除 「最久未使用」的節點

HashMap 解決了第 1 點,但它無法維護使用順序。陣列可以記錄順序,但移動元素需要 O(n)。雙向鏈結串列(Doubly Linked List, DLL)配合 HashMap 才能同時滿足所有需求。

資料結構圖:

HashMap:
┌─────────────────────────────────┐
│ "a" ────────────────────┐       │
│ "b" ──────────┐         │       │
│ "c" ──┐       │         │       │
└───────┼───────┼─────────┼───────┘
        ↓       ↓         ↓
DLL: [HEAD] ⟺ [c|3] ⟺ [b|2] ⟺ [a|1] ⟺ [TAIL]
      (哨兵)   ←最久未用             最近使用→ (哨兵)

訪問 "c" 後:
DLL: [HEAD] ⟺ [b|2] ⟺ [a|1] ⟺ [c|3] ⟺ [TAIL]
              ←最久未用             最近使用→

操作流程:

get(key):
  1. HashMap 找到節點 → O(1)
  2. 將節點移到 DLL 尾部(更新前後指標)→ O(1)
  3. 回傳 value

put(key, value):
  若 key 已存在:
    1. HashMap 找到節點,更新 value → O(1)
    2. 移到 DLL 尾部 → O(1)
  若 key 不存在:
    1. 若容量已滿:
       a. 移除 DLL 頭部節點(最久未用)→ O(1)
       b. 從 HashMap 刪除對應 key → O(1)
    2. 建立新節點,插入 DLL 尾部 → O(1)
    3. HashMap 新增 key → 節點映射 → O(1)

為什麼用 哨兵節點(Sentinel Nodes)?因為沒有哨兵的話,每次操作都要判斷「是不是第一個節點」或「是不是最後一個節點」,邊界條件非常容易出錯。加上 dummy head 和 dummy tail 後,所有節點的插入和刪除邏輯完全一致,程式碼更簡潔也更不容易出 bug。


2.2 LFU — HashMap + Frequency Buckets

LFU 比 LRU 複雜,因為需要同時追蹤 使用頻率使用順序(同頻率內的先後順序)。核心思路是用多個 DLL,每個 DLL 對應一個頻率等級:

  • keyMap: key → Node(O(1) 存取任意節點)
  • freqMap: freq → DLL(每個頻率一個雙向鏈結串列)
  • minFreq: 追蹤當前最低頻率(用於 O(1) 找到淘汰對象)

資料結構圖:

keyMap:
  "a" → Node(key="a", val=1, freq=3)
  "b" → Node(key="b", val=2, freq=1)
  "c" → Node(key="c", val=3, freq=2)

freqMap:
  freq=1 → DLL: [HEAD] ⟺ ["b"] ⟺ [TAIL]
  freq=2 → DLL: [HEAD] ⟺ ["c"] ⟺ [TAIL]
  freq=3 → DLL: [HEAD] ⟺ ["a"] ⟺ [TAIL]

minFreq = 1

訪問 "b" 後(freq: 1→2):
  freq=1 → DLL: [HEAD] ⟺ [TAIL]  (空)
  freq=2 → DLL: [HEAD] ⟺ ["c"] ⟺ ["b"] ⟺ [TAIL]
  freq=3 → DLL: [HEAD] ⟺ ["a"] ⟺ [TAIL]
  minFreq = 2(因為 freq=1 的桶空了)

淘汰時:取 freqMap[minFreq] 的 DLL 頭部節點——這就是「最低頻率中最久未用的」,完美符合 LFU 的淘汰語義。

關鍵的 minFreq 維護邏輯

  • 新增節點時minFreq 重置為 1(新節點頻率一定是 1)
  • 節點頻率 +1 時:如果舊頻率的 DLL 變空了,且舊頻率等於 minFreq,則 minFreq++
  • 這兩條規則保證 minFreq 永遠正確,且維護成本為 O(1)

2.3 淘汰策略比較

特性FIFOLRULFULRU-KARCW-TinyLFU
核心思想先進先出淘汰最久未用淘汰最低頻率最近第 K 次存取自適應 LRU+LFU頻率過濾 + LRU
get/put 複雜度O(1)O(1)O(1)O(log n)O(1)O(1)
記憶體開銷
抗掃描攻擊極好
冷啟動速度
實作複雜度★☆☆★★☆★★★★★★★★★★★★★★

使用建議:

  • 面試場景 → 先實作 LRU,再口述 LFU 的改進思路
  • Redis 生產環境allkeys-lru(通用)或 allkeys-lfu(Redis 4.0+,熱點場景)
  • Java 應用層 → Caffeine(採用 W-TinyLFU,命中率最高)
  • CDN / 邊緣快取 → ARC(訪問模式多變時自適應效果最好)

什麼是 掃描攻擊(Scan Attack)?假設有人一次性掃描整個資料表,LRU 會把所有快取內容替換成這些只用一次的掃描資料,導致原本的熱點資料全部失效。LFU 因為考慮頻率,這些只訪問一次的掃描資料頻率很低,會被優先淘汰,因此抗掃描能力更強。


3. 核心實作

3.1 LRU Cache — JavaScript/TypeScript 實作

以下是完整可運行的 LRU Cache 實作,包含 TTL 支援與命中率統計:

/**
 * LRU Cache — HashMap + Doubly Linked List
 * get / put 均為 O(1)
 */

class LRUNode<K, V> {
  key: K;
  value: V;
  prev: LRUNode<K, V> | null = null;
  next: LRUNode<K, V> | null = null;
  expiresAt: number | null = null; // TTL 支援

  constructor(key: K, value: V, ttlMs?: number) {
    this.key = key;
    this.value = value;
    if (ttlMs !== undefined) {
      this.expiresAt = Date.now() + ttlMs;
    }
  }

  isExpired(): boolean {
    return this.expiresAt !== null && Date.now() > this.expiresAt;
  }
}

class LRUCache<K = string, V = unknown> {
  private capacity: number;
  private map: Map<K, LRUNode<K, V>> = new Map();

  // 哨兵節點(Sentinel Nodes)避免邊界判斷
  private head: LRUNode<K, V>; // dummy head(最久未用端)
  private tail: LRUNode<K, V>; // dummy tail(最近使用端)

  // 統計資訊
  private hits: number = 0;
  private misses: number = 0;

  constructor(capacity: number) {
    if (capacity <= 0) throw new Error("Capacity must be positive");
    this.capacity = capacity;

    // 初始化哨兵節點,head ⟺ tail
    this.head = new LRUNode<K, V>(null as K, null as V);
    this.tail = new LRUNode<K, V>(null as K, null as V);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  /**
   * 將節點從 DLL 中摘除(不從 Map 刪除)
   * 只需修改前後節點的指標 → O(1)
   */
  private removeNode(node: LRUNode<K, V>): void {
    node.prev!.next = node.next;
    node.next!.prev = node.prev;
  }

  /**
   * 將節點插入到 tail 前方(最近使用位置)
   */
  private insertToTail(node: LRUNode<K, V>): void {
    node.prev = this.tail.prev;
    node.next = this.tail;
    this.tail.prev!.next = node;
    this.tail.prev = node;
  }

  /**
   * 移動節點到最近使用位置 = 摘除 + 插入尾部
   */
  private moveToTail(node: LRUNode<K, V>): void {
    this.removeNode(node);
    this.insertToTail(node);
  }

  /**
   * 淘汰最久未使用的節點(head 的下一個)
   */
  private evictLRU(): void {
    const lruNode = this.head.next!;
    if (lruNode === this.tail) return; // 空快取

    this.removeNode(lruNode);
    this.map.delete(lruNode.key);
  }

  get(key: K): V | -1 {
    const node = this.map.get(key);

    if (!node) {
      this.misses++;
      return -1;
    }

    // TTL 過期處理
    if (node.isExpired()) {
      this.removeNode(node);
      this.map.delete(key);
      this.misses++;
      return -1;
    }

    this.hits++;
    this.moveToTail(node); // 標記為最近使用
    return node.value;
  }

  put(key: K, value: V, ttlMs?: number): void {
    if (this.map.has(key)) {
      // 已存在:更新 value,移到尾部
      const node = this.map.get(key)!;
      node.value = value;
      if (ttlMs !== undefined) {
        node.expiresAt = Date.now() + ttlMs;
      }
      this.moveToTail(node);
    } else {
      // 新增:若滿則先淘汰
      if (this.map.size >= this.capacity) {
        this.evictLRU();
      }
      const newNode = new LRUNode(key, value, ttlMs);
      this.map.set(key, newNode);
      this.insertToTail(newNode);
    }
  }

  delete(key: K): boolean {
    const node = this.map.get(key);
    if (!node) return false;
    this.removeNode(node);
    this.map.delete(key);
    return true;
  }

  get size(): number {
    return this.map.size;
  }

  getHitRate(): number {
    const total = this.hits + this.misses;
    return total === 0 ? 0 : this.hits / total;
  }

  getStats() {
    return {
      hits: this.hits,
      misses: this.misses,
      hitRate: `${(this.getHitRate() * 100).toFixed(2)}%`,
      size: this.map.size,
      capacity: this.capacity,
    };
  }

  /**
   * 回傳當前快取內容(從最久未用到最近使用)
   */
  toArray(): Array<{ key: K; value: V }> {
    const result: Array<{ key: K; value: V }> = [];
    let curr = this.head.next;
    while (curr !== this.tail) {
      result.push({ key: curr!.key, value: curr!.value });
      curr = curr!.next;
    }
    return result;
  }
}

// === 測試 ===
const lru = new LRUCache<number, number>(3);
lru.put(1, 10);
lru.put(2, 20);
lru.put(3, 30);
console.log("初始狀態:", lru.toArray());
// 輸出:[{key:1, value:10}, {key:2, value:20}, {key:3, value:30}]

lru.get(1); // 訪問 key=1,移到尾部
console.log("get(1) 後:", lru.toArray());
// 輸出:[{key:2, value:20}, {key:3, value:30}, {key:1, value:10}]

lru.put(4, 40); // 容量已滿,淘汰最久未用的 key=2
console.log("put(4) 後:", lru.toArray());
// 輸出:[{key:3, value:30}, {key:1, value:10}, {key:4, value:40}]

console.log("get(2):", lru.get(2)); // 輸出:-1(已被淘汰)
console.log("get(3):", lru.get(3)); // 輸出:30
console.log("統計:", lru.getStats());
// 輸出:{ hits: 3, misses: 1, hitRate: '75.00%', size: 3, capacity: 3 }

程式碼關鍵要點:

  • 哨兵節點headtail 是不儲存資料的 dummy 節點,讓所有插入/刪除操作無需邊界判斷
  • Map 存 Node 引用Map<K, LRUNode> 讓我們能在 O(1) 找到任意節點
  • 雙向指標prevnext 讓我們能在 O(1) 從鏈結串列中摘除任意節點
  • TTL 支援:生產環境中快取通常需要過期機制,這裡以 lazy eviction 方式在 get 時檢查

3.2 LFU Cache — JavaScript/TypeScript 實作

LFU 的複雜度在於維護 頻率桶(Frequency Buckets)minFreq 追蹤:

/**
 * LFU Cache — keyMap + freqMap + minFreq
 * get / put 均為 O(1)
 *
 * 資料結構:
 *   keyMap:  key → Node(快速存取節點)
 *   freqMap: freq → DoublyLinkedList(每個頻率一個鏈結串列)
 *   minFreq: 當前最低頻率(用於快速找淘汰對象)
 */

class LFUNode<K, V> {
  key: K;
  value: V;
  freq: number = 1;
  prev: LFUNode<K, V> | null = null;
  next: LFUNode<K, V> | null = null;

  constructor(key: K, value: V) {
    this.key = key;
    this.value = value;
  }
}

class FreqList<K, V> {
  head: LFUNode<K, V>; // dummy head(最久未用端)
  tail: LFUNode<K, V>; // dummy tail(最近使用端)
  size: number = 0;

  constructor() {
    this.head = new LFUNode<K, V>(null as K, null as V);
    this.tail = new LFUNode<K, V>(null as K, null as V);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  /** 插入到尾部(最近使用位置) */
  insertToTail(node: LFUNode<K, V>): void {
    node.prev = this.tail.prev;
    node.next = this.tail;
    this.tail.prev!.next = node;
    this.tail.prev = node;
    this.size++;
  }

  /** 移除指定節點 */
  removeNode(node: LFUNode<K, V>): void {
    node.prev!.next = node.next;
    node.next!.prev = node.prev;
    this.size--;
  }

  /** 移除並回傳頭部節點(最低頻率中最久未用的) */
  removeLRU(): LFUNode<K, V> | null {
    if (this.size === 0) return null;
    const lru = this.head.next!;
    this.removeNode(lru);
    return lru;
  }

  isEmpty(): boolean {
    return this.size === 0;
  }
}

class LFUCache<K = string, V = unknown> {
  private capacity: number;
  private currentSize: number = 0;
  private minFreq: number = 0;

  private keyMap: Map<K, LFUNode<K, V>> = new Map();
  private freqMap: Map<number, FreqList<K, V>> = new Map();

  constructor(capacity: number) {
    this.capacity = capacity;
  }

  private getOrCreateFreqList(freq: number): FreqList<K, V> {
    if (!this.freqMap.has(freq)) {
      this.freqMap.set(freq, new FreqList<K, V>());
    }
    return this.freqMap.get(freq)!;
  }

  /**
   * 將節點頻率 +1,並移到對應頻率桶的尾部
   * 這是 LFU 最核心的操作
   */
  private incrementFreq(node: LFUNode<K, V>): void {
    const oldFreq = node.freq;
    const oldList = this.freqMap.get(oldFreq)!;
    oldList.removeNode(node);

    // 若舊頻率桶空了且是最低頻率,更新 minFreq
    if (oldList.isEmpty() && oldFreq === this.minFreq) {
      this.minFreq++;
    }

    node.freq++;
    const newList = this.getOrCreateFreqList(node.freq);
    newList.insertToTail(node);
  }

  get(key: K): V | -1 {
    const node = this.keyMap.get(key);
    if (!node) return -1;

    this.incrementFreq(node); // 增加頻率
    return node.value;
  }

  put(key: K, value: V): void {
    if (this.capacity <= 0) return;

    if (this.keyMap.has(key)) {
      // 已存在:更新 value,增加頻率
      const node = this.keyMap.get(key)!;
      node.value = value;
      this.incrementFreq(node);
    } else {
      // 新增
      if (this.currentSize >= this.capacity) {
        // 淘汰:最低頻率中最久未用的節點
        const minFreqList = this.freqMap.get(this.minFreq)!;
        const evicted = minFreqList.removeLRU();
        if (evicted) {
          this.keyMap.delete(evicted.key);
          this.currentSize--;
        }
      }

      const newNode = new LFUNode(key, value);
      this.keyMap.set(key, newNode);

      // 新節點頻率為 1,minFreq 一定重置為 1
      this.minFreq = 1;
      const freqList = this.getOrCreateFreqList(1);
      freqList.insertToTail(newNode);
      this.currentSize++;
    }
  }

  get size(): number {
    return this.currentSize;
  }
}

// === 測試 ===
const lfu = new LFUCache<number, number>(2);
lfu.put(1, 10);
lfu.put(2, 20);
console.log("get(1):", lfu.get(1));  // 輸出:10(key=1 freq 變為 2)
lfu.put(3, 30);                      // 容量已滿,淘汰 freq 最低的 key=2(freq=1)
console.log("get(2):", lfu.get(2));  // 輸出:-1(已被淘汰)
console.log("get(3):", lfu.get(3));  // 輸出:30(key=3 freq 變為 2)
lfu.put(4, 40);                      // key=1 freq=2, key=3 freq=2 → 淘汰較久未用的 key=1
console.log("get(1):", lfu.get(1));  // 輸出:-1(已被淘汰)
console.log("get(3):", lfu.get(3));  // 輸出:30
console.log("get(4):", lfu.get(4));  // 輸出:40

LFU 最容易出錯的兩個地方:

  1. minFreq 的維護:新增節點時必須重置為 1,頻率提升時若舊桶空了且是 minFreq 才 +1
  2. 同頻率的 LRU 順序:同一個 FreqList 內,尾部是最近使用的,頭部是最久未用的。淘汰時取頭部

3.3 LRU Cache — C++ 實作

C++ 的優勢在於 std::listsplice() 方法可以在 O(1) 時間內將節點從一個位置移到另一個位置,而且不會使 iterator 失效:

/**
 * LRU Cache — C++ 實作
 * 使用 std::list 作為 DLL + std::unordered_map 儲存 key → iterator
 * 關鍵:splice() 在 O(1) 內移動節點而不使 iterator 失效
 */

#include <unordered_map>
#include <list>
#include <optional>
#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <atomic>
#include <vector>
#include <string>

using namespace std;

template<typename K, typename V>
class LRUCache {
private:
    int capacity_;

    // list 儲存 {key, value},front = 最近使用,back = 最久未用
    list<pair<K, V>> dll_;

    // key → list iterator,有了 iterator 就能 O(1) 操作節點
    unordered_map<K, typename list<pair<K, V>>::iterator> map_;

    mutable shared_mutex mutex_; // 讀寫鎖(允許並行讀取)

    // 統計
    mutable atomic<int64_t> hits_{0};
    mutable atomic<int64_t> misses_{0};

public:
    explicit LRUCache(int capacity) : capacity_(capacity) {
        if (capacity <= 0) {
            throw invalid_argument("Capacity must be positive");
        }
    }

    /** O(1) 查詢 */
    optional<V> get(const K& key) {
        unique_lock<shared_mutex> lock(mutex_);

        auto it = map_.find(key);
        if (it == map_.end()) {
            misses_.fetch_add(1);
            return nullopt;
        }

        hits_.fetch_add(1);

        // splice:O(1) 將節點移到 front(最近使用位置)
        // 關鍵:splice 不會使 iterator 失效
        dll_.splice(dll_.begin(), dll_, it->second);

        return it->second->second;
    }

    /** O(1) 插入或更新 */
    void put(const K& key, const V& value) {
        unique_lock<shared_mutex> lock(mutex_);

        auto it = map_.find(key);
        if (it != map_.end()) {
            // 已存在:更新 value,移到 front
            it->second->second = value;
            dll_.splice(dll_.begin(), dll_, it->second);
        } else {
            // 新增
            if (static_cast<int>(map_.size()) >= capacity_) {
                // 淘汰 back(最久未用)
                const K& evict_key = dll_.back().first;
                map_.erase(evict_key);
                dll_.pop_back();
            }
            dll_.emplace_front(key, value);
            map_[key] = dll_.begin();
        }
    }

    bool remove(const K& key) {
        unique_lock<shared_mutex> lock(mutex_);
        auto it = map_.find(key);
        if (it == map_.end()) return false;
        dll_.erase(it->second);
        map_.erase(it);
        return true;
    }

    int size() const {
        shared_lock<shared_mutex> lock(mutex_);
        return static_cast<int>(map_.size());
    }

    double hit_rate() const {
        int64_t h = hits_.load();
        int64_t m = misses_.load();
        int64_t total = h + m;
        return total == 0 ? 0.0 : static_cast<double>(h) / total;
    }

    vector<pair<K, V>> to_vector() const {
        shared_lock<shared_mutex> lock(mutex_);
        return vector<pair<K, V>>(dll_.begin(), dll_.end());
    }
};

3.4 LFU Cache — C++ 實作

/**
 * LFU Cache — C++ 實作
 * key_map: key → list iterator
 * freq_map: freq → list<Node>
 * min_freq: 追蹤最低頻率
 */

template<typename K, typename V>
class LFUCache {
private:
    struct Node {
        K key;
        V value;
        int freq;
        Node(K k, V v, int f = 1) : key(k), value(v), freq(f) {}
    };

    int capacity_;
    int min_freq_ = 0;

    // key → list iterator(在 freq_map[freq] 的 list 中)
    unordered_map<K, typename list<Node>::iterator> key_map_;

    // freq → DLL(front = 最近使用,back = 最久未用)
    unordered_map<int, list<Node>> freq_map_;

    mutex mutex_;

    void increment_freq(typename list<Node>::iterator& it) {
        int old_freq = it->freq;
        auto& old_list = freq_map_[old_freq];

        // 暫存 node 資料,從舊頻率桶移除
        Node node = *it;
        node.freq++;
        old_list.erase(it);

        // 若舊頻率桶空了且為 min_freq,更新
        if (old_list.empty() && old_freq == min_freq_) {
            min_freq_++;
        }

        // 插入新頻率桶的 front(最近使用)
        freq_map_[node.freq].emplace_front(node);
        key_map_[node.key] = freq_map_[node.freq].begin();
    }

public:
    explicit LFUCache(int capacity) : capacity_(capacity) {}

    optional<V> get(const K& key) {
        lock_guard<mutex> lock(mutex_);

        auto it = key_map_.find(key);
        if (it == key_map_.end()) return nullopt;

        auto list_it = it->second;
        V value = list_it->value;
        increment_freq(list_it);

        return value;
    }

    void put(const K& key, const V& value) {
        if (capacity_ <= 0) return;

        lock_guard<mutex> lock(mutex_);

        auto it = key_map_.find(key);
        if (it != key_map_.end()) {
            // 更新 value,增加頻率
            it->second->value = value;
            increment_freq(it->second);
        } else {
            // 新增:若滿則淘汰
            if (static_cast<int>(key_map_.size()) >= capacity_) {
                auto& min_list = freq_map_[min_freq_];
                const K& evict_key = min_list.back().key;
                key_map_.erase(evict_key);
                min_list.pop_back();
            }

            freq_map_[1].emplace_front(key, value, 1);
            key_map_[key] = freq_map_[1].begin();
            min_freq_ = 1;
        }
    }

    int size() const {
        return static_cast<int>(key_map_.size());
    }
};

// === 測試 ===
int main() {
    cout << "=== LRU Cache (C++) ===" << endl;

    LRUCache<int, int> lru(3);
    lru.put(1, 10);
    lru.put(2, 20);
    lru.put(3, 30);

    auto state = lru.to_vector();
    cout << "狀態 (最近→最久): ";
    for (auto& [k, v] : state) cout << k << ":" << v << " ";
    cout << endl; // 輸出:3:30 2:20 1:10

    lru.get(1); // 移到 front
    lru.put(4, 40); // 淘汰最久未用(key=2)

    state = lru.to_vector();
    cout << "get(1)+put(4) 後: ";
    for (auto& [k, v] : state) cout << k << ":" << v << " ";
    cout << endl; // 輸出:4:40 1:10 3:30

    auto v2 = lru.get(2);
    cout << "get(2): " << (v2.has_value() ? to_string(*v2) : "-1") << endl;
    // 輸出:-1(已被淘汰)

    cout << "命中率: " << lru.hit_rate() * 100 << "%" << endl;

    cout << "\n=== LFU Cache (C++) ===" << endl;

    LFUCache<int, int> lfu(2);
    lfu.put(1, 10);
    lfu.put(2, 20);

    auto r1 = lfu.get(1);
    cout << "get(1): " << (r1.has_value() ? to_string(*r1) : "-1") << endl;
    // 輸出:10

    lfu.put(3, 30); // 淘汰 freq 最低的 key=2
    auto r2 = lfu.get(2);
    cout << "get(2): " << (r2.has_value() ? to_string(*r2) : "-1") << endl;
    // 輸出:-1

    auto r3 = lfu.get(3);
    cout << "get(3): " << (r3.has_value() ? to_string(*r3) : "-1") << endl;
    // 輸出:30

    return 0;
}

C++ 實作要點:

  • std::list + splice():C++ STL 的 std::list 是雙向鏈結串列,splice() 可以在 O(1) 時間內移動節點,且不會使 iterator 失效。這比手動管理指標更安全
  • std::shared_mutex:讀寫鎖允許多個讀取者並行,只有寫入時互斥,提高並行效能
  • std::optional:比回傳 -1 更 type-safe,呼叫者必須顯式處理「值不存在」的情況

4. 效能分析

時間與空間複雜度

操作LRU CacheLFU Cache
get(key)O(1)O(1)
put(key, value)O(1)O(1)
delete(key)O(1)O(1)
空間複雜度O(capacity)O(capacity)

兩者的時間複雜度完全相同,都是 O(1)。差異在於 常數因子——LFU 每次操作需要維護 freqMapminFreq,比 LRU 多幾個指標操作。

命中率比較

不同存取模式下的命中率表現(假設快取容量為 1,000,資料集大小 10,000):

存取模式LRULFU說明
時間局部性(Temporal Locality)85%70%最近訪問的資料更可能再被訪問
頻率局部性(Frequency Locality)65%90%少數熱點資料被高頻訪問
均勻隨機存取10%10%無論哪種策略都無法有效快取
掃描後恢復60%85%掃描攻擊後 LFU 恢復更快

記憶體與吞吐量

指標LRULFU
每節點記憶體開銷~32 B(2 個指標 + key/value)~40 B(多一個 freq 欄位)
單執行緒吞吐量~10M ops/s~8M ops/s
實作程式碼行數~80 行~120 行

在實際生產環境中,快取的效能瓶頸通常不在資料結構本身,而在 網路延遲(分散式快取)和 序列化/反序列化 成本。


5. 生產環境考量

5.1 多層快取架構

生產環境中的快取通常是多層的,每一層的速度和容量不同:

Client Request
      ↓
┌─────────────────────────────────────────┐
│  L1: Browser Cache                      │
│  Cache-Control: max-age=3600            │
│  ETag / Last-Modified                   │
└────────────────────┬────────────────────┘
                     ↓ Miss
┌─────────────────────────────────────────┐
│  L2: CDN Cache (Cloudflare / Fastly)    │
│  Edge nodes worldwide                   │
│  命中率目標: 70-85%                     │
└────────────────────┬────────────────────┘
                     ↓ Miss
┌─────────────────────────────────────────┐
│  L3: Application Cache (In-Process)     │
│  LRU/LFU in memory (e.g., Caffeine)    │
│  容量: ~10,000 條目, TTL: 5min          │
└────────────────────┬────────────────────┘
                     ↓ Miss
┌─────────────────────────────────────────┐
│  L4: Distributed Cache (Redis Cluster)  │
│  多台伺服器共享快取                      │
│  容量: 10GB, TTL: 30min                 │
└────────────────────┬────────────────────┘
                     ↓ Miss
┌─────────────────────────────────────────┐
│  L5: Database / Origin                  │
│  PostgreSQL / MongoDB                   │
└─────────────────────────────────────────┘

每一層的 Cache Miss 才會往下查詢,層層過濾後真正打到資料庫的請求量極少。

5.2 Cache Aside Pattern(旁路快取)

業界最常用的快取模式:

讀取:                      寫入:
┌────────────────┐         ┌────────────────┐
│ 1. 讀 Cache    │         │ 1. 寫 DB       │
│    ↓ Hit       │         │    ↓           │
│ 2. 回傳資料    │         │ 2. 刪除 Cache  │
│    ↓ Miss      │         │   (不是更新)  │
│ 3. 讀 DB       │         └────────────────┘
│    ↓           │
│ 4. 寫入 Cache  │
│ 5. 回傳資料    │
└────────────────┘

為什麼寫入時要「刪除」而不是「更新」Cache? 因為如果兩個寫入請求並行到達,順序可能是:請求 A 寫 DB → 請求 B 寫 DB → 請求 B 更新 Cache → 請求 A 更新 Cache,導致 Cache 和 DB 資料不一致。刪除 Cache 更安全,下次讀取時會自動從 DB 載入最新資料。

5.3 三大快取問題

快取穿透(Cache Penetration)

問題: 請求的 key 在 DB 中根本不存在,每次都穿透快取直接打到 DB。

// 解法:Bloom Filter + 快取空值
async function getCachedData(key: string): Promise<Data | null> {
  // 1. Bloom Filter 快速過濾不存在的 key
  if (!bloomFilter.mightExist(key)) {
    return null; // 確定不存在,不查 DB
  }

  // 2. 查快取
  const cached = await redis.get(key);
  if (cached !== null) {
    return cached === "null" ? null : JSON.parse(cached);
  }

  // 3. 查 DB
  const data = await db.query(key);

  // 4. 寫回快取(包含空值,防止重複穿透)
  if (data === null) {
    await redis.setex(key, 30, "null"); // 快取空值 30 秒
  } else {
    await redis.setex(key, 300, JSON.stringify(data));
    bloomFilter.add(key);
  }

  return data;
}

快取擊穿(Cache Breakdown)

問題: 熱點 key 過期的瞬間,大量並行請求同時穿透到 DB。

// 解法:互斥鎖(Mutex Lock)
async function getWithLock(key: string): Promise<Data | null> {
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);

  // 嘗試獲取鎖(SETNX + 5 秒過期)
  const lockKey = `lock:${key}`;
  const locked = await redis.set(lockKey, "1", { NX: true, EX: 5 });

  if (!locked) {
    // 未獲鎖:等待 100ms 後重試讀快取
    await new Promise((r) => setTimeout(r, 100));
    return getWithLock(key);
  }

  try {
    // 雙重檢查(double-check)
    const doubleCheck = await redis.get(key);
    if (doubleCheck !== null) return JSON.parse(doubleCheck);

    // 查 DB 並寫回快取
    const data = await db.query(key);
    await redis.setex(key, 300, JSON.stringify(data));
    return data;
  } finally {
    await redis.del(lockKey); // 釋放鎖
  }
}

快取雪崩(Cache Avalanche)

問題: 大量 key 在同一時間過期,或 Redis 整體宕機,所有請求打到 DB。

// 解法:TTL 加隨機偏移,避免同時過期
function setWithJitter(
  key: string,
  value: string,
  baseTTL: number // 秒
): void {
  const jitter = Math.floor(Math.random() * 60); // 0~60 秒隨機偏移
  redis.setex(key, baseTTL + jitter, value);
}

其他防禦措施包括:多層快取(本地快取兜底)、熔斷降級(DB 壓力過大時回傳友好錯誤)、Redis 高可用部署(Sentinel 或 Cluster)。

5.4 Redis 淘汰策略配置

Redis 支援 8 種淘汰策略,實際就是在伺服器層面實作了我們上面討論的演算法:

策略說明適用場景
noeviction達上限時回傳錯誤不允許資料丟失
allkeys-lru對所有 key 使用 LRU通用首選
allkeys-lfu對所有 key 使用 LFU熱點訪問(Redis 4.0+)
allkeys-random隨機淘汰均勻訪問
volatile-lru只對有 TTL 的 key 用 LRU部分 key 有過期
volatile-lfu只對有 TTL 的 key 用 LFU部分 key 有過期
volatile-ttl優先淘汰 TTL 最短的重視時效性
volatile-random只對有 TTL 的 key 隨機淘汰特殊場景
# redis.conf 推薦配置
maxmemory 4gb
maxmemory-policy allkeys-lru
maxmemory-samples 10  # LRU/LFU 採樣數(越高越精確,預設 5)

值得注意的是,Redis 的 LRU 實作是 近似 LRU(Approximate LRU)——它不維護完整的鏈結串列,而是隨機取樣 maxmemory-samples 個 key,淘汰其中最久未用的。這種設計大幅降低了記憶體開銷,且在取樣數為 10 時已非常接近精確 LRU 的效果。

5.5 生產就緒的快取服務

以下是整合本地快取 + 分散式快取 + 防穿透/擊穿的完整服務:

/**
 * 生產就緒的多層快取服務
 * L1: 本地 LRU 快取(in-process)
 * L2: Redis 分散式快取
 * 防護:互斥鎖防擊穿 + TTL 隨機偏移防雪崩
 */
class CacheService<V> {
  private localCache: LRUCache<string, V>;
  private redis: any;
  private defaultTTL: number;

  constructor(options: {
    localCapacity: number;
    redisClient: any;
    defaultTTLSeconds: number;
  }) {
    this.localCache = new LRUCache<string, V>(options.localCapacity);
    this.redis = options.redisClient;
    this.defaultTTL = options.defaultTTLSeconds;
  }

  async get(
    key: string,
    loader: () => Promise<V | null>,
    ttl: number = this.defaultTTL
  ): Promise<V | null> {
    // L1: 本地快取
    const local = this.localCache.get(key);
    if (local !== -1) return local as V;

    // L2: Redis 快取
    const redisVal = await this.redis.get(key);
    if (redisVal !== null) {
      const parsed = JSON.parse(redisVal) as V;
      this.localCache.put(key, parsed, 60_000); // 本地 TTL 60 秒
      return parsed;
    }

    // 互斥鎖防擊穿
    const lockKey = `lock:${key}`;
    const lockAcquired = await this.redis.set(
      lockKey, "1", { NX: true, EX: 5 }
    );

    if (!lockAcquired) {
      await new Promise((resolve) => setTimeout(resolve, 100));
      return this.get(key, loader, ttl);
    }

    try {
      // Double-check
      const doubleCheck = await this.redis.get(key);
      if (doubleCheck !== null) {
        return JSON.parse(doubleCheck) as V;
      }

      // 回源載入
      const data = await loader();

      if (data === null) {
        await this.redis.setex(key, 30, "null"); // 防穿透
        return null;
      }

      // 寫入 Redis(加隨機 TTL 防雪崩)
      const jitter = Math.floor(Math.random() * 60);
      await this.redis.setex(key, ttl + jitter, JSON.stringify(data));

      // 回填本地快取
      this.localCache.put(key, data, 60_000);

      return data;
    } finally {
      await this.redis.del(lockKey);
    }
  }

  async invalidate(key: string): Promise<void> {
    this.localCache.delete(key);
    await this.redis.del(key);
  }
}

6. LeetCode 練習

以下是與快取設計相關的經典題目,建議按順序練習:

題目一:LeetCode 146 — LRU Cache

項目內容
難度Medium
連結LeetCode 146
核心HashMap + Doubly Linked List,O(1) get/put
提示就是本文 3.1 節的實作,面試最高頻題之一

題目二:LeetCode 460 — LFU Cache

項目內容
難度Hard
連結LeetCode 460
核心keyMap + freqMap + minFreq,O(1) get/put
提示重點在 minFreq 的維護邏輯,以及同頻率內的 LRU 順序

題目三:LeetCode 1146 — Snapshot Array

項目內容
難度Medium
連結LeetCode 1146
核心版本化資料結構設計,可結合快取思想
提示使用 HashMap + Binary Search 實作版本查找

題目四:LeetCode 588 — Design In-Memory File System

項目內容
難度Hard
連結LeetCode 588
核心Trie + HashMap 設計記憶體內資料結構
提示類似快取系統的設計思路——在記憶體中高效組織資料

題目五:LeetCode 362 — Design Hit Counter

項目內容
難度Medium
連結LeetCode 362
核心固定窗口 + 計數器,與快取 TTL 概念相關
提示使用環形陣列或 Queue 實作時間窗口

練習建議:先完成 146(LRU)和 460(LFU),這兩題是面試必考題。LRU 的核心在於理解 HashMap + DLL 的配合,LFU 的難點在於 minFreq 的維護。建議先在白板上畫出資料結構圖,再開始寫程式碼。


7. 總結

在這篇文章中,我們從需求分析出發,深入探討了快取系統的兩大核心淘汰策略:

  1. LRU Cache:使用 HashMap + Doubly Linked List 實現 O(1) 的 getput。HashMap 提供快速查找,DLL 維護使用順序,哨兵節點簡化邊界處理。適合大多數通用場景。

  2. LFU Cache:在 LRU 的基礎上增加 頻率桶(Frequency Buckets)minFreq 追蹤,能夠保留高頻訪問的熱點資料。適合存取模式存在明顯偏斜的場景。

  3. 策略比較:FIFO 最簡單但效果最差;LRU 是通用首選;LFU 適合熱點場景但冷啟動慢;ARC 和 W-TinyLFU 是更先進的自適應策略。

  4. 生產環境:快取不只是一個資料結構,更是一個完整的系統。多層快取架構、Cache Aside Pattern、防穿透/擊穿/雪崩策略,以及 Redis 的淘汰策略配置,都是實際工程中不可缺少的知識。

快取設計是系統設計面試中最高頻的主題之一。理解了 LRU 和 LFU 的核心原理後,你不僅能在面試中從容應對 LeetCode 146 和 460 題,還能在實際工作中設計出高效可靠的快取系統。

在下一篇文章中,我們將探討另一個經典的系統設計問題——

設計搜尋自動補全(Autocomplete)

,學習如何用 Trie 和排序機制實現即時的搜尋建議功能,敬請期待!


FAQ

Q: LRU 和 LFU 哪個命中率比較高?該怎麼選?

沒有絕對的優劣,取決於資料的存取模式。LRU 假設「最近被使用過的資料,未來再次被使用的機率較高」,適合大多數通用場景,優點是冷啟動快、實作簡單。LFU 假設「被使用次數越多的資料越重要」,適合存在明顯熱點資料的場景,命中率通常高於 LRU,但缺點是冷啟動慢且對流量模式變化的反應遲鈍。實務上建議先用 LRU 作為預設,若觀察到存在明顯熱點存取模式再切換為 LFU。Java 領域推薦使用 Caffeine 框架(W-TinyLFU 演算法),在各種存取模式下都能取得接近最優的命中率。

Q: 為什麼 LRU Cache 要用雙向鏈結串列而不是陣列?

核心原因是要同時達成 O(1) 查找O(1) 順序調整。陣列雖然支援索引存取,但將某個元素從中間移到最前面需要 O(n) 時間搬移後方元素。雙向鏈結串列配合 HashMap 則能同時滿足:HashMap 儲存 key → 節點 映射實現 O(1) 查找;DLL 的 prevnext 指標讓任意位置的摘除和插入都是 O(1)。如果用單向鏈結串列,刪除時無法直接找到前驅節點,仍需 O(n) 遍歷。使用哨兵節點(dummy head/tail)還能省去大量邊界條件判斷,讓程式碼更簡潔。

Q: 快取穿透、快取擊穿和快取雪崩有什麼區別?怎麼解決?

這三者都是分散式快取的常見問題。快取穿透:請求的 key 在 DB 中不存在,每次都穿透到 DB。解法是快取空值或使用布隆過濾器。快取擊穿:熱點 key 過期瞬間大量請求打到 DB。解法是用互斥鎖(Redis SETNX)限制只有一個請求查 DB。快取雪崩:大量 key 同時過期或 Redis 宕機。解法包括 TTL 加隨機偏移、多層快取架構、熔斷降級,以及 Redis 叢集高可用部署。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →