Spring Cache — một annotation, một proxy, ba cái bẫy
@Cacheable, @CachePut, @CacheEvict chạy qua AOP proxy, nên self-call thì cache im lặng không hoạt động. Thêm key strategy và chống cache stampede.
TL;DR: Spring không tự cài cache, nó chỉ định nghĩa hợp đồng (Cache, CacheManager) rồi cắm implementation thật vào, @Cacheable chạy qua AOP proxy giống hệt cơ chế đứng sau @Transactional và @PreAuthorize. Ba cái bẫy: key strategy sai làm cache miss liên tục hoặc trả nhầm dữ liệu; self-call bypass proxy nên cache lặng lẽ không chạy, không lỗi; và cache stampede khi một key hot hết hạn, hàng loạt request cùng lao vào DB — sync = true khoá lại chỉ một request chạy method thật.
TaskFlow có một endpoint báo cáo: GET /api/projects/{id}/stats trả tỉ lệ hoàn thành, số task quá hạn. Query aggregation phía sau mất trung bình 400ms mỗi lần gọi, và dashboard gọi lại nó nhiều lần một phút cho cùng project. Dữ liệu bên dưới thì chỉ đổi vài lần một ngày.
Sửa nhanh nhất trông như thêm đúng một dòng: @Cacheable. Đúng, nhưng đúng chỗ đó mở ra ba cái bẫy annotation không tự cảnh báo — vì nó dùng chung cơ chế proxy với mọi annotation đã học trong khoá này.
1. Cache abstraction là gì — hợp đồng, không phải implementation
Cache (tiếng Việt: bộ nhớ đệm) là vùng lưu tạm kết quả một phép tính tốn kém, để lần gọi sau lấy thẳng thay vì tính lại. Spring không tự viết cơ chế lưu trữ này — nó định nghĩa hai interface làm hợp đồng:
org.springframework.cache.Cache— một cache cụ thể, cóget(key),put(key, value),evict(key).org.springframework.cache.CacheManager— nhà máy tạo và quản lý cácCache, theo tên ("projectStats","userProfile"...).
Bản thân hai interface này không lưu gì cả. Bạn cắm một implementation thật vào: ConcurrentMapCacheManager (in-memory, chỉ hợp dev/test), CaffeineCacheManager hoặc RedisCacheManager (bài sau). Code gọi @Cacheable không cần biết implementation nào đang chạy phía sau, nên đổi Caffeine sang Redis không sửa dòng nào ở tầng service. Đây chính là facade pattern: một mặt tiền thống nhất che đi nhiều cách hiện thực khác nhau.
@EnableCaching trên class @Configuration là công tắc bật cơ chế này: Spring quét bean tìm annotation cache rồi bọc từng bean có annotation trong một proxy AOP. Thiếu @EnableCaching, annotation vẫn nằm đó nhưng không ai đọc — method chạy như chưa từng có @Cacheable.
2. @Cacheable — luồng chạy qua proxy
Thêm đúng một dòng vào ProjectStatsService:
@Service
public class ProjectStatsService {
private final TaskRepository taskRepository;
public ProjectStatsService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Cacheable("projectStats")
public ProjectStats getProjectStats(Long projectId) {
// query aggregation: group by status, tinh % hoan thanh, mat ~400ms
return taskRepository.computeStats(projectId);
}
}
Bean thật nhận được từ container không phải object bạn viết — nó là một proxy bọc bên ngoài (JDK dynamic proxy hoặc CGLIB, đã học ở bài AOP Proxy). Luồng chạy khi caller gọi getProjectStats(projectId) qua proxy:
flowchart LR
A[Caller goi qua proxy] --> B[Proxy tinh cache key tu tham so]
B --> C{Key co san trong cache?}
C -- "Hit" --> D[Tra ket qua tu cache]
C -- "Miss" --> E[Proxy goi method that: query DB 400ms]
E --> F[Ghi ket qua vao cache theo key]
F --> G[Tra ket qua ve caller]
D --> GĐiểm quan trọng nhất: khi trúng cache (Hit), method thật không hề chạy — proxy trả thẳng giá trị đã lưu. Đặt breakpoint bên trong method mà không thấy nó dừng lại dù request vẫn ra kết quả đúng không phải lỗi debugger, đó chính là cache đang hoạt động đúng thiết kế.
3. Key strategy — chỗ mặc định phản chủ
Proxy cần một khóa (key) để tra cache. Không truyền key tường minh, Spring dùng SimpleKeyGenerator với quy tắc:
| Số tham số của method | Key mặc định |
|---|---|
| Không tham số | SimpleKey.EMPTY — mọi lần gọi chung một key |
| Đúng một tham số | Giá trị tham số đó, dùng thẳng làm key |
| Từ hai tham số trở lên | Một SimpleKey gộp tất cả tham số theo đúng thứ tự |
Method một tham số như getProjectStats(Long projectId) thì key mặc định chính là projectId — ổn. Thêm một tham số nữa là bẫy đầu tiên xuất hiện:
// SAI: requestedBy khong lien quan gi den du lieu tra ve, nhung van gop vao key
@Cacheable("projectStats")
public ProjectStats getProjectStats(Long projectId, User requestedBy) {
return taskRepository.computeStats(projectId);
}
SimpleKeyGenerator gộp (projectId, requestedBy) thành một SimpleKey. Hai user khác nhau gọi cùng projectId tạo ra hai key khác nhau — cache bị nhân bản vô ích dù dữ liệu trả về giống hệt nhau. Tệ hơn: nếu User không override equals/hashCode, mọi request đều miss vì hai instance không bao giờ được coi là bằng nhau, dù cùng một user thật gọi lại.
Cách tường minh nhất: SpEL trong thuộc tính key, chỉ đưa đúng thứ định danh dữ liệu vào key.
@Cacheable(value = "projectStats", key = "#projectId")
public ProjectStats getProjectStats(Long projectId, User requestedBy) {
return taskRepository.computeStats(projectId);
}
Cache name không tự phân biệt method nào ghi vào nó. Hai method khác nhau cùng value = "projectStats" và cùng key = "#projectId" sẽ ghi đè lẫn nhau trong cùng một cache:
@Cacheable(value = "projectStats", key = "#projectId")
public Project getProject(Long projectId) { ... }
@Cacheable(value = "projectStats", key = "#projectId")
public ProjectStats getProjectStatsSummary(Long projectId) { ... }
Cả hai đều dùng key 42 cho project 42 nhưng trả về hai kiểu dữ liệu khác nhau. Method ghi sau đè lên entry của method kia — lần đọc tiếp theo có thể nhận nhầm kiểu dữ liệu. Đặt tên cache theo đúng method/domain để mỗi cache name chỉ có một "chủ".
4. @CachePut so với @CacheEvict
Hai annotation giải quyết hai bài toán khác nhau khi dữ liệu thay đổi:
@CachePut(value = "projectStats", key = "#projectId")
public ProjectStats recomputeStats(Long projectId) {
return taskRepository.computeStats(projectId); // LUON chay, roi ghi lai cache
}
@CacheEvict(value = "projectStats", key = "#projectId")
public void onTaskStatusChanged(Long projectId) {
// xoa entry cu, lan doc tiep theo se mien la cache miss va tinh lai
}
@CachePut luôn chạy method rồi ghi kết quả mới vào đúng key — khác @Cacheable, nó không bao giờ trả thẳng từ cache. Dùng khi bạn vừa có kết quả mới toanh và muốn cache phản ánh ngay, không cần một round-trip đọc lại.
@CacheEvict chỉ xóa, không tính gì cả — dùng khi biết cache đã cũ nhưng chưa cần giá trị mới ngay, để lần đọc tiếp theo tự miss và tính lại. Đây là lựa chọn đúng cho onTaskStatusChanged: task đổi trạng thái không cần trả ProjectStats mới ngay, chỉ cần dashboard lần sau không thấy số cũ.
allEntries = true xóa toàn bộ entry trong cache name đó, bỏ qua key — chính đáng khi một thao tác ảnh hưởng nhiều key cùng lúc mà liệt kê từng key tốn kém hơn xóa sạch, ví dụ import hàng loạt task làm thay đổi thống kê nhiều project cùng lúc.
5. Vì sao self-call làm cache lặng lẽ không hoạt động?
Bẫy quen thuộc nếu bạn từng gặp @Transactional hoặc @PreAuthorize không tác dụng khi gọi từ method khác trong cùng class — cơ chế gốc là một, đã mổ kỹ ở bài AOP Proxy của spring-core: container chỉ chèn logic tại lời gọi đi qua proxy. Gọi trực tiếp this.method() thì lời gọi đó không bao giờ chạm tới proxy.
@Service
public class ProjectStatsService {
@Cacheable(value = "projectStats", key = "#projectId")
public ProjectStats getProjectStats(Long projectId) {
return taskRepository.computeStats(projectId);
}
public DashboardSummary getDashboard(Long projectId) {
// SAI: this.getProjectStats() la self-call, khong di qua proxy
ProjectStats stats = this.getProjectStats(projectId);
return DashboardSummary.from(stats);
}
}
flowchart TB
Client[Controller ben ngoai] -- "goi qua proxy" --> Proxy[ProjectStatsService proxy]
Proxy -- "cache duoc kiem tra" --> Real[ProjectStatsService that]
Real -- "this.getProjectStats - self call" --> Real
Real -. "khong bao gio quay lai Proxy" .-> ProxyGọi getDashboard từ bên ngoài đúng như bình thường: không exception, không warning trong log. Nhưng this.getProjectStats(projectId) gọi thẳng vào object thật, bỏ qua proxy — 400ms query chạy lại mỗi lần, cache annotation thành trang trí vô hại. Bẫy này nguy hiểm hơn lỗi cú pháp vì mọi thứ trông đúng, chỉ chậm hơn dự kiến.
Ba lối thoát, không ngang giá:
- Tách sang bean khác — sạch nhất.
DashboardServiceinjectProjectStatsServicequa constructor rồi gọiprojectStatsService.getProjectStats(projectId)— lời gọi này đi qua đúng object mà container quản lý (proxy), không phảithis. - Tự inject chính mình (
@Autowired ProjectStatsService self, rồi gọiself.getProjectStats(...)) — chạy được nhưng xấu: đọc code khó hiểu vì sao một class phụ thuộc chính nó, dễ bị xóa nhầm lúc refactor. - Đẩy annotation lên entry point — cache thẳng ở
getDashboardnếu đó là điểm vào duy nhất. NếugetProjectStatscòn được gọi trực tiếp từ chỗ khác thì đường gọi đó mất cache.
6. sync = true chống cache stampede
Một key hot — projectStats của project được xem nhiều nhất — hết hạn đúng lúc dashboard đang có hàng chục request đồng thời cho cùng project đó. Tất cả cùng miss trong một khoảnh khắc, vì chưa request nào kịp ghi lại cache. Hậu quả: hàng chục query 400ms cùng lúc đập vào DB để tính lại cùng một kết quả — hiện tượng gọi là cache stampede (thundering herd), có thể kéo theo timeout dây chuyền sang request khác dùng chung connection pool.
@Cacheable(value = "projectStats", key = "#projectId", sync = true)
public ProjectStats getProjectStats(Long projectId) {
return taskRepository.computeStats(projectId);
}
sync = true khiến proxy khóa lại theo key: chỉ một request được chạy method thật cho một key đang miss, các request khác cùng key phải đợi thay vì cùng lao vào DB, rồi nhận thẳng kết quả vừa tính xong. Đánh đổi là độ trễ chờ khóa; đổi lại DB chỉ nhận đúng một query thay vì hàng chục. Giới hạn cần biết: sync = true không dùng chung được với unless.
Pitfall thường gặp
❌ Cache method trả về null mà không loại trừ:
@Cacheable("projectStats")
public ProjectStats getProjectStats(Long projectId) {
return taskRepository.computeStats(projectId); // co the tra null neu project khong ton tai
}
✅ Mặc định, Spring vẫn cache cả giá trị null — request tiếp theo trúng cache và trả null ngay, không query lại xác nhận project có tồn tại hay chưa. Dùng unless = "#result == null" để loại trừ.
❌ Cache dữ liệu phụ thuộc người dùng mà quên đưa danh tính vào key — pitfall nguy hiểm nhất trong bài, hậu quả là rò dữ liệu:
@Cacheable("myTasks")
public List<Task> getMyTasks() {
Long currentUserId = CurrentUser.id(); // doc tu context noi bo, KHONG nam trong tham so
return taskRepository.findByAssignee(currentUserId);
}
✅ Method không tham số nên key mặc định là SimpleKey.EMPTY — mọi user tra cùng một entry. User A gọi trước, cache lưu task của A; user B gọi ngay sau, nhận thẳng task của A. Bất kỳ dữ liệu đọc theo identity ngầm (context nội bộ, session) mà không nằm trong tham số method đều phải đưa vào key tường minh, ví dụ truyền userId làm tham số rồi key = "#userId".
❌ Không đặt TTL, cache sống mãi: ConcurrentMapCacheManager mặc định không tự hết hạn entry — dữ liệu có thể cũ vô thời hạn nếu không có @CacheEvict chủ động dọn. Bài sau (Caffeine so với Redis) mới cấu hình TTL cụ thể.
Đào sâu
- Spring Framework Reference — Caching Abstraction — tài liệu chính chủ định nghĩa
Cache/CacheManager, quy tắcSimpleKeyGenerator, và giới hạn self-invocation của cơ chế proxy-based AOP vừa học ở mục 5.
Ghi chú: nguồn xác nhận mọi hành vi mặc định trong bài. Đáng đọc khi cần cấu hình CacheResolver tùy biến hoặc condition/unless phức tạp hơn phạm vi bài này.
Liên hệ các bài khác
- Bài AOP Proxy — spring-core — nguồn gốc cơ chế proxy dùng lại xuyên suốt bài này: JDK dynamic proxy so với CGLIB, self-call bypass giải thích ở tầng cơ chế.
- Caffeine so với Redis — bài tiếp theo, chọn implementation thật đứng sau
CacheManager. @Asyncvà executor — cùng cơ chế proxy, bẫy khác: exception bị nuốt khi method trả vềvoid.@Scheduled— cùng họ proxy, câu hỏi khác: chống chạy trùng job khi nhiều instance.
Tóm tắt
SimpleKeyGeneratorgộp mọi tham số vào key mặc định — tham số không liên quan danh tính hoặc thiếuequals/hashCodelà nguồn cache-miss âm thầm phổ biến nhất.- Hai method chung cache name và key sẽ ghi đè entry của nhau — đặt tên cache theo đúng method/domain, không dùng chung cho hai kiểu dữ liệu.
sync = truechặn cache stampede: chỉ một request chạy method thật mỗi key miss, các request khác đợi thay vì cùng query DB.- Pitfall nặng nhất là bảo mật: quên đưa identity người dùng vào key khi dữ liệu phụ thuộc người gọi thì user này có thể nhận thẳng dữ liệu cache của user khác.
- Tách sang bean khác là lối thoát sạch nhất cho self-call; self-inject chạy được nhưng khó đọc, dễ bị xóa nhầm lúc refactor.
Tự kiểm tra
Q1Vì sao thêm @Cacheable đúng cú pháp vào getProjectStats vẫn không tăng tốc endpoint dashboard, nếu getDashboard gọi nó bằng this.getProjectStats(...) trong cùng class?▸
Bean thật container quản lý là một proxy bọc ngoài ProjectStatsService, và logic kiểm tra cache chỉ nằm trong proxy đó. Gọi this.getProjectStats(...) chạy trực tiếp trên object gốc, không đi qua proxy, nên cache check không bao giờ được thực thi — method thật chạy lại toàn bộ mỗi lần, không exception, không log cảnh báo, chỉ chậm hơn dự kiến.
Q2Method getProject(Long id) và getProjectStatsSummary(Long id) cùng dùng value = "projectStats" và key = "#id". Điều gì xảy ra khi cả hai cùng được gọi với id = 42?▸
getProject(Long id) và getProjectStatsSummary(Long id) cùng dùng value = "projectStats" và key = "#id". Điều gì xảy ra khi cả hai cùng được gọi với id = 42?Cả hai ghi vào đúng cùng một entry — cache name không tự phân biệt method nào tạo ra nó, chỉ là namespace phẳng theo key. Method ghi sau đè lên giá trị của method trước; lần đọc tiếp theo có thể nhận nhầm kiểu dữ liệu. Fix đúng là tách cache name theo từng method/domain, không dùng chung tên cho hai kiểu dữ liệu khác nhau.
Q3getProjectStats(Long projectId, User requestedBy) dùng key mặc định (khong khai bao key). Hai user khac nhau cung goi voi projectId = 7 co trung mot cache entry khong? Vi sao?▸
Không. Hai tham số nên SimpleKeyGenerator gộp cả hai vào một SimpleKey — hai user khác nhau ra hai key khác nhau dù projectId giống hệt, cache bị nhân bản vô ích. Cách đúng: khai key = "#projectId" tường minh.
Q4Phân biệt khi nào dùng @CachePut và khi nào dùng @CacheEvict sau khi một task đổi trạng thái xong.▸
@CachePut hợp khi vừa có kết quả mới và muốn cache phản ánh ngay — method luôn chạy rồi ghi đè entry. @CacheEvict hợp khi chỉ cần đảm bảo cache không giữ dữ liệu cũ, chưa cần giá trị mới ngay — chỉ xóa, để lần đọc tiếp theo tự miss và tính lại. Task đổi trạng thái không cần trả kết quả mới ngay lúc đó nên evict là lựa chọn nhẹ hơn.
Q5sync = true giải quyết cache stampede bằng cơ chế gì bên dưới? Đánh đổi là gì?▸
Proxy khóa thực thi theo key: request đầu tiên miss thì chạy method, request khác cùng miss key đó phải đợi thay vì chạy song song, rồi nhận thẳng kết quả. Đổi lại độ trễ chờ khóa lấy đúng một query DB thay vì hàng chục.
Q6getMyTasks() không nhận tham số, đọc user hiện tại từ SecurityContext bên trong method, rồi cache bằng @Cacheable("myTasks") không khai key. User A gọi trước, User B gọi ngay sau — User B nhận được gì? Vì sao đây là pitfall nguy hiểm nhất trong bài?▸
User B nhận đúng danh sách task của User A. Key mặc định của method không tham số là SimpleKey.EMPTY — một key duy nhất cho mọi lần gọi, trong khi identity thật lại đọc ngầm từ context nội bộ nên không ảnh hưởng tới key. Nguy hiểm nhất vì hậu quả là rò dữ liệu riêng tư sang user khác, âm thầm, không log bất thường. Fix: đưa identity vào key tường minh, ví dụ key = "#userId".
Bài tiếp theo: Caffeine so với Redis
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên