Caching in Spring Boot

Getting started with Spring Boots default cache implementation

I have already published this post here in 2019. However, for my blog on hashnode I have revised and improved my work from 4 years ago.

1. Introduction

In this blog post, we'll take a quick look at what a cache is and in which scenarios a cache is useful. Then we'll look at how caching works in Spring Boot using Spring Boot's default cache implementation. For the main part of the post, I brought along a demo project with some code, which you can find on my GitHub Account.

2. Caching

Caching is a technique that involves the intermediate storage of data in very fast memory, usually. This means that this data can be made available much more quickly for subsequent requests since it does not have to be retrieved or recalculated from the primary and usually slower memory first.

Caching is particularly useful for the following scenarios:

  • The same data is requested again and again (so-called hot spots), which have to be loaded from the database anew with each request. This data can be cached in the main memory of the server application (RAM) or on the client (browser cache). This reduces access times and the number of data transfers since the server does not have to repeatedly request data from the database and send it to the client.

  • Long-term or resource-intensive operations are often performed with specific parameters. Depending on the parameters, the result of the operation can be stored temporarily so that the server can send the result to the client without executing the operation.

3. Caching in Spring Boot 3

In Spring Boot it is very easy to add caching to an application. All you need to do is activate caching support via Annotation @EnableCaching. As we are used to from Spring Boot, the entire caching infrastructure is configured for us.

Springs Caching Service is an abstraction and not an implementation. Therefore it is necessary to use a cache provider. Spring Boot supports a wide range of cache providers:

A change of the cache provider has no effect on the existing code, as the developer only gets in touch with the abstract concepts.

If no cache provider is added, Spring Boot configures a very simple provider that caches in main memory using maps. This is sufficient for testing, but for applications in production, you should choose one of the above cache providers.

Nevertheless, let's take a look at Spring Boot's default cache implementation here and now.

4. Demo

The demo project is built on the following foundation:

  • Java 17

  • Spring Boot 3

  • Gradle 7.6

Please feel free to clone my sample code from my provided GitHub repository.

4.1 Used Dependencies

For the demo project, we need the following dependencies in our Spring Boot application:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
}

For caching we need the dependency spring-boot-starter-cache. The dependency spring-boot-starter-web is a starter for building web applications. In our example, we will build a simple service that performs a calculation for us. The calculation can be triggered by using a REST endpoint.

4.2 Enable Caching

To enable caching support in Spring Boot, we need a simple configuration class that must be annotated with @EnableCaching. Up to this point, we don't need to do anything more as the following code shows:

@Configuration
@EnableCaching
public class CacheConfig {
}

4.3 Cacheable operation

We start our example with a simple service that calculates the area of a circle. The formula A = PI * radius² is used to calculate the area. Let's assume for this demo that the calculation of the radius is a very expensive operation. The code is as follows:

@Service
@Slf4j
public class CalculationService {

  public double areaOfCircle(int radius) {
    log.info("calculate the area of a circle with a radius of {}", radius);
    return Math.PI * Math.pow(radius, 2);
  }
}

Caching in Spring is applied to public methods (and public methods only!) so that especially the calls for very costly operations can be reduced. We now want to add the result of this calculation to a cache depending on the radius passed by the parameter, so that the calculation does not have to be repeated every time. To do this, we annotate the method with the @Cachable annotation:

@Cacheable(value = "areaOfCircleCache", key = "#radius", condition = "#radius > 5")
public double areaOfCircle(int radius) {
  log.info("calculate the area of a circle with a radius of {}", radius);
  return Math.PI * Math.pow(radius, 2);
}

Each time this method is called with a radius greater than 5, the caching behavior is applied. This checks whether the method has already been called once for the specified parameter. If so, the result is returned from the cache and the method is not executed. If no, then the method is executed and the result is returned and stored in the cache.

The following parameters, among others, are available for annotation:

Annotation parameterDescription
value / cacheNamesName of the cache in which the results of the method execution are to be stored.
keyThe key for the cache entries in Spring Expression Language (SpEL). If the parameter is not specified, a key is created for all method parameters by default.
keyGeneratorName of a bean that implements the KeyGenerator interface and thus allows the creation of a user-defined cache key.
conditionCondition in Spring Expression Language (SpEL) that specifies when a result is to be cached.
unlessCondition in Spring Expression Language (SpEL) that specifies when a result should not be cached.

