7 min read

Speed up your Spring application with Cache 💾 💪

Speed up your Spring  application with Cache 💾 💪
"If everything seems under control, you're not going fast enough." - Mario Andrettis.

What will you learn

In this short blog post, you will learn how you use Spring built-in cache mechanism, how to upgrade the default cache to more advanced cache called Ehcache which will let you use it consistently with multiple threads.  

The story

Lets say your app is doing a some repetitive heavy tasks. each task in turn can take a several seconds to compute. Your customers are getting angry, it takes to much time to load components on your your app!
Thats sad, you wont be able to keep a steady client base like that..
Luckily, a cache can save the day!

What is cache

Cache is hardware or software that is used to store something, usually data, temporarily in a computing environment. this article will engage in software type cache.

How does a cache work?

When a cache client attempts to access data, it first checks the cache. If the data is found there, that is referred to as a cache hit. If not, thats called a cache miss. when missed the data is being pulled from main memory and copied into the cache.

Getting started

As of any other Spring application first we should set up our maven pom.xml.
to get started with Spring's built in cache we wont need much. We will update the dependencies later on on the Ehcache section. Add these dependencies to your pom.xml file:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

spring-boot-starter-test will help us test our app and spring-boot-starter-aop will let us time our function more easily. You can find more about AOP method timing here.

Now, we can care create our example classes to use in our application.
First we will create a class named HardTask.java, this class will represent some work we do in our application (for example, reaching out to another service to get user tokens for authentication). Its main method startTask() will take 1 second to return its answer.

public class HardTask implements Serializable {

	private int id;
	private String name;
	private String answer;
	private LocalDateTime startTime;
	private LocalDateTime deadline;

	public HardTask(int id, String name, LocalDateTime startTime, LocalDateTime deadline, String answer) {
		this.id = id;
		this.name = name;
		this.startTime = startTime;
		this.deadline = deadline;
		this.answer = answer;
	}

	public String getName() {
		return name;
	}

	public String getAnswer() {
		return answer;
	}
    
    public String startTask() throws InterruptedException {
		Thread.sleep(1000);
		return this.answer;
	}

}

In order to use the cache mechanism on classes, we must override two methods, equlas() and hashCode(). these methods will let the cache mechanism understand which object are actually the same. If you are using IntelliJ simply press control/cmd + N and select equals() and hashCode from the popup menu.

	@Override public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		HardTask task = (HardTask) o;
		return id == task.id && name.equals(task.name) && startTime.equals(task.startTime) && deadline.equals(task.deadline);
	}

	@Override public int hashCode() {
		return Objects.hash(id, name, startTime, deadline);
	}

Next we would like to create a service to accept our HardTasks. Create a Spring @Service named TaskSerice.java.
Notice,
1. @TrackTime annotation - this annotation was declared on the AOP part that was mentioned above. It is not necessary in this example but it will help us time our methods to better understand how the cache mechanism works.
2. @Cacheable("start_a_task") annotation - this is the annotation to let spring know this method should use the cache. In this example we will use a simple cache configuration, you can find out about more advanced configurations in Spring docs here. adding "start_a_task" to the @Cacheble annotation will create an entry in cache to look for every time this method is executed.

@Service
public class TaskService {

	private final Logger logger = LoggerFactory.getLogger(this.getClass());

	@TrackTime
	@Cacheable("start_a_task")
	public String startTask(HardTask task) throws InterruptedException {
		logger.info(String.format("Starting Task: %s.", task.getName()));
		// simulates some work on the task
		String answer = task.startTask();
		return String.format("task %s = %s", task.getName(), answer);
	}

}

We are now ready to create our Application.java class and run our program.
It is important to add @EnableCaching annotation or else cache will not be used throughout the application.

@EnableCaching
@SpringBootApplication
public class UsingCasheWithSpringApplication implements CommandLineRunner {

	private final Logger logger = LoggerFactory.getLogger(this.getClass());


	public static void main(String[] args) {
		SpringApplication.run(UsingCasheWithSpringApplication.class, args);
        }

	@Override
	public void run(String... args) throws Exception {}

}

Lets test our application properly with @Test functions.
At the test package on the main class, we will add a test that creates a HardTask and tests the cache mechanism.

@SpringBootTest
@EnableCaching
class UsingCacheWithSpringApplicationTests {

	@Autowired TaskService taskService;
	private final Logger logger = LoggerFactory.getLogger(this.getClass());


	@Test
	void testSameTasks() throws InterruptedException {
		HardTask task = new HardTask(1,"A", LocalDateTime.now(), LocalDateTime.now().plus(1, ChronoUnit.HOURS), "1");
		logger.info(taskService.startTask(task));
		logger.info(taskService.startTask(task));

	}
}

Running the test:

2022-08-26 15:39:14.385  : Starting Task: A.
2022-08-26 15:39:15.388  : Time Taken by execution(HardTask) is 1008ms
2022-08-26 15:39:15.393  : task A = 1
2022-08-26 15:39:15.393  : task A = 1

The cache worked! you can see that when first starting the task a second passed until the answer returned but on the second attempt the answer returned immediately.

Using threads

