Cache với @Cacheable và Redis trong Spring Boot

1. Tổng quan

Nhiều ứng dụng gọi đi gọi lại cùng một truy vấn chậm. Ví dụ là lấy thông tin sản phẩm, cấu hình hay tỷ giá. Spring Cache Abstraction giải quyết việc này bằng vài annotation. Redis đóng vai trò nơi lưu cache dùng chung cho nhiều instance.

Đọc xong bài này, bạn sẽ làm được các việc sau:

  • Cấu hình Redis làm cache provider.
  • Dùng @Cacheable, @CachePut, @CacheEvict.
  • Đặt TTL riêng cho từng cache và lưu dữ liệu dạng JSON.
  • Kiểm thử hành vi cache bằng JUnit 5 và Testcontainers.

2. Dependency Maven

Bạn cần hai starter: một cho Spring Cache, một cho Redis.

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

Phần test cần thêm Testcontainers. Spring Boot đã quản lý version cho các dependency này:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3. Cấu hình kết nối Redis

Khai báo địa chỉ Redis và chọn Redis làm cache provider trong application.yml:

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

Ý nghĩa từng thuộc tính:

  • spring.data.redis.* cấu hình kết nối. Spring Boot dùng client Lettuce mặc định.
  • spring.cache.type: redis chỉ định rõ provider. Nếu có Redis trên classpath, Spring Boot cũng tự chọn Redis. Ghi rõ ra giúp tránh nhầm lẫn khi classpath có nhiều provider.

Bạn có thể chạy nhanh một Redis local bằng Docker:

docker run -d --name redis -p 6379:6379 redis:7-alpine

4. Ví dụ cơ bản với @Cacheable

4.1. Model và repository giả lập truy vấn chậm

Ta dùng một record đơn giản cho sản phẩm:

public record Product(Long id, String name, BigDecimal price) {}

Repository dưới đây giả lập database chậm. Nó cũng đếm số lần được gọi để ta kiểm chứng cache:

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
            1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
            2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
    ));
    private final AtomicInteger queryCount = new AtomicInteger();

    public Optional<Product> findById(Long id) {
        queryCount.incrementAndGet();
        sleep(1000); // giả lập truy vấn mất 1 giây
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        store.put(product.id(), product);
        return product;
    }

    public void deleteById(Long id) {
        store.remove(id);
    }

    public int getQueryCount() { return queryCount.get(); }

    public void resetQueryCount() { queryCount.set(0); }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4.2. Bật cache và đánh dấu method

Bật cơ chế cache bằng @EnableCaching. Đặt annotation này trên một class @Configuration (sẽ viết ở mục 5) hoặc trên class main. Sau đó đánh dấu method cần cache:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

Luồng xử lý như sau:

  • cacheNames = "products" là tên cache. Trên Redis, key mặc định có dạng products::<key>.
  • key = "#id" là biểu thức SpEL lấy tham số id làm key. Nếu method chỉ có một tham số, bạn có thể bỏ thuộc tính này.
  • Lần gọi đầu, Spring không thấy key trong cache. Nó chạy method, rồi lưu kết quả vào Redis.
  • Các lần gọi sau với cùng id, Spring trả kết quả từ Redis. Method không được chạy.
  • Nếu method ném exception (ProductNotFoundException), Spring không lưu gì vào cache.

Gọi findById(1L) hai lần rồi kiểm tra Redis:

redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798

Lần gọi đầu mất khoảng 1 giây. Lần thứ hai chỉ mất vài mili giây. TTL còn khoảng 1800 giây vì ta sẽ cấu hình 30 phút cho cache products ở mục tiếp theo.

5. Cấu hình RedisCacheManager: TTL và serializer

Mặc định, RedisCacheManager dùng JDK serialization. Cách này có ba nhược điểm:

  • Class phải implement Serializable.
  • Dữ liệu trong Redis là binary nên khó đọc.
  • Dữ liệu dễ hỏng khi bạn đổi cấu trúc class.

Mặc định cache cũng không có TTL, nghĩa là entry sống mãi. Ta sửa cả hai điểm này:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30))
                        .disableCachingNullValues()
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
    }
}

Giải thích từng bean:

  • Bean RedisCacheConfiguration là cấu hình mặc định cho mọi cache. Spring Boot tự nhận bean này. Mọi entry có TTL 10 phút, không lưu null, và value được lưu dưới dạng JSON.
  • RedisCacheManagerBuilderCustomizer ghi đè cấu hình riêng cho cache products. Cache này có TTL 30 phút và dùng serializer có kiểu cụ thể là Product.class.

Vì sao products dùng Jackson2JsonRedisSerializer<>(Product.class)? GenericJackson2JsonRedisSerializer cần lưu thông tin kiểu (@class) vào JSON. Với class final như record, cơ chế này dễ trả về LinkedHashMap thay vì đúng kiểu, và bạn sẽ gặp ClassCastException. Serializer có kiểu cụ thể tránh được hoàn toàn vấn đề đó.

Dữ liệu trong Redis giờ đọc được trực tiếp:

redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"

Lưu ý: Khi bạn tự khai báo bean RedisCacheConfiguration, các thuộc tính spring.cache.redis.time-to-live, spring.cache.redis.key-prefix… trong application.yml sẽ bị bỏ qua. Hãy chọn một trong hai cách cấu hình, đừng trộn lẫn.

6. Cập nhật và xoá cache: @CachePut và @CacheEvict

Cache chỉ có ích khi dữ liệu trong đó đúng. Khi sản phẩm thay đổi, bạn phải cập nhật hoặc xoá entry tương ứng.

@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
    // ví dụ: gọi sau khi import hàng loạt sản phẩm
}

