Spring Production-Ready/Mini-challenge: TaskFlow v6 — cache, job nền và event
25/26
Bài 25 / 26~30 phútCaching, Async & SchedulingMiễn phí lượt xem

Mini-challenge: TaskFlow v6 — cache, job nền và event

Thêm báo cáo có cache, job sinh báo cáo theo lịch chạy an toàn khi nhiều instance, và thông báo phát qua Spring Events sau khi transaction commit.

TL;DR: Đóng vòng module bằng một tính năng thật: mỗi đêm TaskFlow sinh báo cáo tổng hợp và gửi cho từng team lead. Năm bước độc lập ráp lại đúng năm annotation đã học — @Cacheable cho số liệu báo cáo, chọn nơi đặt cache khi TaskFlow chạy ba instance, @Async cho việc gửi mail, @Scheduled cho job đêm an toàn nhiều instance, và Spring Events để việc gửi mail chỉ chạy sau khi dữ liệu báo cáo đã commit thật. Mỗi bước có tiêu chí hoàn thành bạn tự kiểm bằng quan sát, không phải bằng cách đọc lại code cho là đúng.

Bài này dùng lại đúng những class đã xuất hiện ở năm bài trước, không giới thiệu domain mới: ProjectStatsService (bài 01), TaskService/onTaskStatusChanged (bài 01), NotificationService cùng notificationExecutor (bài 03), NightlyReportJob (bài 04), ApplicationEventPublisher (bài 05). Việc của bạn là điền phần còn thiếu để năm mảnh rời đó vận hành như một tính năng, và tự phát hiện ba cạm bẫy đã học nếu chúng xuất hiện lại.

✅ Checklist hoàn thành

Năm mục dưới độc lập: làm xong mục nào tự kiểm được ngay mục đó, không phải làm tuần tự hết mới kiểm tra được gì.

  • Bước 1 — Cache trúng thật. ProjectStatsService.getProjectStats@Cacheable đúng key, và bạn chứng minh được cache trúng bằng quan sát, không chỉ tin annotation có mặt là đủ.
  • Bước 2 — Đúng chỗ đặt cache. Chọn Caffeine hay Redis cho cache báo cáo, có lý do rõ ràng theo trục invalidation, và xử lý được tình huống TaskFlow chạy ba instance sau load balancer.
  • Bước 3 — @Async không nuốt lỗi, không tự self-call. Gửi mail báo cáo cho nhiều team lead chạy thật sự bất đồng bộ, SMTP lỗi thì có nơi nhìn thấy.
  • Bước 4 — Job đêm an toàn nhiều instance. NightlyReportJob chạy đúng một lần mỗi đêm dù ba instance cùng bật lịch, và chạy lại thủ công không tạo báo cáo trùng.
  • Bước 5 — Event đúng pha transaction. Report phải commit xong mới publish việc gửi mail; rollback thì không mail nào được gửi.

🎯 Đề bài

TaskFlow chạy ba instance sau load balancer (đúng bối cảnh bài 02). Product muốn thêm tính năng: mỗi đêm lúc 0h, TaskFlow tổng hợp thống kê từng project rồi gửi báo cáo cho các team lead qua email. Số liệu lấy từ ProjectStatsService.getProjectStats, method đã tồn tại từ bài 01 nhưng query mất 400ms mỗi lần gọi, và báo cáo đêm cần gọi nó cho hàng chục project liên tiếp.

Bốn mảnh code sau đã tồn tại rải rác từ các bài trước, nhưng chưa được ráp lại:

// Da co tu bai 01 - query 400ms, chua chac chan cache dang chay dung
@Service
public class ProjectStatsService {

    private final TaskRepository taskRepository;

    public ProjectStatsService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    @Cacheable("projectStats")
    public ProjectStats getProjectStats(Long projectId) {
        return taskRepository.computeStats(projectId);
    }
}

