Spring Production-Ready/Tổng kết — Caching, Async & Scheduling
26/26
Bài 26 / 26~6 phútCaching, Async & SchedulingMiễn phí lượt xem

Tổng kết — Caching, Async & Scheduling

Cheat sheet cache abstraction, chọn nơi đặt cache, @Async, @Scheduled và Spring Events, kèm self-assessment năm năng lực của module.

TL;DR: Module 03 bóc năm cơ chế cùng đứng trên một nền AOP proxy: cache, chạy nền, lịch định kỳ, event. Đây là một trang để bookmark: cheat sheet chọn nhanh, glossary thuật ngữ, pitfall gom lại, và checklist tự chấm năm năng lực của module.

Đã đi qua những gì

Module bắt đầu từ một câu hỏi chung cho cả năm bài: annotation nào cũng gọn một dòng, vậy vì sao chúng lại mất tác dụng theo đúng một kiểu quen thuộc? Bài 01 trả lời bằng cơ chế proxy — self-call bỏ qua nó, và key strategy sai làm cache miss liên tục hoặc rò dữ liệu giữa user. Bài 02 đặt câu hỏi đó vào bối cảnh nhiều instance: cùng một @CacheEvict, Caffeine chỉ xoá được cache của đúng instance chạy nó, Redis xoá được cả cụm nhưng đổi lấy một vòng mạng và serialization. Bài 03 chuyển sang @Async, nơi kiểu trả về quyết định exception sống hay biến mất, và executor mặc định dùng chung cho mọi việc là nguồn nghẽn âm thầm. Bài 04 đưa vào trục thời gian: fixedRatefixedDelay đếm mốc từ hai điểm khác nhau, còn scheduler một thread làm mọi job khác trễ theo mà không lỗi nào báo. Bài 05 khép vòng bằng Spring Events, sửa đúng ngộ nhận phổ biến nhất: @EventListener mặc định chạy đồng bộ, không phải bất đồng bộ như cái tên gợi ý, và @TransactionalEventListener AFTER_COMMIT mới là công cụ đúng cho việc phụ không thể thu hồi như gửi mail. Mini-challenge TaskFlow v6 ráp cả năm mảnh vào một tính năng thật: báo cáo đêm có cache, chạy nền qua executor riêng, sinh an toàn trên nhiều instance, và phát thông báo chỉ sau khi dữ liệu đã commit.

Bốn annotation, một sợi chỉ xuyên suốt:

flowchart TB
    P["AOP Proxy"] --> C["@Cacheable - bai 01"]
    P --> A["@Async - bai 03"]
    P --> S["@Scheduled - bai 04"]
    P --> E["@TransactionalEventListener - bai 05"]
    C --> PIT["Ho cam bay chung"]
    A --> PIT
    S --> PIT
    E --> PIT
    PIT --> P1["Self-call bo qua proxy"]
    PIT --> P2["Ranh gioi thread: ThreadLocal khong di theo"]
    PIT --> P3["Ranh gioi transaction: khong doi viec phu chay xong"]

🗺️ Cheat sheet

