Статьи

@Cacheable накладные расходы весной

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

С тех пор мы можем просто комментировать тяжелые методы и позволить машине Spring и AOP выполнять свою работу:

1
2
@Cacheable("books")
public Book findBook(ISBN isbn) {...}

"books" — это имя кеша, параметр isbn становится ключом кеша, а возвращенный объект Book будет помещен под этот ключ. Значение имени кэша зависит от используемого менеджера кэша (EhCache, параллельная карта и т. Д.) — Spring упрощает подключение различных поставщиков кэширования. Но этот пост не будет посвящен кешированию весной

Некоторое время назад мой товарищ по команде оптимизировал довольно низкоуровневый код и обнаружил возможность кеширования. Он быстро применил @Cacheable чтобы обнаружить, что код работает хуже, чем раньше. Он избавился от аннотации и сам реализовал кэширование, используя старый добрый java.util.ConcurrentHashMap . Производительность была намного лучше. Он обвинял в сложности и сложности @Cacheable и Spring AOP. Я не мог поверить, что уровень кэширования может работать так плохо, пока мне самому не пришлось несколько раз отлаживать аспекты кэширования Spring (знаете, какая-то неприятная ошибка в моем коде, аннулирование кэша — одна из двух самых сложных вещей в CS ). Что ж, код абстракции для кэширования гораздо сложнее, чем можно было бы ожидать (в конце концов, он просто получает и кладет !), Но это не обязательно означает, что он должен быть таким медленным?

В науке мы не верим и не верим, мы измеряем и измеряем. Поэтому я написал тест для точного измерения накладных @Cacheable слоя @Cacheable . Уровень абстракции кэширования в Spring реализован поверх Spring AOP, который в дальнейшем может быть реализован поверх прокси Java, сгенерированных CGLIB подклассов или инструментария AspectJ. Таким образом, я протестирую следующие конфигурации:

Позвольте мне повторить: мы не измеряем прирост производительности кэширования и не сравниваем различные поставщики кэша. Вот почему наш метод тестирования настолько быстр, насколько это возможно, и я буду использовать ConcurrentMapCacheManager простой ConcurrentMapCacheManager из Spring. Итак, вот метод, о котором идет речь:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public interface Calculator {
  
    int identity(int x);
  
}
  
public class PlainCalculator implements Calculator {
  
    @Cacheable("identity")
    @Override
    public int identity(int x) {
        return x;
    }
  
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public abstract class BaseConfig {
  
    @Bean
    public Calculator calculator() {
        return new PlainCalculator();
    }
  
}
  
@Configuration
class NoCachingConfig extends BaseConfig {}
  
@Configuration
class ManualCachingConfig extends BaseConfig {
    @Bean
    @Override
    public Calculator calculator() {
        return new CachingCalculatorDecorator(super.calculator());
    }
}
  
@Configuration
abstract class CacheManagerConfig extends BaseConfig {
  
    @Bean
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager();
    }
  
}
  
@Configuration
@EnableCaching(proxyTargetClass = true)
class CacheableCglibConfig extends CacheManagerConfig {}
  
@Configuration
@EnableCaching(proxyTargetClass = false)
class CacheableJdkProxyConfig extends CacheManagerConfig {}
  
@Configuration
@EnableCaching(mode = AdviceMode.ASPECTJ)
class CacheableAspectJWeaving extends CacheManagerConfig {
  
    @Bean
    @Override
    public Calculator calculator() {
        return new SpringInstrumentedCalculator();
    }
  
}
  
@Configuration
@EnableCaching(mode = AdviceMode.ASPECTJ)
class AspectJCustomAspect extends CacheManagerConfig {
  
    @Bean
    @Override
    public Calculator calculator() {
        return new ManuallyInstrumentedCalculator();
    }
  
}

Каждый класс @Configuration представляет один контекст приложения. CachingCalculatorDecorator — это декоратор реального калькулятора, который выполняет кеширование (добро пожаловать в 1990-е):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public class CachingCalculatorDecorator implements Calculator {
  
    private final Map<Integer, Integer> cache = new java.util.concurrent.ConcurrentHashMap<Integer, Integer>();
  
    private final Calculator target;
  
    public CachingCalculatorDecorator(Calculator target) {
        this.target = target;
    }
  
    @Override
    public int identity(int x) {
        final Integer existing = cache.get(x);
        if (existing != null) {
            return existing;
        }
        final int newValue = target.identity(x);
        cache.put(x, newValue);
        return newValue;
    }
}

SpringInstrumentedCalculator и ManuallyInstrumentedCalculator точно такие же, как PlainCalculator но они оснащены инструментарием AspectJ во время компиляции с Spring и пользовательским аспектом соответственно. Мой пользовательский аспект кэширования выглядит так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public aspect ManualCachingAspect {
  
    private final Map<Integer, Integer> cache = new ConcurrentHashMap<Integer, Integer>();
  
    pointcut cacheMethodExecution(int x): execution(int com.blogspot.nurkiewicz.cacheable.calculator.ManuallyInstrumentedCalculator.identity(int)) && args(x);
  
    Object around(int x): cacheMethodExecution(x) {
        final Integer existing = cache.get(x);
        if (existing != null) {
            return existing;
        }
        final Object newValue = proceed(x);
        cache.put(x, (Integer)newValue);
        return newValue;
    }
  
}

