Sự kiện ứng dụng là cách tách rời hai phần mà không cần hàng đợi. Nó nghe như hô một thông báo vào phòng rồi bỏ đi làm việc khác. Nhưng phép đo cho thấy nó thật ra là: bạn đi từng bàn, vỗ vai từng người, đứng chờ họ làm xong — 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ọi — http-nio-8080-exec-2, luồng của request. Bạn đang tự đi vỗ vai từng người, không phải để lại mẩu tin cho ai đó xử lý sau.

Listener #3 không bao giờ chạy. Một listener ném là dây chuyền dừng — một người làm rơi, bạn dừng luôn ở đó.

publishEvent ném ra tận nơi gọi. Nó không nuốt lỗi; lời trách quay ngược về chính bạn.

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 — bạn chỉ đi vỗ vai mọi người sau khi giấy tờ của chính mình đã ký xong. 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ình và trong bộ nhớ. Ứng dụng chết là sự kiện chưa xử lý biến mất — mẩu tin bạn vừa hô tan vào không khí.

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ý. Đó là cái hòm thư thật: bỏ thư vào rồi đi, người đọc có sập cũng không phải việc của bạn, và lá thư còn đó kể cả khi toà nhà đóng cửa.

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.

Nếu chỉ soi một thứ sau bài này, điểm danh mọi chỗ phát sự kiện trong 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 có @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.

Mẫu số chung

Một "sự kiện" trong-tiến-trình, mặc định, là một lời gọi phương thức đồng bộ trá hình: người phát chạy từng listener ngay tại chỗ, trên luồng của mình, trong phạm vi lỗi và giao dịch của mình. Đây là mẫu Observer ở khắp nơi: EventEmitter của Node phát đồng bộ, sự kiện của .NET, EventBus của Guava, cơ chế phát sự kiện của trình duyệt. Cái bẫy nằm ở ngôn từ: "publish / subscribe / event / listener" mượn từ thế giới nhắn tin và thì thầm "bất đồng bộ, cô lập lỗi", nhưng phát trong-tiến-trình không cho bạn thứ nào trong hai, trừ khi bạn tự thêm (@Async, một pool). Tách rời về cú pháp không phải tách rời lúc chạy — một ngoại lệ ném ra đi thẳng về người phát, trừ khi bạn đã dựng một ranh giới thật.

Và ranh giới thật — một người nghe sập không kéo người phát chết theo, sống qua khởi động lại, thử lại được, băng qua ranh giới tiến trình — đòi một hàng đợi bền, theo một cái thang: sự kiện trong bộ nhớ (nhanh, và có một siêu năng lực riêng: nguyên-tử-cùng-giao-dịch, nhưng chung số phận và mất khi khởi động lại) → mẫu outbox (ghi CSDL nguyên tử cộng bàn giao bền, không cần giao dịch phân tán) → một broker đầy đủ như Kafka/RabbitMQ (xuyên dịch vụ, phát lại được). Khớp cơ chế với cái bảo đảm bạn cần: siêu năng lực duy nhất của sự kiện trong bộ nhớ — được-ăn-cả-ngã-về-không cùng giao dịch bao quanh — tồn tại chính vì nó không tách rời; đến lúc cần tách rời thật, hãy trèo lên thang.

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.