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: redischỉ đị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ạngproducts::<key>.key = "#id"là biểu thức SpEL lấy tham sốidlà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
RedisCacheConfigurationlà 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ưunull, và value được lưu dưới dạng JSON. RedisCacheManagerBuilderCustomizerghi đè cấu hình riêng cho cacheproducts. 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ínhspring.cache.redis.time-to-live,spring.cache.redis.key-prefix… trongapplication.ymlsẽ 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:
@CachePutluô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.@CacheEvictxoá 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êmbeforeInvocation = true.allEntries = truexoá toàn bộ cacheproducts. 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 = truechố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:
@CachePutghi 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. @BeforeEachdọn cache và bộ đếm, giúp các test độc lập với nhau. Container làstaticnê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.