Practice · Low Level Design · Shared State and Contention
Design an LRU Cache
এমন একটা cache যেটা ভরে গেলে সবচেয়ে বেশি সময় ধরে কেউ ছোঁয়নি এমন জিনিসটা ফেলে দেয়, get আর put দুটোই O(1): hash map আর doubly linked list, তারপর thread-safe করা।
Learn mode: পুরো solution নিচে দেখানো আছে।
Problem এর পরিচয়
| Difficulty | Intermediate |
| সময় | Design আর code মিলিয়ে ১–২ ঘণ্টা; interview তে মোটামুটি ৩০–৪৫ মিনিট |
| আগে জানা দরকার | Hash map, pointer বা reference, lock নিয়ে একটু ধারণা |
| যা শিখবে | Hash map আর doubly linked list একসাথে, sentinel node, O(1) get আর put, evict হলে খবর দেওয়ার callback, thread-safe wrapper, আর কেন এখানে "read" এর জন্যও write lock লাগে |
| বাস্তবে কোথায় | Redis এর maxmemory-policy allkeys-lru, database এর buffer pool, OS এর page cache, CDN এর edge cache, mobile app এর image cache |
LRU cache সবচেয়ে সম্প্রতি ব্যবহার হওয়া জিনিস গুলো রাখে, আর যেটা সবচেয়ে বেশি সময় ধরে কেউ ছোঁয়নি সেটা ফেলে দেয়। Interview তে এটা বারবার আসে, কারণ সহজ সমাধান গুলো ধীর, আর দ্রুত সমাধানে দুইটা data structure কে একসাথে কাজ করাতে হয়। LLD এর দিক থেকে আরও দুইটা প্রশ্ন আসে: cache এর নিজের code থেকে lock এর ঝামেলা আলাদা রাখবে কীভাবে, আর Get কেন read lock ভাগ করে নিতে পারে না।
Problem statement
একটা food delivery app প্রতিটা screen এ customer এর profile (নাম, ঠিকানা) দেখায়। প্রতিবার database থেকে আনলে ধীর হয়, তাই server সম্প্রতি ব্যবহার হওয়া profile গুলো memory তে রাখে। Memory সীমিত: cache এ নির্দিষ্ট কয়টা profile ই রাখা যায়। Cache ভরা অবস্থায় নতুন profile এলে, যেটা সবচেয়ে আগে শেষবার ব্যবহার হয়েছিল সেটা বাদ যায়।
আমরা কী বানাচ্ছি: একটা নির্দিষ্ট size এর, যেকোনো type এর জন্য চলে এমন LRU cache, যার Get, Put আর Delete তিনটাই O(1), সাথে এমন একটা version যেটা অনেক goroutine একসাথে ব্যবহার করতে পারে।
কেন জরুরি: ভুল জিনিস evict করলে hit গুলো database call হয়ে যায়; প্রতি call এ O(n) হলে cache নিজেই ধীর অংশ হয়ে যায়; আর data race থাকলে cache নিজের list নিজেই নষ্ট করে, ভুল value দেয় বা crash করে।
এক লাইনে: যেকোনো key O(1) এ খুঁজে পাও, সবচেয়ে পুরনো key কোনটা সেটা O(1) এ জানো, আর একটা key কে O(1) এ "সবচেয়ে নতুন" বানাও।
Requirement
Functional requirement
New(capacity): capacity কমপক্ষে ১ হতে হবে।Get(key): value ফেরত দাও আর key টাকে "এইমাত্র ব্যবহার হলো" ধরো; key না থাকলে সেটা miss।Put(key, value): নতুন যোগ করো বা আগেরটা বদলাও; বদলালেও key টা "এইমাত্র ব্যবহার হলো" ধরা হয়।- ভরা cache এ
Putনতুন key আনলে, আগে সবচেয়ে কম সম্প্রতি ব্যবহার হওয়া key টা বাদ যায়। Delete(key): key থাকলে সরিয়ে দাও।- চাইলে একটা callback দেওয়া যায়, যেটা প্রতিটা evict এর খবর পায় (metric বা resource বন্ধ করার জন্য)।
- Hit আর miss গুনে রাখো।
Non-functional requirement
| Requirement | এখানে মানে কী |
|---|---|
| গতি | Get, Put আর Delete O(1), size যত বড়ই হোক |
| Memory | কখনোই capacity র বেশি entry না |
| Concurrency তেও ঠিক থাকা | অনেক goroutine একটা cache ভাগ করে ব্যবহার করলেও list ভাঙবে না |
| আবার ব্যবহার | যেকোনো key আর value type এর জন্য চলবে |
যা ধরে নিচ্ছি আর যা এখানে নেই
- একটা process, capacity গোনা হয় entry দিয়ে (byte দিয়ে না)। সময় ধরে মেয়াদ (TTL) নেই, disk এ রাখা নেই।
যা জমা দিতে হবে
- দুইটা data structure আর তারা একে অপরকে কীভাবে চেনে।
- Get, put, delete আর evict এর code, সাথে thread-safe version।
- Evict এর ক্রম, update, edge case আর concurrency র test।
Hint 1: শুধু একটা map কেন না?
Map O(1) এ key খুঁজে দেয়, কিন্তু সব ঘেঁটে না দেখে বলতে পারে না কোন key সবচেয়ে পুরনো।
Hint 2: শুধু একটা list কেন না?
"সবচেয়ে নতুন আগে" ক্রমে রাখা list জানে সবচেয়ে পুরনো কোনটা (শেষেরটা), কিন্তু তার ভেতরে একটা key খুঁজতে O(n) লাগে।
Hint 3: দুইটাই
ক্রমের জন্য list রাখো, আর key থেকে list এর node এ যাওয়ার একটা map রাখো। Map node টা খুঁজে দেয়; doubly linked list সেই node কে O(1) এ খুলে নিতে পারে, কারণ সে দুই পাশের প্রতিবেশীকেই চেনে।
Hint 4: Get কি শুধু পড়ে?
Get list এর সাথে কী করে দেখো। তারপর ঠিক করো ওর কোন lock লাগবে।
মূল entity
| Entity | এক কথায় কাজ | কী রাখে | কী করে |
|---|---|---|---|
node | কে কবে ব্যবহার হলো সেই list এর একটা entry | key, value, prev, next | কিছু না: শুধু data |
Cache[K, V] | LRU এর logic, এক thread এর জন্য | capacity, key → node এর map, head আর tail sentinel, evict এর callback, hit আর miss এর হিসাব | Get, Put, Delete, Len, Keys, Stats |
Safe[K, V] | একই cache, যেটা অনেক goroutine ভাগ করে নিতে পারে | একটা mutex আর একটা Cache | একই method গুলো, প্রতিটা lock এর ভেতরে |
Node কেন নিজের key রাখে: শেষের node টা evict করার সময় map থেকেও ওটাকে মুছতে হয়, আর node নিজে ছাড়া কেউ জানে না সে কোন key এর।
কে কার সাথে কীভাবে যুক্ত: map আর list দুটোর মালিক Cache; map এর value গুলো list এর ভেতরের node এর দিকে point করে। Safe একটা Cache কে মুড়ে রাখে, lock ছাড়া আর কিছুই যোগ করে না।
Diagram
Class diagram
কীভাবে পড়বে: Safe এর ভেতরে একটা Cache, আর সে শুধু একটা mutex যোগ করে। Node গুলোর মালিক Cache; map আর list দুটোই একই node object এর দিকে point করে, তাই প্রতিটা entry র একটাই copy। node থেকে নিজের দিকে ফেরা তীরটাই doubly linked list: প্রতিটা node আগেরটা আর পরেরটাকে চেনে।
চিহ্ন গুলো: + মানে public, - মানে private। ভরা diamond ◆ মানে মালিক (অংশটা মালিকের সাথেই আসে, সাথেই যায়), সাধারণ তীর মানে reference ধরে রাখে, dashed তীর মানে ব্যবহার করে, আর ফাঁপা ত্রিভুজওয়ালা dashed তীর মানে এই interface implement করে।
Sequence: Safe দিয়ে একটা Get
কীভাবে পড়বে: প্রতিটা খাড়া dashed লাইন একটা object, আর সময় চলে উপর থেকে নিচে। ভরা তীর মানে call, dashed তীর মানে উত্তর ফেরত আসা, আর সরু bar গুলো দেখায় কোন object কখন কাজে ব্যস্ত। সামনে আনার কাজটা হয় lock এর ভেতরে, mu.Lock() আর mu.Unlock() এর মাঝে। Get কেন read lock নিতে পারে না তার পুরো কারণ এটাই: সে লেখে, diagram এর ঠিক মাঝখানে।
আগে get, তারপর ভরা cache এ put
কীভাবে পড়বে: list এর সামনে সবচেয়ে সম্প্রতি ব্যবহার হওয়া, পেছনে যেটা এরপর বাদ যাবে। Get কখনো খোঁজে না: map node টা হাতে তুলে দেয়, আর সরাতে লাগে কয়েকটা pointer বদল। Evict ও খোঁজে না: সবচেয়ে পুরনোটা সবসময় tail.prev।
Get কেন read lock নিতে পারে না
কীভাবে পড়বে: read lock একসাথে অনেক reader কে ঢুকতে দেয়, যেটা তখনই নিরাপদ যখন তারা আসলেই শুধু পড়ে। Get node সরায়, মানে লেখে। দুইটা একসাথে চললে list নষ্ট হয়।
Design principle
- যে কাজ লাগবে সেই অনুযায়ী structure বাছো: O(1) এ খোঁজা → hash map; O(1) এ "সবচেয়ে পুরনো" আর O(1) এ "সবচেয়ে নতুন বানাও" → doubly linked list। কোনোটা একা যথেষ্ট না।
- Sentinel থাকলে আলাদা case থাকে না: নকল একটা head আর tail থাকলে list কখনো খালি হয় না, আর প্রতিটা আসল node এর দুই পাশেই প্রতিবেশী থাকে, তাই
unlinkআরpushFrontএ কোনোnilcheck নেই। - Logic আর lock আলাদা:
Cacheসোজা এক-thread এর code, যেটা আলাদা করে test করা যায়। Mutex যোগ করেSafe। যে code cache টা একটাই goroutine থেকে ব্যবহার করে, তাকে lock এর খরচ দিতে হয় না। - Hard-code না করে hook দাও: evict এর callback দিয়ে caller evict গুনতে পারে বা একটা file বন্ধ করতে পারে, cache কে এসব জানতে হয় না।
Design pattern
Decorator (Cache এর উপর Safe)
- কেন: thread safety আর "কোন key বাদ যাবে" দুইটা আলাদা ব্যাপার।
- কী সমাধান করে:
Safeএর method গুলোCacheএর মতোই, প্রতিটার চারপাশে একটা lock বসিয়ে call টা ভেতরে পাঠিয়ে দেয়। - না থাকলে: LRU logic এর প্রতিটা লাইনের মাঝে
mu.Lock()ঢুকে যায়, আর এক-thread এর user রাও তার খরচ দেয়। - Trade-off: আরও একটা ছোট type, আর
Cacheএ নতুন method আনলেSafeএও একটা লাগে। - কোথায়:
safe.go।
Observer, একদম ছোট রূপে (evict এর callback)
onEvict একটা function, যেটা কোনো entry বের হয়ে গেলে cache ডাকে। এর মানে কী হবে সেটা caller ঠিক করে (একটা metric, একটা connection বন্ধ করা)। এখানে একজন listener ই যথেষ্ট, তাই পুরো listener list এর চেয়ে একটা সাধারণ function ই ভালো।
Eviction policy র জন্য Strategy কেন না?
LRU কে LFU আর FIFO এর পাশে একটা EvictionPolicy বানানোর লোভ হয়। কিন্তু এখানে policy আর data structure একই জিনিস: LRU এর লাগে কে কবে ব্যবহার হলো তার list, LFU এর লাগে কে কতবার ব্যবহার হলো সেই হিসাবের bucket। একটা common interface এই পার্থক্য লুকিয়ে ফেলে আর সাধারণ case টাকে ধীর করে। একাধিক policy লাগলে একটা ছোট Cache interface এর পেছনে আলাদা আলাদা cache লেখো।
Go তে পুরো solution
Project structure
lru/
├── go.mod
├── cache.go node, Cache: the map, the list and the LRU logic
├── safe.go Safe: the same cache behind a mutex
├── cache_test.go tests, including a 10,000-step check against a simple model
└── cmd/demo/main.go a profile cache for a delivery app
ধাপ ১: cache
Put টা আস্তে পড়ো: আগে থেকে থাকা key বদলায় আর সামনে আসে; ভরা cache এ নতুন key এলে আগে tail.prev evict হয়, তারপর নতুনটা সামনে বসে।
// cache.go
package lru
import "fmt"
// node is one entry in the recency list.
type node[K comparable, V any] struct {
key K
val V
prev, next *node[K, V]
}
// Cache is a fixed-size LRU cache: when it's full, the entry nobody has touched
// for the longest time goes. Get and Put are O(1). Not safe for many goroutines: see Safe.
type Cache[K comparable, V any] struct {
cap int
items map[K]*node[K, V] // key -> its node, so we find it in O(1)
head *node[K, V] // sentinel: head.next is the most recently used
tail *node[K, V] // sentinel: tail.prev is the least recently used
onEvict func(K, V)
hits int
misses int
}
// New makes a cache that holds at most capacity entries. onEvict may be nil.
func New[K comparable, V any](capacity int, onEvict func(K, V)) (*Cache[K, V], error) {
if capacity <= 0 {
return nil, fmt.Errorf("capacity must be positive, got %d", capacity)
}
c := &Cache[K, V]{cap: capacity, items: make(map[K]*node[K, V], capacity),
head: &node[K, V]{}, tail: &node[K, V]{}, onEvict: onEvict}
c.head.next, c.tail.prev = c.tail, c.head
return c, nil
}
// Get returns the value and marks it as just used.
func (c *Cache[K, V]) Get(key K) (V, bool) {
n, ok := c.items[key]
if !ok {
c.misses++
var zero V
return zero, false
}
c.hits++
c.moveToFront(n)
return n.val, true
}
// Put adds or updates a value. If the cache is full, the least recently used entry goes first.
func (c *Cache[K, V]) Put(key K, val V) {
if n, ok := c.items[key]; ok {
n.val = val
c.moveToFront(n)
return
}
if len(c.items) == c.cap {
lru := c.tail.prev
c.unlink(lru)
delete(c.items, lru.key)
if c.onEvict != nil {
c.onEvict(lru.key, lru.val)
}
}
n := &node[K, V]{key: key, val: val}
c.items[key] = n
c.pushFront(n)
}
// Delete removes a key; it reports whether the key was there.
func (c *Cache[K, V]) Delete(key K) bool {
n, ok := c.items[key]
if ok {
c.unlink(n)
delete(c.items, key)
}
return ok
}
func (c *Cache[K, V]) Len() int { return len(c.items) }
// Keys lists the keys from most to least recently used. O(n), for tests and debugging.
func (c *Cache[K, V]) Keys() []K {
keys := make([]K, 0, len(c.items))
for n := c.head.next; n != c.tail; n = n.next {
keys = append(keys, n.key)
}
return keys
}
func (c *Cache[K, V]) Stats() (hits, misses int) { return c.hits, c.misses }
// The three list moves. Thanks to the sentinels there are no nil checks.
func (c *Cache[K, V]) unlink(n *node[K, V]) {
n.prev.next, n.next.prev = n.next, n.prev
n.prev, n.next = nil, nil
}
func (c *Cache[K, V]) pushFront(n *node[K, V]) {
n.prev, n.next = c.head, c.head.next
c.head.next.prev = n
c.head.next = n
}
func (c *Cache[K, V]) moveToFront(n *node[K, V]) {
c.unlink(n)
c.pushFront(n)
}
ধাপ ২: thread-safe wrapper
// safe.go
package lru
import "sync"
// Safe wraps a Cache with a mutex so many goroutines can share it.
// Get moves an entry to the front, so even a "read" writes: it needs the full lock, not RLock.
type Safe[K comparable, V any] struct {
mu sync.Mutex
c *Cache[K, V]
}
func NewSafe[K comparable, V any](capacity int, onEvict func(K, V)) (*Safe[K, V], error) {
c, err := New(capacity, onEvict)
if err != nil {
return nil, err
}
return &Safe[K, V]{c: c}, nil
}
func (s *Safe[K, V]) Get(key K) (V, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Get(key)
}
func (s *Safe[K, V]) Put(key K, val V) {
s.mu.Lock()
defer s.mu.Unlock()
s.c.Put(key, val)
}
func (s *Safe[K, V]) Delete(key K) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Delete(key)
}
func (s *Safe[K, V]) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Len()
}
func (s *Safe[K, V]) Keys() []K {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Keys()
}
func (s *Safe[K, V]) Stats() (hits, misses int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.c.Stats()
}
ধাপ ৩: চালিয়ে দেখো
// cmd/demo/main.go
package main
import (
"fmt"
"strings"
"lru"
)
func main() {
evicted := ""
c, _ := lru.New(3, func(k, _ string) { evicted = k })
fmt.Println("profile cache, capacity 3, keys from most to least recent")
show := func(action string) {
note := ""
if evicted != "" {
note = " evicted " + evicted
evicted = ""
}
fmt.Printf("%-26s [%s]%s\n", action, strings.Join(c.Keys(), " "), note)
}
put := func(k, v string) { c.Put(k, v); show("put " + k + " = " + v) }
get := func(k string) {
if v, ok := c.Get(k); ok {
show("get " + k + " -> " + v)
} else {
show("get " + k + " -> miss")
}
}
put("rahim", "Mirpur")
put("nadia", "Dhanmondi")
put("tania", "Banani")
get("rahim")
put("sabbir", "Uttara")
get("nadia")
get("tania")
put("karim", "Gulshan")
put("sabbir", "Bashundhara")
hits, misses := c.Stats()
fmt.Printf("hits %d, misses %d, size %d\n", hits, misses, c.Len())
}
$ go run ./cmd/demo
profile cache, capacity 3, keys from most to least recent
put rahim = Mirpur [rahim]
put nadia = Dhanmondi [nadia rahim]
put tania = Banani [tania nadia rahim]
get rahim -> Mirpur [rahim tania nadia]
put sabbir = Uttara [sabbir rahim tania] evicted nadia
get nadia -> miss [sabbir rahim tania]
get tania -> Banani [tania sabbir rahim]
put karim = Gulshan [karim tania sabbir] evicted rahim
put sabbir = Bashundhara [sabbir karim tania]
hits 2, misses 1, size 3
Output টা পড়ি:
- প্রথম তিনটা put: প্রতিটা নতুন key সামনে বসে; rahim, যে সবার আগে ঢুকেছে, পেছনে।
- get rahim: hit, আর rahim লাফ দিয়ে সামনে চলে আসে। এখন সবচেয়ে পুরনো nadia।
- put sabbir: cache ভরা, তাই nadia (পেছনেরটা) evict হয়, rahim না। ওই
getনা হলে rahim ই যেত। - get nadia: miss; miss হলে ক্রম বদলায় না।
- put karim:
get taniaর পরে rahim আবার পেছনে, তাই rahim evict হয়। - put sabbir = Bashundhara: নতুন key না, update: কেউ evict হয় না, sabbir নতুন ঠিকানা নিয়ে সামনে আসে।
- Stats: দুইটা hit (rahim, tania), একটা miss (nadia)।
বাকি পাঁচটা language
from __future__ import annotations
import threading
from collections.abc import Callable
class Node[K, V]:
"""One entry in the recency list."""
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key: K | None = None, val: V | None = None):
self.key, self.val = key, val
self.prev: Node[K, V] | None = None
self.next: Node[K, V] | None = None
class Cache[K, V]:
"""Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe.
(collections.OrderedDict can do this in a few lines; we build the list by hand to see how.)"""
def __init__(self, capacity: int, on_evict: Callable[[K, V], None] | None = None):
if capacity <= 0:
raise ValueError(f"capacity must be positive, got {capacity}")
self.cap, self.on_evict = capacity, on_evict
self.items: dict[K, Node[K, V]] = {}
self.head: Node[K, V] = Node() # sentinel: head.next is the most recently used
self.tail: Node[K, V] = Node() # sentinel: tail.prev is the least recently used
self.head.next, self.tail.prev = self.tail, self.head
self.hits = self.misses = 0
def get(self, key: K) -> V | None:
n = self.items.get(key)
if n is None:
self.misses += 1
return None
self.hits += 1
self._move_to_front(n)
return n.val
def put(self, key: K, val: V) -> None:
n = self.items.get(key)
if n is not None:
n.val = val
self._move_to_front(n)
return
if len(self.items) == self.cap:
lru = self.tail.prev
assert lru is not None
self._unlink(lru)
del self.items[lru.key] # type: ignore[arg-type]
if self.on_evict:
self.on_evict(lru.key, lru.val) # type: ignore[arg-type]
n = Node(key, val)
self.items[key] = n
self._push_front(n)
def delete(self, key: K) -> bool:
n = self.items.pop(key, None)
if n is not None:
self._unlink(n)
return n is not None
def __len__(self) -> int:
return len(self.items)
def keys(self) -> list[K]:
"""Most to least recently used. O(n), for tests and debugging."""
out, n = [], self.head.next
while n is not self.tail:
out.append(n.key)
n = n.next
return out
# The three list moves. Thanks to the sentinels there are no None checks.
def _unlink(self, n: Node[K, V]) -> None:
n.prev.next, n.next.prev = n.next, n.prev # type: ignore[union-attr]
n.prev = n.next = None
def _push_front(self, n: Node[K, V]) -> None:
n.prev, n.next = self.head, self.head.next
self.head.next.prev = n # type: ignore[union-attr]
self.head.next = n
def _move_to_front(self, n: Node[K, V]) -> None:
self._unlink(n)
self._push_front(n)
class Safe[K, V]:
"""Wraps a Cache with a lock. get moves an entry, so even a read needs the lock."""
def __init__(self, capacity: int, on_evict: Callable[[K, V], None] | None = None):
self._c: Cache[K, V] = Cache(capacity, on_evict)
self._lock = threading.Lock()
def get(self, key: K) -> V | None:
with self._lock:
return self._c.get(key)
def put(self, key: K, val: V) -> None:
with self._lock:
self._c.put(key, val)
def delete(self, key: K) -> bool:
with self._lock:
return self._c.delete(key)
def keys(self) -> list[K]:
with self._lock:
return self._c.keys()
def main() -> None:
evicted = [""]
def remember(k: str, _: str) -> None:
evicted[0] = k
c: Cache[str, str] = Cache(3, remember)
print("profile cache, capacity 3, keys from most to least recent")
def show(action: str) -> None:
note = f" evicted {evicted[0]}" if evicted[0] else ""
evicted[0] = ""
print(f"{action:<26} [{' '.join(c.keys())}]{note}")
def put(k: str, v: str) -> None:
c.put(k, v)
show(f"put {k} = {v}")
def get(k: str) -> None:
v = c.get(k)
show(f"get {k} -> {v if v is not None else 'miss'}")
put("rahim", "Mirpur")
put("nadia", "Dhanmondi")
put("tania", "Banani")
get("rahim")
put("sabbir", "Uttara")
get("nadia")
get("tania")
put("karim", "Gulshan")
put("sabbir", "Bashundhara")
print(f"hits {c.hits}, misses {c.misses}, size {len(c)}")
if __name__ == "__main__":
main()// A JS Map remembers insertion order, so delete + set can fake LRU in a few lines.
// We build the list by hand to see how it works. (The names are LruCache and Entry
// because browsers already have global Cache and Node types.)
class Entry<K, V> {
key: K; val: V;
prev: Entry<K, V> | null = null;
next: Entry<K, V> | null = null;
constructor(key: K, val: V) { this.key = key; this.val = val; }
}
/** Fixed-size LRU cache. get and put are O(1). */
class LruCache<K, V> {
private items = new Map<K, Entry<K, V>>();
private head: Entry<K, V>; // sentinel: head.next is the most recently used
private tail: Entry<K, V>; // sentinel: tail.prev is the least recently used
private cap: number;
private onEvict?: (k: K, v: V) => void;
hits = 0;
misses = 0;
constructor(capacity: number, onEvict?: (k: K, v: V) => void) {
if (capacity <= 0) throw new Error(`capacity must be positive, got ${capacity}`);
this.cap = capacity;
this.onEvict = onEvict;
this.head = new Entry<K, V>(undefined as K, undefined as V);
this.tail = new Entry<K, V>(undefined as K, undefined as V);
this.head.next = this.tail;
this.tail.prev = this.head;
}
get(key: K): V | undefined {
const n = this.items.get(key);
if (!n) { this.misses++; return undefined; }
this.hits++;
this.moveToFront(n);
return n.val;
}
put(key: K, val: V): void {
const found = this.items.get(key);
if (found) { found.val = val; this.moveToFront(found); return; }
if (this.items.size === this.cap) {
const lru = this.tail.prev!;
this.unlink(lru);
this.items.delete(lru.key);
this.onEvict?.(lru.key, lru.val);
}
const n = new Entry(key, val);
this.items.set(key, n);
this.pushFront(n);
}
delete(key: K): boolean {
const n = this.items.get(key);
if (!n) return false;
this.unlink(n);
this.items.delete(key);
return true;
}
get size(): number { return this.items.size; }
/** Most to least recently used. O(n), for tests and debugging. */
keys(): K[] {
const out: K[] = [];
for (let n = this.head.next!; n !== this.tail; n = n.next!) out.push(n.key);
return out;
}
// The three list moves. Thanks to the sentinels there are no null checks.
private unlink(n: Entry<K, V>): void {
n.prev!.next = n.next;
n.next!.prev = n.prev;
n.prev = n.next = null;
}
private pushFront(n: Entry<K, V>): void {
n.prev = this.head;
n.next = this.head.next;
this.head.next!.prev = n;
this.head.next = n;
}
private moveToFront(n: Entry<K, V>): void { this.unlink(n); this.pushFront(n); }
}
// One thread runs JavaScript, so there's no Safe wrapper: a method can't be interrupted halfway.
function main(): void {
let evicted = "";
const c = new LruCache<string, string>(3, (k) => { evicted = k; });
console.log("profile cache, capacity 3, keys from most to least recent");
const show = (action: string) => {
const note = evicted ? ` evicted ${evicted}` : "";
evicted = "";
console.log(`${action.padEnd(26)} [${c.keys().join(" ")}]${note}`);
};
const put = (k: string, v: string) => { c.put(k, v); show(`put ${k} = ${v}`); };
const get = (k: string) => { const v = c.get(k); show(`get ${k} -> ${v ?? "miss"}`); };
put("rahim", "Mirpur");
put("nadia", "Dhanmondi");
put("tania", "Banani");
get("rahim");
put("sabbir", "Uttara");
get("nadia");
get("tania");
put("karim", "Gulshan");
put("sabbir", "Bashundhara");
console.log(`hits ${c.hits}, misses ${c.misses}, size ${c.size}`);
}
main();use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Mutex;
// A doubly linked list with pointers both ways fights Rust's ownership rules. So the nodes
// live in a Vec and point to each other by index. Slot 0 is the head sentinel, slot 1 the tail.
const HEAD: usize = 0;
const TAIL: usize = 1;
struct Node<K, V> {
key: Option<K>, // None only for the two sentinels
val: Option<V>,
prev: usize,
next: usize,
}
/// Fixed-size LRU cache. get and put are O(1). Not thread-safe on its own: see Safe.
struct Cache<K, V> {
cap: usize,
items: HashMap<K, usize>, // key -> slot in `nodes`
nodes: Vec<Node<K, V>>,
free: Vec<usize>, // slots freed by delete, reused by put
hits: u32,
misses: u32,
}
impl<K: Hash + Eq + Clone, V: Clone> Cache<K, V> {
fn new(cap: usize) -> Result<Self, String> {
if cap == 0 {
return Err("capacity must be positive, got 0".into());
}
let nodes = vec![
Node { key: None, val: None, prev: HEAD, next: TAIL },
Node { key: None, val: None, prev: HEAD, next: TAIL },
];
Ok(Cache { cap, items: HashMap::new(), nodes, free: vec![], hits: 0, misses: 0 })
}
fn get(&mut self, key: &K) -> Option<V> {
let Some(&i) = self.items.get(key) else {
self.misses += 1;
return None;
};
self.hits += 1;
self.unlink(i);
self.push_front(i);
self.nodes[i].val.clone()
}
/// Adds or updates. Returns the evicted entry, if the cache was full.
fn put(&mut self, key: K, val: V) -> Option<(K, V)> {
if let Some(&i) = self.items.get(&key) {
self.nodes[i].val = Some(val);
self.unlink(i);
self.push_front(i);
return None;
}
let mut evicted = None;
if self.items.len() == self.cap {
let lru = self.nodes[TAIL].prev;
self.unlink(lru);
let (k, v) = (self.nodes[lru].key.take().unwrap(), self.nodes[lru].val.take().unwrap());
self.items.remove(&k);
self.free.push(lru);
evicted = Some((k, v));
}
let node = Node { key: Some(key.clone()), val: Some(val), prev: HEAD, next: TAIL };
let i = match self.free.pop() {
Some(i) => {
self.nodes[i] = node;
i
}
None => {
self.nodes.push(node);
self.nodes.len() - 1
}
};
self.items.insert(key, i);
self.push_front(i);
evicted
}
#[allow(dead_code)]
fn delete(&mut self, key: &K) -> bool {
let Some(i) = self.items.remove(key) else { return false };
self.unlink(i);
self.nodes[i].key = None;
self.nodes[i].val = None;
self.free.push(i);
true
}
fn len(&self) -> usize {
self.items.len()
}
/// Most to least recently used. O(n), for tests and debugging.
fn keys(&self) -> Vec<K> {
let mut out = vec![];
let mut i = self.nodes[HEAD].next;
while i != TAIL {
out.push(self.nodes[i].key.clone().unwrap());
i = self.nodes[i].next;
}
out
}
// The two list moves, with indices instead of pointers.
fn unlink(&mut self, i: usize) {
let (p, n) = (self.nodes[i].prev, self.nodes[i].next);
self.nodes[p].next = n;
self.nodes[n].prev = p;
}
fn push_front(&mut self, i: usize) {
let first = self.nodes[HEAD].next;
self.nodes[i].prev = HEAD;
self.nodes[i].next = first;
self.nodes[first].prev = i;
self.nodes[HEAD].next = i;
}
}
/// Shares a Cache between threads. get moves an entry, so even a read takes the lock
/// (and Rust won't let you call get through a shared reference without one).
#[allow(dead_code)]
struct Safe<K, V>(Mutex<Cache<K, V>>);
#[allow(dead_code)]
impl<K: Hash + Eq + Clone, V: Clone> Safe<K, V> {
fn get(&self, key: &K) -> Option<V> {
self.0.lock().unwrap().get(key)
}
fn put(&self, key: K, val: V) -> Option<(K, V)> {
self.0.lock().unwrap().put(key, val)
}
}
fn main() {
let mut c: Cache<String, String> = Cache::new(3).unwrap();
println!("profile cache, capacity 3, keys from most to least recent");
fn show(c: &Cache<String, String>, action: String, evicted: Option<(String, String)>) {
let note = evicted.map(|(k, _)| format!(" evicted {k}")).unwrap_or_default();
println!("{action:<26} [{}]{note}", c.keys().join(" "));
}
let put = |c: &mut Cache<String, String>, k: &str, v: &str| {
let ev = c.put(k.to_string(), v.to_string());
show(c, format!("put {k} = {v}"), ev);
};
let get = |c: &mut Cache<String, String>, k: &str| {
let v = c.get(&k.to_string()).unwrap_or("miss".into());
show(c, format!("get {k} -> {v}"), None);
};
put(&mut c, "rahim", "Mirpur");
put(&mut c, "nadia", "Dhanmondi");
put(&mut c, "tania", "Banani");
get(&mut c, "rahim");
put(&mut c, "sabbir", "Uttara");
get(&mut c, "nadia");
get(&mut c, "tania");
put(&mut c, "karim", "Gulshan");
put(&mut c, "sabbir", "Bashundhara");
println!("hits {}, misses {}, size {}", c.hits, c.misses, c.len());
}import java.util.*;
import java.util.function.BiConsumer;
// java.util.LinkedHashMap(cap, 0.75f, true) with removeEldestEntry is a ready-made LRU.
// We build the list by hand to see how it works.
public class Main {
static final class Node<K, V> {
K key;
V val;
Node<K, V> prev, next;
Node(K key, V val) { this.key = key; this.val = val; }
}
/** Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe. */
static class Cache<K, V> {
private final int cap;
private final Map<K, Node<K, V>> items = new HashMap<>();
private final Node<K, V> head = new Node<>(null, null); // head.next = most recently used
private final Node<K, V> tail = new Node<>(null, null); // tail.prev = least recently used
private final BiConsumer<K, V> onEvict;
int hits, misses;
Cache(int capacity, BiConsumer<K, V> onEvict) {
if (capacity <= 0) throw new IllegalArgumentException("capacity must be positive, got " + capacity);
this.cap = capacity;
this.onEvict = onEvict;
head.next = tail;
tail.prev = head;
}
Optional<V> get(K key) {
Node<K, V> n = items.get(key);
if (n == null) { misses++; return Optional.empty(); }
hits++;
moveToFront(n);
return Optional.of(n.val);
}
void put(K key, V val) {
Node<K, V> n = items.get(key);
if (n != null) { n.val = val; moveToFront(n); return; }
if (items.size() == cap) {
Node<K, V> lru = tail.prev;
unlink(lru);
items.remove(lru.key);
if (onEvict != null) onEvict.accept(lru.key, lru.val);
}
n = new Node<>(key, val);
items.put(key, n);
pushFront(n);
}
boolean delete(K key) {
Node<K, V> n = items.remove(key);
if (n != null) unlink(n);
return n != null;
}
int size() { return items.size(); }
/** Most to least recently used. O(n), for tests and debugging. */
List<K> keys() {
List<K> out = new ArrayList<>();
for (Node<K, V> n = head.next; n != tail; n = n.next) out.add(n.key);
return out;
}
// The three list moves. Thanks to the sentinels there are no null checks.
private void unlink(Node<K, V> n) {
n.prev.next = n.next;
n.next.prev = n.prev;
n.prev = n.next = null;
}
private void pushFront(Node<K, V> n) {
n.prev = head;
n.next = head.next;
head.next.prev = n;
head.next = n;
}
private void moveToFront(Node<K, V> n) { unlink(n); pushFront(n); }
}
/** Wraps a Cache with a lock. get moves an entry, so even a read is synchronized. */
static final class Safe<K, V> {
private final Cache<K, V> c;
Safe(int capacity, BiConsumer<K, V> onEvict) { c = new Cache<>(capacity, onEvict); }
synchronized Optional<V> get(K key) { return c.get(key); }
synchronized void put(K key, V val) { c.put(key, val); }
synchronized boolean delete(K key) { return c.delete(key); }
synchronized List<K> keys() { return c.keys(); }
}
static String evicted = "";
static Cache<String, String> c;
static void show(String action) {
String note = evicted.isEmpty() ? "" : " evicted " + evicted;
evicted = "";
System.out.printf("%-26s [%s]%s%n", action, String.join(" ", c.keys()), note);
}
static void put(String k, String v) { c.put(k, v); show("put " + k + " = " + v); }
static void get(String k) { show("get " + k + " -> " + c.get(k).orElse("miss")); }
public static void main(String[] args) {
c = new Cache<>(3, (k, v) -> evicted = k);
System.out.println("profile cache, capacity 3, keys from most to least recent");
put("rahim", "Mirpur");
put("nadia", "Dhanmondi");
put("tania", "Banani");
get("rahim");
put("sabbir", "Uttara");
get("nadia");
get("tania");
put("karim", "Gulshan");
put("sabbir", "Bashundhara");
System.out.printf("hits %d, misses %d, size %d%n", c.hits, c.misses, c.size());
}
}#include <cstdio>
#include <functional>
#include <list>
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
// Fixed-size LRU cache. get and put are O(1). Not thread-safe: see Safe.
// std::list is already a doubly linked list, and splice moves a node to the front in O(1)
// without copying it, so the map can keep iterators into the list.
template <typename K, typename V>
class Cache {
public:
using OnEvict = std::function<void(const K&, const V&)>;
explicit Cache(int capacity, OnEvict onEvict = nullptr) : cap_(capacity), onEvict_(std::move(onEvict)) {
if (capacity <= 0) throw std::invalid_argument("capacity must be positive, got " + std::to_string(capacity));
}
std::optional<V> get(const K& key) {
auto it = items_.find(key);
if (it == items_.end()) {
misses++;
return std::nullopt;
}
hits++;
order_.splice(order_.begin(), order_, it->second); // move to front
return it->second->second;
}
void put(const K& key, V val) {
if (auto it = items_.find(key); it != items_.end()) {
it->second->second = std::move(val);
order_.splice(order_.begin(), order_, it->second);
return;
}
if (static_cast<int>(items_.size()) == cap_) {
auto& [k, v] = order_.back(); // least recently used
if (onEvict_) onEvict_(k, v);
items_.erase(k);
order_.pop_back();
}
order_.emplace_front(key, std::move(val));
items_[key] = order_.begin();
}
bool remove(const K& key) {
auto it = items_.find(key);
if (it == items_.end()) return false;
order_.erase(it->second);
items_.erase(it);
return true;
}
size_t size() const { return items_.size(); }
// Most to least recently used. O(n), for tests and debugging.
std::vector<K> keys() const {
std::vector<K> out;
for (auto& [k, _] : order_) out.push_back(k);
return out;
}
int hits = 0, misses = 0;
private:
int cap_;
OnEvict onEvict_;
std::list<std::pair<K, V>> order_; // front = most recently used
std::unordered_map<K, typename std::list<std::pair<K, V>>::iterator> items_;
};
// Wraps a Cache with a mutex. get moves an entry, so even a read takes the lock
// (a shared_mutex with shared_lock in get would be a data race).
template <typename K, typename V>
class Safe {
public:
explicit Safe(int capacity) : c_(capacity) {}
std::optional<V> get(const K& key) {
std::lock_guard lock(mu_);
return c_.get(key);
}
void put(const K& key, V val) {
std::lock_guard lock(mu_);
c_.put(key, std::move(val));
}
private:
std::mutex mu_;
Cache<K, V> c_;
};
std::string join(const std::vector<std::string>& v) {
std::string out;
for (size_t i = 0; i < v.size(); i++) out += (i ? " " : "") + v[i];
return out;
}
int main() {
std::string evicted;
Cache<std::string, std::string> c(3, [&](const std::string& k, const std::string&) { evicted = k; });
std::printf("profile cache, capacity 3, keys from most to least recent\n");
auto show = [&](const std::string& action) {
std::string note = evicted.empty() ? "" : " evicted " + evicted;
evicted.clear();
std::printf("%-26s [%s]%s\n", action.c_str(), join(c.keys()).c_str(), note.c_str());
};
auto put = [&](const std::string& k, const std::string& v) {
c.put(k, v);
show("put " + k + " = " + v);
};
auto get = [&](const std::string& k) {
auto v = c.get(k);
show("get " + k + " -> " + v.value_or("miss"));
};
put("rahim", "Mirpur");
put("nadia", "Dhanmondi");
put("tania", "Banani");
get("rahim");
put("sabbir", "Uttara");
get("nadia");
get("tania");
put("karim", "Gulshan");
put("sabbir", "Bashundhara");
std::printf("hits %d, misses %d, size %zu\n", c.hits, c.misses, c.size());
}Language ভেদে কী আলাদা: Python, TypeScript আর Java একই list হাতে বানায়, কিন্তু আসল code এর জন্য প্রত্যেকের একটা shortcut আছে: OrderedDict.move_to_end, Map (যেটা ঢোকানোর ক্রম মনে রাখে, তাই delete করে আবার set করলেই হয়), আর access order সহ LinkedHashMap। C++ এ std::list, যার splice কোনো copy ছাড়াই node কে সামনে আনে, তাই map list এর iterator রেখে দিতে পারে। Rust টাই মজার: যে list এর node দুই দিকে point করে, সেটা borrow checker এর সাথে লড়াই করে, তাই node গুলো একটা Vec এ থাকে আর index দিয়ে একে অপরকে চেনে, আর put callback ডাকার বদলে evict হওয়া entry ফেরত দেয়। TypeScript এ class এর নাম LruCache আর Entry, কারণ browser এ আগে থেকেই global Cache আর Node type আছে।
Testing
// cache_test.go
package lru
import (
"fmt"
"math/rand"
"slices"
"sync"
"testing"
)
func newCache(t *testing.T, capacity int) (*Cache[string, int], *[]string) {
var evicted []string
c, err := New(capacity, func(k string, _ int) { evicted = append(evicted, k) })
if err != nil {
t.Fatal(err)
}
return c, &evicted
}
func TestEvictsLeastRecentlyUsed(t *testing.T) {
c, evicted := newCache(t, 2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3) // a is the oldest
if _, ok := c.Get("a"); ok || !slices.Equal(*evicted, []string{"a"}) {
t.Fatalf("a should be gone, evicted %v", *evicted)
}
}
func TestGetMakesAnEntryRecent(t *testing.T) {
c, evicted := newCache(t, 2)
c.Put("a", 1)
c.Put("b", 2)
c.Get("a") // now b is the oldest
c.Put("c", 3) // so b goes, not a
if !slices.Equal(*evicted, []string{"b"}) || !slices.Equal(c.Keys(), []string{"c", "a"}) {
t.Fatalf("evicted %v, keys %v", *evicted, c.Keys())
}
}
func TestUpdateDoesNotGrow(t *testing.T) {
c, evicted := newCache(t, 2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("a", 10)
if v, _ := c.Get("a"); v != 10 || c.Len() != 2 || len(*evicted) != 0 {
t.Fatalf("v %d, len %d, evicted %v", v, c.Len(), *evicted)
}
}
func TestCapacityOne(t *testing.T) {
c, _ := newCache(t, 1)
c.Put("a", 1)
c.Put("b", 2)
if !slices.Equal(c.Keys(), []string{"b"}) {
t.Fatalf("keys %v", c.Keys())
}
}
func TestBadCapacityRefused(t *testing.T) {
for _, n := range []int{0, -1} {
if _, err := New[string, int](n, nil); err == nil {
t.Fatalf("capacity %d should be refused", n)
}
}
}
func TestDeleteKeepsListConsistent(t *testing.T) {
c, _ := newCache(t, 3)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3)
if !c.Delete("b") || c.Delete("b") {
t.Fatal("first delete true, second false")
}
c.Put("d", 4)
if !slices.Equal(c.Keys(), []string{"d", "c", "a"}) {
t.Fatalf("keys %v", c.Keys())
}
}
// 10,000 random operations, checked against a slow but obviously correct model:
// a slice with the most recent key first.
func TestMatchesSimpleModel(t *testing.T) {
c, _ := newCache(t, 4)
var model []string
r := rand.New(rand.NewSource(1))
for i := range 10_000 {
k := fmt.Sprint(r.Intn(8))
at := slices.Index(model, k)
switch r.Intn(3) {
case 0: // get
_, ok := c.Get(k)
if ok != (at >= 0) {
t.Fatalf("op %d: get %s ok=%v", i, k, ok)
}
if ok {
model = append([]string{k}, slices.Delete(model, at, at+1)...)
}
case 1: // put
c.Put(k, i)
if at >= 0 {
model = slices.Delete(model, at, at+1)
} else if len(model) == 4 {
model = model[:3]
}
model = append([]string{k}, model...)
case 2: // delete
c.Delete(k)
if at >= 0 {
model = slices.Delete(model, at, at+1)
}
}
if !slices.Equal(c.Keys(), model) {
t.Fatalf("op %d: cache %v, model %v", i, c.Keys(), model)
}
}
}
// Many goroutines share one Safe cache. Run with -race.
func TestSafeUnderConcurrency(t *testing.T) {
s, _ := NewSafe[int, int](50, nil)
var wg sync.WaitGroup
for g := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for i := range 2000 {
k := (g*7 + i) % 100
if i%3 == 0 {
s.Put(k, i)
} else {
s.Get(k)
}
}
}()
}
wg.Wait()
keys := s.Keys()
slices.Sort(keys)
if s.Len() > 50 || len(keys) != s.Len() || len(slices.Compact(keys)) != len(keys) {
t.Fatalf("len %d, keys %d", s.Len(), len(keys))
}
if hits, misses := s.Stats(); hits+misses != 8*1333 { // 1333 gets per goroutine
t.Fatalf("counted %d gets, want %d", hits+misses, 8*1333)
}
}
$ go test -race -v ./...
=== RUN TestEvictsLeastRecentlyUsed
--- PASS: TestEvictsLeastRecentlyUsed (0.00s)
=== RUN TestGetMakesAnEntryRecent
--- PASS: TestGetMakesAnEntryRecent (0.00s)
=== RUN TestUpdateDoesNotGrow
--- PASS: TestUpdateDoesNotGrow (0.00s)
=== RUN TestCapacityOne
--- PASS: TestCapacityOne (0.00s)
=== RUN TestBadCapacityRefused
--- PASS: TestBadCapacityRefused (0.00s)
=== RUN TestDeleteKeepsListConsistent
--- PASS: TestDeleteKeepsListConsistent (0.00s)
=== RUN TestMatchesSimpleModel
--- PASS: TestMatchesSimpleModel (0.03s)
=== RUN TestSafeUnderConcurrency
--- PASS: TestSafeUnderConcurrency (0.02s)
PASS
ok lru 1.067s
? lru/cmd/demo [no test files]
| Test | কী check করে |
|---|---|
TestEvictsLeastRecentlyUsed | Capacity 2 তে তৃতীয় key এলে প্রথমটা evict হয় |
TestGetMakesAnEntryRecent | একটা Get key টাকে evict হওয়া থেকে বাঁচায় |
TestUpdateDoesNotGrow | আগে থেকে থাকা key এ put করলে update হয়, কেউ evict হয় না |
TestCapacityOne | সবচেয়ে ছোট cache ও ঠিক চলে |
TestBadCapacityRefused | Capacity 0 আর -1 নেওয়া হয় না |
TestDeleteKeepsListConsistent | Delete এর পরেও list এর ক্রম আর পরের put গুলো ঠিক থাকে |
TestMatchesSimpleModel | ১০,০০০টা random get, put আর delete এর পরেও ক্রম একটা সাধারণ slice এর সাথে মেলে |
TestSafeUnderConcurrency | ৮টা goroutine একটা Safe cache ভাগ করে: size capacity র মধ্যে থাকে, কোনো key দুইবার নেই, প্রতিটা get গোনা হয় |
Model test টাই সবচেয়ে শক্ত: slice এর version টা ধীর কিন্তু দেখেই বোঝা যায় ঠিক, তাই দ্রুত version কখনো এর সাথে না মিললে bug টা দ্রুত version এ।
আমরা Safe কে sync.RWMutex বানিয়ে Get এ RLock দিয়েও চালিয়েছি, মানে দ্বিতীয় diagram এর ভুলটা। প্রতিবার race detector data race ধরেছে (আমাদের run এ ১২ থেকে ১৯টা), Get, unlink আর pushFront এ, আর test fail করেছে।
Design review
যা ভালো: সব কাজ O(1); sentinel এর কারণে list এর code ছোট; LRU logic এক-thread এর আর আলাদা করে পুরো test করা; lock আছে শুধু একটা পাতলা wrapper এ।
| "যদি…" | কী বদলাতে হবে |
|---|---|
| একটা lock এ কুলায় না (অনেক core) | Shard করো: N টা cache, প্রত্যেকের নিজের lock, আর key এর hash দেখে shard বাছো। তখন evict হয় "প্রতি shard এ LRU", যেটা বাস্তবে যথেষ্ট কাছাকাছি। |
| Entry র মেয়াদ থাকবে | প্রতিটা node এ মেয়াদ শেষের সময় রাখো; Get এ মেয়াদ পেরোনো entry কে miss ধরে সরিয়ে দাও। |
| গোনা হবে byte এ, সংখ্যায় না | প্রতিটা entry নিজের size বলে; নতুনটা না ধরা পর্যন্ত Put পেছন থেকে evict করতে থাকে। |
| Callback নিজেই cache কে ডাকে | Safe এর ভেতরে এতে deadlock হয়, কারণ lock আগে থেকেই ধরা। Evict হওয়া entry গুলো জমিয়ে রাখো, lock ছাড়ার পরে callback ডাকো। |
| একটা scan পুরো cache ধুয়ে দেয় (বড় একটা report সবকিছু একবার করে ছোঁয়) | এমন policy নাও যেটা scan সামলাতে পারে, যেমন 2Q বা segmented LRU: নতুন key আগে একটা পরীক্ষামূলক list এ যায়। |
| অনেকগুলো server | Shared cache (Redis) ব্যবহার করো, অথবা মেনে নাও প্রতিটা server এর নিজের cache; এই cache টা থাকে প্রতি process এর layer হিসেবে। |
Interview প্রশ্ন
Practice প্রশ্ন
Beginner
- শুধু hash map দিয়ে কেন O(1) এ LRU হয় না?
- List এর ভাষায় "least recently used" মানে কী?
Intermediate
- Singly না, doubly linked list কেন?
- Sentinel node কী, আর এতে কী বাঁচে?
Advanced
Getএর কেন read lock না, পুরো lock লাগে?- 32-core machine এ cache এর lock টাই bottleneck। কী করবে?
Design এর সাধারণ ভুল
- প্রতি key তে একটা সময় রেখে সবচেয়ে পুরনোটা ঘেঁটে বের করা: O(n) evict।
- Singly linked list: একটা node খুলতে তার আগের node লাগে, যেটা খুঁজতে হয়।
Getএ ক্রম update করতে ভুলে যাওয়া, এতে LRU হয়ে যায় FIFO।- Node এ key না রাখা, ফলে evict এর সময় map থেকে মোছা যায় না।
- "এটা তো শুধু পড়া" ভেবে
GetএRLock।
উত্তর
১. শুধু map কেন না?
Map এর কোনো ক্রম নেই। সবচেয়ে কম সম্প্রতি ব্যবহার হওয়া key পেতে প্রতি key তে একটা সময় রেখে সবগুলো ঘাঁটতে হবে: প্রতি evict এ O(n)।
২. List এর ভাষায় LRU?
প্রতিবার ব্যবহার হলে key সামনে চলে আসে, তাই list এর পেছনে থাকে সেই key, যেটা সবচেয়ে বেশি সময় ধরে ব্যবহার হয়নি। Evict হয় ওটাই।
৩. Doubly কেন?
একটা node খুলতে হলে তার আগের node এর next বদলাতে হয়। prev pointer থাকলে সেটা O(1); singly linked list এ head থেকে হেঁটে খুঁজে বের করতে হতো।
৪. Sentinel?
নকল একটা head আর tail, যেগুলো সবসময় থাকে। তখন প্রতিটা আসল node এর দুই পাশেই প্রতিবেশী থাকে, তাই insert আর unlink এ "list কি খালি?" বা "এটা কি প্রথম node?" ধরনের কোনো branch লাগে না।
৫. Get এ পুরো lock কেন?
Get node টাকে সামনে আনে, মানে কয়েকটা pointer নতুন করে লেখে। Read lock দুইটা Get কে একসাথে এই কাজ করতে দেয়, আর তাদের pointer লেখা একটার মধ্যে আরেকটা ঢুকে list ভেঙে যায়। RLock দিয়ে আমাদের test ১২ থেকে ১৯টা data race দেখিয়েছে।
৬. Lock ই bottleneck?
Cache টাকে কয়েকটা shard এ ভাগ করো (ধরো ১৬টা), প্রত্যেকের নিজের lock, key এর hash দেখে shard বাছা। আলাদা shard ছোঁয়া thread গুলো একে অপরের জন্য অপেক্ষা করে না। পুরো cache জুড়ে একদম ঠিকঠাক LRU ক্রম আর থাকে না, কিন্তু সেটা খুব কমই দরকার হয়।
Interviewer রা যা দেখেন: code এর আগে map + doubly linked list এর জুটিটা বুঝিয়ে বলা, সবকিছু O(1), node এ key রাখা, পরিষ্কার pointer এর code (sentinel এতে সাহায্য করে), আর read lock এর ফাঁদটা চেনা।
নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।