Đặt một order là một sự kiện nghiệp vụ. Gửi email xác nhận, cập nhật projection tồn kho và ghi một audit row là ba hệ quả của nó, và không hệ quả nào thuộc về OrderService.place. Cơ chế event của Spring là cách tách sự kiện khỏi hệ quả ngay trong cùng một process, và giá của nó là một interface với một annotation.
Đây cũng là góc của Spring mà lời khuyên cũ đi nhanh nhất. Phần lớn bài viết về @EventListener có trước khi @TransactionalEventListener tồn tại, và phần lớn bài viết về @TransactionalEventListener có trước khi Spring Framework 6.1 thêm kiểm tra khiến application từ chối khởi động nếu dùng sai, nên bài này cho thấy Spring Boot 4.1.1 trên Java 21 thực sự làm gì, với database H2 in-memory. App chạy ở port 8204 thay vì 8080 mặc định, nên bạn sẽ thấy port này trong các lệnh curl và trong tên thread nio-8204-exec-N ở log.
![]()
Những event Spring Boot tự publish lúc khởi động — ContextRefreshedEvent, ApplicationStartedEvent, ApplicationReadyEvent — đã được trace trong bài 2 của khoá này, kèm vị trí của dòng Started … in và cái giá của một exception trong listener readiness; bài này nói về event do bạn publish và không lặp lại phần đó.
Project và event
Toàn bộ cơ chế nằm trong spring-context, thứ mà mọi application Spring Boot đều đã có. Không có starter nào phải thêm, không có @EnableEvents, không có gì phải cấu hình. Một project web cộng JPA là đủ:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,h2" -o demo.zip./gradlew dependencies --configuration runtimeClasspath cho thấy cỗ máy event đến từ đâu — chính spring-boot kéo nó vào:
| +--- org.springframework.boot:spring-boot:4.1.1
| | +--- org.springframework:spring-core:7.0.9
| | \--- org.springframework:spring-context:7.0.9
| | +--- org.springframework:spring-aop:7.0.9
...
| | | +--- org.springframework.boot:spring-boot-transaction:4.1.1
| | | | +--- org.springframework.boot:spring-boot-persistence:4.1.1
| | | | | \--- org.springframework:spring-tx:7.0.9spring-context cho bạn ApplicationEventPublisher và @EventListener; spring-tx, đi kèm spring-boot-starter-data-jpa, cho bạn @TransactionalEventListener. Cả hai đã có sẵn trên classpath của project ở trên.
Hai setting quan trọng cho các phép đo. Log pattern in ra tên thread, vốn là một nửa bài viết này, còn open-in-view được tắt để ranh giới transaction đúng chỗ annotation đặt nó chứ không phải chỗ một servlet filter đặt nó:
server.port=8204
spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
logging.pattern.console=%d{HH:mm:ss.SSS} %5p [%15.15t] %-40.40logger{39} : %m%n
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUGTrong Spring hiện đại, event là một giá trị thuần. Nó không kế thừa ApplicationEvent, không implement gì cả, và record là hình dạng chính xác dành cho nó — immutable, với toString sinh sẵn nên log rất đẹp. Một marker interface chỉ đáng thêm vì lát nữa có một listener muốn nhìn thấy toàn bộ họ event:
package com.example.demo.order;
/** A supertype every shop event carries, so one listener can see them all. */
public interface ShopEvent {
}package com.example.demo.order;
import java.math.BigDecimal;
/** An event is a value: a record with no base class and no Spring type in it. */
public record OrderPlaced(Long orderId, String sku, int quantity, BigDecimal total) implements ShopEvent {
}Mọi listener trong bài đều in ra cùng ba thông tin về chỗ nó đang chạy, nên một helper duy nhất mang chúng:
package com.example.demo.support;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** One place to print where a listener is actually running. */
public final class Tx {
private Tx() {
}
public static String where() {
return "thread=" + Thread.currentThread().getName()
+ " actualTx=" + TransactionSynchronizationManager.isActualTransactionActive()
+ " syncActive=" + TransactionSynchronizationManager.isSynchronizationActive()
+ " txName=" + TransactionSynchronizationManager.getCurrentTransactionName();
}
}Publisher là ApplicationEventPublisher, inject qua constructor như mọi bean khác. ApplicationContext implement nó, nên thứ bạn nhận được chính là context — nhưng phụ thuộc vào interface hẹp giúp service dễ test với mock và nói rõ class thực sự cần gì:
package com.example.demo.order;
import com.example.demo.support.Tx;
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final OrderRepository orders;
private final ApplicationEventPublisher events;
public OrderService(OrderRepository orders, ApplicationEventPublisher events) {
this.orders = orders;
this.events = events;
}
@Transactional
public Long place(String sku, int quantity, BigDecimal total) {
Order order = orders.save(new Order(sku, quantity, total));
log.info("place() about to publish {}", Tx.where());
events.publishEvent(new OrderPlaced(order.getId(), sku, quantity, total));
log.info("place() publishEvent returned, method is about to return");
return order.getId();
}
@Transactional
public Long placeAndFail(String sku, int quantity, BigDecimal total) {
Order order = orders.save(new Order(sku, quantity, total));
events.publishEvent(new OrderPlaced(order.getId(), sku, quantity, total));
log.info("placeAndFail() published, now throwing");
throw new PaymentDeclined("card declined for " + sku);
}
}Order và AuditRow là entity JPA bình thường với một id và vài column, còn OrderController mở POST /api/orders cùng GET /api/counts trả về số row của cả hai bảng. Bản thân transaction — @Transactional làm gì, khi nào rollback, vì sao bean được inject là một CGLIB proxy — là bài 30 của khoá Basics và được coi là đã biết ở đây.
publishEvent thực sự làm gì
Listener đầu tiên là email xác nhận. Nó là một method trên một bean bình thường:
package com.example.demo.listener;
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class ConfirmationEmailListener {
private static final Logger log = LoggerFactory.getLogger(ConfirmationEmailListener.class);
@EventListener
@Order(10)
public void sendConfirmation(OrderPlaced event) {
log.info("email order={} {}", event.orderId(), Tx.where());
}
}Một request, với bốn listener khác cùng đăng ký trên event đó:
curl -s -X POST "http://localhost:8204/api/orders?sku=SKU-1&quantity=2&total=249.90"Đây là những gì application đã log. Timestamp bị bỏ và cột logger được rút gọn còn tên class; ngoài ra không sửa gì:
INFO [nio-8204-exec-1] OrderService : place() about to publish thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] PayloadPeekListener : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderPlaced[orderId=1, sku=SKU-1, quantity=2, total=249.90]
INFO [nio-8204-exec-1] ConfirmationEmailListener : email order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] StockProjectionListener : stock order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] StockProjectionListener : large order=1 total=249.90 (condition matched)
INFO [nio-8204-exec-1] ChainingListener : confirm order=1 returning an OrderConfirmed
INFO [nio-8204-exec-1] GenericEventListeners : supertype listener saw OrderConfirmed
INFO [nio-8204-exec-1] ChainingListener : onConfirmed OrderConfirmed[orderId=1, reference=REF-1] thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] GenericEventListeners : supertype listener saw OrderPlaced
INFO [nio-8204-exec-1] OrderService : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction commit
INFO [nio-8204-exec-1] OrderController : controller after service thread=http-nio-8204-exec-1 actualTx=false syncActive=false txName=nullMười hai dòng đó chốt lại ba sự thật, và đó đúng là ba thứ người ta hay hiểu sai nhất.
Publish là một lời gọi method, không phải gửi message. publishEvent chỉ return sau khi listener cuối cùng chạy xong. Không có queue, không broker, không buffer: multicaster duyệt qua các listener khớp và gọi chúng.
Mọi listener đều chạy trên http-nio-8204-exec-1 — đúng thread worker của Tomcat đang xử lý request, cũng là thread mà place() đang chạy.
Mọi listener đều chạy bên trong transaction của publisher. TransactionSynchronizationManager.isActualTransactionActive() là true và getCurrentTransactionName() là com.example.demo.order.OrderService.place trong từng listener, còn Initiating transaction commit xuất hiện sau tất cả. Listener nào ghi database ở đây là ghi vào transaction của caller, và caller rollback thì việc của listener rollback theo.

