@Async: chạy nền mà không đánh mất exception
@Async cũng đi qua proxy, dùng executor nào là do bạn khai. Method trả void thì exception biến mất không dấu vết, trả CompletableFuture thì không.
TL;DR: @Async (Spring Framework) cho một method chạy trên thread khác thay vì thread đang xử lý request, dựa trên cùng cơ chế AOP proxy đã học ở @Cacheable, nên cùng dính bẫy self-call. Bài này xoáy vào hai quyết định Spring không làm hộ. Một là method @Async trả kiểu gì: void để exception rơi thẳng vào hư không, hay CompletableFuture để exception mang được về người gọi. Hai là chạy trên executor nào, vì executor mặc định gộp chung mọi loại việc còn production cần tách riêng theo loại. Cuối bài là phần hay bị bỏ sót nhất: ThreadLocal (MDC, SecurityContextHolder) và transaction không tự đi theo thread mới.
1. Vì sao email xác nhận của TaskFlow im lặng suốt ba ngày?
TaskFlow gửi email xác nhận mỗi khi user tạo project mới. Gọi SMTP qua mạng ngoài mất trung bình 2 giây, và ban đầu createProject chờ hết 2 giây đó rồi mới trả response:
@Service
public class ProjectService {
private final ProjectRepository projectRepository;
private final NotificationService notificationService;
public Project createProject(CreateProjectRequest req) {
Project project = projectRepository.save(new Project(req));
notificationService.sendConfirmation(project.getOwnerEmail(), project);
return project;
}
}
Người dùng ngồi chờ 2 giây đó dù chẳng cần biết email đã gửi xong chưa, project đã lưu rồi, họ chỉ cần thấy trang chuyển tiếp. Thêm @Async là xong, đúng không?
@Async
public void sendConfirmation(String email, Project project) {
smtpClient.send(email, buildConfirmationMessage(project));
}
createProject giờ trả response gần như ngay lập tức. Ba tuần sau, support nhận ticket: một nhóm user báo "tạo project rồi mà không thấy email nào". Kiểm tra log SMTP thì thấy đúng là có request lỗi, domain email công ty đối tác chặn địa chỉ gửi của TaskFlow, smtpClient.send() ném MailSendException. Nhưng log ứng dụng không có dòng nào nhắc tới exception đó. Không ai biết trong ba ngày, vì chẳng có gì báo động cả.
Trước khi đọc tiếp: method sendConfirmation ở trên trả void và ném MailSendException. Exception đó đi đâu? Ai bắt nó, và có ai thấy nó không? Viết ra câu trả lời của bạn rồi đọc phần 4, đừng đoán mò, phần này có đáp án chính xác.
2. @EnableAsync và @Async: proxy đẩy việc sang executor
@Async chạy trên cùng cơ chế proxy đã dạy ở bài 01 — Cache abstraction. Bật @EnableAsync ở configuration, Spring bọc bean chứa method @Async bằng một proxy. Khi caller gọi notificationService.sendConfirmation(...), proxy chặn lời gọi đó, đẩy nó vào một executor (bộ điều phối thread, cơ chế ThreadPoolExecutor đã học ở Java Internals) rồi trả về ngay cho caller, thường trả null/rỗng nếu return type không phải Future.
flowchart LR
A[HTTP thread: goi createProject] --> B[proxy chan sendConfirmation]
B --> C[HTTP thread: return ngay lap tuc]
B -. day task .-> D[Executor thread: chay sendConfirmation that]
D --> E[MDC / SecurityContext / Transaction: KHONG di theo]Hai thread tách nhau ngay tại điểm proxy chặn: thread HTTP quay về xử lý response, thread executor mới thật sự chạy smtpClient.send(...). Đây là lý do createProject trả nhanh, và cũng là lý do mọi thứ gắn với thread HTTP (§6) biến mất ở phía bên kia.
3. Bẫy self-call: tái xuất, không phải khái niệm mới
Bài 01 đã giải thích cơ chế: proxy chỉ chặn được lời gọi đi qua nó, tức là gọi từ bean khác vào. ProjectService gọi notificationService.sendConfirmation(...) là đi qua proxy đúng cách, nên @Async có hiệu lực. Bẫy nằm ở chỗ khác — khi lời gọi xuất phát từ bên trong chính NotificationService:
@Service
public class NotificationService {
// goi tu ProjectService -> di qua proxy -> @Async co hieu luc
@Async
public void sendConfirmation(String email, Project project) {
smtpClient.send(email, buildConfirmationMessage(project));
}
// goi tu ben trong cung class -> khong qua proxy
public void sendBatch(List<Project> projects) {
for (Project p : projects) {
this.sendConfirmation(p.getOwnerEmail(), p); // self-call: chay dong bo, tuan tu
}
}
}
sendBatch tưởng gửi 100 email song song, thực tế gửi tuần tự và chờ đủ 200 giây — không lỗi, không log, chỉ đơn giản là annotation không có hiệu lực. Ba cách fix giống hệt bài 01: tách sendConfirmation sang class khác, inject self bean, hoặc gọi qua ApplicationContext. Không lặp lại chi tiết proxy ở đây, quay lại bài 01 nếu cần ôn.
4. Kiểu trả về quyết định exception đi đâu
Đây là câu trả lời cho phần Predict ở đầu bài. Spring cho method @Async hai kiểu trả về hợp lệ, khác nhau hoàn toàn về số phận của exception.
4.1 Trả void: exception không có chỗ nào để đi
Khi method @Async trả void, executor thread bắt exception nội bộ rồi đưa cho một AsyncUncaughtExceptionHandler (interface Spring định nghĩa để xử lý exception không throw ra được). Mặc định, Spring dùng handler nội bộ chỉ log ở mức ERROR (SimpleAsyncUncaughtExceptionHandler gọi thẳng logger.error): không throw lên đâu, không retry, không alert. Log ồn hoặc không ai theo dõi đúng logger đó thì dòng ERROR này chìm nghỉm, đúng thứ đã xảy ra với TaskFlow suốt ba ngày.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return notificationExecutor(); // bean khai o 5.2
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
/* TODO: dien handler bao dong that su, khong chi log */
}
}
Điền method còn thiếu trước khi đọc tiếp: getAsyncUncaughtExceptionHandler() cần làm gì hơn việc chỉ log để TaskFlow không lặp lại sự cố ba ngày?
getAsyncUncaughtExceptionHandler() đúng nghĩa cần đẩy thông tin đi xa hơn một dòng log, báo về hệ thống alert, hoặc ít nhất gắn severity ERROR kèm đủ ngữ cảnh để người trực không phải đoán:
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async method {} failed: {}", method.getName(), ex.getMessage(), ex);
alertClient.notifyOnCallTeam("async-failure", method.getName(), ex);
};
}
Handler này vẫn không cho caller (createProject) biết gì, createProject đã return từ lâu. Nó chỉ đảm bảo có người nhìn thấy lỗi, không đảm bảo nghiệp vụ tự phục hồi.
4.2 Trả CompletableFuture: exception mang được về người gọi
Đổi return type sang CompletableFuture<Void> (kiểu đại diện một kết quả sẽ có trong tương lai, cơ chế complete/join/exceptionally đã học ở Java Internals), exception ném ra trong method được Spring bọc vào chính future đó thay vì đưa cho AsyncUncaughtExceptionHandler:
@Async("notificationExecutor")
public CompletableFuture<Void> sendConfirmation(String email, Project project) {
smtpClient.send(email, buildConfirmationMessage(project));
return CompletableFuture.completedFuture(null);
}
Caller giờ nhận về một CompletableFuture sống. Muốn biết kết quả thì gọi .join()/.get() (block cho tới khi xong, ném lại exception nếu có), hoặc đăng ký .exceptionally(...) để xử lý bất đồng bộ mà không block:
public Project createProject(CreateProjectRequest req) {
Project project = projectRepository.save(new Project(req));
notificationService.sendConfirmation(project.getOwnerEmail(), project)
.exceptionally(ex -> {
log.error("Confirmation email failed for {}", project.getOwnerEmail(), ex);
auditService.recordFailedNotification(project.getId(), ex.getMessage());
return null;
});
return project;
}
createProject vẫn trả về gần như ngay lập tức, .exceptionally() không block, nó chỉ đăng ký một hành động chạy khi future hoàn tất hoặc lỗi. Khác biệt với bản void không nằm ở tốc độ, mà ở việc ai chịu trách nhiệm biết lỗi: void giao hẳn cho AsyncUncaughtExceptionHandler toàn cục, CompletableFuture để lại lựa chọn cho đúng người gọi hiểu rõ ngữ cảnh nghiệp vụ (ở đây là ghi audit).
4.3 Quy tắc chọn
Cần biết kết quả, hoặc cần biết việc đó có hỏng hay không, thì đừng trả void. void chỉ hợp lý cho việc thật sự fire-and-forget mà một dòng log tập trung là đủ giám sát (ví dụ ghi một dòng vào bảng thống kê nội bộ, không ảnh hưởng nghiệp vụ nếu mất một bản ghi). Bất cứ khi nào caller, hoặc một hệ thống giám sát khác, cần phản ứng với thất bại, trả CompletableFuture.
5. Executor: dùng chung là nghẽn chung
5.1 Executor mặc định gộp mọi loại việc
Không khai AsyncConfigurer, Spring Boot tự tạo một ThreadPoolTaskExecutor tên applicationTaskExecutor (hoặc SimpleAsyncTaskExecutor khi bật virtual thread, tạo thread mới mỗi lần gọi thay vì giữ pool) dùng chung cho mọi method @Async trong ứng dụng, trừ khi bạn chỉ định executor khác bằng tên. Vấn đề: TaskFlow còn export báo cáo PDF (CPU-bound, vài giây) và đồng bộ dữ liệu với hệ thống kế toán bên ngoài (I/O-bound, có lúc timeout tới 30 giây), bên cạnh việc gửi email. Dồn cả hai loại việc vào một pool nhỏ nghĩa là một đợt đồng bộ kế toán bị treo 30 giây chiếm hết thread, làm email xác nhận (vốn chỉ cần 2 giây) phải xếp hàng phía sau.
5.2 Chỉ định executor theo tên, tách theo loại việc
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("notify-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Async("notificationExecutor") chỉ định đúng executor bằng tên bean. Một accountingSyncExecutor khác (core/max nhỏ hơn, AbortPolicy thay vì CallerRunsPolicy) khai tương tự cho đồng bộ kế toán, sao cho một đợt đồng bộ chậm chỉ chiếm thread của riêng nó, không đụng tới hàng đợi gửi email.
5.3 Chọn tham số theo hồ sơ tải
Nguyên tắc core/max pool size với I/O-bound so với CPU-bound đã học ở module 01 khoá này và Java Internals, nhắc lại đúng chỗ áp dụng: gửi SMTP hay đồng bộ kế toán là I/O-bound (thread phần lớn thời gian chờ mạng) nên pool có thể lớn hơn số core CPU, như notificationExecutor đặt core 4/max 8 dù máy chỉ 2-4 core. Export PDF là CPU-bound nên pool nên bám sát số core. Khối lượng I/O-bound đủ lớn có thể cân nhắc virtual thread (Executors.newVirtualThreadPerTaskExecutor()), nhưng nhớ ba cạm bẫy đã học ở module 01: pinning, ThreadLocal, mất backpressure.
5.4 Rejection policy: hàng đợi đầy thì sao?
Khi cả pool bận và hàng đợi (queueCapacity) đầy, executor phải quyết định làm gì với task mới, đây là rejection policy. Mặc định của ThreadPoolTaskExecutor là AbortPolicy: ném RejectedExecutionException ngay tại điểm gọi. Với method @Async trả void, exception đó lại rơi vào đúng cái hố đã nói ở §4.1, bị nuốt nếu không cấu hình handler tử tế. Ba lựa chọn thường dùng:
| Policy | Hành vi | Hợp với |
|---|---|---|
AbortPolicy (mặc định) | Ném exception, task bị bỏ | Việc phải biết ngay khi quá tải |
CallerRunsPolicy | Thread gọi tự chạy task đó (chậm lại, không mất việc) | Việc không được mất, chấp nhận chậm |
DiscardPolicy | Âm thầm bỏ task, không báo gì | Gần như không bao giờ nên dùng |
notificationExecutor ở §5.2 dùng CallerRunsPolicy: hàng đợi đầy thì thread HTTP tự gửi email đó (chậm lại vài giây, chấp nhận được) thay vì mất email hoàn toàn. accountingSyncExecutor dùng AbortPolicy vì đồng bộ kế toán cần biết ngay để retry có kiểm soát. Để mặc định AbortPolicy cho việc quan trọng mà method lại trả void là cách phổ biến nhất để mất việc mà không ai hay.
6. Context không tự đi theo sang thread mới
6.1 ThreadLocal biến mất khi đổi thread
MDC (Mapped Diagnostic Context, cơ chế gắn key-value như trace ID vào log của một luồng xử lý, đã học ở Spring Core cùng logging), SecurityContextHolder, và @RequestScope đều dựa trên ThreadLocal: biến chỉ tồn tại và đọc được trên đúng thread đã set nó. Gọi MDC.get("traceId") bên trong sendConfirmation trả về null, vì executor thread không phải thread HTTP đã set nó. Hệ quả thực tế: log của việc chạy nền không có trace ID, không nối được vào cùng một trace với request đã tạo ra nó, đúng vấn đề tracing đã nói ở module 02 khoá này. Cách xử lý là truyền dữ liệu cần thiết qua tham số thay vì trông chờ context tự đi theo:
public Project createProject(CreateProjectRequest req) {
Project project = projectRepository.save(new Project(req));
String traceId = MDC.get("traceId"); // doc TRUOC khi sang thread khac
notificationService.sendConfirmation(project.getOwnerEmail(), project, traceId);
return project;
}
@Async("notificationExecutor")
public CompletableFuture<Void> sendConfirmation(String email, Project project, String traceId) {
MDC.put("traceId", traceId); // set lai tren thread moi
try {
smtpClient.send(email, buildConfirmationMessage(project));
return CompletableFuture.completedFuture(null);
} finally {
MDC.clear();
}
}
Cần propagate nhiều context cùng lúc (trace, security, request attribute), Spring hỗ trợ TaskDecorator: bọc Runnable trước khi executor chạy để copy context sang thread mới một lần, thay vì truyền tay từng tham số ở mọi call site.
6.2 @Transactional không đi theo: cầu nối sang Spring Events
Đây là phần dễ gây bug nhất: đẩy việc sang thread khác bằng @Async nghĩa là ra khỏi transaction hiện tại, vì transaction (đã học ở Spring REST & Data) cũng gắn với thread. Gọi @Async bên trong một method @Transactional rồi trông chờ nó thấy dữ liệu chưa commit là sai:
flowchart LR
A[Async method] --> B{Kieu tra ve}
B -- void --> C[AsyncUncaughtExceptionHandler]
C --> D[Chi log - de bo qua]
B -- CompletableFuture --> E[Exception boc vao future]
E --> F[Nguoi goi: join / get / exceptionally]@Transactional
public Project createProject(CreateProjectRequest req) {
Project project = projectRepository.save(new Project(req)); // chua commit
notificationService.sendConfirmation(project.getOwnerEmail(), project);
return project;
}
Thread executor chạy sendConfirmation không nằm trong transaction của createProject. sendConfirmation tự query lại project từ DB bằng ID có thể đọc trước khi transaction gốc commit, tuỳ thời điểm thread executor được lên lịch. Truyền thẳng object project đã có sẵn (như code trên làm) tránh được việc đọc lại, nhưng không đảm bảo chỉ gửi email sau khi transaction commit thành công: rollback ở bước sau sendConfirmation thì email đã gửi rồi không thu hồi được. Đây chính là chỗ bài 05 — Spring Events tiếp quản bằng @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT).
Pitfall thường gặp
❌ Nhầm 1 — nghĩ executor mặc định đủ dùng cho mọi @Async:
✅ Executor mặc định (applicationTaskExecutor) dùng chung cho toàn app. Một loại việc chậm (đồng bộ kế toán 30 giây) chiếm hết thread làm loại việc nhanh (email 2 giây) phải xếp hàng phía sau. Khai executor riêng theo nhóm việc (§5.2).
❌ Nhầm 2 — trả void cho việc mà nghiệp vụ cần biết có thành công hay không:
✅ void giao toàn bộ trách nhiệm biết lỗi cho AsyncUncaughtExceptionHandler, mặc định chỉ log, dễ chìm trong log ồn. Trả CompletableFuture nếu caller hoặc audit cần biết kết quả (§4).
❌ Nhầm 3 — để mặc định AbortPolicy cho việc không được phép mất, trong khi method lại trả void:
✅ Hàng đợi đầy, AbortPolicy ném RejectedExecutionException ngay tại điểm gọi. Với method void, exception đó lại rơi vào đúng hố nuốt exception ở §4.1. Chọn CallerRunsPolicy cho việc không được mất, hoặc đảm bảo AsyncUncaughtExceptionHandler thật sự báo động (§5.4).
❌ Nhầm 4 — gọi @Async từ trong @Transactional rồi để thread nền tự query lại dữ liệu vừa lưu:
✅ Thread executor không nằm trong transaction gốc, có thể đọc trước khi commit. Truyền object đã có sẵn qua tham số, hoặc dùng @TransactionalEventListener(AFTER_COMMIT) ở bài 05 nếu cần đảm bảo thứ tự với commit.
Đào sâu
Spring Framework Reference — Task Execution and Scheduling xác nhận phần lõi của bài. Với return type void: "the exception is uncaught and cannot be transmitted", mặc định "the exception is merely logged" — đúng lý do email của TaskFlow im lặng suốt ba ngày. Với Future/CompletableFuture, exception được throw khi gọi get(), khớp §4.2.
Liên hệ các bài khác
- Bài 01 — Cache abstraction: cùng cơ chế proxy, cùng bẫy self-call. Bài này chỉ nhắc lại chỗ nó tái xuất, không dạy lại cơ chế.
- Bài 05 — Spring Events: giải quyết đúng vấn đề
@Transactionalkhông đi theo thread mới ở §6.2.@TransactionalEventListener(AFTER_COMMIT)là công cụ đúng khi cần đảm bảo thứ tự với commit. - Bài 04 — Scheduled: cũng chạy trên thread ngoài request, cùng lớp vấn đề ThreadLocal/transaction ở §6. Đọc tiếp để thấy cách
@Scheduledxử lý khác@Asyncở điểm nào.
Tóm tắt
- Bẫy self-call tái xuất từ bài 01, không phải khái niệm mới:
this.method()bỏ qua proxy hoàn toàn, không lỗi nào báo. - Ba tuần email TaskFlow im lặng quy về đúng một chữ
void. Đổi sangCompletableFuturelà đủ để exception có đường về quajoin/get/exceptionally. - Chọn rejection policy trước khi chọn pool size:
CallerRunsPolicycho việc không được mất,AbortPolicycho việc cần biết ngay khi quá tải. Pool size chỉ quyết định executor nghẽn nhanh hay chậm, policy quyết định việc sống hay chết khi hàng đợi đầy. - Context theo thread không phải bug của executor, mà đúng thiết kế ThreadLocal: đọc dữ liệu ra TRƯỚC khi gọi
@Async, truyền qua tham số thay vì trông chờ nó tự đi theo.
Tự kiểm tra
Q1Method @Async trả void và bên trong ném exception. Exception đó đi đâu, và vì sao caller không bao giờ biết được?▸
Q2Vì sao gọi this.sendConfirmation(...) từ trong cùng class lại khiến @Async mất tác dụng, dù code compile và chạy bình thường không lỗi?▸
Q3Hàng đợi của notificationExecutor đầy đúng lúc có 20 project được tạo liên tiếp. Với CallerRunsPolicy, chuyện gì xảy ra với 21 email vượt quá capacity, so với AbortPolicy?▸
Q4sendConfirmation (chạy trên executor thread) gọi MDC.get("traceId") và nhận về null, dù thread HTTP đã set traceId trước khi gọi nó. Giải thích cơ chế, và cách sửa đúng.▸
Q5createProject có @Transactional, gọi @Async sendConfirmation ngay sau khi save project. sendConfirmation tự query lại project bằng ID để lấy dữ liệu mới nhất. Vì sao đây là thiết kế rủi ro, và cách tránh nó mà không cần đợi bài 05?▸
Bài tiếp theo: Bài 04 — Scheduled — @Scheduled cũng chạy ngoài thread request, nhưng thêm bài toán chống chạy trùng khi có nhiều instance.
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
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