Spring Production-Ready/Spring Events — tách luồng phụ khỏi luồng chính
24/26
Bài 24 / 26~13 phútCaching, Async & SchedulingMiễn phí lượt xem

Spring Events — tách luồng phụ khỏi luồng chính

@EventListener mặc định chạy đồng bộ trong cùng transaction. @TransactionalEventListener AFTER_COMMIT mới là thứ bạn cần khi gửi mail sau khi lưu.

TL;DR: ApplicationEventPublisher cho phép một service công bố "chuyện này đã xảy ra" thay vì tự gọi thẳng từng việc phụ như gửi mail hay ghi log. @EventListener mặc định chạy đồng bộ, cùng thread, cùng transaction với nơi publish: listener chậm thì method gốc chậm theo, listener throw thì kéo cả transaction rollback. Việc phụ có tác dụng phụ không thể thu hồi như gửi mail thì dùng @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) — chỉ chạy khi transaction đã commit. Event trong tiến trình không bền: crash giữa chừng là mất, không retry.

Sáu tháng trước, TaskService.assign() chỉ có năm dòng: tìm task, gán người, lưu. Bây giờ nó dài sáu mươi dòng — mỗi tính năng mới cần biết "task vừa được gán cho ai" (gửi mail, ghi audit log, cập nhật thống kê, bắn webhook) lại mở đúng method này ra sửa. Việc chính bị chôn giữa bốn việc phụ, review pull request giờ tốn nhiều thời gian đọc code không liên quan hơn đọc logic gán việc thật sự.

Bài này giải thích cách Spring Events tách việc phụ khỏi luồng chính, và điểm hay bị hiểu sai nhất: @EventListener không phải "bất đồng bộ theo mặc định" — nó chạy ngay lập tức, đồng bộ, trong cùng transaction.

1. Ý tưởng — công bố event thay vì gọi thẳng

Cách viết tự nhiên nhất khi thêm một việc phụ là mở method gốc ra, thêm một dòng gọi thẳng. Vấn đề là method gốc trở thành điểm hội tụ của mọi thứ — vừa phải biết cách gán task, vừa phải biết định dạng mail, định dạng audit log. Càng nhiều việc phụ, method càng phình, và mọi thay đổi ở một việc phụ đều có nguy cơ đụng vào phần gán task đang chạy ổn định.

Spring Events lật ngược quan hệ đó. Bên phát (TaskService) chỉ công bố "chuyện này đã xảy ra" qua ApplicationEventPublisher, không cần biết ai đang nghe. Bên nghe (@EventListener) tự đăng ký nhận đúng loại event mình quan tâm — đúng interface ApplicationEventPublisher đã xuất hiện lúc container khởi động ở khoá Spring Core (mọi ApplicationContext publish event vòng đời qua cơ chế này); ở đây bạn dùng lại nó cho event nghiệp vụ của riêng ứng dụng.

Event nên là một record bất biến, dùng lại pattern DTO đã quen ở module 01. Record mang đúng dữ liệu tại thời điểm xảy ra, không mang entity còn sống mà listener có thể sửa lung tung:

public record TaskAssignedEvent(
        Long taskId,
        Long assigneeId,
        Long assignedBy,
        Instant assignedAt) {
}

TaskService.assign() sau khi tách chỉ còn việc của nó: tìm task, gán, lưu, publish một event.

@Service
public class TaskService {

    private final TaskRepository taskRepo;
    private final ApplicationEventPublisher publisher;

    public TaskService(TaskRepository taskRepo, ApplicationEventPublisher publisher) {
        this.taskRepo = taskRepo;
        this.publisher = publisher;
    }

    @Transactional
    public Task assign(Long taskId, Long assigneeId) {
        Task task = taskRepo.findById(taskId).orElseThrow();
        task.setAssigneeId(assigneeId);
        taskRepo.save(task);

        publisher.publishEvent(new TaskAssignedEvent(
                taskId, assigneeId, currentUserId(), Instant.now()));

        return task;
    }
}

