@Async@Scheduled là hai chú thích dễ dùng tới mức người ta quên rằng có một pool luồng phía sau.

Pool từ chối việc

spring:
  task:
    execution:
      pool:
        core-size: 2
        max-size: 2
        queue-capacity: 3
  nộp 10 | nhận 5 | từ chối 5 (TaskRejectedException) | xong 5 | 911 ms

Hai luồng chạy, ba việc xếp hàng — năm việc còn lại bị từ chối thẳng, không chờ.

Đây là cùng hành vi với pool kết nối ở bài 34: quá trần thì ném ngoại lệ, không xếp hàng vô hạn. Và đó là hành vi đúng — hàng đợi vô hạn chỉ đẩy vấn đề sang chỗ hết bộ nhớ.

Mặc định của Spring Boot khác hẳn con số tôi đặt: core-size: 8, max-size không giới hạn, và queue-capacity không giới hạn. Nghĩa là mặc định bạn có một hàng đợi có thể phình tới hết heap.

Ba tham số này phải đặt theo tải thật, và phải có cách xử lý khi bị từ chối:

@Bean(name = "executorCuaToi")
Executor executor() {
    var e = new ThreadPoolTaskExecutor();
    e.setCorePoolSize(4); e.setMaxPoolSize(8); e.setQueueCapacity(100);
    e.setThreadNamePrefix("viec-");
    e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    e.initialize();
    return e;
}

CallerRunsPolicy cho luồng gọi tự chạy việc đó — nó tạo áp lực ngược tự nhiên: hệ thống chậm lại thay vì mất việc.

Tự gọi thì nó chạy đồng bộ

  luồng của request: http-nio-8080-exec-4
  bat dau viec 99 tren luong http-nio-8080-exec-4      <- CÙNG luồng

Việc @Async chạy trên chính luồng của request, không phải task-N.

Lại là proxy — cùng cơ chế với @Transactional (bài 26), @Cacheable (bài 35), @PreAuthorize (bài 41). Danh sách này giờ có bốn mục, và chúng đều im lặng.

Với @Async, hậu quả là request đáng lẽ trả về ngay lại chờ hết công việc nền. Không lỗi, chỉ chậm.

Và tôi vấp lại đúng bẫy thứ năm ngay trong lúc đo:

  NullPointerException: Cannot invoke "AtomicInteger.incrementAndGet()"
    because "this.bat.tuChoi" is null

Đọc trường trên bean bị proxy cho ra null — proxy CGLIB là lớp con, phương thức được uỷ nhiệm còn trường thì không. Đúng chuyện đã gặp ở bài 35, và tôi mắc lại lần thứ hai.

Ngoại lệ bị nuốt

@Async public void khongTraVe() { throw new IllegalStateException("no o day"); }

Phương thức @Async trả về void mà ném ngoại lệ thì không ai thấy — người gọi đã đi tiếp từ lâu.

Hai cách xử lý:

@Async public CompletableFuture<String> viec() { ... }     // ngoại lệ nằm trong Future

Hoặc khai một bộ xử lý toàn cục:

@Configuration
class CauHinhAsync implements AsyncConfigurer {
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (e, method, args) -> log.error("loi trong {} ", method.getName(), e);
    }
}

Bộ xử lý này chỉ áp dụng cho phương thức trả về void. Với CompletableFuture, bạn phải tự gọi .exceptionally() hoặc .handle() — nếu không, ngoại lệ nằm im trong Future và không ai đọc.

@Async và giao dịch

@Transactional
public void ngoai() {
    repo.save(don);
    batDongBo.xuLy(don.getId());     // @Async — chạy trên luồng khác
}

Luồng @Async không thấy giao dịch của luồng gọi, và nó chạy trước khi giao dịch kia commit. Nên nó có thể đọc CSDL và không tìm thấy bản ghi bạn vừa lưu.

Cách chữa: đợi commit rồi mới chạy.

@TransactionalEventListener(phase = AFTER_COMMIT)
@Async
public void sauKhiLuu(SuKienDonMoi e) { ... }

Và nhắc lại từ bài 27: đừng truyền entity sang luồng khác, chỉ truyền id.

@Scheduled

@Scheduled(fixedRate = 500)         void a() { }   // mỗi 500ms kể từ lúc BẮT ĐẦU lần trước
@Scheduled(fixedDelay = 500)        void b() { }   // 500ms sau khi lần trước KẾT THÚC
@Scheduled(cron = "0 0 3 * * *")    void c() { }   // 3 giờ sáng hằng ngày
  sau khoảng 4 giây với fixedRate=500  ->  8 lần chạy

Khác biệt fixedRate với fixedDelay quan trọng: với fixedRate, nếu công việc chạy lâu hơn chu kỳ, các lần chạy dồn lại. fixedDelay thì không bao giờ chồng lấn.

Bốn điều phải biết:

Mặc định chỉ có MỘT luồng. Mọi @Scheduled trong ứng dụng dùng chung một luồng, nên một công việc chạy lâu chặn tất cả. Sửa bằng:

spring.task.scheduling.pool.size: 4

Đặt zone cho cron. @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Ho_Chi_Minh"). Không có nó thì nó theo múi giờ của JVM — và CLAUDE.md của blog này ghi rõ: để mặc định UTC thì công việc 3 giờ sáng chạy lúc 10 giờ sáng giờ Việt Nam.

Nhiều bản sao thì chạy nhiều lần. Ba container là báo cáo hằng ngày gửi ba lần. Dùng khoá phân tán (bài 37) hoặc ShedLock:

@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "baoCaoHangNgay", lockAtMostFor = "10m")
void baoCao() { }

Ngoại lệ dừng lịch của phương thức đó với fixedDelay. Bọc thân phương thức trong try/catch — hoặc chấp nhận rằng một lỗi tạm thời có thể tắt hẳn công việc đó tới lần khởi động sau.

Khi nào không dùng @Scheduled

Khi công việc phải chạy đúng một lần dù có bao nhiêu bản sao, phải thử lại được, phải xem được lịch sử, hoặc mất hơn vài phút — lúc đó bạn cần hàng đợi công việc thật: Quartz với kho JDBC, hoặc một hàng đợi thông điệp.

@Scheduled đúng cho việc nhẹ, chạy được lại từ đầu, và không sao nếu thỉnh thoảng bỏ lỡ.

Thử ba mươi giây

curl -s localhost:8080/actuator/metrics/executor.queued | jq '.measurements'
curl -s localhost:8080/actuator/metrics/executor.active | jq '.measurements'

queued khác 0 kéo dài nghĩa là bạn đang nộp việc nhanh hơn xử lý — và với hàng đợi mặc định không giới hạn, nó sẽ lớn lên cho tới khi hết heap.

Ngày mai: sự kiện ứng dụng — cách tách rời hai phần mà không cần hàng đợi.