Spring Cache: @Cacheable, Caffeine, Redis

6 câu hỏi~30 phútTừ khóa: @EnableCaching, @Cacheable, @CachePut, @CacheEvict, cache key, CacheManager, Caffeine, Redis, TTL, self-invocation, two-level cache

Tiếp đi: Spring Cache. Trang chi tiết sản phẩm của shop-app bị gọi rất nhiều, mỗi lần đều query DB. Mình cache lại kiểu gì cho gọn?

Spring có sẵn một lớp trừu tượng cache: bạn đánh annotation lên method, Spring lo phần "có trong cache thì trả luôn, chưa có thì gọi method rồi lưu lại". Chiến lược cache nói chung (cache-aside, write-through...) đã có ở bài Redis cache strategies; bài này tập trung vào cách Spring làm.

1. Bài toán: GET /products/{id} chiếm 70% traffic

@Service
public class ProductService {
    public ProductDto getProduct(Long id) {
        log.info("Query DB product {}", id);
        return productRepository.findById(id)
            .map(ProductDto::from)
            .orElseThrow(() -> new NotFoundException(id));     // ~30ms, join 3 bảng
    }
}
1000 req/s ──→ OrderController/ProductController ──→ ProductService ──→ DB
                                                         1000 query/s cho dữ liệu
                                                         gần như không đổi

2. Vấn đề

3. Giải pháp: Spring Cache abstraction

4. Code từng bước

Bước 1: Dependency (bắt đầu với Caffeine, local)

<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>

Bước 2: Config

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
@Configuration
@EnableCaching
public class CacheConfig { }

Bước 3: Đánh annotation

@Service
public class ProductService {

    @Cacheable(cacheNames = "products", key = "#id")
    public ProductDto getProduct(Long id) {
        log.info("Query DB product {}", id);
        return productRepository.findById(id).map(ProductDto::from)
            .orElseThrow(() -> new NotFoundException(id));
    }

    @CachePut(cacheNames = "products", key = "#result.id")
    @Transactional
    public ProductDto updateProduct(Long id, UpdateProductRequest req) {
        Product p = productRepository.findById(id).orElseThrow();
        p.setPrice(req.price());
        return ProductDto.from(p);
    }

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

Cache DTO, không cache JPA entity: entity có lazy proxy, detached ngoài session, và dễ bị sửa nhầm.

Bước 4: Test

curl localhost:8080/products/1   # log: Query DB product 1   (~30ms)
curl localhost:8080/products/1   # không log                (~1ms, từ cache)
curl -X PUT localhost:8080/products/1 -H "Content-Type: application/json" -d '{"price":199000}'
curl localhost:8080/products/1   # không log, giá mới 199000 (CachePut đã ghi)

5. Cách hoạt động

ProductController
     |
     | productService.getProduct(1)
     v
PROXY (CacheInterceptor)
     | key = 1
     | cache.get("products", 1)
     |    ├─ HIT  ──────────────────────→ trả về, KHÔNG gọi method
     |    └─ MISS
     v
ProductService.getProduct(1) ──→ DB
     |
     | cache.put("products", 1, dto)
     v
trả về

6. So sánh ba annotation

Gọi method?Tác động cacheDùng cho
@CacheableChỉ khi missĐọc, miss thì ghiĐọc nhiều: get product
@CachePutLuôn luônGhi đè kết quảUpdate xong muốn cache có giá trị mới
@CacheEvictLuôn luônXóa entry (hoặc allEntries=true)Delete, hoặc update mà không muốn tự dựng giá trị

7. Khi nào nên dùng / không nên dùng

Mình có search(String keyword, int page, String sort). Spring tạo key thế nào? Hai user tìm khác nhau có bị trả nhầm kết quả không?

Câu hỏi đúng chỗ hay sai. Mặc định Spring dùng SimpleKeyGenerator:

Không tham số     → SimpleKey.EMPTY
Một tham số       → chính tham số đó
Nhiều tham số     → SimpleKey(keyword, page, sort)   (dựa vào equals/hashCode)

Trả nhầm xảy ra khi key thiếu thông tin ảnh hưởng tới kết quả:

// SAI: key chỉ có keyword, page 2 trả về kết quả page 1
@Cacheable(cacheNames = "productSearch", key = "#keyword")
public Page<ProductDto> search(String keyword, int page, String sort) { ... }

// ĐÚNG: key gồm mọi thứ làm kết quả khác nhau
@Cacheable(cacheNames = "productSearch",
           key = "#keyword + ':' + #page + ':' + #sort",
           condition = "#page < 5",            // chỉ cache 5 trang đầu
           unless = "#result.isEmpty()")         // không cache kết quả rỗng
public Page<ProductDto> search(String keyword, int page, String sort) { ... }

Các bẫy key hay gặp:

Shop-app chạy 3 pod. Mình update giá ở pod 1 thì pod 2, pod 3 vẫn trả giá cũ. Vậy phải chuyển sang Redis à?

Đúng, đó là giới hạn của cache local. @CacheEvict chỉ xóa trong RAM của pod nhận request.

PUT /products/1 ──→ pod-1: evict ✔  cache: 199k
                    pod-2:          cache: 250k  ← cũ tới khi hết TTL
                    pod-3:          cache: 250k  ← cũ
Caffeine (local)Redis (distributed)
Tốc độ~nanosecond, trong heap~1ms, qua mạng + serialize
Nhất quán giữa podKhông, mỗi pod một bảnCó, một bản dùng chung
RestartMất hết, cache lạnhCòn nguyên
Bộ nhớĂn heap JVM, coi chừng GCNgoài JVM
Hạ tầngKhông cầnThêm Redis phải vận hành

Chuyển sang Redis chỉ đổi dependency và config, code annotation giữ nguyên:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  cache:
    type: redis
  data:
    redis:
      host: ${REDIS_HOST}
      password: ${REDIS_PASSWORD}

Nếu dữ liệu chấp nhận lệch vài chục giây (ví dụ danh mục), Caffeine với TTL ngắn vẫn là lựa chọn rẻ và nhanh. Cần mọi pod thấy cùng giá ngay thì dùng Redis.

Mỗi loại dữ liệu mình muốn TTL khác nhau: sản phẩm 10 phút, danh mục 1 giờ, giá flash sale 30 giây. Config ở đâu?

spring.cache.redis.time-to-live chỉ đặt chung cho tất cả. TTL riêng từng cache thì khai báo qua RedisCacheManagerBuilderCustomizer:

@Configuration
@EnableCaching
public class CacheConfig {

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

