Chuyển tới nội dung chính

14.8 — 7. Quartz.NET (Alternative Overview)

Tóm tắt

Quartz.NET và Hangfire giải hai bài toán khác nhau dù trông giống nhau. Hangfire mạnh về hàng đợi job: đưa việc vào, nó chạy, có retry và dashboard. Quartz mạnh về lập lịch: trigger phức tạp, lịch loại trừ ngày lễ, và clustering với cơ chế chuyển giao khi một node chết. Khái niệm quan trọng nhất của Quartz mà Hangfire không có là misfire — chuyện gì xảy ra khi một trigger đáng lẽ đã chạy nhưng không chạy được (server tắt, không còn thread). Bốn chính sách misfire cho bốn hành vi khác nhau, và chọn sai gây ra hoặc bỏ sót lần chạy, hoặc chạy dồn 50 lần ngay khi server lên.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Chọn giữa Quartz và Hangfire có lý do.
  • Cấu hình trigger cho lịch phức tạp.
  • Chọn đúng chính sách misfire.
  • Ngăn job chạy chồng lên chính nó.
  • Cấu hình clustering.

Nội dung bài học​

14.8.1 — Hai bài toán khác nhau​

HangfireQuartz.NET
Ý tưởng cốt lõiHàng đợi jobLập lịch
Fire-and-forgetCó sẵnPhải tự dựng
Trigger phức tạpCron cơ bảnRất mạnh
Lịch loại trừ (ngày lễ)KhôngCó
DashboardCó sẵnKhông
RetryCó sẵnTự cài
ClusteringQua databaseThiết kế sẵn
MisfireKhông có khái niệmBốn chính sách
Giấy phépLGPLApache 2.0

Hai hàng đầu và hai hàng cuối quyết định lựa chọn.

Chọn Hangfire khi: phần lớn việc là "đưa vào hàng đợi rồi chạy", cần dashboard, cần retry mà không muốn viết.

Chọn Quartz khi: lịch là trung tâm bài toán, cần loại trừ ngày lễ hoặc múi giờ phức tạp, cần clustering mạnh, hoặc giấy phép LGPL là vấn đề.

Với một CRM điển hình, Hangfire thường đúng hơn — phần lớn việc là gửi email, đồng bộ dữ liệu, dọn dẹp. Quartz đáng khi bạn đang xây thứ mà lập lịch là nghiệp vụ.

14.8.2 — Cấu hình​

dotnet add package Quartz.Extensions.Hosting
builder.Services.AddQuartz(q =>
{
q.SchedulerId = "AUTO";

var jobKey = new JobKey("lead-reminder", "crm");

q.AddJob<LeadReminderJob>(opts => opts
.WithIdentity(jobKey)
.StoreDurably());

q.AddTrigger(t => t
.ForJob(jobKey)
.WithIdentity("lead-reminder-trigger")
.WithCronSchedule("0 0 9 * * ?", x => x
.InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("SE Asia Standard Time"))
.WithMisfireHandlingInstructionFireAndProceed()));
});

builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true; // shutdown gon gang
options.AwaitApplicationStarted = true; // doi app san sang
});
[DisallowConcurrentExecution]
public sealed class LeadReminderJob(
IServiceScopeFactory scopeFactory, ILogger<LeadReminderJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<ILeadService>();

await service.SendRemindersAsync(context.CancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Job nhắc nhở lead thất bại");

throw new JobExecutionException(ex, refireImmediately: false);
}
}
}

Bốn chi tiết:

  • JobExecutionException là cách đúng để báo lỗi cho Quartz; refireImmediately: false tránh vòng lặp chạy lại ngay.
  • context.CancellationToken được kích hoạt khi scheduler dừng — truyền nó xuống.
  • IServiceScopeFactory vì job không có scope riêng, đúng như BackgroundService (bài 9.10).
  • WaitForJobsToComplete = true cho job đang chạy hoàn tất khi shutdown.

14.8.3 — Misfire​

Đây là khái niệm quan trọng nhất của Quartz.

Misfire xảy ra khi một trigger đáng lẽ đã chạy nhưng không chạy được: server đang tắt, không còn thread rảnh, hoặc job trước còn chạy với DisallowConcurrentExecution.

Job chay 9h moi ngay.
Server tat tu 8h toi 11h.
=> Lan chay 9h bi MISFIRE. Lam gi bay gio?

