Как устроен LRU-кэш и какие ещё бывают политики вытеснения?
- Реализуйте LRU-кэш с O(1) на операцию.
- Чем LRU отличается от LFU?
- Почему полное сканирование таблицы вымывает кэш?
LRU выбрасывает то, к чему дольше всего не обращались. O(1) на операцию даёт пара хеш-таблица + двусвязный список: таблица находит узел, список хранит порядок использования.
- LFU — выбрасывает то, к чему обращались реже всего: держит популярное, но медленно забывает старое
- Слабость LRU — однократное сканирование вымывает горячие данные
- Современные: SLRU, 2Q, ARC, W-TinyLFU (Caffeine) — защита от сканирования и частотные фильтры
А ещё Redis: maxmemory-policy allkeys-lru / allkeys-lfu — приближённые, по выборке ключей
Подробно
LRU (least recently used). Гипотеза — недавно использованное скоро понадобится снова. Реализация за O(1):
- хеш-таблица ключ → узел списка;
- двусвязный список в порядке использования: голова — самый свежий, хвост — самый старый;
get: найти узел через таблицу, перенести в голову;put: если ключ есть — обновить и перенести в голову; если нет — создать узел в голове, а при переполнении удалить хвост (и из таблицы).
Двусвязный — чтобы удалить узел из середины за O(1). В Python — OrderedDict с move_to_end и popitem(last=False), в Java — LinkedHashMap с accessOrder = true и removeEldestEntry.
LFU (least frequently used) — считает обращения, выбрасывает наименее частый. Хорошо держит стабильно популярное, но плохо забывает: вчерашний хит с миллионом обращений сидит в кэше навсегда. Нужны старение счётчиков и O(1)-структура (списки по частотам).
Проблемы LRU:
- вымывание сканированием — один отчёт, читающий всю таблицу, или обход каталога краулером проталкивает через кэш миллионы ключей, которые больше не понадобятся, и вытесняет горячие;
- одноразовые ключи занимают место наравне с популярными.
Улучшения:
- SLRU / 2Q — новый ключ попадает в «испытательный» сегмент и переходит в основной, только если к нему обратились снова;
- ARC — адаптивно делит место между «недавними» и «частыми»;
- W-TinyLFU (Caffeine в Java, Ristretto в Go) — маленькое LRU-окно для новых и частотный фильтр на count-min sketch при входе в основную часть: новый ключ вытеснит жертву, только если встречался чаще неё. Близко к оптимальному на реальных нагрузках;
- TTL — отдельно от политики: данные устаревают по времени.
Где важно: локальные кэши в сервисах, буферный кэш базы (в PostgreSQL — clock sweep, приближение LRU со счётчиком использования), page cache ОС, CDN, Redis (allkeys-lru/allkeys-lfu — приближённые: выбирают жертву из случайной выборки ключей, а не держат точный список).
Пример
from collections import OrderedDict
class LRU:
def __init__(self, capacity):
self.cap, self.data = capacity, OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key) # стал самым свежим
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.cap:
self.data.popitem(last=False) # вытесняем самый старыйЧто хотят услышать
- Хеш-таблица + двусвязный список и почему именно они.
- Операции get и put за O(1) с переносом в голову и удалением хвоста.
- LFU и его недостаток.
- Вымывание сканированием и современные политики — для middle+.
Частые ошибки
- Хранить порядок в массиве или односвязном списке — удаление становится O(n).
- Забыть удалить вытесненный ключ из хеш-таблицы.
Проверьте себя
Один верный вариант. Подумайте, потом откройте ответ.
Показать варианты и ответ
- ✗LRU выбрасывает самый популярный элемент
- ✗LRU устойчив к однократному сканированию
- ✗LRU требует сортировки элементов при каждом обращении
- ✓LRU — хеш-таблица и двусвязный список
Что спросят дальше
Связанные вопросы
Разбор собеседования с sotto
После собеседования sotto разбирает разговор: оценка, каждый вопрос — хорошо, частично или слабо, чего не хватило в ответе и над чем поработать к следующему разу. Первые 30 минут бесплатно.