Кешування для Java-проєктів

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу розповісти вам про таку таку популярну структуру даних як кеш. У цій статті я опишу характеристики кешування, поділюся інформацією про найбільш популярні Java-бібліотеки, які дозволяють вам кешувати дані і також викладу результати тестування їх продуктивності.

Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про сучасні тенденції роботи з даними та використання кешування для підвищення ефективності. Тут мова піде здебільшого про локальне кешування, тобто коли дані знаходяться на тому ж сервері, що і сам застосунок.

Що таке кеш

Навіть якщо ви явно не використовуєте кешування у вашому проєкті, воно може застосовуватися і без вашої участі. Наприклад, Hibernate містить кеші першого (Session) і другого рівня для зберігання тих даних, з якими зараз працює користувач. Що таке кеш і для чого він потрібний? Кеш — це структура даних, яка зберігає дані та надає до них досить швидкий доступ для читання та запису (зазвичай у пам’яті).

Зазвичай туди кладуть дані у двох випадках:

  1. Отримувати ці дані із зовнішнього джерела або досить довго, або це може бути пов’язано з тимчасовою недоступністю даних (з бази даних або зовнішньої підсистеми).
  2. Дані виходять у результати будь-яких тривалих обчислень (при цьому передбачається, що обчислення детерміновані).

Звідси випливає одна з вимог до роботи з таким даними — завжди повинна бути можливість завантажити або підрахувати/перерахувати дані, якщо вони відсутні в кеші (віддалені) або сам кеш недоступний (відключений). Тобто кеш є опціональним інфраструктурним компонентом, який не впливає на саму функціональність програми, а лише на його продуктивність.

Якщо ви проходили технічні співбесіди на розробника, то вас обов’язково мали запитувати про найбільш популярні структурні дані або пропонувати написати реалізацію такої структури на live coding. Найчастіше це пов’язаний список або черга. Але на мою думку складніша і цікавіша структура — ​​це кеш, і за вмінням написати повнофункціональний кеш можна сказати про кандидата набагато більше.

У найпростішому варіанті кеш повинен надавати дві операції у своєму API:

  1. Отримати значення за ключем (унікальним ідентифікатором), якщо воно є.
  2. Помістити пару ключ-значення у кеш (або оновити її). Цю операцію можна проводити синхронно або асинхронно, але в будь-якому випадку вона повинна підтримувати вміст кешу в консистентному стані.

Також зазвичай додають ще дві базові операції:

  1. Видалити ключ із кешу (evict).
  2. Очистити кеш (invalidate).

Таким чином, якщо вам для ваших потреб потрібен локальний кеш, то перший варіант, який може прийти вам у голову, це in-memory компоненти з JDK:

  • HashMap (для потоко-небезпечного використання).
  • ConcurrentHashMap (для потоко-безпечного використання).

Однак дуже швидко стає зрозуміло, що для ефективної роботи кеш також має надавати можливості налаштування:

  • Обмеження за кількістю елементів (чим більше кеш, тим менш ефективний).
  • Можливість вибору алгоритму видалення (eviction) елементів під час заповнення кешу.
  • Перегляд статистики.
  • Декларативна чи імперативна конфігурація.
  • Розбиття кешу на області/регіони.
  • Можливість завантаження елементів із зовнішнього джерела (бажано асинхронного).
  • Можливість вказівки автоматичного видалення ключів, що не використовуються (expiration).
  • Нотифікації про всі події життєвого циклу кешу.
  • Можливість зберігати дані не тільки в купі, але і в off-heap, на диску або розподілено.

Ці налаштування є певною мірою опціональними, але для production використання критичними. І навіть зараз у дуже серйозних якісних проєктах зустрічаються помилки, де розробники забувають, наприклад, про обмеження розміру кешу, і це призводить до Ou-tOfMemoryError:

Алгоритми та патерни

При цьому крім самих налаштувань дуже важливо те, як ваш застосунок буде взаємодіяти з кешем і джерелом даних (datasource або system-of-record, SoR). На даний момент існує 4 загальноприйнятих патерна:

  1. Cache-aside
  2. Read-through
  3. Write-through
  4. Write-behind