Bốn chính sách:

Chính sáchHành vi
WithMisfireHandlingInstructionFireAndProceedChạy một lần ngay, rồi tiếp tục lịch
WithMisfireHandlingInstructionDoNothingBỏ qua, chờ lần kế tiếp
WithMisfireHandlingInstructionIgnoreMisfiresChạy bù tất cả các lần đã lỡ
Mặc định (smart policy)Tuỳ loại trigger

Chọn theo bản chất job:

// Báo cáo hàng ngày — chạy bù MỘT lần là đủ
.WithMisfireHandlingInstructionFireAndProceed()

// Dọn dẹp mỗi 5 phút — bỏ qua, lần sau chạy
.WithMisfireHandlingInstructionDoNothing()

// Không bao giờ dùng cho job chạy dày — chạy bù HÀNG TRĂM lần
.WithMisfireHandlingInstructionIgnoreMisfires()

Chính sách thứ ba là cái bẫy: job chạy mỗi 5 phút, server tắt 3 tiếng, và khi lên nó chạy 36 lần liên tiếp — thường làm quá tải ngay lúc hệ thống vừa khởi động.

Hangfire không có khái niệm này: recurring job bị lỡ thì chạy ngay khi server lên, tương đương FireAndProceed. Đơn giản hơn, nhưng không chọn được hành vi khác.

14.8.4 — Trigger phức tạp​

// Lich lap don gian
.WithSimpleSchedule(s => s.WithIntervalInHours(1).RepeatForever())

// Cron day du
.WithCronSchedule("0 0/15 9-17 ? * MON-FRI") // moi 15 phut, 9h-17h, T2-T6

// Lịch theo lịch (xử lý đúng giờ mùa hè)
.WithCalendarIntervalSchedule(s => s
.WithIntervalInMonths(1)
.PreserveHourOfDayAcrossDaylightSavings(true))

// Lich hang ngay trong khoang gio
.WithDailyTimeIntervalSchedule(s => s
.StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(9, 0))
.EndingDailyAt(TimeOfDay.HourAndMinuteOfDay(17, 0))
.OnMondayThroughFriday()
.WithIntervalInMinutes(30))

PreserveHourOfDayAcrossDaylightSavings là ví dụ cho thấy Quartz nghĩ kỹ về lịch: với WithIntervalInMonths, chuyển giờ mùa hè sẽ làm job chạy lệch một tiếng nếu không xử lý.

Lịch loại trừ là thứ Hangfire hoàn toàn không có:

var holidays = new HolidayCalendar();
holidays.AddExcludedDate(new DateTime(2026, 1, 1));
holidays.AddExcludedDate(new DateTime(2026, 4, 30));

await scheduler.AddCalendar("vn-holidays", holidays, replace: true, updateTriggers: true);

// Trigger bỏ qua những ngày đó
.ModifiedByCalendar("vn-holidays")

Với job nghiệp vụ như "gửi báo cáo mỗi ngày làm việc", đây là lý do chính đáng để chọn Quartz.

14.8.5 — DisallowConcurrentExecution​

[DisallowConcurrentExecution]
public sealed class DataSyncJob : IJob { ... }

Không có nó, job chạy 10 phút với lịch mỗi 5 phút sẽ chồng lên chính nó: hai instance cùng xử lý cùng dữ liệu.

Attribute này áp cho cùng một JobKey, và trong cluster nó áp trên toàn cluster — chỉ một node chạy tại một thời điểm.

Đây là điểm mạnh so với BackgroundService, nơi bạn phải tự viết khoá phân tán (bài 9.10).

Kết hợp với [PersistJobDataAfterExecution] khi job cần nhớ trạng thái giữa các lần chạy:

[DisallowConcurrentExecution]
[PersistJobDataAfterExecution]
public sealed class IncrementalSyncJob : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var map = context.JobDetail.JobDataMap;
var lastSync = map.GetDateTimeValue("lastSync");

await SyncSinceAsync(lastSync, context.CancellationToken);

map.Put("lastSync", DateTime.UtcNow); // lưu lại cho lần sau
}
}

Hai attribute này phải đi cùng nhau — PersistJobDataAfterExecution một mình với job chạy song song sẽ ghi đè dữ liệu lẫn nhau.

14.8.6 — Clustering​

q.UsePersistentStore(s =>
{
s.UseSqlServer(connectionString);
s.UseClustering(c =>
{
c.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
c.CheckinInterval = TimeSpan.FromSeconds(10);
});
s.UseSystemTextJsonSerializer();
});

Trong cluster, mọi node dùng chung database và tự điều phối: mỗi trigger chỉ chạy trên một node, và node chết thì node khác nhận lại job đang chạy dở của nó.

Cơ chế nhận lại đó là khác biệt lớn nhất so với Hangfire. Nhưng nó cũng nghĩa là job có thể chạy hai lần — node A chưa chết hẳn nhưng ngừng báo danh, node B nhận job. Nên job vẫn phải idempotent (bài 14.7).

Ba yêu cầu: đồng hồ các node phải đồng bộ (NTP), SchedulerInstanceId phải là AUTO, và không dùng RAMJobStore.

14.8.7 — Rà lại code của bạn​

Danh sách rà soát Quartz

  • •Đã chọn giữa Quartz và Hangfire dựa trên lịch có phải trung tâm bài toán không.
  • •Mọi trigger khai rõ múi giờ.
  • •Chính sách misfire được chọn có chủ đích cho từng trigger.
  • •Không dùng IgnoreMisfires cho job chạy dày.
  • •Job chạy lâu có [DisallowConcurrentExecution].
  • •PersistJobDataAfterExecution luôn đi cùng DisallowConcurrentExecution.
  • •Job dùng IServiceScopeFactory để lấy service scoped.
  • •context.CancellationToken được truyền xuống mọi lời gọi async.
  • •Lỗi được bọc trong JobExecutionException với refireImmediately false.
  • •WaitForJobsToComplete được bật để shutdown gọn gàng.
  • •Cluster có đồng hồ đồng bộ và job vẫn idempotent.

Bài tập áp dụng​

Bài 1 — So sánh ba chính sách misfire​

Đăng ký job chạy mỗi 5 phút với ba chính sách misfire khác nhau, tắt scheduler 30 phút, bật lại và đếm số lần chạy ở mỗi trường hợp.

Tiêu chí hoàn thành: bạn chọn được chính sách đúng cho từng loại job, và giải thích được vì sao Hangfire không có khái niệm này.

Gợi ý và lời giải — Bài 1

Gợi ý. Scheduler tắt 30 phút, job chạy mỗi 5 phút. Có 6 lần chạy đã bị lỡ. Nên làm gì với chúng?

Lời giải — ba chính sách:

// A — chạy bù MỌI lần đã lỡ, càng nhanh càng tốt
.WithSimpleSchedule(s => s
.WithIntervalInMinutes(5).RepeatForever()
.WithMisfireHandlingInstructionIgnoreMisfires())

// B — chạy bù ĐÚNG MỘT lần, rồi về lịch bình thường
.WithSimpleSchedule(s => s
.WithIntervalInMinutes(5).RepeatForever()
.WithMisfireHandlingInstructionFireNow())

// C — BỎ QUA mọi lần đã lỡ, chờ lần kế tiếp theo lịch
.WithSimpleSchedule(s => s
.WithIntervalInMinutes(5).RepeatForever()
.WithMisfireHandlingInstructionNextWithRemainingCount())
Scheduler tắt từ 10:00 tới 10:30. Bật lại lúc 10:30.
Sáu lần chạy đã lỡ: 10:05, 10:10, 10:15, 10:20, 10:25, 10:30

A (IgnoreMisfires) 6 lần chạy dồn trong vài giây, rồi 10:35, 10:40...
B (FireNow) 1 lần ngay lúc 10:30, rồi 10:35, 10:40...
C (NextWithRemaining) 0 lần bù, lần đầu là 10:35

Chọn chính sách theo loại job — câu hỏi quyết định là: "mỗi lần chạy có giá trị riêng, hay chỉ lần cuối mới quan trọng?"

Loại jobChính sáchVì sao
Đồng bộ dữ liệu tăng dầnA — chạy bù hếtMỗi lần xử lý một lô riêng; bỏ lô nào là mất dữ liệu lô đó
Làm mới cache, kiểm tra sức khoẻC — bỏ qua hếtChỉ trạng thái hiện tại có ý nghĩa; chạy bù 6 lần là 6 lần làm cùng một việc
Gửi thông báo theo lịchC — bỏ quaGửi 6 thông báo cùng lúc lúc 10:30 tệ hơn không gửi
Tổng hợp số liệu, sinh báo cáoB — bù một lầnMột lần chạy đọc hết dữ liệu tích luỹ là đủ
Gọi API bên ngoài có rate limitB hoặc CA sẽ bắn 6 request cùng lúc và bị chặn

Chính sách A là chính sách nguy hiểm nhất, và nó nguy hiểm theo cách khó lường:

Scheduler tắt 8 tiếng (sự cố hạ tầng qua đêm)
Job chạy mỗi phút
-> 480 lần chạy dồn cùng lúc khi bật lại
-> mỗi lần mở kết nối database, gọi API, ghi log
-> nghẽn ngay lúc hệ thống vừa hồi phục và còn mong manh nhất

Sự cố thứ hai — do chính cơ chế phục hồi gây ra — thường nghiêm trọng hơn sự cố gốc. Nếu phải dùng A, hãy kèm giới hạn đồng thời:

[DisallowConcurrentExecution]
public class DongBoJob : IJob { }

Với attribute này, 480 lần chạy sẽ xếp hàng tuần tự thay vì dội cùng lúc — chậm nhưng không làm sập gì.

Vì sao Hangfire không có khái niệm misfire — đây là phần chính của bài, và nó nói về một khác biệt kiến trúc sâu hơn là một tính năng thiếu.

Hangfire không lưu lịch dưới dạng các lần chạy sẽ xảy ra. Nó lưu đúng hai thứ cho mỗi recurring job: biểu thức cron và thời điểm chạy kế tiếp. Một luồng nền quét định kỳ:

Mỗi phút:
với mỗi recurring job:
nếu NextExecution <= bây giờ:
xếp một job vào hàng đợi
tính NextExecution mới từ cron

Khi scheduler tắt 30 phút rồi bật lại, nó thấy NextExecution = 10:05 đã qua, xếp một job, rồi tính NextExecution kế tiếp từ thời điểm hiện tại. Năm lần lỡ kia không tồn tại ở bất cứ đâu để mà xử lý — chúng chưa bao giờ được vật chất hoá thành bản ghi.

Hangfire:  lịch là một HÀM  -> "lần kế tiếp là khi nào?" -> tính lại từ hiện tại
Quartz: lịch là các LẦN CHẠY được lên kế hoạch -> lần lỡ là thực thể có thật

Vì Quartz theo dõi từng lần kích hoạt đã lên kế hoạch, nó buộc phải có chính sách cho những lần không kích hoạt được. Hangfire không có gì để quyết định, nên không có khái niệm đó.

Về hiệu quả, Hangfire hành xử gần giống chính sách B: một lần bù, rồi về lịch.

Nếu cần hành vi kiểu A với Hangfire, phải tự cài — và cách đúng là để job tự biết nó cần xử lý tới đâu:

public class DongBoJob
{
public async Task ChayAsync(CancellationToken ct)
{
var moc = await _db.DiemDongBo.FirstAsync(x => x.Ten == "lead", ct);

// Xử lý MỌI thứ từ lần chạy thành công cuối, bất kể đã lỡ bao nhiêu lần
var banGhi = await _db.Leads
.Where(l => l.UpdatedUtc > moc.XuLyToi)
.OrderBy(l => l.UpdatedUtc)
.Take(1000)
.ToListAsync(ct);

foreach (var lo in banGhi.Chunk(100)) await XuLyLoAsync(lo, ct);

moc.XuLyToi = banGhi.Count > 0 ? banGhi[^1].UpdatedUtc : moc.XuLyToi;
await _db.SaveChangesAsync(ct);
}
}

Cách này tốt hơn misfire policy, kể cả khi bạn đang dùng Quartz. Lý do:

  • Không phụ thuộc vào scheduler còn nhớ gì. Scheduler mất dữ liệu, đổi máy, hay bị dựng lại từ đầu — job vẫn biết phải làm từ đâu.
  • Không dội. Một lần chạy xử lý hết phần tồn, theo lô có kiểm soát.
  • Tự nhiên idempotent. Chạy hai lần thì lần thứ hai không thấy gì mới (bài 14.6).
  • Quan sát được. moc.XuLyToi cho bạn biết job đang trễ bao nhiêu, và đó là chỉ số đáng đặt cảnh báo:
Cảnh báo khi (bây giờ - XuLyToi) > 3 × chu kỳ chạy

Nói cách khác: misfire policy là cách scheduler xử lý việc nó bị lỡ. Con trỏ tiến độ là cách job tự bảo đảm không bỏ sót việc — và đó là mức bảo đảm cao hơn.


Bài 2 — Job chồng lấn lên chính nó​

Viết job mất 10 phút với lịch mỗi 5 phút, không có DisallowConcurrentExecution, và log thời điểm bắt đầu và kết thúc. Xác nhận chồng lấn, rồi thêm attribute và kiểm chứng.

Tiêu chí hoàn thành: bạn nêu được phạm vi mà DisallowConcurrentExecution có hiệu lực, và cách bảo đảm chỉ một lần chạy trên toàn cụm.

Gợi ý và lời giải — Bài 2

Gợi ý. Attribute là siêu dữ liệu trong một assembly. Instance khác có đọc được nó không?

Lời giải:

public class JobCham : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var id = Guid.NewGuid().ToString("N")[..6];
_logger.LogInformation("[{Id}] Bắt đầu lúc {Luc:HH:mm:ss}", id, DateTime.Now);
await Task.Delay(TimeSpan.FromMinutes(10), context.CancellationToken);
_logger.LogInformation("[{Id}] Kết thúc lúc {Luc:HH:mm:ss}", id, DateTime.Now);
}
}
[a1b2c3] Bắt đầu lúc 10:00:00
[d4e5f6] Bắt đầu lúc 10:05:00 <- lần 1 vẫn đang chạy
[a1b2c3] Kết thúc lúc 10:10:00
[g7h8i9] Bắt đầu lúc 10:10:00
[d4e5f6] Kết thúc lúc 10:15:00
[j0k1l2] Bắt đầu lúc 10:15:00

Luôn có hai lần chạy song song. Và nếu job chậm hơn nữa — chẳng hạn 30 phút — sẽ có sáu lần chạy chồng nhau, mỗi lần giữ một DbContext, một kết nối, một tập khoá.

[DisallowConcurrentExecution]
public class JobCham : IJob { }
[a1b2c3] Bắt đầu lúc 10:00:00
[a1b2c3] Kết thúc lúc 10:10:00
[d4e5f6] Bắt đầu lúc 10:10:00 <- lần lúc 10:05 bị bỏ qua
[d4e5f6] Kết thúc lúc 10:20:00

Chú ý: lần kích hoạt lúc 10:05 bị bỏ qua hẳn, không xếp hàng chờ. Quartz áp chính sách misfire cho nó — nên hành vi chính xác phụ thuộc vào chính sách bạn đặt ở bài 1.

Phạm vi có hiệu lực của DisallowConcurrentExecution:

Một JobDetail, trong một scheduler.

Cụ thể hơn:

Tình huốngCó chặn được không
Hai lần kích hoạt cùng job, cùng tiến trìnhCó
Hai trigger khác nhau trỏ tới cùng JobDetailCó
Hai JobDetail khác nhau cùng class IJobKhông
Hai instance ứng dụng, mỗi cái một scheduler độc lậpKhông
Cụm Quartz có IsClustered = trueCó

Dòng áp chót là chỗ gây sự cố trên production:

Pod 1: scheduler riêng, JobDetail "bao-cao" -> DisallowConcurrentExecution có hiệu lực TRONG pod 1
Pod 2: scheduler riêng, JobDetail "bao-cao" -> có hiệu lực TRONG pod 2
Pod 3: ...

Lúc 10:00, cả ba pod cùng chạy "bao-cao". Attribute không chặn được gì.

Attribute là siêu dữ liệu đọc bởi scheduler cục bộ; ba scheduler độc lập không biết gì về nhau. Đây chính là sự cố ở bài 14.11.

Bảo đảm chỉ một lần chạy trên toàn cụm — ba cách:

1. Quartz clustering — cách tự nhiên nhất nếu bạn đã dùng Quartz:

services.AddQuartz(q =>
{
q.UsePersistentStore(s =>
{
s.UseProperties = true;
s.UseSqlServer(connectionString);
s.UseClustering(c =>
{
c.CheckinInterval = TimeSpan.FromSeconds(10);
c.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
});
s.UseNewtonsoftJsonSerializer();
});
});

Các scheduler dùng chung một database và khoá bi quan trên bảng trigger: instance nào lấy được khoá thì chạy, các instance khác thấy trigger đã bị chiếm và bỏ qua.

Ba điều kiện bắt buộc, thiếu một là cụm không hoạt động:

  • Mọi instance phải trỏ vào cùng một database.
  • Mọi instance phải có cùng SchedulerName.
  • Mỗi instance phải có InstanceId khác nhau — dùng AUTO để sinh tự động.
q.SchedulerName = "CrmScheduler";
q.SchedulerId = "AUTO";

Và đồng hồ giữa các máy phải đồng bộ trong vài giây — clustering dựa trên dấu thời gian, nên lệch đồng hồ gây hành vi khó hiểu.

2. Khoá phân tán — dùng được với bất kỳ scheduler nào, kể cả BackgroundService tự viết:

public async Task Execute(IJobExecutionContext context)
{
await using var khoa = await _redLock.CreateLockAsync(
resource: "job:bao-cao-hang-ngay",
expiryTime: TimeSpan.FromMinutes(15), // dài hơn thời gian chạy dự kiến
waitTime: TimeSpan.Zero, // không chờ — instance khác thì bỏ qua
retryTime: TimeSpan.Zero);

if (!khoa.IsAcquired)
{
_logger.LogInformation("Instance khác đang chạy job này, bỏ qua");
return;
}

await LamViecAsync(context.CancellationToken);
}

Hai tham số quan trọng:

  • expiryTime phải dài hơn thời gian chạy thật. Ngắn hơn thì khoá hết hạn giữa chừng và instance khác bắt đầu chạy song song — đúng vấn đề bạn đang cố tránh.
  • waitTime: Zero là đúng cho job theo lịch: instance khác nên bỏ qua, không nên xếp hàng chờ rồi chạy lại cùng công việc.

Với job có thời gian chạy khó đoán, dùng khoá tự gia hạn (auto-extend) thay vì đoán expiryTime.

3. Job tự idempotent — cách bền vững nhất, và nên có bất kể bạn chọn cách nào ở trên:

public async Task Execute(IJobExecutionContext context)
{
var khoaChay = $"bao-cao:{DateTime.UtcNow:yyyy-MM-dd}";

_db.LanChayJob.Add(new LanChayJob { Khoa = khoaChay, BatDau = DateTime.UtcNow });
try
{
await _db.SaveChangesAsync(); // unique constraint trên Khoa
}
catch (DbUpdateException ex) when (LaViPhamUnique(ex))
{
_logger.LogInformation("Job {Khoa} đã chạy hôm nay, bỏ qua", khoaChay);
return;
}

await LamViecAsync(context.CancellationToken);
}

Vì sao nên có cách 3 dù đã có cách 1 hoặc 2: khoá phân tán và clustering đều có thể thất bại theo những cách bạn không lường trước — Redis failover làm mất khoá, lệch đồng hồ làm hai instance cùng thấy trigger tự do, network partition chia cụm làm đôi. Cách 3 là lớp cuối, và nó dựa trên một bảo đảm mạnh hơn hẳn: unique constraint của database.

Ba lớp này không thừa nhau — chúng bảo vệ ở ba tầng khác nhau, và chỉ lớp cuối là không thể bị bỏ qua.


Bài 3 — Lịch ngày lễ​

Cấu hình HolidayCalendar với các ngày lễ Việt Nam và xác nhận job không chạy vào những ngày đó.

Tiêu chí hoàn thành: bạn nêu được vì sao ngày lễ Việt Nam không cấu hình cứng một lần được, và thiết kế được cách nạp động.

Gợi ý và lời giải — Bài 3

Gợi ý. Tết Nguyên đán năm 2027 rơi vào ngày nào theo dương lịch?

Lời giải — cấu hình cơ bản:

var lichLe = new HolidayCalendar();
lichLe.AddExcludedDate(new DateTime(2026, 1, 1)); // Tết Dương lịch
lichLe.AddExcludedDate(new DateTime(2026, 4, 26)); // Giỗ Tổ Hùng Vương
lichLe.AddExcludedDate(new DateTime(2026, 4, 30)); // Giải phóng miền Nam
lichLe.AddExcludedDate(new DateTime(2026, 5, 1)); // Quốc tế Lao động
lichLe.AddExcludedDate(new DateTime(2026, 9, 2)); // Quốc khánh

