@Cacheable không cache - rồi khi cache được, nó trả nhầm đồ của người khác
Cache cũng chỉ là một cái Map, và khoá mặc định của nó chỉ gồm tham số. Hai method khác nhau cùng cacheNames là cùng một ô.
Thằng em quay lại, và lần này nó làm mình hơi bất ngờ.
@Cacheable mà cache không ăn. Nhưng khoan, anh đừng nói gì - để em tự soi đã. Rồi nó gửi liền ba dòng, như đọc bài kiểm tra:
final, không private - vỏ dựng được, bài một. Em gọi từ controller qua bean inject, không phải this - đi qua cửa, bài hai. Còn cái vụ anh kể về @EnableAsync… em vừa check: em chưa có @EnableCaching. Người trực quầy chưa được mời. Đúng không? Đúng. Đúng cả ba. Nó thêm @EnableCaching, cache chạy, và mình chưa kịp trả lời tin nhắn đầu thì nó đã tự xong. Thằng em của một năm trước sẽ hỏi “hay Spring ghét em”. Thằng em bây giờ tự loại trừ ba nghi phạm trong mười phút. Mental model đang làm việc.
Nhưng nó nhắn tiếp, và lần này giọng khác hẳn:
/users/5/profile của em… thỉnh thoảng trả về danh sách đơn hàng. Không phải lỗi 500. Đôi khi là ClassCastException, đôi khi là JSON của một thứ hoàn toàn khác, trông vẫn hợp lệ. Chập chờn, không tái hiện được đều. Em nghi ngờ chính em. Code của nó đây, và bạn thử tìm bug trước khi đọc tiếp:
@Service
public class UserService {
@Cacheable("users")
public UserProfile getProfile(Long id) { ... }
@Cacheable("users")
public List<Order> getOrders(Long id) { ... }
}
Hai method, cùng cache tên "users", cùng nhận một Long. Trông vô hại. Nhiều codebase ngoài đời có đúng đoạn này, chạy nhiều tháng không ai nghi ngờ.
Để thấy bug, phải mở cái cache ra xem nó là gì. Và nó là thứ bạn đã gặp từ bài đầu tiên của series: một cái Map. Lại là một cái Map. @Cacheable chỉ là một advice ở cái vỏ, và việc của advice đó gói gọn trong ba bước: tính một cái key từ tham số, tra Map - có thì trả luôn và method của bạn không chạy; không có thì chạy method, nhét kết quả vào Map dưới key đó.
Toàn bộ số phận của bạn nằm ở chữ “tính key từ tham số”. Vậy key được tính thế nào?
Người tính key mặc định tên là SimpleKeyGenerator, và luật của nó ngắn đến giật mình: không tham số - một key rỗng dùng chung; một tham số - chính tham số đó là key; nhiều tham số - gói tất cả vào một SimpleKey.
Đọc lại luật đó một lần nữa, chậm thôi, và để ý thứ không có mặt: tên method. Tên class. Kiểu trả về. Không gì cả. Key của getProfile(5L) là 5. Key của getOrders(5L) cũng là 5. Cùng cache "users", cùng key 5 - cùng một ô trong Map.
Giờ thì đoạn phim quay chậm: user 5 gọi getProfile trước - Map ghi 5 → UserProfile. Ai đó gọi getOrders(5L) - proxy tính key, ra 5, tra Map, thấy có - và trả luôn cái UserProfile đó về nơi đang chờ một List<Order>. Method getOrders không chạy. Không dòng SQL nào. Nếu Jackson serialize được thì client nhận một JSON sai mà hợp lệ; nếu code chạm vào kiểu thì ClassCastException. Còn thứ tự ai-gọi-trước quyết định ai bị nhiễm - vì thế mà nó chập chờn, vì thế mà không tái hiện được đều.
Không có bug ở đâu cả. SimpleKeyGenerator chạy đúng như nó được viết. Cái Map chạy đúng như mọi cái Map. Chỉ là hai method đang bỏ đồ vào cùng một ngăn tủ, và cái tủ thì không hỏi đồ này của ai.
Nhưng khoan. Sao Spring lại thiết kế key không có tên method? Trông như một lỗi hiển nhiên mà.
Không phải lỗi - là một ý đồ bị hiểu ngược. Trong đầu người thiết kế, mỗi cache name là một Map riêng cho một loại dữ liệu: cache "users" chứa user, key là user id; cache "orders" chứa đơn hàng, key là order id. Theo cách nhìn đó, key đâu cần tên method - trong một tủ chỉ chứa một loại đồ, mã số là đủ. Tên cache là cái tủ, không phải cái nhãn nhóm.
Còn trong đầu dev, "users" đọc như một cái nhãn: “mấy thứ liên quan tới user thì dán chung tag này”. Hai cách đọc, một cái tên. Và cái Map thì chỉ nghe theo cách đọc thứ nhất.
Bạn có thấy quen không? Bài @Controller mùa trước có một câu mình bảo bạn nhớ: trong hệ có quầy nhận-tất, không tồn tại lỗi “không ai phục vụ”, chỉ tồn tại phục vụ sai - và phục vụ sai đáng sợ hơn, vì kết quả trông hoàn toàn hợp lệ. Hôm nay đúng câu đó, đổi hiện trường: không-cache chỉ làm bạn chậm; nhầm-cache đưa bạn dữ liệu của người khác, đóng gói tử tế. Cache miss là lỗi ồn ào của thế giới cache. Cache trúng nhầm mới là thứ giết bạn trong im lặng.
Fix thì đi từ gốc: trả lại đúng ý đồ một tủ một loại đồ.
@Cacheable("userProfiles")
public UserProfile getProfile(Long id) { ... }
@Cacheable("userOrders")
public List<Order> getOrders(Long id) { ... }
Đó là fix đúng nhất, và gần như luôn đủ. Hai cách còn lại - khai key bằng SpEL (key = "'profile:' + #id") hoặc viết KeyGenerator riêng có kèm tên method - chỉ nên dùng khi bạn có lý do thật sự để nhiều method dùng chung một tủ, ví dụ để xóa chung một phát bằng @CacheEvict. Dùng chung tủ được, nhưng lúc đó bạn phải tự gánh phần danh tính mà key mặc định không gánh.
Và đó là chữ đáng nhớ nhất hôm nay: danh tính. Cái Map không biết ai gửi đồ, không nhớ mặt method nào bỏ vào. Với nó, bạn là cái key của bạn - không hơn một ký tự. Key thiếu danh tính thì hai kẻ khác nhau thành một người, và mọi thứ trông vẫn chạy, cho tới khi một bên mở tủ lấy nhầm đời của bên kia. Cứ giữ chữ “danh tính” đó trong túi. Sang mùa Hibernate, nó sẽ quay lại trong một hiện trường mà bạn không ngờ tới - ngay trong một cái HashSet bạn dùng hằng ngày - và lần đó thứ đánh mất danh tính không phải cache key, mà là chính entity của bạn.
Muốn tự thấy tận mắt, ba mươi giây: đặt breakpoint trong SimpleKeyGenerator.generateKey(), gọi lần lượt hai method với cùng một id, và nhìn hai lời gọi khác nhau đúc ra cùng một key. Hoặc trần trụi hơn, in thẳng ruột cái tủ:
var cache = (ConcurrentMapCache) cacheManager.getCache("users");
log.info("{}", cache.getNativeCache()); // {5=UserProfile{...}} - một ngăn, hai chủ
Còn nguyên lý, thứ sống lâu hơn @Cacheable: một kho chứa dùng chung chỉ an toàn khi key mang đủ danh tính của người gửi. Composite key trong database, partition key trong Kafka, cache key trong Redis, thậm chí tên file trong thư mục tạm - cùng một luật. Kho không bao giờ sai. Chỉ có key kể thiếu chuyện.
Thằng em đọc xong, gửi đúng một câu, và mình nghĩ nó xứng đáng đứng làm câu chốt của cả mùa proxy này:
Đừng tin thứ mình không nhìn thấy đang đứng giữa.
this, đừng tin là người trực đã được mời. Giờ thêm: kể cả khi tất cả chạy đúng - cái Map vẫn chỉ biết mỗi cái key. Cả mùa nay hóa ra anh chỉ dạy em một câu: đừng tin thứ mình không nhìn thấy đang đứng giữa. Đúng rồi đó. Và mùa sau, em sẽ gặp một thứ đứng giữa còn kín tiếng hơn nhiều. 😃
Tài nguyên tập 6
Cheat-sheet tập 6 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 1.4JDK 17.mvn spring-boot:run → Episode14Runner in khối [EP 1.4]: gọi qua this nên cache không ăn, và getUserName(99) với getUserAddress(99) đụng chung một key.Vị trí trong Lớp Trung Gian Vô Hình
Bài này trả lời
- Cache trả về dữ liệu của tham số khác