Останні три відносяться до групи Cache-as-SoR. Якщо ви використовуєте cache-aside, то ваш застосунок сам керує і кешем, і джерелом даних:

String value = cache.get(key);
if (value == null) {
     value = dataSource.get(key);
     cache.put(key, value);
}

В інших випадках кеш не тільки зберігає дані, але і є прокладкою між вашим застосунком і джерелом даних (SoR), тобто вміє завантажувати або записувати туди дані. При використанні патерна Read-through кеш (його іноді називають loading cache) вміє завантажувати дані з SoR, якщо таких даних немає. У цьому випадку необхідно вказати для кешу компонент-завантажувач даних:

Cache cache = new Cache();
cache.setLoader(key -> dataSource.get(key));

У випадку Write-through патерна кеш вміє записувати дані в SoR, а при використанні Write-behind кеш зберігає операції запису в чергу і виконує їх пізніше, асинхронно.

Крім того, на продуктивність впливає і те, як видаляються з кешу у разі його заповнення застарілі елементи. Численні дослідження показали, що найпростіші алгоритми не є найефективнішими:

  • LRU (Least Recently Used)
  • MRU (Most Recently Used)
  • LFU (Least Frequently Used)

І як показує статистика Wikipedia, кращого результату вдається досягти при використанні нових алгоритмів:

  • ARC (Adaptive Replacement Cache) — використовує безліч черг для різних ключів.
  • LIRS (Low Inter-reference Recency Set) — використовується в MySQL, H2, Infinispan та NetBSD (за назвою Clock Pro).
  • Window TinyLFU — покращений LRU.

На жаль, у самій JDK немає окремого компонента для кешування, хоча в LinkedHashMap є спеціальний метод removeEldestEntry:

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
     return false;
}

Ви можете перевизначити його в класах-спадкоємцях і отримати якусь подобу LRU. Чи варто використовувати HashMap/ConcurrentHashMap/LinkedHashMap, якщо вам підходить їхня функціональність? HashMap (а це фактично хеш-функція на основі chaining) була придумана ще в 50-х роках минулого століття. Проте саме цей алгоритм використовували у Hashtable/HashMap творці Java та JDK у 90-ті роки, і за 30 років тут нічого не змінилося. Наскільки ефективним є HashMap зараз? 10 років тому було запропоновано ідею нового асоціативного масиву — Swiss Table. Він зберігає дані в масиві, розділеному на слоти, використовує метадані і навіть спеціальні SSE-інструкції процесора для прискорення роботи. Ми обов’язково перевіримо, наскільки цей алгоритм швидше, ніж той, що існує в HashMap.

Основні бібліотеки для кешування даних

У будь-якому випадку навряд чи хтось захоче з нуля реалізовувати свій кеш, тим більше, що Java-індустрія до поточного часу пропонує широкий вибір кеш-провайдерів:

  1. Клас LRUMap від Apache Commons
  2. Java Caching System (JCS)
  3. EhCache
  4. Google Guava Cache
  5. Cache2k
  6. Caffeine
  7. JetCache

Проаналізуємо їх можливості детальніше. LRUMap — це клас з Apache Commons Collections, який успадковує клас AbstractMap з JDK. З усіх фіч, які були вказані ваше, він реалізує лише дві:

  • Обмеження розміру кешу.
  • Алгоритм LRU для видалення елементів, що не використовуються.

Для визначення найменш використовуваних елементів використовується додатковий зв’язковий список, в якому елементи переміщуються в кінець при зверненні до них. Таким чином, якщо у вас у проєкті вже є Apache Commons Collections і вам потрібен найпростіший кеш, то даний варіант цілком може вам стати в нагоді.

Java Caching System (JCS) — проєкт від Apache Commons, який з’явився раніше за всіх, у 2002-му році і досі продовжує розвиватися. Він підтримує специфікацію JCache і дозволяє зберігати дані не тільки в пам’яті, але і на диску, а також віддалено (звертаючись до них через TCP або RMI). Унікальною здатністю JCS є не просто підтримка різних способів зберігання даних, а й їхня тісна інтеграція. Якщо у вас в пам’яті закінчилися дані, то вам необов’язково очищати її від елементів, що не використовуються, можна налаштувати swap, щоб такі дані автоматично переміщалися в кеш на диску.