Dòng thứ hai của log là lớp bọc. ApplicationEventMulticaster chỉ làm việc với ApplicationEvent, nên payload không phải ApplicationEvent sẽ bị đóng hộp: AbstractApplicationContext.publishEvent bọc nó trong một PayloadApplicationEvent có source là context. Bình thường bạn không thấy nó, nhưng một listener có thể hỏi xin:
package com.example.demo.listener;
import com.example.demo.order.OrderPlaced;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.PayloadApplicationEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class PayloadPeekListener {
private static final Logger log = LoggerFactory.getLogger(PayloadPeekListener.class);
@EventListener
@Order(5)
public void peek(PayloadApplicationEvent<OrderPlaced> event) {
log.info("wrapper class={} source={} payload={}",
event.getClass().getSimpleName(),
event.getSource().getClass().getName(),
event.getPayload());
}
}Hãy khai báo theo payload type, đừng khai báo theo lớp bọc. Dạng lớp bọc chỉ dành cho trường hợp hiếm khi bạn cần getSource() hay getTimestamp(), và — như phần generic bên dưới đo được — cách nó khớp type có một cạnh rất sắc.
Thứ tự, condition và listener trả về event
Nhiều listener trên cùng một event chạy theo một thứ tự xác định, và @Order quyết định thứ tự đó. Khác với trên BeanPostProcessor, nơi bài 2 đo được @Order bị bỏ qua hoàn toàn, ở đây annotation chính là thứ multicaster sort theo: AbstractApplicationEventMulticaster sort danh sách listener lấy về bằng AnnotationAwareOrderComparator, và comparator này đọc @Order. Trace ở trên chạy 5, 10, 20, 30, 40 rồi mới đến listener không có annotation.
condition nhận một biểu thức SpEL, được evaluate trên event trước khi method được gọi. #event — hoặc event, hoặc #root.event — là payload; #root.args là mảng argument, và argument đầu tiên còn có thể viết là args[0], #a0 hay #p0. Method chạy khi biểu thức cho ra boolean true hoặc một trong các chuỗi "true", "on", "yes" và "1":
package com.example.demo.listener;
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class StockProjectionListener {
private static final Logger log = LoggerFactory.getLogger(StockProjectionListener.class);
@EventListener
@Order(20)
public void reserveStock(OrderPlaced event) {
log.info("stock order={} {}", event.orderId(), Tx.where());
}
@EventListener(condition = "#event.total > 100")
@Order(30)
public void flagLargeOrder(OrderPlaced event) {
log.info("large order={} total={} (condition matched)", event.orderId(), event.total());
}
}Một order thứ hai, lần này tổng là 40.00:
INFO [nio-8204-exec-3] PayloadPeekListener : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderPlaced[orderId=2, sku=SKU-2, quantity=1, total=40.00]
INFO [nio-8204-exec-3] ConfirmationEmailListener : email order=2 thread=http-nio-8204-exec-3 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-3] StockProjectionListener : stock order=2 thread=http-nio-8204-exec-3 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-3] ChainingListener : confirm order=2 returning an OrderConfirmed
INFO [nio-8204-exec-3] GenericEventListeners : supertype listener saw OrderConfirmed
INFO [nio-8204-exec-3] ChainingListener : onConfirmed OrderConfirmed[orderId=2, reference=REF-2] thread=http-nio-8204-exec-3 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-3] GenericEventListeners : supertype listener saw OrderPlaced
INFO [nio-8204-exec-3] OrderService : place() publishEvent returned, method is about to returnDòng large biến mất và không có gì khác thay đổi: listener @Order(30) bị bỏ qua còn phần còn lại của chuỗi chạy bình thường. condition được evaluate trên thread publish, bên trong transaction của publisher, nên một biểu thức throw sẽ throw vào publisher y hệt thân listener — hãy giữ nó ở mức so sánh field và đẩy mọi thứ phức tạp hơn vào trong method.
Một listener cũng có thể trả về một giá trị, và Spring publish giá trị đó như một event tiếp theo:
package com.example.demo.listener;
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class ChainingListener {
private static final Logger log = LoggerFactory.getLogger(ChainingListener.class);
/** A non-void listener: Spring publishes the returned value as a further event. */
@EventListener
@Order(40)
public OrderConfirmed confirm(OrderPlaced event) {
log.info("confirm order={} returning an OrderConfirmed", event.orderId());
return new OrderConfirmed(event.orderId(), "REF-" + event.orderId());
}
@EventListener
public void onConfirmed(OrderConfirmed event) {
log.info("onConfirmed {} {}", event, Tx.where());
}
}Nó chạy được, và log cho thấy chính xác cách nó chạy. Nhìn lại thứ tự: confirm return, các listener của OrderConfirmed chạy ngay lập tức, rồi mới đến lượt listener OrderPlaced cuối cùng. Dispatch lồng nhau xảy ra bên trong dispatch ngoài. ApplicationListenerMethodAdapter.handleResult còn mở gói một array hay một Collection thành mỗi phần tử một event, và một CompletionStage thành một event được publish khi stage hoàn tất.
Thông minh, và thông minh chính là vấn đề. Thứ tự publish của một event giờ phụ thuộc vào listener nào trả nó về, stack trace của event thứ hai chạy xuyên qua listener thứ nhất, và không có chỗ nào khai báo rằng đặt order cũng đồng thời confirm order. Chỉ dùng event trả về khi event thứ hai thực sự dẫn xuất từ event thứ nhất và cặp đó được đọc như một khối; publish tường minh — inject ApplicationEventPublisher vào listener — ngay khi có ai đó phải hỏi event thứ hai từ đâu ra.
Nghe theo supertype và theo generic type
Một listener khai báo theo supertype nhận được mọi subtype. GenericEventListeners.onAnyShopEvent(ShopEvent) đã thấy cả OrderPlaced lẫn OrderConfirmed trong các trace ở trên, không cần đăng ký gì ngoài kiểu của parameter. Đó là nửa rẻ và chắc chắn của chuyện khớp type.
Generic là nửa còn lại. Hai event, giống hệt nhau trừ việc một cái chịu giúp container:
package com.example.demo.generic;
/** A generic event with no help for the container: T is erased at runtime. */
public record OrderEvent<T>(String kind, T subject) {
}package com.example.demo.generic;
import org.springframework.core.ResolvableType;
import org.springframework.core.ResolvableTypeProvider;
/** The same event, telling the container what T actually is. */
public record TypedOrderEvent<T>(String kind, T subject) implements ResolvableTypeProvider {
@Override
public ResolvableType getResolvableType() {
return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(subject));
}
}Năm listener, phủ đủ mọi cách gọi tên type:
package com.example.demo.generic;
import com.example.demo.order.ShopEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class GenericEventListeners {
private static final Logger log = LoggerFactory.getLogger(GenericEventListeners.class);
@EventListener
public void onAnyShopEvent(ShopEvent event) {
log.info("supertype listener saw {}", event.getClass().getSimpleName());
}
@EventListener
public void onAnyOrderEvent(OrderEvent<?> event) {
log.info("OrderEvent<?> got {}", event);
}
@EventListener
public void onRawOrderEvent(OrderEvent event) {
log.info("OrderEvent (raw) got {}", event);
}
@EventListener
public void onProductEvent(OrderEvent<Product> event) {
log.info("OrderEvent<Product> got {}", event);
}
@EventListener
public void onTypedProductEvent(TypedOrderEvent<Product> event) {
log.info("TypedOrderEvent<Product> got {}", event);
}
}Publish mỗi loại một lần — một subject Product và một subject Customer, cho cả hai kiểu event — cho ra kết quả sau. Còn có onCustomerEvent và onTypedCustomerEvent, giống hệt chỉ khác type argument:
INFO [nio-8204-exec-2] GenericController : --- publishing OrderEvent<Product> ---
INFO [nio-8204-exec-2] PayloadPeekListener : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderEvent[kind=added, subject=Product[sku=SKU-1]]
INFO [nio-8204-exec-2] GenericEventListeners : OrderEvent<?> got OrderEvent[kind=added, subject=Product[sku=SKU-1]]
INFO [nio-8204-exec-2] GenericEventListeners : OrderEvent (raw) got OrderEvent[kind=added, subject=Product[sku=SKU-1]]
INFO [nio-8204-exec-2] GenericController : --- publishing OrderEvent<Customer> ---
INFO [nio-8204-exec-2] PayloadPeekListener : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderEvent[kind=registered, subject=Customer[email=a@b.c]]
INFO [nio-8204-exec-2] GenericEventListeners : OrderEvent<?> got OrderEvent[kind=registered, subject=Customer[email=a@b.c]]
INFO [nio-8204-exec-2] GenericEventListeners : OrderEvent (raw) got OrderEvent[kind=registered, subject=Customer[email=a@b.c]]
INFO [nio-8204-exec-2] GenericController : --- publishing TypedOrderEvent<Product> ---
INFO [nio-8204-exec-2] GenericEventListeners : TypedOrderEvent<Product> got TypedOrderEvent[kind=added, subject=Product[sku=SKU-1]]
INFO [nio-8204-exec-2] GenericController : --- publishing TypedOrderEvent<Customer> ---
INFO [nio-8204-exec-2] GenericEventListeners : TypedOrderEvent<Customer> got TypedOrderEvent[kind=registered, subject=Customer[email=a@b.c]]Hãy đọc bốn dòng đầu hai lần. Truyền miệng nói rằng erasure làm listener OrderEvent<Product> nhận luôn cả OrderEvent<Customer>. Thực tế còn tệ hơn: onProductEvent không hề được gọi, với cả hai event. Một listener đặt tên một type argument cụ thể trên một generic event thường sẽ lặng lẽ không nhận được gì, không warning, không lỗi khởi động, không một dòng log.
Lý do nằm ở ba dòng của ApplicationListenerMethodAdapter:
public boolean supportsEventType(ResolvableType eventType) {
for (ResolvableType declaredEventType : this.declaredEventTypes) {
if (eventType.hasUnresolvableGenerics() ?
declaredEventType.toClass().isAssignableFrom(eventType.toClass()) :
declaredEventType.isAssignableFrom(eventType)) {
return true;
}
if (PayloadApplicationEvent.class.isAssignableFrom(eventType.toClass())) {
ResolvableType payloadType = eventType.as(PayloadApplicationEvent.class).getGeneric();
if (declaredEventType.isAssignableFrom(payloadType)) {
return true;
}
if (payloadType.resolve() == null) {
// Always accept such event when the type is erased
return true;
}
}
}
return false;
}ResolvableType.forInstance(new OrderEvent<>("added", product)) chỉ nhìn thấy raw class, nên payload type giải ra là OrderEvent<T> với T chưa xác định. OrderEvent<Product> không assignable từ đó, còn payload type resolve ra OrderEvent.class chứ không phải null, nên lối thoát cuối cùng cũng không kích hoạt. OrderEvent<?> và raw OrderEvent thì khớp, vì wildcard và raw type chấp nhận một argument chưa xác định.
Cũng chính ba dòng đó giải thích vì sao listener theo lớp bọc lại nhận nhầm payload. PayloadPeekListener khai báo PayloadApplicationEvent<OrderPlaced>, vậy mà nó log cả hai lần publish OrderEvent — event type có generic không resolve được, nên nhánh đầu tiên tụt xuống so sánh raw class và PayloadApplicationEvent khớp PayloadApplicationEvent. Nó không chạy với TypedOrderEvent, loại có generic resolve được. Khai báo listener theo lớp bọc thì hãy chuẩn bị nhận cả payload bạn không hề muốn.
ResolvableTypeProvider xoá sạch vấn đề này bằng bốn dòng code, và TypedOrderEvent<Product> với TypedOrderEvent<Customer> mỗi cái chỉ nhận đúng phần của mình. Cách khác là đừng làm event generic: ProductAdded và CustomerRegistered thành hai record rẻ hơn một type parameter và định tuyến hoàn hảo.
Listener throw exception thì publisher chịu gì
Vì dispatch là một lời gọi method bên trong transaction của publisher, listener throw nghĩa là throw vào mặt publisher. Đây là listener email hỏng, với một listener nữa đứng sau:
package com.example.demo.listener;
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class BrokenEmailListener {
private static final Logger log = LoggerFactory.getLogger(BrokenEmailListener.class);
@EventListener
@Order(10)
public void sendConfirmation(OrderPlaced event) {
log.info("email order={} {}", event.orderId(), Tx.where());
throw new IllegalStateException("SMTP server refused the message");
}
@EventListener
@Order(20)
public void reserveStock(OrderPlaced event) {
log.info("stock order={} this line never appears", event.orderId());
}
}curl -s -i -X POST "http://localhost:8204/api/orders?sku=SKU-1&quantity=2&total=249.90"
curl -s "http://localhost:8204/api/counts"HTTP/1.1 500
{"timestamp":"2026-09-18T03:09:15.670Z","status":500,"error":"Internal Server Error","path":"/api/orders"}
{"orders":0,"audit":0}Order biến mất. Không phải "biến mất khỏi projection" — orders là 0, row đã bị rollback, vì exception đi ra khỏi publishEvent, ra khỏi place(), rồi qua transaction interceptor, nơi áp dụng đúng quy tắc rollback thông thường. Log và stack nói rõ điều đó, và stack là bằng chứng rõ ràng nhất trong cả bài:
INFO [nio-8204-exec-1] OrderService : place() about to publish thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] BrokenEmailListener : email order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction rollback
DEBUG [nio-8204-exec-1] JpaTransactionManager : Rolling back JPA transaction on EntityManager [SessionImpl(467824594<open>)]
ERROR [nio-8204-exec-1] [dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalStateException: SMTP server refused the message] with root cause
java.lang.IllegalStateException: SMTP server refused the message
at com.example.demo.listener.BrokenEmailListener.sendConfirmation(BrokenEmailListener.java:22)
at org.springframework.context.event.ApplicationListenerMethodAdapter.doInvoke(ApplicationListenerMethodAdapter.java:392)
at org.springframework.context.event.ApplicationListenerMethodAdapter.processEvent(ApplicationListenerMethodAdapter.java:270)
at org.springframework.context.event.SimpleApplicationEventMulticaster.doInvokeListener(SimpleApplicationEventMulticaster.java:180)
at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:151)
at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:448)
at com.example.demo.order.OrderService.place(OrderService.java:28)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:371)
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:719)
at com.example.demo.order.OrderService$$SpringCGLIB$$0.place(<generated>)
at com.example.demo.order.OrderController.place(OrderController.java:40)Một stack duy nhất, từ controller qua CGLIB proxy, qua place, qua publishEvent, vào thẳng listener. Listener @Order(20) không hề chạy — multicastEvent dừng ở lỗi đầu tiên. Cũng để ý rằng caller không có cách nào phân biệt chuyện này với một lỗi bên trong chính place(), và đó là tóm tắt trung thực của hành vi mặc định: listener đồng bộ là một phần use case của caller.
Đưa listener ra khỏi thread của caller thì mọi thứ đổi. @Async được bật đúng như bài 40 của khoá Basics — @EnableAsync trên một class config, applicationTaskExecutor của Boot với 8 core thread tên task-N — và nó ghép được với @EventListener trên cùng một method:
@Component
public class AsyncBrokenEmailListener {
@Async
@EventListener
@Order(10)
public void sendConfirmation(OrderPlaced event) {
log.info("email order={} {}", event.orderId(), Tx.where());
throw new IllegalStateException("SMTP server refused the message");
} INFO [nio-8204-exec-1] OrderService : place() about to publish thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] AsyncBrokenEmailListener : stock order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [ task-1] AsyncBrokenEmailListener : email order=1 thread=task-1 actualTx=false syncActive=false txName=null
INFO [nio-8204-exec-1] OrderService : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction commit
ERROR [ task-1] SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void com.example.demo.listener.AsyncBrokenEmailListener.sendConfirmation(com.example.demo.order.OrderPlaced)
java.lang.IllegalStateException: SMTP server refused the message
at com.example.demo.listener.AsyncBrokenEmailListener.sendConfirmation(AsyncBrokenEmailListener.java:24)
at org.springframework.aop.interceptor.AsyncExecutionInterceptor.lambda$invoke$0(AsyncExecutionInterceptor.java:112)Bốn khác biệt, tất cả đo trong đúng một lần chạy. Thân listener chạy trên task-1. actualTx=false: transaction gắn theo thread và không đi theo. Listener @Order(20) chạy trước, vì cái @Order(10) chỉ đẩy việc sang executor rồi return. Và exception rơi vào SimpleAsyncUncaughtExceptionHandler cùng một dòng log ERROR — request vẫn trả 200 với {"orderId":1} và order vẫn commit. Việc bị mất mà không ai được báo; đó là toàn bộ cái giá phải trả.
@TransactionalEventListener và bốn phase
Chạy listener bên trong transaction của publisher là sai với hầu hết việc phụ. Email gửi từ trong transaction vẫn được gửi kể cả khi transaction rollback ngay sau đó; projection cập nhật ở đó nhìn thấy những row chưa ai khác nhìn thấy. @TransactionalEventListener dời thân listener sang một thời điểm bạn chọn trong vòng đời của transaction:
| Phase | Chạy lúc | @TransactionalEventListener |
|---|---|---|
BEFORE_COMMIT | ngay trước khi lệnh commit được phát, vẫn trong transaction | phase = TransactionPhase.BEFORE_COMMIT |
AFTER_COMMIT | sau khi commit thành công | mặc định — @TransactionalEventListener không tham số |
AFTER_ROLLBACK | sau khi transaction rollback | phase = TransactionPhase.AFTER_ROLLBACK |
AFTER_COMPLETION | sau khi transaction kết thúc, theo cả hai hướng | phase = TransactionPhase.AFTER_COMPLETION |
Một bean với mỗi phase một listener:
package com.example.demo.listener;
import com.example.demo.order.OrderPlaced;
import com.example.demo.support.Tx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class PhaseListeners {
private static final Logger log = LoggerFactory.getLogger(PhaseListeners.class);
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(OrderPlaced event) {
log.info("BEFORE_COMMIT order={} {}", event.orderId(), Tx.where());
}
@TransactionalEventListener
public void afterCommit(OrderPlaced event) {
log.info("AFTER_COMMIT order={} {}", event.orderId(), Tx.where());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollback(OrderPlaced event) {
log.info("AFTER_ROLLBACK order={} {}", event.orderId(), Tx.where());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void afterCompletion(OrderPlaced event) {
log.info("AFTER_COMPLETION order={} {}", event.orderId(), Tx.where());
}
}Order commit thành công, với org.springframework.transaction.event ở mức DEBUG:
INFO [nio-8204-exec-1] OrderService : place() about to publish thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] TransactionalApplicationListenerMethodAdapter : Registered transaction synchronization for org.springframework.context.PayloadApplicationEvent[source=…]
INFO [nio-8204-exec-1] OrderService : place() publishEvent returned, method is about to return
INFO [nio-8204-exec-1] PhaseListeners : BEFORE_COMMIT order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction commit
DEBUG [nio-8204-exec-1] JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1162799326<open>)]
INFO [nio-8204-exec-1] PhaseListeners : AFTER_COMMIT order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] PhaseListeners : AFTER_COMPLETION order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] OrderController : controller after service thread=http-nio-8204-exec-1 actualTx=false syncActive=false txName=nullVà cùng request đó với outcome=fail, khiến placeAndFail throw sau khi publish:
DEBUG [nio-8204-exec-2] JpaTransactionManager : Creating new transaction with name [com.example.demo.order.OrderService.placeAndFail]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
INFO [nio-8204-exec-2] OrderService : placeAndFail() published, now throwing
DEBUG [nio-8204-exec-2] JpaTransactionManager : Initiating transaction rollback
DEBUG [nio-8204-exec-2] JpaTransactionManager : Rolling back JPA transaction on EntityManager [SessionImpl(1901539364<open>)]
INFO [nio-8204-exec-2] PhaseListeners : AFTER_ROLLBACK order=2 thread=http-nio-8204-exec-2 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.placeAndFail
INFO [nio-8204-exec-2] PhaseListeners : AFTER_COMPLETION order=2 thread=http-nio-8204-exec-2 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.placeAndFail
Năm điều trong hai trace đó đáng giữ lại.
Không có gì chạy lúc publish. publishEvent return sau đúng một dòng DEBUG nói rằng một synchronization đã được đăng ký. Thân listener bị hoãn; object event được giữ lại cho tới khi transaction đến đúng điểm đã yêu cầu.
Các phase chọn đúng. BEFORE_COMMIT và AFTER_COMMIT chạy ở nhánh commit và không chạy ở nhánh rollback; AFTER_ROLLBACK ngược lại; AFTER_COMPLETION chạy ở cả hai.
Mọi thứ vẫn chạy trên thread đã publish. http-nio-8204-exec-1 và http-nio-8204-exec-2 — @TransactionalEventListener hoãn khi nào, không bao giờ đổi ở đâu.
actualTx=true trong listener AFTER_COMMIT là một lời nói dối đừng tin. Transaction đã commit rồi; chỉ là isActualTransactionActive() chưa được reset, trong khi isSynchronizationActive() đã lật sang false. Code rẽ nhánh theo câu hỏi "tôi có đang trong transaction không?" sẽ nhận câu trả lời sai ở đây. Phần sau đo cái giá của nó.
AFTER_COMMIT không được cài đặt bằng afterCommit(). Cả ba phase sau commit dùng chung một callback, và đó là lý do thứ tự giữa listener AFTER_COMMIT và listener AFTER_COMPLETION không do phase quyết định. Chạy cùng một jar ba lần cho thấy đúng như vậy: hai lần in AFTER_COMMIT trước, một lần in AFTER_COMPLETION trước, code không đổi gì. Đừng bao giờ dựa vào thứ tự giữa các listener khác phase sau commit:
@Override
public void beforeCommit(boolean readOnly) {
if (getTransactionPhase() == TransactionPhase.BEFORE_COMMIT) {
processEventWithCallbacks();
}
}
@Override
public void afterCompletion(int status) {
TransactionPhase phase = getTransactionPhase();
if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) {
processEventWithCallbacks();
}
else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) {
processEventWithCallbacks();
}
else if (phase == TransactionPhase.AFTER_COMPLETION) {
processEventWithCallbacks();
}
}Điều đó cũng chốt luôn câu hỏi về lỗi ở hai đầu. Listener BEFORE_COMMIT throw thì đang ở giữa lúc commit được phát, và lần chạy đó log ra Initiating transaction rollback after commit exception: HTTP 500, orders=0, order mất commit. Listener AFTER_COMMIT throw thì đã qua điểm không thể quay lại — cùng thí nghiệm đó cho HTTP 200, orders=1, và dòng này, dấu vết duy nhất mà lỗi để lại:
ERROR [nio-8204-exec-1] TransactionSynchronizationUtils : TransactionSynchronization.afterCompletion threw exception
java.lang.IllegalStateException: SMTP server refused the messageDùng BEFORE_COMMIT cho việc bắt buộc phải nằm trong transaction — một lần validate cuối, một row dẫn xuất ghi trong cùng một khối. Dùng AFTER_COMMIT, mặc định, cho mọi thứ mà thế giới bên ngoài nhìn thấy.
Khi không có transaction: fallbackExecution
Nếu không có gì đăng ký transaction synchronization thì @TransactionalEventListener không có chỗ nào để bám. Publish cùng event đó từ một method không có @Transactional cho ra kết quả sau, ở mức DEBUG:
INFO [nio-8204-exec-3] OrderService : placeWithoutTransaction() about to publish thread=http-nio-8204-exec-3 actualTx=false syncActive=false txName=null
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
DEBUG [nio-8204-exec-3] TransactionalApplicationListenerMethodAdapter : No transaction is active - skipping org.springframework.context.PayloadApplicationEvent[source=…]
INFO [nio-8204-exec-3] PhaseListeners : AFTER_COMMIT fallbackExecution=true order=3 thread=http-nio-8204-exec-3 actualTx=false syncActive=false txName=nullListener bị bỏ qua trong im lặng. Không exception, không warning — mỗi listener một dòng DEBUG, trên một logger chẳng ai bật ở production. Đây là lý do phổ biến nhất khiến @TransactionalEventListener "không chạy": publisher không hề nằm trong transaction, thường vì test gọi thẳng service, hoặc vì annotation @Transactional nằm trên một method bị gọi qua self-invocation nên proxy bị bỏ qua.
Listener thứ năm có fallbackExecution = true là cái đã chạy:
@TransactionalEventListener
@TransactionalEventListener(fallbackExecution = true)
public void afterCommitWithFallback(OrderPlaced event) {
log.info("AFTER_COMMIT fallbackExecution=true order={} {}", event.orderId(), Tx.where());
}Không có transaction thì nó chạy ngay và chạy inline, y như một @EventListener thường; có transaction thì nó hành xử như mọi listener cùng phase. Đây là công tắc đúng cho một listener cần chạy được cả trong unit test hay từ một entry point không transaction, và là công tắc sai cho một listener mà toàn bộ mục đích là chỉ hành động sau commit. Một chi tiết trong source đáng biết: một lần chạy fallback ở phase AFTER_ROLLBACK sẽ log Processing … as a fallback execution on AFTER_ROLLBACK phase ở mức WARN, vì chạy handler rollback khi chẳng có gì rollback gần như chắc chắn là nhầm.
Cái bẫy ghi database trong AFTER_COMMIT
Đây là chỗ các application thật làm mất dữ liệu. Một listener AFTER_COMMIT ghi một audit row — việc tự nhiên nhất đời, và cũng là lý do actualTx=true ở phần trước quan trọng:
@Component
public class PlainAuditListener {
private final AuditRepository audit;
public PlainAuditListener(AuditRepository audit) {
this.audit = audit;
}
@TransactionalEventListener
public void writeAudit(OrderPlaced event) {
log.info("audit listener {}", Tx.where());
AuditRow row = audit.save(new AuditRow("order " + event.orderId() + " placed"));
log.info("audit listener saved row id={}", row.getId());
}
}Đặt một order, rồi gọi GET /api/counts:
INFO [nio-8204-exec-2] OrderService : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-2] JpaTransactionManager : Initiating transaction commit
DEBUG [nio-8204-exec-2] JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(963334426<open>)]
INFO [nio-8204-exec-2] PlainAuditListener : audit listener thread=http-nio-8204-exec-2 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-2] JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(963334426<open>)] for JPA transaction
DEBUG [nio-8204-exec-2] JpaTransactionManager : Participating in existing transaction
INFO [nio-8204-exec-2] PlainAuditListener : audit listener saved row id=null
DEBUG [nio-8204-exec-2] JpaTransactionManager : Closing JPA EntityManager after transaction
{"audit":0,"orders":1}Participating in existing transaction — @Transactional của chính repository tìm thấy EntityManager còn gắn trên thread, sót lại từ transaction đã commit xong, và nhảy vào đó. Không còn commit nào để flush vào nữa, nên lệnh insert không bao giờ xảy ra: row.getId() trả về null, audit_rows có 0 row, và HTTP response là 200. Không exception, không warning, health check vẫn xanh. Một audit trail rỗng không.

