«1. Обзор

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

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

2. Настройка проекта

Чтобы использовать его таким образом, нам нужно добавить его зависимость в наш pom.xml.

Последнюю версию можно найти в репозитории Maven Central:

<dependency>
    <groupId>org.infinispan</groupId>
    <artifactId>infinispan-core</artifactId>
    <version>9.1.5.Final</version>
</dependency>

С этого момента вся необходимая базовая инфраструктура будет обрабатываться программно.

3. Настройка CacheManager

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

Infinispan поставляется с очень простым способом создания CacheManager:

public DefaultCacheManager cacheManager() {
    return new DefaultCacheManager();
}

Теперь мы можем создавать наши кэши с его помощью.

4. Настройка кеша

Кэш определяется именем и конфигурацией. Необходимую конфигурацию можно построить с помощью класса ConfigurationBuilder, уже доступного в нашем пути к классам.

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

public class HelloWorldRepository {
    public String getHelloWorld() {
        try {
            System.out.println("Executing some heavy query");
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            // ...
            e.printStackTrace();
        }
        return "Hello World!";
    }
}

Кроме того, чтобы иметь возможность проверять изменения в наших кэшах, Infinispan предоставляет простую аннотацию @Listener.

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

@Listener
public class CacheListener {
    @CacheEntryCreated
    public void entryCreated(CacheEntryCreatedEvent<String, String> event) {
        this.printLog("Adding key '" + event.getKey() 
          + "' to cache", event);
    }

    @CacheEntryExpired
    public void entryExpired(CacheEntryExpiredEvent<String, String> event) {
        this.printLog("Expiring key '" + event.getKey() 
          + "' from cache", event);
    }

    @CacheEntryVisited
    public void entryVisited(CacheEntryVisitedEvent<String, String> event) {
        this.printLog("Key '" + event.getKey() + "' was visited", event);
    }

    @CacheEntryActivated
    public void entryActivated(CacheEntryActivatedEvent<String, String> event) {
        this.printLog("Activating key '" + event.getKey() 
          + "' on cache", event);
    }

    @CacheEntryPassivated
    public void entryPassivated(CacheEntryPassivatedEvent<String, String> event) {
        this.printLog("Passivating key '" + event.getKey() 
          + "' from cache", event);
    }

    @CacheEntryLoaded
    public void entryLoaded(CacheEntryLoadedEvent<String, String> event) {
        this.printLog("Loading key '" + event.getKey() 
          + "' to cache", event);
    }

    @CacheEntriesEvicted
    public void entriesEvicted(CacheEntriesEvictedEvent<String, String> event) {
        StringBuilder builder = new StringBuilder();
        event.getEntries().forEach(
          (key, value) -> builder.append(key).append(", "));
        System.out.println("Evicting following entries from cache: " 
          + builder.toString());
    }

    private void printLog(String log, CacheEntryEvent event) {
        if (!event.isPre()) {
            System.out.println(log);
        }
    }
}

Теперь давайте создадим метод для управления созданием кеша:

Обратите внимание, как мы передаем конфигурацию в CacheManager, а затем используем то же имя cacheName, чтобы получить объект, соответствующий нужному кешу. Обратите также внимание на то, как мы информируем слушателя о самом объекте кеша.

private <K, V> Cache<K, V> buildCache(
  String cacheName, 
  DefaultCacheManager cacheManager, 
  CacheListener listener, 
  Configuration configuration) {

    cacheManager.defineConfiguration(cacheName, configuration);
    Cache<K, V> cache = cacheManager.getCache(cacheName);
    cache.addListener(listener);
    return cache;
}

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

4.1. Простой кеш

Самый простой тип кеша можно определить в одну строку, используя наш метод buildCache:

Теперь мы можем построить сервис:

public Cache<String, String> simpleHelloWorldCache(
  DefaultCacheManager cacheManager, 
  CacheListener listener) {
    return this.buildCache(SIMPLE_HELLO_WORLD_CACHE, 
      cacheManager, listener, new ConfigurationBuilder().build());
}

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

public String findSimpleHelloWorld() {
    String cacheKey = "simple-hello";
    return simpleHelloWorldCache
      .computeIfAbsent(cacheKey, k -> repository.getHelloWorld());
}

Давайте добавим в наши тесты простой метод для определения времени наших методов:

Тестируя его, мы можем проверить время между выполнением двух вызовов метода:

protected <T> long timeThis(Supplier<T> supplier) {
    long millis = System.currentTimeMillis();
    supplier.get();
    return System.currentTimeMillis() - millis;
}

4.2. Кэш с истечением срока

@Test
public void whenGetIsCalledTwoTimes_thenTheSecondShouldHitTheCache() {
    assertThat(timeThis(() -> helloWorldService.findSimpleHelloWorld()))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(() -> helloWorldService.findSimpleHelloWorld()))
      .isLessThan(100);
}

Мы можем определить кеш, в котором все записи имеют срок жизни, другими словами, элементы будут удалены из кеша по истечении заданного периода. Конфигурация довольно проста:

