Xử lý tác vụ định kỳ với @Scheduled trong Spring Boot

1. Tổng quan

Nhiều ứng dụng cần chạy việc theo lịch: dọn đơn hàng hết hạn, đồng bộ tồn kho mỗi 5 phút, gửi báo cáo lúc 8 giờ sáng. Spring hỗ trợ việc này qua annotation @Scheduled mà không cần thêm thư viện ngoài.

Trong bài này, bạn sẽ bật scheduling và phân biệt fixedRate, fixedDelay, cron. Bạn cũng sẽ đưa lịch chạy ra file cấu hình, chỉnh thread pool và viết test cho job. Cuối bài là những trường hợp không nên dùng @Scheduled.

2. Bật scheduling

@Scheduled nằm sẵn trong spring-context. Bạn chỉ cần starter cơ bản của Spring Boot:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>

Tiếp theo, thêm @EnableScheduling vào một lớp cấu hình:

@SpringBootApplication
@EnableScheduling
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

Thiếu @EnableScheduling là lỗi hay gặp nhất. Ứng dụng vẫn khởi động bình thường, không báo lỗi gì, nhưng không job nào chạy.

Lưu ý: Phương thức gắn @Scheduled phải không có tham số. Nó nên trả về void, vì giá trị trả về sẽ bị bỏ qua. Lớp chứa nó phải là một bean, ví dụ có @Component hoặc @Service.

3. Chạy theo khoảng thời gian cố định

3.1. fixedRate

fixedRate tính chu kỳ từ lúc bắt đầu lần chạy trước:

@Component
public class InventoryJob {

    private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);

    @Scheduled(fixedRate = 5000)
    public void syncInventory() {
        log.info("Đồng bộ tồn kho lúc {}", LocalTime.now().withNano(0));
    }
}

Đơn vị mặc định là mili giây. Job chạy ngay khi ứng dụng sẵn sàng, sau đó cứ 5 giây chạy một lần:

INFO [   scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:00
INFO [   scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:05
INFO [   scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:10

Nếu một lần chạy mất lâu hơn 5 giây, lần kế tiếp không chạy song song. Nó bắt đầu ngay khi lần trước xong, vì mặc định chỉ có một thread (scheduling-1).

3.2. fixedDelay

fixedDelay tính chu kỳ từ lúc kết thúc lần chạy trước. Kiểu này hợp với những việc không được chồng lên nhau, như gọi API bên ngoài hoặc xử lý từng lô dữ liệu:

@Component
public class EmailQueueJob {

    private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);

    @Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
    public void sendPendingEmails() throws InterruptedException {
        log.info("Bắt đầu gửi email");
        Thread.sleep(2000); // giả lập công việc mất 2 giây
        log.info("Gửi xong");
    }
}

Giải thích:

  • initialDelay = 10: chờ 10 giây sau khi khởi động mới chạy lần đầu, để tránh làm chậm lúc khởi động.
  • timeUnit = TimeUnit.SECONDS: đổi đơn vị cho cả fixedDelay lẫn initialDelay.
  • Mỗi lần chạy mất 2 giây, sau đó nghỉ 3 giây. Thời điểm bắt đầu của hai lần liên tiếp cách nhau 5 giây:
09:00:10 Bắt đầu gửi email
09:00:12 Gửi xong
09:00:15 Bắt đầu gửi email
09:00:17 Gửi xong

4. Lập lịch bằng cron

Khi cần chạy vào giờ cụ thể, hãy dùng cron. Biểu thức cron của Spring có 6 trường, nhiều hơn cron Unix một trường giây ở đầu:

┌─ giây (0-59)
│ ┌─ phút (0-59)
│ │ ┌─ giờ (0-23)
│ │ │ ┌─ ngày trong tháng (1-31)
│ │ │ │ ┌─ tháng (1-12 hoặc JAN-DEC)
│ │ │ │ │ ┌─ thứ trong tuần (0-7 hoặc MON-SUN)
* * * * * *

Ví dụ gửi báo cáo doanh thu lúc 8 giờ sáng các ngày từ thứ Hai đến thứ Sáu, theo giờ Việt Nam:

@Component
public class SalesReportJob {

    private final ReportService reportService;

    public SalesReportJob(ReportService reportService) {
        this.reportService = reportService;
    }

    @Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
    public void sendDailyReport() {
        reportService.sendYesterdaySales();
    }
}

Thuộc tính zone rất quan trọng khi server chạy theo UTC, điều thường gặp với container. Nếu bỏ qua, báo cáo sẽ đến lúc 15 giờ thay vì 8 giờ.

Một số biểu thức hay dùng:

0 */15 * * * *        mỗi 15 phút
0 0 2 * * *           2 giờ sáng mỗi ngày
0 0 0 1 * *           0 giờ ngày đầu mỗi tháng
0 30 9 * * SAT,SUN    9:30 thứ Bảy và Chủ nhật
@daily                tương đương 0 0 0 * * *
@hourly               tương đương 0 0 * * * *

Lưu ý: Nếu chép biểu thức 5 trường từ crontab Linux, ví dụ 0 8 * * *, Spring sẽ ném IllegalStateException lúc khởi động. Bạn cần thêm trường giây vào đầu.

5. Đưa lịch chạy ra file cấu hình

Hardcode lịch trong code buộc bạn phải build lại mỗi khi đổi. Hãy dùng placeholder. Với thuộc tính dạng số, dùng biến thể ...String:

@Component
public class OrderCleanupJob {

    private final OrderRepository orderRepository;
    private final AtomicInteger runCount = new AtomicInteger();

    public OrderCleanupJob(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
    public void removeExpiredOrders() {
        orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
        runCount.incrementAndGet();
    }

    public int getRunCount() {
        return runCount.get();
    }
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI

Giá trị sau dấu : là mặc định, dùng khi thuộc tính không được khai báo. Với cron, bạn viết @Scheduled(cron = "${jobs.sales-report.cron}").

Muốn tắt một job cron mà không sửa code, bạn đặt giá trị là -:

jobs.sales-report.cron=-

Spring nhận ra - (hằng số Scheduled.CRON_DISABLED) và không đăng ký job đó. Cách này tiện khi bạn muốn tắt job trên môi trường dev hoặc test.

6. Cấu hình thread pool

Mặc định, Spring Boot tạo một ThreadPoolTaskScheduler với đúng một thread. Mọi job dùng chung thread đó. Nếu SalesReportJob treo 10 phút, InventoryJob cũng phải chờ 10 phút.

Bạn có thể tăng kích thước pool:

spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-

Sau khi tăng, các job khác nhau có thể chạy song song:

INFO [   shop-job-1] InventoryJob   : Đồng bộ tồn kho lúc 09:00:05
INFO [   shop-job-2] EmailQueueJob  : Bắt đầu gửi email

Từ Spring Boot 3.2, khi chạy Java 21, bạn có thể dùng virtual thread:

spring.threads.virtual.enabled=true

Lúc này Boot dùng SimpleAsyncTaskScheduler, và mỗi lần chạy job nằm trên một virtual thread riêng. Thuộc tính pool.size không còn tác dụng.

Lưu ý: Tăng pool size không làm cùng một job chạy chồng lên chính nó. Mỗi phương thức @Scheduled vẫn chạy tuần tự. Nếu muốn các lần chạy chồng nhau, bạn phải thêm @Async (kèm @EnableAsync). Hầu như không nên làm vậy, vì dễ gây xử lý trùng dữ liệu.

7. Xử lý exception

Khi job ném exception với fixedRate hoặc fixedDelay, Spring ghi log lỗi rồi vẫn chạy lại ở lần kế tiếp. Cron cũng vậy. Job không dừng hẳn, nhưng lỗi cũng dễ bị bỏ qua nếu bạn không theo dõi log.

Cách an toàn nhất là bắt lỗi ngay trong job và xử lý có chủ đích:

@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
    try {
        inventoryClient.pullLatestStock();
    } catch (RestClientException ex) {
        log.warn("Không gọi được kho hàng, sẽ thử lại ở lần sau: {}", ex.getMessage());
        alertService.notifyOps("inventory-sync-failed");
    }
}

Nếu muốn có một chỗ xử lý chung cho mọi job, bạn đăng ký ErrorHandler qua customizer của Boot:

@Configuration
public class SchedulingConfig {

    @Bean
    ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
        return scheduler -> scheduler.setErrorHandler(ex ->
                alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
    }
}

Customizer này chỉ áp dụng cho ThreadPoolTaskScheduler. Nó không có tác dụng khi bạn bật virtual thread.

8. Viết test cho job

Hãy tách test thành hai tầng. Tầng thứ nhất kiểm tra logic nghiệp vụ. Tầng thứ hai kiểm tra rằng scheduler thực sự gọi job.

Logic nghiệp vụ nên test như một phương thức bình thường, gọi trực tiếp và không cần chờ:

class OrderCleanupJobUnitTest {

    @Test
    void shouldDeleteOrdersOlderThan24Hours() {
        OrderRepository repo = mock(OrderRepository.class);
        OrderCleanupJob job = new OrderCleanupJob(repo);

        job.removeExpiredOrders();

        ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
        verify(repo).deleteExpiredBefore(captor.capture());
        assertThat(captor.getValue())
                .isBefore(Instant.now().minus(Duration.ofHours(23)));
        assertThat(job.getRunCount()).isEqualTo(1);
    }
}

Để kiểm tra việc lập lịch, bạn dùng Awaitility. Không nên dùng Thread.sleep, vì test sẽ chậm và chập chờn. Phiên bản Awaitility đã được Spring Boot quản lý, nên bạn không cần ghi version:

<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {

    @MockitoBean
    OrderRepository orderRepository;

    @Autowired
    OrderCleanupJob job;

    @Test
    void shouldRunRepeatedly() {
        await().atMost(Duration.ofSeconds(2))
               .untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
    }
}

Test này hạ chu kỳ xuống 100 ms qua properties. Sau đó nó chờ tối đa 2 giây cho tới khi job chạy ít nhất 3 lần. Awaitility kiểm tra lặp lại và trả về ngay khi điều kiện đúng, nên test thường xong trong khoảng 300 ms. @MockitoBean có từ Spring Boot 3.4. Với phiên bản cũ hơn, bạn dùng @MockBean.

Ở các test tích hợp khác, nên tắt scheduling để job không chạy ngầm và làm sai dữ liệu. Có hai cách. Cách thứ nhất là đặt cron thành -. Cách thứ hai là chuyển @EnableScheduling sang một lớp cấu hình riêng, có điều kiện:

@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }

Khi đó, test chỉ cần khai báo jobs.enabled=false.

9. Khi nào KHÔNG nên dùng @Scheduled

@Scheduled đơn giản, nhưng có giới hạn rõ ràng:

  • Chạy nhiều instance. Với 3 pod Kubernetes, job sẽ chạy 3 lần cùng lúc, và báo cáo bị gửi 3 lần. Bạn cần khóa phân tán, ví dụ thư viện ShedLock dùng bảng trong database, hoặc tách job ra một service chạy đơn lẻ.
  • Cần lưu trạng thái job. Nếu ứng dụng tắt đúng lúc 8 giờ, lần chạy đó sẽ mất và không được chạy bù. Khi cần misfire handling, lịch sử chạy hoặc lập lịch động lúc runtime, hãy dùng Quartz.
  • Job rất nặng hoặc chạy hàng giờ. Nên chuyển sang Spring Batch hoặc một hệ thống queue để có retry, chia lô và theo dõi tiến độ.
  • Lịch do người dùng tạo, ví dụ “nhắc tôi lúc 15:00”. Annotation chỉ hợp với lịch cố định lúc khởi động. Lịch động nên lưu trong database và dùng TaskScheduler lập trình, hoặc dùng Quartz.

Thêm vài lỗi hay gặp:

  • Gắn @Scheduled lên phương thức private hoặc phương thức của lớp không phải bean.
  • Quên zone, khiến job chạy lệch giờ trên server UTC.
  • Đặt @Scheduled và @Transactional trên cùng một phương thức có thể chạy được. Tuy vậy, tách logic sang một service có @Transactional sẽ dễ test và dễ đọc hơn.

10. Kết luận

Trong bài này, bạn đã:

  • bật scheduling bằng @EnableScheduling;
  • phân biệt fixedRate, tính từ lúc bắt đầu, với fixedDelay, tính từ lúc kết thúc;
  • viết biểu thức cron 6 trường có múi giờ;
  • đưa lịch chạy ra file cấu hình và tắt job bằng -;
  • tăng thread pool hoặc dùng virtual thread để job không chặn nhau;
  • xử lý exception và viết test bằng Awaitility.

Với ứng dụng chạy một instance và lịch cố định, @Scheduled là đủ. Khi hệ thống mở rộng ra nhiều instance hoặc cần độ tin cậy cao hơn, hãy cân nhắc ShedLock hoặc Quartz trước khi gặp sự cố trên production.

spring-bootschedulingcronjava