Spring Production-Ready/Caffeine hay Redis — đặt cache ở đâu
21/26
Bài 21 / 26~12 phútCaching, Async & SchedulingMiễn phí lượt xem

Caffeine hay Redis — đặt cache ở đâu

Cache trong tiến trình nhanh nhưng mỗi instance một bản, không xoá chéo được. Cache ngoài xoá chung nhưng phải serialize và thêm một điểm hỏng.

TL;DR: Caffeine giữ cache ngay trong heap của tiến trình Java, đọc nhanh, không tốn serialize, nhưng mỗi instance chạy một bản riêng nên @CacheEvict chỉ xoá được cache của đúng instance nhận request; hai instance còn lại vẫn trả dữ liệu cũ tới khi tự hết hạn. Redis đứng ngoài tiến trình, mọi instance nhìn chung một bản nên evict một lần là xong toàn cụm, nhưng đổi lại mỗi lần đọc/ghi phải qua mạng và serialize — object đưa vào cache phải serialize được, và Redis chết thì phải có CacheErrorHandler để request không chết theo. Annotation @Cacheable/@CacheEvict giữ nguyên; thứ đổi chỉ là CacheManager cắm bên dưới.

Một thành viên TaskFlow đổi tên hiển thị trong hồ sơ, bấm Lưu, request trả 200. Nhấn F5, có lúc thấy tên mới, có lúc vẫn tên cũ — ngẫu nhiên. Annotation @Cacheable/@CacheEvict không sai một chữ; nó chỉ xoá được cache trên đúng MỘT trong ba instance đang chạy TaskFlow phía sau load balancer.

Bài này giải thích vì sao cùng một đoạn annotation cho hai kết quả khác nhau tuỳ nơi cache đặt, và ba trục phân biệt Caffeine (local) với Redis (distributed): xoá cache chéo được hay không, phải serialize hay không, ai chịu trách nhiệm khi cache chết.

1. Vì sao F5 lúc thấy tên mới, lúc thấy tên cũ?

TaskFlow chạy ba instance giống hệt nhau sau load balancer, chia request round-robin. Mỗi instance là một tiến trình JVM riêng, tự khởi tạo CacheManager riêng — bình thường, đó đúng là cách scale ngang một app stateless.

Annotation y hệt bài trước:

@Service
public class ProfileService {

    @Cacheable(cacheNames = "profiles", key = "#userId", sync = true)
    public ProfileDto getProfile(String userId) {
        return profileRepository.findById(userId)
                .map(ProfileDto::from)
                .orElseThrow(() -> new ProfileNotFoundException(userId));
    }

    @CacheEvict(cacheNames = "profiles", key = "#userId")
    public void updateDisplayName(String userId, String newName) {
        profileRepository.updateDisplayName(userId, newName);
    }
}

Người dùng gọi PUT /api/profile, load balancer đưa request rơi trúng instance A. updateDisplayName chạy, @CacheEvict xoá entry profiles::user-42 — nhưng chỉ xoá trên A.

Thử đoán trước khi đọc tiếp

A, B, C đều có cache Caffeine riêng cho cùng entry profiles::user-42. PUT vừa chạy trên A, xoá cache của A. Ngay sau đó F5 hai lần liên tiếp, round-robin đưa hai request GET lần lượt tới B rồi C. B và C trả tên gì — mới hay cũ? Viết ra câu trả lời trước khi đọc tiếp.

B và C không hề hay biết gì về lệnh evict vừa xảy ra trên A. CacheManager của mỗi instance quản lý một Map nằm trong heap của đúng tiến trình đó — @CacheEvict chỉ gọi cache.evict(key) trên instance đang chạy nó, không có cơ chế nào tự lan sang instance khác. B và C tiếp tục trả entry cũ, tới khi entry tự hết hạn theo expireAfterWrite hoặc bị đuổi vì chạm maximumSize. F5 nhiều lần cho ba kết quả khác nhau vì mỗi lần rơi vào một instance với một trạng thái cache khác nhau.

flowchart LR
    LB[Load Balancer] --> A[Instance A]
    LB --> B[Instance B]
    LB --> C[Instance C]
    PUT[PUT doi ten] --> A
    A -- CacheEvict chi xoa cache nay --> CA[Cache rieng A: da xoa]
    B --> CB[Cache rieng B: con ten cu]
    C --> CC[Cache rieng C: con ten cu]

Đây không phải bug ở annotation — nó làm đúng việc đã hứa: xoá cache local của tiến trình đang chạy. Bug nằm ở kỳ vọng sai: tưởng "cache" là một khái niệm duy nhất, trong khi Caffeine và Redis là hai mô hình vật lý khác hẳn nhau.

2. Caffeine — cache sống trong heap của chính tiến trình