После всей этой подготовки мы, наконец, можем написать сам тест. Сначала я запускаю все контексты приложения и выбираю экземпляры Calculator . Каждый экземпляр отличается. Например, noCaching — это экземпляр PlainCalculator без оболочек, cacheableCglib — это сгенерированный CGLIB подкласс, а aspectJCustom — это экземпляр ManuallyInstrumentedCalculator с моим пользовательским аспектом.

01
02
03
04
05
06
07
08
09
10
private final Calculator noCaching = fromSpringContext(NoCachingConfig.class);
private final Calculator manualCaching = fromSpringContext(ManualCachingConfig.class);
private final Calculator cacheableCglib = fromSpringContext(CacheableCglibConfig.class);
private final Calculator cacheableJdkProxy = fromSpringContext(CacheableJdkProxyConfig.class);
private final Calculator cacheableAspectJ = fromSpringContext(CacheableAspectJWeaving.class);
private final Calculator aspectJCustom = fromSpringContext(AspectJCustomAspect.class);
  
private static <T extends BaseConfig> Calculator fromSpringContext(Class<T> config) {
    return new AnnotationConfigApplicationContext(config).getBean(Calculator.class);
}

Я собираюсь проверить каждый экземпляр Calculator следующим тестом. Необходим дополнительный аккумулятор, иначе JVM может оптимизировать весь цикл (!):

1
2
3
4
5
6
7
private int benchmarkWith(Calculator calculator, int reps) {
    int accum = 0;
    for (int i = 0; i < reps; ++i) {
        accum += calculator.identity(i % 16);
    }
    return accum;
}

Вот полный тест суппорта без уже обсужденных частей:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CacheableBenchmark extends SimpleBenchmark {
  
    //...
  
    public int timeNoCaching(int reps) {
        return benchmarkWith(noCaching, reps);
    }
  
    public int timeManualCaching(int reps) {
        return benchmarkWith(manualCaching, reps);
    }
  
    public int timeCacheableWithCglib(int reps) {
        return benchmarkWith(cacheableCglib, reps);
    }
  
    public int timeCacheableWithJdkProxy(int reps) {
        return benchmarkWith(cacheableJdkProxy, reps);
    }
  
    public int timeCacheableWithAspectJWeaving(int reps) {
        return benchmarkWith(cacheableAspectJ, reps);
    }
  
    public int timeAspectJCustom(int reps) {
        return benchmarkWith(aspectJCustom, reps);
    }
}

Я надеюсь, что вы все еще следите за нашим экспериментом. Теперь мы собираемся выполнить Calculate.identity() миллионы раз и посмотрим, какая конфигурация кэширования работает лучше всего. Поскольку мы вызываем identity() с 16 различными аргументами, мы почти никогда не затрагиваем сам метод, поскольку всегда получаем попадание в кэш. Хотите увидеть результаты?

1
2
3
4
5
6
7
benchmark      ns linear runtime
                  NoCaching    1.77 =
              ManualCaching   23.84 =
         CacheableWithCglib 1576.42 ==============================
      CacheableWithJdkProxy 1551.03 =============================
CacheableWithAspectJWeaving 1514.83 ============================
              AspectJCustom   22.98 =

Каверномер

интерпретация

Пойдемте шаг за шагом. Во-первых, вызов метода в Java довольно быстрый! 1,77 наносекунды , мы говорим здесь о 3 циклах процессора на моем процессоре Intel® Core ™ 2 Duo T7300 @ 2,00 ГГц! Если это не убедит вас в том, что Java работает быстро, я не знаю, что будет. Но вернемся к нашему тесту.

Ручной кеширующий декоратор тоже довольно быстрый. Конечно, это на порядок медленнее по сравнению с чистым вызовом функции, но все равно невероятно быстро по сравнению со всеми @Scheduled . Мы видим падение на 3 порядка , с 1,8 нс до 1,5 мкс. Я особенно разочарован @Cacheable при поддержке AspectJ. После того, как весь аспект кэширования предварительно скомпилирован в мой файл Java .class , я ожидал бы, что он будет намного быстрее по сравнению с динамическими прокси и CGLIB. Но, похоже, это не так. Все три метода Spring AOP похожи.

Самым большим сюрпризом является мой собственный аспект AspectJ. Это даже быстрее, чем CachingCalculatorDecorator ! может это из-за полиморфного вызова в декораторе? Я настоятельно рекомендую вам клонировать этот тест на GitHub и запустить его ( mvn clean test , занимает около 2 минут), чтобы сравнить ваши результаты.

Выводы

Вам может быть интересно, почему слой абстракции Spring такой медленный? Ну, во-первых, проверьте реализацию ядра в CacheAspectSupport — это на самом деле довольно сложно. Во-вторых, это действительно так медленно? Посчитайте — вы обычно используете Spring в бизнес-приложениях, где узким местом являются база данных, сеть и внешние API. Какие задержки вы обычно видите? Миллисекунды? Десятки или сотни миллисекунд? Теперь добавьте накладные расходы в 2 мкс (в худшем случае). Для кэширования запросов к базе данных или вызовов REST это совершенно незначительно. Неважно, какую технику вы выберете .

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

PS: и тест, и содержание этой статьи в формате Markdown находятся в свободном доступе.

Ссылка: @Cacheable накладные расходы весной от нашего партнера JCG Томаша Нуркевича из блога Java и соседей .