Теперь мы создадим наш кеш, используя приведенную выше конфигурацию:

private Configuration expiringConfiguration() {
    return new ConfigurationBuilder().expiration()
      .lifespan(1, TimeUnit.SECONDS)
      .build();
}

И, наконец, используем его аналогичным методом из нашего простого кеша выше:

public Cache<String, String> expiringHelloWorldCache(
  DefaultCacheManager cacheManager, 
  CacheListener listener) {
    
    return this.buildCache(EXPIRING_HELLO_WORLD_CACHE, 
      cacheManager, listener, expiringConfiguration());
}

Давайте еще раз проверим наше время:

public String findSimpleHelloWorldInExpiringCache() {
    String cacheKey = "simple-hello";
    String helloWorld = expiringHelloWorldCache.get(cacheKey);
    if (helloWorld == null) {
        helloWorld = repository.getHelloWorld();
        expiringHelloWorldCache.put(cacheKey, helloWorld);
    }
    return helloWorld;
}

Запустив его, мы увидим, что в быстрой последовательности попадает кеш. Чтобы продемонстрировать, что истечение срока действия связано со временем помещения записи, давайте добавим его в нашу запись:

@Test
public void whenGetIsCalledTwoTimesQuickly_thenTheSecondShouldHitTheCache() {
    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isLessThan(100);
}

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

@Test
public void whenGetIsCalledTwiceSparsely_thenNeitherHitsTheCache()
  throws InterruptedException {

    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isGreaterThanOrEqualTo(1000);

    Thread.sleep(1100);

    assertThat(timeThis(() -> helloWorldService.findExpiringHelloWorld()))
      .isGreaterThanOrEqualTo(1000);
}

Обратите внимание, что срок действия записи истек, когда мы пытаемся получить к ней доступ. Infinispan проверяет запись с истекшим сроком действия в два момента: когда мы пытаемся получить к ней доступ или когда поток reaper сканирует кеш.

Executing some heavy query
Adding key 'simple-hello' to cache
Expiring key 'simple-hello' from cache
Executing some heavy query
Adding key 'simple-hello' to cache

Мы можем использовать истечение срока действия даже в кешах без него в их основной конфигурации. Метод put принимает больше аргументов:

Или, вместо фиксированного срока жизни, мы можем дать нашей записи максимальное время простоя:

simpleHelloWorldCache.put(cacheKey, helloWorld, 10, TimeUnit.SECONDS);

Используя -1 в атрибуте срока службы, кеш не пострадает истечения срока его действия, но когда мы объединяем его с 10 секундами idleTime, мы сообщаем Infinispan об истечении срока действия этой записи, если она не будет посещена в этот период времени.

simpleHelloWorldCache.put(cacheKey, helloWorld, -1, TimeUnit.SECONDS, 10, TimeUnit.SECONDS);

4.3. Удаление кэша

«В Infinispan мы можем ограничить количество записей в данном кеше с помощью конфигурации удаления:

В этом примере мы ограничиваем максимальное количество записей в этом кеше до одной, что означает, что если мы попытаемся ввести другую one, он будет удален из нашего кеша.

private Configuration evictingConfiguration() {
    return new ConfigurationBuilder()
      .memory().evictionType(EvictionType.COUNT).size(1)
      .build();
}

Опять же, метод аналогичен уже представленному здесь:

Давайте построим наш тест:

public String findEvictingHelloWorld(String key) {
    String value = evictingHelloWorldCache.get(key);
    if(value == null) {
        value = repository.getHelloWorld();
        evictingHelloWorldCache.put(key, value);
    }
    return value;
}

Запустив тест, мы можем посмотреть журнал активности нашего слушателя:

@Test
public void whenTwoAreAdded_thenFirstShouldntBeAvailable() {

    assertThat(timeThis(
      () -> helloWorldService.findEvictingHelloWorld("key 1")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findEvictingHelloWorld("key 2")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findEvictingHelloWorld("key 1")))
      .isGreaterThanOrEqualTo(1000);
}

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

Executing some heavy query
Adding key 'key 1' to cache
Executing some heavy query
Evicting following entries from cache: key 1, 
Adding key 'key 2' to cache
Executing some heavy query
Evicting following entries from cache: key 2, 
Adding key 'key 1' to cache

4.4. Пассивация кэша

Пассивация кэша — одна из мощных функций Infinispan. Комбинируя пассивацию и вытеснение, мы можем создать кеш, который не занимает много памяти, не теряя при этом информацию.

Давайте посмотрим на конфигурацию пассивации:

Мы снова форсируем только одну запись в нашей кэш-памяти, но говорим Infinispan пассивировать оставшиеся записи, а не просто удалять их.

private Configuration passivatingConfiguration() {
    return new ConfigurationBuilder()
      .memory().evictionType(EvictionType.COUNT).size(1)
      .persistence() 
      .passivation(true)    // activating passivation
      .addSingleFileStore() // in a single file
      .purgeOnStartup(true) // clean the file on startup
      .location(System.getProperty("java.io.tmpdir")) 
      .build();
}