We all know how amazing threads are, so lets spice up our application with some shiny threads. Simply we will create a 2 HardTasks add 2 copies of each of each task to a list and execute them concretely with threads.  

	@Override
	public void run(String... args) throws Exception {
		HardTask task = new HardTask(1, "A", LocalDateTime.now(), LocalDateTime.now().plus(1, ChronoUnit.HOURS), "1");
		HardTask task2 = new HardTask(2, "B", LocalDateTime.now(), LocalDateTime.now().plus(1, ChronoUnit.HOURS), "2");

		List<HardTask> tasks = List.of(task, task, task2, task2);
		tasks.forEach(t -> {
			new Thread(() -> {
				try {
					logger.info(taskService.startTask(t));
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}).start();
		});
	}

Run the application.

-08-24 22:03:14.447  [Thread-1] : Starting Task: A.
2022-08-24 22:03:14.447  [Thread-3] : Starting Task: B.
2022-08-24 22:03:14.447  [Thread-4] : Starting Task: B.
2022-08-24 22:03:14.447  [Thread-2] : Starting Task: A.
2022-08-24 22:03:15.455  [Thread-1] : Time Taken by executionHardTask) is 1016ms
2022-08-24 22:03:15.455  [Thread-2] : Time Taken by execution(HardTask) is 1016ms
2022-08-24 22:03:15.455  [Thread-4] : Time Taken by execution(HardTask) is 1016ms
2022-08-24 22:03:15.455  [Thread-3] : Time Taken by execution(HardTask) is 1016ms
2022-08-24 22:03:15.461  [Thread-4] : task B = 2
2022-08-24 22:03:15.461  [Thread-3] : task B = 2
2022-08-24 22:03:15.461  [Thread-2] : task A = 1
2022-08-24 22:03:15.461  [Thread-1] : task A = 1

Oh no, that didnt work as expected.. Each thread simultaneously entered the function and cache was not used. Why is that?
Spring default cache is not fully consistent, that means that if the an entry was not saved for a certain method and input, another thread can invoke the method look at the cache, encounter a cache miss and therefore enter the execute the method again. This behavior called 'Eventual consistency'  and you can read more about it here. Using sync=true option will result in the same outcome since  'Eventual consistency'  is promised with synchronization as well. 'Eventual consistency' is enough in many situations, but we would like achieve full consistency.

Achieve consistency  

In order to achieve consistency we have to replace the default cache Spring provides us with a more advanced one. There are a lot of options to choose from, and in that case I chose Ehcache.

Configure Ehcache:

Ehcache configuration is pretty straight forward. All we have to do is to add our resources package a file named ehcache.xml, include Ehcache maven dependency and a Ehcache bean configuration class.

As usual, first add maven dependency

        <dependency>
            <groupId>org.ehcache</groupId>
            <artifactId>ehcache</artifactId>
            <version>3.10.0</version>
        </dependency>

Next, create ehcache.xml in your resources package.
On this xml file we will include our cache configuration.

<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="ehcache.xsd"
         updateCheck="true"
         monitoring="autodetect"
         dynamicConfig="true">
    <cache name="applicationCache"
           maxEntriesLocalHeap="10000"
           maxEntriesLocalDisk="1000"
           eternal="false"
           diskSpoolBufferSizeMB="50"
           timeToIdleSeconds="45" timeToLiveSeconds="90"
           memoryStoreEvictionPolicy="LFU"
           transactionalMode="off"
           statistics="true">
    </cache>
</ehcache>

The most important property here, is the cache name. I chose to call the cache in this example 'applicaitonCache'. Practically, you create multiple caches to serve different function in your application, each one can have a different configuration.

EhCacheConfig.java

@Configuration
public class EhCacheConfig {

	@Bean
	public CacheManager cacheManager() {
		return new EhCacheCacheManager(cacheMangerFactory().getObject());
	}

	@Bean
	public EhCacheManagerFactoryBean cacheMangerFactory() {
		EhCacheManagerFactoryBean bean = new EhCacheManagerFactoryBean();
		bean.setConfigLocation(new ClassPathResource("ehcache.xml"));
		bean.setShared(true);
		return bean;
	}

}

This class defines 2 beans, crucial to define if not using the default cache provided by spring.

In addition, we need to tell spring to use the cache we just configured on ehcache.xml and make synchronization possible.
On TaskService lets edit the @Cacheable annotation

@Cacheable(cacheNames = "applicationCache", sync = true)

Lets run our application again and see the results.  

2022-08-26 16:24:39.430 [Thread-3] : Starting Task: A.
2022-08-26 16:24:39.430 [Thread-6] : Starting Task: B.
2022-08-26 16:24:40.432 [Thread-6] : Time Taken by execution(HardTask) is 1006ms
2022-08-26 16:24:40.432 [Thread-3] : Time Taken by execution(HardTask) is 1006ms
2022-08-26 16:24:40.436 [Thread-3] : task A = 1
2022-08-26 16:24:40.436 [Thread-4] : task A = 1
2022-08-26 16:24:40.436 [Thread-6] : task B = 2
2022-08-26 16:24:40.436 [Thread-5] : task B = 2

You can now see that the execution of TaskA and TaskB happened only once and all the other answers was retrieved from the cache. This was possible thanks to Ehcache locking mechanism.


All the code from this post can be found in this Github repo.