Hãy tưởng tượng một tờ giấy dán trên cửa tủ lạnh ghi "còn 3 hộp sữa". Liếc tờ giấy nhanh hơn nhiều so với mở tủ, cúi xuống, đếm từng hộp — đó là toàn bộ lý do cache tồn tại. Nhưng cái bẫy nằm ngay đó: nếu ai trong nhà lấy một hộp sữa mà quên sửa tờ giấy, thì tờ giấy nói dối bạn — và bạn tin nó, vì bạn đâu có mở tủ ra kiểm. Mọi rắc rối của cache gói gọn trong đúng một câu hỏi: ai sửa tờ giấy khi cái tủ thay đổi? Cache là cách rẻ nhất để làm hệ thống nhanh lên, và là cách nhanh nhất để có dữ liệu sai. Bài này về cả hai mặt.
Bật và đo
@EnableCaching // trên lớp cấu hình
@Service
@CacheConfig(cacheNames = "sanpham")
class Bo {
@Cacheable(key = "#id")
public String lay(String id) { /* ngủ 100 ms */ }
}
gọi lần 1: 110,8 ms | lần 2: 0,31 ms | số lần chạy thật = 1
Nhanh hơn 357 lần, và phương thức chỉ chạy đúng một lần.
@CacheEvict xoá khỏi cache:
sau @CacheEvict, gọi lại: 101,6 ms | số lần chạy thật = 2
Đúng như mong đợi: phương thức chạy lại. Đó là lúc bạn xé tờ giấy cũ để viết lại từ đầu.
Cái bẫy thứ nhất: tự gọi
public String layQuaChinhMinh(String id) { return lay(id); } // gọi thẳng
TỰ GỌI trong cùng lớp, 3 lần -> số lần chạy thật = 3 (mong đợi 1)
Cache không hoạt động. Ba lần gọi là ba lần chạy thật.
Đây là đúng cơ chế đã đo ở bài 26 với @Transactional: Spring cài đặt bằng proxy, và lời gọi từ bên trong cùng lớp không đi qua proxy.
Cùng lý do đó, @Cacheable không hoạt động trên phương thức private, final, hay static. Không có cảnh báo — chỉ là cache không bao giờ trúng.
Chuyện phụ: trường public trên bean bị proxy trả về null
Tôi gặp cái này ngay trong lúc viết phép đo. Ban đầu tôi để bộ đếm là trường công khai:
@Service class Bo { public final AtomicInteger soLanChay = new AtomicInteger(); }
// ...
bo.soLanChay.get();
NullPointerException: Cannot invoke "AtomicInteger.get()" because "this.bo.soLanChay" is null
Proxy CGLIB là một lớp con do Spring sinh. Phương thức được ghi đè và uỷ nhiệm sang đối tượng thật, nhưng trường thì không — proxy có bộ trường riêng của nó, chưa bao giờ được khởi tạo.
Nên đọc trường trực tiếp trên một bean bị proxy cho ra null, còn gọi getter thì đúng. Đổi sang dem() là chạy.
Bài học chung: với bean bị proxy, luôn đi qua phương thức. Điều này cũng giải thích vì sao mã dùng phản chiếu để đọc trường của bean hay hỏng theo kiểu khó hiểu.
Cái bẫy thứ hai: condition và unless
@Cacheable(key = "#id", condition = "#id.length() > 3")
condition không thoả, 2 lần -> số lần chạy thật = 2
condition được xét trước khi gọi phương thức. Không thoả thì bỏ qua cache hoàn toàn.
@Cacheable(key = "#id", unless = "#result == null")
unless result==null, 2 lần -> số lần chạy thật = 2
unless xét sau khi có kết quả, và nó dùng để không lưu kết quả đó.
Khác biệt quan trọng: condition dùng tham số, unless dùng #result.
Và mặc định, Spring có lưu giá trị null. Đó thường là điều bạn muốn — nó chặn kiểu tấn công hỏi liên tục những id không tồn tại. Nhưng nếu dữ liệu sắp có thì bạn đang lưu một câu trả lời sai.
Cái bẫy thứ ba: khoá cache
Mặc định, khoá được sinh từ tất cả tham số. Nghĩa là:
@Cacheable
public List<Don> tim(String tu, Pageable p, HttpServletRequest req) { }
HttpServletRequest là một phần của khoá, và mỗi request là một đối tượng khác nhau — cache không bao giờ trúng, mà bộ nhớ thì cứ đầy lên.
Luôn khai khoá tường minh:
@Cacheable(key = "#tu + ':' + #p.pageNumber")
Và nếu tham số là đối tượng, nó phải có equals và hashCode đúng — đúng hợp đồng ở bài 18 sê-ri Java. Không có thì mỗi lần gọi là một khoá mới.
@CachePut và @CacheEvict
@CachePut(key = "#id") // LUÔN chạy phương thức, rồi cập nhật cache
public SanPham capNhat(String id, SanPham moi) { }
@CacheEvict(key = "#id") // xoá một khoá
@CacheEvict(allEntries = true) // xoá cả cache
Điểm cần nhớ: @CachePut luôn gọi phương thức; @Cacheable thì không nếu đã có trong cache. Đặt cả hai trên một phương thức là mâu thuẫn.
@CacheEvict(beforeInvocation = true) xoá trước khi chạy — dùng khi phương thức có thể ném ngoại lệ mà bạn vẫn muốn cache bị dọn.
Và cái bẫy hay gặp: xoá cache trong giao dịch chưa commit. Cache bị xoá ngay, nhưng nếu giao dịch rollback thì dữ liệu cũ vẫn còn trong CSDL, và request tiếp theo nạp lại đúng giá trị cũ vào cache. Với @CachePut thì tệ hơn: cache mang giá trị chưa bao giờ được commit.
Cách chữa: dọn cache sau khi commit, bằng @TransactionalEventListener(AFTER_COMMIT).
Chọn nhà cung cấp
Mặc định của Spring Boot khi không có gì khác là ConcurrentMapCacheManager — một ConcurrentHashMap, không có hết hạn, không có giới hạn kích thước. Nó chỉ lớn lên, và cuối cùng là hết heap.
Dùng cho thử nghiệm, không dùng cho sản xuất.
Caffeine — trong bộ nhớ, có hết hạn và giới hạn:
spring:
cache:
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
Nhanh nhất, nhưng mỗi bản sao ứng dụng có cache riêng. Với ba container, một lần xoá cache chỉ xoá ở một chỗ — hai chỗ kia vẫn trả dữ liệu cũ. Đây là lý do phổ biến nhất của "sửa rồi mà thỉnh thoảng vẫn thấy dữ liệu cũ": ba cái tủ lạnh, ba tờ giấy, bạn chỉ sửa một tờ.
Redis — chung cho mọi bản sao, có hết hạn, sống qua khởi động lại. Cái giá là một lượt đi về mạng và một dịch vụ nữa phải vận hành. Bài 37 sẽ nói.
Cache tầng hai của Hibernate
Persistence context là cache tầng một — chỉ sống trong một giao dịch. Bài 24 đã dùng nó mà không gọi tên.
Tầng hai chia sẻ giữa các giao dịch:
spring.jpa.properties.hibernate.cache.use_second_level_cache: true
spring.jpa.properties.hibernate.cache.use_query_cache: true
@Entity @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
class SanPham { }
Tôi khuyên cân nhắc kỹ trước khi bật. Nó có ích với dữ liệu tham chiếu đọc nhiều ghi hiếm — danh mục, cấu hình, bảng mã. Với dữ liệu nghiệp vụ thay đổi thường xuyên, nó thêm một tầng vô hiệu hoá cache nữa để sai, mà lợi ích thì nhỏ.
Cache truy vấn (use_query_cache) còn khó hơn: nó bị vô hiệu hoá khi bất kỳ bảng nào trong truy vấn thay đổi, nên trên bảng ghi thường xuyên nó gần như không bao giờ trúng — trong khi vẫn tốn công quản lý.
Với phần lớn ứng dụng, cache ở tầng ứng dụng (@Cacheable) dễ hiểu và dễ kiểm soát hơn nhiều.
Ba câu hỏi trước khi thêm cache
Đã đo chưa? Cache một truy vấn 2 mili giây không giúp gì, mà thêm cả một lớp có thể sai.
Dữ liệu cũ bao lâu thì chấp nhận được? Câu trả lời quyết định thời gian hết hạn. Nếu câu trả lời là "không bao giờ", bạn cần vô hiệu hoá cache theo sự kiện, và đó là việc khó hơn nhiều.
Ai xoá cache khi dữ liệu đổi? Nếu dữ liệu có thể đổi từ nơi khác — một job, một dịch vụ khác, một câu SQL chạy tay — thì @CacheEvict trong mã của bạn không bao giờ được gọi. Đây chính là câu "ai sửa tờ giấy khi cái tủ thay đổi", và nó là câu khó nhất trong cả bài.
Cache có hết hạn ngắn thường an toàn hơn cache vô hiệu hoá thủ công, vì nó tự sửa sai — tờ giấy tự hết hiệu lực sau mười phút, nên dối lắm cũng chỉ dối được mười phút.
Nếu muốn soi nhanh cache trong dự án mình, đếm hai con số:
grep -rn "@Cacheable" --include='*.java' src/main | wc -l
grep -rn "@CacheEvict\|@CachePut" --include='*.java' src/main | wc -l
Số thứ nhất lớn hơn nhiều số thứ hai nghĩa là bạn đang dán rất nhiều tờ giấy mà hiếm khi sửa chúng. Hãy chắc những cache đó có thời gian hết hạn — nếu không, dữ liệu cũ sẽ sống mãi.
Mẫu số chung
Có một câu nói nổi tiếng trong ngành của Phil Karlton: "Chỉ có hai việc khó trong khoa học máy tính: vô hiệu hoá cache, và đặt tên." Cả bài này là bằng chứng cho nửa đầu — và điều đáng nói là cái khó đó giống hệt nhau ở mọi tầng của hệ thống, không riêng @Cacheable của Spring.
- Cache HTTP / CDN là cùng tờ giấy ở quy mô web:
Cache-Control,ETag, rồi trình duyệt và CDN giữ bản sao — và cả ngành sinh ra đủ trò "cache busting", purge, đánh version vào tên tệp chỉ để trả lời đúng câu "ai sửa tờ giấy". - Cache truy vấn của CSDL: MySQL gỡ bỏ hẳn query cache ở phiên bản 8.0 — vì chi phí vô hiệu hoá nó (mỗi lần bảng đổi là xoá) lớn hơn lợi ích. Đó đúng là bài học
use_query_cachecủa Hibernate ở trên, nhưng là cả một cộng đồng thừa nhận ở quy mô sản xuất. - Cache phía trình duyệt (
useMemo, memo hoá) có cùng cái bẫy "stale closure": giá trị nhớ không cập nhật khi thứ nó phụ thuộc đã đổi — lại là tờ giấy nói dối.
Sợi chỉ chung đáng mang theo: phần dễ của cache — cú tăng tốc 357 lần — chính là cái mồi nhử bạn bước qua phần khó mà không nhận ra. Viết @Cacheable mất năm giây; trả lời "ai vô hiệu hoá nó khi dữ liệu đổi, kể cả khi dữ liệu đổi từ ngoài tầm mã của tôi" mới là việc thật. Và hai liều thuốc thì phổ quát ở mọi ngôn ngữ, mọi tầng: ưu tiên hết-hạn-ngắn hơn vô-hiệu-hoá-thủ-công (tự sửa sai thắng phải-nhớ-sửa), và biết rằng cache dùng chung đổi một lượt mạng lấy sự nhất quán — còn cache cục bộ mỗi bản sao thì nhanh nhưng để lại đúng cái cảnh "ba cái tủ, ba tờ giấy". Cache không khó vì khó làm nhanh; nó khó vì khó giữ đúng.
Ngày mai: kiểm thử tầng dữ liệu — @DataJpaTest, và vì sao H2 trong test cho bạn cảm giác an toàn giả.