Высокопроизводительный кеш Принцип кофеина и реальный бой

Java Язык программирования

1. Введение

Caffeine — это высокопроизводительный компонент локального кэша, разработанный на основе Java 8, который обеспечивает почти лучший показатель совпадений. Spring 5 больше не поддерживает Guava Cache и вместо этого использует Caffeine.

НижеОфициальный отчет об испытаниях кофеина.

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

Эта статья сначала знакомит с принципом реализации Caffeine, а затем объясняет, как использовать Caffeine в проекте.

2. Принцип кофеина

2.1 Алгоритм исключения

2.1.1 Общие алгоритмы

Для внутрипроцессного кэширования Java мы можем реализовать его через HashMap. Однако память процесса Java ограничена, и в нее невозможно помещать объекты кеша бесконечно. Для этого требуется подходящий алгоритм, который поможет нам исключить объекты с относительно низкой потребительской ценностью, оставив место для новых объектов. Распространенными алгоритмами удаления кеша являются FIFO, LRU и LFU.

FIFO (First In First Out): первый пришел первый ушел.

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

LRU (наименее недавно использованный): в последнее время он не использовался дольше всего.

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

LFU (наименее часто используемый):

Наименее часто используется в последнее время. Это необходимо, чтобы сначала удалить наименее часто используемые данные, и необходимо поддерживать поле, представляющее частоту использования.

Есть два основных недостатка:

1. Если частота доступа относительно высока, поле частоты будет занимать определенное место;

2. Невозможно разумно обновлять новые горячие данные.Например, у определенного певца много проигрывания старых песен.Если новые песни сортировать вместе со старыми песнями, они никогда не выйдут.

2.1.2 Алгоритм W-TinyLFU

Caffeine использует алгоритм W-TinyLFU для решения вышеуказанных недостатков LRU и LFU. Алгоритм W-TinyLFU предложен в статье «TinyLFU: высокоэффективная политика доступа к кэшу».

В основном он делает две вещи:

1. Используйте алгоритм Count-Min Sketch, чтобы уменьшить потребление памяти, вызванное информацией о частоте;

2. Поддерживайте механизм PK, чтобы обеспечить возможность кэширования новых горячих данных.

Как показано на рисунке ниже, алгоритм Count-Min Sketch аналогичен идее фильтра Блума, и нам на самом деле не нужно точное значение для частотной статистики. При хранении данных, после выполнения нескольких операций хэш-функции над ключом, частота хранения двумерного массива в разных местах (когда фактически реализован Caffeine, используется одномерный массив длинного типа, и каждое число длинного типа делится на 16 частей, каждая по 4 бита, по умолчанию 15 раз — это самая высокая частота доступа, и каждый ключ фактически хешируется четыре раза, попадая где-то в 16 копий разных длинных чисел). При чтении времен доступа ключа будут сравниваться значения частоты на всех позициях, и будет возвращено минимальное значение. Существует максимальное значение суммы частот обращения всех ключей, при достижении максимального значения выполняется сброс, то есть частота каждого ключа кэша делится на 2.

Как показано на рисунке ниже, кэш-память частоты доступа в основном разделена на две части, а именно LRU и Segmented LRU. Недавно полученные данные будут поступать в первый LRU, называемый WindowDeque в Caffeine. Когда WindowDeque заполнен, он войдет в ProbationDeque в сегментированном LRU, а при последующем доступе к нему будет повышен до уровня ProtectedDeque. Когда ProtectedDeque заполнен, данные будут понижены до ProbationDeque. Когда данные необходимо удалить, данные в ProbationDeque удаляются. Конкретный механизм исключения: взять голову и хвост команды в ProbationDeque для PK.Данные во главе команды первыми попадают в очередь, которая называется жертвой, а данные в хвосте команды называется нападающим.

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

2.2 Высокопроизводительное чтение и запись

Caffeine считает, что операции чтения выполняются часто, операции записи — время от времени, а чтение и запись — это асинхронные потоки для обновления информации о частоте.

2.2.1 Чтение буфера