Cách sửa hiển nhiên — gắn @Transactional lên listener — nay bị từ chối ngay lúc khởi động. Từ Spring Framework 6.1, factory biến một method @TransactionalEventListener thành listener là RestrictedTransactionalEventListenerFactory, được @EnableTransactionManagement đăng ký và do đó được auto-configuration transaction của Boot đăng ký:
@TransactionalEventListener
@Transactional
public void writeAudit(OrderPlaced event) {ERROR SpringApplication : Application run failed
org.springframework.beans.factory.BeanInitializationException: Failed to process @EventListener annotation on bean with name 'requiredAuditListener': @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: public void com.example.demo.audit.RequiredAuditListener.writeAudit(com.example.demo.order.OrderPlaced)
at org.springframework.context.event.EventListenerMethodProcessor.afterSingletonsInstantiated(EventListenerMethodProcessor.java:145)
Caused by: java.lang.IllegalStateException: @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED
at org.springframework.transaction.annotation.RestrictedTransactionalEventListenerFactory.createApplicationListener(RestrictedTransactionalEventListenerFactory.java:52)Application không khởi động được, và thông báo nêu rõ hai propagation được phép. Bản thân kiểm tra này bỏ qua phase BEFORE_COMMIT — listener chạy bên trong transaction muốn gắn @Transactional kiểu gì cũng được — và áp dụng cho mọi phase còn lại. REQUIRES_NEW là cái bạn cần:
@TransactionalEventListener
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAudit(OrderPlaced event) {Cùng request, cùng thân listener:
DEBUG [nio-8204-exec-1] JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(1470717527<open>)]
DEBUG [nio-8204-exec-1] JpaTransactionManager : Suspending current transaction, creating new transaction with name [com.example.demo.audit.RequiresNewAuditListener.writeAudit]
DEBUG [nio-8204-exec-1] JpaTransactionManager : Opened new EntityManager [SessionImpl(335637450<open>)] for JPA transaction
INFO [nio-8204-exec-1] RequiresNewAuditListener : audit listener thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.audit.RequiresNewAuditListener.writeAudit
INFO [nio-8204-exec-1] RequiresNewAuditListener : audit listener saved row id=1
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction commit
DEBUG [nio-8204-exec-1] JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(335637450<open>)]
DEBUG [nio-8204-exec-1] JpaTransactionManager : Closing JPA EntityManager after transaction
DEBUG [nio-8204-exec-1] JpaTransactionManager : Resuming suspended transaction after completion of inner transaction
{"orders":1,"audit":1}Một EntityManager thứ hai, một connection thứ hai, một commit thật, id=1, và 1 row trong audit_rows. Để ý syncActive=true và txName là tên method của chính listener: đây là một transaction thật, không phải bóng ma của transaction trước.
⚠️ Mọi lệnh ghi database từ listener
AFTER_COMMIT(hayAFTER_COMPLETION) đều cần@Transactional(propagation = Propagation.REQUIRES_NEW). Thiếu nó thì lệnh ghi bị vứt đi trong im lặng, và triệu chứng duy nhất nhìn thấy được là một id sinh tự động trả vềnull.
Cái giá là connection thứ hai. Một listener giữ connection từ pool trong khi thread request cũng đang giữ một connection sẽ nhân đôi áp lực pool của endpoint đó, nên một listener AFTER_COMMIT có ghi database thuộc về phía sau một pool có giới hạn và một timeout, không phải trên đường đi nóng của mọi request. Bản thân propagation là chủ đề của bài 30 khoá Basics và sẽ được mổ xẻ đầy đủ ở phần sau của khoá này.
Cho listener chạy async
Có hai công tắc, và chúng làm hai việc khác nhau.
@Async trên method listener dời đúng listener đó sang executor. Nó ghép được với @TransactionalEventListener, và phase vẫn hoạt động:
@Component
public class AsyncListeners {
@Async
@EventListener
@Order(10)
public void sendConfirmation(OrderPlaced event) {
log.info("email order={} {}", event.orderId(), Tx.where());
}
@EventListener
@Order(20)
public void reserveStock(OrderPlaced event) {
log.info("stock order={} {}", event.orderId(), Tx.where());
}
@Async
@TransactionalEventListener
public void afterCommitAsync(OrderPlaced event) {
log.info("AFTER_COMMIT @Async order={} {}", event.orderId(), Tx.where());
}
} INFO [nio-8204-exec-1] OrderService : place() about to publish thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] AsyncListeners : stock order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [ task-1] AsyncListeners : email order=1 thread=task-1 actualTx=false syncActive=false txName=null
INFO [nio-8204-exec-1] OrderService : place() publishEvent returned, method is about to return
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction commit
DEBUG [nio-8204-exec-1] JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(122201492<open>)]
INFO [ task-2] AsyncListeners : AFTER_COMMIT @Async order=1 thread=task-2 actualTx=false syncActive=false txName=nullSynchronization vẫn được đăng ký trên thread request, commit vẫn xảy ra ở đó, và chỉ thân listener là dời đi — sang task-2, sau commit. @Async cộng @TransactionalEventListener là thứ gần nhất mà cơ chế này có được với ý "làm việc phụ ngoài request, nhưng chỉ khi order thật sự tồn tại". Để ý điểm cải thiện so với trường hợp đồng bộ: trên task-2 thì actualTx đọc ra false, đúng sự thật, nên một lệnh ghi REQUIRES_NEW ở đó bắt đầu từ trang giấy trắng.
Một applicationEventMulticaster async là công tắc còn lại, và nó có phạm vi toàn cục. Container tra bean này theo tên, nên tên là bắt buộc:
package com.example.demo.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.SimpleApplicationEventMulticaster;
import org.springframework.core.task.TaskExecutor;
@Configuration
public class AsyncEventConfig {
/** The bean name is fixed: the container looks this one up by name. */
@Bean(name = "applicationEventMulticaster")
public SimpleApplicationEventMulticaster applicationEventMulticaster(
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor) {
SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster();
multicaster.setTaskExecutor(taskExecutor);
return multicaster;
}
}Có bean đó cùng toàn bộ listener trong bài, đúng một request cho ra:
INFO [nio-8204-exec-1] OrderService : place() about to publish thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
INFO [ task-2] StockProjectionListener : stock order=1 thread=task-2 actualTx=false syncActive=false txName=null
INFO [ task-3] ConfirmationEmailListener : email order=1 thread=task-3 actualTx=false syncActive=false txName=null
INFO [ task-7] GenericEventListeners : supertype listener saw OrderPlaced
INFO [ task-6] ChainingListener : confirm order=1 returning an OrderConfirmed
INFO [ task-6] GenericEventListeners : supertype listener saw OrderConfirmed
INFO [nio-8204-exec-1] OrderService : place() publishEvent returned, method is about to return
INFO [ task-8] ChainingListener : onConfirmed OrderConfirmed[orderId=1, reference=REF-1] thread=task-8 actualTx=false syncActive=false txName=null
INFO [nio-8204-exec-1] PhaseListeners : BEFORE_COMMIT order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=true txName=com.example.demo.order.OrderService.place
DEBUG [nio-8204-exec-1] JpaTransactionManager : Initiating transaction commit
INFO [ task-1] PayloadPeekListener : wrapper class=PayloadApplicationEvent source=…AnnotationConfigServletWebServerApplicationContext payload=OrderPlaced[orderId=1, sku=SKU-1, quantity=2, total=249.90]
INFO [nio-8204-exec-1] PhaseListeners : AFTER_COMMIT order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.place
INFO [nio-8204-exec-1] PhaseListeners : AFTER_COMPLETION order=1 thread=http-nio-8204-exec-1 actualTx=true syncActive=false txName=com.example.demo.order.OrderService.placeHai kết quả, và cái thứ hai làm phần lớn mọi người bất ngờ.
Mọi @EventListener thường đều dời đi, và @Order mất hết ý nghĩa. bảy lần gọi listener rải trên sáu thread task-N khác nhau, và listener ở @Order(20) log trước cái ở @Order(10); listener lớp bọc ở @Order(5) log sau commit. Multicaster vẫn submit theo thứ tự, nhưng thứ tự submit không phải thứ tự chạy. Nếu có hai listener nào của bạn phụ thuộc vào việc chạy tuần tự, một multicaster async sẽ phá chúng.
Mọi @TransactionalEventListener đều ở lại http-nio-8204-exec-1, đúng phase. Đó là cố ý, và nó nằm ở một dòng của framework:
default boolean supportsAsyncExecution() {
return false;
}SimpleApplicationEventMulticaster.multicastEvent kiểm tra listener.supportsAsyncExecution() trước khi giao bất cứ thứ gì cho executor, và listener transaction trả lời không — nó phải đăng ký synchronization trên thread sở hữu transaction, còn trên thread của pool thì không có transaction nào. Vậy nên một multicaster async làm các method @EventListener của bạn chạy async và để nguyên các method @TransactionalEventListener ở đúng chỗ cũ.
Giữa hai cách, hãy ưu tiên @Async trên từng listener. Nó hiện ngay tại method phải trả giá, nó không đụng tới thứ tự của mọi thứ khác, và nó không đổi hành vi của những listener do người chưa từng đọc class config này viết ra. Chỉ lấy multicaster ra khi bạn thật sự muốn mọi listener trong application rời khỏi thread publish — và khi đó hãy set một ErrorHandler cho nó, vì không có ErrorHandler thì exception của listener trên thread pool là chuyện executor muốn làm gì thì làm.
Event hợp với việc gì, và dừng ở đâu
| Cơ chế | Chạy lúc nào | Thread | Transaction | Listener lỗi thì sao |
|---|---|---|---|---|
@EventListener | trong publishEvent, trước khi nó return | của publisher | của publisher, actualTx=true | throw vào publisher; listener sau bị bỏ; transaction của publisher rollback |
@EventListener + @Async | một lúc nào đó sau khi publishEvent return | task-N | không có | SimpleAsyncUncaughtExceptionHandler log ở mức ERROR; publisher không biết gì |
applicationEventMulticaster async | một lúc nào đó sau khi publishEvent return | task-N, mất thứ tự | không có | về tay executor, hoặc về ErrorHandler của multicaster nếu bạn set |
@TransactionalEventListener(BEFORE_COMMIT) | ngay trước khi commit được phát | của publisher | của publisher, còn mở | Initiating transaction rollback after commit exception — transaction rollback |
@TransactionalEventListener (AFTER_COMMIT) | sau khi commit thành công | của publisher | đã commit, muốn ghi phải REQUIRES_NEW | log ERROR từ TransactionSynchronizationUtils; HTTP 200; việc bị mất |
@TransactionalEventListener(AFTER_ROLLBACK) | sau khi rollback | của publisher | đã rollback | như trên |
@TransactionalEventListener + @Async | sau commit, ngoài thread | task-N | không có, actualTx=false | SimpleAsyncUncaughtExceptionHandler |
Hãy đọc cột cuối như một ràng buộc thiết kế. Spring event rất tốt ở khoản tách rời trong cùng process: OrderService không import code gửi mail, thêm một hệ quả của việc đặt order là thêm một @Component và không gì khác, và từng listener test được riêng. Trong một JVM, với @TransactionalEventListener(AFTER_COMMIT), chúng còn có được bảo đảm quan trọng nhất — việc phụ không xảy ra cho một order chưa bao giờ được commit.
Nhưng chúng không phải một cơ chế đáng tin về mặt giao nhận, và hàng cuối của bảng là lý do. Commit thành công, listener throw, caller nhận 200, và email không bao giờ được gửi. Cùng lỗ hổng đó mở ra ngay cả khi không có exception nào: process bị kill giữa commit và listener, việc biến mất theo. Không có gì để retry, vì không có gì ghi lại rằng việc đó còn nợ.
Câu trả lời tiêu chuẩn là outbox pattern: trong cùng transaction ghi order, ghi thêm một row mô tả việc phải làm; một poller riêng đọc các row đó, làm việc, đánh dấu xong, và retry cái nào hỏng. Event khi đó có một dấu vết bền, và ngữ nghĩa "ít nhất một lần" là của bạn chứ không phải của JVM. Đó là Chương 5 của khoá này. Event publication registry của Spring Modulith là một bản dựng sẵn của cùng ý tưởng, còn message broker như Kafka hay RabbitMQ là bản xuyên process; cả hai đều không thay đổi những gì @TransactionalEventListener làm bên trong một transaction, và vì vậy bài này là điều kiện cần cho cả hai.
Hai quy tắc nhỏ hơn cũng rút ra từ bảng đó. Giữ listener nhanh khi nó đồng bộ, vì nó nằm trong transaction của caller và giữ connection của transaction ấy. Và đừng bao giờ publish một event chỉ để gọi một method: nếu chỉ có đúng một listener và listener đó bắt buộc phải thành công thì một lời gọi method trên bean được inject rõ ràng hơn, được kiểm tra kiểu, và hiện trong stack trace.
FAQ
@EventListener và @TransactionalEventListener khác nhau thế nào?
@EventListener chạy method ngay trong publishEvent, trên thread và trong transaction của publisher, trước khi publishEvent return. @TransactionalEventListener thay vào đó đăng ký một transaction synchronization và chạy method ở một điểm được chọn trong vòng đời transaction — mặc định là sau khi commit thành công. Đo ở trên: @EventListener throw làm order rollback, còn listener AFTER_COMMIT throw thì order vẫn commit và request vẫn HTTP 200.
Tại sao @TransactionalEventListener của tôi không bao giờ chạy?
Gần như luôn vì publisher không nằm trong transaction. Spring log mỗi listener bị bỏ một dòng DEBUG — No transaction is active - skipping … trên org.springframework.transaction.event — và không làm gì thêm: không exception, không warning. Hãy kiểm tra method publish có thật sự đi qua proxy @Transactional không, vì self-invocation trong cùng bean thì không. Nếu listener bắt buộc phải chạy trong mọi trường hợp, thêm fallbackExecution = true.
Có ghi được xuống database từ listener AFTER_COMMIT không?
Chỉ khi có @Transactional(propagation = Propagation.REQUIRES_NEW) trên method listener. Thiếu nó thì lệnh save nhảy vào transaction đã commit còn gắn trên thread, và row không bao giờ xuống database — lần chạy đo được trả về id sinh tự động null và 0 row, không lỗi. @Transactional thường thì không phải lựa chọn: từ Spring Framework 6.1, application từ chối khởi động với @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED.
Spring event có chạy async không?
Không. Mặc định publishEvent là một lời gọi method bình thường, chạy hết mọi listener khớp rồi mới return, trên thread đang gọi. Cho listener chạy async là tuỳ chọn: @Async trên method listener, hoặc một bean SimpleApplicationEventMulticaster tên applicationEventMulticaster có TaskExecutor, thứ dời mọi @EventListener thường trong application và phá luôn @Order giữa chúng.
Tại sao listener cho generic event không bao giờ nhận được gì?
Vì type argument bị erasure. Listener khai báo OrderEvent<Product> không hề được gọi trong lần chạy ở trên, với cả payload Product lẫn Customer, bởi ResolvableType.forInstance chỉ nhìn thấy raw class và phép khớp bị từ chối. Hãy cho event implement ResolvableTypeProvider và trả về ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(subject)) — bản có type đã định tuyến mỗi payload tới đúng một listener — hoặc bỏ type parameter và publish hai record riêng biệt.
Spring event có đảm bảo listener được chạy không?
Không. Không có lưu trữ, không retry, không acknowledgement. Listener AFTER_COMMIT throw thì log ở mức ERROR và không bao giờ được chạy lại; process chết giữa commit và listener thì việc mất luôn, không còn dấu vết nào nói rằng nó còn nợ. Hãy dùng outbox pattern khi việc phụ bắt buộc phải xảy ra: ghi nó thành một row trong cùng transaction và để một poller đẩy nó đi.
Nên dùng record hay class kế thừa ApplicationEvent?
Record. Kế thừa ApplicationEvent đã không còn bắt buộc từ Spring 4.2, và record giữ event là một giá trị immutable thuần với toString dễ đọc, không có kiểu nào của Spring trong chữ ký và không lý do gì để một test phải dựng context. Spring bọc nó trong một PayloadApplicationEvent ở bên trong; listener khai báo theo payload type và không bao giờ thấy lớp bọc.
Kết luận
Cơ chế event của Spring là hai thứ nhỏ với hành vi rất khác nhau. publishEvent cộng @EventListener là một lời gọi method đồng bộ, có thứ tự, nằm trong transaction, chỉ thêm một lớp gián tiếp: đo ở đây, mọi listener chạy trên http-nio-8204-exec-1 bên trong transaction của OrderService.place, @Order quyết định thứ tự, một condition bỏ qua một listener, một giá trị trả về thành event lồng nhau, và một listener throw kéo luôn row của order xuống theo. @TransactionalEventListener giữ nguyên thread nhưng chọn thời điểm — BEFORE_COMMIT trong transaction, AFTER_COMMIT sau khi commit thành công, AFTER_ROLLBACK sau khi không thành công, AFTER_COMPLETION cả hai — và bỏ qua tất cả trong im lặng khi không có transaction nào, trừ khi fallbackExecution nói khác.
Ba phép đo đáng mang theo từ bài này. Listener cho generic OrderEvent<Product> không nhận được gì nếu thiếu ResolvableTypeProvider. Listener AFTER_COMMIT thấy actualTx=true dù transaction đã commit, nên lệnh ghi database ở đó cần REQUIRES_NEW hoặc bị vứt đi không một lời báo lỗi — 0 row so với 1. Và một multicaster async dời mọi @EventListener sang thread task-N nhưng để mọi @TransactionalEventListener ở lại thread publish, vì supportsAsyncExecution() trả về false cho chúng.
Đó là hết Chương 1 của khoá này — auto-configuration, các điểm mở rộng của container, AOP cùng proxy, và giờ là event. Chương 2 quay sang database và mở đầu bằng vấn đề mà mọi application JPA gặp đầu tiên: query N+1, cách nhìn thấy nó, và bốn công cụ xử lý nó — fetch join, @EntityGraph, projection, và batch insert.