Spring Cache for All

Demo: Spring cache using Hazelcast as cache provider

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

A Hitchhiker’s Guide to Caching Patterns