Оглавление
- Введение
- Уровни кэширования в Hibernate
- Внутренняя реализация L1 и L2, управление кэшем
- EHCache в качестве кэша второго уровня
- Кэширование в Spring Boot: аннотации и CacheManager
- Влияние кэширования на производительность и согласованность данных
- Кеш в рабочем окружении (кластер)
- Redis как внешний распределенный кэш
- Заключение
Введение
Кэширование – это механизм сохранения часто запрашиваемых данных в быстром хранилище (обычно в памяти) для снижения количества обращений к более медленным источникам данных (например, к базе данных). В контексте Hibernate (ORM для Java) и Spring Data (абстракция доступа к данным в Spring) кэширование решает сразу несколько задач: ускоряет повторные чтения одних и тех же данных, снижает нагрузку на базу данных и сеть, а также сглаживает так называемый “разрыв” между объектами Java и реляционной БД. Без кэширования каждое обращение к данным приводит к повторному выполнению SQL-запросов или внешних вызовов, что ведет к повышенным задержкам, росту нагрузки на БД и расходу ресурсов сервера. кэширование же выступает в роли ускорителя: один раз получив данные из БД, приложение может повторно выдавать их из памяти, избегая лишних дорогостоящих операций.
В Hibernate и связанных технологиях существуют несколько уровней кэширования, позволяющих балансировать между свежестью данных и производительностью. Ниже мы рассмотрим уровни кэша первого и второго уровня, кэширование результатов запросов, а также углубимся в реализацию этих механизмов. Отдельно обсудим, как Spring Data/JPA работает с кэшем Hibernate, и как включить кэширование в Spring Boot приложениях с помощью соответствующих аннотаций и настроек. Также разберем популярный провайдер второго уровня – EHCache: почему его часто выбирают, как его сконфигурировать (через XML и Java API), какие существуют политики протухания и вытеснения данных. Наконец, обсудим работу кэша в кластерной среде: использование EHCache в распределенных приложениях, ограничения стандартного L2-кэша и альтернативы в виде внешнего кэша (Redis), с их преимуществами и недостатками. Завершим статью лучшими практиками по выбору и настройке кэша для Hibernate/Spring Data и типичными ошибками, которых следует избегать.
Уровни кэширования в Hibernate
Hibernate предоставляет несколько уровней кэширования данных приложения. Каждый из них работает на своем уровне “глубины” и преследует разные цели. Основные уровни – это кэш первого уровня (Level 1 cache, L1) и кэш второго уровня (Level 2 cache, L2). Отдельно можно включить кэш запросов (Query Cache) для кэширования результатов часто выполняемых запросов. Рассмотрим их по порядку.
Кэш первого уровня (L1 cache)
Кэш первого уровня встроен в Hibernate и функционирует на уровне сессии (Session) или контекста постоянства (Persistence Context). Это значит, что каждая сессия Hibernate (соответствует EntityManager в JPA) имеет свой собственный L1-кэш, изолированный от других сессий. L1-кэш активен всегда по умолчанию и не требует специальной настройки. Его поведение заключается в следующем: при загрузке объекта из базы данных Hibernate помещает этот объект в кэш сессии. Повторные запросы того же объекта (по тому же идентификатору) в пределах той же сессии не приведут к дополнительным SQL-запросам – Hibernate вернет объект из кэша L1. Таким образом достигается “неповторяющееся чтение” на уровне сессии: каждый конкретный объект загружается из БД только один раз за сессию.
Особенностью L1-кэша является его жизненный цикл: он привязан к жизненному циклу сессии. Когда сессия закрывается (например, транзакция закончена или EntityManager.close() вызван), весь ее кэш очищается. Это сделано умышленно, чтобы обеспечить изолированность данных между транзакциями: кэш каждого Session/EntityManager содержит только актуальные для него данные и не “загрязняется” данными из других сессий. В результате параллельные сессии могут работать с одними и теми же идентификаторами объектов независимо, не влияя друг на друга – это важно для корректности транзакций.
Применение L1-кеша дает следующие выгоды: снижение повторных обращений к БД за одними и теми же данными в рамках одной бизнес-операции. Например, если в ходе обработки запроса нужно несколько раз получить один и тот же объект, Hibernate гарантированно выполнит SQL запрос только при первом доступе, а далее будет возвращать кэшированный экземпляр из памяти. Без L1-кеша же каждый вызов find/getReference мог бы дублировать запрос к БД.

Рис. 1: работа кэша первого уровня
Кэш второго уровня (L2 cache)
Кэш второго уровня – это глобальный кэш на уровне SessionFactory (или EntityManagerFactory в терминах JPA). В отличие от L1, он разделяется между несколькими сессиями: все сессии, созданные одной фабрикой, могут читать и записывать в единое кэш-хранилище. Цель L2-кеша – расширить преимущества кэширования за пределы одной транзакции: если данные однажды были загружены одной сессией и помещены в кэш L2, то другие сессии (даже в других потоках или запросах) могут воспользоваться ими, не выполняя повторный SQL запрос. Таким образом, кэш второго уровня особенно полезен для повторяющихся чтений одних и тех же данных в разных транзакциях и для разных пользователей.
Стоит подчеркнуть, что L2-кэш в Hibernate не активируется автоматически, его нужно включить явным образом и настроить подходящий провайдер. По умолчанию Hibernate просто не будет использовать второй уровень (значение hibernate.cache.use_second_level_cache по умолчанию = false). Причина – поддержание L2-кэша требует дополнительных усилий по синхронизации данных, и не для всех приложений он оправдан. Однако при правильном применении второй уровень способен значительно увеличить производительность: по оценкам, выигрыш особенно заметен в высоконагруженных веб-приложениях, где многие пользователи делают схожие запросы к БД. Кэшируя результаты сложных и медленных запросов в памяти, мы сокращаем повторное выполнение этих запросов и снижаем нагрузку на БД.
Когда L2-кеш включен и настроен, Hibernate при загрузке объекта сначала проверяет L1 (кэш сессии), а затем – при отсутствии объекта там – обращается к L2-кэшу. Если объект найден во втором уровне, его состояние берется из памяти, минуя базу данных. Если же и во втором уровне кэша записи нет (кэш-промах), тогда выполняется SQL-запрос к БД, а полученные данные затем сохраняются в оба кэша – L1 и L2 – на будущее. Таким образом, последовательность поиска данных выглядит так: L1 -> L2 -> БД. При дальнейшем использовании объекта в той же сессии будет работать уже L1-кэш; а вот при обращении к тому же объекту из другой сессии – данные будут взяты из L2, если они там сохранились.

