Spring Production-Ready/@Scheduled — fixed rate, fixed delay và cái bẫy nhiều instance
23/26
Bài 23 / 26~12 phútCaching, Async & SchedulingMiễn phí lượt xem

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

Fixed rate đếm từ lúc bắt đầu, fixed delay đếm từ lúc kết thúc — chọn sai thì job chồng lên nhau. Và mặc định scheduler chỉ có một thread.

TL;DR: @Scheduled chạy method định kỳ theo ba mốc khác nhau: fixedRate đếm từ lúc lần trước bắt đầu, fixedDelay đếm từ lúc lần trước kết thúc, cron bám theo đồng hồ thật. Chọn sai mốc, job chậm hơn chu kỳ sẽ dồn các lần chạy chồng lên nhau. Nhưng bẫy lớn nhất không nằm ở tam giác đó: scheduler mặc định của Spring chỉ có một thread, nên một job chạy lâu âm thầm làm mọi job khác trễ theo mà không có lỗi nào báo — và khi ứng dụng chạy nhiều instance, mỗi instance tự kích hoạt job của riêng mình, sinh ra kết quả trùng lặp nếu không có khoá phân tán chặn bớt.

TaskFlow có một job gửi báo cáo tổng hợp công việc mỗi đêm lúc 0h — mỗi team lead nhận một email liệt kê task quá hạn, task sắp đến hạn, số task hoàn thành trong ngày. Team vận hành deploy ứng dụng lên 3 instance để chịu tải giờ cao điểm ban ngày. Sáng hôm sau, support nhận hàng loạt ticket: mỗi team lead nhận đúng ba email giống hệt nhau, gửi cách nhau vài giây, cùng một nội dung.

Nguyên nhân: @Scheduled không biết gì về các instance khác. Mỗi instance chạy trong một tiến trình JVM riêng, và scheduler của Spring chỉ lo lịch trong đúng tiến trình đó — ba tiến trình cùng bật @EnableScheduling, cùng đọc đúng một cấu hình lịch, thì cả ba đều tự kích hoạt job vào đúng 0h00, không tiến trình nào biết hai tiến trình kia cũng vừa làm y hệt.

Bài này giải quyết ba câu hỏi: chọn fixedRate, fixedDelay hay cron cho đúng bài toán; vì sao scheduler mặc định có thể âm thầm làm mọi job trễ nhau; và cách chặn đúng bug ở trên.

1. Bật lịch — @EnableScheduling@Scheduled cơ bản

Scheduled task (tác vụ định kỳ) là một method được framework tự động gọi lại theo lịch, thay vì chờ request hay event kích hoạt. @Scheduled dùng đúng cơ chế AOP proxy bạn đã học ở bài trước với @Async, chỉ khác ở chỗ tự nó gọi chính nó theo lịch — nên bản thân scheduler cũng là một pool thật sự, cũng cần khai riêng (mục 4).

Bật scheduling cần đúng hai bước:

@Configuration
@EnableScheduling
public class SchedulingConfig {
}

@Component
public class NightlyReportJob {

    private final ReportService reportService;

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

    @Scheduled(cron = "0 0 0 * * *")
    public void sendNightlyReport() {
        reportService.generateAndSend();
    }
}

Method @Scheduled bắt buộc không nhận tham số: scheduler tự gọi theo thời gian, không có ngữ cảnh nào để truyền tham số vào. Khai tham số là Spring từ chối đăng ký ngay lúc khởi động. Còn void chỉ là quy ước, không phải ràng buộc cứng — trả về giá trị khác vẫn đăng ký và chạy bình thường, Spring lặng lẽ bỏ qua giá trị đó vì không ai đứng nhận. Cứ khai void để nói rõ ý định.

2. Fixed rate, fixed delay hay cron — đếm mốc từ đâu?

Chọn sai mốc, job TaskFlow "trùng" theo một cách khác: không trùng giữa instance, mà trùng ngay trong một instance.

Thử đoán trước khi đọc tiếp