Bốn việc phụ giờ nằm ở bốn listener riêng, mỗi listener chỉ biết một việc:

@Component
public class AssignmentMailListener {

    private final MailSender mailSender;

    public AssignmentMailListener(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    @EventListener
    public void onTaskAssigned(TaskAssignedEvent event) {
        mailSender.sendAssignedMail(event.assigneeId(), event.taskId());
    }
}

TaskService không còn biết MailSender tồn tại. Thêm việc phụ thứ năm là thêm một listener mới, không đụng assign().

flowchart TB
    subgraph before["Truoc: goi thang tu TaskService"]
        A1["assign()"] --> A2["send mail"]
        A1 --> A3["ghi audit log"]
        A1 --> A4["cap nhat thong ke"]
        A1 --> A5["ban webhook"]
    end
    subgraph after["Sau: publish, subscribe"]
        B1["assign() publish TaskAssignedEvent"] -.-> C1["MailListener"]
        B1 -.-> C2["AuditListener"]
        B1 -.-> C3["StatsListener"]
        B1 -.-> C4["WebhookListener"]
    end
    A5 -. "refactor: tach event" .-> B1

Annotation mất hiệu lực khi bị self-call bypass qua this.method() trong CÙNG class, như bài 01 với @Cacheable. Listener không rơi vào bẫy đó: ApplicationEventMulticaster luôn gọi listener bean từ NGOÀI.

2. Vì sao @EventListener mặc định đồng bộ lại nguy hiểm?

Đây là chỗ hiểu nhầm phổ biến nhất về Spring Events: nhiều người đọc "event-driven" rồi mặc định nó chạy bất đồng bộ. Sự thật ngược lại — @EventListener mặc định chạy đồng bộ, trên đúng thread đang gọi publishEvent(), trong cùng transaction context nếu có transaction đang mở; publishEvent() block tới khi mọi listener chạy xong mới trả về.

Hệ quả trực tiếp: listener chậm thì assign() chậm theo — SMTP server phản hồi mất hai giây thì người dùng chờ đúng hai giây đó, dù họ không quan tâm mail đã gửi hay chưa. Hệ quả thứ hai nghiêm trọng hơn: listener ném exception thì exception lan ngược lên assign(), và vì method đang chạy trong @Transactional, toàn bộ transaction gốc rollback, kể cả phần lưu task hoàn toàn hợp lệ.

@Transactional
public Task assign(Long taskId, Long assigneeId) {
    Task task = taskRepo.findById(taskId).orElseThrow();
    task.setAssigneeId(assigneeId);
    taskRepo.save(task);

    publisher.publishEvent(new TaskAssignedEvent(
            taskId, assigneeId, currentUserId(), Instant.now()));
    // AssignmentMailListener chay NGAY tai day - dong bo, cung transaction
    // Mail da roi khoi SMTP server truoc khi dong ngoac nay ket thuc

    validateWorkload(assigneeId);   // vuot han muc thi throw -> ROLLBACK
    return task;
}
Tự thiết kế trước khi đọc tiếp

Mail báo "task đã được gán" đã bay ra ngoài trước khi validateWorkload kịp throw, còn DB thì chưa từng đổi vì rollback. Trước khi đọc tiếp, hãy tự nghĩ: bạn sẽ đổi gì trong cách đăng ký listener để mail chỉ gửi SAU KHI transaction chắc chắn commit?

3. @TransactionalEventListener và AFTER_COMMIT

@TransactionalEventListener giải đúng vấn đề trên: thay vì chạy ngay khi publishEvent() được gọi, nó gắn việc thực thi vào một pha cụ thể của vòng đời transaction. Mặc định, và cũng là lựa chọn đúng cho gửi mail hay thông báo, là AFTER_COMMIT — listener chỉ chạy sau khi transaction gốc đã commit thành công.

// trong AssignmentMailListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTaskAssigned(TaskAssignedEvent event) {
    mailSender.sendAssignedMail(event.assigneeId(), event.taskId());
}