Традиционные реализации кэша блокируют каждую операцию, чтобы можно было безопасно упорядочить каждый элемент очереди доступа. Одна оптимизация заключается в пакетной обработке операций путем последовательного добавления каждой операции в буфер. После чтения поместите данные в кольцевую очередь RingBuffer.Чтобы уменьшить параллелизм чтения, используются несколько RingBuffers, и каждый поток имеет соответствующий RingBuffer. Циклическая очередь — это массив фиксированной длины, обеспечивающий высокую производительность и минимизирующий издержки производительности, вызванные сборкой мусора. После того, как данные закинуты в очередь, возвращается результат чтения, который аналогичен механизму WAL базы данных, по сравнению с чтением данных ConcurrentHashMap, есть только еще один шаг помещения данных в очередь. Асинхронный поток одновременно считывает массив RingBuffer и обновляет информацию о доступе. Пул потоков здесь использует исполнителя в параметрах конфигурации Caffeine, описанных в разделе фактического боя ниже.

2.2.2 Буфер записи

Подобно буферу чтения, буфер записи используется для хранения событий записи. События в буфере чтения в основном предназначены для оптимизации частоты попаданий стратегии вытеснения, поэтому целостность событий в буфере чтения допускает определенную степень потерь. Но буферизация записи не допускает потери данных, поэтому она должна быть реализована как безопасная очередь. Caffeine записывает данные в очередь блокировки MpscGrowableArrayQueue, которая ссылается на MpscGrowableArrayQueue в JCTools и является высокопроизводительной реализацией для сценариев MPSC-Multi-Producer и Single-Consumer. Потокобезопасно, когда несколько производителей одновременно записывают в очередь, но только один потребитель может использовать очередь одновременно.

3. Настоящий бой с кофеином

3.1 Параметры конфигурации

Caffeine опирается на идею дизайна Guava Cache.Если вы раньше использовали Guava Cache, то Caffeine легко начать, вам нужно только изменить соответствующее имя класса. Пример кода для построения кэша выглядит следующим образом:

Cache cache = Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(6, TimeUnit.MINUTES).softValues().build();

Класс Caffeine эквивалентен классу Builder режима Builder.Cache настраивается через класс Caffeine.Настройка Cache имеет следующие параметры:

  • expireAfterWrite: как долго устраняется интервал записи;
  • expireAfterAccess: как долго должен быть устранен интервал после последнего доступа;
  • refreshAfterWrite: время обновления после записи. Обновление запускается пассивно на основе доступа и поддерживает асинхронное обновление и синхронное обновление. фиксированный временной интервал, иначе легко вызвать OOM, если использовать его отдельно;
  • expireAfter: настраиваемая стратегия устранения, в соответствии с которой Caffeine реализует разное время истечения срока действия для разных ключей с помощью алгоритма колеса времени;
  • maxSize: максимальное количество ключей кэша;
  • weakKeys: для ключа устанавливается слабая ссылка, которую можно удалить непосредственно во время GC;
  • weakValues: значение устанавливается на слабую ссылку, которую можно удалить непосредственно во время GC;
  • softValues: значение устанавливается на мягкую ссылку, которую можно удалить непосредственно перед переполнением памяти;
  • исполнитель: выберите собственный пул потоков, реализация пула потоков по умолчанию — ForkJoinPool.commonPool();
  • maxWeight: Установите максимальный вес кеша;
  • весы: установите конкретный вес ключа;
  • RecordStats: кешированная статистика, такая как частота попаданий и т. д.;
  • removeListener: прослушиватель удаления кеша;
  • Writer: прослушиватель операций записи, обновлений и удалений кеша.

3.2 Фактический бой проекта

Caffeine поддерживает парсинг строковых параметров.Ссылаясь на идею Ehcache, вы можете поместить всю информацию о параметрах элементов кеша в файл конфигурации.Например, есть файл конфигурации caffeine.properties.Параметры конфигурации следующие:

users=maximumSize=10000,expireAfterWrite=180s,softValues
goods=maximumSize=10000,expireAfterWrite=180s,softValues

Для разных кешей разберите файл конфигурации и добавьте его в контейнер Cache.Код выглядит следующим образом:

@Component
@Slf4j
public class CaffeineManager {
    private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);
 
    @PostConstruct
    public void afterPropertiesSet() {
        String filePath = CaffeineManager.class.getClassLoader().getResource("").getPath() + File.separator + "config"
            + File.separator + "caffeine.properties";
        Resource resource = new FileSystemResource(filePath);
        if (!resource.exists()) {
            return;
        }
        Properties props = new Properties();
        try (InputStream in = resource.getInputStream()) {
            props.load(in);
            Enumeration propNames = props.propertyNames();
            while (propNames.hasMoreElements()) {
                String caffeineKey = (String) propNames.nextElement();
                String caffeineSpec = props.getProperty(caffeineKey);
                CaffeineSpec spec = CaffeineSpec.parse(caffeineSpec);
                Caffeine caffeine = Caffeine.from(spec);
                Cache manualCache = caffeine.build();
                cacheMap.put(caffeineKey, manualCache);
            }
        }
        catch (IOException e) {
            log.error("Initialize Caffeine failed.", e);
        }
    }
}

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

Что касается того, следует ли использовать выражения EL Spring через аннотации, доброжелательный видит доброжелательный, а мудрый видит мудрость Автор в основном рассматривает три момента:

Во-первых, выражения EL будут иметь место для изучения затрат;

Во-вторых, введение динамических прокси не может отметить сцену аннотации;

При получении кеша используйте следующие методы:

caffeineManager.getCache(cacheName).get(redisKey, value -> getTFromRedis(redisKey, targetClass, supplier));

Метод get Caffeine с функцией возврата к исходному коду в конечном итоге вызывает метод вычисления ConcurrentHashMap, который может гарантировать, что в сценариях с высокой степенью параллелизма, если горячая клавиша является возвратом к источнику, только один поток в одном процессе возвращается к источнику. source, а остальные блочные. Бизнес должен обеспечить, чтобы метод возврата к источнику был относительно коротким по времени, предотвращая время блокировки потока в течение длительного времени и снижая доступность системы.

В реальной разработке автор использовал двухуровневый кеш Caffeine и Redis. Ключ кэша Caffeine такой же, как у Redis, и оба они называются redisKey. targetClass — это тип возвращаемого объекта, который используется, когда строка, полученная из Redis, десериализуется в фактический объект. Поставщик представляет собой функциональный интерфейс, который представляет собой бизнес-логику кэширования обратно в базу данных.

Метод getTFromRedis реализован следующим образом:

private <T> T getTFromRedis(String redisKey, Class targetClass, Supplier supplier) {
    String data;
    T value;
    String redisValue = UUID.randomUUID().toString();
    if (tryGetDistributedLockWithRetry(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue, 30)) {
        try {
            data = getFromRedis(redisKey);
            if (StringUtils.isEmpty(data)) {
                value = (T) supplier.get();
                setToRedis(redisKey, JackSonParser.bean2Json(value));
            }
            else {
                value = json2Bean(targetClass, data);
            }
        }
        finally {
            releaseDistributedLock(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue);
        }
    }
    else {
        value = json2Bean(targetClass, getFromRedis(redisKey));
    }
    return value;
}

Поскольку возврат к источнику запрашивается из MySQL, хотя сам Caffeine решает проблему, заключающуюся в том, что один и тот же ключ в процессе имеет только один поток обратно к источнику, необходимо обратить внимание на распределенную ситуацию нескольких бизнес-узлов, если у Redis нет кэшированного значения, оно будет проникать при отправке обратно к источнику в MySQL, поэтому при возврате к источнику добавляются распределенные блокировки, чтобы гарантировать, что только один узел возвращается к источнику.

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

В проекте автора раньше в качестве локального кеша использовался Ehcache.После перехода на Caffeine он задействует интерфейс локального кеша.При одинаковом значении TPS загрузка процессора может быть снижена примерно на 10%, а производительность интерфейса была снижена. улучшилось до определенной степени, с максимальным увеличением на 25%. Глядя на цепочку звонков после выхода в интернет, среднее время ответа сокращается примерно на 24%.

4. Резюме

В настоящее время Caffeine является отличным решением для локального кэширования.Используя алгоритм W-TinyLFU, он обеспечивает высокую скорость попадания в кэш и низкое потребление памяти. Если вы раньше использовали Guava Cache, вы можете начать работу, взглянув на имя интерфейса. Если ранее использовался Ehcache, метод использования, предложенный автором, можно использовать в качестве эталона.

5. Ссылки

  1. TinyLFU: A Highly Efficient Cache Admission Policy

  2. Design Of A Modern Cache

  3. Caffeine Github

Автор: Чжан Чжэнлинь