Якщо ви використовуєте дисковий кеш, то відразу ж виникає питання про серіалізацію та безпеку даних. За замовчуванням JCS використовує механізми серіалізації з JDK, які не відрізняються ні швидкістю, ні компактністю стиснення, але ви можете підключити сторонню бібліотеку серіалізації. Для шифрування даних на диску використовується алгоритм AES. При цьому для прискорення доступу ключі все одно зберігаються в пам’яті, а дані на диску.

Також JCS є гнучким у тому, що стосується алгоритмів видалення елементів. За замовчуванням використовуються LRU, але ви також можете підключити MRU, LHMRU, FIFO або ARC.

Завдяки своїй архітектурі, побудованій на плагінах, ви можете підключити й інші способи зберігання даних, наприклад, у базі даних (HSQL, Berkley DB), а звертатися до них через JDBC. Конфігурація зберігається у текстових .ccf-файлах. До мінусів проєкту можна віднести відсутність програмної конфігурації (що ускладнює тестування), використання JIRA для тикетів (хоча сам проєкт хоститься на Github) і відносно невелика популярність (на Stackoverflow за 23 роки всього 88 питань).

EhCache — ще один старожил у сфері кешування, створений у 2003-му році Грегом Люком. Ця людина увійшла в історію як співавтор специфікації JCache, автор платформи обробки даних Hazelcast, де він був CEO/CTO, а також CTO компанії Terracotta. В обох компаніях їхні флагманські продукти використовували саме EhCache для кешування..

Серед переваг цього проєкту:

  • Підтримка як програмної, так і декларативної (XML) конфігурації.
  • Різні варіанти зберігання даних у кеші.
  • Інтеграція з JCache та Spring Cache.
  • Підтримка Java Transaction API.
  • Event listeners.

Як і JCS, EhCache пропонує вам різні варіанти розташування зберігання даних (шари або tiers):

  1. Купа
  2. Off-heap (за допомогою пулів ресурсів)
  3. Дискова підсистема
  4. Розподілений режим роботи (за рахунок інтеграції з Terracotta Server)

Ви можете комбінувати всі ці 4 варіанти конфігурації (тільки зв’язка диск — Terracotta неможлива). Таким чином, найчастіше використовувані дані зберігаються у пам’яті, а решта — у повільніших сховищах.

EhCache дуже популярний у Java/Jakarta EE проєктах, не випадково він обраний як провайдер кешування за замовчуванням для кеша другого рівня в Hibernate.

До мінусів цього проєкту можна віднести великий розмір jar (1.7 мб) і те, що його розробка сповільнилася останнім часом, а останній реліз був понад два роки тому. Також немає можливості вибрати механізм eviction (LRU або LFU) у версії 3.x (у 2.x така опція була). Кожен шар сам автоматично вибирає той чи інший тип eviction, залежно від конфігурації та заповненості даними.

Google Guava — дуже популярний проєкт, який з’явився ще в 2010-му році і відомий насамперед своїми колекціями, але не тільки. Тут є власний компонент для кешування (використовується LRU підхід), і все це зберігається в одному 3-мегабайтному JAR-файлі, що не дуже зручно. Простіше було рознести функціональність за окремими модулями.

Guava внесли свій внесок у технологічний прогрес кешування, наприклад, почавши обертати ключі і значення в soft і weak посилання, але на даний момент, схоже, що даний компонент перейшов у стадію maintenance і самі розробники Guava у відповідь на прохання додати ті чи інші фічі рекомендують перейти на інший проєкт — Caffeine. Більше того, Spring Boot 2 замінили підтримку Guava кеш на Caffeine. Плюс Guava тут у тому, що вони повністю підтримують ОС Android, тоді як для Caffeine така підтримка не гарантована.

