All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · Shared State and Contention

Design an LRU Cache

IntermediateHashMapDoubly linked listO(1)Thread safety⏱ 1-2 h

A cache that forgets the least recently used item, with O(1) get and put: a hash map plus a doubly linked list, then making it thread-safe.

Learn mode: the full solution is shown below.

Problem introduction

DifficultyIntermediate
Time1–2 hours to design and code; about 30–45 minutes in an interview
PrerequisitesHash maps, pointers or references, a little about locks
ConceptsA hash map plus a doubly linked list, sentinel nodes, O(1) get and put, an eviction callback, a thread-safe wrapper, why a "read" here needs a write lock
Real worldRedis with maxmemory-policy allkeys-lru, database buffer pools, the OS page cache, CDN edge caches, the image cache in a mobile app

An LRU cache keeps the most recently used items and throws away the one nobody has touched for the longest time. It is asked in interviews all the time because the obvious solutions are slow, and the fast one needs two data structures working together. The LLD side adds two more questions: how do you keep the cache's own code clean of locking, and why can't Get share a read lock.

Problem statement

A food delivery app shows the customer's profile (name, address) on every screen. Loading it from the database every time is slow, so the server keeps recently used profiles in memory. Memory is limited: the cache can hold only a fixed number of profiles. When it's full and a new profile comes in, the one that was used least recently goes.

What we are building: a fixed-size, generic LRU cache with Get, Put and Delete, all O(1), plus a version many goroutines can share.

Why it matters: a cache that evicts the wrong item turns hits into database calls; a cache that's O(n) per call becomes the slow part; a cache with a data race corrupts its own list and starts returning wrong values or crashing.

In one line: find any key in O(1), know which key is the oldest in O(1), and move a key to "newest" in O(1).

Requirements

Functional requirements

  1. New(capacity): capacity must be at least 1.
  2. Get(key): return the value and mark the key as just used; a missing key is a miss.
  3. Put(key, value): add or update; updating also marks the key as just used.
  4. If Put adds a new key to a full cache, the least recently used key is removed first.
  5. Delete(key): remove a key if it's there.
  6. An optional callback is told about every eviction (for metrics or for closing resources).
  7. Count hits and misses.

Non-functional requirements

RequirementWhat it means here
SpeedGet, Put and Delete are O(1), whatever the size
MemoryAt most capacity entries, ever
Correctness under concurrencyMany goroutines can share one cache without breaking the list
ReuseWorks for any key and value type

Assumptions and out of scope

  • One process, capacity counted in entries (not bytes). No expiry by time (TTL), no persistence.

What to hand in

  • The two data structures and how they point to each other.
  • Code for get, put, delete and eviction, plus a thread-safe version.
  • Tests for the eviction order, updates, edge cases and concurrency.
Hint 1: why not just a map?

A map finds a key in O(1), but it can't tell you which key is the oldest without scanning everything.

Hint 2: why not just a list?

A list kept in "most recent first" order knows the oldest (the last one), but finding a key in it is O(n).

Hint 3: both

Keep the list for the order and a map from key to list node. The map finds the node; a doubly linked list can unlink that node in O(1) because it knows both neighbours.

Hint 4: is Get a read?

Look at what Get does to the list. Then decide which lock it needs.

Core entities

EntityJob in one sentenceHoldsDoes
nodeOne entry in the recency listkey, value, prev, nextnothing: data
Cache[K, V]The LRU logic, single-threadedcapacity, map key → node, head and tail sentinels, eviction callback, hit and miss countsGet, Put, Delete, Len, Keys, Stats
Safe[K, V]The same cache, shareable between goroutinesa mutex and a Cachethe same methods, each under the lock

Why the node keeps its key: when we evict the last node, we must also delete it from the map, and the node is the only place that knows which key it belongs to.

Relationships: Cache owns the map and the list; the map's values point into the list. Safe wraps a Cache and adds nothing but the lock.

Diagrams

Class diagram

Safe[K, V]- mu: sync.Mutex- c: *Cache[K, V]+ Get(key) (V, bool)+ Put(key, val)+ Delete(key) bool+ Len / Keys / StatsCache[K, V]- cap: int- items: map[K]*node- head, tail: *node- onEvict: func(K, V)- hits, misses: int+ Get(key) (V, bool)+ Put(key, val)+ Delete(key) bool- unlink(n)- pushFront(n)- moveToFront(n)node[K, V]key: Kval: Vprev, next: *nodewrapsitems1*prev / nextSafe wraps Cache and adds only the lock (Decorator)Cache owns the nodes: the map finds one, the list keeps the ordereach node knows prev and next: a doubly linked listonly Get, Put and Delete are public (+); list moves stay private (-)
Press play. First Safe wrapping Cache, then the nodes Cache owns, then the prev and next links between nodes, then which methods are public.