Рис. 2: работа кэша второго уровня
Важный нюанс – кэш второго уровня хранит состояния объектов, а не сами живые объекты. Hibernate сохраняет в L2 разобранные данные сущности (поля и их значения), обычно в виде сериализованного Object[] или другого формата, без привязки к конкретному инстансу. При выдаче из L2 Hibernate собирает новый объект (или “hydrate” – заполняет поля нового экземпляра) и возвращает его, параллельно помещая в L1-кэш сессии. Такое устройство предотвращает ситуацию, когда один и тот же экземпляр объекта разделяется между сессиями (что нарушало бы изоляцию) и облегчает поддержание согласованности данных в кэше.
Зачем нужен L2-кэш? Он приносит наибольшую пользу для часто читаемых и редко изменяемых данных. Например, справочники, условно-статические таблицы, данные, общие для многих пользователей (профили, настройки и т.п.) – хорошие кандидаты для кэширования на уровне SessionFactory. Использование L2 позволяет снизить время отклика при повторных запросах таких данных и уменьшить нагрузку на базу при горизонтальном масштабировании (когда множество копий приложения бьют в одну БД). Однако кэш второго уровня – это компромисс между производительностью и сложностью обеспечения согласованности. Как с этим справиться – рассмотрим далее.
Кэш запросов (Query Cache)
Помимо кэширования индивидуальных сущностей по идентификатору, Hibernate умеет кэшировать результаты запросов. Речь идет о HQL/JPQL или Criteria-запросах, выполняющих выборки не по первичному ключу, а по произвольным условиям (например, “дай всех пользователей старше N лет”). Query Cache сохраняет набор результатов запроса, чтобы повторно возвращать тот же список без похода в базу.
Кеш запросов зависит от второго уровня: он хранит только идентификаторы найденных сущностей (и некоторые агрегаты), но не сами объекты. Соответственно, для эффективного использования кэша запросов обычно необходим включенный L2-кэш для этих сущностей – тогда при повторном выполнении запроса Hibernate возьмет список ID из кэша запроса, а сами объекты загрузит из L2, не обращаясь к БД. Если же L2-кеша нет, то смысл query cache теряется – получив список ключей из памяти, ORM все равно полезет в базу за данными по ним.
Чтобы воспользоваться кэшированием запросов, нужно его явно включить свойством hibernate.cache.use_query_cache=true и отметить конкретные запросы как кэшируемые. Например, для JPQL-запроса через EntityManager это делается вызовом setHint(“org.hibernate.cacheable”, true). После этого Hibernate будет сохранять результат запроса (идентификаторы сущностей, удовлетворяющих критерию + указанные агрегатные значения) в специальном регионе кэша. При повторном выполнении точно такого же запроса (с теми же параметрами) он вернет кэшированный результат.
Следует отметить несколько ограничений Query Cache (на практике он более капризен, чем кэширование по ID). Во-первых, кэшируемость должна задаваться для каждого запроса явно – Hibernate по умолчанию не кэширует HQL/JPQL выборки без указания. Во-вторых, Query Cache эффективен для запросов, результат которых не меняется часто. Если данные в таблицах, участвующих в запросе, часто обновляются, то кэш такого запроса будет постоянно инвалидироваться, и толку от него не будет. Также не стоит кэшировать запросы, которые могут приходить с огромным разнообразием параметров – на каждую уникальную комбинацию параметров создается отдельный entry в кэше, что раздувает память. Наконец, есть особенность: по умолчанию все результаты запросов сохраняются в единый регион default-query-results-region. Можно переопределить имя региона для разных запросов, чтобы задать им разные политики жизни (например, один тип запросов хранить дольше, другой – меньше).

