LRU và LFU Cache: Tự thiết kế In-Memory Cache bằng Doubly Linked List + Hash Map
Hướng dẫn thiết kế In-Memory Cache với LRU/LFU, Doubly Linked List và Hash Map: TypeScript implementation O(1), eviction policy, TTL, memory sizing và vận hành production.
ShanverseCập nhật 13 phút đọc— lượt xemĐang tải lượt xem
Caching
Algorithms
Vì sao một Hash Map chưa đủ?
Hash Map cho phép get, set, delete trung bình O(1), nhưng không biết phần tử nào nên bị đuổi khi cache đầy.
Nếu chỉ lưu timestamp:
Bình luận
thì lookup nhanh, nhưng để tìm item cũ nhất phải scan toàn bộ map:
O(n)
.
Doubly Linked List giải quyết phần “thứ tự”:
Head: item mới được dùng nhất.
Tail: item lâu không dùng nhất.
Remove một node bất kỳ: O(1) nhờ prev và next.
Move node lên đầu: O(1).
Hash Map giữ reference trực tiếp tới node, nên không cần duyệt list.
Mermaid
Loading diagram…
LRU Cache hoạt động thế nào?
LRU — Least Recently Used — giả định dữ liệu vừa được dùng có khả năng sớm được dùng lại.
get(key)
Tìm node trong Hash Map.
Nếu không có, trả cache miss.
Nếu có, chuyển node lên đầu list.
Trả value.
put(key, value)
Nếu key đã có, cập nhật value và chuyển node lên đầu.
Nếu key mới, tạo node ở đầu list.
Nếu vượt capacity, xóa node ngay trước tail.
Xóa luôn key tương ứng khỏi Hash Map.
TypeScript: Doubly Linked List + LRU Cache
Sentinel head/tail giúp tránh nhiều nhánh xử lý null.
Sử dụng:
Invariants của LRU
Sau mọi operation:
map.size bằng số node thật trong list.
Mỗi key trong map trỏ đúng một node trong list.
head.next là MRU.
tail.prev là LRU.
Không node nào xuất hiện hai lần.
Node bị evict phải bị xóa khỏi cả list và map.
Phần lớn bug đến từ việc chỉ cập nhật một trong hai cấu trúc.
LFU khác LRU ở đâu?
LFU — Least Frequently Used — đuổi item có số lần truy cập thấp nhất.
Ví dụ:
LFU sẽ đuổi B hoặc C. Để tie-break trong cùng frequency, ta dùng LRU: item cũ hơn trong bucket frequency bị đuổi trước.
Một min-heap có thể triển khai LFU với O(log n). Muốn O(1) trung bình, dùng:
Mỗi frequency có một Doubly Linked List riêng.
Mermaid
Loading diagram…
TypeScript: LFU Cache O(1)
get, put và promotion trung bình O(1). Hàm delete minh họa có thể cần scan frequency map khi xóa bucket nhỏ nhất trực tiếp; nếu API bắt buộc delete O(1), cần cấu trúc liên kết thêm giữa các frequency buckets hoặc định nghĩa lại invariant cập nhật minFrequency.
Walkthrough LFU
Nếu A và B cùng frequency, list trong bucket chọn item least recently used làm nạn nhân.
Complexity so sánh
O(1) của Hash Map là trung bình; collision và runtime behavior vẫn ảnh hưởng tail latency.
Chọn LRU hay LFU?
Chọn LRU khi
Dữ liệu gần đây có khả năng được dùng lại.
Workload thay đổi nhanh theo session/request.
Muốn implementation đơn giản và overhead thấp.
Cache page, API response, parsed config hoặc object ngắn hạn.
Chọn LFU khi
Popularity có phân phối ổn định/Zipfian.
Một tập hot key nhỏ được đọc lặp lại lâu dài.
Scan traffic có thể đẩy hot data ra khỏi LRU.
Chi phí metadata/phức tạp thêm là chấp nhận được.
Nhược điểm của LRU
Một scan tuần tự lớn có thể làm “ô nhiễm” cache và đuổi hot keys.
Nhược điểm của LFU
Item từng hot có frequency rất cao và có thể sống mãi dù workload đã đổi. Vì vậy production LFU cần aging/decay hoặc admission policy.
LFU có decay
Một cách đơn giản là giảm frequency định kỳ:
Nhưng scan toàn bộ cache để decay là O(n) và có thể tạo latency spike. Các hệ thống lớn thường dùng:
Lazy decay khi item được truy cập/sampled.
Probabilistic counter.
Epoch/window frequency.
TinyLFU/Count-Min Sketch cho admission.
Redis dùng LFU xấp xỉ với probabilistic counter và decay; Redis LRU cũng là approximate sampling, không giữ exact doubly linked list cho toàn bộ keyspace.
Giới hạn theo số item chưa đủ
capacity = 10_000 không nói được lượng RAM nếu value có kích thước từ 100 byte đến 10 MB.
Production cache nên có:
Khi put:
Trừ weight cũ nếu update.
Cộng weight mới.
Evict liên tục đến khi cả maxEntries và maxWeight đều đạt.
Reject item riêng lẻ lớn hơn toàn bộ budget.
JSON.stringify(value).length không phản ánh chính xác heap memory. Dùng nó chỉ như proxy được hiệu chỉnh bằng heap profiling.
Thêm TTL
Eviction và expiration là hai khái niệm khác nhau:
Eviction: đuổi vì cache chạm budget.
Expiration: item quá tuổi/freshness window.
Node có thể lưu:
Với in-process TTL, nên dùng monotonic clock như performance.now() để tránh wall clock nhảy.
Lazy expiration:
Trong get, nếu expired thì xóa node và trả miss. Lazy expiration đơn giản nhưng item hết hạn ít được đọc vẫn chiếm RAM. Muốn eager cleanup có thể dùng min-heap theo expiry hoặc timing wheel; tránh tạo một timer cho mỗi key ở quy mô lớn.
Cache-aside an toàn
Double-check trong singleFlight ngăn nhiều request miss cùng lúc gọi database — cache stampede.
Có thể thêm:
TTL jitter để key không hết hạn đồng loạt.
Negative caching ngắn cho “not found”.
Stale-while-revalidate.
Refresh-ahead cho hot key.
Deadline và retry budget khi loader lỗi.
In-memory cache trong nhiều process
Mỗi Node.js worker/pod có cache riêng:
Điều này phù hợp cho dữ liệu:
Có thể stale ngắn hạn.
Có TTL.
Có thể rebuild.
Không phải source of truth.
Nếu mọi node cần một giá trị nhất quán hoặc chia sẻ hit ratio, dùng Redis/Memcached hoặc thêm invalidation channel. In-memory cache không thay thế distributed cache.
CDC/event invalidation: consumer xóa/update local cache.
Write-through/write-behind: phức tạp hơn, cần consistency model rõ.
Event invalidation phải idempotent. Nếu event bị mất, TTL là safety net.
Async và concurrency
JavaScript trong một event loop không chạy hai đoạn synchronous cùng lúc, nhưng async workflow vẫn interleave:
Request A miss và chờ DB.
Request B miss và chờ DB.
Hai kết quả cùng ghi cache.
Data structure operations nên synchronous và ngắn; loader cần single-flight. Với Worker Threads/shared memory, phải có locking hoặc ownership rõ ràng — implementation trên không thread-safe.
Redis khác implementation này
Redis cung cấp policies như:
allkeys-lru, volatile-lru.
allkeys-lfu, volatile-lfu.
volatile-ttl, random và noeviction.
Redis dùng approximate sampling để giảm metadata/CPU, nên không kỳ vọng thứ tự eviction giống exact LRU/LFU trong code học thuật.
Cần phân biệt:
In-process cache: cực nhanh, riêng từng process, mất khi restart.
Redis: network hop, shared, có replication/persistence tùy cấu hình.
Hai lớp có thể cùng tồn tại: L1 in-process + L2 Redis.
Metrics cần có
Hit, miss và hit ratio.
Eviction count theo reason: capacity, weight, TTL, delete.
Entry count và estimated weight.
Loader latency/error.
Single-flight coalesced requests.
Stale served và refresh result.
p50/p95/p99 get/put latency.
Heap usage, GC pause và event-loop lag.
Top hot keys bằng sampling, không dùng raw key làm metric label.
Hit ratio cao không luôn đồng nghĩa tốt nếu cache trả dữ liệu stale hoặc giữ value quá lớn.
Test cases quan trọng
LRU
capacity = 0 không lưu gì.
Get hit di chuyển node lên MRU.
Update existing key không tăng size.
Eviction xóa cả list lẫn map.
Delete head, middle và tail.
Repeated get không tạo duplicate node.
LFU
Key mới có frequency 1.
Get tăng frequency đúng một lần.
Evict bucket có minFrequency nhỏ nhất.
Tie-break cùng frequency theo LRU.
Bucket rỗng bị xóa.
New insert reset minFrequency = 1.
Update value có được tính là access hay không phải được định nghĩa rõ.
Production
Item lớn hơn max weight.
TTL hết đúng lúc get.
Loader lỗi/timeout.
Stampede hàng nghìn request.
Invalidation tới sai thứ tự.
Workload scan và phase shift.
GC pressure khi eviction burst.
Anti-patterns
Dùng Map không giới hạn làm cache.
Xóa khỏi DLL nhưng quên xóa map.
Get hit nhưng không promote node.
Dùng singly linked list rồi phải scan predecessor.
Chỉ giới hạn item count, bỏ qua byte size.
Tạo timer cho từng key.
LFU không decay khiến item cũ sống mãi.
Cache promise lỗi vĩnh viễn.
Cache undefined nhưng dùng undefined làm miss sentinel.
Dùng local cache làm source of truth.
Không có TTL/invalidation.
Cache dữ liệu nhạy cảm không có isolation và cleanup.
Benchmark bằng uniform random workload rồi áp cho production Zipfian.
Production checklist
Chọn policy từ access pattern đo được.
Có hard limit theo entries và/hoặc estimated bytes.
LRU/LFU invariants có unit/property tests.
TTL dùng monotonic time và có cleanup strategy.
Loader có single-flight và timeout.
TTL có jitter nếu nhiều key được tạo cùng lúc.
Có invalidation sau database commit.
LFU có decay/admission policy.
Item quá lớn bị reject.
Cache miss/error không phá source of truth.
Multi-process consistency được chấp nhận hoặc có Redis/events.
Dashboard có hit ratio, eviction, weight, GC và p99.
Đã load-test bằng key/value distribution thật.
Kết luận
LRU = Hash Map + một Doubly Linked List, cho lookup, promotion và eviction O(1) trung bình.
LFU O(1) = Hash Map + nhiều Doubly Linked List theo frequency + minFrequency.
LRU phù hợp workload thiên về recency và thay đổi nhanh.
LFU phù hợp popularity ổn định nhưng cần decay để thích nghi.
Data structure chỉ là nền tảng. Một in-memory cache production còn cần TTL, memory budget theo weight, invalidation, stampede protection, observability và chiến lược nhiều process.
1constcache=newLRUCache<string, string>(2);23cache.put("a", "A");4cache.put("b", "B");5cache.get("a"); // a trở thành MRU6cache.put("c", "C"); // b bị evict78console.log(cache.get("b")); // undefined9console.log(cache.get("a")); // A
Codetypescript
1classLfuNode<K, V> {2prev:LfuNode<K, V> |null=null;3next:LfuNode<K, V
Codetypescript
1constcache=newLFUCache<string, string>(2);23cache.put("a", "A"); // freq(a)=14cache.put("b", "B"); // freq(b)=15cache.get("a"); // freq(a)=26cache.put("c", "C"); // b bị evict vì freq=1