// Da co tu bai 01 - xoa cache khi task doi trang thai
@Service
public class TaskService {

    @CacheEvict(value = "projectStats", key = "#projectId")
    public void onTaskStatusChanged(Long projectId) {
        // ...
    }
}

// Da co tu bai 03 - executor rieng cho viec gui mail, khong dung default
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(50);
    executor.setThreadNamePrefix("notify-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
}

// Da co tu bai 04 - cron dung nhung goi mot method chua ton tai
@Component
public class NightlyReportJob {

    private final ReportService reportService;

    public NightlyReportJob(ReportService reportService) {
        this.reportService = reportService;
    }

    @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Ho_Chi_Minh")
    public void sendNightlyReport() {
        reportService.generateAndSend();
    }
}

ReportService.generateAndSend() chưa tồn tại: đó là phần bạn viết, ráp cả năm annotation lại. Năm bước dưới đây độc lập với nhau (làm bước nào trước cũng được), nhưng bước 4 và 5 cần kết quả của bước 1.

Bước 1 — Cache cho số liệu báo cáo, chứng minh cache trúng thật

ProjectStatsService.getProjectStats đã có @Cacheable("projectStats") từ bài 01. Nhiệm vụ của bạn: đảm bảo key strategy đúng cho việc ReportService sẽ gọi nó lặp lại cho nhiều project trong cùng một lần chạy job.

Tiêu chí hoàn thành quan sát được: gọi getProjectStats(sameProjectId) hai lần liên tiếp trong cùng instance — chứng minh bằng một cách đo được (thời gian phản hồi, log, hoặc counter đếm số lần query thật chạy) rằng lần thứ hai không chạm TaskRepository.computeStats. "Code có @Cacheable" không phải bằng chứng — bạn cần thấy nó trúng thật.

Bước 2 — Chọn Caffeine hay Redis, xử lý invalidation ba instance

onTaskStatusChanged đã evict cache khi một task đổi trạng thái. Nhưng TaskFlow chạy ba instance sau load balancer, nên câu hỏi thật là quyết định cache báo cáo đặt ở đâu, và điều gì xảy ra với hai instance không nhận request evict đó.

Tiêu chí hoàn thành quan sát được: viết ra quyết định Caffeine hay Redis, kèm đúng một câu giải thích theo trục nào (đọc nhiều/ghi hiếm hay cần nhất quán ngay giữa các instance). Nếu chọn Caffeine, mô tả rõ khoảng thời gian tối đa mà một team lead có thể nhìn thấy số liệu cũ sau khi task đổi trạng thái trên instance khác — con số đó phải khớp với cấu hình expireAfterWrite bạn đặt, không phải cảm tính.

Bước 3 — Gửi mail cho nhiều team lead qua @Async, không tự self-call

notificationExecutor đã tồn tại từ bài 03. Viết NotificationService.sendReportEmailToAllLeads(...) gửi mail cho một danh sách team lead, tận dụng executor đó: không dùng executor mặc định, và không để một SMTP lỗi làm mất dấu vết.

Tiêu chí hoàn thành quan sát được: gọi sendReportEmailToAllLeads với 5 team lead, mỗi lần gửi giả lập mất 1 giây. Đo tổng thời gian method trả về — nếu thật sự chạy @Async qua notificationExecutor, method cha trả về gần như ngay lập tức bất kể 5 email; nếu mất khoảng 5 giây, annotation đang âm thầm mất tác dụng. Thêm: giả lập một SMTP lỗi giữa 5 lần gửi — phải có nơi (log ERROR, alert, hoặc CompletableFuture trả lại người gọi) nhìn thấy lỗi đó, không chỉ biến mất.

Bước 4 — Job đêm chạy an toàn khi ba instance, và idempotent

NightlyReportJob.sendNightlyReport() đã bật @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Ho_Chi_Minh"). Vấn đề giống hệt hook mở đầu bài 04: ba instance cùng bật lịch này.