Рис. 3: работа кэша запросов
Когда использовать Query Cache? В ситуациях, когда у вас есть тяжелый запрос на редко меняющиеся данные, который часто выполняется многократно (например, отчет, выборка популярных товаров и т.п.). В таком случае можно существенно выиграть: при первом выполнении запрос получит данные из БД, а при последующих – вернет уже сохраненный результат практически мгновенно. Однако необходимо тщательно следить за инвалидацией: при любом изменении данных, влияющих на результаты запроса, Hibernate автоматически пометит кэш этого запроса как устаревший. В отличие от кэша второго уровня (который можно настроить на неконсистентный режим), кэш запросов стремится к строгой консистентности – любые обновления, вставки или удаления соответствующих сущностей сбрасывают связанные с ними запросы из кэша. Например, при выполнении Session.save() или Query.executeUpdate() Hibernate помечает соответствующие регионы query cache невалидными. Это гарантирует, что устаревшие списки не будут выданы клиенту, но в нагрузку дает дополнительную работу по очистке кэша.
Внутренняя реализация L1 и L2, управление кэшем
Разберем чуть глубже технические детали реализации кэширования в Hibernate и как на него влияет использование Spring Data/JPA.
L1-кэш (Session cache) по сути реализован как простая структура в памяти – ассоциативная карта (например, HashMap), сопоставляющая идентификаторы сущностей и сами загруженные объекты. Он также известен как контекст постоянства (Persistence Context). При вызове методов find/get/load ORM сначала смотрит в этот контекст: если там уже есть объект с нужным ключом (например, сущность класса User с id=5), то возвращает его без запроса к БД. Помимо экономии запросов, L1-кэш обеспечивает и единичность объекта: в пределах одной сессии Hibernate не будет создавать два разных Java-объекта для одной и той же записи в БД. Все запросы на получение User(5) вернут ссылку на один и тот же экземпляр. Это важно, например, чтобы изменения, сделанные в объекте, были консистентны в рамках транзакции – мы всегда работаем с одними данными. Как только сессия завершается, её кэш уничтожается – объекты либо отсоединяются (detached), либо сбрасываются.
L2-кеш построен более сложно. Hibernate спроектирован так, чтобы не зависеть от конкретной реализации кэша. В самом Hibernate есть API – интерфейсы RegionFactory, CacheProvider и др., которые выступают прослойкой между Hibernate и реальным кэш-решением. Разработчики Hibernate предоставляли готовые адаптеры под популярные библиотеки (Ehcache, Infinispan, Hazelcast и т.д.) для версий 5.x и ниже. Начиная с Hibernate 6, курс взят на стандартизацию через JCache (JSR-107): вместо написания специальных RegionFactory под каждую библиотеку используется универсальный адаптер JCache, а конкретный провайдер кэша подключается как реализация JSR-107. Это упрощает поддержку – достаточно, чтобы провайдер реализовал javax.cache.spi.CachingProvider, и Hibernate сможет с ним работать через стандартный API.
Чтобы включить второй уровень кэша, указывают в настройках Hibernate (например, в application.properties или persistence.xml) необходимые свойства. Минимальный набор:
hibernate.cache.use_second_level_cache=true
hibernate.cache.region.factory_class=org.hibernate.cache.jcache.internal.JCacheRegionFactory
hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
hibernate.javax.cache.uri=ehcache.xml
Первая опция включает сам механизм, вторая задает класс фабрики регионов (для JCache-адаптера), далее указываются провайдер JCache и ссылка на конфигурацию кэша. Spring Boot, кстати, позволяет задать эти значения через префикс spring.jpa.properties.hibernate.* в application.yml. В современных версиях Hibernate, если на класспасе есть только один JCache-провайдер (например, Ehcache), некоторые свойства можно опустить – адаптер сам найдет провайдер.
Регион и ключи кэша. L2-кеш внутри себя разделяется на регионы – отдельные области для разных типов данных. Обычно каждому классу-сущности соответствует свой регион кэша (по умолчанию его имя равно полному имени класса). Например, сущности типа User будут храниться в регионе “com.myapp.models.User” и отдельно, скажем, Order – в “com.myapp.models.Order”. Это позволяет тонко настраивать поведение кэша для разных данных (например, для одних задать больше памяти или другой TTL, чем для других). В качестве ключей для кэшированных объектов используются идентификаторы (PK) записей, а значениями – сериализованное состояние (поля) объекта. Ассоциации (связи) кэшируются отдельно: коллекции, помеченные как кэшируемые, получают свои регионы, ключами выступают идентификаторы родительского объекта, а значениями – списки связанных ключей. Например, если User имеет коллекцию orders, и она помечена для кэша, то будет регион User.orders, где ключ – id конкретного пользователя, а значение – список id его заказов. Сами объекты Order при этом должны кэшироваться в своем регионе, иначе кэш коллекции будет мало полезен (он хранит только первичные ключи).
Управление консистентностью (стратегии кэша). Hibernate не может волшебным образом знать, как менять данные в кэше при обновлениях, поэтому используются стратегии конкурентного доступа (cache concurrency strategies). Они определяются при аннотировании сущностей для кэширования. Hibernate поддерживает несколько стратегий, основные из них:
- READ_ONLY – только чтение. Подходит для неизменяемых данных (справочники). Если попытаться обновить такую сущность, будет брошено исключение. Эта стратегия самая простая и быстрая, но не допускает обновлений данных.
- NONSTRICT_READ_WRITE – “нестрогая” стратегия: при обновлении данных в БД кэш не синхронизируется моментально. Объект в кэше помечается как потенциально устаревший, но не блокируется. Предполагается, что небольшое окно несогласованности (несколько миллисекунд) допустимо – например, данные обновляются редко и небольшие расхождения не критичны. В этой стратегии кэш обновляется после коммита транзакции, и возможно кратковременное получение устаревших данных из кэша (пока изменение не пропагировано). Зато нет накладных расходов на блокировки.
- READ_WRITE – строгая стратегия с поддержкой обновлений. При обновлении сущности Hibernate ставит в кэш так называемую “мягкую” блокировку (soft lock). Это метка, сигнализирующая другим транзакциям, что данные временно не актуальны. Пока soft lock не снят (т.е. до коммита текущей транзакции), любые другие обращения к этому объекту будут обходить кэш и читать напрямую из БД. После коммита Hibernate записывает новое состояние объекта в кэш и снимает блокировку. Таким образом достигается строгая согласованность – чтения всегда либо получают актуальные данные, либо вынуждены идти в базу, но не вернут устаревшее из кэша.
- TRANSACTIONAL – полностью транзакционная стратегия, требующая поддержки распределенных XA-транзакций между БД и кэшем. Обновление данных становится частью общей транзакции: либо изменения применяются и в базе, и в кэше, либо откатываются везде. Эта стратегия даёт сильную консистентность, но требует специального кэш-провайдера (например, Infinispan) и настройки XA-координатора. Она редко используется в практике из-за сложности.
Применяя соответствующую стратегию, разработчик должен понимать компромисс между скоростью и согласованностью. Чаще всего для изменяемых данных выбирают READ_WRITE или NONSTRICT_READ_WRITE в зависимости от требований: первая гарантирует консистентность (но медленнее), вторая допускает eventual consistency (зато быстрее и менее ресурсозатратно). Неизменяемые справочные данные можно пометить как READ_ONLY – это максимально эффективно.
Чтобы Hibernate начал кэшировать конкретный класс, недостаточно просто включить L2-кеш — нужно еще отметить сущность аннотацией @Cacheable (JPA) и/или Hibernate-специфичной аннотацией @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.X) с выбором стратегии. Например:
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class User { ... }
Без этих аннотаций Hibernate не будет сохранять объекты этого типа во второй уровень. По умолчанию кэшированием не помечены даже включенные L2-кешом сущности. Аналогично для коллекций: нужно дополнительно указать @Cache(usage = …) на ассоциации, чтобы Hibernate кэшировал списки.
Как Spring Data взаимодействует с кэшем Hibernate? Spring Data JPA (то есть слои репозиториев Spring, работающие через Hibernate) напрямую не добавляет чего-то своего в механизм кэша Hibernate. Репозиторий, вызывая findById или другие методы, под капотом обращается к тому же EntityManager и SessionFactory. Поэтому для Spring Data все описанные выше правила L1/L2-кеша действуют автоматически. Если во втором уровне кэша есть данные, Hibernate их вернет – вне зависимости от Spring Data. Специальной интеграции нет: Spring просто пользуется JPA-слоем.
Однако Spring Framework предоставляет собственный механизм кэширования на уровне методов – Spring Cache Abstraction. Аннотации типа @Cacheable, @CacheEvict, @CachePut и др. позволяют кэшировать результаты выполнения методов (как правило, сервисного или репозиторного слоя). Включив конфигурацией @EnableCaching, мы даем Spring сигнал обернуть вызовы помеченных методов прокси-логикой кэша. Например, можно пометить метод поиска пользователя в БД аннотацией @Cacheable(“users”). Тогда при первом вызове будет выполнен запрос и результат сохранен в кэше (в неком Cache с именем “users”), а при последующих вызовах метода Spring будет сразу возвращать сохраненный результат, не доходя даже до Hibernate.
Важно понимать: Spring Cache и кэш JPA (Hibernate L2) – это два разных уровня, которые не знают напрямую друг о друге. Spring Cache работает на уровне вызова метода, и может кэшировать любые данные (не обязательно сущности, это могут быть и DTO, и результаты вычислений). Hibernate L2 работает на уровне ORM при загрузке данных по идентификатору. Они могут использовать один и тот же физический провайдер (например, Ehcache) и даже общую конфигурацию, но концептуально независимы. В частности, никогда не следует кэшировать сущности (Entity) с помощью Spring Cache – это плохая практика. Сущности – изменяемые объекты, их жизненный цикл управляется ORM, и переносить их между транзакциями через внешний кэш опасно. Spring Cache не знает о транзакциях и об операциях с Entity (изменениях, удалениях и т.д.), поэтому он не сможет автоматически инвалидировать такие объекты при обновлении в БД. К тому же, как упоминалось, Hibernate сам в L2 хранит неживые объекты, а разобранные данные. По этим причинам кэширование сущностей лучше предоставить самому Hibernate, а Spring Cache использовать для других целей – например, кэшировать результаты сложных агрегатных запросов, данные внешних API и прочее (и, кстати, часто эти данные могут храниться в Redis).
Подведем итог взаимодействия: Spring Data JPA прозрачно пользуется L1/L2-кэшем Hibernate, ничего в него не добавляя. Дополнительное кэширование в Spring (аннотации @Cacheable и пр.) – это отдельный механизм, который может дополнять Hibernate, но требует явного включения и настройки CacheManager. При грамотной конфигурации оба уровня могут использовать одну систему (например, Ehcache) и тем самым администрироваться единообразно, но синхронизацией данных между ними должен управлять разработчик (например, при обновлении сущности через Hibernate нужно руками очистить соответствующий кэш Spring, аннотируя метод save с @CacheEvict). Многие предпочитают не смешивать эти подходы для одних и тех же данных, чтобы избежать сложности.
EHCache в качестве кэша второго уровня
На практике самым распространенным провайдером L2-кеша для Hibernate является EHCache. Это популярная open-source библиотека кэширования на Java, изначально разработанная как встроенный кэш для JVM-приложений. Еще со времен Hibernate 2.x EHCache был поддерживаемым дефолтным провайдером кэша и интегрировался “из коробки”. Причины его популярности: простота использования, высокая скорость (хранение данных в памяти JVM), богатый набор функций (конфигурация eviction/expiry, возможность overflow на диск, статистика, кластеризация и т.д.) и хорошая интеграция с фреймворками (Spring, Hibernate, MyBatis и др.). Рассмотрим особенности использования EHCache в связке с Hibernate и Spring Boot.
Подключение и конфигурация EHCache (XML и Java)
Зависимости. Если вы используете Spring Boot с Starter JPA, для подключения EHCache обычно достаточно добавить зависимость org.ehcache:ehcache (версии 3.x) и, для Hibernate 6, модуль hibernate-jcache. Spring Boot при наличии Ehcache автоматически подхватит его как JCache-провайдер (при условии spring-boot-starter-cache в зависимостях). В Maven это выглядело бы так:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.x</version>
</dependency>
Включение кэширования. В настройках приложения (application.properties или yaml) необходимо включить второй уровень и указать настройки, как упоминалось ранее. В случае Spring Boot можно добавить:
spring:
jpa:
properties:
hibernate.cache.use_second_level_cache: true
hibernate.cache.region.factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
hibernate.javax.cache.provider: org.ehcache.jsr107.EhcacheCachingProvider
hibernate.javax.cache.uri: classpath:ehcache.xml
Эти настройки будут переданы Hibernate и тот инициализирует кэш через JCache-адаптер Ehcache. Обратите внимание: файл ehcache.xml должен лежать в classpath (например, в resources), либо вместо URI можно задать конфигурацию программно.
XML-конфигурация EHCache. Ehcache поддерживает детальное конфигурирование через XML. В версии 3 формат основан на XML-схеме JSR-107. Пример простейшего ehcache.xml:
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.ehcache.org/v3"
xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd">
<!-- Шаблон конфигурации для всех кэшей-сущностей -->
<cache-template name="defaultEntityCache">
<expiry>
<!-- Время жизни записи в кэше: 30 минут -->
<ttl unit="minutes">30</ttl>
</expiry>
<resources>
<!-- Максимум 1000 элементов в памяти -->
<heap unit="entries">1000</heap>
</resources>
</cache-template>
<!-- Конфигурация региона кэша для сущности User -->
<cache alias="com.myapp.models.User" uses-template="defaultEntityCache">
<!-- Можно переопределить параметры конкретно для User, если нужно -->
</cache>
<!-- Регион для связанной коллекции, например, User.orders -->
<cache alias="com.myapp.models.User.orders" uses-template="defaultEntityCache"/>
</config>
В этом примере мы создали шаблон defaultEntityCache с политикой: хранить до 1000 записей в heap и устанавливать время жизни (TTL) 30 минут для каждого entry. Затем определили кэши для конкретных регионов (для класса User и для его коллекции orders), используя этот шаблон. Если Hibernate запросит регион, объявленный в конфиге, Ehcache применит указанные ограничения. Если же какой-то регион не объявлен, Hibernate по умолчанию сам создаст кэш с настройками по умолчанию – в Ehcache 3 это обычно без ограничения по размеру и без TTL, что чревато бесконечным ростом памяти. Поэтому рекомендуется явно прописывать регионы в конфигурации или хотя бы включать опцию hibernate.javax.cache.missing_cache_strategy=fail – тогда Hibernate будет бросать исключение, если обратилась к несуществующему региону (чтобы вы не забыли настроить).
Java-based конфигурация. Альтернативно, Ehcache 3 позволяет конфигурировать кэш программно с помощью API. Например, можно создать CacheManager через org.ehcache.config.builders.CacheManagerBuilder, и там же задать кэши. Типичный код инициализации (без Spring):
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
.withCache("com.myapp.models.User",
CacheConfigurationBuilder.newCacheConfigurationBuilder(Long.class, User.class,
ResourcePoolsBuilder.heap(1000))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(30)))
.build()
)
.build(true);
Этот CacheManager можно затем передать Hibernate через настройки JCache (EhcacheCachingProvider по умолчанию может брать конфигурацию из кода вместо файла). В Spring Boot, однако, удобнее использовать XML или properties. Также Spring Boot позволяет автоматически создать CacheManager бином, если на пути есть ehcache.xml. Проще говоря, выбор между XML и Java-конфигурацией – вопрос вкуса и требований. XML хорош своей декларативностью и тем, что не требует кода (а также может меняться без пересборки приложения), тогда как Java-конфигурация позволяет использовать логику для вычисления параметров. В обоих случаях возможности одинаковы.
Политики протухания (expiry) и вытеснения (eviction). Ehcache предоставляет гибкие настройки для управления временем жизни кэшированных данных и тем, как освобождается память при переполнении.
- Expiry – это про время хранения. Можно задать TTL (time-to-live) – фиксированное время, по истечении которого запись считается устаревшей и удаляется, даже если она еще помещается в память. Также есть TTI (time-to-idle) – время жизни при отсутствии обращений: если запись не запрашивали указанное время, она вытесняется. В примере выше мы использовали TTL 30 минут для всех User, что означает: через 30 минут после помещения в кэш запись удалится независимо от дальнейших событий. Если нужен TTI – Ehcache XML позволяет задать <tti unit=”…”>. По умолчанию, если не указано expiry, записи могут жить вечно (что рискованно, т.к. рост памяти не ограничен).
- Eviction – это про освобождение места при достижении лимитов. Мы ограничили, например, heap до 1000 элементов. Когда кэш наполнится 1000 объектами и придет новый, Ehcache должен решить, какую старую запись удалить. По умолчанию Ehcache использует алгоритм LRU (Least Recently Used) – вытесняется наименее недавно использованный элемент. Это наиболее распространенная стратегия кэширования. Опционально Ehcache 3 также поддерживает другие политики (например, LFU – наименее часто используемый, или FIFO), но LRU является стандартом “из коробки”. Настраивать её явно в конфиге 3.x нельзя (в Ehcache 2.x можно было указать). Также Ehcache может хранить данные не только в heap (куче JVM), но и в off-heap памяти и даже на диске (файл подкачки). Например, чтобы не потерять кэш при перезапуске приложения или чтобы вместить больший объем данных, можно подключить persistent storage. Однако в контексте Hibernate L2 обычно достаточно памяти JVM: перезапуск приложения приводит к сбросу кэша, что не критично.
Пример кода с EHCache и Hibernate. Допустим, у нас есть настроенный кэш для сущности User (как выше). Аннотации @Cacheable и @Cache(usage=…) расставлены. Тогда использование выглядит прозрачно:
// В начале работы приложения (например, при старте) Spring Boot создает CacheManager и подключает его к Hibernate
// ... (происходит автоматически при наличии настроек)
...
// В каком-то сервисе:
User u = userRepository.findById(42L).get(); // первый вызов - пойдет в базу и закэширует User#42
User u2 = userRepository.findById(42L).get(); // второй вызов - получит из L2-кэша, SQL не выполняется
...
u.setName("Новое имя");
userRepository.save(u); // при сохранении Hibernate обновит БД и внесет изменения в кэш (или инвалидирует кэш в зависимости от стратегии)
// При стратегии READ_WRITE выше Hibernate поставит soft lock на User#42 в кэше на время транзакции коммита, затем обновит его значение и разблокирует.
...
userRepository.findById(42L); // получение после обновления - вернет уже обновленное значение из кэша (или из БД, если был soft lock, но затем снова закэширует)
Для проверки можно воспользоваться API JPA Cache из EntityManagerFactory. Например, вызов emf.getCache().contains(User.class, 42) вернет true, если сущность с таким id находится во втором уровне кэша.
Кэширование в Spring Boot: аннотации и CacheManager
В Spring Boot включение кэширования на уровне методов происходит при добавлении аннотации @EnableCaching в конфигурации (например, в классе приложения). После этого Spring автоматически настроит инфраструктуру кэшей, используя выбранный провайдер (Ehcache, Hazelcast, Caffeine, Redis и т.д.). Провайдер определяется на основе зависимостей: Boot ищет по порядку проверяет наличие JCache (JSR-107) реализации, затем специфичных – Hazelcast, Ehcache и прочих. Например, если у нас подключен Ehcache 3, Spring Boot создаст JCacheCacheManager, загрузив конфигурацию из ehcache.xml по умолчанию. Если подключен Redis (через spring-boot-starter-data-redis), Boot настроит RedisCacheManager. В случае отсутствия каких-либо библиотек кэша BootFallback – используется простейший ConcurrentMapCacheManager (в памяти, небезопасный для продакшна).
Разработчик может явно сконфигурировать CacheManager как Bean, если нужна нестандартная настройка (например, несколько разных менеджеров для разных целей). Но в большинстве случаев достаточно конфигурации по умолчанию + properties. Например, для JCache/Ehcache можно указать spring.cache.jcache.config=classpath:mycache.xml – путь до файла конфигурации. Для Redis никаких XML не нужно – его параметры (хост, порт) задаются в application.yml через префикс spring.data.redis.*, а все кэши и их TTL настраиваются программно или через CacheManager (можно задать default TTL для всех кэшей).
Аннотации Spring для кэширования работают следующим образом:
- @Cacheable(cacheNames=”…”, key=”…”) – помечает метод, результаты которого будут сохранены в указанный кэш (с определенным ключом). При повторных вызовах с теми же параметрами результат сразу берется из кэша, метод не выполняется.
- @CacheEvict(cacheNames=”…”, key=”…”, allEntries=…) – помечает метод, после выполнения которого нужно удалить определенные записи из кэша (или весь кэш allEntries=true). Обычно ставится на методы изменения данных (создание/обновление/удаление), чтобы инвалидировать устаревшие кэш-записи. Например, после сохранения пользователя имеет смысл очистить кэш с пользователями, чтобы последующие чтения загрузили новые данные.
- @CachePut(cacheNames=”…”, key=”…”) – означает, что метод всегда выполняется, но его результат всегда помещается в кэш по указанному ключу (т. е. используется, чтобы обновить кэш принудительно при каждом вызове).
Эти аннотации можно использовать и вместе с Spring Data репозиториями, и с сервисами. Как уже отмечалось, если они применяются к сущностям, важно синхронизировать их с операциями Hibernate. Например, при вызове save можно поставить @CacheEvict на чтение. Но лучше по возможности разделять зоны ответственности: Hibernate L2 пускай кэширует сущности по id, а Spring Cache – более высокоуровневые результаты (списки, DTO).
Заметим, что Spring Cache допускает использование нескольких провайдеров одновременно. Можно настроить, например, два CacheManager: один на базе Ehcache (локальный), второй – на базе Redis (распределенный кэш). Вопрос, зачем это нужно, рассмотрим далее.
Влияние кэширования на производительность и согласованность данных
Правильно настроенный кэш может радикально ускорить приложение: обращения к памяти на порядки быстрее, чем к базе данных через сеть. Особенно выгода видна при преобладании чтений над записями: если данные чаще читаются, чем обновляются, кэширование приносит максимальный эффект. Например, для практически неизменяемых справочников (страны, настройки и т.п.) кэширование позволяет вообще исключить обращения к БД после первоначальной загрузки данных. Кроме того, кэширование разгружает базу данных: повторные одинаковые запросы не доходят до SQL-движка, снижая конкуренцию за соединения, блокировки таблиц и т.д.
Однако у обратной стороны медали – согласованности (consistency) – есть свои риски. Основная проблема кэшей – устаревшие данные (stale data). Поскольку данные хранятся в памяти отдельно от базы, возможна ситуация, когда в БД информация обновилась, а в кэше осталась старая копия. Если приложение продолжит читать из кэша, оно будет получать неактуальные сведения. В контексте Hibernate это может произойти, например, когда другой процесс (или SQL-скрипт) обновил таблицу вне контекста текущего SessionFactory – Hibernate об этом не узнает и не сможет вовремя инвалидировать кэш. Или же в кластерном окружении один экземпляр приложения обновил объект в БД и сбросил его из своего локального кэша, но другие узлы еще хранят старое значение у себя. Такие ситуации приводят к аномалиям – пользователи могут видеть устаревшие записи.
Hibernate частично решает эти проблемы через стратегии кэширования (см. выше) – например, стратегия READ_WRITE с “мягкими блокировками” гарантирует, что параллельные транзакции не читают устаревшие данные. Но даже она не панацея: если обновление произошло не через Hibernate (минуя его контекст), то механизмы L2 не узнают об изменении. В таких случаях обычно применяются дополнительные меры: либо полностью отключают L2-кеш для таких данных, либо реализуют внешнее уведомление об изменениях (например, через JMS, Redis Pub/Sub или алгоритмы типа cache aside). Также можно установить относительно небольшой TTL (время жизни) для записей, чтобы даже без уведомлений кэш сам периодически сбрасывал старые данные и подтягивал свежие из БД. Это не дает строгой консистентности, но ограничивает “время протухания” данных.
Еще один аспект – границы транзакций и изолированность. L1-кеш гарантирует, что внутри одной транзакции вы работаете с консистентными данными. Но между транзакциями кэш второго уровня может вернуть данные, которые были актуальны на момент последнего обновления в кэше. Например, если одна сессия прочитала объект и поместила его в L2, а другая сессия параллельно изменила эту запись в базе, первая (или третья) сессия, обратившись к L2, все еще получит старое значение. Для таких случаев Hibernate предлагает нестрогую стратегию (NONSTRICT_READ_WRITE) – она допускает небольшой рассинхрон. Если же это недопустимо, нужно либо использовать строгую READ_WRITE (но тогда при частых обновлениях эффективность кэша падает – все время происходят invalidation), либо вообще отказаться от кэширования для этой сущности.
Кеширование также увеличивает нагрузку на память приложения. Если допустить кэшироваться слишком много данных без контроля, можно получить OutOfMemory. Поэтому всегда нужно задавать ограничения (лимиты) и политику eviction для всех регионов кэша. Мониторинг кэша – важная часть эксплуатации: нужно следить за размером, количеством попаданий/промахов (hit/miss), процентом устаревших данных. Hibernate предоставляет статистику второго уровня (включается свойством hibernate.generate_statistics=true), а Ehcache можно мониторить через JMX.
Кроме устаревания данных, типичные проблемы при неверном использовании кэша:
- Дублирование и избыточность. Например, закешировать один и тот же объект и на уровне L2, и отдельно в Spring Cache – смысла нет, это только расходует память и требует синхронизации. Следует выбирать одно место для кэша каждой категории данных.
- Кэширование слишком “жарких” данных. Если данные очень часто меняются, кэш может не успевать обновляться – каждый промах будет идти в БД, а каждый хит – давать устаревшее значение. При интенсивных записях кэширование может даже замедлить работу, добавляя оверхед на управление кэшем и инвалидацию. Поэтому для данных с частыми изменениями, возможно, L2-кеш лучше отключить или использовать короткий TTL.
- Неправильная гранулярность. кэшировать огромные результаты (например, весь список тысяч объектов) – не всегда хорошо, если приложение обычно запрашивает лишь часть. В таких случаях лучше кэшировать по элементам или использовать пагинацию.
- Отсутствие планирования и тестирования. кэш – не панацея, его эффективность должна проверяться нагрузочными тестами. Также нужно продумать стратегии сброса кэша при релизах (например, если изменились алгоритмы, структура данных и т.д.).
Кеш в рабочем окружении (кластер)
Когда приложение работает на нескольких серверах (в кластере), вопрос кэширования осложняется. L1-кеш здесь не создает проблем – он всегда локален и относится только к контексту конкретной транзакции. А вот L2-кеш по умолчанию локален в пределах одного приложения (JVM). То есть если у нас 5 экземпляров сервиса, каждый будет иметь свой второй уровень кэша, не знающий о существовании остальных. Это означает, что данные, обновленные через одну ноду, не автоматически обновят (и не сбросят) кэш на других нодах. Возникает проблема рассинхронизации между узлами: каждый сервер имеет свою копию данных в памяти.
Есть несколько подходов к решению:
- Отключить L2-кеш в кластере. Самый простой, но жертвующий производительностью. Если ваши узлы масштабируются и вы полагаетесь на БД как на единый источник правды, можно вовсе отказаться от второго уровня, чтобы не ломать голову с консистентностью. Все обращения тогда пойдут сразу в базу или будут кэшироваться только на уровне сессии.
- Использовать распределенный кэш/кластерный режим. Некоторые провайдеры кэша поддерживают работу в режиме кластера, синхронизируя данные между узлами. Например, EHCache версии 2 имел возможность репликации через RMI, JMS или Terracotta-сервер, Ehcache 3 может работать с Terracotta Server Array (коммерческое решение) для создания единого распределенного кэша. Другие решения: Hazelcast, Infinispan, Coherence, Ignite – представляют собой распределенные in-memory data grid, которые могут служить вторым уровнем Hibernate на нескольких узлах. Принцип тот же: все узлы обращаются к единому логическому кэшу (хоть и физически распределенному), и обновления рассылаются по всему кластеру. Такой подход обеспечивает единый кэш для всех, но сложнее в настройке и потенциально добавляет сетевые задержки внутри операций Hibernate.
- Внешний централизованный кэш – например, вынести L2-кеш на Redis или Hazelcast-сервер, чтобы все приложения обращались к нему. Подробнее разберем в разделе про Redis.
Использование локального EHCache в кластере без дополнительных настроек несет риск: при обновлении данных на одном узле остальные продолжат некоторое время отдавать старые данные из своего кэша. Если приложение read-heavy и небольшое запаздывание не критично, иногда это приемлемо; но в общем случае это нарушение согласованности. Чтобы улучшить ситуацию, в EHCache можно настроить репликацию кэша: при любом изменении кэша на одном узле происходит рассылка инвалидаций или новых значений на другие. Это достигается через специальные плагины (RMI репликатор, кластер Terracotta и т.п.). Однако такая конфигурация сложна и добавляет накладные расходы – по сути, каждый апдейт сущности превращается в сетевое событие на всех узлах. По эффективности это уже близко к использованию внешнего централизованного хранилища, только сложнее в поддержке.
Почему часто говорят, что стандартный L2-кеш плохо подходит для кластеров? Основные причины:
- Отсутствие координации по умолчанию. Hibernate самостоятельно не синхронизирует кэш между процессами. Без внешнего механизма разные узлы быстро рассинхронизируются.
- Сложность конфигурации кластерного кэша. Настроить Ehcache или другую библиотеку в режиме репликации – нетривиальная задача, требующая тестирования на разных нагрузках (например, чтобы не было “штормов” из сообщений при массовых обновлениях).
- Сетевые задержки нивелируют выгоду. Смысл L2-кеша – быстрый (наносекунды) доступ в памяти. Если каждая операция кэша становится распределенной (через сеть на другие узлы), то задержки увеличиваются на порядки, и выигрыш относительно прямого обращения к БД уже не столь велик. Более того, при интенсивной записи синхронизация кэшей может превратиться в узкое место.
- Сложность поддержки транзакционности. В распределенном кэше тяжелее обеспечить ACID, особенно если не использовать транзакционную стратегию. Можно столкнуться с гонками: узел А обновил значение и разослал инвалидацию, но узел B в это же время прочитал старое значение до применения инвалидации и т.д. Решение – дополнительные блокировки или протоколы – опять же усложняет систему.
Итак, в кластерной среде многие принимают решение не использовать L2-кеш вовсе или заменить его на внешний кэш (кеш-сервис), к которому обращаются все узлы.
Чтобы увидеть архитектуру вариантов, взглянем на следующую схему.