ConceptKhi nào dùngPitfall
@CacheableKết quả tốn kém, đọc nhiều hơn ghi, dữ liệu bên dưới đổi chậmSelf-call bỏ qua proxy; quên đưa identity vào key thì user này thấy cache của user khác
key tường minh (SpEL)Method nhiều tham số, hoặc tham số không liên quan tới danh tính dữ liệuĐể mặc định SimpleKeyGenerator gộp mọi tham số, tạo cache miss vô ích
sync = trueMột key hot, nhiều request cùng miss một lúcKhông dùng chung được với unless
@CachePutVừa có kết quả mới, muốn cache phản ánh ngayLuôn chạy method thật, không tiết kiệm gì nếu dùng nhầm chỗ chỉ cần evict
@CacheEvictBiết cache cũ nhưng chưa cần giá trị mới ngayTrên Caffeine chỉ xoá cache của instance nhận request, không lan sang instance khác
CaffeineĐọc nhiều, ghi hiếm, chịu lệch vài giây giữa các instanceThiếu maximumSize/expiration là rò bộ nhớ, không có gì tự dọn
RedisPhải nhất quán ngay giữa mọi instance, hoặc dữ liệu cần sống qua restartObject cache phải serialize sạch; thiếu CacheErrorHandler thì Redis rớt kéo sập request
@Async trả voidViệc thật sự fire-and-forget, một dòng log tập trung là đủ giám sátException chỉ tới AsyncUncaughtExceptionHandler, mặc định chỉ log rồi biến mất
@Async trả CompletableFutureCaller hoặc nơi khác cần biết việc có thành công hay khôngQuên .exceptionally()/.join() thì vẫn không ai đọc kết quả
Executor riêng theo nhóm việcỨng dụng có nhiều loại @Async khác hồ sơ tải (I/O-bound so với CPU-bound)Dùng chung executor mặc định, một việc chậm chiếm hết thread của việc nhanh
fixedRateJob chắc chắn nhanh hơn chu kỳ, cần đều đặn theo giờ tườngJob chậm hơn chu kỳ thì các lần chạy dồn chồng lên nhau
fixedDelayJob đụng tài nguyên chung, cần khoảng nghỉ chắc chắn giữa hai lầnKhông khớp đồng hồ thật nếu nghiệp vụ cần đúng một mốc giờ
cron + zoneCần đúng thời điểm thật trên đồng hồ (0h đêm, 8h sáng thứ Hai)Thiếu zone thì lịch chạy theo múi giờ JVM, không theo múi giờ nghiệp vụ
Khoá phân tán (ShedLock...)Nhiều instance cùng bật một job định kỳKhông loại trừ tuyệt đối chạy trùng, job vẫn cần tự idempotent
ApplicationEventPublisherTách việc phụ khỏi method nghiệp vụ chính, publisher không cần biết ai ngheBiến event thành luồng nghiệp vụ chính bắt buộc, làm lỗi khó lần theo
@EventListenerViệc phụ có thể chạy ngay trong cùng transaction, chấp nhận ảnh hưởng luồng gốcMặc định đồng bộ, không phải bất đồng bộ; listener throw thì kéo cả transaction rollback
@TransactionalEventListener AFTER_COMMITViệc phụ có tác dụng phụ không thể thu hồi (gửi mail, bắn webhook)Ghi DB tiếp trong listener cần Propagation.REQUIRES_NEW tường minh, thiếu thì âm thầm không lưu

📖 Glossary module

Thuật ngữĐịnh nghĩa 1 câuNguồn
Cache abstractionHai interface Cache/CacheManager của Spring định nghĩa hợp đồng, không tự lưu trữ gìBài 01
SimpleKeyGeneratorQuy tắc sinh key mặc định khi @Cacheable không khai key, gộp mọi tham số theo thứ tựBài 01
Self-call bypassGọi this.method() trong cùng class không đi qua proxy, annotation mất hiệu lực lặng lẽBài 01, tái xuất ở bài 03
Cache stampedeMột key hot hết hạn, hàng loạt request cùng miss và cùng đập vào nguồn dữ liệu thậtBài 01
Cache in-process (Caffeine)Cache nằm trong heap của chính tiến trình, không chia sẻ được giữa các instanceBài 02
Cache distributed (Redis)Cache đứng ngoài tiến trình, mọi instance đọc/ghi chung một bản dữ liệuBài 02
CacheErrorHandlerBean bắt lỗi khi backend cache (Redis) rớt kết nối, tránh exception lan ra requestBài 02
ExecutorBộ điều phối thread nhận task và chạy nó trên một pool, đứng sau @Async@ScheduledBài 03, 04
AsyncUncaughtExceptionHandlerNơi Spring đưa exception của method @Async trả void, mặc định chỉ logBài 03
Rejection policyQuy tắc executor xử lý task mới khi pool và hàng đợi đều đầy (AbortPolicy, CallerRunsPolicy...)Bài 03
TaskSchedulerBean điều phối mọi method @Scheduled, mặc định chỉ có một threadBài 04
IdempotentChạy nhiều lần cho ra đúng một kết quả cuối, giống chạy đúng một lầnBài 04
Khoá phân tánCơ chế nằm ngoài JVM (DB, Redis...) để nhiều instance thống nhất chỉ một bên được chạyBài 04
ApplicationEventPublisherInterface publish event nghiệp vụ, không cần biết ai đang lắng ngheBài 05
TransactionPhaseBốn pha của vòng đời transaction mà @TransactionalEventListener có thể gắn vàoBài 05
AFTER_COMMITPha chỉ kích hoạt sau khi transaction gốc đã commit thành côngBài 05
Propagation.REQUIRES_NEWMở một transaction hoàn toàn mới, độc lập với transaction đã đóngBài 05
Outbox patternGhi event vào một bảng cùng transaction nghiệp vụ, một tiến trình riêng đọc và phát đi bền vữngBài 05
ThreadLocalCơ chế lưu dữ liệu gắn với đúng một thread (MDC, SecurityContextHolder dựa trên nó)Bài 03
Fan-outMột event được nhiều listener độc lập cùng xử lý, không đảm bảo thứ tự trừ khi có @OrderBài 05

⚠️ Pitfall tổng hợp

Self-call bypass proxy — gọi this.method() trong cùng class cho @Cacheable/@Async tưởng annotation vẫn chạy. ✅ Tách sang bean khác, hoặc đẩy annotation lên đúng entry point được gọi từ bên ngoài.