Với thay đổi này, nếu validateWorkload() throw và transaction rollback, onTaskAssigned không bao giờ chạy — Spring chỉ gọi nó khi transaction đóng lại bằng commit thật sự. Mail "đã gán" giờ chỉ đi ra khi dữ liệu trong DB thật sự phản ánh đúng điều mail nói.

flowchart TB
    T0["assign bat dau - @Transactional"] --> T1["taskRepo.save(task)"]
    T1 --> T2["publisher.publishEvent(...)"]
    T2 --> T3["@EventListener chay NGAY - dong bo, cung transaction"]
    T3 --> T4{"code sau do co throw?"}
    T4 -- "co" --> T5["ROLLBACK - mail o T3 da gui roi"]
    T4 -- "khong" --> T6["COMMIT that su"]
    T6 --> T7["@TransactionalEventListener AFTER_COMMIT chay o day"]

TransactionPhase còn ba giá trị khác, ít dùng hơn nhưng nên biết:

PhaseChạy khi nào
BEFORE_COMMITNgay trước commit, vẫn còn cơ hội throw để rollback
AFTER_COMMITSau khi commit thành công (mặc định)
AFTER_ROLLBACKSau khi rollback
AFTER_COMPLETIONSau khi transaction kết thúc, commit hay rollback đều được

Không có transaction nào đang chạy khi publishEvent() được gọi thì listener @TransactionalEventListener mặc định không chạy — muốn vẫn chạy, khai thêm fallbackExecution = true.

4. Bẫy — transaction đã đóng, muốn ghi tiếp phải mở transaction mới

Listener chạy ở AFTER_COMMIT đứng NGOÀI transaction gốc — transaction đó đã đóng, connection đã trả về pool. Nếu listener cần ghi thêm vào DB, chẳng hạn lưu "đã gửi mail lúc mấy giờ" để tránh gửi trùng, nó không thể dựa vào transaction cũ vì transaction đó không còn tồn tại.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onTaskAssigned(TaskAssignedEvent event) {
    mailSender.sendAssignedMail(event.assigneeId(), event.taskId());
    mailLogRepo.save(new MailLog(event.taskId(), Instant.now()));
}

Propagation.REQUIRES_NEW, đã dạy kỹ ở khoá spring-rest-data, mở một transaction hoàn toàn mới, độc lập với transaction gốc đã đóng. Thiếu annotation này, mailLogRepo.save(...) chạy ngoài mọi transaction boundary tường minh — hành vi phụ thuộc open-in-view và các chi tiết ngầm định khác mà bạn không nên đặt cược vào. Thực tế hay gặp: chạy đúng lúc test tay (test case tự bọc transaction riêng), nhưng lên production thì âm thầm không có gì được flush xuống DB.

5. Kết hợp @Async với event

@Async chạy trên thread pool riêng nên transaction gắn qua ThreadLocal không đi theo sang thread mới; method void chạy @Async mà throw thì exception bị nuốt âm thầm trừ khi có AsyncUncaughtExceptionHandler riêng.

Listener AFTER_COMMIT vẫn chạy trên CÙNG thread đã gọi assign() — chỉ đổi THỜI ĐIỂM chạy, không đổi thread. Muốn assign() trả response ngay, kết hợp thêm @Async:

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTaskAssigned(TaskAssignedEvent event) {
    mailSender.sendAssignedMail(event.assigneeId(), event.taskId());
}

Hai annotation trả lời hai câu hỏi khác nhau: @TransactionalEventListener quyết định CHẠY KHI NÀO (chỉ sau commit), @Async quyết định CHẠY TRÊN THREAD NÀO. Đừng nhầm sang dùng thẳng @EventListener thường cộng @Async: nó vẫn kích hoạt NGAY khi publishEvent() được gọi, giữa chừng transaction, còn chưa biết commit hay rollback — @Async chỉ đổi thread thực thi, không làm listener đợi commit. Thread mới có thể gửi mail xong trước khi transaction gốc kịp commit, quay lại đúng lỗi bài này mở đầu, chỉ khó phát hiện hơn vì chạy trên thread khác.

