8. LFU кэш
Введение: Когда новизна уступает популярности
В предыдущей главе мы детально разобрали 7. LRU кэш, опирающийся на принцип временной локальности (temporal locality). Если элемент запросили только что, велика вероятность, что к нему обратятся снова. Однако в реальном сетевом трафике распределение нагрузки почти никогда не бывает равномерным. Оно подчиняется степенному закону Парето и распределению Ципфа (Zipf): около 20% популярных ключей генерируют более 80% всех обращений.
В таких условиях у LRU обнаруживается фундаментальная уязвимость к сканирующим запросам (scan resistance problem). Стоит ночному batch-скрипту или поисковому боту пройтись по базе данных с миллионом уникальных идентификаторов, как LRU послушно вымоет из оперативной памяти действительно ценные, горячие данные, заменив их одноразовым мусором.
Алгоритм LFU (Least Frequently Used) решает эту проблему принципиально: он отслеживает не время последнего доступа, а частоту обращений. Когда лимит памяти исчерпан, из кэша вытесняется элемент с наименьшим счётчиком использования. Это делает LFU идеальным выбором для кэширования статических каталогов товаров, конфигурационных метаданных, скомпилированных SQL-планов, сессий долгоживущих API-клиентов и защиты от сканирующих ботов.
Главная инженерная дилемма LFU кроется в алгоритмической цене: как поддерживать упорядоченность по частоте, не превращая каждую операцию Get и Put в O(n log n) или O(n)? Ответ кроется в элегантной композиции структур данных, гарантирующей строгое O(1) для всех базовых операций.
1. Архитектурный фундамент: Двойная маппа и частотные бакеты
Чтобы добиться константного времени O(1) для операций поиска, инкремента и вытеснения, классический алгоритм LFU (предложенный Ketan Shah, Anirban Mitra и Dhruv Matani) организует данные в двухуровневую систему:
KeyMap: Хеш-таблицаmap[K]*node[K, V](илиmap[K]*Node[K,V]), обеспечивающая мгновенный доступ к узлу по ключу заO(1).FreqMap: Хеш-таблица списковmap[int]*list.List, где ключом выступает целое число — частота обращений, а значением — двусвязный список узлов с данной частотой. Внутри бакета узлы упорядочены по времени обращения (LRU-тайбрейкер): самый свежий узел находится в голове (PushFront), самый старый — в хвосте.minFreq: Целочисленный указатель на текущую минимальную частоту среди всех присутствующих в кэше элементов. ЗначениеminFreqизменяется строго детерминировано.
Рассмотрим поведение системы при операциях чтения и записи:
При Get(key):
- Находим узел в
KeyMap. Если ключ отсутствует — возвращаем промах. - Извлекаем узел из списка
FreqMap[node.freq]. - Инкрементируем счётчик частоты:
node.freq++. - Вставляем узел в начало списка нового бакета
FreqMap[node.freq]. - Если старый бакет
minFreqопустел, инкрементируемminFreq(minFreq++). - Возвращаем значение узла.
При Put(key, value):
- Если ключ уже существует, обновляем его значение и продвигаем частоту точно так же, как в операции
Get. - Если ключ новый, проверяем текущую ёмкость. Если
KeyMapзаполнен до пределаcapacity, находим списокFreqMap[minFreq], удаляем из него самый старый хвостовой узел и стираем его изKeyMap. При необходимости вызываем коллбэкonEvict. - Создаём новый узел со значением
freq = 1. - Помещаем узел в оба хранилища:
KeyMapи бакетFreqMap[1]. - Сбрасываем указатель минимума:
minFreq = 1.
flowchart TD
Req["Запрос Get(key)"] --> Find{"Ключ в KeyMap?"}
Find -->|"Нет"| Miss["Cache Miss<br/>(вернуть false)"]
Find -->|"Да"| Extract["Извлечь узел из списка<br/>FreqMap[node.freq]"]
Extract --> Inc["Инкремент node.freq++"]
Inc --> Insert["Вставить узел в голову<br/>FreqMap[node.freq]"]
Insert --> Check{"Бакет minFreq<br/>стал пустым?"}
Check -->|"Да"| Up["Инкремент: minFreq++"]
Check -->|"Нет"| Ret["Вернуть значение V"]
Up --> Ret
classDef default fill:#0f172a,stroke:#334155,color:#f8fafc
classDef decision fill:#1e293b,stroke:#818cf8,color:#f8fafc
classDef accent fill:#1e293b,stroke:#38bdf8,color:#38bdf8
classDef success fill:#1e293b,stroke:#10b981,color:#10b981
classDef warning fill:#1e293b,stroke:#f59e0b,color:#f59e0b
class Find,Check decision
class Extract,Inc,Insert accent
class Up,Ret success
class Miss warning
Такая архитектура гарантирует, что поиск, перепривязка частоты и вытеснение выполняются за константное время без перемещения массивов в памяти.
2. Production-реализация на Go 1.21+
Ниже представлена потокобезопасная обобщённая (generics) реализация LFU-кэша с использованием стандартной библиотеки Go (container/list), поддержкой таймстампов CreatedAt и настраиваемым хуком вытеснения onEvict.
package lfu
import (
"container/list"
"fmt"
"sync"
"time"
)
// node хранит данные ключа, значение и метаинформацию для LFU-алгоритма
type node[K comparable, V any] struct {
Key K
Value V
Freq int
CreatedAt time.Time
listElem *list.Element // указатель на элемент внутри частотного списка
}
// Cache реализует LFU-кэш с амортизированной сложностью O(1) для всех операций
type Cache[K comparable, V any] struct {
mu sync.RWMutex
capacity int
keyMap map[K]*node[K, V]
freqMap map[int]*list.List
minFreq int
onEvict func(K, V)
}
// New создаёт LFU кэш заданной емкости
func New[K comparable, V any](capacity int, onEvict func(K, V)) (*Cache[K, V], error) {
if capacity <= 0 {
return nil, fmt.Errorf("lfu: capacity must be positive")
}
return &Cache[K, V]{
capacity: capacity,
keyMap: make(map[K]*node[K, V], capacity),
freqMap: make(map[int]*list.List),
minFreq: 1,
onEvict: onEvict,
}, nil
}
// Get возвращает значение и инкрементирует частоту обращения к ключу
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
n, ok := c.keyMap[key]
c.mu.RUnlock()
if !ok {
var zero V
return zero, false
}
c.mu.Lock()
c.moveNodeToNextFreq(n)
val := n.Value
c.mu.Unlock()
return val, true
}
// Put добавляет или обновляет элемент в кэше
func (c *Cache[K, V]) Put(key K, value V) bool {
c.mu.Lock()
defer c.mu.Unlock()
if n, ok := c.keyMap[key]; ok {
n.Value = value
c.moveNodeToNextFreq(n)
return false
}
if len(c.keyMap) >= c.capacity {
c.evict()
}
newNode := &node[K, V]{
Key: key,
Value: value,
Freq: 1,
CreatedAt: time.Now(),
}
c.addToList(newNode)
c.keyMap[key] = newNode
c.minFreq = 1
return true
}
// moveNodeToNextFreq перемещает узел в бакет freq + 1
func (c *Cache[K, V]) moveNodeToNextFreq(n *node[K, V]) {
c.removeFromList(n)
n.Freq++
c.addToList(n)
// Если старый бакет стал пустым и это был minFreq, увеличиваем minFreq
if freqList, ok := c.freqMap[n.Freq-1]; ok && freqList.Len() == 0 {
delete(c.freqMap, n.Freq-1)
if c.minFreq == n.Freq-1 {
c.minFreq = n.Freq
}
}
}
// evict удаляет наименее часто используемый элемент
func (c *Cache[K, V]) evict() {
freqList, ok := c.freqMap[c.minFreq]
if !ok || freqList.Len() == 0 {
return
}
oldest := freqList.Back()
if oldest == nil {
return
}
n := oldest.Value.(*node[K, V])
freqList.Remove(oldest)
if freqList.Len() == 0 {
delete(c.freqMap, c.minFreq)
}
delete(c.keyMap, n.Key)
if c.onEvict != nil {
c.onEvict(n.Key, n.Value)
}
}
// addToList добавляет узел в начало списка его частоты
func (c *Cache[K, V]) addToList(n *node[K, V]) {
if _, ok := c.freqMap[n.Freq]; !ok {
c.freqMap[n.Freq] = list.New()
}
n.listElem = c.freqMap[n.Freq].PushFront(n)
}
// removeFromList удаляет узел из текущего списка
func (c *Cache[K, V]) removeFromList(n *node[K, V]) {
if n.listElem != nil {
c.freqMap[n.Freq].Remove(n.listElem)
n.listElem = nil
}
}
Ключевые инженерные решения в этой реализации:
- LRU-тайбрейкер внутри бакета: Метод
PushFrontдобавляет элемент в начало, аevictудаляет с хвоста (freqList.Back()). При равной частоте вытесняется узел, к которому дольше всего не обращались. - Оптимизация
minFreq: Мы не сканируемfreqMapв поисках минимального ключа. Минимальная частота либо сбрасывается в1при создании нового элемента, либо инкрементируется на1, когда бакетminFreqстановится абсолютно пустым. Это фундаментальный инвариантO(1). - Двухфазная блокировка в
Get:RLockпозволяет быстро отсеять отсутствующие ключи без эксклюзивной блокировки, аLockактивируется только при подтверждённом попадании для продвижения узла по частотной сетке.
3. Mechanical Sympathy: Указательная паутина и GC
С точки зрения архитектуры современного процессора и рантайма Go, LFU является одной из самых «дорогих» структур данных:
- Pointer Chasing и промахи кэша процессора: При вызове
moveNodeToNextFreqпроцессор вынужден выполнить цепочку нелокальных чтений памяти: прочитать полеn.listElem$\to$ разыменовать указателиnext/prev(илиprev/next) для исключения элемента из списка $\to$ найти запись в хеш-таблицеfreqMap[freq]$\to$ разыменовать заголовок нового спискаfreqMap[freq].list$\to$ модифицироватьnext/prevнового соседа. Каждое разыменование указателя — потенциальный Cache Miss. В кэше на 500 000 элементов узлы хаотично разбросаны по виртуальной памяти, и аппаратный префетчер процессора (Hardware Prefetcher) оказывается бессилен. - Давление на Garbage Collector: Каждый элемент порождает минимум 3–4 указателя в управляемой куче. При активном потоке операций
evictиPutаллокатор рантайма работает под предельной нагрузкой, а GC тратит драгоценные процессорные циклы на трассировку огромного графа объектов. В сервисах с десятками гигабайт памяти суммарные задержки Stop-The-World (STW) могут достигать неприемлемых величин. - Escape Analysis: Экземпляры
newNodeиlist.Elementгарантированно покидают стек (escape to heap). Компилятор не может аллоцировать их на стеке функции, поскольку они сохраняются в глобальных хеш-таблицах и передаются в методыlist.PushFront.
Инженерная оптимизация для Low-Latency:
Вместо стандартного container/list в высокопроизводительных сервисах используют плоские массивы индексов, аналогично подходу в 4. Disjoint set union DSU, Union Find. Узлы хранятся в непрерывном срезе []node, а связи next[] и prev[] представляются целочисленными срезами []int. Все манипуляции производятся с 32-битными индексами вместо 64-битных указателей. Это сокращает объём памяти на 40%, обеспечивает отличную пространственную локальность (Spatial Locality) и сводит работу GC к сканированию одного непрерывного буфера.
4. Конкурентность и горизонтальное масштабирование
Одиночный мьютекс sync.RWMutex превращается в узкое горлышко (bottleneck) уже при нагрузке 15–20 тысяч RPS. Конкурирующие горутины блокируются на futex-системных вызовах ядра Linux, вызывая частый контекстный свитч потоков операционной системы (структур M в рантайме Go).
Для масштабирования LFU на многоядерных архитектурах применяются следующие инженерные приёмы:
- Шардирование (Striping / Sharding) по
hash(key) % N:
Создаётся пул изNнезависимых LFU-кэшей, каждый из которых защищён собственным мьютексом. Конфликты за блокировку снижаются вNраз. Хотя глобальная точность счётчикаminFreqформально дробится на локальные минимумы, на практике приN >= 16и качественной хеш-функции отклонение в hit rate составляет не более 1–2%. - Lock-free чтение с батчингом (Batched Write Buffering):
Операция чтенияGetпроверяет ключ через атомарные указатели (atomic.Pointer) или concurrent-карты без захвата эксклюзивного мьютекса. Инкременты частоты и задачи на вытеснение помещаются в неблокирующий кольцевой буфер или буферизованный каналchan. Фоновая горутина вычитывает события пакетами и обновляет бакетыFreqMapза один проход. Это даёт слабую согласованность (eventual consistency), но гарантирует субмикросекундную задержку на чтение. - TTL-гибридизация:
Чистый алгоритм LFU принципиально не очищает неактуальные ключи, если к ним перестали обращаться. Для защиты от утечек памяти запускается периодический воркер, который проверяет метки времениCreatedAtи удаляет элементы поTTL, предотвращая захламление памяти.
5. LFU vs LRU: Выбор под паттерн трафика
| Критерий | LRU (Least Recently Used) | LFU (Least Frequently Used) |
|---|---|---|
| Метрика вытеснения | Время последнего доступа | Суммарная частота обращений |
| Расход памяти | Низкий (один двусвязный список) | Повышенный (мапа бакетов и списки) |
| Холодный старт | Мгновенная адаптация к новому трафику | Требует прогрева (warm-up) |
Hit rate при законе Ципфа (Zipf) |
Умеренный (уязвим к сканированию) | Максимальный (устойчив к всплескам) |
| Сложность реализации | Простая и наглядная | Требует аккуратного контроля инвариантов |
| Идеальные сценарии | Пользовательские сессии, ленты новостей, пагинация | Справочники, метаданные БД, ML-эмбеддинги, CDN |
В стандартной практике Go-разработки алгоритм LRU остаётся решением по умолчанию благодаря исключительной простоте реализации и минимальному накладным расходам на сборщик мусора. LFU становится безальтернативным выбором, когда доступ к данным строго подчиняется степенному закону и стоимость промаха кэша (обращение к тяжёлой БД или удалённому сервису) крайне высока.
Итог
- LFU кэш незаменим в системах с неравномерным трафиком (распределение
Zipf), где ценность объекта определяется накопленной популярностью, а не секундной новизной. - Архитектурная триада
KeyMap+FreqMap+minFreqобеспечивает математически строгое амортизированноеO(1)для операцийGet,Putиevict. - В языке Go связка структур из
container/listсоздаёт существенную нагрузку на GC и вызывает деградацию производительности из-за Pointer Chasing. В сверхнагруженных подсистемах предпочтение отдаётся плоским массивам индексов. - Конкурентная масштабируемость достигается шардированием пространства ключей на
N >= 16независимых бакетов либо разделением путей чтения и записи через каналыchanи батчинг. - Проблема устаревания данных решается периодическим делением частот пополам (
freq = freq / 2) или применением архитектурыW-TinyLFU.
Мы успешно завершили седьмой раздел, посвящённый продвинутым структурам данных: исследованию подверглись деревья отрезков, дерево Фенвика, разреженные таблицы, система непересекающихся множеств DSU, списки с пропусками Skip List, декартово дерево Treap, а также алгоритмы кэширования LRU и LFU.
Теперь мы переходим к фундаментальному разделу алгоритмов: методам упорядочивания данных. Понимание сортировок лежит в основе эффективного построения индексов в базах данных, оптимизации операторов соединения (Merge Join) и тонкого понимания внутреннего устройства рантайма Go. В следующей главе мы начнём с классического алгоритма, демонстрирующего механику перестановок и квадратичную сложность.