We are excited to be a part of AWS re:Invent 2024. Visit us at booth #1844 in Las Vegas.More info
Blog

Caching with Spring Boot and Aerospike

headshot-Roi-Menashe
Roi Menashe
Software Engineer
May 22, 2021|8 min read

In this article you will see an example of a Spring Cache application backed by an Aerospike database.

Source code can be found here:aerospike-examples/simple-springboot-aerospike-cache - Simple example of using Spring Boot cache backed by Aerospike database

Introduction

Spring Cache with Aerospike database allows you to use annotations such as @Cacheable, @CachePut and @CacheEvict that provides a fully managed cache store using an Aerospike database to store the cached data.

In this example we are going to use these annotations on a UserRepository class methods to create/read/update and delete user’s data from the cache.

We will see that if a user is stored in the cache — calling a method with @Cacheable annotation will fetch the user from the cache instead of executing the method’s body that responsible for the actual user fetch from the database, if the user doesn’t exist in the cache it will fetch the user’s data from the database and put it in the cache for later usages (a “cache miss”).

With Spring Cache and Aerospike database we can achieve that with only few lines of code.

Motivation

motivation-4b5debd714ea7bfa25df88d521682b40

Let’s say that we are using another database as our main data store, for example, Microsoft SQL Server. we don’t want to fetch the results from SQL Server every time we request the data, instead we want to get the data from a cache layer.

There are number of benefits for using a cache layer, here are some of them:

  1. Performance— Aerospike can work purely in RAM but reading a record from Aerospike in Hybrid Memory (primary index in memory, data stored on Flash drives) is extremely fast as well (~1ms).

  2. Reduce database load— Moving a significant part of the read load from the main database to Aerospike can help balance the resources on heavy loads.

  3. Scalable — Aerospike scales horizontally by adding more nodes to the cluster, scaling a relational database might be tricky and expensive, so if you are facing a read heavy load you can easily scale up the cache layer.

Project

Setup

We will use docker for our Aerospike database and Spring Boot Initializr to setup our project, if you don’t already have an environment ready — check out steps 1 and 2.1 of the following article on how to setup what you need:

https://medium.com/aerospike-developer-blog/simple-web-application-using-java-spring-boot-aerospike-database-and-docker-ad13795e0089

Dependencies

We need to add spring-data-aerospike dependency.

Add the following dependency to the pom.xml file:

<dependency>
    <groupId>com.aerospike</groupId>
    <artifactId>spring-data-aerospike</artifactId>
    <version>3.0.0</version>
</dependency>
  • This article is relevant for spring-data-aerospike version 3.0.0/2.5.0 and above.

  • Make sure to load maven changes after adding the dependency

Code

We will not use an actual database as our main data store (SQL Server) for this demo, instead we will simulate a database access by printing a simulation message and replace a database read by just returning a specific User.

Configuration

AerospikeConfigurationProperties

package com.aerospike.cache.simplespringbootaerospikecache.configuration;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aerospike")
public class AerospikeConfigurationProperties {
    private String host;
    private int port;
}

AerospikeConfiguration

package com.aerospike.cache.simplespringbootaerospikecache.configuration;
import com.aerospike.client.AerospikeClient;
import com.aerospike.client.policy.ClientPolicy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.aerospike.cache.AerospikeCacheConfiguration;
import org.springframework.data.aerospike.cache.AerospikeCacheManager;
import org.springframework.data.aerospike.convert.AerospikeCustomConversions;
import org.springframework.data.aerospike.convert.AerospikeTypeAliasAccessor;
import org.springframework.data.aerospike.convert.MappingAerospikeConverter;
import org.springframework.data.aerospike.mapping.AerospikeMappingContext;
import org.springframework.data.mapping.model.SimpleTypeHolder;
@Configuration
@EnableConfigurationProperties(AerospikeConfigurationProperties.class)
@Import(value = {MappingAerospikeConverter.class, AerospikeMappingContext.class, AerospikeTypeAliasAccessor.class
        ,AerospikeCustomConversions.class, SimpleTypeHolder.class})
public class AerospikeConfiguration {
    @Autowired
    private MappingAerospikeConverter mappingAerospikeConverter;
    @Autowired
    private AerospikeConfigurationProperties aerospikeConfigurationProperties;
    @Bean(destroyMethod = "close")
    public AerospikeClient aerospikeClient() {
        ClientPolicy clientPolicy = new ClientPolicy();
        clientPolicy.failIfNotConnected = true;
        return new AerospikeClient(clientPolicy, aerospikeConfigurationProperties.getHost(), aerospikeConfigurationProperties.getPort());
    }
    @Bean
    public AerospikeCacheManager cacheManager(AerospikeClient aerospikeClient) {
        AerospikeCacheConfiguration defaultConfiguration = new AerospikeCacheConfiguration("test");
        return new AerospikeCacheManager(aerospikeClient, mappingAerospikeConverter, defaultConfiguration);
    }
}

In the AerospikeConfiguration we will create two types of Beans:

AerospikeClient

Responsible for accessing an Aerospike database and perform database operations.

AerospikeCacheManager

The heart of the cache layer, to define an AerospikeCacheManager you need:

  1. aerospikeClient (AerospikeClient)

  2. aerospikeConverter (MappingAerospikeConverter)

  3. defaultCacheConfiguration (AerospikeCacheConfiguration), a default cache configuration that applies when creating new caches. Cache configuration contains a namespace, a set (null by default meaning write directly to the namespace w/o specifying a set) and an expirationInSeconds (AKA TTL, default is 0 meaning use Aerospike server’s default).

  4. Optional: initalPerCacheConfiguration (Map<String, AerospikeCacheConfiguration>), You can also specify a map of cache names and matching configuration, it will create the caches with the given matching configuration at the application startup.

  • Note: A cache name is only a link to a cache configuration.