6. Ranh giới — event trong tiến trình không bền

Mọi thứ vừa học chạy trong CÙNG một tiến trình (in-process). Event không đi qua network, không lưu vào đâu cả — chỉ là một lời gọi method Spring định tuyến tới listener, trong bộ nhớ JVM đang chạy. Ứng dụng crash đúng lúc giữa "commit" và "listener AFTER_COMMIT chạy xong" thì event mất vĩnh viễn, không retry, không hàng đợi.

Với gửi mail hay ghi log thống kê, mất một lần trong một triệu lần restart là chấp nhận được. Nhưng nếu việc phụ đó quan trọng, ví dụ trừ tiền ví hay đồng bộ kế toán, mất một event là mất tiền thật. Bài toán đó cần outbox pattern: ghi event vào một bảng trong chính DB, CÙNG transaction với thay đổi nghiệp vụ, rồi một tiến trình riêng đọc bảng đó và phát ra message broker như Kafka. Khoá học này dừng ở việc bạn biết đường đi tiếp.

7. Tự thiết kế — ráp bốn quyết định vào một chữ ký

Tự viết trước khi xem đáp án

assign() giờ cần thêm: gửi mail "đã gán" VÀ ghi AssignmentLog, chỉ sau khi transaction commit, không chặn chờ SMTP. Viết chữ ký onTaskAssigned, trả lời bốn quyết định: (1) @EventListener hay @TransactionalEventListener? (2) TransactionPhase nào? (3) Cần @Async không, đổi điều gì? (4) Ghi AssignmentLog cần Propagation.REQUIRES_NEW không, vì sao?

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onTaskAssigned(TaskAssignedEvent event) {
    mailSender.sendAssignedMail(event.assigneeId(), event.taskId());
    assignmentLogRepo.save(new AssignmentLog(event.taskId(), Instant.now()));
}

Bốn quyết định dựa trên §3–§5: (1) chỉ @TransactionalEventListener hoãn thực thi tới đúng pha transaction, @EventListener luôn chạy ngay. (2) AFTER_COMMIT — task phải nằm trong DB trước khi gửi mail hay ghi log. (3) Có — @Async để assign() trả response ngay, chỉ đổi THREAD, không đổi thời điểm. (4) Có — transaction gốc đã đóng tại AFTER_COMMIT, thiếu REQUIRES_NEW thì log dễ âm thầm không được flush.

Pitfall thường gặp

Biến event thành luồng nghiệp vụ chính: khi bước B bắt buộc phải xảy ra để bước A coi là thành công, quan hệ đó phải là lời gọi method trực tiếp, đọc được bằng cách lần theo code từ trên xuống. Event hợp cho việc phụ độc lập, fail riêng được — dùng cho luồng chính khiến lỗi khó lần theo, phải nhảy qua nhiều listener rải rác mới ráp lại được.

Giả định thứ tự listener: nhiều listener cùng nghe một event không đảm bảo chạy theo thứ tự khai báo. Cần thứ tự cụ thể thì dùng @Order(1), @Order(2) tường minh, đừng dựa vào "test chạy đúng thứ tự nên production cũng vậy".

Nuốt exception im lặng khi listener chạy @Async: method void throw exception, Spring chỉ log qua handler mặc định rồi tiếp tục như chưa có gì xảy ra. Cấu hình AsyncUncaughtExceptionHandler riêng (đã học ở bài @Async) để biết listener có đang âm thầm fail.

Đào sâu

📚 Deep Dive Spring Reference

Spec / reference chính thức:

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