Job sendNightlyReport khai fixedRate = 10s, nhưng lần chạy nào cũng mất 15 giây (báo cáo nặng, nhiều query). Viết ra: lần chạy thứ hai sẽ bắt đầu ở giây thứ mấy?

fixedRate đếm chu kỳ từ lúc lần trước bắt đầu. Tới đúng mốc chu kỳ, Spring cố kích hoạt lần chạy mới bất kể lần trước đã xong hay chưa — nếu job chưa xong, lần chạy mới phải đợi thread rảnh rồi chạy ngay khi có thể, và các mốc dồn lại phía sau:

flowchart TD
    A0["t=0s: Run1 bat dau"] --> A1["t=10s: moc dinh ky Run2<br/>nhung Run1 con dang chay"]
    A1 --> A2["t=15s: Run1 xong -> Run2 chay ngay<br/>(da tre 5s so voi moc)"]
    A2 --> A3["t=20s: moc dinh ky Run3<br/>Run2 con dang chay -> tiep tuc don"]

fixedDelay đếm chu kỳ từ lúc lần trước kết thúc. Lần chạy mới luôn cách lần trước đúng một khoảng nghỉ cố định, không bao giờ dồn:

flowchart TD
    B0["t=0s: Run1 bat dau"] --> B1["t=15s: Run1 xong"]
    B1 --> B2["t=15s + 10s = 25s: Run2 bat dau<br/>(luon co it nhat 10s nghi)"]

cron không đếm theo lần chạy trước — nó bám theo thời điểm thật trên đồng hồ. Job chạy lúc 0h00 mỗi đêm dù lần trước mất 2 phút hay 20 phút, lần sau vẫn chờ đúng tới 0h00 hôm sau mới kích hoạt (chi tiết cách viết ở mục 3).

Chọn mốc nào phụ thuộc đúng một câu hỏi: bạn cần khớp đồng hồ thật, hay cần khoảng cách an toàn giữa hai lần chạy?

Nhu cầuChọnVì sao
Cần đúng thời điểm cụ thể (0h đêm, 8h sáng thứ Hai)cronmốc là thời điểm thật trên đồng hồ, không phụ thuộc lần chạy trước
Job đụng tài nguyên chung (DB, file), cần khoảng nghỉ chắc chắn giữa hai lầnfixedDelayluôn chờ hết lần trước rồi mới đếm tiếp, không bao giờ dồn
Job chắc chắn nhanh hơn chu kỳ, chỉ cần đều đặn theo giờ tườngfixedRateđơn giản nhất, nhưng chỉ an toàn khi thời lượng job ổn định và ngắn hơn chu kỳ

3. Cron gọn — 6 trường, zone, initialDelay

Cron là cách lập lịch theo thời điểm thật, có nguồn gốc từ lệnh cron trên Unix. Spring không gọi ra tiến trình cron của hệ điều hành — nó implement lại cú pháp cron bằng bộ phân tích riêng, và mở rộng thêm một trường so với cron Unix cổ điển: 6 trường theo thứ tự giây (0-59), phút (0-59), giờ (0-23), ngày trong tháng (1-31), tháng (1-12), thứ trong tuần (0-7, 0 và 7 đều là Chủ Nhật).

@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Ho_Chi_Minh")
public void sendNightlyReport() {
    reportService.generateAndSend();
}

Biểu thức 0 0 0 * * * nghĩa là "giây 0, phút 0, giờ 0, mọi ngày, mọi tháng, mọi thứ" — đúng 0h00 mỗi ngày.

Thuộc tính zone là chỗ dễ sai nhất: server chạy múi giờ UTC, nhưng nghiệp vụ tính "0h đêm" theo giờ Việt Nam. Không khai zone, cron dùng múi giờ mặc định JVM — container chạy UTC thì 0 0 0 * * * kích hoạt lúc 0h UTC, tức 7h sáng giờ Việt Nam, không phải nửa đêm như mong đợi. Khai tường minh zone = "Asia/Ho_Chi_Minh" để cron luôn đúng múi giờ nghiệp vụ, bất kể server đặt ở đâu.

initialDelay hoãn lần chạy đầu tiên sau khi khởi động — tránh job chạy ngay lúc bean phụ thuộc (connection pool, cache) chưa sẵn sàng. @Scheduled(initialDelay = 30_000, fixedDelay = 60_000) nghĩa là đợi 30 giây sau khi ứng dụng khởi động rồi mới chạy lần đầu, sau đó mới đếm chu kỳ fixedDelay 60 giây như bình thường.

4. Bẫy im lặng nhất — scheduler mặc định chỉ có một thread

bài trước, executor mặc định của @Async không đủ dùng cho mọi loại việc. Scheduler đi đúng con đường đó, nhưng cái giá bỏ qua bước này nặng hơn nhiều: bean TaskScheduler mặc định của Spring chỉ có đúng một thread.

Hệ quả: mọi method @Scheduled trong ứng dụng — bất kể fixedRate, fixedDelay hay cron — xếp hàng chờ chung một thread đó. sendNightlyReport chạy 3 phút thì trong 3 phút đó, một job khác lẽ ra chạy mỗi phút sẽ bị dồn lại, chạy trễ, và không job nào ném lỗi để báo hiệu. Log vẫn sạch, chỉ có lịch là sai.

Cách sửa: khai một TaskScheduler bean với pool nhiều thread hơn (hoặc set property spring.task.scheduling.pool.size):

@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(5);
    scheduler.setThreadNamePrefix("taskflow-scheduler-");
    return scheduler;
}

Nguyên tắc chọn pool size giống @Async: đếm số job định kỳ chạy độc lập, không phải số instance hay CPU core. Job phụ thuộc tài nguyên chung (một connection pool DB nhỏ) thì tăng pool size không giúp gì — nghẽn chỉ chuyển từ scheduler sang connection pool.

5. Nhiều instance cùng bật job — chặn chạy trùng bằng gì?

Đây là lời giải cho bug mở đầu bài. Mỗi instance TaskFlow là một tiến trình JVM riêng; TaskScheduler chỉ điều phối bên trong đúng tiến trình đó, không biết tiến trình khác. Chống chạy trùng cần một cơ chế điều phối nằm ngoài JVM, mà mọi instance cùng nhìn thấy.

flowchart TD
    T["00:00 moi dem: ca 3 instance cung kich hoat job"] --> I1["A: xin lock"]
    T --> I2["B: xin lock"]
    T --> I3["C: xin lock"]
    I1 --> L{"Khoa phan tan<br/>DB hoac Redis"}
    I2 --> L
    I3 --> L
    L -- "duoc cap" --> WIN["A: chay job, gui bao cao"]
    L -- "tu choi" --> LOSE["B va C: bo qua, khong chay"]

Bốn hướng thường gặp:

  • Khoá phân tán qua DB hoặc Redis. Mỗi instance giành lock trước khi chạy; chỉ instance giành được mới chạy job. ShedLock (bên thứ ba, không thuộc Spring) hỗ trợ nhiều lock provider (JDBC, Redis, MongoDB...). Tự viết annotation khoá còn thiếu trước khi xem đáp án:

    @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Ho_Chi_Minh")
    // TODO: them @SchedulerLock voi name, lockAtLeastFor, lockAtMostFor phu hop
    public void sendNightlyReport() { ... }
    
    Tự điền trước khi xem đáp án

    Thêm @SchedulerLock: name phân biệt job, lockAtMostFor dài hơn thời gian job chạy thật, lockAtLeastFor đủ ngắn để không chặn lần chạy kế tiếp.

    Đáp án — thêm đúng một dòng annotation ngay trên method:

    @SchedulerLock(name = "sendNightlyReport",
                   lockAtLeastFor = "5m",
                   lockAtMostFor = "30m")
    
  • Chọn một instance làm leader (ZooKeeper hoặc tương đương) — chỉ leader bật job; nặng hơn khoá per-run nhưng hợp khi nhiều job cần cùng một instance.

  • Đẩy lịch ra ngoài ứng dụng: Kubernetes CronJob tạo đúng một Pod mỗi lần chạy; lịch phức tạp hơn thì dùng scheduler chuyên dụng như Quartz.

  • Bật job theo profile@Profile("scheduler") tắt job ở instance không nên chạy, rẻ nhất nhưng không tự chọn leader.

