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.

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: conditionunless

@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 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ó equalshashCode đú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@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ũ".

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.

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.

Thử ba mươi giây

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 lưu nhiều thứ mà không dọn. Hãy chắc rằng những cache đó có thời gian hết hạn — nếu không, dữ liệu cũ sẽ sống mãi.

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ả.