Using ConcurrentHash and Redis to cache db results in spring boot
Introduction
- Caching is a technique that allows to storage of frequently queried data that doesn’t change that frequently and reduces latency for the request.
- We can use this technique at different tiers of software applications.
- In this article, we will use caching for storing database query results.
Use Case
- We have realized (after inspecting and visualizing tracing that there is a query made to get the book_reviews from the books table whenever `/book_reviews/:isbn` API is invoked.
- Our app is getting a lot of traffic on book_reivews API and making calls to get reviews for every book is not efficient since book_reviews doesn’t change so frequently.
- So we want to cache the book reviews locally at the application level so that we reduce our database roundtrip and reduce the read queries on the database so that it can provide better latency for other critical queries.
Setup
Entity
- We will use the BookReview entity.
@Entity
@Table(name="BOOK_REVIEWS")
public class BookReview {
@Id
@GeneratedValue(strategy= GenerationType.SEQUENCE, generator = "book_reviews_reviews_id_seq")
@SequenceGenerator(name = "book_reviews_reviews_id_seq", sequenceName = "book_reviews_reviews_id_seq", allocationSize = 1)
private Long reviewsId;
private String userId;
private String isbn;
private String bookRating;
}
- Make sure to add some sample data to the book_reviews table.
Controller
- The controller is a simple rest endpoint that calls the logic class.
@RestController
@RequestMapping("/books")
public class BookReviewsController {
@Autowired
private BookReviewsWithSimpleCacheLogic bookReviewsLogic;
@GetMapping("/reviews")
@ResponseBody()
public List<BookReview> getBookReviews(@RequestParam("isbn") String isbn){
return bookReviewsLogic.getAllReviewsByIsbn(isbn);
}
}
Logic
- The logic class uses a repository to get the book_reviews by isbn.
@Service
public class BookReviewsWithSimpleCacheLogic {
@Autowired
private BookRepository bookRepository;
public List<BookReview> getAllReviewsByIsbn(String isbn){
return bookRepository.findByIsbn(isbn);
}
}
Caching
- Spring provides multiple storage options for its cache_abstraction and we can choose among them. If we don’t define one then it chooses default which is a simple concurrent hashmap.
Abstractions
- Spring provides several different annotations such as @Cacheble, @CacheEvict and @CachePut to achieve caching.
- @Cacheble: use it to store the cached result of the method to define cache or return if it already exists.
- @CachePut: forcibly updates the cache when invoked
- @CacheEvict: evicts cache when a method with annotation invoked
Query
- To query book reviews, our repository is making the below query to the database.
select br1_0.reviews_id,br1_0.book_rating,br1_0.isbn,br1_0.user_id from book_reviews br1_0 where br1_0.isbn=?
Caching Query Result
- We are adding a cache using @Cacheble annotation, and defining the cache key and cache value. Here value defines the name of the cache and the key is used to query the cache for a specific key.
@Cacheable(value = "book_reviews", key = "#isbn")
public List<BookReview> getAllReviewsByIsbn(String isbn){
System.out.println("cache doesn't exist, querying the db");
return bookRepository.findByIsbn(isbn);
}
- When we hit the book_reviews endpoint, due to @Cacheable annotation, it will first check if the cache already exists, if not then it computes the method and assigns the result to the cache.
Verification
- If we hit the endpoint for the first time, we see the below query getting executed to the database.
Executing SQL Query: select br1_0.reviews_id,br1_0.book_rating,br1_0.isbn,br1_0.user_id from book_reviews br1_0 where br1_0.isbn=?
- But on subsequent requests, we will not see any log since the result will be returned from the cache.
- If you want to log SQL query in JPA, you can do so either setting application.properties with :
#spring.jpa.show-sql=true
- You can also add sql interceptor and log the SQL query inside the app code.
Evicting Cache
- We cannot keep our cache forever, we need to evict it based on expiry time. For example, we can keep the cache for 5minute and expire it after that.
- After 5 minutes cache expires and in the next call it gets reassigned to the value return from the db query.
- Spring cache provides @CacheEvict annotation to evict cache. We can define any specific key, which we would like to evict from the cache.
@CacheEvict(value = "book_reviews", key = "#isbn")
public void evictCachesWithKey(String isbn){
System.out.println("evicting cache");
}
Verification
- We need to invoke the evictCachesWithKey() method to remove the cache.
- For demo purposes, we are going to invoke this using an endpoint and verify if the method works as expected.
@GetMapping("/evict")
@ResponseBody()
public void evictCaches(@RequestParam("isbn") String isbn){
bookReviewsLogic.evictCachesWithKey(isbn);
}
- If we hit the endpoint we will see the log for the same and on the next request, we will see there was a query made to the DB for book reviews and it was not returned from the cache.
Cache Eviction
- Making a postman request for evicting the cache.
Get Book Reviews
- Making Postman requests for getting the book reviews.
Console Log
- As we can see in the log we are evicting the cache, and on the next request, we are indeed making a call to the database.
Evicting Cache Automatically
- So far to evict the cache we have been calling evict api manually. but the ideal behavior would be to call it automatically based on the defined Time to Live (TTL).
- We need to make some changes to our logic, firstly we need to add a hashmap that stores the cache key and the time it was set. This will help determine if the cache has already surpassed the defined TTL.
private final Map<String, LocalDateTime> cacheKeyWithStartTime = new HashMap<>();
@Cacheable(value = "book_reviews", key = "#isbn")
public List<BookReview> getAllReviewsByIsbn(String isbn){
System.out.println("cache doesn't exist, querying the db");
cacheKeyWithStartTime.put("book_reviews:"+isbn, LocalDateTime.now());
return bookRepository.findByIsbn(isbn);
}
- Second, we need to add a scheduler to run the eviction method periodically so that it will delete all the due caches that surpass the defined TTL.
- We have also modified the cache eviction logic, we are no longer using the @CacheEvict annotation. instead, we are injecting cacheManager which will give us the cache by key and we will confirm if TTL has been surpassed by referring to the hashmap since it stores when it was set.
@Autowired
private CacheManager cacheManager;
private final Duration TTL = Duration.ofMinutes(1);
@Scheduled(cron="0 * * * * *")
public void scheduleCacheEviction(){
System.out.println("Evicting Cache");
evictCaches();
}
public void evictCaches(){
cacheKeyWithStartTime.forEach((key, value) -> {
String[] cacheKey = key.split(":");
Cache cache = cacheManager.getCache(cacheKey[0]);
long actualDuration = Duration.between(value, LocalDateTime.now()).toMinutes();
if(actualDuration >= TTL.toMinutes()){
boolean isEvicted = cache.evictIfPresent(cacheKey[1]);
System.out.println(cacheKey[1]+" cache evicted? " + isEvicted);
}
});
}
}
Verification
- we make the first request to book reviews that store the DB query result in the cache.
- Our scheduler periodically kicks in and removes the cache.
- Now if we hit the book_reviews endpoint again we will see our app making the call to the DB.
Console Log
Using Redis for Cache
- Although the approach we discussed previously does the job it is not as sophisticated a solution as provided by caching services like Redis.
- We can use Redis for caching with Spring Boot.
Redis Instance
- I have a Mac instance and on Mac, we can install and start the service Redis using brew.
->> ~ $ brew install redis
Running `brew update --auto-update`...
->> ~ $ brew services start redis
==> Successfully started `redis` (label: homebrew.mxcl.redis)
Dependency
- We need to add a redis-data dependency in our pom.xml.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Cache config
- Cache config will do the setup for our Redis cache manager. Here we can define configuration and TTL for our caches. We are defining TTL for 1 minute.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("book_reviews", defaultCacheConfig()
.entryTtl(Duration.ofMinutes(1)));
RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultCacheConfig())
.withInitialCacheConfigurations(cacheConfigurations);
return builder.build();
}
private RedisCacheConfiguration defaultCacheConfig() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
}
Cache Logic
- Now for our cache logic, we only need to define @Cacheble and add cache key and value. We need to make sure the value i.e. book_reviews should be added to the Redis cache manager.
@Service
public class BookReviewsWithRedisLogic {
@Autowired
private BookRepository bookRepository;
@Cacheable(value="book_reviews", key="#isbn")
public List<BookReview> getAllReviewsByIsbn(String isbn){
return bookRepository.findByIsbn(isbn);
}
}
- We don’t need to define cache eviction since it is already handled inherently by Redis.
Verification
- Let’s make the first request to initialize the cache from the query result.
- If any subsequent request until TTL, we get cache results instead of querying the database.
- Once TTL expires, the next request hits the database and assigns the result to the cache.
Console Log
- As we can see, the first log was printed when we made the first request. There was no log until we hit TTL, and after that first request, we resulted in querying the DB again.
Conclusion
In this article, we learn how to do caching in spring boot with simple concurrent hashmap storage. We added TTL expiry for caches automatically. We also set up Redis cache manager to use it for caching.