caltalys.dev
@Async của tôi chạy tuần tự như thường
Thiếu một dòng bật tính năng thì annotation chỉ còn là lời khai không ai đọc. Và khi nó chạy được rồi, exception lại chết ở một thread khác.
Hôm nay không phải chuyện thằng em. Hôm nay là chuyện của mình, vài năm trước - cái bug đầu tiên làm mình hiểu proxy, dù lúc đó mình chưa biết gọi tên nó.
Hồi đó mình làm tính năng đăng ký user. Đăng ký xong thì gửi email chào mừng. Gửi email mất tầm ba giây, mà không ai muốn user ngồi nhìn spinner ba giây chỉ vì một cái email, nên mình làm đúng sách:
@Service
public class WelcomeService {
@Async
public void sendWelcomeEmail(User user) {
mailClient.send(...); // ~3 giây
}
}
Deploy. Bấm đăng ký thử. Request vẫn mất ba giây rưỡi.
Không exception, không warning, không một dòng log nào. @Async nằm đó, chữ vàng trên nền code, và method chạy tuần tự như chưa từng có nó. Mình đã làm đúng mọi thứ trong bài blog mình đọc - trừ một dòng mà bài blog đó quên nói.
Bây giờ thì bạn đã có đủ đồ nghề để giải bug này nhanh hơn mình ngày xưa. Thử xem.
@Async hoạt động y hệt @Transactional: nó là một lời hứa được thực hiện ở cái vỏ. Proxy chặn lời gọi từ ngoài, và thay vì mở transaction, nó ném lời gọi sang một thread khác rồi trả quyền điều khiển về ngay cho caller. Method của bạn không hề biết mình đang chạy nền - nó không có gì “async” bên trong cả. Async là việc của người gác cổng, không phải của ngôi nhà.
Vậy theo bài trước, checklist là: method có final không? Không. Có private không? Không. Có gọi qua this từ cùng class không? Không - controller inject welcomeService và gọi từ ngoài, qua cửa đàng hoàng.
Vỏ dựng được. Lời gọi đi từ ngoài. Vậy mà advice vẫn không chạy.
Còn đúng một khả năng, và nó nằm ở bài đầu tiên của cả series, không phải bài proxy: cái vỏ chưa từng được đúc.
Nhớ chặng (7) không? AutoProxyCreator giơ tay, “mày có muốn bọc bean này lại không”. Nhưng khoan - ai giơ tay cho @Async? Mỗi họ annotation có người phụ trách riêng trong cái List<BeanPostProcessor>. Người lo @Async tên là AsyncAnnotationBeanPostProcessor.
Và thằng này không được mời mặc định.
Nó chỉ được nhét vào List khi bạn viết @EnableAsync lên một class @Configuration. Không có chữ đó, List không có nó. Chặng (7) vẫn diễn ra, vòng for vẫn duyệt, nhưng khi câu hỏi “có ai muốn bọc bean này vì @Async không” vang lên - không ai giơ tay. Không phải vì bean có vấn đề. Vì người phụ trách không có mặt trong danh sách.
Quen không? Lại là cái List. Bài đầu tiên, List chưa đầy vì BFPP đến quá sớm, trước giờ đổ đầy. Lần này List chưa đầy vì một lý do trần trụi hơn nhiều: người phụ trách chưa bao giờ được mời. Và cũng như lần trước, không ai báo bạn. @Async được đọc, được hiểu, được… bỏ qua trong im lặng, vì annotation chỉ là lời khai - phải có người trực quầy thì lời khai mới được xử lý.
Fix của mình ngày xưa, sau một buổi chiều: thêm đúng một dòng.
@SpringBootApplication
@EnableAsync // dòng này
public class App { ... }
Nhưng khoan đã. Nếu bạn tinh ý, bạn phải thấy chỗ này không công bằng: @Transactional có bao giờ cần @EnableTransactionManagement đâu? Bạn viết @Transactional từ ngày đầu học Boot, chưa từng enable gì, và nó chạy. Sao @Async lại phải xin phép?
Vì Boot đã mời sẵn hộ bạn - nhưng chỉ mời một người. Auto-configuration của Boot có sẵn cấu hình bật transaction management khi thấy DataSource trên classpath, nên người lo @Transactional luôn có mặt trong List mà bạn không hay. Còn async thì Boot chỉ chuẩn bị sẵn thread pool (cái applicationTaskExecutor bạn hay thấy trong log), nhưng không bật @EnableAsync hộ. Người thì mời sẵn, người thì bạn phải tự mời - và không có bảng thông báo nào cho bạn biết ai thuộc nhóm nào.
Đây là lần thứ hai trong mùa này ta gặp cùng một chuyện: tầng Boot và tầng Framework cư xử khác nhau, và cái bẫy nằm đúng ở khe giữa hai tầng. Bài trước là proxy mặc định. Lần này là lời mời mặc định.
Rồi. Thêm @EnableAsync, chạy lại, request trả về tức thì, email tự đến sau ba giây. Xong chuyện? Chưa. Vì bug thứ hai của câu chuyện này mới là thằng nguy hiểm, và nó đến từ chính cái fix.
Vài tuần sau, mình nhận báo cáo: một số user đăng ký xong không nhận được email. Không phải tất cả - thỉnh thoảng. Mình mở log. Sạch. Không một dòng đỏ nào quanh những lần đăng ký đó.
Mail server chập chờn, mailClient.send() ném exception - chuyện bình thường. Nhưng exception đó bay đi đâu?
Nghĩ theo cơ chế thử xem. Lời gọi sendWelcomeEmail() giờ chạy trên một thread khác. Caller - cái request đăng ký - đã nhận quyền điều khiển về từ lâu, đã trả response, đã đóng stack. Khi exception nổ ra ba giây sau, cái stack mà nó định bay ngược lên không còn tồn tại nữa. Exception không thể tới caller. Về mặt vật lý là không thể - hai thread, hai stack, không có cầu.
Nó đi đâu thì tùy chữ ký method của bạn, và đây là chỗ tinh vi:
Method trả void - như của mình - thì exception rơi vào một người nhặt rác tên SimpleAsyncUncaughtExceptionHandler, và người này có ghi một dòng error vào log. Nghe ổn? Vấn đề là dòng log đó nằm chơ vơ ở một thread pool, không gắn với request nào, không ai alert trên nó, và caller của bạn thì đã trả 200 OK cho user từ ba giây trước. User được báo “đăng ký thành công, kiểm tra email nhé”. Email không bao giờ tới.
Method trả CompletableFuture thì còn im hơn: exception được nhét vào trong cái future, nằm đó chờ ai gọi .get() hay .join() để được kể ra. Bạn fire-and-forget, không ai .get() - exception ở trong hộp mãi mãi. Không một dòng log nào. Đúng nghĩa đen.
Fire-and-forget nghe rất ngầu cho tới khi bạn nhận ra vế sau: forget nghĩa là quên luôn cả lỗi.
Hai triệu chứng hôm nay, xếp cạnh nhau: một cái là async không async - vì cái vỏ chưa từng được đúc; một cái là exception bốc hơi - vì lời gọi đã sang thread khác và cây cầu về đã sập. Trông như hai chuyện. Nhưng chúng là hai mặt của đúng một câu bạn đã thuộc: advice sống ở cái vỏ, và cái vỏ thì đứng giữa hai bên. Bên này không có vỏ - không có async. Có vỏ rồi - thì từ khoảnh khắc nó ném việc sang thread khác, con đường quay lại cũng bị cắt. Cái vỏ cho bạn sự tách rời, và tính phí bằng chính sự tách rời đó.
Muốn tự kiểm chứng, một dòng là đủ:
@Async
public void sendWelcomeEmail(User user) {
log.info("đang chạy trên thread: {}", Thread.currentThread().getName());
...
}
Tên thread trùng với thread của request (http-nio-8080-exec-1) - bạn đang sync, vỏ chưa được đúc, đi kiểm tra @EnableAsync và this. Tên thread là task-1 - async thật rồi, và từ giờ hãy lo cho những exception không còn đường về: thêm AsyncUncaughtExceptionHandler của riêng bạn cho void, hoặc luôn .exceptionally() / .whenComplete() nếu trả future.
Còn nguyên lý, thứ sống lâu hơn @Async: một lời hứa chỉ có hiệu lực khi có người trực để thực hiện nó. Annotation, config flag, event subscription, webhook - tất cả đều là lời khai một chiều. Chúng không tự chạy. Luôn phải có một người-ở-phía-bên-kia đã được đăng ký từ trước, và câu hỏi đầu tiên khi một lời hứa bị lờ đi luôn là: người trực quầy này đã được mời chưa?
Mình kể chuyện này cho thằng em, như một món quà trước - vì kiểu gì nó cũng sẽ dính. Nó nghe xong, ngẫm một lúc, rồi nhắn lại:
Một lời hứa chỉ có hiệu lực khi có người trực để thực hiện nó.
Đúng rồi đó. Log sạch nhất là log của những chuyện xảy ra ở nơi không ai nhìn. 😃
Tài nguyên đi kèm
-
ZIP Source demo - tập 1.3
JDK 17.
mvn spring-boot:run→Episode13Runnerin khối[EP 1.3]: chưa bật@EnableAsyncthì cả nhịpvoidlẫn nhịpCompletableFutuređều block caller 2 giây.https://caltalys.dev/downloads/book1/1.3-spring-deep-dive-demo.zip