4.4 Testing our Cache

We now use our CalculationService within the class CalculationRestController and implement a simple REST endpoint, which gives us the result for the calculation of a circular area:

@RestController
@Slf4j
public class CalculationRestController {

  private final CalculationService calculationService;

  public CalculationRestController(CalculationService calculationService) {
    this.calculationService = calculationService;
  }

  @GetMapping(path = "/api/area-of-circle", produces = MediaType.APPLICATION_JSON_VALUE)
  public ResponseEntity<Double> areaOfCircle(@RequestParam int radius) {
    log.info("Requesting area of circle calculation for radius {}", radius);
    double result = calculationService.areaOfCircle(radius);
    return ResponseEntity.ok(result);
  }
}

If, for example, we call the URL http://localhost:8080/api/area-of-circle?radius=6 after starting our application, the area of a circle with a radius of 6 is calculated and the result is displayed in the browser or Postman for example.

For the first call of the URL, the calculation of the circle area is still carried out. For all further calls, we get the result from the cache. Our built-in log output shows that the method is entered only once.

If we calculate the circular area for a radius of 3, then the method is always executed, because the specified radius does not meet the cache condition #radius > 5. A possible log output could be as follows:

CalculationRestController  : Requesting area of circle calculation for radius 6
CalculationService         : calculate the area of a circle with a radius of 6
CalculationRestController  : Requesting area of circle calculation for radius 6
CalculationRestController  : Requesting area of circle calculation for radius 6
CalculationRestController  : Requesting area of circle calculation for radius 3
CalculationService         : calculate the area of a circle with a radius of 3
CalculationRestController  : Requesting area of circle calculation for radius 3
CalculationService         : calculate the area of a circle with a radius of 3

5. Advanced scenarios

5.1 Using a custom key generator

If the possibilities of the SpEL for the generation of the cache key are not enough, the annotation @Cacheable offers the possibility to use a KeyGenerator bean for generating custom cache keys. This can be useful, for example, if a key is to be generated in a specific format for a cache provider. The bean must implement the functional interface KeyGenerator.

We define the associated bean in the class CacheConfig:

  @Bean
  public KeyGenerator multiplyKeyGenerator() {
    return (Object target, Method method, Object... params) ->
        // you can build your own key based on given parameters
        method.getName() + "_" + Arrays.toString(params);
  }

The name of the bean must be specified as the value for the annotation parameter keyGenerator in @Cacheable Annotation:

  @Cacheable(value = "multiplyCache", keyGenerator = "multiplyKeyGenerator")
  public double multiply(int factor1, int factor2) {
    log.info("Multiply {} with {}", factor1, factor2);
    return factor1 * factor2;
  }

If the method is now called with the parameters factor1=2 and factor2=3, then the result of the calculation is cached with the caching key multiply_[2, 3].

5.2 @CachePut

Methods annotated with @Cacheable are not executed again if a value already exists in the cache for the cache key. If the value does not exist in the cache, then the method is executed and places its value in the cache.

Now there is also the use case that we always want the method to be executed and its result to be placed in the cache. This is done using the @CachePut annotation, which has the same annotation parameters as @Cachable.

A possible scenario for using @CachePut is, for example, creating an entity object, as the following example shows:

@Service
@Slf4j
public class StudentService {

  @CachePut(cacheNames = "studentsCache", key = "#result.id")
  public Student create(String firstName, String lastName, String courseOfStudies) {
    log.info("Creating student with firstName={}, lastName={} and courseOfStudies={}",
        firstName, lastName, courseOfStudies
    );

    Student newStudent = new Student(firstName, lastName, courseOfStudies);
    return repository.save(newStudent);
  }
}

The key #result is a placeholder provided by Spring and refers to the return value of the method. The ID of the student is, therefore, the cache key. The cache entry is the return value of the method, the student in our example.

The method now creates a student object and stores it in the studentsCache at the end. The next time this object is requested, it can be retrieved directly from the cache.

5.3 @CacheConfig

