設計 LRU/LFU Cache — 雜湊表與雙向鏈結串列的 O(1) 快取實作 | 資料結構與演算法
Cache(快取) 是系統設計中最關鍵的效能優化手段之一。當快取容量有限時,需要 淘汰策略(Eviction Policy) 決定哪些資料該被移除。本文將深入實作兩種最經典的淘汰策略—— LRU(Least Recently Used) 與 LFU(Least Frequently Used) Cache,從需求分析、方案設計到完整的 JavaScript/TypeScript 與 C++ 雙語言實作,並涵蓋 分散式快取架構 與生產環境的最佳實踐,帶你徹底掌握 O(1) 快取的設計與實戰。
前言
在上一篇文章中,我們實作了
Rate Limiter(限流器),學會如何用資料結構保護後端服務免受流量衝擊。今天我們要解決系統設計中另一個核心問題——如何讓系統跑得更快。
答案是 Cache(快取)。
快取的核心思想很簡單:將計算代價高昂或延遲高的資料,儲存在一個存取速度更快的媒介中。記憶體的讀取速度大約是磁碟的 100,000 倍,是網路請求的 1,000 倍。一個設計良好的快取層可以將系統回應時間從 100ms 降到 1ms 以內。
但問題是——記憶體是有限的。當快取已滿,新的資料要進來時,必須決定淘汰誰。這個決策機制就是 淘汰策略(Eviction Policy),而 LRU 和 LFU 是最經典的兩種策略。更重要的是,它們都要求 get 和 put 操作在 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)
- 時間複雜度:
get與put均為 O(1) - 記憶體可控:固定容量上限,記憶體使用可預測
- 執行緒安全(生產環境):支援並行存取
- TTL 支援(生產環境):支援設定過期時間
- 監控指標:提供命中率(Hit Rate)等統計資訊
規模估算
假設為一個高流量 Web 服務設計應用層 Cache:
| 指標 | 數值 | 說明 |
|---|---|---|
| 應用 QPS | 50,000 | 每秒請求數 |
| 快取命中目標 | 80% | 80% 請求從快取回應 |
| 平均資料大小 | 1 KB | 每個快取條目 |
| 容量上限 | 10,000 條 | 約 10 MB 記憶體 |
| TTL | 5 分鐘 | 預設過期時間 |
| 回源 QPS(Cache Miss) | 10,000 | 20% × 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) 時間內完成三件事:
- 查找 任意 key 的 value
- 移動 任意節點到「最近使用」的位置
- 找到並移除 「最久未使用」的節點
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 淘汰策略比較
| 特性 | FIFO | LRU | LFU | LRU-K | ARC | W-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 }
程式碼關鍵要點:
- 哨兵節點:
head和tail是不儲存資料的 dummy 節點,讓所有插入/刪除操作無需邊界判斷 - Map 存 Node 引用:
Map<K, LRUNode>讓我們能在 O(1) 找到任意節點 - 雙向指標:
prev和next讓我們能在 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 最容易出錯的兩個地方:
minFreq的維護:新增節點時必須重置為 1,頻率提升時若舊桶空了且是minFreq才 +1- 同頻率的 LRU 順序:同一個
FreqList內,尾部是最近使用的,頭部是最久未用的。淘汰時取頭部
3.3 LRU Cache — C++ 實作
C++ 的優勢在於 std::list 的 splice() 方法可以在 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 Cache | LFU 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 每次操作需要維護 freqMap 和 minFreq,比 LRU 多幾個指標操作。
命中率比較
不同存取模式下的命中率表現(假設快取容量為 1,000,資料集大小 10,000):
| 存取模式 | LRU | LFU | 說明 |
|---|---|---|---|
| 時間局部性(Temporal Locality) | 85% | 70% | 最近訪問的資料更可能再被訪問 |
| 頻率局部性(Frequency Locality) | 65% | 90% | 少數熱點資料被高頻訪問 |
| 均勻隨機存取 | 10% | 10% | 無論哪種策略都無法有效快取 |
| 掃描後恢復 | 60% | 85% | 掃描攻擊後 LFU 恢復更快 |
記憶體與吞吐量
| 指標 | LRU | LFU |
|---|---|---|
| 每節點記憶體開銷 | ~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. 總結
在這篇文章中,我們從需求分析出發,深入探討了快取系統的兩大核心淘汰策略:
LRU Cache:使用 HashMap + Doubly Linked List 實現 O(1) 的
get和put。HashMap 提供快速查找,DLL 維護使用順序,哨兵節點簡化邊界處理。適合大多數通用場景。LFU Cache:在 LRU 的基礎上增加 頻率桶(Frequency Buckets) 和
minFreq追蹤,能夠保留高頻訪問的熱點資料。適合存取模式存在明顯偏斜的場景。策略比較:FIFO 最簡單但效果最差;LRU 是通用首選;LFU 適合熱點場景但冷啟動慢;ARC 和 W-TinyLFU 是更先進的自適應策略。
生產環境:快取不只是一個資料結構,更是一個完整的系統。多層快取架構、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 的 prev 和 next 指標讓任意位置的摘除和插入都是 O(1)。如果用單向鏈結串列,刪除時無法直接找到前驅節點,仍需 O(n) 遍歷。使用哨兵節點(dummy head/tail)還能省去大量邊界條件判斷,讓程式碼更簡潔。
Q: 快取穿透、快取擊穿和快取雪崩有什麼區別?怎麼解決?
這三者都是分散式快取的常見問題。快取穿透:請求的 key 在 DB 中不存在,每次都穿透到 DB。解法是快取空值或使用布隆過濾器。快取擊穿:熱點 key 過期瞬間大量請求打到 DB。解法是用互斥鎖(Redis SETNX)限制只有一個請求查 DB。快取雪崩:大量 key 同時過期或 Redis 宕機。解法包括 TTL 加隨機偏移、多層快取架構、熔斷降級,以及 Redis 叢集高可用部署。