How to read it: Safe owns one Cache and adds only a mutex. Cache owns its nodes; the map and the list point at the same node objects, so there is one copy of each entry. The arrow from node back to itself is the doubly linked list: every node points to the one before and the one after.

Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.

Sequence: a Get through Safe

callerSafeCacheitems mapGet(rahim)mu.Lock()Get(rahim)items[rahim]nodemoveToFront, hits++Mirpur, truemu.Unlock()Mirpur, true
Press play. Get rahim: Safe locks, Cache looks the key up in the map, gets the node, moves it to the front and counts a hit, then Safe unlocks and returns.

How to read it: Each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. The move to the front happens inside the lock, between mu.Lock() and mu.Unlock(). That is the whole reason Get can't use a read lock: it writes, right there in the middle of the diagram.

Get, then put on a full cache

HEADTAILmap[key] → nodeO(1) lookupnewestoldesttanianadiarahimsabbirthe list: tania, nadia, rahim (rahim is the oldest)get rahim: the map points straight at its nodeunlink it and push it to the front: O(1)put sabbir, but the cache is fullthe node just before TAIL goes: nadia is evictedfront = most recently used, back = next to go
Press play. The list is tania, nadia, rahim. Get rahim: the map jumps straight to rahim's node, which moves to the front. Then put sabbir on a full cache: the node just before TAIL (nadia) is evicted and sabbir goes to the front.

How to read it: the front of the list is the most recently used, the back is the next to go. Get never searches: the map hands over the node, and moving it is a few pointer changes. Eviction never searches either: the oldest is always tail.prev.

Why Get can't use a read lock

goroutine 1Get(rahim)goroutine 2Get(tania)the lockRLock, then Lockthe listhead.next = ?G1G2G1G2RLock: readers may go in togetherbut Get moves a node: both rewrite head.next, one change is losta full Lock: G2 waits until G1 is doneone Get at a time, the list stays right
Press play. With RLock, two Gets go in together, and both move a node to the front, so both rewrite head.next and one change is lost. With a full Lock, the second Get waits, and the list stays right.

How to read it: a read lock lets many readers in at once, which is only safe if they really just read. Get moves a node, so it writes. Two of them at once corrupt the list.

Design principles

  • Pick structures by the operations you need: O(1) lookup → hash map; O(1) "oldest" and O(1) "move to newest" → doubly linked list. Neither alone is enough.
  • Sentinels remove special cases: with a dummy head and tail, the list is never empty and every real node has two neighbours, so there are no nil checks in unlink and pushFront.
  • Separate logic from locking: Cache is simple single-threaded code you can test on its own. Safe adds the mutex. Code that only uses a cache from one goroutine doesn't pay for a lock.
  • Hooks, not hard-coding: the eviction callback lets callers count evictions or close a file without the cache knowing about either.

Design patterns

Decorator (Safe around Cache)

  1. Why: thread safety is a separate concern from "which key goes".
  2. What it solves: Safe has the same methods as Cache, adds a lock around each, and passes the call on.
  3. Without it: mu.Lock() is mixed into every line of the LRU logic, and single-threaded users pay for it.
  4. Trade-off: one more small type, and every new Cache method needs a matching Safe method.
  5. Where: safe.go.

Observer, in its smallest form (the eviction callback)

onEvict is a function the cache calls when an entry leaves. The caller decides what that means (a metric, closing a connection). One listener is enough here, so a plain function is better than a full listener list.

Why not Strategy for the eviction policy?

It's tempting to make LRU one EvictionPolicy among LFU and FIFO. But the policy is the data structure: LRU needs a recency list, LFU needs frequency buckets. A shared interface would hide that and slow the common case. If you need several policies, write several caches behind one small Cache interface.

Complete solution in Go

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

Step 1: the cache

Read Put slowly: an existing key is updated and moved; a new key on a full cache first evicts tail.prev, then goes to the front.

// 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)
}

Step 2: the 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()
}

Step 3: run it

// 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

Reading the output:

  • First three puts: each new key goes to the front; rahim, the first one in, is at the back.
  • get rahim: a hit, and rahim jumps to the front. Now nadia is the oldest.
  • put sabbir: the cache is full, so nadia (the back) is evicted, not rahim. Without that get, rahim would have gone.
  • get nadia: a miss; the order doesn't change on a miss.
  • put karim: after get tania, rahim is at the back again, so rahim is evicted.
  • put sabbir = Bashundhara: an update, not a new key: no eviction, sabbir moves to the front with the new address.
  • Stats: two hits (rahim, tania), one miss (nadia).