await scheduler.AddCalendar("le-viet-nam", lichLe, replace: true, updateTriggers: true);
var trigger = TriggerBuilder.Create()
.WithIdentity("bao-cao-hang-ngay")
.WithCronSchedule("0 8 * * ?", x => x.InTimeZone(mgVietNam))
.ModifiedByCalendar("le-viet-nam")
.Build();

Vì sao không cấu hình cứng một lần được — bốn lý do, và ba lý do đầu là đặc thù Việt Nam:

1. Tết Nguyên đán theo âm lịch. Ngày dương lịch đổi mỗi năm, trong khoảng cuối tháng 1 tới giữa tháng 2:

2026: 17 tháng 2
2027: 6 tháng 2
2028: 26 tháng 1
2029: 13 tháng 2

Không có công thức đơn giản — lịch âm dựa trên chu kỳ mặt trăng và tính theo múi giờ UTC+7 cho Việt Nam, khác với lịch âm Trung Quốc ở một số năm.

2. Giỗ Tổ Hùng Vương cũng theo âm lịch — mùng 10 tháng 3 âm lịch, tức khoảng cuối tháng 4 dương lịch, đổi mỗi năm.

3. Chính phủ công bố lịch nghỉ hằng năm, thường vào cuối năm trước. Số ngày nghỉ Tết và ngày nghỉ bù không cố định:

Ngày lễ rơi vào cuối tuần -> nghỉ bù vào ngày làm việc kế tiếp
Nghỉ Tết: thường 5–9 ngày, do Thủ tướng quyết định từng năm
Nghỉ ghép: đôi khi hoán đổi ngày làm việc để có kỳ nghỉ dài liền mạch

Hoán đổi ngày làm việc là điểm đáng chú ý: có năm một ngày Thứ Bảy trở thành ngày làm việc bình thường để bù cho kỳ nghỉ dài. Một lịch chỉ biết "loại trừ ngày lễ" sẽ bỏ sót trường hợp này.

4. Ngày lễ khác nhau theo nơi. Hệ thống phục vụ khách hàng ở nhiều nước cần nhiều lịch khác nhau.

Thiết kế nạp động:

public class Ngayle
{
public int Id { get; set; }
public DateOnly Ngay { get; set; }
public string Ten { get; set; } = null!;
public string MaQuocGia { get; set; } = "VN";
public bool LaNgayNghi { get; set; } = true; // false = ngày làm việc hoán đổi
}
public class NapLichLeJob : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var namNay = DateTime.UtcNow.Year;

var ngayLe = await _db.NgayLe
.Where(n => n.MaQuocGia == "VN"
&& n.LaNgayNghi
&& n.Ngay.Year >= namNay && n.Ngay.Year <= namNay + 1)
.ToListAsync(context.CancellationToken);

var lich = new HolidayCalendar();
foreach (var n in ngayLe)
lich.AddExcludedDate(n.Ngay.ToDateTime(TimeOnly.MinValue));

await context.Scheduler.AddCalendar(
"le-viet-nam", lich, replace: true, updateTriggers: true);

_logger.LogInformation("Đã nạp {SoNgay} ngày lễ cho {Nam}–{NamSau}",
ngayLe.Count, namNay, namNay + 1);
}
}
// Nạp lúc khởi động, rồi mỗi ngày
q.AddJob<NapLichLeJob>(j => j.WithIdentity("nap-lich-le"));
q.AddTrigger(t => t
.ForJob("nap-lich-le")
.StartNow()
.WithCronSchedule("0 5 0 * * ?", x => x.InTimeZone(mgVietNam)));

Bốn chi tiết quan trọng:

  1. updateTriggers: true — không có nó, các trigger đã đăng ký vẫn dùng lịch cũ. Đây là lỗi hay gặp nhất khi cập nhật lịch lúc chạy.
  2. Nạp cả năm sau — trigger tính lần chạy kế tiếp trước nhiều tháng, nên lịch phải phủ tới đó.
  3. Nạp lại định kỳ — để ngày lễ mới được thêm vào database có hiệu lực mà không cần restart.
  4. Nạp lúc khởi động (StartNow) — nếu không, instance vừa khởi động chạy với lịch rỗng cho tới nửa đêm.

Nguồn dữ liệu ngày lễ:

NguồnƯuNhược
Nhập tay vào databaseKiểm soát hoàn toàn, xử lý được ngày hoán đổiPhải nhớ cập nhật mỗi năm
Thư viện lịch âm (Lunar.NET, tự cài thuật toán)Tính được Tết và Giỗ Tổ tự độngKhông biết ngày nghỉ bù do chính phủ quyết
API ngày lễ (Nager.Date, Calendarific)Tự động, nhiều quốc giaPhụ thuộc bên ngoài, dữ liệu Việt Nam có thể chậm cập nhật