Quan trọng hơn mọi công cụ: job nên idempotent — chạy hai lần vẫn ra đúng một kết quả. Khoá phân tán không loại trừ tuyệt đối chạy trùng — lock có thể hết hạn giữa chừng; phòng thủ thật sự là job tự kiểm tra "báo cáo hôm nay đã gửi chưa" trước khi gửi.

Pitfall thường gặp

Tưởng exception làm job chết vĩnh viễn: đúng với ScheduledExecutorService thô của Java, nhưng Spring bọc mọi repeating task bằng TaskUtils.LOG_AND_SUPPRESS_ERROR_HANDLER — job vẫn chạy đúng lịch ở lần kế tiếp. Nguy hiểm thật nằm ở chiều ngược lại: lỗi chỉ rơi vào một dòng log ERROR rồi chìm nghỉm, y hệt @Async trả voidbài trước — job "vẫn chạy" hằng đêm suốt nhiều tuần mà đêm nào cũng hỏng, không ai biết. ✅ Try/catch ngay trong thân job để tự log và cảnh báo, đừng phó mặc cho error handler ngầm định; gắn metric mỗi lần chạy thành công (Micrometer & tag) để phát hiện job hỏng âm thầm.

Cron cũng chạy chồng, theo cách khác mục 2: cron chỉ tính mốc theo đồng hồ, không đợi lần trước xong. Tăng pool size ở mục 4 để job khác đỡ trễ vô tình mở khả năng một job cron dài hơn chu kỳ chạy song song với chính nó. ✅ Đo thời lượng job cron trước khi mở rộng pool size; job có nguy cơ dài hơn chu kỳ cần khoá như mục 5, kể cả trong một instance.

Job dài đè lên lúc shutdown: ứng dụng tắt giữa chừng job, để lại việc dở dang. ✅ setWaitForTasksToCompleteOnShutdown(true) cùng setAwaitTerminationSeconds(...) trên ThreadPoolTaskScheduler.

Không metric, job chết âm thầm hàng tháng: annotation bị xoá nhầm hoặc lock provider hỏng đều không tự báo lỗi. ✅ Đo bằng metric như Micrometer & tag: counter thành công/thất bại, gauge thời điểm chạy gần nhất, cảnh báo khi cũ hơn ngưỡng.

Đào sâu

📚 Deep Dive Spring Reference

Spec / reference chính thức:

  • Spring Framework Reference — Task Execution and Scheduling — định nghĩa chính thức của fixedRate/fixedDelay (đo từ đâu), cú pháp cron 6 trường, thuộc tính zone, ràng buộc chữ ký method, và cách khai TaskScheduler tuỳ chỉnh.
  • ShedLock — thư viện khoá phân tán cho scheduled task dùng ở mục 5, không thuộc Spring, hỗ trợ nhiều lock provider (JDBC, Redis, MongoDB, ZooKeeper...).

Ghi chú: phần "Scheduling Tasks" là nguồn chuẩn nhất khi bạn phân vân giữa hai hành vi trông giống nhau — kiểm tra lại đó thay vì suy đoán từ tên annotation.

Liên hệ các bài khác

Tóm tắt

  • fixedRate đếm từ lúc bắt đầu nên có thể dồn lần chạy chồng nhau; fixedDelay đếm từ lúc kết thúc nên luôn có khoảng nghỉ; cron bám theo đồng hồ thật, không quan tâm lần trước.
  • Không thấy exception nào nghĩa là job đang chạy tốt? Chưa chắc — Spring suppress rồi chạy tiếp, job hỏng âm thầm trông giống hệt job thành công nếu không tự bắt và đo bằng metric.
  • zone trên cron không phải tuỳ chọn trang trí: thiếu nó, lịch chạy theo múi giờ JVM chứ không theo múi giờ nghiệp vụ.
  • Một job đột nhiên trễ dù code không đổi? Nghi scheduler một luồng mặc định đang bị job khác chiếm, trước khi nghi logic của chính job đó.
  • Khoá phân tán chặn phần lớn trường hợp chạy trùng nhiều instance, nhưng tuyến phòng thủ cuối cùng vẫn là thiết kế job idempotent.