The other five languages

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()

What changes from language to language: Python, TypeScript and Java build the same list by hand, but each has a shortcut for real code: OrderedDict.move_to_end, a Map (which keeps insertion order, so delete and set again), and LinkedHashMap with access order. C++ uses std::list, whose splice moves a node to the front without copying, so the map can store list iterators. Rust is the interesting one: a list where nodes point both ways fights the borrow checker, so the nodes live in a Vec and point to each other by index, and put returns the evicted entry instead of calling a callback. TypeScript names its classes LruCache and Entry because browsers already have global Cache and Node types.

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]
TestChecks
TestEvictsLeastRecentlyUsedWith capacity 2, adding a third key evicts the first
TestGetMakesAnEntryRecentA Get saves a key from eviction
TestUpdateDoesNotGrowPutting an existing key updates it without evicting
TestCapacityOneThe smallest cache still works
TestBadCapacityRefusedCapacity 0 and -1 are refused
TestDeleteKeepsListConsistentAfter a delete, the list order and later puts are still right
TestMatchesSimpleModel10,000 random gets, puts and deletes give the same order as a plain slice
TestSafeUnderConcurrency8 goroutines share a Safe cache: size stays within capacity, no duplicate keys, every get is counted

The model test is the strongest one: the slice version is slow but obviously correct, so if the fast version ever disagrees, the fast version has a bug.

We also changed Safe to use a sync.RWMutex with RLock in Get, the mistake from the second diagram. The race detector reported data races on every run (12 to 19 in our runs), in Get, unlink and pushFront, and the test failed.

Design review

What's good: every operation is O(1); sentinels keep the list code short; the LRU logic is single-threaded and fully tested on its own; the lock lives in one thin wrapper.

"What if…"What to change
One lock is too slow (many cores)Shard: N caches, each with its own lock, and pick the shard by hashing the key. Eviction becomes "LRU per shard", which is close enough in practice.
Entries should expireStore an expiry time in each node; treat an expired entry as a miss in Get and remove it.
Limit by bytes, not countEach entry reports its size; Put evicts from the back until the new entry fits.
The callback calls the cacheUnder Safe that deadlocks, because the lock is already held. Collect evicted entries and call the callback after unlocking.
Scans flush the cache (one big report touches everything once)Use a policy that resists scans, like 2Q or segmented LRU: new keys go to a probation list first.
Many serversUse a shared cache (Redis) or accept that each server has its own; this cache stays the per-process layer.

Interview questions

Practice questions

Beginner

  1. Why can't a hash map alone do LRU in O(1)?
  2. What does "least recently used" mean, in terms of the list?

Intermediate

  1. Why a doubly linked list and not a singly linked one?
  2. What are sentinel nodes, and what do they save you?

Advanced

  1. Why does Get need a full lock rather than a read lock?
  2. Your cache's lock is the bottleneck on a 32-core machine. What do you do?

Common design mistakes

  • Keeping a timestamp per key and scanning for the oldest: O(n) eviction.
  • A singly linked list: unlinking a node needs its previous node, which means a search.
  • Forgetting to update the order on Get, which turns LRU into FIFO.
  • Not storing the key in the node, so eviction can't delete it from the map.
  • RLock in Get, because "it's just a read".

Answers

1. Why not just a map?

A map has no order. To find the least recently used key you'd keep a timestamp per key and scan them all: O(n) per eviction.

2. LRU in list terms?

Every use moves a key to the front, so the back of the list is the key that has gone longest without being used. That's the one evicted.

3. Why doubly linked?

To unlink a node you must change its previous node's next. With a prev pointer that's O(1); in a singly linked list you'd have to walk from the head to find it.

4. Sentinels?

A dummy head and tail that are always there. Every real node then has both neighbours, so insert and unlink have no "is the list empty?" or "is this the first node?" branches.

5. Full lock for Get?

Get moves the node to the front, which rewrites several pointers. A read lock lets two Gets do that at once, and their pointer writes interleave and break the list. Our test showed 12 to 19 data races with RLock.

6. Lock is the bottleneck?

Split the cache into shards (say 16), each with its own lock, chosen by the key's hash. Threads touching different shards don't wait for each other. You lose exact global LRU order, which rarely matters.

What interviewers look for: the map + doubly linked list combination explained before coding, O(1) everything, the key stored in the node, clean pointer code (sentinels help), and the read-lock trap.

Designed it yourself and understood the solution? Mark it to track your progress.