    @Bean
    RedisCacheManagerBuilderCustomizer perCacheTtl(RedisCacheConfiguration base) {
        return builder -> builder
            .withCacheConfiguration("products",   base.entryTtl(Duration.ofMinutes(10)))
            .withCacheConfiguration("categories", base.entryTtl(Duration.ofHours(1)))
            .withCacheConfiguration("flashPrice", base.entryTtl(Duration.ofSeconds(30)));
    }
}
redis-cli KEYS 'shop:*'
shop:products::1
shop:categories::all
redis-cli TTL shop:flashPrice::42    →  27

Vài lưu ý: dùng JSON serializer thay vì JDK serialization (đọc được, không phụ thuộc Serializable); thêm chút random vào TTL nếu nhiều key được nạp cùng lúc để tránh hết hạn đồng loạt (cache avalanche). Các vấn đề stampede, penetration xem thêm ở bài Redis. Với key nóng bị nhiều request cùng miss, @Cacheable(sync = true) cho phép chỉ một thread trong pod gọi DB.

Mình viết getProducts(List ids) gọi this.getProduct(id) trong vòng lặp để tận dụng cache, mà log vẫn query DB mỗi lần. Sao vậy?

Cùng bẫy với @Transactional và @Async: self-invocation. Cache chạy bằng proxy; this.getProduct() gọi thẳng object thật, không đi qua proxy, nên @Cacheable bị bỏ qua.

ProductController ──→ PROXY ──→ ProductService.getProducts(ids)
                                        |
                                        | this.getProduct(id)   ← không qua proxy
                                        v
                                 ProductService.getProduct(id) ──→ DB mỗi lần

Cách sửa gọn nhất: tách phần cache sang bean khác.

@Component
public class ProductCache {
    @Cacheable(cacheNames = "products", key = "#id")
    public ProductDto get(Long id) { ... query DB ... }
}

@Service
public class ProductService {
    private final ProductCache productCache;