Tiêu chí hoàn thành quan sát được: giả lập 0h đêm bằng cách gọi sendNightlyReport() "đồng thời" trên ba instance test (ba thread, hoặc ba lần gọi tay sát nhau) — chỉ đúng một lần ReportService.generateAndSend() thực sự chạy tới cùng, hai lần còn lại phải tự nhận ra và bỏ qua. Sau đó gọi generateAndSend() một lần nữa thủ công ngay sau khi báo cáo hôm đó đã sinh xong — không được tạo ra báo cáo thứ hai cho cùng một ngày.

Bước 5 — Tách việc gửi mail ra Spring Events, đúng pha transaction

ReportService.generateAndSend() (bạn viết) cần: tính báo cáo bằng cache ở bước 1, lưu report vào DB trong một transaction, rồi publish một event để việc gửi mail (bước 3) xảy ra sau đó, không gọi thẳng NotificationService từ trong generateAndSend().

Tiêu chí hoàn thành quan sát được: giả lập generateAndSend() ném exception ngay sau khi publish event nhưng trước khi transaction commit (ví dụ throw có chủ đích ở dòng cuối) — không một email nào được gửi, dù event đã publish. Ngược lại, khi mọi thứ chạy trót lọt, mail chỉ đi ra sau khi report đã thật sự nằm trong DB.

🔍 Luồng dữ liệu

flowchart TB
    T["00:00 - ca 3 instance cung kich hoat"] --> L{"Khoa phan tan - buoc 4"}
    L -- "duoc cap" --> RS["ReportService.generateAndSend - transactional"]
    L -- "tu choi" --> SKIP["2 instance con lai bo qua"]
    RS --> PS["ProjectStatsService.getProjectStats - cache, buoc 1+2"]
    PS --> SAVE["Luu Report vao DB"]
    SAVE --> COMMIT{"Transaction commit?"}
    COMMIT -- "co" --> EVT["publish ReportGeneratedEvent"]
    COMMIT -- "khong - rollback" --> NOMAIL["Khong listener nao chay"]
    EVT --> LISTENER["ReportMailListener AFTER_COMMIT - buoc 5"]
    LISTENER --> ASYNC["notificationExecutor - buoc 3"]
    ASYNC --> MAIL["Gui mail tung team lead"]

📦 Concept mapping

Learning outcome của moduleBài nguồnÁp dụng ở bước nào
Implement @Cacheable@CacheEvict với key strategy đúng, tránh bẫy self-call của proxyBài 01Bước 1
Compare Caffeine local với Redis distributed theo trục invalidation, serialization và chi phí vận hànhBài 02Bước 2
Implement @Async đúng executor và diagnose exception bị nuốt khi method trả về voidBài 03Bước 3
Choose fixed rate, fixed delay hay cron, và design chống chạy trùng khi chạy nhiều instanceBài 04Bước 4
Design luồng phụ bằng Spring Events và justify khi nào cần @TransactionalEventListener AFTER_COMMITBài 05Bước 5

▶️ Starter

Điền các TODO, không cần viết lại phần đã có sẵn ở mục 🎯 Đề bài:

public record ReportGeneratedEvent(
        Long reportId,
        List<String> teamLeadEmails,
        Instant generatedAt) {
}

@Service
public class ReportService {

    private final ProjectRepository projectRepository;
    private final ProjectStatsService projectStatsService;
    private final ReportRepository reportRepository;
    private final TeamLeadRepository teamLeadRepository;
    private final ApplicationEventPublisher publisher;

    public ReportService(ProjectRepository projectRepository,
                          ProjectStatsService projectStatsService,
                          ReportRepository reportRepository,
                          TeamLeadRepository teamLeadRepository,
                          ApplicationEventPublisher publisher) {
        this.projectRepository = projectRepository;
        this.projectStatsService = projectStatsService;
        this.reportRepository = reportRepository;
        this.teamLeadRepository = teamLeadRepository;
        this.publisher = publisher;
    }