Objects

User

package com.aerospike.cache.simplespringbootaerospikecache.objects;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.data.aerospike.mapping.Document;
import org.springframework.data.annotation.Id;
@Data
@Document
@AllArgsConstructor
public class User {
    @Id
    private int id;
    private String name;
    private String email;
    private int age;
}

Repositories

UserRepository

package com.aerospike.cache.simplespringbootaerospikecache.repositories;
import com.aerospike.cache.simplespringbootaerospikecache.objects.User;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class UserRepository {
    @Cacheable(value = "test", key = "#id")
    public Optional<User> getUserById(int id) {
        System.out.println("Simulating a read from the main data store.");
        // In case the id doesn't exist in the cache it will "fetch" jimmy page with the requested id and add it to the cache (cache miss).
        return Optional.of(new User(id, "jimmy page", "jimmy@gmail.com", 77));
    }
    @CachePut(value = "test", key = "#user.id")
    public User addUser(User user) {
        System.out.println("Simulating addition of " + user + " to the main data store.");
        return user;
    }
    @CacheEvict(value = "test", key = "#id")
    public void removeUserById(int id) {
        System.out.println("Simulating removal of " + id + " from the main data store.");
    }
}
  • The cache annotations requires a “value” field, this is the cache name, if the cache name doesn’t exist — by passing initialPerCacheConfiguration param when creating a Bean of AerospikeCacheManager in a configuration class, it will configure the cache with the properties of the given defaultCacheConfiguration (3.3.1 Configuration > AerospikeCacheManager).

Services

UserService

package com.aerospike.cache.simplespringbootaerospikecache.services;
import com.aerospike.cache.simplespringbootaerospikecache.objects.User;
import com.aerospike.cache.simplespringbootaerospikecache.repositories.UserRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@AllArgsConstructor
public class UserService {
    UserRepository userRepository;
    public Optional<User> readUserById(int id) {
        return userRepository.getUserById(id);
    }
    public User addUser(User user) {
        return userRepository.addUser(user);
    }
    public void removeUserById(int id) {
        userRepository.removeUserById(id);
    }
}

Controllers

UserController

package com.aerospike.cache.simplespringbootaerospikecache.controllers;
import com.aerospike.cache.simplespringbootaerospikecache.objects.User;
import com.aerospike.cache.simplespringbootaerospikecache.services.UserService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@AllArgsConstructor
public class UserController {
    UserService userService;
    @GetMapping("/users/{id}")
    public Optional<User> readUserById(@PathVariable("id") Integer id) {
        return userService.readUserById(id);
    }
    @PostMapping("/users")
    public User addUser(@RequestBody User user) {
        return userService.addUser(user);
    }
    @DeleteMapping("/users/{id}")
    public void deleteUserById(@PathVariable("id") Integer id) {
        userService.removeUserById(id);
    }
}

Add @EnableCaching

SimpleSpringbootAerospikeCacheApplication

Add @EnableCaching to the class that contains the main method.

package com.aerospike.cache.simplespringbootaerospikecache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnableCaching
@SpringBootApplication
public class SimpleSpringbootAerospikeCacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(SimpleSpringbootAerospikeCacheApplication.class, args);
    }
}

Test

We will use Postman to simulate client requests:

Add User (@CachePut)

a. Create a new POST request with the following url: http://localhost:8080/users

b. Add a new key-value header in the Headers section:

Key: Content-Type

Value: application/json

c. Add a Body in a valid JSON format:

{
    "id":1,
    "name":"guthrie",
    "email":"guthriegovan@gmail.com",
    "age":35
}

d. Press Send.

send-1c0d52e5624d04833551cccdb2851b4b

And we can now see that this user was added to the cache.

cache-406a2ce5e275e5bddb35bed05dc3b0c7

Read User (@Cacheable)

a. Create a new GET request with the following url: http://localhost:8080/users/1

b. Add a new key-value header in the Headers section:

Key: Content-Type

Value: application/json

c. Press Send.

send2-374bd5759d6f27f510fd4ee77f65ac3a

Remove User (@CacheEvict)

a. Create a new DELETE request with the following url: http://localhost:8080/users/1

b. Add a new key-value header in the Headers section:

Key: Content-Type

Value: application/json

c. Press Send.

send3-e9475729ef44a603b137c185280a0163

And we can now see that this user was deleted from the cache (thanks to the @CacheEvict annotation in the UserRepository).

Untitled

Cache miss — Read User that is not in the cache (@Cacheable)

We can use the GET request that we configured before with an id that we know for sure that is not in the cache (yet).

Lets try calling the get request with the id 5:

get-e04697009bf97029c510f8ef4bba7935

We got the following user’s data for the request id 5:

{
    "id": 5,
    "name": "jimmy page",
    "email": "jimmy@gmail.com",
    "age": 77
}

If you remember correctly we wrote it hard-coded in the UserRepository to simulate an actual database fetch of a user id that doesn’t exist in the cache.

We can now also see that the user was added to the cache.

cache3-b32b8a9041ab624f066a052b8db0b74a

Conclusion

This was a demonstration of how simple it is to create a basic cache layer with Aerospike database using Spring Boot caching.

You can do much more with Spring Boot and Aerospike database, check out this medium article on how to start a simple web application using Java, Spring Boot, Aerospike and Docker.

https://medium.com/aerospike-developer-blog/simple-web-application-using-java-spring-boot-aerospike-database-and-docker-ad13795e0089