Introduction
In modern applications it is crucial to reduce response times and optimize resources as much as possible. One of the techniques to improve application performance is caching. Caching is a hidden mechanism in which the same data can be read multiple times in a faster way. Caching also helps scalability of applications.
Spring cache is a module of the Spring framework providing support for transparently adding caching to an existing Spring application. It works by applying caching to Java methods. That means it reduces the number of executions based on the information in the cache. Whenever a cache method is invoked, Spring checks if the method was already called with the same parameters. If it was, the result from the cache is returned. Otherwise, the method is executed and the result is stored in the cache. The next time the method is called, the cached result is returned.
Be aware that is approach only works if the method ensures the same output for a given input.
Cache Providers
Spring cache is an abstraction and not an implementation. This means you can write code compatible with multiple implementations but requires the actual storage for the cache.
The main interfaces of the abstraction are org.springframework.cache.Cache and org.springframework.cache.CacheManager. Cache interface defines common cache operations like get, put, evict and clear. It is a set of key-value pairs. CacheManager allows to retreive cache regions (an instance of a Cache). it is a wrapper to obtain caches.
Spring supports the following providers:
- JCache (JSR-107) (EhCache 3, Hazelcast, Infinispan, and others)
- Hazelcast
- Infinispan
- Couchbase
- Redis
- Caffeine
- Cache2k
- Simple
If no caching library is present in the app, then the default is the simple provider. It is configured an implementation backed by a ConcurrentHashMap as the cache store.
To setup caching in an app, two actions are needed:
- Configure the cache where data is stored.
- Identify the methods where caching is applied.
So let's implement caching to our Customer REST Api. We will start with the simple cache in this article and then switch to other providers in future releases.
Dependencies
The first step is to add the cache dependency to the pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Under the hood, this starter contains the spring-context-support dependency with the spring beans, context and core artifacts.
Configuring Cache
Cache annotations are not activated by default. They must be declatively enabled so that the annotation are automatically trigger. So do so we use the @EnableCaching in any of the configuration classes of the application.
package dev.noelopez.restdemo1.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
}
There are some important notes to take in account with the default settings:
- The default advice mode for processing caching annotations is proxy. This means local calls to the method (in the same class) are not intercepted in this mode.
- When using proxy mode, annotations should only be applied to public methods.
- it is recommended to only annotate concrete classes with cache annotations.
Now we are ready to mark methods with cache annotations. Let's put this into practice in the next section.
Adding entries to the cache
@Cacheable is used to designate a method as cacheable. The result is stored in the cache so that the next invocations with the same parameters are returned from the cache region avoiding to execute the method.
@Service
public class CustomerService {
...
@Cacheable(cacheNames="customers", key="#id")
public Optional<Customer> findById(Long id) {
return customerRepo.findById(id);
}
...
}
In the above snippet the method findById is marked as cacheable in the customers region. The key for cache access is the customer id. keep in mind that a Cache is basically a key-value pair store. Hence, we need a way to get the value from it. In the section section it will be explain how keys are generated in more detail.
Now let's called the method passing the argument 2. As demostrated in the below lines the methof in the service layer is triggered.
2023-09-10T19:27:21.130-03:00 INFO 15748 --- [nio-8080-exec-1]
d.n.r.aspect.LoggingExecutionTimeAspect : Method [Optional
dev.noelopez.restdemo1.service.CustomerService.findById(Long)]
with params [2] executed in 13 ms
2023-09-10T19:27:21.143-03:00 INFO 15748 --- [nio-8080-exec-1]
d.n.r.aspect.LoggingExecutionTimeAspect : Method [ResponseEntity
dev.noelopez.restdemo1.controller.CustomerController.findCustomers
(Long)] with params [2] executed in 34 ms
However, on the second invocation the method is not run and the value returned from the cache. As you can see only the Controller method is invoked but not the service method.
2023-09-10T19:29:18.499-03:00 INFO 15748 --- [nio-8080-exec-3]
d.n.r.aspect.LoggingExecutionTimeAspect : Method [ResponseEntity
dev.noelopez.restdemo1.controller.CustomerController.findCustomers
(Long)] with params [2] executed in 1 ms
Both calls to the method return the same data to the client consuming the customer endpoint.
Key Generation
Spring uses a simple key generator that uses the method parameters provided to create a key. If a method has no parameters then an empty simple key is returned. This strategy will be ok for most use cases provided that the params implement valid equals and hascode methods.
There are situations where we want to specify how the key is generated. For instance if one of the parameters is irrelevant for the caching as it is only needed for method logic.
@Cacheable(cacheNames="customersSearch",
key = "{#customer.name, #customer.status,
#customer.email, #customer.details.vip}",
public List<Customer> findByFields(Customer customer) {
return customerRepo.findByFields(customer);
}
In the above code, findByFields method filters customers by name, status, email and vip. Hence, the key can be created only using them.
The method stores the results in a new cache region which must be declared in the cache configuration
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager(
"customers","customersSearch");
}
}
Conditional Caching
There are situations where we do not want a method to add an entry to the cache for every invocation. For instance, consider the following scenarios:
- Entries in the cache must be only for a range of values of an argument.
- The return value must meet a specific requirement.
The first case is supported by the condition parameter. It evaluates to true or false a SPEL expresion. If it resolves to true, the method call is cached. If it is false, it will not be cached. Either case, the method is always executed.
Here the findByFields will be cached if a user is searching customers whose name is less than 10 characters.
@Cacheable(cacheNames="customersSearch",
key = "{#customer.name, #customer.status,
#customer.email, #customer.details.vip}",
condition="#customer.name?.length() < 10")
public List<Customer> findByFields(Customer customer) {
return customerRepo.findByFields(customer);
}
As name can be null, the safe navigation operator (?) is used to avoid a NullPointerException.
The second case affects the returned value rather than the input params. Spring supports this option via the unless parameter. The expression is evaluated after the method is invoked. If the expression resolves to false, then the method is cached.
Let's see this in action with a couple of examples. Suppose we are interested in caching searches returning a list of 10 customers or more.
@Cacheable(cacheNames="customersSearch",
key = "{#customer.name, #customer.status,
#customer.email, #customer.details.vip}",
condition="#customer.name?.length() < 10",
unless ="#result.size() < 10"
)
The second case applies to the findById method introduced in previous sections. Say we only need to cached VIP customers. The next snippet shows how to implement this change in the method
@Cacheable(cacheNames="customers", key="#id",
unless = "not #result?.details.vip")
public Optional<Customer> findById(Long id) {
return customerRepo.findById(id);
}
Note that #result is the actual customer (the business entity) and not the java.lang.Optional wrapper.
Updating entries to the cache
@CachePut annotation is used when the cache has to be updated and the method is always executed. Once the method invocation is completed the result is put in the cache. If the entry was already added to the cache it is updated. It requires the cache region and the key same as @Cacheable. It can be configured with conditions too.
Our application allows to update customer data. In this case, the customer cache should be updated so that the latest changes are available in the cache when the user searches for a single customer.
@Transactional
@CachePut(cacheNames="customers", key="#customer.id")
public Customer save(Customer customer) {
return customerRepo.save(customer);
}
Removing entries from the cache
Spring cache also provides a way of removing entries from a cache. This is called cache eviction and it comes in handy for removing unused data or stale data. The annotation @CacheEvict marks methods for cache eviction so that entries can be removed. It needs one or more cache regions that will be impacted by the action.
The below code triggers cache eviction when deleting a customer from the database. Let's explain this with an example. Say customer 2 is updated hence put in the cache region customers. Then it is deleted and removed from cache, so that next time is loaded it will not be in the cache. This will force the findById method to be run again and customer 2 will not be found.
@Transactional
@CacheEvict(cacheNames="customers", key = "#id")
public void deleteById(Long id) {
customerRepo.deleteById(id);
}
Note that cache eviction takes place after the method is invoked. If a exception is raised and the delete operation fails the eviction is not trigger. No need to remove the customer from the cache if it was not possible to delete it.
If eviction must occur before the method is invoked, you must set the attribute beforeInvocation to true (default value is false).
Another feature of @CacheEvict is allEntries attribute which permits to clear out an entire region instead of an entry based on the key.
@Transactional
@CachePut(cacheNames="customers", key="#customer.id")
@CacheEvict(cacheNames="customersSearch", allEntries=true)
public Customer save(Customer customer) {
return customerRepo.save(customer);
}
Here the customersSearch region is totally cleaned up when a customer is updated.
Simple Cache Eviction Policy
The cache abstraction does not provide any cache eviction policy. That must be done directly through your cache provider. However, we can implement a simple cache eviction policy using a scheduler. The idea is to clean up the cache every hour so that data in it does not grow fast.
This functionality can be added to the CacheCofig class.
@Configuration
@EnableCaching
@EnableScheduling
public class CacheConfig {
private Logger logger =
LoggerFactory.getLogger(CacheConfig.class);
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("customers",
"customersSearch");
}
@Scheduled(fixedRate = Timer.ONE_HOUR)
@Caching(evict = {
@CacheEvict(cacheNames="customers", allEntries=true),
@CacheEvict(cacheNames="customersSearch", allEntries=true)
})
public void clearAllCaches() {
logger.info("Clearing all caches entries");
}
}
Let's review the above code. First thing is to enable scheduling so that Spring is aware of it. The annotation @EnableScheduling ensures that a background task executor is created. Without it, nothing gets scheduled. Then, the Scheduled annotation defines when a particular method runs.
In our code, method clearAllCaches is scheduled to be executed at one hour intervals.
Another alternative is to use the cache manager to perform the cleaning.
@Scheduled(fixedRate = Timer.ONE_HOUR)
public void clearAllCachesWithManager() {
CacheManager manager = cacheManager();
manager.getCacheNames().stream()
.forEach(name -> manager.getCache(name).clear());
logger.info("Clearing all caches entries");
}
@Caching Annotation
The annotations we have seen so far are not repeatable. Meaning they can only be used once per method. What happens if we need multiple configurations for the same @CacheEvict o @CachePut annotations? @Caching allows to set different configurations for a method. This is usedful when two or more cache regions behave differently for the same method.
Let's examine the next code
@Transactional
@Caching(evict = {
@CacheEvict(cacheNames="customers", key = "#id"),
@CacheEvict(cacheNames="customersSearch", allEntries=true)
})
public void deleteById(Long id) {
customerRepo.deleteById(id);
}
Two actions are triggered after the method call is finished. The entry associated to the id is removed from the customers region if it exists. The second @CacheEvict annotation is responsible for purging the whole customersSearch region.
Class level configuration
The cache operations we have implemented are defined at method level. All of them share the same cache region customers. It can be dull and monotonous to set it for each operation. Here is where annotation @CacheConfig helps.
We can replace the cacheNames="customers" attribute from the methods by @CacheConfig as shown below
@Service
@CacheConfig(cacheNames = "customers")
public class CustomerService {
...
@Cacheable()
public Optional<Customer> findById(Long id) {
return customerRepo.findById(id);
}
...
}
We just need the @Cacheable annotation declared in the method findById. The cache entry key is the id because it is the only parameter.
Listing all keys in the Cache region
Sometimes it is needed to inspect the data stored in the cache. Neither Cache interface nor CacheManager class offer a method to get all entries or keys. That means the information has to be pulled from the cache implementation our application is using.
The full qualified cache implementation class can be obtained from the manager
System.out.println(manager.getCache(name).getClass())
System.out.println(manager.getCache(name).getNativeCache().getClass());
The above line displays the cache manage and the internal cache storage.
class org.springframework.cache.concurrent.ConcurrentMapCache
class java.util.concurrent.ConcurrentHashMap
Knowing the storage implementation class, a cast operation is all needed to access the storage implementation. As we are dealing with a map, it is straightfordward to get the keys from it as demonstrated in the next code.
private void printCachesKeys(CacheManager manager) {
manager.getCacheNames().stream()
.forEach(name -> printCacheKeys(manager, name));
}
private void printCacheKeys(CacheManager manager, String name) {
ConcurrentHashMap cacheMap = (ConcurrentHashMap)
manager.getCache(name).getNativeCache();
logger.info(String.format("Cache Name %s contains %s entries "
,name, cacheMap.size()));
logger.info(String.format("keys : %s", cacheMap.keySet()));
}
The method can be called before cleaning the cache or rin any other scenario. It will print the cache name and its keys.
Cache Name customersSearch contains 2 entries
keys : [[null, null, null, true], [null, null, null, null]]
Cache Name customers contains 5 entries
keys : [1, 2, 3, 4, 12]
Conclusion
In this article, we have learned how Spring Cache works and the different operations available like adding, updating and removing entries from the cache. We looked at some of the features such as key generation and cache class configuration. Finally, we coded some useful examples to automatically clear caches and examine its size and keys.
As usual, project code can be found in the git repository here.
Hope you enjoyed reading the article and donβt forget to subscribe to be notified for future articles on more subjects in the Spring and Java ecosystem.
Top comments (3)
How to speedup any idea plz suggest @siy
As I've mentioned, just use the other framework. For example, Vert.x.
Speedup your app - don't use Spring :)