The @CacheConfig annotation allows us to define certain cache configurations at the class level. This is especially useful if certain cache settings are the same for all methods to be cached:

@Service
@Slf4j
@CacheConfig(cacheNames = "studentsCache")
public class StudentService {

  private final StudentRepositoryMock repository;

  public StudentService(StudentRepositoryMock repository) {
    this.repository = repository;
  }

  @Cacheable
  public Student find(UUID id) {
    // Implementation omitted for reasons of clarity
  }

  @CachePut(key = "#result.id")
  public Student create(String firstName, String lastName, String courseOfStudies) {
    // Implementation omitted for reasons of clarity
  }
}

Both the find() and create() methods use the studentsCache cache in this example. When a student is created, it is stored directly in the cache. The next time the find() method is called, this student can be loaded directly from the cache. A call to the database is no longer necessary in this case.

6. Cache Eviction

A cache can become very large very quickly. The problem with large caches is that they occupy a lot of important main memory and mostly consist of stale data that is no longer needed.

To avoid inflated caches, you should, of course, have configured a meaningful eviction strategy.

6.1 Cache eviction on demand

The following example shows how to remove all entries from the caches areaOfCircleCache and multiplyCache:

  @CacheEvict(cacheNames = {"areaOfCircleCache", "multiplyCache"}, allEntries = true)
  public void evictCache() {
    log.info("Evict all cache entries...");
  }

This method can be executed e.g. when a certain endpoint is called or a specific event occurs.

6.2 Evict values based on a given key

On the other hand, it is also possible to empty the cache based on a given key. If we want to evict a certain student from the cache we just do this as follows:

  @CacheEvict
  public void evict(UUID id) {
    log.info("Removing student with id {} from cache", id.toString());
  }

6.3 Evict value and re-caching at the same time

Removing an entry from the cache by using @CacheEvict is executed by default once the execution of the method is finished. We can use the parameter beforeInvocation=true if we want to remove the entry before the method starts its execution.

This allows a new value to be added to the cache at the same time via @Cacheable. This achieves a refresh of the cached value as you can see here:

  @Cacheable
  @CacheEvict(beforeInvocation = true)
  public Student findAndRefresh(UUID id) {
    log.info("Refreshing student with id {}", id.toString());
    return repository.fetch(id);
  }

6.4 Clear cache at a specified interval

If the cache is to be cleared at a defined time interval, first you have to enable Spring's scheduled task execution capability. You can do this with the annotation @EnableScheduling in a simple Configuration class:

@Configuration
@EnableScheduling
public class SchedulingConfig {
}

To access our student's cache from anywhere in the Spring Boot application we use CacheManager, Spring's central cache manager SPI.

To do this, we create a CacheManager bean in the CacheConfig class and initialize our student's cache:

@Configuration
@EnableCaching
public class CacheConfig {

  public static final String STUDENTS_CACHE_NAME = "studentsCache";

  @Bean
  public CacheManager cacheManager() {
    return new ConcurrentMapCacheManager(STUDENTS_CACHE_NAME);
  }

  // other code...
}

Now, we can schedule the cache eviction at a fixed rate of one hour:

@Component
@Slf4j
public class CacheClearingTask {

  private static final long ONE_HOUR_IN_MS = 3_600_000;

  private final CacheManager cacheManager;

  public CacheClearingTask(CacheManager cacheManager) {
    this.cacheManager = cacheManager;
  }

  @Scheduled(fixedRate = ONE_HOUR_IN_MS)
  public void clearStudentsCache() {
    Cache cache = cacheManager.getCache(STUDENTS_CACHE_NAME);
    if (cache != null) {
      cache.clear();
      log.info("Cache '{}' successfully cleared", STUDENTS_CACHE_NAME);
    }
  }
}

7. Summary

In this blog post, we looked at how to use and configure caching in Spring Boot. We looked at the following:

  • What are caches and what are they good for?

  • How does caching work in Spring?

  • Using Spring Cache Annotations

    • @EnableCaching

    • @Cacheable

    • @CachePut

    • @CacheConfig

    • @CacheEvict

  • Custom cache keys

If you like this post, you might also like my post about using Ehcache as a cache provider.