99 Ngày Spring — Ngày 36: Caching với @Cacheable
Năm bài liền ta tối ưu cách truy vấn — hôm nay là bước nhảy tư duy: tốc độ lớn nhất đến từ việc không truy vấn nữa. Những câu hỏi lặp đi lặp lại cùng câu trả lời — danh mục sản phẩm, tỷ giá, trang chủ — xứng đáng được trả lời từ cache trong micro giây. Spring cho việc đó đúng một annotation: @Cacheable. Bài hôm nay đi đủ vòng đời: cache abstraction và cơ chế proxy, key với SpEL và hai luật sống còn, bộ đôi giữ cache thật @CacheEvict/@CachePut, TTL với Caffeine — và bốn cái bẫy khiến cache nổi tiếng là con dao hai lưỡi.

Cache abstraction — một annotation, đổi cả chiến lược
// Ngày 31–35 tối ưu CÁCH truy vấn. Hôm nay: tối ưu bằng cách KHÔNG truy vấn.
// Quan sát: có những câu hỏi được hỏi đi hỏi lại với CÙNG câu trả lời
// - danh mục sản phẩm (đổi 1 lần/tuần) — hỏi 10.000 lần/phút
// - tỷ giá tiền tệ (đổi 1 lần/giờ)
// - trang chủ blog (bài mới 1 lần/ngày)
// → trả lời từ SỔ TAY (cache) thay vì tra SÁCH GỐC (DB): micro giây thay mili giây
// Spring Cache Abstraction — cache bằng ANNOTATION, không sửa logic:
@Configuration
@EnableCaching // 1. bật hạ tầng cache (một lần duy nhất)
public class CacheConfig {}
@Service
public class SanPhamService {
@Cacheable("sanPham") // 2. dán nhãn — xong!
public SanPham timTheoId(Long id) {
// lần ĐẦU với mỗi id: chạy method, cất kết quả vào cache "sanPham"
// các lần SAU cùng id: TRẢ THẲNG từ cache — method KHÔNG chạy
return repo.findById(id).orElseThrow();
}
}
// "Abstraction" nghĩa là: code KHÔNG biết cache nằm ở đâu
// Đổi ConcurrentHashMap → Caffeine → Redis: chỉ đổi dependency + config
// Cơ chế bên dưới: PROXY — người gác cổng kiểm tra cache trước khi cho gọi method
// (đúng cơ chế AOP của @Transactional Ngày 22 — và cùng những điểm mù, xem cuối bài)Công thức hai bước:
@EnableCachingmột lần +@Cacheable("ten")lên method — lần đầu chạy thật và cất kết quả, các lần sau method không chạy nữa, kết quả bay thẳng từ cache.Abstraction là điểm ăn tiền: code không biết cache nằm đâu — demo bằng map trong RAM, production đổi sang Caffeine hay Redis chỉ bằng dependency + config, không sửa một dòng logic.
Bên dưới là proxy AOP — đúng người gác cổng của
@Transactional(Ngày 22): kiểm tra cache trước khi cho gọi method thật. Cùng cơ chế nghĩa là cùng điểm mù — để dành cho mục bẫy cuối bài.
Key — linh hồn của cache
// KEY — linh hồn của cache: kết quả cất dưới key nào?
// Mặc định: key = TOÀN BỘ tham số (SimpleKey). Thường đúng ý. Nhưng:
@Cacheable("sanPham")
public SanPham tim(Long id, boolean logChiTiet) { ... }
// key = (id, logChiTiet) → tim(1, true) và tim(1, false) cache HAI BẢN?! Sai ý
// Chỉ định key bằng SpEL:
@Cacheable(value = "sanPham", key = "#id") // chỉ id là danh tính
public SanPham tim(Long id, boolean logChiTiet) { ... }
@Cacheable(value = "gioHang", key = "#user.id") // theo thuộc tính
public GioHang cuaAi(User user) { ... }
// Điều kiện — cache CÓ CHỌN LỌC:
@Cacheable(value = "sanPham", key = "#id",
unless = "#result == null") // ĐỪNG cất khi kết quả null
public SanPham timAnToan(Long id) { ... }
// condition = "#id > 0" → chỉ cache khi thỏa từ ĐẦU (unless xét kết quả ở CUỐI)
// Hai luật sống còn của key:
// 1. Cùng danh tính → cùng key; khác danh tính → PHẢI khác key
// (thiếu tham số trong key = hai câu hỏi khác nhau nhận CHUNG một câu trả lời!)
// 2. Kết quả cất trong cache là ĐỒ DÙNG CHUNG — trả record/DTO bất biến (Ngày 33),
// đừng trả entity đang gắn persistence contextMặc định key gộp toàn bộ tham số — thường đúng, nhưng tham số phụ (cờ log, locale hiển thị) lọt vào key là cache tách bản vô ích; chỉ định
key = "#id"bằng SpEL để key đúng bằng danh tính của câu hỏi.Cache có chọn lọc:
unless = "#result == null"chặn cất kết quả rỗng (xét ở cuối, sau khi có kết quả),conditionchặn từ đầu — nhớ hướng xét của từng cái.Luật thứ hai hay bị bỏ qua: kết quả trong cache là đồ dùng chung nhiều thread cùng đọc — trả record/DTO bất biến (Ngày 33), đừng trả entity còn gắn persistence context: vừa nặng vừa dễ sinh
LazyInitializationExceptionngoài giờ.
@CacheEvict & @CachePut — giữ cache thật
// Cache mà không dọn = nói dối có hệ thống. Hai annotation giữ cache THẬT:
// @CacheEvict — XÓA khỏi cache khi dữ liệu gốc đổi
@CacheEvict(value = "sanPham", key = "#sp.id")
public SanPham capNhat(SanPham sp) {
return repo.save(sp); // lưu DB xong → đuổi bản cũ khỏi cache
} // lần đọc sau cache miss → nạp bản MỚI từ DB
@CacheEvict(value = "sanPham", allEntries = true) // dọn CẢ cache
public void nhapHangLoat(List<SanPham> ds) { ... } // đổi quá nhiều → dọn sạch cho lành
// @CachePut — CHẠY method + GHI ĐÈ cache (không bao giờ đọc cache)
@CachePut(value = "sanPham", key = "#result.id")
public SanPham capNhatVaLamNong(SanPham sp) {
return repo.save(sp); // cache luôn có bản mới nhất — không chờ lần đọc sau
}
// Phối hợp nhiều động tác — @Caching:
@Caching(evict = {
@CacheEvict(value = "sanPham", key = "#id"),
@CacheEvict(value = "trangChu", allEntries = true) // trang chủ có top sản phẩm!
})
public void xoa(Long id) { repo.deleteById(id); }
// Bộ ba tóm gọn:
// @Cacheable — ĐỌC: có trong cache? trả luôn : chạy rồi cất
// @CacheEvict — XÓA: dữ liệu gốc đổi → đuổi bản cache
// @CachePut — GHI: luôn chạy method, luôn ghi đè cache
// Quên evict khi update là bug cache số 1: DB đã mới, cache vẫn cũ — user thấy maBộ ba một hơi thở:
@Cacheableđọc (có thì trả, không thì chạy rồi cất),@CacheEvictxóa khi nguồn đổi,@CachePutluôn chạy và ghi đè — làm nóng cache ngay lúc ghi, không chờ lần miss sau.Bug cache số 1 toàn ngành: update quên evict — DB đã mới, cache vẫn cũ, user thấy dữ liệu ma. Kỷ luật: mọi method ghi lên dữ liệu có cache phải mang
@CacheEvict/@CachePuttương ứng.Đừng quên cache phái sinh: sửa một sản phẩm cũng phải đuổi cache trang chủ nếu trang chủ liệt kê top sản phẩm —
@Cachinggom nhiều evict một chỗ; nghi ngờ thìallEntries = truecho lành.
TTL & Caffeine — cache có hạn dùng
# Cache mặc định của Boot: ConcurrentHashMap — CHỈ để demo
# ❌ không TTL (cache "vĩnh viễn"), không giới hạn size (OutOfMemory hẹn giờ)
# Sản xuất tối thiểu: Caffeine — LRU cache công nghiệp, in-memory
# (đúng thuật toán LRU bạn tự viết 10 dòng ở bài Java Ngày 35!)
# dependency: com.github.ben-manes.caffeine:caffeine
spring:
cache:
type: caffeine
cache-names: sanPham, tyGia, trangChu
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
# └─ tối đa 10k entry (LRU đuổi bớt) · TTL 10 phút
# TTL (Time-To-Live) — chốt bảo hiểm CUỐI CÙNG:
# evict chủ động có thể sót (đường ghi khác, dữ liệu ngoài hệ thống)
# → TTL đảm bảo: tệ nhất, dữ liệu cũ chỉ sống thêm 10 phút
# Chọn TTL = trả lời "chịu được dữ liệu cũ bao lâu?" — câu hỏi NGHIỆP VỤ:
# tỷ giá: 1 phút · danh mục: 10 phút · cấu hình hệ thống: 1 giờ
# Giới hạn của Caffeine: cache TRONG TỪNG instance
# → 3 instance = 3 cache lệch nhau; restart là mất sạch
# → cache DÙNG CHUNG + sống sót restart: Redis — chính là bài NGÀY MAI
# Đo trước khi tin (điệp khúc của series):
management:
endpoints.web.exposure.include: caches,metrics
# /actuator/metrics/cache.gets?tag=cache:sanPham → hit ratio
# hit ratio < 50%? — cache sai chỗ hoặc TTL quá ngắn: gỡ đi còn hơnCache mặc định của Boot (
ConcurrentHashMap) không TTL, không giới hạn size — chỉ để demo; sản xuất tối thiểu là Caffeine:maximumSizevới LRU (đúng thuật toán bạn tự viết mười dòng bài Java Ngày 35!) +expireAfterWrite.TTL là chốt bảo hiểm cuối: evict chủ động có thể sót (đường ghi khác, dữ liệu đổi ngoài hệ thống) — TTL cam kết dữ liệu cũ chết trong hạn; chọn TTL là câu hỏi nghiệp vụ: "chịu được dữ liệu cũ bao lâu?"
Giới hạn của in-memory: cache riêng từng instance — ba instance ba cache lệch nhau, restart mất sạch; và điệp khúc đo lường: actuator lộ hit ratio — dưới 50% thì cache đang sai chỗ, gỡ đi còn hơn giữ.
Bốn cái bẫy của caching
Self-invocation — đúng điểm mù của
@Transactional(Ngày 22): method trong class gọi method@Cacheablecùng class là gọi thẳng, không qua proxy — cache câm lặng như chưa từng tồn tại. Tách class hoặc tự inject.Cache penetration: hỏi mãi một id không tồn tại —
unlesschặn cất null thì mỗi lần hỏi là một lần xuống DB; cân nhắc cất "kết quả rỗng" có TTL ngắn nếu bị hỏi ác ý.Stale read là bản chất, không phải bug: cache là đánh đổi độ tươi lấy tốc độ — quyết định có ý thức bằng TTL + evict, và đừng cache thứ không chịu cũ nổi một giây (số dư tài khoản!).
Cache stampede: entry hot hết hạn, nghìn request cùng ùa xuống DB — Caffeine đỡ bằng cách khóa nạp theo key trong một instance; nhiều instance cần lời giải tầng trên (khóa phân tán, làm nóng trước — hẹn Redis Ngày 37).
Bài tập nhỏ
Thêm cache vào Task Manager:
@CacheablechotimTheoId— bật log SQL, gọi hai lần, xác nhận câu SQL thứ hai không xuất hiện.Cố tình update không evict và chứng kiến dữ liệu ma; rồi thêm
@CacheEvictsửa lại — bug này gặp một lần trong môi trường an toàn còn hơn gặp trong production.Tái hiện self-invocation: gọi method
@Cacheabletừ method cùng class, xem cache không hoạt động — rồi tách sang service khác.Cấu hình Caffeine
expireAfterWrite=30s, chờ 31 giây và xác nhận cache miss — TTL vận hành trước mắt bạn.Mở
/actuator/metrics/cache.getssau một loạt gọi — tính hit ratio bằng tay và đối chiếu.
Kết luận
Gói bài trong bốn ý: tốc độ lớn nhất là không truy vấn — @EnableCaching + @Cacheable biến method thành cache trong hai dòng, proxy AOP đứng gác; key là danh tính — SpEL key = "#id", unless chặn null, kết quả phải bất biến; cache phải thật — @CacheEvict/@CachePut đi kèm mọi đường ghi, TTL của Caffeine làm chốt bảo hiểm cuối; và cache là đánh đổi có ý thức — biết bốn cái bẫy trước khi dán annotation. Nhưng Caffeine chết cùng instance và không chia sẻ được — Ngày 37 ta đưa cache ra ngoài process: Redis — RedisTemplate, cache store dùng chung và session store. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


