Search code examples
javaspring-bootmicrometerspring-cachecaffeine-cache

SpringBoot cache + caffeine + micrometer, how to configure multiple caches in one application and with observability


My use case is fairly simple. I have a SpringBoot app that does nothing but expose one rest endpoint.

The rest endpoint does nothing but call two other external services I have no control over. The network calls are expensive (the processing on each of the third-party calls is above 5 seconds)

The data returned by both third-party services does not often change either.

Hence, I am thinking of using a cache. I.e., using SpringBoot, Spring cache, and Caffeine.

Due to production requirements, I would also like to add observability using a micrometer to the cache. Being able to see cache miss. cache hit, is a must.

To achieve the above, I tried to write the following code:

<dependencies>

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

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

        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>io.opentelemetry</groupId>
            <artifactId>opentelemetry-exporter-otlp</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-tracing-bridge-otel</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-otlp</artifactId>
        </dependency>
    </dependencies>
package controller;

import com.ivoronline.model.MedicalReportDto;
import com.ivoronline.model.PatientInfoDto;
import com.ivoronline.service.InfoService;
import com.ivoronline.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MyController {

  @Autowired
  PersonService personService;

  @Autowired
  InfoService infoService;

  @GetMapping("/getAll")
  public String getAll(@RequestParam String name) {
    PatientInfoDto person = personService.getPatientInfo(name);
    MedicalReportDto medical = infoService.getLatestReport(name);
    return person.toString() + medical.toString();
  }

}

package service;

import com.ivoronline.model.MedicalReportDto;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class InfoService {

    private final RestClient restClient;

    public InfoService(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
    }

    @Cacheable(cacheNames = "ReportInfo")
    public MedicalReportDto getLatestReport(String PatientId) {
        System.out.println("Fetching Latest Report of Patient (but please apply cache here to see only one call per ID) : {}" + PatientId);
        ResponseEntity<String> response = this.restClient.get()
                .uri("http://localhost:8083/report/getreport/" + PatientId)
                .retrieve()
                .toEntity(String.class);
        System.out.println(response.getBody());
        return new MedicalReportDto(response.getBody());
    }

}

package service;

import com.ivoronline.model.PatientInfoDto;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.util.Map;

@Service
public class PersonService {

  private final RestClient restClient;

  public PersonService(RestClient.Builder restClientBuilder) {
    this.restClient = restClientBuilder.build();
  }

  @Cacheable(cacheNames = "PatientInfo")
  public PatientInfoDto getPatientInfo(String BedDeptNum) {
    System.out.println("Fetching Patient Info (but please apply cache here to see only one call per ID): {}" + BedDeptNum);
    ResponseEntity<String> response = this.restClient.post()
            .uri("http://localhost:8888/patient/")
            .body(Map.of("BedDeptNum", BedDeptNum))
            .retrieve()
            .toEntity(String.class);
    System.out.println(response.getBody());
    return new PatientInfoDto(response.getBody());
  }

}

Each cache has its own configuration (time, size, eviction).

Each cache needs to be observed.

mport com.github.benmanes.caffeine.cache.Caffeine;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;
import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CachingConfig {

  @Bean
  public CacheManager cacheManager(MeterRegistry registry) {
    // How to construct caffeine manager for each of the two below caches, with observability?
    return manager;

  }

  private CaffeineCache reportCache() {
    return new CaffeineCache("ReportInfo", Caffeine.newBuilder()
            .expireAfterWrite(, TimeUnit.MINUTES) // some custom time
            .maximumSize() // some custom size
            .recordStats()
            .build());
  }

  private CaffeineCache patientCache() {
    return new CaffeineCache("PatientInfo", Caffeine.newBuilder()
            .expireAfterWrite(, TimeUnit.MINUTES) // some other custom time
            .maximumSize() // some other custom size
            .recordStats()
            .build());
  }

}

Issue:

What to put in the configuration.java file so:

  • Both caches have their own metrics

  • each of the cache has its own configuration


Solution

  • Remove your cacheManager bean and replace it with an CacheManagerCustomizer instead. Make sure that you have both spring-boot-starter-actuator and approriate Google Caffeine and Micrometer dependencies and that will be all you need to have metrics. Spring Boot already configures those metrics.

    @Configuration
    @EnableCaching
    public class CachingConfig {
    
      @Bean
      public CacheManagerCustomizer<CaffeineCachaManager> cacheManagerCustomizer() {
        return (cm) -> {
          cm.registerCustomCache("ReportInfo", reportCache());
          cm.registerCustomCache("PatientInfo", patientCache());
        };
      }
    
      private Caffeine reportCache() {
        return  Caffeine.newBuilder()
                .expireAfterWrite(, TimeUnit.MINUTES) // some custom time
                .maximumSize() // some custom size
                .recordStats()
                .build();
      }
    
      private Caffeine patientCache() {
        return Caffeine.newBuilder()
                .expireAfterWrite(, TimeUnit.MINUTES) // some other custom time
                .maximumSize() // some other custom size
                .recordStats()
                .build();
      }
    }
    

    Something along those lines should give you the at least some metrics. If you want more detailed metrics you could inject the MeterRegistry into your CacheManagerCustomizer and pass it along to the reportCache and patientCache methods and register additionally the CaffeineStatsCounter with the cache.

      @Bean
      public CacheManagerCustomizer<CaffeineCachaManager> cacheManagerCustomizer(MeterRegistry registry) {
        return (cm) -> {
          cm.registerCustomCache("ReportInfo", reportCache(registry));
          cm.registerCustomCache("PatientInfo", patientCache(registry));
        };
      }
    
      private Caffeine reportCache(MeterRegistry registry) {
        return Caffeine.newBuilder()
                .expireAfterWrite(, TimeUnit.MINUTES) // some custom time
                .maximumSize() // some custom size
                .recordStats(() -> new CaffeineStatsCounter(registry, "ReportInfo") )
                .build();
      }
    
      private Caffeine patientCache(MeterRegistry registry) {
        return Caffeine.newBuilder()
                .expireAfterWrite(, TimeUnit.MINUTES) // some other custom time
                .maximumSize() // some other custom size
                .recordStats(() -> new CaffeineStatsCounter(registry, "PatientInfo") )
                .build();
      }
    }
    

    Now you still benefit from the auto-configuration from Spring Boot with the caching and only need to configure your caches.