    /* TODO buoc 4: kiem tra report hom nay da sinh chua truoc khi lam gi khac - idempotent */
    /* TODO buoc 5: @Transactional bao ngoai, luu Report vao DB, roi publish ReportGeneratedEvent */
    public void generateAndSend() {
        for (Long projectId : projectRepository.findAllIds()) {
            ProjectStats stats = projectStatsService.getProjectStats(projectId);
            // TODO: gop stats vao noi dung report
        }
    }
}

@Component
public class ReportMailListener {

    private final NotificationService notificationService;

    public ReportMailListener(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    /* TODO buoc 5: dung phase nao de dam bao report da commit that? */
    public void onReportGenerated(ReportGeneratedEvent event) {
        // TODO buoc 3: goi notificationService, khong tu goi thang tung email o day
    }
}

@Service
public class NotificationService {

    // sendConfirmation(...) da co tu bai 03, khong doi

    /* TODO buoc 3: annotation nao, executor nao? */
    public void sendReportEmail(String leadEmail, Report report) {
        smtpClient.send(leadEmail, buildReportMessage(report));
    }

    /* TODO buoc 3: goi sendReportEmail cho tung leadEmail - can chung minh chay that su bat dong bo */
    public void sendReportEmailToAllLeads(List<String> leadEmails, Report report) {
        for (String leadEmail : leadEmails) {
            sendReportEmail(leadEmail, report);
        }
    }
}

💡 Gợi ý

Đọc gợi ý của bước nào đang làm, đừng đọc hết một lượt

Mỗi gợi ý chỉ nêu câu hỏi định hướng hoặc công thức trần; số liệu, lựa chọn cụ thể và code hoàn chỉnh nằm ở mục Lời giải bên dưới.

Bước 1: SimpleKeyGenerator cho method một tham số dùng thẳng tham số đó làm key — tự hỏi: key mặc định của getProjectStats(Long projectId) có đủ hay cần khai key tường minh? Muốn "chứng minh trúng thật" mà không sửa code nghiệp vụ, có thể đo bằng cách nào không cần debugger (gợi ý: đếm số lần một method khác trong TaskRepository thực sự được gọi)?

Bước 2: Quay lại bảng ba trục ở bài 02 (invalidation / serialization / vận hành). Câu hỏi cần trả lời trước khi chọn: báo cáo team lead có bắt buộc thấy đúng số liệu ngay lập tức trên cả ba instance, hay lệch vài phút là chấp nhận được vì dữ liệu vốn tính lại mỗi đêm? Nếu chọn Caffeine, expireAfterWrite nên đặt bao lâu so với chu kỳ job (tính từ lúc job chạy tới lúc job chạy lần sau)? Nếu chọn Redis, bạn đánh đổi những gì so với việc không cần đặt expiration?

Bước 3: @Async không có tác dụng nếu lời gọi không đi qua proxy. sendReportEmailToAllLeadssendReportEmail đang nằm trong CÙNG một class — lời gọi giữa chúng đi qua đường nào? Ba lối thoát đã liệt kê ở bài 01 (tách bean, self-inject, đẩy annotation lên entry point) — lối nào hợp nhất cho đúng hai method này? Muốn exception không biến mất, hai lựa chọn kiểu trả về đã học ở bài 03 khác nhau ở chỗ nào khi gửi hàng loạt (một lần thất bại có nên chặn các lần gửi còn lại không)?

Bước 4: Scheduler mặc định của mỗi instance không biết instance khác tồn tại — cần một nơi mà cả ba cùng nhìn thấy để chỉ một instance "thắng". Bài 04 đã liệt kê bốn hướng, hướng nào dễ áp dụng nhất cho một job chạy một lần mỗi đêm? Idempotent nghĩa là chạy N lần cho cùng một kết quả cuối — generateAndSend() cần tự hỏi câu gì trước khi tạo Report mới (gợi ý: report của "hôm nay" là duy nhất theo trục nào)?

Bước 5: @TransactionalEventListener có bốn pha, chỉ một pha đảm bảo dữ liệu đã thật sự nằm trong DB trước khi listener chạy. generateAndSend() publish event ở đâu trong thân method để đảm bảo Report đã save trước lúc publish? Không có gì đảm bảo listener AFTER_COMMIT chạy trên transaction nào — nếu sau này bạn muốn ghi thêm "đã gửi lúc mấy giờ" vào DB trong ReportMailListener, transaction nào đang mở tại đó?

✅ Lời giải

Tự làm trước khi đọc phần này

Phần dưới là lời giải đầy đủ: số liệu, lựa chọn cụ thể, code hoàn chỉnh. Chỉ mở sau khi đã tự thử ít nhất một lượt theo Gợi ý ở trên.

Bước 1 — key strategy và cách chứng minh cache trúng

getProjectStats(Long projectId) một tham số nên key mặc định (SimpleKeyGenerator) đã đúng là chính projectId, không cần khai key tường minh cho method này (khác bài 01, method đó minh hoạ bẫy khi có tham số thứ hai không liên quan). Việc cần thêm là sync = true, vì ReportService gọi lặp lại cho nhiều project trong cùng một lần chạy job, và job có thể trùng thời điểm với người dùng đang mở dashboard xem đúng project đó:

@Cacheable(value = "projectStats", key = "#projectId", sync = true)
public ProjectStats getProjectStats(Long projectId) {
    return taskRepository.computeStats(projectId);
}

Cách chứng minh cache trúng thật mà không cần debugger: thêm một counter tạm trong test (hoặc dùng Mockito.verify(taskRepository, times(1)).computeStats(projectId) sau hai lần gọi getProjectStats(projectId) liên tiếp trong cùng instance). times(1) nghĩa là lần gọi thứ hai đã được cache chặn lại trước khi chạm TaskRepository: đây là bằng chứng, không phải suy diễn từ việc annotation có mặt trong code.

Bước 2 — quyết định Caffeine hay Redis

Chọn Caffeine, với lý do đúng trục "đọc nhiều, ghi hiếm, chịu lệch vài giây giữa các instance" ở bảng quyết định bài 02: báo cáo được tính lại từ đầu mỗi đêm, nên số liệu vốn dĩ chỉ cần đúng tính tới thời điểm job chạy gần nhất: hai instance chưa evict kịp trong vài phút không phải sự cố, vì chính người dùng cũng không kỳ vọng dashboard cập nhật real-time theo từng lần task đổi trạng thái.

spring:
  cache:
    type: caffeine
    cache-names: projectStats
    caffeine:
      spec: maximumSize=2000,expireAfterWrite=15m

expireAfterWrite=15m chọn theo chu kỳ nghiệp vụ: job đêm chỉ chạy một lần mỗi 24 giờ, nhưng dashboard ban ngày vẫn đọc getProjectStats trực tiếp (không qua job) nên cache vẫn cần refresh trong ngày: 15 phút đủ ngắn để một instance không giữ số liệu cũ quá lâu sau khi onTaskStatusChanged evict nó, mà vẫn đủ dài để không đánh mất lợi ích cache. Nếu nghiệp vụ sau này yêu cầu team lead thấy đúng số liệu real-time trên mọi instance ngay khi một task đổi trạng thái ở instance khác, đó là lúc chuyển sang Redis, đúng điều kiện "phải nhất quán ngay giữa mọi instance" ở bảng quyết định bài 02.

Bước 3 — executor riêng, tránh self-call, không nuốt exception

sendReportEmailToAllLeadssendReportEmail nằm cùng class NotificationService. Nếu để nguyên như Starter, sendReportEmailToAllLeads gọi sendReportEmail(...) bằng this ngầm định, bỏ qua proxy, @Async trên sendReportEmail không có hiệu lực: cả vòng lặp chạy tuần tự, đồng bộ, đúng bẫy self-call đã học ở bài 01 và 03. Lối thoát đúng ở đây là đẩy @Async lên đúng entry point: sendReportEmailToAllLeads mới là method được gọi từ bên ngoài (từ ReportMailListener), nên annotation đặt ở đó, còn sendReportEmail chỉ là helper nội bộ chạy đồng bộ bên trong đúng tác vụ async đó:

@Async("notificationExecutor")
public void sendReportEmailToAllLeads(List<String> leadEmails, Report report) {
    for (String leadEmail : leadEmails) {
        try {
            sendReportEmail(leadEmail, report);
        } catch (MailSendException e) {
            log.error("Gui bao cao that bai cho {}: {}", leadEmail, e.getMessage(), e);
            // KHONG re-throw: mot email loi khong duoc chan cac email con lai trong vong lap
        }
    }
}

private void sendReportEmail(String leadEmail, Report report) {
    smtpClient.send(leadEmail, buildReportMessage(report));
}

Toàn bộ method trả void và chạy trên notificationExecutor đã có sẵn (không dùng executor mặc định). Vì cả vòng lặp nằm trong một tác vụ async duy nhất, try/catch bên trong vòng lặp đã đủ để một SMTP lỗi không làm mất dấu vết. Không cần CompletableFuture ở đây vì không có caller nào chờ kết quả cụ thể của việc gửi hàng loạt này (khác bài 03, nơi createProject cần biết một email đơn lẻ có gửi được không). Muốn quan sát: 5 lần gửi giả lập 1 giây mỗi lần, tổng thời gian bên trong tác vụ async vẫn khoảng 5 giây, nhưng ReportMailListener gọi sendReportEmailToAllLeads thì trả về gần như ngay lập tức — đúng bằng chứng annotation đang có hiệu lực.

Bước 4 — khoá phân tán và idempotent

Dùng ShedLock, đúng hướng "khoá phân tán qua DB hoặc Redis" ở bài 04, dễ áp dụng nhất cho một job chạy một lần mỗi đêm:

@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Ho_Chi_Minh")
@SchedulerLock(name = "sendNightlyReport", lockAtLeastFor = "5m", lockAtMostFor = "30m")
public void sendNightlyReport() {
    reportService.generateAndSend();
}

lockAtLeastFor = "5m" đảm bảo lock không nhả sớm dù generateAndSend() chạy nhanh, tránh một instance khác giành lock ngay sau đó rồi chạy lại. Nhưng ShedLock không đủ tuyệt đối (lock có thể hết hạn giữa chừng), nên phòng thủ thật sự nằm trong generateAndSend(), tự kiểm tra idempotent theo trục "report của một NGÀY là duy nhất":

@Transactional
public void generateAndSend() {
    LocalDate today = LocalDate.now(ZoneId.of("Asia/Ho_Chi_Minh"));
    if (reportRepository.existsByReportDate(today)) {
        return;   // report hom nay da sinh - bo qua, khong tao ban thu hai
    }

    List<ProjectStats> allStats = projectRepository.findAllIds().stream()
            .map(projectStatsService::getProjectStats)
            .toList();

    Report report = reportRepository.save(new Report(today, allStats));

    List<String> leadEmails = teamLeadRepository.findAllEmails();
    publisher.publishEvent(new ReportGeneratedEvent(report.getId(), leadEmails, Instant.now()));
}

existsByReportDate(today) chạy đúng ngay đầu method, trong cùng transaction với việc save. Hai lần gọi gần như đồng thời (lock hết hạn giữa chừng, hoặc retry tay) vẫn chỉ một lần thấy false và thật sự tạo report, nhờ ràng buộc UNIQUE trên cột report_date ở tầng DB làm tuyến phòng thủ cuối nếu ứng dụng có hai instance cùng lọt qua check gần như đồng thời.

Bước 5 — publish sau khi save, AFTER_COMMIT mới gửi mail

Đoạn generateAndSend() ở Bước 4 đã publish ReportGeneratedEvent sau dòng reportRepository.save(...), đúng thứ tự. Nhưng publishEvent() chỉ định tuyến tới listener, không tự đợi transaction commit. Muốn listener chỉ chạy khi report thật sự nằm trong DB, listener phải khai @TransactionalEventListener(AFTER_COMMIT), không phải @EventListener thường:

@Component
public class ReportMailListener {

