@Async và @Scheduled là hai chú thích dễ dùng tới mức người ta quên rằng phía sau có một tổ thư ký số lượng cố định với một cái khay đựng việc, và một cái đồng hồ báo thức treo trên tường văn phòng này.

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 — cái khay việc không có đáy, cứ chất mãi cho tới khi ngập cả phòng.

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: người mang việc tới phải tự xắn tay làm, nên đầu vào 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 — cái đồng hồ báo thức treo trên tường mỗi văn phòng, mở ba chi nhánh thì nó reo ở cả ba. 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ỡ.

Nếu chỉ soi một thứ sau bài này, nhìn cái khay việc đang đầy cỡ nào, trong 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.

Mẫu số chung

Khi quá tải, bạn phải chọn một chính sách một cách có chủ đích — bỏ bớt việc, chối thẳng, hay đẩy ngược áp lực — vì cái mặc định "biết điều" là một hàng đợi không giới hạn chỉ dời cú sập sang chỗ hết bộ nhớ. Hàng đợi @Async mặc định của Spring không có trần, đúng cái bẫy của mọi hàng đợi thiếu trần; câu trả lời lành mạnh là áp lực ngược — CallerRunsPolicy bắt người sản xuất chậm lại (tự làm lấy) thay vì mất việc hay nổ tung, cùng ý tưởng với điều tiết luồng của TCP, channel có đệm giới hạn, và backpressure của reactive streams. Một hàng đợi không trần là một rò rỉ bộ nhớ có hẹn giờ; hãy chặn trần nó, và quyết trước điều gì xảy ra khi nó đầy.

Điều thứ hai: một tác vụ nền/định kỳ chạy trong tiến trình là của từng bản sao, không phải của cả hệ thống — chạy ba bản sao thì một job @Scheduled reo ba lần, và nó chết theo tiến trình. Bất cứ thứ gì phải chạy đúng một lần trên toàn cụm, sống qua khởi động lại, thử lại được, hay xem lại được lịch sử là một bài toán phối hợp phân tán — một khoá phân tán (ShedLock), hoặc một bộ lập lịch/hàng đợi công việc thật (Quartz với kho JDBC, một message queue) — chứ không phải một chú thích. Đúng cái thang ở bài sự kiện ứng dụng: tiện-lợi-trong-tiến-trình hợp cho việc cục bộ làm-hết-sức; còn "làm việc này đúng một lần cho cả hệ thống, đáng tin" thì phải đặt ra ngoài tiến trình.

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.