Давайте посмотрим, что произойдет, когда мы попытаемся заполнить более одной записи:

Давайте создадим наш тест и запустим его:

public String findPassivatingHelloWorld(String key) {
    return passivatingHelloWorldCache.computeIfAbsent(key, k -> 
      repository.getHelloWorld());
}

Теперь давайте посмотрим на наши действия слушателя:

@Test
public void whenTwoAreAdded_thenTheFirstShouldBeAvailable() {

    assertThat(timeThis(
      () -> helloWorldService.findPassivatingHelloWorld("key 1")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findPassivatingHelloWorld("key 2")))
      .isGreaterThanOrEqualTo(1000);

    assertThat(timeThis(
      () -> helloWorldService.findPassivatingHelloWorld("key 1")))
      .isLessThan(100);
}

~~ ~ Обратите внимание, сколько шагов потребовалось, чтобы наш кеш содержал только одну запись. Также обратите внимание на порядок шагов — пассивация, вытеснение, затем загрузка и активация. Давайте посмотрим, что означают эти шаги:

Executing some heavy query
Adding key 'key 1' to cache
Executing some heavy query
Passivating key 'key 1' from cache
Evicting following entries from cache: key 1, 
Adding key 'key 2' to cache
Passivating key 'key 2' from cache
Evicting following entries from cache: key 2, 
Loading key 'key 1' to cache
Activating key 'key 1' on cache
Key 'key 1' was visited

Пассивация — наша запись хранится в другом месте, вдали от основного хранилища Infinispan (в данном случае памяти) Выселение — запись удаляется, чтобы освободить память и чтобы сохранить настроенное максимальное количество записей в кэше. Загрузка — при попытке доступа к нашей пассивированной записи Infinispan проверяет ее сохраненное содержимое и снова загружает запись в память. Активация — запись теперь снова доступна в Infinispan ~~ ~ 4,5. Транзакционный кэш

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

Давайте посмотрим, как мы можем определить кеш с транзакционными возможностями:

Чтобы его можно было протестировать, давайте создадим два метода — один, который быстро завершает транзакцию, а другой занимает некоторое время:

private Configuration transactionalConfiguration() {
    return new ConfigurationBuilder()
      .transaction().transactionMode(TransactionMode.TRANSACTIONAL)
      .lockingMode(LockingMode.PESSIMISTIC)
      .build();
}

Теперь давайте создадим тест, который выполняет оба метода, и проверим, как поведет себя Infinispan:

public Integer getQuickHowManyVisits() {
    TransactionManager tm = transactionalCache
      .getAdvancedCache().getTransactionManager();
    tm.begin();
    Integer howManyVisits = transactionalCache.get(KEY);
    howManyVisits++;
    System.out.println("I'll try to set HowManyVisits to " + howManyVisits);
    StopWatch watch = new StopWatch();
    watch.start();
    transactionalCache.put(KEY, howManyVisits);
    watch.stop();
    System.out.println("I was able to set HowManyVisits to " + howManyVisits + 
      " after waiting " + watch.getTotalTimeSeconds() + " seconds");

    tm.commit();
    return howManyVisits;
}
public void startBackgroundBatch() {
    TransactionManager tm = transactionalCache
      .getAdvancedCache().getTransactionManager();
    tm.begin();
    transactionalCache.put(KEY, 1000);
    System.out.println("HowManyVisits should now be 1000, " +
      "but we are holding the transaction");
    Thread.sleep(1000L);
    tm.rollback();
    System.out.println("The slow batch suffered a rollback");
}

Выполнив его, мы снова увидим следующие действия в нашей консоли:

@Test
public void whenLockingAnEntry_thenItShouldBeInaccessible() throws InterruptedException {
    Runnable backGroundJob = () -> transactionalService.startBackgroundBatch();
    Thread backgroundThread = new Thread(backGroundJob);
    transactionalService.getQuickHowManyVisits();
    backgroundThread.start();
    Thread.sleep(100); //lets wait our thread warm up

    assertThat(timeThis(() -> transactionalService.getQuickHowManyVisits()))
      .isGreaterThan(500).isLessThan(1000);
}

~ ~~ Проверяем время в основном потоке, ожидая окончания транзакции, созданной медленным методом.

Adding key 'key' to cache
Key 'key' was visited
Ill try to set HowManyVisits to 1
I was able to set HowManyVisits to 1 after waiting 0.001 seconds
HowManyVisits should now be 1000, but we are holding the transaction
Key 'key' was visited
Ill try to set HowManyVisits to 2
I was able to set HowManyVisits to 2 after waiting 0.902 seconds
The slow batch suffered a rollback

5. Заключение

В этой статье мы рассмотрели, что такое Infinispan, а также его основные функции и возможности в качестве кэша в приложении.

Как всегда, код можно найти на Github.

«