Кэш первого и второго уровня
Запросил один и тот же объект по идентификатору три раза подряд в одной сессии. К базе ушёл ОДИН запрос, а вернулся все три раза один и тот же объект в памяти. Это кэш первого уровня, и выключить его нельзя.
А вот статистика того же прогона по кэшу второго уровня показала нули во всех строках: ни записей, ни попаданий, ни промахов. Он по умолчанию просто не включён, и это первое, что стоит знать, прежде чем на него рассчитывать.
// Формулировки: «какие уровни кэша есть в Hibernate?», «чем они отличаются?», «почему кэш отдаёт устаревшие данные?».
Кэш первого уровня
Кэш первого уровня - это и есть контекст персистентности, память сессии. Сессия тут - разговор с базой в рамках одной операции: она открывается, накапливает загруженные объекты и закрывается. Кэш включён всегда, живёт ровно столько, сколько живёт сессия, и никому её не отдаёт: у каждой сессии свой.
Он даёт две вещи. Экономию запросов - повторное обращение по тому же идентификатору не идёт в базу, у меня три обращения стоили один запрос. И гарантию тождества - одна строка базы в пределах сессии представлена ровно одним объектом, поэтому сравнение по ссылке внутри сессии осмысленно.
// Обратная сторона - память. Сессия, через которую прошли сотни тысяч объектов, держит их все, и приложение упирается в предел кучи - той области памяти, где живут все объекты Java. Для массовой обработки контекст чистят вручную пачками либо используют отдельный режим без накопления.
- кэш первого уровня
- память сессии, включён всегда, у каждой сессии свой
Кэш второго уровня и кэш запросов
Кэш второго уровня общий для всех сессий и живёт вместе с приложением. Сам Hibernate его только использует, а хранилище подключается отдельно - Ehcache, Infinispan или другое через стандарт JCache. Отсюда и нули в моей статистике: провайдер не подключён, значит кэша нет.
Кэшируются в нём сущности по идентификатору, а также коллекции, и включается это точечно аннотацией на конкретных классах - не «на всё приложение». Хорошие кандидаты те, что читают часто, а меняют редко: справочники, настройки, категории.
// Отдельно живёт кэш запросов, и его включают реже, чем думают. Он хранит не сами объекты, а СПИСКИ идентификаторов по конкретному запросу с конкретными параметрами. Значит, работает только при полном совпадении параметров и обесценивается при любом изменении затронутых таблиц. На запросах с разнообразными фильтрами он чаще вредит, чем помогает.
- кэш второго уровня
- общий на приложение, требует отдельного хранилища, включается точечно
- кэш запросов
- хранит списки идентификаторов под конкретный запрос с параметрами
Где кэш начинает врать
Главное правило: кэш второго уровня знает только про изменения, сделанные через саму ORM. Всё, что прошло мимо неё, для кэша не существует.
Отсюда три типовых источника устаревших данных. Первый - массовые запросы на изменение, написанные вручную: они меняют строки напрямую и кэш не трогают. Второй - другое приложение или скрипт миграции, работающие с той же базой. Третий - несколько экземпляров твоего же приложения: у каждого свой кэш, и изменение в одном не видно другому, пока хранилище не сделано распределённым.
// Отсюда и выбор кандидатов. Кэшируй то, где короткая рассинхронизация не страшна: справочники, настройки, описания. Не кэшируй остатки на складе, балансы и всё, по чему принимаются решения. И задавай время жизни записи - тогда даже пропущенное изменение проживёт ограниченное время.
- устаревание кэша
- изменения мимо ORM кэш не видит и не сбрасывает
Как отвечать: «Почему кэш второго уровня отдаёт устаревшие данные?»
Потому что он сбрасывается только на изменения, прошедшие через саму ORM. Три типовых случая. Массовый запрос на обновление, написанный руками: он идёт напрямую в базу, строки меняются, кэш об этом не узнаёт. Второй потребитель той же базы - другое приложение, скрипт миграции, ручная правка администратором. И горизонтальное масштабирование: у каждого экземпляра приложения свой кэш, изменение в одном не видно другим, пока хранилище не распределённое. Поэтому я кэширую только то, где короткая рассинхронизация не опасна - справочники, настройки, категории - и обязательно ставлю время жизни записи. Остатки, балансы и всё, по чему принимаются решения, во второй уровень не кладу. И держу в голове, что по умолчанию этот кэш вообще выключен: без подключённого хранилища его просто нет, я это видел по нулям в статистике.
Почему это сильный ответ: названы три разных источника рассинхронизации, включая масштабирование, о котором забывают, и дан критерий выбора того, что вообще можно кэшировать.
На чём валят
- −Считать, что кэш второго уровня работает из коробки. Без подключённого хранилища его нет - в статистике сплошные нули.
- −Кэшировать остатки, балансы и цены. Любое изменение мимо ORM превращает их в правдоподобное враньё.
- −Забыть про несколько экземпляров приложения. У каждого свой кэш, и они расходятся между собой.
- −Включать кэш запросов «для скорости». Он привязан к точным параметрам и сбрасывается при изменении таблиц.
- −Тянуть сотни тысяч объектов через одну сессию. Кэш первого уровня держит их все, и приложение упирается в память.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.
- Чем кэш второго уровня (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) — под результаты запросов.
- В каком случае 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 применяют к данным, которые меняются преимущественно через это же приложение, и продумывают инвалидацию. «Инвалидация кэша» — известная трудная проблема.
- Что кэширует query cache (кэш запросов) и почему он капризен?A)Скомпилированные планы выполнения SQL на стороне СУБД, поэтому его настройка не касается приложенияB)Полные строки таблиц в оперативной памяти приложения, заменяя собой кэши первого и второго уровняC)Только запросы на запись (INSERT/UPDATE), группируя их в пакеты для последующей отправки в базуD)Результаты запросов (списки id) по тексту+параметрам; легко инвалидируется при любой записи в таблицу
показать ответ и разбор
+D)Результаты запросов (списки id) по тексту+параметрам; легко инвалидируется при любой записи в таблицу// разбор: Query cache хранит РЕЗУЛЬТАТЫ запросов — обычно списки идентификаторов — под ключом «текст запроса + значения параметров», а сами сущности берёт из L2 (поэтому работает в связке с ним). Он капризен: любая модификация ЗАТРОНУТОЙ ТАБЛИЦЫ инвалидирует связанные записи query cache (иначе вернул бы устаревший список), поэтому на часто изменяемых таблицах он почти не даёт выигрыша и может даже вредить. Полезен для ПОВТОРЯЮЩИХСЯ запросов с одинаковыми параметрами к редко меняющимся данным. Включается отдельно и требует явного setCacheable.
- Гарантирует ли L1-кэш, что два find по одному id в одной сессии дадут один объект?A)Да — в пределах сессии сущность с данным id представлена одним экземпляромB)Нет, каждый find создаёт новый объект из свежего результата запроса к базе данных зановоC)Да, но только если сущность помечена как @Cacheable и включён кэш второго уровня приложенияD)Нет, идентичность обеспечивается лишь в пределах одной транзакции, но не в пределах одной сессии
показать ответ и разбор
+A)Да — в пределах сессии сущность с данным id представлена одним экземпляром// разбор: L1-кэш обеспечивает ИДЕНТИЧНОСТЬ: в пределах одного контекста персистентности сущность с конкретным (тип, id) существует в ЕДИНСТВЕННОМ экземпляре. Повторный find/загрузка по тому же id вернёт ТОТ ЖЕ объект (==), без второго SELECT, и увидит уже сделанные в сессии изменения. Это даёт согласованность внутри единицы работы (аналог repeatable read на уровне приложения). Между РАЗНЫМИ сессиями такой гарантии нет — там объекты разные (detached-копии). На identity-гарантии строятся многие ожидания корректности кода JPA.
- Стоит ли кэшировать в L2 часто изменяемые транзакционные данные (например, остатки на счетах)?A)Да, именно горячие изменяемые данные дают максимальный выигрыш от кэширования, их и кэшируют первымиB)Нет — выигрыша мало, а риск отдать устаревшее и сложность инвалидации высокиC)Да, L2 автоматически обновляется при каждом изменении в реальном времени, поэтому устаревания встречается редкоD)Разницы нет: L2-кэш одинаково эффективен для данных независимо от частоты их изменения
показать ответ и разбор
+B)Нет — выигрыша мало, а риск отдать устаревшее и сложность инвалидации высоки// разбор: L2-кэш выгоден для данных, которые ЧИТАЮТ часто, а МЕНЯЮТ редко: справочники, конфигурации, категории. Часто изменяемые транзакционные данные — плохой кандидат: почти каждое чтение сопровождается инвалидацией, выигрыш испаряется, а риск отдать устаревшее (особенно при изменениях мимо Hibernate) и цена согласованности растут. Кэшировать «на всякий случай» вредно. Прежде чем включать L2, измеряют: какие запросы горячие, насколько данные статичны, и выбирают стратегию параллелизма (read-only для неизменяемых). Инвалидация — главная сложность кэширования.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.