Khác biệt giữa các annotation:

  • @CachePut luôn chạy method, rồi ghi kết quả trả về vào cache với key #product.id(). Vì vậy method phải trả về đúng object cần cache. Nếu trả về void, cache sẽ không có dữ liệu đúng.
  • @CacheEvict xoá entry theo key sau khi method chạy thành công. Nếu cần xoá trước khi method chạy, bạn thêm beforeInvocation = true.
  • allEntries = true xoá toàn bộ cache products. Với Redis, thao tác này quét key theo pattern. Bạn chỉ nên dùng khi thật cần.

7. Điều khiển việc cache: condition, unless, sync

@Cacheable có thêm vài thuộc tính hữu ích:

@Cacheable(cacheNames = "productSearch",
           key = "#keyword.toLowerCase()",
           condition = "#keyword.length() >= 3",
           unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
    // truy vấn chậm...
    return List.of();
}

@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
    return findFromDatabase(id); // method private trong cùng class, đọc thẳng database
}

Ý nghĩa từng thuộc tính:

  • condition được đánh giá trước khi chạy method. Ở đây, từ khoá ngắn hơn 3 ký tự thì không tra và cũng không lưu cache.
  • unless được đánh giá sau khi chạy method và có thể dùng #result. Ở đây, danh sách rỗng sẽ không được lưu.
  • key = "#keyword.toLowerCase()" giúp “Nón” và “nón” dùng chung một entry.
  • sync = true chống cache stampede. Khi nhiều thread cùng miss một key, chỉ một thread chạy method, các thread còn lại chờ kết quả. Cơ chế này chỉ đồng bộ trong một JVM, không phải distributed lock giữa các instance.

Cache productSearch không có cấu hình riêng nên dùng cấu hình mặc định ở mục 5, tức TTL 10 phút và GenericJackson2JsonRedisSerializer. Với giá trị là List<Product> chứa record, bạn nên khai báo serializer có kiểu cụ thể giống cách làm với products. Như vậy bạn tránh được lỗi ClassCastException đã nói ở mục 5.

8. Kiểm thử hành vi cache

Cách đáng tin cậy nhất là test với Redis thật qua Testcontainers. Annotation @ServiceConnection tự cấu hình host và port cho Spring Boot, bạn không cần sửa application.yml.

@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        productRepository.resetQueryCount();
    }

    @Test
    void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
        Product first = productService.findById(1L);
        Product second = productService.findById(1L);

        assertThat(first).isEqualTo(second);
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
        assertThat(cacheManager.getCache("products").get(1L, Product.class))
                .isEqualTo(first);
    }

    @Test
    void whenProductUpdated_thenCacheReturnsNewValue() {
        productService.findById(2L);

        productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
        Product cached = productService.findById(2L);

        assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
    }
}

Hai test này kiểm chứng các điểm sau:

  • Test thứ nhất: gọi hai lần nhưng repository chỉ bị gọi một lần. Entry cũng thực sự có trong cache với đúng kiểu Product. Điều đó xác nhận serializer hoạt động đúng.
  • Test thứ hai: @CachePut ghi giá trị mới, nên lần đọc sau lấy từ cache mà không gọi repository thêm lần nào.
  • @BeforeEach dọn cache và bộ đếm, giúp các test độc lập với nhau. Container là static nên chỉ khởi động một lần cho cả class.

9. Lỗi hay gặp và khi nào không nên dùng

Self-invocation. Spring Cache hoạt động qua proxy. Nếu một method trong ProductService gọi this.findById(id), lời gọi đi thẳng vào object, không qua proxy, nên cache không được áp dụng. Đó cũng là lý do ví dụ findHotProduct ở mục 7 đọc thẳng database thay vì gọi findById. Annotation trên method private cũng không có tác dụng. Cách sửa là tách method cần cache sang một bean khác.

Cache giá trị null khi đã tắt null. Với disableCachingNullValues(), method trả về null sẽ gây lỗi:

java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...

Bạn có hai cách xử lý: thêm unless = "#result == null", hoặc ném exception như ví dụ ở mục 4.

Đổi cấu trúc class. Bạn thêm hoặc đổi tên field trong Product, nhưng Redis vẫn còn JSON kiểu cũ. Kết quả có thể là lỗi deserialize hoặc dữ liệu thiếu field. Bạn nên đổi tên cache (ví dụ products-v2) hoặc dọn cache khi deploy.

Cache entity JPA. Entity có lazy proxy và quan hệ hai chiều, rất dễ lỗi khi serialize. Hãy cache DTO hoặc record.

Lưu ý: Không nên dùng cache khi dữ liệu đòi hỏi tính nhất quán tuyệt đối, ví dụ số dư tài khoản hay tồn kho lúc thanh toán. Cũng không nên cache khi dữ liệu thay đổi liên tục, vì tỷ lệ hit sẽ thấp và bạn chỉ tốn thêm một lượt gọi mạng tới Redis. Nếu chỉ chạy một instance và dữ liệu nhỏ, cache in-memory như Caffeine thường nhanh và đơn giản hơn Redis.

10. Kết luận

Bạn đã cấu hình Redis làm cache provider cho Spring Boot. Bạn dùng @Cacheable để đọc, @CachePut để cập nhật, @CacheEvict để xoá entry. Bạn cũng đặt TTL và serializer JSON riêng cho từng cache, rồi kiểm chứng hành vi bằng test với Redis thật qua Testcontainers.

Ba điều cần nhớ:

  • Luôn đặt TTL.
  • Cache DTO thay vì entity.
  • Cẩn thận với self-invocation.

Làm đúng ba điều này sẽ giúp bạn tránh phần lớn sự cố khi đưa cache lên production.

spring-bootrediscachespring-cachetestcontainers