3. LRU Cache
«Старое уступает место новому не из прихоти, а из суровой нехватки пространства».
— Брайан Керниган
Архитектура LRU-кэша: Композиция структур, конкуренция и механика памяти
Least Recently Used (LRU) кэш — один из наиболее часто запрашиваемых архитектурных паттернов на техническом интервью уровня Middle+ и Senior. Его задача обманчиво проста: хранить ограниченный объем данных и при нехватке памяти вытеснять те элементы, к которым дольше всего не было обращений.
Однако реализация LRU-кэша, способного стабильно выдерживать конкурентную нагрузку, не создающего утечек памяти и сводящего к минимуму латентность в горячем пути, требует глубокого знания устройства рантайма Go, кэш-линий процессора и поведения сборщика мусора (GC).
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef warn fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Op["Вызов Get(key) или Put(key, val)"]:::entry --> Lock["Захват sync.Mutex"]:::process
Lock --> Find{"Ключ присутствует в Map?"}:::process
Find -- "Да (Hit)" --> Update["Перемещение узла в голову списка (Head)<br/>Обновление payload при Put"]:::accent
Find -- "Нет (Miss)" --> CheckCap{"size >= capacity?"}:::process
CheckCap -- "Да (Кэш переполнен)" --> Evict["Извлечение хвоста (Tail.Prev)<br/>delete(map, victim.key)<br/>size--"]:::warn
CheckCap -- "Нет" --> Alloc["Создание нового узла &Node{}"]:::process
Evict --> Alloc
Alloc --> Insert["Добавление в голову списка (Head.Next)<br/>map[key] = node<br/>size++"]:::accent
Update --> Unlock["Разблокировка мьютекса<br/>Возврат результата"]:::success
Insert --> Unlock
1. Архитектурный паттерн: Симбиоз Map и Двусвязного списка
LRU-кэш требует выполнения двух взаимоисключающих требований с константной сложностью $O(1)$:
- Мгновенный поиск элемента по ключу $\to$ $O(1)$ через хэш-таблицу
map[string]*Node. - Мгновенное изменение приоритета и удаление жертвы $\to$ $O(1)$ через двусвязный список.
Массивы и слайсы здесь неприменимы: удаление или вставка в середину слайса требует сдвига элементов за линейное время $O(N)$. Хэш-таблица сама по себе не хранит хронологический порядок доступа. Только комбинация map (хранящего указатели на узлы списка) и двусвязного списка (связывающего узлы по времени обращения) обеспечивает честное $O(1)$ для всех операций.
2. Идиоматичная реализация на Go
package lru
import "sync"
// Node представляет элемент двусвязного списка
type Node struct {
key string
value any
prev *Node
next *Node
}
// LRUCache реализует потокобезопасный LRU-кэш с O(1) операциями
type LRUCache struct {
mu sync.Mutex
capacity int
size int
items map[string]*Node
head *Node // Фиктивная голова (самый свежий элемент — head.next)
tail *Node // Фиктивный хвост (кандидат на вытеснение — tail.prev)
}
// NewLRUCache создает новый кэш заданной емкости
func NewLRUCache(capacity int) *LRUCache {
if capacity <= 0 {
capacity = 16
}
c := &LRUCache{
capacity: capacity,
items: make(map[string]*Node, capacity),
head: &Node{},
tail: &Node{},
}
// Замыкаем фиктивные узлы друг на друга
c.head.next = c.tail
c.tail.prev = c.head
return c
}
// Get возвращает значение и обновляет приоритет элемента
func (c *LRUCache) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
node, ok := c.items[key]
if !ok {
return nil, false
}
// Элемент запрошен — перемещаем его в начало как самый свежий
c.moveToHead(node)
return node.value, true
}
// Put вставляет или обновляет элемент, при необходимости вытесняя старый
func (c *LRUCache) Put(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
if node, ok := c.items[key]; ok {
// Ключ уже существует: обновляем payload и двигаем в голову
node.value = value
c.moveToHead(node)
return
}
// Создаем новый узел
newNode := &Node{
key: key,
value: value,
}
c.items[key] = newNode
c.addToHead(newNode)
c.size++
// Проверяем превышение емкости
if c.size > c.capacity {
// Удаляем наименее используемый элемент (хвост списка)
victim := c.removeTail()
delete(c.items, victim.key)
c.size--
}
}
// Вспомогательные методы управления двусвязным списком
func (c *LRUCache) addToHead(node *Node) {
node.prev = c.head
node.next = c.head.next
c.head.next.prev = node
c.head.next = node
}
func (c *LRUCache) removeNode(node *Node) {
node.prev.next = node.next
node.next.prev = node.prev
}
func (c *LRUCache) moveToHead(node *Node) {
c.removeNode(node)
c.addToHead(node)
}
func (c *LRUCache) removeTail() *Node {
victim := c.tail.prev
c.removeNode(victim)
return victim
}
3. Механика памяти: Кэш-линии CPU и сборщик мусора
1. Pointer Chasing и промахи L1/L2 кэша
В классической реализации каждый Node аллоцируется отдельно в куче через runtime.mallocgc.
Узлы разбросаны по разным виртуальным страницам памяти. При перемещении узла процессор выполняет доступ по 4 указателям (node.prev, node.next, c.head.next и т.д.). Каждый такой скачок может вызывать промах L1d-кэша процессора с задержкой в ~100–150 тактов.
Решение для High-Performance сервисов (Array-backed / Indexed LRU):
Вместо связных списков на указателях узлы упаковываются в один сплошной плоский слайс nodes := make([]Node, capacity).
Вместо указателей *Node используются целочисленные индексы int32:
type IndexedNode struct {
key string
value any
prev int32 // Индекс в слайсе, -1 если нет
next int32
}
Такой подход устраняет фрагментацию памяти, кардинально снижает нагрузку на сборщик мусора и позволяет аппаратному префетчеру CPU работать с максимальной эффективностью.
2. Парадокс синхронизации в LRU: Почему sync.RWMutex вреден?
В классических структурах данных sync.RWMutex позволяет десяткам горутин одновременно читать данные через RLock().
Но в LRU-кэше операция Get(key) не является чистым чтением!
При каждом успешном Get вызывается moveToHead(node), который мутирует ссылки prev и next списка. Это операция записи!
Если использовать RLock() при чтении, то параллельные вызовы Get приведут к одновременной мутации указателей списка без эксклюзивной блокировки, что мгновенно вызовет Data Race и повреждение связности списка.
Следовательно, в LRU операция Get обязана брать полный Lock(), и разделяемый RWMutex теряет свой смысл, лишь добавляя накладные расходы на атомарный счетчик читателей.
4. Оптимизация для продакшена: Шардирование кэша
Чтобы масштабировать LRU-кэш на десятки ядер CPU без блокировки единым мьютексом, применяют шардирование (Striped / Sharded LRU):
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
Req["Запрос Get / Put"]:::process --> Hash["Хэширование ключа:<br/>shardID = FNV1a(key) % N"]:::accent
Hash --> Shard0["Шард 0 (Mutex + LRU)"]:::process
Hash --> Shard1["Шард 1 (Mutex + LRU)"]:::process
Hash --> ShardN["Шард N-1 (Mutex + LRU)"]:::process
Shard0 --> Res["Локальная операция"]:::success
Shard1 --> Res
ShardN --> Res
type ShardedLRU struct {
shardMask uint32
shards []*LRUCache
}
func NewShardedLRU(numShards, totalCapacity int) *ShardedLRU {
// Количество шардов приводим к степени двойки для быстрой битовой маски
shardCap := totalCapacity / numShards
shards := make([]*LRUCache, numShards)
for i := 0; i < numShards; i++ {
shards[i] = NewLRUCache(shardCap)
}
return &ShardedLRU{
shardMask: uint32(numShards - 1),
shards: shards,
}
}
func (s *ShardedLRU) getShard(key string) *LRUCache {
// Быстрый FNV-1a хэш
var h uint32 = 2166136261
for i := 0; i < len(key); i++ {
h = (h ^ uint32(key[i])) * 16777619
}
return s.shards[h&s.shardMask]
}
func (s *ShardedLRU) Get(key string) (any, bool) {
return s.getShard(key).Get(key)
}
func (s *ShardedLRU) Put(key string, value any) {
s.getShard(key).Put(key, value)
}
Шардирование снижает вероятность contention ровно в $N$ раз, позволяя разным ядрам процессора обрабатывать запросы параллельно.
Вопросы для собеседования
Резюме
- Архитектура LRU строится на симбиозе хэш-таблицы (поиск за $O(1)$) и двусвязного списка (обновление порядка за $O(1)$).
- Использование фиктивных узлов
headиtailисключает проверки наnilи делает код устойчивым к ошибкам. - Операция
Getв LRU является операцией записи (мутирует список), поэтому требует эксклюзивногоsync.Mutex. - Для высоконагруженных систем стандартом является шардирование кэша по битовой маске FNV-хэша.
В следующей лекции мы разберем кэш, ориентированный на частоту обращений: 4. LFU Cache, где научимся поддерживать двухуровневые списки с минимальной частотой за константное время $O(1)$.