Sự kiện ứng dụng là cách tách rời hai phần mà không cần hàng đợi. Bài này đo hành vi thật của nó, và nó không như tên gọi gợi ý.

Nó chạy đồng bộ

@EventListener @Order(1) void mot(DonMoi e){ ghi("#1"); }
@EventListener @Order(2) void hai(DonMoi e){ ghi("#2"); throw new IllegalStateException("no"); }
@EventListener @Order(3) void ba(DonMoi e){ ghi("#3 - CÓ CHẠY KHÔNG?"); }
  đồng bộ #1 trên http-nio-8080-exec-2
  đồng bộ #2 (NÉM)
  >>> publishEvent NÉM RA: IllegalStateException

Ba điều đọc ra được, và cả ba đều ngược với trực giác về chữ "sự kiện":

Listener chạy trên chính luồng gọihttp-nio-8080-exec-2, luồng của request.

Listener #3 không bao giờ chạy. Một listener ném là dây chuyền dừng.

publishEvent ném ra tận nơi gọi. Nó không nuốt lỗi.

publishEvent hành xử như một lời gọi phương thức, không như gửi thông điệp vào hàng đợi. Nếu bạn dùng nó để "tách rời" hai phần rồi cho rằng phần kia hỏng không ảnh hưởng phần này — bạn nhầm.

Và listener @Async trong phép đo của tôi cũng không chạy, vì chuỗi đã dừng trước khi tới nó.

Điều đó có tốt không

Có, cho đúng mục đích của nó.

Chạy đồng bộ nghĩa là sự kiện nằm trong cùng giao dịch với người phát. Ghi log kiểm toán, cập nhật bộ đếm, kiểm quy tắc nghiệp vụ — tất cả cùng thành công hoặc cùng rollback. Đó là thứ hàng đợi không cho bạn.

Nhưng phải dùng đúng chỗ: sự kiện ứng dụng là cách tổ chức mã trong một tiến trình, không phải cơ chế tích hợp giữa các dịch vụ.

Bất đồng bộ khi cần

@Async @EventListener
public void gửiThư(DonMoi e) { ... }

Thêm @Async là listener chạy trên pool riêng, và ngoại lệ của nó không ảnh hưởng người phát.

Nhưng nhớ bài 53: ngoại lệ trong @Async trả về void bị nuốt hoàn toàn nếu không có AsyncUncaughtExceptionHandler. Và bài 52: trace id không lan sang luồng đó.

@TransactionalEventListener

Đây là công cụ tôi thấy giá trị nhất trong bài này.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sauKhiLuu(DonMoi e) { thanhToan.goi(e.ma()); }

Listener chỉ chạy sau khi giao dịch commit thành công. Giao dịch rollback thì nó không chạy.

Nó giải quyết chính xác vấn đề ở bài 26: gọi HTTP ra ngoài bên trong giao dịch giữ kết nối CSDL suốt lời gọi mạng, và với RestClient không có phép chờ (bài 20) thì pool cạn sau vài chục request.

Blog này dùng đúng mẫu đó để gửi thư báo bài mới — thư chỉ gửi khi bài đã thật sự được lưu.

Bốn giai đoạn:

  BEFORE_COMMIT      còn trong giao dịch, còn ghi CSDL được
  AFTER_COMMIT       mặc định — việc đã chắc chắn xong
  AFTER_ROLLBACK     dọn dẹp khi hỏng
  AFTER_COMPLETION   cả hai trường hợp

Hai điều dễ vấp:

AFTER_COMMIT mặc định vẫn chạy trên luồng gọi, chỉ là sau khi commit. Muốn thật sự rời ra thì thêm @Async.

Ghi CSDL trong AFTER_COMMIT cần giao dịch mới. Giao dịch cũ đã đóng, nên @Transactional(REQUIRES_NEW) — và nhớ cảnh báo ở bài 27 về việc REQUIRES_NEW không phải lúc nào cũng cô lập thật.

Sự kiện có sẵn của Spring

@EventListener
public void sanSang(ApplicationReadyEvent e) { ... }   // sau khi khởi động XONG

ApplicationReadyEvent là chỗ đúng cho việc khởi tạo cần ứng dụng đã sẵn sàng — đúng hơn @PostConstruct nhiều, vì lúc @PostConstruct chạy thì các bean khác có thể chưa xong.

Các sự kiện khác đáng biết: ContextRefreshedEvent, ContextClosedEvent (dọn dẹp lúc tắt), AvailabilityChangeEvent (đổi trạng thái liveness/readiness, bài 51), và các sự kiện xác thực của Spring Security — AuthenticationSuccessEvent, AuthenticationFailureBadCredentialsEvent — rất hợp để đếm số lần đăng nhập sai (bài 40).

Ba nguyên tắc

Sự kiện phải bất biến và tự đủ. Dùng record, và truyền id chứ không truyền entity — nhắc lại từ bài 27, vì entity thuộc về một persistence context cụ thể.

Đừng dựa vào thứ tự. @Order hoạt động, nhưng phụ thuộc vào thứ tự listener là dấu hiệu bạn cần một lời gọi phương thức tường minh chứ không phải sự kiện.

Đừng dùng sự kiện cho luồng chính. Đọc mã có sự kiện khó hơn nhiều: bạn thấy publishEvent nhưng không thấy ai nghe. Dùng cho việc phụ — thông báo, kiểm toán, cache — chứ không cho các bước của nghiệp vụ chính.

Khi nào cần hàng đợi thật

Sự kiện ứng dụng chỉ sống trong một tiến trìnhtrong bộ nhớ. Ứng dụng chết là sự kiện chưa xử lý biến mất.

Cần hàng đợi thật (Kafka, RabbitMQ, hoặc một bảng trong CSDL) khi: người nhận nằm ở dịch vụ khác; sự kiện phải sống qua khởi động lại; cần thử lại; cần xem lại lịch sử; hoặc cần nhiều bản sao cùng xử lý.

Mẫu trung gian đáng biết là outbox: ghi sự kiện vào một bảng trong cùng giao dịch với dữ liệu nghiệp vụ, rồi một tiến trình riêng đọc bảng đó và gửi đi. Nó cho bạn tính nguyên tử của giao dịch cộng độ bền của hàng đợi, mà không cần giao dịch phân tán.

Thử ba mươi giây

grep -rn "publishEvent" --include='*.java' src/main

Với mỗi kết quả, hỏi: nếu một listener ném ngoại lệ, phương thức này có nên hỏng theo không?

Trả lời "không" mà listener đó không@Async hoặc @TransactionalEventListener — bạn vừa tìm thấy một chỗ hỏng dây chuyền đang chờ dữ liệu thật.

Ngày mai: luồng ảo trong Spring Boot — một dòng cấu hình, và 200 request đồng thời nhanh hơn gấp đôi.