Cách phối hợp nên dùng: tính Tết và Giỗ Tổ bằng thư viện lịch âm để có khung sẵn, rồi cho admin xác nhận và điều chỉnh qua giao diện sau khi chính phủ công bố. Cách này tự động hoá phần tính toán và giữ con người ở chỗ cần phán đoán.

Và một cảnh báo về kỳ nghỉ Tết — nó dài nhiều ngày liên tiếp:

Job "chốt sổ hằng ngày" bị bỏ qua 7 ngày liền
-> ngày làm việc đầu tiên sau Tết, dữ liệu 7 ngày chưa được xử lý

Loại trừ ngày lễ là hoãn công việc, không phải huỷ công việc. Với job xử lý dữ liệu tích luỹ, hãy dùng con trỏ tiến độ như ở bài 1 — job chạy lại sẽ xử lý hết phần tồn, và kỳ nghỉ dài không còn là vấn đề.

Với job thật sự không nên chạy vào ngày lễ — gửi email marketing, gọi điện chăm sóc khách hàng — thì loại trừ là đúng, vì công việc đó mất ý nghĩa nếu làm muộn chứ không chỉ bị chậm.

Cuối cùng, một cảnh báo về HolidayCalendar: nó loại trừ cả ngày. Muốn loại trừ theo giờ — chẳng hạn không chạy ngoài giờ hành chính — hãy dùng DailyCalendar hoặc CronCalendar, và có thể kết hợp nhiều lịch bằng thuộc tính CalendarBase:

var lichGioLamViec = new DailyCalendar("17:30:00", "08:00:00")
{
CalendarBase = lichLe, // loại trừ CẢ ngày lễ LẪN ngoài giờ
};

Tự kiểm tra​

Câu hỏi thường gặp

Quartz và Hangfire giải bài toán gì khác nhau?

Hangfire là hàng đợi job: đưa việc vào rồi nó chạy, có retry và dashboard sẵn. Quartz là bộ lập lịch: trigger phức tạp, lịch loại trừ ngày lễ, và clustering có cơ chế nhận lại job khi node chết. Với CRM điển hình thì Hangfire thường đúng hơn; Quartz đáng khi lập lịch chính là nghiệp vụ.

Misfire là gì?

Là khi một trigger đáng lẽ đã chạy nhưng không chạy được, vì server đang tắt, không còn thread rảnh, hoặc job trước còn chạy. Quartz có bốn chính sách xử lý; Hangfire không có khái niệm này mà luôn chạy ngay khi server lên.

Chính sách misfire nào nguy hiểm?

IgnoreMisfires với job chạy dày. Job mỗi 5 phút mà server tắt 3 tiếng sẽ chạy bù 36 lần liên tiếp ngay khi lên, thường làm quá tải đúng lúc hệ thống vừa khởi động. Với job dày thì DoNothing hợp lý hơn.

DisallowConcurrentExecution làm gì?

Ngăn job chồng lên chính nó khi lần chạy trước chưa xong. Trong cluster nó áp trên toàn cluster, chỉ một node chạy tại một thời điểm, nên bạn không phải tự viết khoá phân tán như với BackgroundService.

Vì sao PersistJobDataAfterExecution phải đi cùng DisallowConcurrentExecution?

Vì nếu job chạy song song mà cùng lưu dữ liệu sau khi chạy, chúng sẽ ghi đè lẫn nhau và trạng thái lưu lại là không xác định.

Clustering của Quartz có đảm bảo job chạy đúng một lần không?

Không. Node chưa chết hẳn nhưng ngừng báo danh sẽ khiến node khác nhận lại job đang chạy dở, nên job có thể chạy hai lần. Vì vậy job vẫn phải idempotent, và các node phải có đồng hồ đồng bộ qua NTP.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Hangfire là hàng đợi, Quartz là lịch. Chọn theo bài toán, không theo thói quen.
  2. Misfire là khái niệm Hangfire không có — và IgnoreMisfires với job dày là bẫy.
  3. Clustering không đảm bảo chạy đúng một lần. Job vẫn phải idempotent.

Tham khảo​

Điều hướng​