Tự kiểm tra

Tự kiểm tra
Q1
Job dùng fixedRate = 10s nhưng mỗi lần chạy mất 15 giây. Lần chạy thứ hai bắt đầu ở giây thứ mấy, và vì sao?

Giây thứ 15, ngay khi lần một vừa xong — không phải giây thứ 10 như chu kỳ khai báo. fixedRate đếm mốc từ lúc lần trước bắt đầu, nên mốc 10s đã "tới hạn" trong khi lần một còn chạy; scheduler đợi thread rảnh rồi chạy ngay, trễ 5 giây so với mốc lý thuyết. Mốc 20s cũng trôi qua trước khi lần hai xong, nên các lần sau cứ dồn nối đuôi nhau.

Q2
Đổi job trên sang fixedDelay = 10s, vẫn chạy mất 15 giây. Lần chạy thứ hai bắt đầu ở giây thứ mấy? Vì sao kết quả khác câu trên?

Lần chạy thứ hai bắt đầu ở giây thứ 25 (15 giây chạy xong lần một, cộng thêm 10 giây nghỉ). fixedDelay đếm mốc từ lúc lần trước kết thúc, nên nó không quan tâm lần trước mất bao lâu — luôn đảm bảo đúng một khoảng nghỉ cố định trước khi bắt đầu lần kế tiếp, và không bao giờ dồn chạy chồng.

Q3
Vì sao cron cần khai zone khi server chạy UTC nhưng nghiệp vụ tính giờ theo múi giờ Việt Nam? Bỏ qua zone thì hậu quả cụ thể là gì?

Không khai zone, cron tính theo múi giờ mặc định của JVM — container chạy UTC thì 0 0 0 * * * kích hoạt đúng 0h UTC, tức 7h sáng giờ Việt Nam, không phải nửa đêm như nghiệp vụ mong đợi. Khai tường minh zone = "Asia/Ho_Chi_Minh" để cron luôn tính đúng múi giờ nghiệp vụ, bất kể server vận hành ở đâu.

Q4
Scheduler mặc định chỉ có một thread. Ứng dụng có job A chạy 2 phút và job B cần chạy đúng mỗi phút. Điều gì xảy ra với job B, và cách sửa?

Trong 2 phút job A chạy, thread scheduler duy nhất bị chiếm — job B lẽ ra kích hoạt mỗi phút bị dồn lại, chờ job A xong mới có cơ hội chạy, và không có exception hay warning nào báo hiệu. Cách sửa: khai TaskScheduler bean tuỳ chỉnh (ThreadPoolTaskScheduler với setPoolSize lớn hơn 1) hoặc set spring.task.scheduling.pool.size, để mỗi job có thread riêng thay vì xếp hàng chung.

Q5
Tại sao chỉ thêm khoá phân tán (ví dụ ShedLock) vẫn chưa đủ an toàn tuyệt đối — job vẫn nên được thiết kế idempotent?

Khoá phân tán giảm xác suất chạy trùng xuống rất thấp nhưng không loại trừ tuyệt đối: lock có thể hết hạn (lockAtMostFor) giữa lúc job vẫn còn chạy nếu ước lượng thời lượng sai, hoặc một lần retry sau sự cố mạng có thể vô tình kích hoạt job lần nữa trước khi lock cũ được giải phóng đúng cách. Idempotent (chạy nhiều lần cho cùng một kết quả cuối) là tuyến phòng thủ không phụ thuộc vào cơ chế khoá nào hoạt động hoàn hảo — job tự kiểm tra "báo cáo hôm nay đã gửi chưa" trước khi gửi, thay vì tin tưởng tuyệt đối vào lock.

Bài tiếp theo: Spring Events

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

Spring Events — tách luồng phụ khỏi luồng chính