Будучи студентом или стажером, вы наверняка столкнетесь с подобной задачей — включить кэширование сущностей, чтобы сэкономить на обращениях к базе данных. Однако, в интернете информацию придется собирать по кусочкам из статей десятилетней давности, вопросов на 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
}

Ноо...

Логи SQL запросов к базе
Логи SQL запросов к базе

Как видно, каждый запрос потребовал обращения к базе данных. Логично — мы пока не настраивали никакой кэш!

Конфигурирование кэша второго уровня

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, и идет за этим в базу.

Чтобы это изменить, потребуется включить кэш запросов. Для этого:

  1. Включим кэш запросов в application.properties / application.yml:

cache:
  use_query_cache: true
  1. Сконфигурируем кэш запросов в ehcache.xml (в данном случае — применим шаблон):

<cache alias="org.hibernate.cache.internal.StandardQueryCache"
       uses-template="default"/>
  1. Пометим нужные методы в репозитории, как кэшируемые. В нашем случае это 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 требуется каждый запрос.

И опять обращения к MenuItemEntity заканчиваются запросами в базу данных...
И опять обращения к MenuItemEntity заканчиваются запросами в базу данных...

Дело в том, что коллекции не кэшируются автоматически — нужно это явно указать при помощи все той же аннотации @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
        }
    ]
}
Значения restaurants не сохраняются в кэш, и значения menu_items выбираются при помощи join, а не отдельным запросом.
Значения restaurants не сохраняются в кэш, и значения menu_items выбираются при помощи join, а не отдельным запросом.

⚠️ Важно помнить, что если повесить на List<MenuItemEntity> menu аннотацию @Cache, то это будет работать, однако кэш создастся и для самой сущности RestaurantEntity!


Надеюсь, эта статья прояснила основные моменты настройки кэша второго уровня в Hibernate. Полный код программы можно найти вот здесь.

Комментарии (9)


  1. VadimGus
    02.08.2024 18:10

    Спасибо. А не встречался ли вам аналог Hibernate для Javascript?


    1. Xentention Автор
      02.08.2024 18:10

      Практически не пишу на Javascript и с БД через него взаимодействовать не приходилось, так что, к сожалению, не знаю


    1. hello_my_name_is_dany
      02.08.2024 18:10

      Есть TypeORM, но там до сих пор нет релизной версии и бывали случаи, что ломали API


  1. martyncev
    02.08.2024 18:10

    Вот бы еще кеш не на Ehcache а на redis работал из коробки..


    1. MountainGoat
      02.08.2024 18:10

      Нафиг не надо из коробки что-либо кроме безусловно свободной лицензии.


    1. grisha9
      02.08.2024 18:10
      +4

      А зачем? Чтобы вместо БД, ходить в редис и получать по сути туже сетевую операцию, с передачей данных по сети? получается то же на то же. Я как то занимался этим и пришел к выводу, что это не имеет смысла. т.к. кешируются в основном неизменяемые справочные сущности. И сделать запрос что в бд по id, что в редис сути не меняет. Основное время займет передача данных по сети. Я даже делал замеры и результаты были одинаковые. Основная суть кеша чтобы убрать обращение к внешней системе по сети и хранить данные в памяти. А не в том чтобы заменить одну внешнюю систему на другую, концептуально ничего не улучшив и сделаться заложником доступности еще одного внешнего хранилища. К томуже современные СУДБ также имеют настраиваемый memory cache где хранят часто запрашиваемые сущности.


    1. Bifurcated
      02.08.2024 18:10

      А что не работает? Я использовал redisson-hibernate-6 всё работало, о его настройках можно в их репозитории на гитхабе прочитать.


  1. agoncharov
    02.08.2024 18:10
    +1

    Актуальных материалов про это мало, потому что сейчас это мало кому нужно - включая кэш второго уровня у вас пропадает возможность балансировать нагрузку (по крайней мере в лоб) между несколькими инстансами приложения, поскольку такие локальные кэши становятся несогласованными друг с другом. Есть у ehcache механизм репликации кэшей, но там свои минусы


    1. Xentention Автор
      02.08.2024 18:10

      There are only two hard things in Computer Science: cache invalidation and naming things.

      И тем не менее, L2 кэш иногда используется — как правильно заметили выше, в основном для неизменяемых справочных сущностей