Рис. 3: Архитектура кэширования: приложение с Hibernate использует локальный L2-кэш (Ehcache) или внешний распределенный кэш (Redis) для хранения данных, снижая нагрузку на базу данных.
При чтении данных Hibernate сперва обращается к кэшу (L1 внутри сессии, затем L2); при промахе – идет запрос в базу. В кластерном окружении (несколько узлов App Node 1 и 2) локальный EHCache на каждом узле хранит копию данных, что требует механизма синхронизации при обновлении (показано пунктирными линиями при записи в БД). Альтернативно, оба узла могут использовать общий Redis-кеш, обращаясь к нему по сети – это упрощает согласованность между узлами, но вводит сетевые задержки.
На схеме выше синими стрелками показаны обращения к кэшу при чтении, красными – к базе при кэш-промахах или при принудительном обновлении, зеленым пунктиром – вариант с Redis как общим кэшем. Как видно, во втором случае оба приложения разделяют единое кэш-хранилище, что упрощает поддержание единой “правды” в кэше для кластера, но требует сетевых запросов к Redis и поддержки последнего.
Redis как внешний распределенный кэш
Redis – это сверхпопулярное in-memory хранилище данных, часто применяемое в качестве распределенного кэша. В контексте Hibernate/Spring можно использовать Redis несколькими способами: 1. В качестве основы Spring Cache: например, аннотировать сервисы @Cacheable, а CacheManager настроить на Redis – тогда результаты методов будут сохраняться в Redis и доступны всем узлам приложения. 2. Через специальные провайдеры L2-кеша для Hibernate, которые хранят данные во внешнем Redis. Существуют библиотеки (например, от Redisson), реализующие RegionFactory для Redis, позволяя Hibernate L2 работать напрямую с Redis как хранилищем. 3. Комбинированный подход: локальный кэш + Redis как бэкенд. Например, использовать двухуровневый кэш: сперва проверяется локальный (Ehcache) – очень быстрый, при промахе – берется из Redis. Такой “cache aside” подход иногда реализуется кастомно или через возможности Redisson (near cache).
Преимущества Redis для кэширования:
- Он распределенный и централизованный. Все узлы приложения будут видеть один и тот же кэш. Обновление данных через один узел сразу станет видимым для других (поскольку они обращаются к тому же хранилищу). Это решает основную проблему кластерного L2 – рассинхронизацию.
- Redis очень быстрый (хранит данные в памяти, реализован на С, оптимизирован). Хоть доступ к нему и идет через сеть, латентность обычно составляет миллисекунды или меньше. Для многих задач это приемлемо, особенно если база данных находится еще дальше и медленнее.
- Богатство функций: поддержка различных структур данных (строки, хеши, списки, множества и т.д.), транзакции, LUA-скрипты, pub/sub механизмы и репликация. Например, Redis можно использовать не только для простого кэша по ключу, но и как механизм рассылки сообщений об инвалидации (публикуя события).
- Устойчивость и масштабирование: Redis может быть настроен в режиме master-slave (репликация) с автоматическим фейловером или в режиме кластера (шардинг данных по узлам) – это позволяет построить отказоустойчивый кэш, переживающий падение узла. Также Redis может сбрасывать данные на диск (RDB снапшоты или AOF лог) для сохранения состояния между перезапусками.
Недостатки и сложности:
- Сетевая задержка. Вызов Redis – это отдельный сетевой запрос. По скорости обращение к Redis обычно медленнее, чем к локальному Ehcache (память внутри процесса) в десятки раз. Хотя миллисекунды – быстро, в высокочастотных операциях это может стать узким местом. Локальный кэш может обрабатывать сотни тысяч операций в секунду на ядро, а Redis ограничен пропускной способностью сети и своей однопоточностью на узел.
- Непрозрачность для Hibernate. Если использовать Redis вне Hibernate (через Spring Cache), то нужно самостоятельно управлять что и когда помещать/удалять. А если использовать кастомный L2-провайдер на Redis, нужно убедиться, что он корректно реализует все стратегии (soft lock и т.п.). Поддержка транзакционной стратегии через Redis затруднена (Redis не поддерживает XA-транзакции нативно). Обычно с Redis L2-кеш работает в режиме READ_WRITE или NONSTRICT_READ_WRITE.
- Сложность инфраструктуры. Добавляя Redis, мы привносим новый компонент, который нужно развернуть и поддерживать. Это усложняет архитектуру. Нужно следить за его производительностью, настроить мониторинг памяти, избежать ситуаций, когда Redis становится точкой отказа (single point of failure).
- Потенциальная несогласованность: хоть все узлы и делят один кэш, остается проблема рассинхрона между кэшем и самой БД. Если транзакция в БД откатилась, а мы уже обновили Redis – получится неверное состояние. Или наоборот: обновили БД, но до Redis не дошло (например, из-за ошибки соединения) – кэш остался старым. Эти проблемы решаются либо отказом от строгой синхронности (допущением eventual consistency), либо усложнением логики (проверки версий, подтверждения и т.п.).
Когда Redis подходит для Hibernate/JPA кэширования?
- Когда у вас кластер микросервисов или экземпляров, которым нужен общий кэш. Если приложение масштабируется горизонтально, а характер данных – больше чтений, чем изменений, то Redis даст выигрыш, обеспечив единый источник кэша для всех. Без него каждый экземпляр молотил бы одни и те же SELECT’ы к БД (или пришлось бы городить сложную репликацию между Ehcache на узлах).
- Когда требуется больший объем кэша, чем может позволить память одной JVM. Redis легко разместить на отдельной машине с 64+ ГБ памяти, тогда как Java-процесс с такой heap начинает страдать от GC-пауз. Ehcache, конечно, тоже умеет off-heap и диск, но управлять этим сложнее.
- Если нужна кэш-цепочка: скажем, использовать Redis как 2-й уровень, а перед ним еще иметь локальный 1-й уровень кэша (например, Caffeine). Такой двухъярусный подход (L1 near-cache + L2 remote cache) может сочетать лучшее из двух миров: частые обращения обслуживаются локально без сети, а общая база данных в Redis держит консистентность между узлами. К слову, Redisson (Java клиент для Redis) предоставляет Near Cache функциональность, которая как раз позволяет кэшировать недавно использованные объекты локально с автоматической invalidation по pub/sub – это приближает по концепции к Ehcache, но в связке с Redis.
Когда Redis использовать не стоит:
- Если приложение одиночное (1 узел) или кэшируемые данные сугубо индивидуальны для каждого пользователя (нет смысла шарить). В такой ситуации локальный кэш (Ehcache, Caffeine) справится проще и быстрее, а внешний Redis принесет только оверхед.
- Если задержки критичны и каждая миллисекунда на счету (например, low-latency trading системы) – лишний сетевой прыжок может быть нежелателен. Локальный кэш тут предпочтительнее, хоть и без межпроцессной консистентности.
- Если данные очень часто меняются и почти не читаются повторно – кэш просто не даст выигрыша. Redis в худшем случае может добавить задержку. Иногда лучше такие части системы не кэшировать вовсе, полагаясь на быструю БД или другие оптимизации.
- Если нет возможностей/желания сопровождать дополнительный сервис. Redis – еще один компонент, требующий DevOps-обслуживания: бэкапы, мониторинг, масштабирование. В небольших проектах с этим могут не хотеть связываться, тогда проще использовать встроенный механизм кэша с минимумом движущихся частей.
В качестве альтернатив Redis (как внешнего кэша) можно рассматривать и другие системы: Hazelcast, Infinispan, Memcached. У каждой свои особенности: например, Memcached очень быстрый, но только кей-вэлью и без persistence; Hazelcast/Infinispan более интегрированы с Java и поддерживают транзакционность, но сложнее. Redis выделяется простотой и универсальностью, потому и стал практически стандартом распределенного кэша.
Ниже представлена сравнительная таблица EHCache и Redis применительно к задаче кэширования в Java-приложении:
| Характеристика | EHCache (L2 локально) | Redis (внешний кэш) |
|---|---|---|
| Размещение данных | В памяти JVM (локально в приложении). Возможен off-heap и диск (BigMemory). | В памяти отдельного процесса (сервера). Опционально на диск (RDB/AOF снапшоты). |
| Доступ из кода | Встроенно через Hibernate/JCache или Spring Cache (в том же процессе, вызовы напрямую к памяти). | Через сетевой клиент (TCP). Требуется библиотека-клиент (Jedis, Redisson и т.д.), запросы по сети. |
| Скорость доступа | Очень высокая (наносекунды – микросекунды), ограничена скоростью памяти и GC. | Высокая, но с сетевой задержкой (миллисекунды). Легко масштабировать чтение (реплики). |
| Консистентность в кластерe | По умолчанию отсутствует (каждый узел — свой кэш). Нужна настройка репликации или не использовать L2. | Единый кэш для всех узлов – консистентность между приложениями лучше. Но возможен рассинхрон с БД, если не правильно обновлять. |
| Масштабирование объема | Ограничено памятью каждого приложения. На каждом узле данные дублируются. | Отдельный сервер(ы) может иметь большой объем RAM. Можно кластеризовать Redis для увеличения объема. |
| Сложность обслуживания | Низкая: встроен в приложение, конфигурируется файлами/кодом. Но сложнее в кластерной настройке (репликация). | Требует деплоя и поддержки внешнего сервиса. Нужно следить за Redis (нагрузка, репликация, отказоуст.). |
| Интеграция с Hibernate/Spring | Тесная: Hibernate поддерживает Ehcache natively (или через JCache); Spring Boot автоматически конфигурирует Ehcache при наличии на класспате. | Через доп. библиотеки (Spring Data Redis для Spring Cache; Hibernate Redisson для L2). Spring Boot упрощает подключение (стартеры), но все же внешний компонент. |
| Особенности | Богатые возможности Java-кеша: гибкие eviction, JMX, интеграция с JVM (гарбидж коллектор и пр.). Локальный кэш не требует сериализации данных (хранит в виде объектов Java). | Данные отправляются по сети и хранятся в Redis в сериализованном виде (обычно JSON, бинарный или Java сериализация). Redis предлагает структуры (Hash, Set и т.п.), можно использовать для сложных сценариев. Предлагает Pub/Sub, LUA-скрипты для атомарных операций. |
| Типичные случаи применения | Монолит или небольшое число инстансов. Часто доступные справочники, коды, настройки. Когда latency критична (внутрипроцессный кэш быстрее). | Микросервисы и кластеры. Общие данные между сервисами, сессионные данные (через Spring Session), высоконагруженные чтения на масштабируемых узлах. Не требует Java, может разделяться между разными технологиями. |
Из таблицы видно, что Ehcache лучше подходит для локального кэширования в пределах одного приложения – он прост, быстр и не требует развертывания отдельных узлов. Redis же выигрывает в распределенных сценариях, когда нужен единый кэш на все приложение в облаке или кластер. В реальных системах их нередко используют вместе: например, Ehcache как уровень L2 для отдельных узлов, а Redis – для кэширования между сервисами или для другого вида данных (например, кэширование результатов запросов или сессий пользователей). Также комбинация возможна, как упоминалось, в виде двухуровневого кэша: локальный быстрый + удаленный общий.
Заключение
Кэширование в Hibernate и Spring Data – мощный инструмент оптимизации, но требующий грамотной настройки. Подведем основные рекомендации и лучшие практики:
- Оцените профили нагрузок: если в приложении преобладают чтения одних и тех же данных, особенно сложных или редко меняющихся – L2-кеш может существенно улучшить производительность. Если же данные очень динамичные или каждый запрос уникален – выгода от кэша будет низкой, а риски и сложности – не оправданы.
- Выбор провайдера кэша: для простых сценариев (монолит, мало узлов) прекрасно подойдет Ehcache или другой in-JVM кэш. Для распределенной среды рассмотрите внешнее решение (Redis, Hazelcast и пр.) для общности данных. Помните, что Ehcache (локальный) – “ускоритель” с минимальными задержками, а Redis (внешний) – “обменник” для консистентности между узлами; выбирать следует исходя из приоритетов (скорость против консистентности).
- Настройка Hibernate L2: включив второй уровень, не забудьте пометить нужные сущности аннотациями @Cacheable/@Cache(usage=…), иначе ничего не будет кэшироваться. Выберите подходящую CacheConcurrencyStrategy – для изменяемых данных обычно READ_WRITE оптимальна, для неизменяемых READ_ONLY. Обязательно настройте конфигурацию регионов: лимиты по памяти и expiry, чтобы кэш не рос бесконтрольно.
- Spring Cache vs JPA Cache: используйте их по назначению. Spring Cache (аннотации @Cacheable и др.) – для кэширования на уровне сервисов, когда вы хотите закешировать результат метода, не обязательно привязанный к сущности (например, агрегированные данные). JPA L2 – для кэширования сущностей по идентификатору, что происходит автоматически в недрах ORM.
- Мониторинг и отладка: включите метрики кэша – следите за hit/miss ratio. Хороший кэш дает высокий процент попаданий (>80%). Если же кэш пропускает слишком много (низкий hit rate) – возможно, он неправильно применен (кешируется не то или TTL слишком мал). В Hibernate можно логировать категории кэша, а в Spring Boot Actuator есть метрики по кэшу.
- Управление данными в кэше: определите политики инвалидации. Либо используйте ограниченные TTL, либо обеспечьте, что при любом обновлении данные сразу удаляются/обновляются в кэше (например, через @CacheEvict или слушатели Hibernate Events). Особенно критично это при использовании Redis – несвоевременная инвалидация приведет к тому, что все узлы будут читать устаревшие данные.
- Избегайте избыточного кэширования: не нужно кэшировать всё. Например, нет смысла кэшировать сущности, которые и так всегда живут в L1-кэше во время транзакции. Не стоит и вкладывать кэши друг в друга (кешировать результат, который уже был взят из кэша). кэш должен стоять там, где самая “дорогая” операция, которую реально можно пропустить.
- Тестируйте под нагрузкой: кэширование может изменить характер нагрузки (например, снизить число запросов к БД, но увеличить потребление памяти и CPU на сериализацию). Проверьте, что приложение действительно работает быстрее и устойчиво при длительной работе (нет утечек памяти из-за кэша, нет ли резкого падения hit rate со временем и т.п.).
Ошибки, которых стоит избегать:
- Отсутствие очистки кэша при изменении данных – главная причина багов. Всегда думайте: “А что если данные обновились – как мой кэш узнает об этом?”.
- Кэширование больших объемов без лимитов – прямая дорога к переполнению памяти. Лучше ограничить и позволить реже сходить в БД, чем стараться держать всю базу в кэше.
- Отсутствие резервного плана: если внешний кэш (типа Redis) внезапно недоступен, приложение должно корректно лечь на ноги – например, пойти напрямую в БД или деградировать производительность, но не упасть совсем. Используйте таймауты, обрабатывайте исключения от кэша.
- Безграничное время жизни записей – даже если данные почти статичны, перезагружать их раз в сутки или по событию релиза полезно, чтобы избежать ситуации “вечного устаревания”.
В заключение: кэш – не панацея, а инструмент. Неправильное применение кэширования может усложнить систему и даже ухудшить ситуацию (в терминах CAP-треугольника кэши склоняются к производительности за счет консистентности). Но при грамотном выборе стратегии, провайдера и параметров – кэширование в Hibernate и Spring Data даст значительный прирост скорости работы приложения, повысит масштабируемость и снизит нагрузку на базу данных. Планируйте архитектуру кэша с учетом природы ваших данных, внимательно настраивайте и всегда помните о факторе согласованности.
![]()
You must be logged in to post a comment.