    public List<ProductDto> getProducts(List<Long> ids) {
        return ids.stream().map(productCache::get).toList();   // qua proxy ✔
    }
}

Vấn đề tiếp theo: Redis nhanh nhưng mỗi lần vẫn tốn ~1ms mạng. Với key cực nóng, có thể làm cache 2 tầng:

request ──→ L1 Caffeine (trong pod, TTL 30s)
              ├─ HIT → trả về
              └─ MISS → L2 Redis (dùng chung, TTL 10m)
                          ├─ HIT → ghi vào L1, trả về
                          └─ MISS → DB → ghi L2, ghi L1

Update giá: ghi DB → evict Redis → publish "evict products:1" (Redis pub/sub)
                                    → mọi pod xóa L1 của mình

Spring không có sẵn cache 2 tầng; tự viết một Cache bọc hai cache, hoặc dùng thư viện (ví dụ JetCache). Đổi lại độ phức tạp: thêm kênh invalidation, và vẫn có thể lệch tối đa bằng TTL của L1. Chỉ làm khi đo thấy Redis là nút thắt.

Phỏng vấn hỏi Spring Cache thì trả lời sao?

Q1. Spring Cache hoạt động thế nào? @Cacheable, @CachePut, @CacheEvict khác nhau ra sao?

🇻🇳 Tiếng Việt:

Bật bằng @EnableCaching, Spring bọc bean bằng proxy có CacheInterceptor. @Cacheable tra cache theo key, hit thì không gọi method, miss thì gọi rồi lưu. @CachePut luôn gọi method và ghi kết quả vào cache. @CacheEvict xóa entry. Nơi lưu thật do CacheManager quyết định, như Caffeine hay Redis, nên đổi backend không đổi code.

🇬🇧 English:

With @EnableCaching, Spring wraps beans in a proxy with a CacheInterceptor. @Cacheable looks up the key; on a hit the method is skipped, on a miss it runs and the result is stored. @CachePut always runs the method and writes the result. @CacheEvict removes entries. The actual store comes from the CacheManager, such as Caffeine or Redis, so switching backends needs no code change.

Q2. Khi nào dùng Caffeine, khi nào dùng Redis?

🇻🇳 Tiếng Việt:

Caffeine nằm trong heap, cực nhanh, nhưng mỗi instance một bản nên evict không lan sang pod khác và mất khi restart. Redis dùng chung cho mọi instance, nhất quán hơn nhưng tốn round trip mạng và phải vận hành. Dữ liệu chấp nhận lệch ngắn thì Caffeine với TTL ngắn; cần nhất quán giữa pod thì Redis; key rất nóng thì cache hai tầng có invalidation qua pub/sub.

🇬🇧 English:

Caffeine lives in the heap and is extremely fast, but each instance has its own copy, so evictions don't reach other pods and it's lost on restart. Redis is shared by all instances, more consistent but costs a network round trip and operational effort. For data that tolerates brief staleness, use Caffeine with a short TTL; for cross-pod consistency, Redis; for very hot keys, a two-level cache with pub/sub invalidation.

Q3. Vì sao @Cacheable đôi khi không có tác dụng?

🇻🇳 Tiếng Việt:

Hay gặp nhất là self-invocation: gọi method trong cùng class không qua proxy. Ngoài ra: quên @EnableCaching, method không public, key là object không có equals/hashCode nên mỗi lần một key mới, hoặc condition/unless loại bỏ kết quả. Cách sửa self-invocation là tách method cache sang bean khác.

🇬🇧 English:

Most often it's self-invocation: calling the method from the same class bypasses the proxy. Other causes: missing @EnableCaching, a non-public method, key objects without equals/hashCode so every call creates a new key, or condition/unless filtering the result. The fix for self-invocation is moving the cached method to another bean.

Cách nhớ ngắn gọn


Tự kiểm tra

@Cacheable hit thì method có chạy không?

Không. Proxy trả giá trị từ cache, method không được gọi.

Update giá ở pod 1 với Caffeine, pod 2 thấy gì?

Giá cũ, cho tới khi entry ở pod 2 hết TTL. Evict chỉ tác động cache local của pod 1.

condition và unless khác nhau thế nào?

condition xét trước khi gọi method; unless xét sau, dùng được #result.

Vì sao nên cache DTO thay vì JPA entity?

Entity có lazy proxy, bị detached ngoài session và dễ bị sửa nhầm; DTO bất biến và serialize gọn.

Gọi this.getProduct(id) trong cùng class, cache có chạy không?

Không, vì không đi qua proxy. Tách method cache sang bean khác.