сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Spring и Hibernate

Кэш первого и второго уровня

Кэши ORM: один включён всегда, второй - нет

Запросил один и тот же объект по идентификатору три раза подряд в одной сессии. К базе ушёл ОДИН запрос, а вернулся все три раза один и тот же объект в памяти. Это кэш первого уровня, и выключить его нельзя.

А вот статистика того же прогона по кэшу второго уровня показала нули во всех строках: ни записей, ни попаданий, ни промахов. Он по умолчанию просто не включён, и это первое, что стоит знать, прежде чем на него рассчитывать.

// Формулировки: «какие уровни кэша есть в Hibernate?», «чем они отличаются?», «почему кэш отдаёт устаревшие данные?».

Кэш первого уровня

Кэш первого уровня - это и есть контекст персистентности, память сессии. Сессия тут - разговор с базой в рамках одной операции: она открывается, накапливает загруженные объекты и закрывается. Кэш включён всегда, живёт ровно столько, сколько живёт сессия, и никому её не отдаёт: у каждой сессии свой.

Он даёт две вещи. Экономию запросов - повторное обращение по тому же идентификатору не идёт в базу, у меня три обращения стоили один запрос. И гарантию тождества - одна строка базы в пределах сессии представлена ровно одним объектом, поэтому сравнение по ссылке внутри сессии осмысленно.

// Обратная сторона - память. Сессия, через которую прошли сотни тысяч объектов, держит их все, и приложение упирается в предел кучи - той области памяти, где живут все объекты Java. Для массовой обработки контекст чистят вручную пачками либо используют отдельный режим без накопления.

кэш первого уровня
память сессии, включён всегда, у каждой сессии свой

Кэш второго уровня и кэш запросов

Кэш второго уровня общий для всех сессий и живёт вместе с приложением. Сам Hibernate его только использует, а хранилище подключается отдельно - Ehcache, Infinispan или другое через стандарт JCache. Отсюда и нули в моей статистике: провайдер не подключён, значит кэша нет.

Кэшируются в нём сущности по идентификатору, а также коллекции, и включается это точечно аннотацией на конкретных классах - не «на всё приложение». Хорошие кандидаты те, что читают часто, а меняют редко: справочники, настройки, категории.

// Отдельно живёт кэш запросов, и его включают реже, чем думают. Он хранит не сами объекты, а СПИСКИ идентификаторов по конкретному запросу с конкретными параметрами. Значит, работает только при полном совпадении параметров и обесценивается при любом изменении затронутых таблиц. На запросах с разнообразными фильтрами он чаще вредит, чем помогает.

кэш второго уровня
общий на приложение, требует отдельного хранилища, включается точечно
кэш запросов
хранит списки идентификаторов под конкретный запрос с параметрами

Где кэш начинает врать

Главное правило: кэш второго уровня знает только про изменения, сделанные через саму ORM. Всё, что прошло мимо неё, для кэша не существует.

Отсюда три типовых источника устаревших данных. Первый - массовые запросы на изменение, написанные вручную: они меняют строки напрямую и кэш не трогают. Второй - другое приложение или скрипт миграции, работающие с той же базой. Третий - несколько экземпляров твоего же приложения: у каждого свой кэш, и изменение в одном не видно другому, пока хранилище не сделано распределённым.

// Отсюда и выбор кандидатов. Кэшируй то, где короткая рассинхронизация не страшна: справочники, настройки, описания. Не кэшируй остатки на складе, балансы и всё, по чему принимаются решения. И задавай время жизни записи - тогда даже пропущенное изменение проживёт ограниченное время.

устаревание кэша
изменения мимо ORM кэш не видит и не сбрасывает

Как отвечать: «Почему кэш второго уровня отдаёт устаревшие данные?»

