As a Software Engineer at Cognizant Softvision, I was working on a project with a data ingestion process. During the project, the need for cache came into play in order to solve the ingestion duration. Since using real-life examples is an excellent way to learn new skills and tools, I’d like to walk you through the basics of the Spring cache using Hazelcast as cache provider based on my experience.
What is caching?
Caching is a technique wherein objects of application are kept in a temporary storage area known as a cache. The cache acts as an intermediate low-latency data source.
Cached objects/key-value stores could be anything. Here are just a few examples:
- Results of expensive and time-consuming operations
- Static web pages
- The contents of your backend database
Why do you need the cache?
Caching improves application performance through shortening response time (API latency), producing higher throughput (IOPS), and decreasing resource usage for the application. Also, it can take up additional traffic and reduce database traffic, therefore reducing database costs.
Common situations in which to use cache:
- Concurrent requests
- Heavy read/write operations
- Slow access to data operations
Case study
Tech Stack: Java 11, Spring Boot, Hazelcast as Cache provider with support for Spring Boot framework; MySQL as database.
Context: Heavy read/write operations to the MySQL database data with rest calls to fetch data for data ingestion (http requests: GET/PUT/POST and/or jdbc operations: Read, Update, Write).
Cache Patterns: Read Through/WriteThrough
Architectural Hazelcast Pattern: Embedded Distributed Cache
High availability: Ensured via a load balancer
If data is not in cache then the long-lasting jdbc call is made and after cache is updated. Therefore, on the next request data will be fetched from cache. If data is present in cache (cache hit), then data is accessed from cache. In this way, the long lasting call to the db is shortened from the performance standpoint.
Setting up Cache
- Spring Cache Abstraction support is provided by: hazelcast-all package:
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast-all</artifactId>
<version>${hazelcast-all.version}</version>
</dependency>
- The configuration consists of the following beans: CacheManager, HazelcastInstance and a Config.
@Configuration
@EnableCaching
public class CacheConfiguration {
@Bean
public Config hazelCastConfig() {
return new Config();
}
@Bean
public HazelcastInstance hazelcastInstance(Config hazelCastConfig) {
return Hazelcast.newHazelcastInstance(hazelCastConfig);
}
@Bean
public CacheManager cacheManager(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance) {
return new HazelcastCacheManager(hazelcastInstance);
}
}
Note the @EnableCaching annotation on the classenables spring cache. CacheManager interface helps deal with cache objects and executes operations like cache creation, destruction and introspection.
- The hazelcast.yml config file enables the hazelcast cache cluster to multicast.
- Cache content is initialized at app start with some books that are also pre-loaded in the database table named book.
public Cache initBookCacheMapByIsbn(){
log.info("Initialising {}",CacheConstants.BOOKS_CACHE_ISBN);
List<Book> books=bookService.getAll();
books.stream().forEach(
b->cacheManager.getCache(CacheConstants.BOOKS_CACHE_ISBN).put(b.getIsbn(),b)
);
log.info("Init cache {} done",CacheConstants.BOOKS_CACHE_ISBN);
return getCacheMap(CacheConstants.BOOKS_CACHE_ISBN);
}
All table entries are loaded in a cache named CacheConstants.BOOKS_CACHE_ISBN. The key for cache is isbn and the value the Book object. This is a key value store, identical to a Map model.
Application Code
Now let’s see what the spring boot application looks like with a book management solution:
@Table(name = "book")
public class Book implements Serializable {
@Id
@Column
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String author;
@Column
private String name;
@Column
private String isbn;
}
id is a generated value when persisted in the database table.
isbn is the International Standard Book Number and it’s unique for each book.
Before we dive deeper into the cache for book application, there are a couple of things we need keep in mind:
Let’s get back to the application at the controller layer. There are two methods of interest. One fetches a book by ISBN with a cache mechanism – getBookByIsbnNonCached() and one without it – getBookByIsbn().
While on the service layer:
@Service
@Log4j2
@CacheConfig(cacheNames = {CacheConstants.BOOKS_CACHE_ISBN, ...})
public class BookService {
@Autowired
private BookRepository bookRepository;
...
@Cacheable(value = CacheConstants.BOOKS_CACHE_ISBN, key = "#isbn", unless = "#result == null")
public Book getByIsbn(String isbn) {
log.info("Retrieving book with isbn: {}", isbn);
return bookRepository.findByIsbn(isbn).orElseThrow(() -> new RuntimeException("Book with isbn: " + isbn +
"was not found"));
}
public Book getByIsbnNonCached(String isbn) {
log.info("Retrieving book with isbn: {}", isbn);
return bookRepository.findByIsbn(isbn).orElseThrow(() -> new RuntimeException("Book with isbn: " + isbn +
"was not found"));
}
...
}
Note the class annotation: @CacheConfig(cacheNames = {CacheConstants.BOOKS_CACHE_ISBN, …}) which points to the same cache name as in the cache initialization.
The method annotated with @Cacheable(value = CacheConstants.BOOKS_CACHE_ISBN, key = “#isbn”, unless = “#result == null”) specifies the same cache to use for the method: Book getByIsbn(String isbn).
The method annotated with @Cacheable will be:
- Executed if a cached value is not found in the cache map
- Not executed if the cache value is found in the cache map
Important notes:
- isbn is specified as the key for cache
- Also, it is mentioned that in case of null Book object on return of getByIsbn() not to cache it.
The demo
This demo prepares, with docker, the two instances of the book management application, one load balancer using nginx and a mysql db with the book table pre-populated with initial data, just as presented in the tech stack.
Getting a book by using cache
Fetch a book using cache:
GET http://localhost:80/book/isbn/9781949460865
Content-Type: application/json
Cached Response:
{
"id": 2,
"author": "Jules Verne",
"name": "The Mysterious Island",
"isbn": "9781949460865"
}
Response code: 200; Time: 11ms; Content length: 85 bytes
Non cached response:
{
"id": 2,
"author": "Jules Verne",
"name": "The Mysterious Island",
"isbn": "9781949460865"
}
Response code: 200; Time: 20ms; Content length: 85 bytes
Note the improved response for cached with 9ms vs non cached. This performance improvement scales up even if the requests are concurrent for multiple books resources.
Cache for a new book resource
Add a new book using the below code at the service layer:
@CachePut(value = CacheConstants.BOOKS_CACHE_ISBN, key = "#book.isbn", unless = "#result == null")
public Book create(Book book){
log.info("Saving a new book with isbn: {}",book.getIsbn());
return bookRepository.save(book);
}
The method Book create(Book book) annotated with @CachePut uses the same cache as in the initialization and will always be executed and the result cached, unless the return is different from null. Also, the new object is persisted in the book table.
Create operation will take longer due to interaction with database and cache, but in our particular scenario is a good trade off since the need is to have the objects ready in cache.
Let’s see it in action with the following request:
POST http://localhost:80/book/isbn
Content-Type: application/json
{
"author": "Isaac Asimov",
"isbn": "0553293354",
"name": "Foundation"
}
As response:
{
"id": 12,
"author": "Isaac Asimov",
"name": "Foundation",
"isbn": "0553293354"
}
In order to verify the content of the cache there is a dedicated custom-made endpoint to query whole cache content of: books-cache-isbn (a.k.a. CacheConstants.BOOKS_CACHE_ISBN) by showing the content of the CacheManager object:
GET http://localhost:80/cache/map/books-cache-isbn
Content-Type: application/json
As response:
{
"readTimeout": 0,
"nativeCache": {
...
"0553293354": {
"id": 12,
"author": "Isaac Asimov",
"name": "Foundation",
"isbn": "0553293354"
},
...
}, "name": "books-cache-isbn"
}
Cache when updating a book resource:
Updating a new book using the below code at the service layer:
@CachePut(value = CacheConstants.BOOKS_CACHE_ISBN, key = "#book.isbn", unless = "#result == null")
public Book updateByIsbn(String isbn, Book book) {
. . .
Optional<Book> optionalBook = bookRepository.findByIsbn(book.getIsbn());
if (optionalBook.isPresent()) {
Book existingBook = optionalBook.get();
existingBook.setIsbn(book.getIsbn());
existingBook.setAuthor(book.getAuthor());
existingBook.setName(book.getName());
return bookRepository.save(existingBook);
}
. . .
}
The method Book updateByIsbn(Book book) annotated with @CachePut will always be executed and the result cached, unless the return is different from null.
Similar to create, the update operation will take longer due to interaction with database and cache, but in our particular scenario is a good trade off since the need is to have the objects ready in cache.
Let’s see the update in action with the following request:
PUT http://localhost:80/book/isbn/0553293354
Content-Type: application/json
{
"author": "Isaac Asimov",
"isbn": "0553293354",
"name": "The Foundation"
}
And as response:
{
"id": 12,
"author": "Isaac Asimov",
"name": "The Foundation",
"isbn": "0553293354"
}
Delete a book:
The code responsible for delete:
@CacheEvict(value = CacheConstants.BOOKS_CACHE_ISBN, key = "#isbn")
public void deleteByIsbn(String isbn) {
log.info("Deleting book with isbn: {}", isbn);
Optional<Book> book = bookRepository.findByIsbn(isbn);
if (book.isPresent()) {
bookRepository.delete(book.get());
} else {
log.error("Book with isbn {} not found!", isbn);
}
}
Note the annotation @CacheEvict – the method annotated will indicate the removal of one or more/all values in cache.
In action:
DELETE http://localhost:80/book/isbn/0553293354
Content-Type: application/json
Conclusion
Spring Cache is something that can improve performance for your application. Hazelcast is just one of the providers and choosing one is part of an analysis.
Introducing cache into a particular situation is something to be analyzed thoroughly and it should not be a substitute for masking underlying database/persistence or other performance issues.
References
Hazelcast official docs:
Multi-Site Caching with Spring
Hazelcast IMDG Reference Manual
Hazelcast: Getting Member Statistics
Hazelcast: Using the REST Endpoint Groups
Cache Patterns:
Where Is My Cache? Architectural Patterns for Caching Microservices
Spring Boot Tomcat Session Replication on Kubernetes Using Hazelcast