Spring Production-Ready/@Async: chạy nền mà không đánh mất exception
22/26
Bài 22 / 26~13 phútCaching, Async & SchedulingMiễn phí lượt xem

@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ả.

Exception đi đâu?

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@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 */
    }
}
Tự điền trước khi xem đáp án

Đ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 ThreadPoolTaskExecutorAbortPolicy: 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:

PolicyHành viHợp với
AbortPolicy (mặc định)Ném exception, task bị bỏViệc phải biết ngay khi quá tải
CallerRunsPolicyThread 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 đề @Transactional khô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 @Scheduled xử 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 sang CompletableFuture là đủ để exception có đường về qua join/get/exceptionally.
  • Chọn rejection policy trước khi chọn pool size: CallerRunsPolicy cho việc không được mất, AbortPolicy cho 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

Tự kiểm tra
Q1
Method @Async trả void và bên trong ném exception. Exception đó đi đâu, và vì sao caller không bao giờ biết được?
Executor thread bắt exception rồi đưa cho AsyncUncaughtExceptionHandler, mặc định handler này chỉ log ở mức ERROR, không throw lên đâu và không có kênh nào đẩy nó về caller. Caller đã return từ lâu trước khi exception xảy ra, vì proxy trả về ngay sau khi đẩy task vào executor, nên không còn đường nối hai thread lại. Muốn caller biết, đổi return type sang CompletableFuture để exception được bọc vào future thay vì đưa cho handler.
Q2
Vì 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?
Spring hiện thực @Async bằng proxy bọc quanh bean, chỉ chặn được lời gọi từ bean khác đi vào qua đúng reference đã proxy hoá. this.sendConfirmation(...) là lời gọi Java thuần trên chính đối tượng gốc, không đi qua proxy nên @Async không có cơ hội can thiệp, method chạy đồng bộ như bình thường. Không có exception hay warning vì về mặt Java đây là code hợp lệ, chỉ là annotation bị bỏ qua âm thầm, đúng cơ chế self-call bypass đã học ở bài 01.
Q3
Hà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?
Với CallerRunsPolicy, thread đang gọi sendConfirmation tự chạy task đó thay vì đẩy vào executor: request chậm lại đúng bằng thời gian gửi email, nhưng cả 21 email vẫn được gửi hết. Với AbortPolicy (mặc định), executor ném RejectedExecutionException ngay tại điểm gọi; nếu sendConfirmation trả void, exception này rơi vào AsyncUncaughtExceptionHandler và có nguy cơ chỉ bị log rồi biến mất, 21 email đó không bao giờ được gửi. CallerRunsPolicy đánh đổi độ trễ để không mất việc, AbortPolicy giữ nhanh nhưng cần handler thật sự báo động.
Q4
sendConfirmation (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.
MDC lưu dữ liệu bằng ThreadLocal, chỉ thread đã ghi mới đọc lại được. Executor thread chạy sendConfirmation khác thread HTTP đã gọi MDC.put("traceId", ...), nên MDC trên thread mới trống rỗng, MDC.get trả về null. Cách sửa đúng là đọc traceId TRƯỚC khi gọi method @Async, truyền nó như tham số, rồi set lại MDC.put trên thread executor ở đầu method, không trông chờ ThreadLocal tự đi theo. Với nhiều loại context cùng lúc, dùng TaskDecorator để copy tự động thay vì truyền tay.
Q5
createProject 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?
Thread executor chạy sendConfirmation không nằm trong transaction của createProject, vì transaction cũng gắn với thread giống ThreadLocal. Nếu thread executor chạy trước khi transaction gốc commit, query lại project bằng ID có thể không thấy dữ liệu vừa lưu. Cách tránh ngay là đừng query lại: truyền thẳng object project đã có sẵn làm tham số, tránh đọc lại DB từ thread ngoài transaction. Cách này không giải quyết được trường hợp cần đợi commit thành công mới gửi, đó là lý do bài 05 tồn tại.

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

Đặt 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

Bài tiếp theo

@Scheduled — fixed rate, fixed delay và cái bẫy nhiều instance