Cache2k — новий проєкт, створений у 2014-му році, як легковажна бібліотека для кешування. І справді, вона займає лише 400 кб. До її переваг можна віднести:

  • Відсутність блокувань при доступі до даних.
  • Не використовується внутрішній клас Unsafe з JDK, що в принципі позбавляє проблем при міграції на нові версії Java.
  • Потоко-безпечна реалізація та підтримка атомарності в операціях.
  • Автоматичний процес видалення застарілих ключів та їх оновлення (refresh ahead).
  • Збір статистики.
  • Підтримка JCache та XML конфігурації.
  • Інтеграція з Spring Framework та Scala Cache.
  • Підтримка Android.

Цікава особливість Cache2k — реалізація стійкості до збоїв (resiliency) Якщо при запиті до зовнішнього джерела було викинуто виняток, Cache2k не тільки повертає останнє збережене значення, але і на деякий період перестає відправляти запити ззовні. Фактично це lite-версія такого патерну як Circuit Breaker.

До її мінусів можна віднести те, що вона практично перестала розвиватися три роки тому, а останній її реліз був у лютому 2022 року.

Caffeine — досить новий проєкт, який у 2015 році створив американський розробник Бен Манес. Манес деякий час працював у Google, а останнім часом є CTO у логістичній компанії Vector.

Caffeine позиціонує себе як високопродуктивна бібліотека кешування, яка була створена на основі Google Guava Cache і стала її наступником. При цьому вона вже могла використовувати нові фічі із JDK 8 (Google Guava все ще базувалася на JDK 6). Серед її особливостей:

  • Автоматичне завантаження даних кеш (за допомогою асинхронності).
  • Використання алгоритму eviction Window-TinyLFU (що поєднує як LRU, так і LFU).
  • Асинхронне оновлення застарілих даних.
  • Збір статистики.
  • Нотифікації про всі події, що відбуваються у кеші.
  • Підтримка JCache.

Технологічно Caffeine — найпросунутіша система кешування з усіх розглянутих. Якщо глянути на її внутрішній пристрій:

то можна помітити наступне:

  • Дані зберігаються у ConcurrentHashMap.
  • Для визначення застарілих елементів використовуються три черги (access, write та time) — залежно від того, яка політика eviction зазначена.
  • Для прискорення роботи кешу всі операції спочатку поміщаються в буфер (окремо для запису та читання) для подальшого виконання в батчах.

Цікавою особливістю Caffeine є наявність спеціального підпроєкту Simulator, який ви можете використовувати для тестування різних механізмом кешування, адаптувавши тут ваше завантаження та роботу з даними.

Jetcache — порівняно нова (2017 рік) бібліотека кешування від Alibaba. Пропонують декларативний та імперативний підхід, а також свій Cache API, у якого на даний момент 4 реалізації:

  1. Redis (як клієнт використовується Lettuce або Jedis)
  2. Tair (ще один проєкт, але вже комерційний, від Alibaba, сумісний із Redis сервер для хмарного використання)
  3. Caffeine
  4. LinkedHashMap

А також є цілий розсип фіч:

  • Дворівневий кеш (локальний + віддалений).
  • Автоматичний збір статистики/метрик.
  • Використання JDK та бібліотек Jackson/Fastjson 1.x та 2.x/Kryo для серіалізації даних (Fastjson це власна розробка Alibaba).
  • Автоматичні оновлення кешу, підтримка асинхронної роботи та розподілених блокувань під час використання Redis.
  • Підтримка Spring Boot (декларативна конфігурація) та Spring Data Redis.

Фактично Jetcache — це китайський аналог Spring Cache, за допомогою як локальних, так і віддалених сховищ даних. Цікавою особливістю цієї бібліотеки є дворівневий кеш, коли ви можете для одного і того ж use-case зберігати цей і локально, і віддалено (Redis). Відносний мінус проєкту — ієрогліфи, що постійно зустрічаються в документації та обговореннях в Інтернеті, а також повна відсутність JavaDocs в коді і жодного (!) питання на Stackoverflow..

Тестуємо продуктивність

Перейдемо до тестування продуктивності цих бібліотек. Для цього ми як звичайно використовували бібліотеку JMH і наступну конфігурацію:

  • JMH 1.37
  • JDK 24.0.1
  • Intel Core i9, 8 cores
  • 32 GB
  • 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)

