Spring Cache: @Cacheable, Caffeine, Redis
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 đề
- DB làm lại cùng một việc hàng nghìn lần, connection pool căng, latency tăng khi flash sale.
- Tự viết cache bằng
ConcurrentHashMaptrong service: lẫn logic business với logic cache, không TTL, không giới hạn size, đầy RAM.
3. Giải pháp: Spring Cache abstraction
@EnableCaching: bật cơ chế (proxy, giống@Transactional, bài 4.1.3).@Cacheable: đọc cache, miss thì gọi method và lưu.@CachePut: luôn gọi method, ghi kết quả vào cache.@CacheEvict: xóa entry khỏi cache.CacheManager: nơi lưu thật, đổi được: Caffeine (RAM local), Redis (dùng chung)...
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 cache | Dùng cho | |
|---|---|---|---|
@Cacheable | Chỉ khi miss | Đọc, miss thì ghi | Đọc nhiều: get product |
@CachePut | Luôn luôn | Ghi đè kết quả | Update xong muốn cache có giá trị mới |
@CacheEvict | Luôn luôn | Xó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
- Nên: đọc nhiều ghi ít, chấp nhận lệch vài phút: danh mục, chi tiết sản phẩm, cấu hình.
- Không nên: tồn kho khi đặt hàng, số dư, trạng thái thanh toán.
OrderServicekiểm tra tồn kho phải đọc DB (có lock), không đọc cache. - Không nên: dữ liệu theo từng user mà ít lặp lại; hit rate thấp, chỉ tốn RAM.
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:
- Tham số là object không override
equals/hashCode→ mỗi lần là key mới, cache vô dụng. Dùng record. - Dữ liệu phụ thuộc user/tenant/ngôn ngữ mà key không có → user A thấy dữ liệu của user B. Đưa
tenantId,localevào key. conditionxét trước khi gọi method;unlessxét sau, có#result.
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 pod | Không, mỗi pod một bản | Có, một bản dùng chung |
| Restart | Mất hết, cache lạnh | Còn nguyên |
| Bộ nhớ | Ăn heap JVM, coi chừng GC | Ngoài JVM |
| Hạ tầng | Không cần | Thê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.@Cacheabletra cache theo key, hit thì không gọi method, miss thì gọi rồi lưu.@CachePutluôn gọi method và ghi kết quả vào cache.@CacheEvictxóa entry. Nơi lưu thật doCacheManagerquyết định, như Caffeine hay Redis, nên đổi backend không đổi code.
🇬🇧 English:
With@EnableCaching, Spring wraps beans in a proxy with aCacheInterceptor.@Cacheablelooks up the key; on a hit the method is skipped, on a miss it runs and the result is stored.@CachePutalways runs the method and writes the result.@CacheEvictremoves entries. The actual store comes from theCacheManager, 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/hashCodenên mỗi lần một key mới, hoặccondition/unlessloạ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 withoutequals/hashCodeso every call creates a new key, orcondition/unlessfiltering the result. The fix for self-invocation is moving the cached method to another bean.
Cách nhớ ngắn gọn
- Cache = proxy, giống
@Transactional: cấm self-call. - Cacheable đọc, CachePut ghi, CacheEvict xóa.
- Key phải chứa mọi thứ làm kết quả khác nhau (page, tenant, locale).
- Caffeine nhanh nhưng mỗi pod một bản; Redis chung nhưng tốn mạng.
- TTL riêng từng cache; không cache tồn kho, số dư.
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.