    private final NotificationService notificationService;

    public ReportMailListener(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onReportGenerated(ReportGeneratedEvent event) {
        Report report = reportRepository.findById(event.reportId()).orElseThrow();
        notificationService.sendReportEmailToAllLeads(event.teamLeadEmails(), report);
    }
}

Với @EventListener thường (không transactional), giả lập exception ngay sau khi publish nhưng trước khi generateAndSend() return sẽ để lộ đúng lỗi mở đầu bài 05: mail đã bay ra ngoài, nhưng transaction rollback nên DB không hề có report đó: team lead nhận mail báo cáo trỏ tới một report không tồn tại. Với AFTER_COMMIT, transaction rollback thì onReportGenerated không bao giờ được gọi, đúng tiêu chí hoàn thành của bước này.

Nếu muốn ghi thêm "đã gửi lúc mấy giờ" ngay trong onReportGenerated, transaction gốc của generateAndSend() đã đóng hẳn tại thời điểm AFTER_COMMIT chạy, nên thao tác ghi đó cần @Transactional(propagation = Propagation.REQUIRES_NEW) tường minh trên chính onReportGenerated, đúng bẫy đã học ở mục 4 bài 05. Thiếu annotation này, dòng ghi có thể chạy "được" lúc test tay nhưng không bao giờ thật sự nằm trong DB khi lên production.

🎓 Mở rộng

Level 1 — Warm cache trước giờ cao điểm: thêm một @Scheduled khác chạy lúc 7h sáng, gọi trước getProjectStats cho các project được xem nhiều nhất, để dashboard buổi sáng không phải chịu 400ms cho lần đọc đầu tiên.

Level 2 — Metric cho job đêm: dùng lại Micrometer đã học ở module 02, counter đếm số lần generateAndSend() thực sự tạo report mới so với số lần bị chặn bởi idempotent check, gauge lưu thời điểm report gần nhất, cảnh báo khi quá 26 giờ chưa có report mới (phòng đúng pitfall "job chết âm thầm" ở bài 04).

Level 3 — Outbox pattern cho việc gửi mail: Spring Events ở đây chấp nhận được vì mất một báo cáo là chấp nhận được (retry đêm sau). Thử hình dung lại nếu báo cáo này là hoá đơn tính phí — đâu là điểm trong generateAndSend() cần đổi sang outbox pattern (mục 6, bài 05) để không mất event khi ứng dụng crash giữa lúc gửi?

✨ Điều bạn vừa làm được

Bạn vừa ráp năm annotation từng học riêng lẻ thành một tính năng chạy được trên cụm ba instance. Cache đúng key và chứng minh được cache trúng, chọn hạ tầng cache theo đúng trục nghiệp vụ chứ không theo thói quen, executor riêng cho việc chạy nền mà không dính bẫy self-call, job định kỳ an toàn và idempotent trên nhiều instance, và luồng phụ tách rời bằng event chỉ kích hoạt sau khi dữ liệu đã chắc chắn commit. Đối chiếu lại với năm outcome của module ở Bài 07 — Tổng kết module trước khi coi module này đã xong.

Bài tiếp theo: Tổng kết — Caching, Async & Scheduling

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

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