Для більш простої реалізації тестування я додав абстракцію LocalCache:

public interface LocalCache<K,V> extends AutoCloseable {
     V get(K key);
     V getAndUpdate(K key, V value);
     V put(K key, V value);
}

Опис операцій:

  1. get — отримати значення по ключу (або null, якщо відсутнiй). Ця операція була додана, щоб порівняти «чисту» швидкодію кешу при доступi до даних.
  2. getAndUpdate — отримати значення за ключом і якщо воно відсутнє, то записати в кеш це значення.
  3. put — записати в кеш пару ключ/значення (використовується для ініціалізації даних).

Відповідно було підготовлено два benchmarks:

  • Отримати значення ключа (при цьому воно точно присутнє). Використовувався кеш із максимальним розміром 50 тисяч елементів.
  • Отримати значення ключа, якщо такого ключа немає, то записуємо нове значення в кеш. Використовувався кеш із максимальним розміром 50 тисяч елементів. При цьому ключів могло бути максимум 1 мільйон. Даний benchmark був тільки для тих реалізацій, які підтримували data eviction (тобто виключалися JDK реалізації та Swiss Table).

Кожна з бібліотек конфігурувалася так, щоб зберігати дані тільки в купі, якщо в ній збиралася статистика, але така опція відключалася (якщо її можна було вимкнути). Ключ для пошуку щоразу генерувався випадковим чином.

У тестуванні взяли участь наступні реалізації:

  1. HashMap (тільки перший benchmark)
  2. ConcurrentHashMap (тільки перший benchmark)
  3. Swiss table, взятий тут (тільки перший benchmark)
  4. На базі LinkedHashMap з використанням алгоритму LRU
  5. LRUMap з Apache Commons Collections 4.4
  6. JCS 3.2.1 (за умовчанням використовувався алгоритм LRU)
  7. EhCache 3.10.8
  8. Кеш із Google Guava 33.4.8
  9. Caffeine 3.2.0
  10. Caffeine 3.2.0, де ключі та значення оберталися у weak references
  11. Cache2K 2.6.1
  12. JetCache 2.7.8, яка використовує Caffeine як cache provider

Ось код конфігурації benchmark:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 10)
@Measurement(iterations = 10)
public class CacheBenchmark {
      private static final int CACHE_SIZE = 50_000;
      private static final int MAX_ITEM_SIZE = 1_000_000;
      private final Map<String, LocalCache<Integer, Integer>> readonlyCache;
      private final Map<String, LocalCache<Integer, Integer>> writeCache;
      private final Random random = new Random();