Cache thiếu identity trong key — method đọc theo user hiện tại nhưng key mặc định không phân biệt user. ✅ Đưa identity vào key tường minh (key = "#userId"), đừng để SimpleKey.EMPTY gộp mọi user vào một entry.

@CacheEvict tưởng xoá toàn cụm khi backend là Caffeine. ✅ Caffeine chỉ xoá cache local của instance chạy nó — cần Redis hoặc kênh tín hiệu riêng để lan sang mọi instance.

@Async trả void cho việc cần biết thành công hay thất bại. ✅ Trả CompletableFuture nếu caller hoặc audit cần biết kết quả; void chỉ hợp việc fire-and-forget thật sự.

Dùng executor mặc định cho mọi loại @Async/@Scheduled. ✅ Khai executor riêng theo nhóm việc, pool size theo hồ sơ tải I/O-bound so với CPU-bound.

Nhầm fixedRate với fixedDelay khi job chậm hơn chu kỳ.fixedRate đếm từ lúc bắt đầu nên dồn chồng; fixedDelay đếm từ lúc kết thúc nên luôn có khoảng nghỉ.

Cron thiếu zone, tưởng chạy đúng nửa đêm giờ Việt Nam. ✅ Khai tường minh zone = "Asia/Ho_Chi_Minh", đừng phụ thuộc múi giờ mặc định của JVM.

Nhiều instance cùng bật một job định kỳ, không khoá. ✅ Khoá phân tán (ShedLock, leader election, CronJob của Kubernetes...) cộng thiết kế idempotent làm tuyến phòng thủ cuối.

@EventListener thường cộng @Async, tưởng đợi transaction commit.@EventListener luôn kích hoạt ngay tại publishEvent(); muốn đợi commit phải dùng @TransactionalEventListener AFTER_COMMIT.

Ghi DB trong listener AFTER_COMMIT mà không mở transaction mới. ✅ Transaction gốc đã đóng hẳn tại AFTER_COMMIT — thao tác ghi cần @Transactional(propagation = Propagation.REQUIRES_NEW) tường minh.

Liên hệ các module khác

  • Module 01 — Modern Java trong Spring: virtual thread executor học ở đó là một lựa chọn thật cho @Async I/O-bound trong module này, cùng ba cạm bẫy pinning/ThreadLocal/backpressure vẫn áp dụng nguyên vẹn khi dùng cho executor của @Async/@Scheduled.
  • Module 02 — Observability & Production-ready: job @Scheduled chết âm thầm hay executor bị nghẽn đều không tự báo lỗi bằng annotation — đo bằng Micrometer counter/gauge (số lần job chạy thành công, thời điểm chạy gần nhất) là cách duy nhất phát hiện sớm, thay vì đợi khách hàng báo cáo thiếu mất.
  • Cả hai module trước đều dùng lại đúng ProjectStatsService/NotificationService/NightlyReportJob của TaskFlow — mini-challenge bài 06 module này là nơi observability của module 02 lẽ ra nên gắn vào đầu tiên nếu bạn muốn mở rộng thêm.

✅ Self-assessment

  • Implement @Cacheable@CacheEvict với key strategy đúng, tránh bẫy self-call của proxy.
    • Nếu chưa chắc: re-read bài 01 mục 3 và mục 5.
  • Compare Caffeine local với Redis distributed theo trục invalidation, serialization và chi phí vận hành.
    • Nếu chưa chắc: re-read bài 02 mục 4 và mục 7.
  • Implement @Async đúng executor và diagnose exception bị nuốt khi method trả về void.
    • Nếu chưa chắc: re-read bài 03 mục 4 và mục 5.
  • Choose fixed rate, fixed delay hay cron, và design chống chạy trùng khi chạy nhiều instance.
    • Nếu chưa chắc: re-read bài 04 mục 2 và mục 5.
  • Design luồng phụ bằng Spring Events và justify khi nào cần @TransactionalEventListener AFTER_COMMIT.
    • Nếu chưa chắc: re-read bài 05 mục 2 và mục 3.

🚀 What's next

Module 03 là module cuối của course Spring Production-Ready (tier Mid trong lộ trình Spring). TaskFlow giờ đã có modern Java ở tầng DTO/client, observability đầy đủ, và caching/async/scheduling đúng pitfall — sẵn sàng cho tier Senior: Spring Reactive & Microservices, nơi kiến trúc chuyển sang hệ phân tán nhiều service, và một số câu hỏi module này để ngỏ (outbox pattern ở bài 05, message broker thật thay vì event in-process) mới thật sự cần lời giải đầy đủ.

📚 Tài liệu mở rộng

Bài trước: Mini-challenge: TaskFlow v6

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