Потому что он сбрасывается только на изменения, прошедшие через саму ORM. Три типовых случая. Массовый запрос на обновление, написанный руками: он идёт напрямую в базу, строки меняются, кэш об этом не узнаёт. Второй потребитель той же базы - другое приложение, скрипт миграции, ручная правка администратором. И горизонтальное масштабирование: у каждого экземпляра приложения свой кэш, изменение в одном не видно другим, пока хранилище не распределённое. Поэтому я кэширую только то, где короткая рассинхронизация не опасна - справочники, настройки, категории - и обязательно ставлю время жизни записи. Остатки, балансы и всё, по чему принимаются решения, во второй уровень не кладу. И держу в голове, что по умолчанию этот кэш вообще выключен: без подключённого хранилища его просто нет, я это видел по нулям в статистике.

Почему это сильный ответ: названы три разных источника рассинхронизации, включая масштабирование, о котором забывают, и дан критерий выбора того, что вообще можно кэшировать.

На чём валят

  • Считать, что кэш второго уровня работает из коробки. Без подключённого хранилища его нет - в статистике сплошные нули.
  • Кэшировать остатки, балансы и цены. Любое изменение мимо ORM превращает их в правдоподобное враньё.
  • Забыть про несколько экземпляров приложения. У каждого свой кэш, и они расходятся между собой.
  • Включать кэш запросов «для скорости». Он привязан к точным параметрам и сбрасывается при изменении таблиц.
  • Тянуть сотни тысяч объектов через одну сессию. Кэш первого уровня держит их все, и приложение упирается в память.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.

  1. #caching1 / 5
    Чем кэш второго уровня (L2) отличается от L1?
    A)L2 — это тот же L1, просто переименованный в новых версиях Hibernate без функциональных различий
    B)L2 разделяется между сессиями, опционален и требует настройки провайдера
    C)L2 работает внутри одной сессии, а L1 — общий для всех сессий приложения на всё время работы
    D)L2 кэширует только результаты, а L1 — только сами сущности, поэтому их используют вместе
    показать ответ и разбор
    +B)L2 разделяется между сессиями, опционален и требует настройки провайдера

    // разбор: L2-кэш РАЗДЕЛЯЕТСЯ между всеми сессиями одной SessionFactory (в отличие от L1, живущего внутри сессии). Он ОПЦИОНАЛЕН: включается явно и требует провайдера (Ehcache, Infinispan, Hazelcast) и пометки сущностей @Cacheable/@Cache со стратегией параллелизма (read-only, read-write и др.). Полезен для СПРАВОЧНЫХ, редко меняющихся данных, читаемых часто — экономит обращения к БД. Риски: устаревшие данные и сложность инвалидации, особенно если БД правят мимо Hibernate. Отдельно есть кэш ЗАПРОСОВ (query cache) — под результаты запросов.

  2. #caching2 / 5
    В каком случае L2-кэш может вернуть устаревшие данные?
    A)Только если кэш заполнен до отказа и Hibernate вытесняет из него самые свежие записи первыми
    B)Такого встречается редко: Hibernate держит L2-кэш согласованным с содержимым базы данных
    C)Когда БД меняют в обход Hibernate (нативный SQL, другое приложение, миграция)
    D)Только при чтении из реплики базы данных; при чтении с мастера кэш возвращает свежие данные
    показать ответ и разбор
    +C)Когда БД меняют в обход Hibernate (нативный SQL, другое приложение, миграция)

    // разбор: L2-кэш согласуется с БД только через ОПЕРАЦИИ HIBERNATE: когда данные меняет сам Hibernate, он инвалидирует соответствующие записи кэша. Но если БД правят В ОБХОД: нативный bulk-UPDATE/DELETE (в т.ч. JPQL modifying-запросы не всегда чистят кэш корректно), другое приложение, ручная миграция, репликация — Hibernate об этом не знает, и L2 отдаёт УСТАРЕВШИЕ данные до истечения TTL или ручной инвалидации. Поэтому L2 применяют к данным, которые меняются преимущественно через это же приложение, и продумывают инвалидацию. «Инвалидация кэша» — известная трудная проблема.

  3. #caching3 / 5
    Что кэширует query cache (кэш запросов) и почему он капризен?
    A)Скомпилированные планы выполнения SQL на стороне СУБД, поэтому его настройка не касается приложения
    B)Полные строки таблиц в оперативной памяти приложения, заменяя собой кэши первого и второго уровня
    C)Только запросы на запись (INSERT/UPDATE), группируя их в пакеты для последующей отправки в базу
    D)Результаты запросов (списки id) по тексту+параметрам; легко инвалидируется при любой записи в таблицу
    показать ответ и разбор
    +D)Результаты запросов (списки id) по тексту+параметрам; легко инвалидируется при любой записи в таблицу

    // разбор: Query cache хранит РЕЗУЛЬТАТЫ запросов — обычно списки идентификаторов — под ключом «текст запроса + значения параметров», а сами сущности берёт из L2 (поэтому работает в связке с ним). Он капризен: любая модификация ЗАТРОНУТОЙ ТАБЛИЦЫ инвалидирует связанные записи query cache (иначе вернул бы устаревший список), поэтому на часто изменяемых таблицах он почти не даёт выигрыша и может даже вредить. Полезен для ПОВТОРЯЮЩИХСЯ запросов с одинаковыми параметрами к редко меняющимся данным. Включается отдельно и требует явного setCacheable.

  4. #caching4 / 5
    Гарантирует ли L1-кэш, что два find по одному id в одной сессии дадут один объект?
    A)Да — в пределах сессии сущность с данным id представлена одним экземпляром
    B)Нет, каждый find создаёт новый объект из свежего результата запроса к базе данных заново
    C)Да, но только если сущность помечена как @Cacheable и включён кэш второго уровня приложения
    D)Нет, идентичность обеспечивается лишь в пределах одной транзакции, но не в пределах одной сессии
    показать ответ и разбор
    +A)Да — в пределах сессии сущность с данным id представлена одним экземпляром

    // разбор: L1-кэш обеспечивает ИДЕНТИЧНОСТЬ: в пределах одного контекста персистентности сущность с конкретным (тип, id) существует в ЕДИНСТВЕННОМ экземпляре. Повторный find/загрузка по тому же id вернёт ТОТ ЖЕ объект (==), без второго SELECT, и увидит уже сделанные в сессии изменения. Это даёт согласованность внутри единицы работы (аналог repeatable read на уровне приложения). Между РАЗНЫМИ сессиями такой гарантии нет — там объекты разные (detached-копии). На identity-гарантии строятся многие ожидания корректности кода JPA.

  5. #caching5 / 5
    Стоит ли кэшировать в L2 часто изменяемые транзакционные данные (например, остатки на счетах)?
    A)Да, именно горячие изменяемые данные дают максимальный выигрыш от кэширования, их и кэшируют первыми
    B)Нет — выигрыша мало, а риск отдать устаревшее и сложность инвалидации высоки
    C)Да, L2 автоматически обновляется при каждом изменении в реальном времени, поэтому устаревания встречается редко
    D)Разницы нет: L2-кэш одинаково эффективен для данных независимо от частоты их изменения
    показать ответ и разбор
    +B)Нет — выигрыша мало, а риск отдать устаревшее и сложность инвалидации высоки

    // разбор: L2-кэш выгоден для данных, которые ЧИТАЮТ часто, а МЕНЯЮТ редко: справочники, конфигурации, категории. Часто изменяемые транзакционные данные — плохой кандидат: почти каждое чтение сопровождается инвалидацией, выигрыш испаряется, а риск отдать устаревшее (особенно при изменениях мимо Hibernate) и цена согласованности растут. Кэшировать «на всякий случай» вредно. Прежде чем включать L2, измеряют: какие запросы горячие, насколько данные статичны, и выбирают стратегию параллелизма (read-only для неизменяемых). Инвалидация — главная сложность кэширования.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.