      public CacheBenchmark() {
            readonlyCache = new HashMap<>();
            readonlyCache.put("hashMap", new HashMapLocalCache<>());
            readonlyCache.put("concurrentHashMap", new ConcurrentHashMapLocalCache<>());
            readonlyCache.put("lruLinkedtHashMap", new LRULinkedHashMapLocalCache<>(CACHE_SIZE));
            readonlyCache.put("lruMap", new LRUMapLocalCache<>(CACHE_SIZE));
            readonlyCache.put("swissTable", new SwissTableLocalCache<>());
            readonlyCache.put("jcs", new JcsLocalCache<>("read-only"));
            readonlyCache.put("ehcache", new EhCacheLocalCache<>(CACHE_SIZE, Integer.class, Integer.class));
            readonlyCache.put("caffeine", new CaffeineLocalCache<>(CACHE_SIZE, false));
            readonlyCache.put("caffeine-weak", new CaffeineLocalCache<>(CACHE_SIZE, true));
            readonlyCache.put("guava", new GoogleGuavaLocalCache<>(CACHE_SIZE));
            readonlyCache.put("cache2k", new Cache2KLocalCache<>(CACHE_SIZE, Integer.class, Integer.class));
            readonlyCache.put("jetcache", new JetCacheLocalCache<>(CACHE_SIZE));
            writeCache = new HashMap<>();
            writeCache.put("lruLinkedtHashMap", new LRULinkedHashMapLocalCache<>(CACHE_SIZE));
            writeCache.put("lruMap", new LRUMapLocalCache<>(CACHE_SIZE));
            writeCache.put("jcs", new JcsLocalCache<>("writable"));
            writeCache.put("ehcache", new EhCacheLocalCache<>(CACHE_SIZE, Integer.class, Integer.class));
            writeCache.put("caffeine", new CaffeineLocalCache<>(CACHE_SIZE, false));
            writeCache.put("caffeine-weak", new CaffeineLocalCache<>(CACHE_SIZE, true));
            writeCache.put("guava", new GoogleGuavaLocalCache<>(CACHE_SIZE));
            writeCache.put("cache2k", new Cache2KLocalCache<>(CACHE_SIZE, Integer.class, Integer.class));
            writeCache.put("jetcache", new JetCacheLocalCache<>(CACHE_SIZE));
            for (int i = 0; i < CACHE_SIZE; i++) {
                   final int j = i;
                   readonlyCache.values().forEach(entry -> entry.put(j, j));
            }
     }
     @TearDown
     public void tearDown() {
            readonlyCache.values().forEach(t -> {
                try {
                    t.close();
                } catch (Exception e) {
             }
         });
     }

Перший benchmark:

@Param({ «hashMap», «concurrentHashMap», «lruLinkedtHashMap», «lruMap», «swissTable», «jcs», «ehcache», «caffeine», «caffeine-weak», «guava», «cache2k», «jetcache» })
private String cacheId;

@Benchmark
public Integer getCacheValueReadOnly() {
       int key = random.nextInt(CACHE_SIZE);
       return readonlyCache.get(cacheId).get(key);
}

Результати першого benchmark:

Benchmark (cacheId) Mode Cnt Score Error Units
CacheBenchmark.getCacheValueReadOnly hashMap avgt 5 22.012 ± 0.111 ns/op
CacheBenchmark.getCacheValueReadOnly concurrentHashMap avgt 5 21.870 ± 0.203 ns/op
CacheBenchmark.getCacheValueReadOnly lruLinkedtHashMap avgt 5 21.905 ± 0.616 ns/op
CacheBenchmark.getCacheValueReadOnly lruMap avgt 5 67.256 ± 1.790 ns/op
CacheBenchmark.getCacheValueReadOnly swissTable avgt 5 11666.673 ± 83.701 ns/op
CacheBenchmark.getCacheValueReadOnly jcs avgt 5 121.186 ± 10.418 ns/op
CacheBenchmark.getCacheValueReadOnly ehcache avgt 5 57.602 ± 4.329 ns/op
CacheBenchmark.getCacheValueReadOnly caffeine avgt 5 85.596 ± 1.764 ns/op
CacheBenchmark.getCacheValueReadOnly caffeine-weak avgt 5 44.330 ± 0.380 ns/op
CacheBenchmark.getCacheValueReadOnly guava avgt 5 117.354 ± 3.636 ns/op
CacheBenchmark.getCacheValueReadOnly cache2k avgt 5 36.300 ± 2.992 ns/op
CacheBenchmark.getCacheValueReadOnly jetcache avgt 5 166.424 ± 8.334 ns/op

Аналізуючи результати, можна зазначити наступне:

  1. HashMap і ConcurrentHashMap залишаються найшвидшими рішеннями для випадку, коли вам потрібен найпростіший і статичний read-only кеш.
  2. Якщо брати бібліотеки, які призначені безпосередньо для кешування, тут кращі результати показала cache2k.
  3. На подив бібліотека Caffeine, яка заявляє про себе, як про найшвидший кеш, показала не найкращі результати, але виявилося, що використання weak references для ключів/значень вдвічі прискорює її роботу, і така конфігурація показала другий результат серед усіх бібліотек.
  4. Абсолютний аутсайдер — SwissTable, що змушує задуматися про те, наскільки ефективний цей алгоритм або його конкретна реалізація.

Другий benchmark:

@Param({ «lruLinkedtHashMap», «lruMap», «jcs», «ehcache», «caffeine»,
«caffeine-weak», «guava», «cache2k», «jetcache» })
private String writeCacheId;
@Benchmark
public Integer getRandomValue() {
      int key = random.nextInt(MAX_ITEM_SIZE);
      return writeCache.get(cacheId).getAndUpdate(key, key);
}

Результати другого benchmark:

Benchmark (writeCacheId) Mode Cnt Score Error Units
CacheBenchmark.getRandomValue lruLinkedtHashMap avgt 4 59.609 ± 2.073 ns/op
CacheBenchmark.getRandomValue lruMap avgt 4 77.257 ± 1.300 ns/op
CacheBenchmark.getRandomValue jcs avgt 4 227.941 ± 5.819 ns/op
CacheBenchmark.getRandomValue ehcache avgt 4 543.744 ± 24.953 ns/op
CacheBenchmark.getRandomValue caffeine avgt 4 290.239 ± 4.754 ns/op
CacheBenchmark.getRandomValue caffeine-weak avgt 4 298.660 ± 9.781 ns/op
CacheBenchmark.getRandomValue guava avgt 4 338.714 ± 9.371 ns/op
CacheBenchmark.getRandomValue cache2k avgt 4 210.519 ± 1.516 ns/op
CacheBenchmark.getRandomValue jetcache avgt 4 322.083 ± 11.519 ns/op

Аналізуючи результати, можна зазначити наступне:

  1. Найкращі результати показали найпростіші реалізації на базі LinkedHashMap.
  2. Серед бібліотек кешування найкращі результати — у cache2k та jcs.
  3. Caffeine показав результат на 30% гірше переможця (серед бібліотек), але тут використання weak references уповільнило її роботу на 3%.
  4. Найгірший результат показав EhCache.

Але це не все. JCS — єдина бібліотека з усіх аналізованих, що дозволяє змінювати у конфігурації алгоритм eviction. До цього часу ми використовували LRU. Спробуємо застосувати інші чотири (FIFO, MRU, LHMLRU, ARC, soft references):

Benchmark (algorythm) Mode Cnt Score Error Units

CacheBenchmark.getRandomValue FIFO avgt 4 233.203 ± 7.859 ns/op
CacheBenchmark.getRandomValue LHMLRU avgt 4 151.409 ± 9.327 ns/op
CacheBenchmark.getRandomValue soft avgt 4 373.580 ± 9.324 ns/op
CacheBenchmark.getRandomValue MRU avgt 4 230.767 ± 10.067 ns/op

Також було кілька помилок під час використання LHMLRU:

org.apache.commons.jcs3.engine.memory.shrinking.ShrinkerThread
INFO: Unexpected trouble in shrink cycle
java.util.ConcurrentModificationException
at java.base/java.util.LinkedHashMap$LinkedHashIterator.nextNode(LinkedHashMap.java:1024)

На жаль, протестувати алгоритм ARC не вдалося, оскільки його реалізація носить експериментальний характер, не входить до основного проєкту і навіть не компілюється. На жаль, через відсутність тикетів на JCS GitHub неможливо навіть повідомити проблему.

Загалом можна відзначити:

  1. Алгоритм LHMLRU — це LRU на базі LinkedHashMap показав кращу швидкодію, тому у всіх наступних тестах ми будемо використовувати саме його.
  2. FIFO, MRU та LRU показали приблизно рівну швидкодію.
  3. При використанні «soft references» всі значення обертаються у soft references. При цьому не діє обмеження на розмір кешу, він може зростати нескінченно, але при цьому значення, що не використовуються, можуть видалятися збирачем сміття. Але при цьому такий підхід показав найгірший результат.

Наш другий benchmark відрізнявся від звичайного використання writable кешу, тому що там був відсутній такий необхідний крок, як завантаження даних із зовнішнього джерела або їх обчислення в тому випадку, якщо їх немає в кеші. Зараз ми відразу ж передаємо дані в кеш, ніби вони у нас завжди під рукою. Тому для того, щоб оцінити ефективність кожної реалізації, порівняємо їхню статистику. А для цього потрібно уточнити термінологію:

  • Cache miss — звернення до кешу, коли немає ключа.
  • Cache hit — звернення до кешу, коли є ключ.
  • Hit rate — відношення cache hit count до всіх запитів до кешу, тобто hit count/(miss count + hit count).

Тому hit rate — це найважливіший показник (метрика), яка перевіряється у таких тестах. Для того, щоб виміряти його, додамо новий Java Record:

public record CacheStatistics(long hitCount, long missCount) {
     public double getHitRate() {
          return (double) hitCount / (hitCount + missCount);
     }
}

А також новий метод в інтерфейс LocalCache, який повертатиме об’єкт CacheStatistics для тих реалізацій, де ведеться статистика:

default CacheStatistics getStatistics() {
     return null;
}

І потім після всіх тестів виведемо hit rate для кожної бібліотеки:

@TearDown
public void tearDown() {
        writeCache.entrySet().stream().filter(entry -> entry.getValue().getStatistics() != null)
             .forEach(entry -> System.out.println(
                «Cache: » + entry.getKey() + «, hitRate: » + entry.getValue().getStatistics().getHitRate()));

Потім включимо ведення статистиці тих бібліотек, де його можна включити, оскільки раніше ми його вимикали, щоб воно не впливало на продуктивність. В результаті отримаємо:

Cache: jcs, hitRate: 0.04998156910853292
Cache: cache2k, hitRate: 0.04997203216692542
Cache: guava, hitRate: 0.049970803467164704
Cache: caffeine, hitRate: 0.051538958710939976

У результаті можна констатувати, що всі бібліотеки показали приблизно однаковий hit rate, а мінімальні відмінності можна пояснити похибкою.

Залишилось порівняти розміри дистрибутивів:

  1. JetCache — 166 кб
  2. Cache2k — 277 кб
  3. JCS — 534 кб
  4. Caffeine — 885 кб
  5. EhCache — 1700 кб
  6. Guava — 2900 кб

Висновки

  • Якщо вам потрібний найпростіший кеш з максимальною швидкодією, то вам, безумовно, підійдуть Map-реалізації з JDK.
  • Бібліотека JCS (на базі алгоритму LHMLRU) показала найкращий результат серед усіх конкурентів, випередивши найближчого суперника вдвічі.
  • Також JCS є єдиною бібліотекою, яка дозволяє змінювати алгоритм eviction, в інших проєктах він або жорстко зашитий, або може змінюватися, але движком самої бібліотеки.
  • Cache2k має найменший дистрибутив (якщо не вважати JetCache, який є просто API).
  • Бібліотеки cache2k та EhCache вже два роки не оновлюються.
  • Найбільша спільнота (за популярністю) у EhCache, за цим показником вона в 15 разів випереджає решту претендентів.
  • Усі бібліотеки (крім JCS) підтримують програмну конфігурацію.
  • Багато бібліотек підтримують специфікацію JCache або інтегруються з Spring Cache.
  • Додатковою перевагою багатьох реалізацій є підтримка додаткових засобів зберігання даних (диск, off-heap, розподілені сховища), коли дані, що не використовуються, не видаляються з кешу, а просто переносяться в повільніше сховище.
  • Ну і головне — при виборі бібліотеки вам потрібно скласти такі benchmarks, які симулюють ваші робочі use-cases.
👍ПодобаєтьсяСподобалось15
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Хорошая статья.
Redis в части случаев можно использовать напрямую (через клиент) в качестве кеша для хранения данных в его структурах отличных от Key — Value.

Тут мова піде здебільшого про локальне кешування, тобто коли дані знаходяться на тому ж сервері, що і сам застосунок

Дякую за відгук.
Я планую в майбутньому написати окрему статтю про віддалений кеш на базі Redis та його численних клонів.

Годна стаття.
Ще би про кешування в спрінгбуті згадати. Але це мабуть не вклалось би у абзац.

Дякуємо за відгук.
Кешування в Spring Boot — це фактично Spring Cache плюс ті бібліотеки, про які йдеться у статті. Комунікація через JCache чи безпосередньо. Про це є і велика докладна документація, і просто tutorials.

Також JCS є єдиною бібліотекою, яка дозволяє змінювати алгоритм eviction, в інших проєктах він або жорстко зашитий, або може змінюватися, але движком самої бібліотеки.

думаю варто уточнити що мова йде про size-based eviction

Підписатись на коментарі