Caffeine là thư viện cache in-process cho Java: entry nằm ngay trong heap của tiến trình đang chạy. Đọc một entry chỉ là theo một tham chiếu trong bộ nhớ — không network hop, không serialize, không thêm hạ tầng phải vận hành. Cái giá chính là hệ quả ở Mục 1: cache không chia sẻ được giữa các tiến trình (Mục 3 nói tiếp).

Caffeine không tự biết khi nào một entry nên biến mất — phải khai chính sách đuổi tường minh:

  • expireAfterWrite: đếm từ lúc ghi gần nhất, không quan tâm có ai đọc. Hợp dữ liệu có "hạn dùng" cố định.
  • expireAfterAccess: đếm lại mỗi lần đọc/ghi. Hợp dữ liệu kiểu session.
  • maximumSize: giới hạn số entry, chạm ngưỡng thì đuổi entry ít dùng gần đây (W-TinyLFU).
Không đặt giới hạn = rò bộ nhớ

Cache không có maximumSize lẫn expiration sẽ phình theo số key khác nhau từng đi qua nó — không có gì tự dọn. Với hệ chạy dài ngày, đó là một dạng memory leak thật, kết cục thường là OutOfMemoryError.

Cấu hình đủ dùng — chỉ dependency và property, annotation ở Mục 1 giữ nguyên:

spring:
  cache:
    type: caffeine
    cache-names: profiles
    caffeine:
      spec: maximumSize=5000,expireAfterWrite=10m

Recall autoconfiguration back-off của spring-core: CacheAutoConfiguration dò theo một thứ tự cố định, trong đó Redis đứng trước Caffeine. Chỉ có Caffeine trên classpath thì Boot tự chọn CaffeineCacheManager; có CẢ HAI dependency thì Boot chọn Redis trước — lúc đó spring.cache.type: caffeine là bắt buộc để ép đúng ý.

3. Redis — cache đứng ngoài, mọi instance nhìn chung một bản

Redis trong vai trò cache của TaskFlow là một tiến trình riêng, tách khỏi cả ba instance, kết nối qua mạng. Cả ba instance cùng đọc/ghi vào đúng một bản dữ liệu — evict chạy trên A xoá luôn phần Redis mà B, C cũng đang nhìn vào, không còn khoảng lệch như Caffeine.

Đổi lại hai chi phí Caffeine không có. Mỗi lần đọc/ghi giờ là một vòng qua mạng: Caffeine đọc trong heap nhanh cỡ nano giây, còn Redis luôn cộng thêm ít nhất một round-trip TCP, chậm hơn Caffeine cỡ một tới vài bậc độ lớn dù cùng datacenter. Dữ liệu cũng phải serialize, vì object gửi vào Redis phải hoá thành byte rồi phía đọc dựng lại đúng object đó. Mặc định, RedisCacheConfiguration dùng JdkSerializationRedisSerializer, giòn tới mức đổi field hay đổi tên class là deserialize thất bại ngay. Serializer JSON (GenericJackson2JsonRedisSerializer) đọc được bằng mắt hơn, nhưng không tự mang thông tin kiểu — LocalDateTime là nguồn lỗi kinh điển nếu thiếu module java.time phù hợp.

@Bean
public RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .entryTtl(Duration.ofMinutes(10));
}

Hệ quả trực tiếp: object cache vào Redis phải serialize được sạch sẽProfileDto ở Mục 1 (record phẳng, không proxy) đã đúng hướng; entity JPA còn dính lazy proxy thì không (chi tiết ở Pitfall).

4. Ba trục so sánh thật

Bảng "so sánh chung chung" (nhanh hơn, mạnh hơn) không nói lên gì — ba trục dưới đây mới là thứ quyết định chọn Caffeine hay Redis.

TrụcCaffeine (local)Redis (distributed)
Invalidationevict chỉ chạm cache của instance chạy nóevict một lần, mọi instance nhìn thấy ngay
Serializationkhông tốn gì, object nằm nguyên trong heaptốn mỗi lần đọc/ghi, giới hạn kiểu dữ liệu cache được
Vận hành & điểm hỏngđi kèm ứng dụng, chết cùng tiến trìnhmột hệ riêng phải vận hành, chết độc lập

Trục thứ tư, ngắn hơn: nhất quán ngay sau khi ghi. Caffeine — instance vừa ghi thấy ngay, instance khác thấy bản cũ tới hết TTL. Redis — sau evict/put, mọi instance đọc lại đều thấy cùng giá trị mới.

Trục "vận hành & điểm hỏng" cần một mảnh nữa: mặc định Spring truyền nguyên exception cache ra người gọi — Redis rớt kết nối sẽ làm sập luôn GET /api/profile, dù dữ liệu thật vẫn nằm yên trong Postgres. Cần một tầng phòng thủ riêng — hai method đầu viết sẵn, tự điền hai method còn lại trước khi xem đáp án:

@Configuration
@EnableCaching
public class CacheErrorConfig implements CachingConfigurer {

    private static final Logger log =
            LoggerFactory.getLogger(CacheErrorConfig.class);

    @Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            public void handleCacheGetError(
                    RuntimeException e, Cache c, Object k) {
                log.warn("cache get loi", e);
            }

            public void handleCachePutError(
                    RuntimeException e, Cache c, Object k, Object v) {
                log.warn("cache put loi", e);
            }

            // TODO: handleCacheEvictError va handleCacheClearError
            //       cung khuon log.warn roi return
        };
    }
}
Tự điền trước khi xem đáp án

CacheErrorHandler có 4 method bắt buộc — method nào thiếu, Spring dùng hành vi mặc định (ném lại exception) cho đúng loại lỗi đó. Viết tiếp handleCacheEvictErrorhandleCacheClearError theo đúng khuôn hai method trên.

Đáp án:

public void handleCacheEvictError(
        RuntimeException e, Cache c, Object k) {
    log.warn("cache evict loi", e);
}

public void handleCacheClearError(
        RuntimeException e, Cache c) {
    log.warn("cache clear loi", e);
}

Với handler đủ 4 method, getProfile khi Redis rớt sẽ log cảnh báo rồi chạy tiếp method gốc thay vì ném lỗi. Caffeine không cần lớp phòng thủ này — cache chết nghĩa là cả tiến trình đã chết.

5. Đổi CacheManager — chỉ đổi dependency và property

Đây là giá trị của cache abstraction đã học ở bài trước: annotation trên ProfileService ở Mục 1 không cần sửa một dòng. Chuyển giữa Caffeine và Redis chỉ đổi hai thứ ngoài code nghiệp vụ — dependency và property spring.cache.type:

# Caffeine
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=5000,expireAfterWrite=10m

# Redis
spring:
  cache:
    type: redis
    redis:
      time-to-live: 600000

CacheManager là interface, @Cacheable/@CacheEvict chỉ nói chuyện qua interface đó — chúng không biết và không cần biết implementation bên dưới là CaffeineCacheManager hay RedisCacheManager. Đổi hạ tầng cache không đụng vào logic nghiệp vụ, chỉ đụng vào cấu hình.

6. Cache hai tầng — không phải bữa trưa miễn phí

Một lựa chọn hay được nhắc tới: đặt Caffeine làm tầng gần (L1), Redis làm tầng xa (L2). Đọc thử L1 trước, trúng thì trả ngay; trượt mới hỏi Redis rồi ghi ngược vào L1 — hot key phần lớn được L1 chặn lại trước khi chạm mạng.

Cái giá quay lại đúng vấn đề mở đầu bài: L1 của mỗi instance vẫn là cache local riêng, evict ở Redis không tự xoá bản L1 nằm trong heap instance khác. Cache hai tầng đúng nghĩa cần tự dựng thêm kênh phát tín hiệu xoá (ví dụ Redis pub/sub: ghi xong publish message, mọi instance subscribe rồi tự evict L1 của mình) — không có sẵn trong Spring Cache abstraction, và bug ở kênh đó tái tạo lại đúng lỗi ở Mục 1. Chỉ đáng làm khi đã đo được Redis là nút thắt thật, không phải mặc định "thêm cho nhanh".

7. Bảng quyết định — chọn Caffeine hay Redis

Dạng dữ liệuChọnVì sao
Đọc nhiều, ghi hiếm, chịu lệch vài giây giữa các instanceCaffeinekhông cần hạ tầng ngoài, độ lệch tạm thời chấp nhận được
Phải nhất quán ngay giữa mọi instance, hoặc sống qua restartRediscache local mất sạch mỗi lần restart; Redis độc lập vòng đời ứng dụng
Session, rate limit theo IP hoặc userRedis (bắt buộc)mọi instance phải đếm chung một số, không thể giữ bộ đếm riêng trong heap

Pitfall thường gặp

Nhầm 1 — Caffeine không đặt maximumSize lẫn expiration: new CaffeineCacheManager() không kèm spec giới hạn nào. ✅ Không có gì tự dọn cache — key mới liên tục sinh thì heap phình liên tục, kết cục là OutOfMemoryError. Luôn khai maximumSize hoặc expiration.

Nhầm 2 — cache thẳng entity JPA vào Redis (@Cacheable trả thẳng Project thay vì DTO). ✅ Entity mang proxy Hibernate cho quan hệ lazy — serialize proxy chưa init dữ liệu dễ ném lỗi hoặc lưu object rỗng. Map sang DTO phẳng, giống ProfileDto ở Mục 1.

