Кэш второго уровня Hibernate для чайников
Будучи студентом или стажером, вы наверняка столкнетесь с подобной задачей — включить кэширование сущностей, чтобы сэкономить на обращениях к базе данных. Однако, в интернете информацию придется собирать по кусочкам из статей десятилетней давности, вопросов на stackoverflow и документации.
Эта статья-туториал ставит перед собой цель упростить эту задачу и пошагово показать, как настроить базовый кэш в Hibernate 6.
Тем не менее, автор советует сначала почитать вот эту статью с теорией, хотя на момент написания ей и исполнилось целых 12 лет:)
Первые шаги
В качестве примера создадим небольшое приложение с таблицей меню. Оно обновляется не часто, поэтому мы хотим как можно реже ходить в базу.
Код сущности
Таблица айтемов:
@Getter
@Setter
@Entity
@Table(name = "menu_items")
public class MenuItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "calories", nullable = false)
private Integer calories;
@Column(name = "price", nullable = false)
private Double price;
}
Сделаем в таблицу меню 3 запроса на получение сущности с id = 1. Успех!
{
"id": 1,
"name": "bread",
"calories": 500,
"price": 50.0
}
Ноо...
Как видно, каждый запрос потребовал обращения к базе данных. Логично — мы пока не настраивали никакой кэш!
Конфигурирование кэша второго уровня
Hibernate не поставляет реализацию L2 кэша из коробки — для этого нужно подключить любую из доступных реализаций. Самые популярные из них описаны здесь. Мы же будем использовать ehcache.
Добавим в pom.xml следующие зависимости:
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.4.1.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-jcache</artifactId>
<version>6.4.1.Final</version>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.10.0</version>
<classifier>jakarta</classifier>
</dependency>
И изменим файл application.yml (application.properties), чтобы дать Hibernate понять, что мы хотим включить L2 кэш и указать его конфигурацию:
jpa:
properties:
hibernate:
javax.cache:
provider: org.ehcache.jsr107.EhcacheCachingProvider
uri: ehcache.xml
cache:
use_second_level_cache: true
region.factory_class: jcache
use_second_level_cache — включает кэш 2 уровня
provider — указывает класс провайдера (реализации) кэша
uri — указывает на файл конфигурации
region.factory_class — инкапсулирует детали реализации провайдера
Теперь сконфигурируем ehcache.xml. Полное описание всех параметров можно найти в документации, нам же хватит всего 3 — названия, срока жизни и размера хипа. Для удобства дальнейшего переиспользования зададим параметры в виде шаблона, который потом применим к кэшу MenuItemEntity:
<?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="default">
<expiry>
<ttl unit="days">1</ttl>
</expiry>
<heap>1000</heap>
</cache-template>
<cache alias="com.example.cache_for_dummies.entity.MenuItemEntity" uses-template="default"/>
</config>
Остался последний штрих: пометить сущность MenuItemEntity, как кэшируемую — для этого достаточно аннотации org.hibernate.annotations.Cache:
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
В качестве параметра usage можно указать одну из следующий стратегий:
READ-ONLY — хороша для данных, которые часто читаются, но не изменяются;
READ-WRITE — подходит для приложений, которые регулярно обновляют данные;
NONSTRICT READ-WRITE — подходит, если данные обновляются не так часто, и маловероятно одновременное изменение одной сущности двумя транзакциями;
TRANSACTIONAL — поддержка для транзакционных провайдеров. Используется в JTA-окружении.
Помимо обязательного usage, в аннотации @Cache можно указать и другие параметры:
Название | Тип | Описание |
includeLazy | boolean | Определяет, будут ли lazy атрибуты включены в кэш второго уровня в момент их загрузки. По умолчанию true |
region | String | Регион кэша. По умолчанию равен полному имени класса |
Запускаем приложение и делаем те же 3 запроса к меню:
К базе данных было всего одно обращение, после чего данные были взяты из кэша — ура! Теперь попробуем сделать getAll, то есть получить все записи из меню:
Несмотря на то, что мы настроили кэш сущностей, каждая выборка сопровождается запросом в базу.
Почему так происходит?
Дело в том, что сущности по умолчанию кэшируются по их идентификатору, то есть id. Hibernate не хранит сами объекты — он хранит их строковое представление в формате, концептуально представляемом, как ключ-значение. Поэтому, грубо говоря, когда мы хотим выполнить "find all", hibernate не имеет данных о том, что такое all, и идет за этим в базу.
Чтобы это изменить, потребуется включить кэш запросов. Для этого:
Включим кэш запросов в application.properties / application.yml:
cache:
use_query_cache: true
Сконфигурируем кэш запросов в ehcache.xml (в данном случае — применим шаблон):
<cache alias="org.hibernate.cache.internal.StandardQueryCache"
uses-template="default"/>
Пометим нужные методы в репозитории, как кэшируемые. В нашем случае это findAll:
public interface MenuItemRepository extends JpaRepository<MenuItemEntity, Long> {
@Override
@QueryHints(@QueryHint(name = AvailableHints.HINT_CACHEABLE, value = "true"))
List<MenuItemEntity> findAll();
}
Готово! В логах видим всего одно обращение к базе:
Про коллекции и внешние ключи
Ресторан набирает популярность, поэтому было принято решение открыть несколько филиалов — и хранить информацию о них в базе данных. Кроме того, меню в ресторанах может быть разным, и мы хотим в любой момент получить информацию о том, в каких ресторанах можно попробовать то или иное блюдо. Обновленные сущности принимают следующий вид:
Код
Новая сущность RestaurantEntity:
@Getter
@Setter
@Entity
@Table(name = "restaurants")
public class RestaurantEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "name", nullable = false)
private String name;
@ManyToMany
@JoinTable(name = "restaurants_items",
joinColumns = @JoinColumn(name = "restaurant_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<MenuItemEntity> menu;
}
Обновленная сущность MenuItemEntity:
@Getter
@Setter
@Entity
@Table(name = "menu_items")
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class MenuItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "calories", nullable = false)
private Integer calories;
@Column(name = "price", nullable = false)
private Double price;
@ManyToMany(mappedBy = "restaurants")
private List<RestaurantEntity> restaurants;
}
Радуемся, и запускаем приложение:
{
"id": 2,
"name": "beer",
"calories": 400,
"price": 350.0,
"restaurants": [
{
"id": 2,
"name": "Restaurant 2"
}
]
}
Смотрим в логи и видим, что, хотя обращение к таблице menu_items было всего 1 раз, обращение к таблице restaurants_items требуется каждый запрос.
Дело в том, что коллекции не кэшируются автоматически — нужно это явно указать при помощи все той же аннотации @Cache:
@ManyToMany(mappedBy = "menu")
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
private List<RestaurantEntity> restaurants;
Проблема решена — теперь связь с таблицей restaurants_items тоже находится в кэше:) В случае с One-To-Many отношением этого было бы достаточно, однако для Many-To-Many требуется дополнительное обращение уже напрямую в таблицу restaurants, пропуская выборку из restaurants_items. Но и этого можно избежать, повесив аннотацию @Cacheable на сущность RestaurantEntity:
@Getter
@Setter
@Entity
@Table(name = "restaurants")
@Cacheable
public class RestaurantEntity {
Иногда считается хорошим тоном помечать сущности и @Cache и @Cacheable одновременно.
При этом, для com.example.cache_for_dummies.entity.MenuItemEntity.restaurants по умолчанию будет создан отдельный кэш — поэтому стоит или не забыть сконфигурировать его в ehcache.xml, или явно указать region. Например:
@ManyToMany(mappedBy = "menu")
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY,
region = "com.example.cache_for_dummies.entity.MenuItemEntity")
private List<RestaurantEntity> restaurants;
К сожалению, в обратную сторону это не работает:
{
"id": 2,
"name": "Restaurant 2",
"menu": [
{
"id": 2,
"name": "beer",
"calories": 400,
"price": 350.0
},
{
"id": 4,
"name": "ribs",
"calories": 700,
"price": 800.0
}
]
}
⚠️ Важно помнить, что если повесить на List<MenuItemEntity> menu аннотацию @Cache, то это будет работать, однако кэш создастся и для самой сущности RestaurantEntity!
Надеюсь, эта статья прояснила основные моменты настройки кэша второго уровня в Hibernate. Полный код программы можно найти вот здесь.