Tóm tắt

  • Method bỗng chậm hoặc rollback sau khi thêm @EventListener? Nghi ngay nó — mặc định chạy đồng bộ, cùng thread và transaction, nên lỗi hay độ trễ đổ thẳng lên method gốc.
  • Việc phụ có tác dụng phụ không thể thu hồi (mail, webhook)? Hỏi trước: annotation đang dùng có đợi transaction đóng lại, hay chỉ "nghe" đúng tên.
  • Listener chạy AFTER_COMMIT đứng ngoài transaction gốc; cần ghi DB tiếp thì phải khai Propagation.REQUIRES_NEW tường minh, đừng dựa vào ngầm định.
  • Ghép @Async sau @TransactionalEventListener, không phải thay cho nó — dùng riêng @EventListener cộng @Async đưa bạn về lại đúng lỗi rollback-nhưng-mail-đã-gửi.
  • Trước khi dùng Spring Events cho một luồng: mất một event có làm mất tiền hay dữ liệu thật không? Có thì cần outbox pattern hoặc broker thật.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao nói @EventListener chạy "đồng bộ theo mặc định" lại là điều dễ gây hiểu nhầm nhất về Spring Events? Nếu AssignmentMailListener gọi một SMTP server phản hồi chậm hai giây, điều gì xảy ra với người dùng đang gọi assign()?

Tên gọi "event-driven" khiến nhiều người mặc định nó chạy nền. Thực tế publishEvent() gọi trực tiếp từng listener trên đúng thread hiện tại, block tới khi tất cả chạy xong mới trả về. Với SMTP chậm hai giây, người gọi assign() phải chờ đúng hai giây đó mới nhận response.

Q2
Trong ví dụ transaction rollback ở bài này, tại sao mail "đã gán" vẫn được gửi dù dữ liệu task chưa từng thay đổi thật sự trong DB?

@EventListener chạy ngay tại dòng publishEvent(), NGAY GIỮA transaction, trước khi biết commit hay rollback. Gửi mail là tác dụng phụ bên ngoài DB — SMTP đã nhận và gửi đi thì không cơ chế rollback nào kéo lại được. validateWorkload() throw sau đó, DB rollback đúng nghĩa, nhưng mail đã rời hệ thống từ trước.

Q3
Listener chạy ở AFTER_COMMIT gọi mailLogRepo.save(...) nhưng dữ liệu không bao giờ xuất hiện trong DB, dù không có exception nào ném ra. Nguyên nhân nhiều khả năng nhất là gì, và cách fix?

Listener thiếu @Transactional(propagation = Propagation.REQUIRES_NEW). Ở AFTER_COMMIT, transaction gốc đã đóng hẳn nên thao tác ghi chạy ngoài mọi transaction boundary tường minh — tuỳ open-in-view, thay đổi có thể không bao giờ được flush xuống DB mà không báo lỗi. Fix: khai REQUIRES_NEW tường minh.

Q4
Nếu dùng thẳng @EventListener (không phải @TransactionalEventListener) cộng @Async cho cùng một listener, điều gì khác so với dùng @TransactionalEventListener(AFTER_COMMIT) cộng @Async? Giải thích bằng đúng thời điểm listener được kích hoạt.

@EventListener luôn kích hoạt NGAY tại dòng publishEvent(), bất kể transaction đã commit hay chưa; @Async chỉ đổi thread thực thi. Thread mới có thể gửi mail xong TRƯỚC KHI transaction gốc kịp commit. Dùng @TransactionalEventListener(AFTER_COMMIT) cộng @Async thì kích hoạt bị hoãn tới sau commit thật, rồi mới đẩy sang thread khác.

Q5
Bạn được giao thiết kế luồng "gán task xong thì đồng bộ dữ liệu sang hệ thống kế toán ngoài, tuyệt đối không được mất dù server crash". Spring Events, kể cả @TransactionalEventListener(AFTER_COMMIT), có đủ đảm bảo yêu cầu đó không? Vì sao, và bạn đề xuất gì thay thế?

Không đủ. Spring Events chạy in-process, event chỉ tồn tại trong bộ nhớ JVM khi publishEvent() đang chạy — crash đúng lúc giữa "commit xong" và "listener AFTER_COMMIT chạy xong" là mất vĩnh viễn. Cần outbox pattern: ghi event thành một dòng trong bảng của chính DB, CÙNG transaction với thay đổi task, rồi một tiến trình riêng đọc bảng đó và phát ra message broker bền như Kafka.

Bài tiếp theo: 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

Bài tiếp theo

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