Nhầm 3 — tưởng @CacheEvict xoá cache toàn cụm khi backend là Caffeine (đúng lỗi mở đầu bài). ✅ Muốn evict lan mọi instance, cache phải ở nơi mọi instance cùng nhìn thấy — chọn Redis, hoặc tự dựng kênh tín hiệu như Mục 6.

Đào sâu

📚 Deep Dive Spring Reference

Spec / reference chính thức:

Liên hệ các bài khác

  • Bài 01 — Cache abstraction — annotation @Cacheable/@CacheEvict và bẫy self-call dùng nguyên trong bài này.
  • Bài 04 — Scheduled — cùng họ "nhiều instance nhìn khác nhau": job @Scheduled chạy trùng trên mỗi instance nếu thiếu cơ chế khoá.
  • Bài 03 — Async và Executor — bài tiếp theo, vẫn là chọn hạ tầng đúng cho một tác vụ, lần này là chạy nền thay vì cache.

Tóm tắt

  • Bug F5-lúc-thấy-lúc-không nằm ở chỗ Caffeine không có khái niệm "toàn cụm" — evict chỉ chạm đúng instance chạy nó.
  • Caffeine nhanh vì không rời heap; cái giá là không chia sẻ được, bắt buộc giới hạn kích thước để tránh phình bộ nhớ.
  • Redis chia sẻ được giữa mọi instance; cái giá là mỗi lần đọc/ghi tốn một vòng mạng cộng serialize, object cache phải serialize sạch.
  • Mặc định Spring truyền thẳng exception cache ra người gọi — chọn Redis mà không đăng ký CacheErrorHandler nghĩa là Redis rớt kéo sập luôn request.
  • Cache hai tầng giảm tải cho Redis với hot key, nhưng tự mang lại đúng vấn đề invalidation của cache local ở tầng gần.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao đoạn code @Cacheable/@CacheEvict giống hệt nhau lại cho hai kết quả khác nhau tuỳ CacheManager cắm Caffeine hay Redis?
Annotation chỉ nói "tra/xoá cache" — nó không biết cache vật lý nằm ở đâu, đó là việc của CacheManager. Với CaffeineCacheManager, mỗi instance có object cache riêng trong heap của chính nó — evict() chỉ sửa được heap tiến trình đang chạy nó. Với RedisCacheManager, mọi instance gọi qua mạng tới cùng một tiến trình Redis, nên evict() sửa đúng bản dữ liệu mà tất cả cùng nhìn vào.
Q2
Vì sao một Caffeine cache không đặt maximumSize vẫn nguy hiểm, kể cả khi lúc mới chạy chỉ cache vài chục entry?
Số entry lúc khởi động không nói lên gì về sau — mỗi key mới được giữ vô thời hạn nếu thiếu expiration hay giới hạn kích thước. Sau vài ngày production, số key tích luỹ đủ lớn để chiếm hết heap còn trống, JVM ném OutOfMemoryError đúng lúc tải cao nhất — lỗi tích luỹ theo thời gian nên dễ bị bỏ sót lúc review.
Q3
Đoạn cấu hình sau thiếu gì để tránh việc Redis rớt kết nối làm sập GET /api/profile? spring.cache.type=redis
Property chỉ khai backend và TTL, không xử lý lỗi khi Redis mất kết nối. Mặc định Spring truyền nguyên exception cache ra người gọi, nên Redis rớt sẽ làm method có @Cacheable ném exception lên controller. Cần thêm bean CacheErrorHandler qua CachingConfigurer để bắt lỗi, log cảnh báo, rồi cho Spring chạy tiếp method gốc.
Q4
Nêu hai lý do cơ chế vì sao không nên cache trực tiếp một entity JPA vào Redis.
  • Lazy proxy không serialize sạch: quan hệ lazy thường là proxy Hibernate chưa load dữ liệu thật — serialize nó dễ ném lỗi hoặc lưu object rỗng.
  • Entity gắn với persistence context: entity vừa lấy từ database còn mang trạng thái mà Hibernate đang quản lý; sau khi deserialize lại từ Redis, nó không còn nằm trong persistence context nào — dùng như entity "sống" là sai giả định.
Q5
TaskFlow cần một rate limiter đếm request theo IP, chia sẻ giữa 4 instance. Chọn Caffeine hay Redis, và vì sao?
Bắt buộc Redis. Rate limiter cần một bộ đếm DUY NHẤT mà cả 4 instance cùng nhìn và cùng cập nhật. Dùng Caffeine, mỗi instance giữ bộ đếm riêng trong heap — một IP rải request qua 4 instance sẽ vượt ngưỡng thật gấp 4 lần trước khi instance nào tưởng đã chặn đủ.

Bài tiếp theo: Async và Executor

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

Đặt 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

Bài tiếp theo

@Async: chạy nền mà không đánh mất exception