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

14.7 — 6. Background Jobs với Hangfire

Tóm tắt

Hangfire khác BackgroundService ở một điểm quyết định: job được lưu vào database, nên nó sống sót qua restart, có retry tự động và có giao diện xem lịch sử. Nhưng việc lưu vào database kéo theo hai hệ quả phải thiết kế quanh. Tham số job được serialize — truyền một entity là serialize toàn bộ đối tượng vào bảng, và nếu kiểu đó đổi hình dạng thì job cũ không deserialize được nữa; luôn truyền id, không truyền đối tượng. Hangfire đảm bảo at-least-once, không phải exactly-once — job sẽ chạy hai lần trong một số tình huống, nên mọi job phải idempotent. Và mặc định /hangfire mở cho mọi người ở môi trường local — bảo vệ nó là việc đầu tiên.

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

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

  • Chọn giữa Hangfire và BackgroundService.
  • Thiết kế tham số job đúng cách.
  • Viết job idempotent với retry.
  • Dùng hàng đợi ưu tiên.
  • Bảo vệ dashboard và giám sát job.

Nội dung bài học​

14.7.1 — Hangfire hay BackgroundService​

BackgroundServiceHangfire
Job sống sót qua restartKhôngCó
Retry tự độngTự viếtCó sẵn
Lịch cronTự tínhCó sẵn
Xem lịch sử chạyKhôngDashboard
Chạy lại thủ côngKhôngCó
Chống chạy trùng nhiều instanceTự viếtCó sẵn
Phụ thuộcKhôngDatabase
Độ phức tạpThấpTrung bình

Câu hỏi quyết định đã nêu ở bài 9.10: "nếu job này không chạy một lần, có ai biết không?"

Không ai biết → cần Hangfire. Với BackgroundService, một job im lặng thất bại là một job không ai phát hiện.

14.7.2 — Cấu hình​

builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(connectionString, new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.Zero, // dùng thông báo, không poll
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true, // giam tranh chap
}));

builder.Services.AddHangfireServer(options =>
{
options.WorkerCount = Environment.ProcessorCount * 2;
options.Queues = ["critical", "default", "low"]; // THU TU = uu tien
options.ServerName = $"{Environment.MachineName}:{Environment.ProcessId}";
});

options.Queues theo thứ tự là thứ tự ưu tiên — worker lấy hết critical rồi mới tới default. Một hàng đợi critical luôn đầy sẽ khiến default không bao giờ được xử lý.

QueuePollInterval = TimeSpan.Zero bật cơ chế thông báo thay vì hỏi định kỳ — giảm đáng kể tải database.

Tách server khỏi API khi job nặng:

if (builder.Configuration.GetValue<bool>("Hangfire:RunServer"))
builder.Services.AddHangfireServer(...);

API chỉ đưa job vào hàng đợi; một tiến trình worker riêng xử lý. Nhờ đó job nặng không làm chậm request, và bạn scale hai thứ độc lập — cùng lý do ở bài 9.10.

14.7.3 — Tham số phải nhỏ​

// SAI — serialize CẢ đối tượng vào database
jobClient.Enqueue<IEmailService>(s => s.SendWelcomeAsync(customer));

Ba vấn đề:

  1. Dữ liệu cũ. Job chạy 5 phút sau với bản chụp lúc tạo — khách hàng có thể đã đổi email.
  2. Bảng job phình. Một đối tượng 50KB nhân với hàng nghìn job.
  3. Đổi kiểu phá job cũ. Thêm hay xoá thuộc tính khiến job đã xếp hàng không deserialize được và thất bại vĩnh viễn.
// DUNG — chi truyen id
jobClient.Enqueue<IEmailService>(s => s.SendWelcomeAsync(customerId));

Job tự nạp dữ liệu mới nhất khi chạy. Đơn giản hơn, an toàn hơn, và bảng job nhỏ.

Quy tắc: chỉ truyền kiểu nguyên thuỷ và id. Không entity, không DTO lớn, không CancellationToken (Hangfire cung cấp IJobCancellationToken riêng).

14.7.4 — Job phải idempotent​

Hangfire đảm bảo at-least-once. Job chạy hai lần khi: worker chết giữa chừng và job được giao lại, retry sau lỗi tạm thời, hoặc ai đó bấm "Requeue" trên dashboard.

// SAI — chạy hai lần là gửi hai email
public async Task SendWelcomeAsync(int customerId)
{
var customer = await _db.Customers.FindAsync(customerId);
await _email.SendAsync(customer.Email, "Chào mừng", ...);
}
// ĐÚNG — kiểm tra trước, ghi nhận sau
public async Task SendWelcomeAsync(int customerId)
{
var customer = await _db.Customers.FindAsync([customerId]);
if (customer is null) return; // đã bị xoá

if (customer.WelcomeEmailSentAt is not null)
{
_logger.LogInformation("Email chào mừng đã gửi cho {Id}, bỏ qua", customerId);
return; // ĐÃ GỬI
}

await _email.SendAsync(customer.Email, "Chào mừng", ...);

customer.WelcomeEmailSentAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}

Vẫn còn một khoảng trống: tiến trình chết giữa gửi email và lưu cờ. Đó là bản chất của at-least-once — bạn giảm xác suất chứ không loại bỏ được. Với thao tác thật sự không được lặp, cần idempotency key ở phía dịch vụ nhận (bài 14.10).

Kiểm tra null ở đầu cũng quan trọng: job xếp hàng cho một bản ghi đã bị xoá phải kết thúc êm, không ném exception rồi retry ba lần.

14.7.5 — Retry​

[AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 300, 900])]
[Queue("default")]
public async Task SyncToExternalCrmAsync(int customerId) { ... }

Mặc định Hangfire retry 10 lần với khoảng cách tăng dần — thường quá nhiều. Ba lần với backoff là hợp lý hơn cho phần lớn job.

Phân biệt hai loại lỗi:

public async Task SyncAsync(int customerId)
{
var customer = await _db.Customers.FindAsync([customerId]);
if (customer is null) return;

try
{
await _externalApi.SyncAsync(customer.ToDto());
}
catch (HttpRequestException ex) when (ex.StatusCode >= HttpStatusCode.InternalServerError)
{
throw; // loi tam thoi -> CHO retry
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.BadRequest)
{
// Dữ liệu sai -> retry KHÔNG giúp gì
_logger.LogError(ex, "Dữ liệu khách hàng {Id} không hợp lệ", customerId);
await _alerts.NotifyAsync($"Sync thất bại cho khách hàng {customerId}");
return; // kết thúc, không retry
}
}

Retry một lỗi vĩnh viễn chỉ tốn tài nguyên và làm nhiễu dashboard. Lỗi dữ liệu cần người xử lý, không cần thử lại.

Job hết lượt retry chuyển sang trạng thái Failed và ở đó mãi — Hangfire không tự xoá. Cần giám sát:

var failedCount = JobStorage.Current.GetMonitoringApi().FailedCount();
if (failedCount > 10) await _alerts.NotifyAsync($"{failedCount} job thất bại");

Đây là chỉ số quan trọng nhất cần cảnh báo — job Failed chất đống mà không ai biết là tình huống phổ biến.

14.7.6 — Bốn loại job​

// 1. Fire-and-forget — chay ngay
jobClient.Enqueue<IEmailService>(s => s.SendWelcomeAsync(customerId));

// 2. Delayed — chay sau
jobClient.Schedule<ILeadService>(s => s.SendFollowUpAsync(leadId), TimeSpan.FromHours(24));

// 3. Recurring — theo lich cron
recurringJobs.AddOrUpdate<IReportService>(
"daily-digest",
s => s.SendDailyDigestAsync(),
"0 8 * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("SE Asia Standard Time") });

// 4. Continuation — chay sau khi job khac xong
var parentId = jobClient.Enqueue<ILeadService>(s => s.ValidateAsync(leadId));
jobClient.ContinueJobWith<ILeadService>(parentId, s => s.AssignAsync(leadId));

Múi giờ là bắt buộc cho recurring job. Mặc định Hangfire dùng UTC, nên "0 8 * * *" là 8 giờ UTC — tức 15 giờ ở Việt Nam. Đây là lỗi rất hay gặp.

Recurring job đăng ký bằng id chuỗi; gọi lại AddOrUpdate với cùng id sẽ cập nhật. Đổi id nghĩa là tạo job mới, và job cũ vẫn chạy — nhớ RemoveIfExists.

ContinueJobWith chạy job tiếp theo kể cả khi job cha thất bại, trừ khi khai rõ:

jobClient.ContinueJobWith<ILeadService>(
parentId, s => s.AssignAsync(leadId),
JobContinuationOptions.OnlyOnSucceededState);

14.7.7 — Dashboard và giới hạn​

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = [new HangfireAuthorizationFilter()],
IsReadOnlyFunc = context => !context.GetHttpContext().User.IsInRole("Admin"),
});

public sealed class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var http = context.GetHttpContext();
return http.User.Identity?.IsAuthenticated == true && http.User.IsInRole("Admin");
}
}

Mặc định của Hangfire chỉ cho phép truy cập local — nhưng sau reverse proxy, "local" có thể là IP của proxy, nghĩa là dashboard mở cho tất cả. Luôn khai authorization filter tường minh.

Dashboard cho phép xem tham số job — nếu bạn truyền dữ liệu nhạy cảm, nó hiển thị công khai với mọi người vào được. Thêm một lý do chỉ truyền id.

Bốn giới hạn của Hangfire:

  1. Phụ thuộc database. Mọi thao tác job đều chạm database; hàng nghìn job mỗi giây tạo tải đáng kể.
  2. Không dành cho thông lượng rất cao. Trên vài trăm job mỗi giây, dùng message broker.
  3. Bảng job lớn dần. Job thành công được dọn sau ExpirationCheckInterval, nhưng Failed thì không.
  4. Không phải message bus. Không có publish/subscribe, không có định tuyến — đó là việc của bài 14.9.

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

Danh sách rà soát Hangfire

  • •Dashboard có authorization filter tường minh, không dựa vào mặc định local.
  • •Tham số job chỉ là kiểu nguyên thuỷ và id, không phải entity hay DTO lớn.
  • •Mọi job đều idempotent và kiểm tra null cho bản ghi đã bị xoá.
  • •AutomaticRetry được khai rõ, không dùng mặc định 10 lần.
  • •Lỗi vĩnh viễn kết thúc job thay vì ném để retry.
  • •Có cảnh báo khi số job Failed vượt ngưỡng.
  • •Recurring job khai rõ múi giờ, không dùng UTC mặc định.
  • •Đổi id recurring job có kèm RemoveIfExists cho id cũ.
  • •ContinueJobWith khai rõ OnlyOnSucceededState nếu cần.
  • •Thứ tự hàng đợi phản ánh đúng ưu tiên, và hàng đợi thấp không bị bỏ đói.
  • •Server Hangfire tách khỏi API nếu job nặng.

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

Bài 1 — Job chạy hai lần​

Viết job gửi email không idempotent, kill tiến trình worker giữa chừng, và xác nhận email được gửi hai lần sau khi khởi động lại.

Tiêu chí hoàn thành: bạn giải thích được vì sao at-least-once là lựa chọn thiết kế bắt buộc, chứ không phải khiếm khuyết của Hangfire.

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

Gợi ý. Worker chết sau khi gửi email nhưng trước khi báo "xong". Hangfire biết được điều gì?

Lời giải — job có lỗi:

public class GuiEmailJob
{
public async Task ChayAsync(int leadId)
{
var lead = await _db.Leads.FirstAsync(l => l.Id == leadId);

await _emailSender.GuiAsync(lead.Email, "Chào mừng", NoiDung(lead));

// Worker bị kill ở ĐÂY -> email đã gửi nhưng Hangfire không biết
lead.DaGuiEmailChaoMung = true;
await _db.SaveChangesAsync();
}
}
# Xếp hàng job
curl -X POST http://localhost:5000/leads/123/gui-email

# Ngay sau khi email được gửi, kill worker
kill -9 $(pgrep -f "Crm.Worker")

# Khởi động lại
dotnet run --project src/Crm.Worker
[08:14:22] Đã gửi email tới an@company.com
[08:14:22] Tiến trình bị kill
[08:14:45] Worker khởi động lại
[08:14:45] Phát hiện job 4821 ở trạng thái Processing với server đã chết
[08:14:45] Chuyển job 4821 về Enqueued
[08:14:46] Đã gửi email tới an@company.com <- LẦN THỨ HAI

Khách hàng nhận hai email giống hệt nhau.

Vì sao at-least-once là lựa chọn thiết kế bắt buộc. Vì cái ngược lại — at-most-once — có nghĩa là mất job, và với phần lớn nghiệp vụ, mất còn tệ hơn trùng.

Vấn đề gốc: Hangfire không thể biết job đã chạy tới đâu khi worker chết. Nó chỉ thấy một bản ghi trong database ở trạng thái Processing, thuộc về một server không còn gửi heartbeat. Từ đó có đúng hai suy luận:

Suy luận A: "job có thể chưa xong"  -> chạy lại  -> AT-LEAST-ONCE (có thể trùng)
Suy luận B: "job có thể đã xong" -> bỏ qua -> AT-MOST-ONCE (có thể mất)

Không có suy luận thứ ba, vì thông tin để phân biệt không tồn tại. Và nó không tồn tại vì một lý do căn bản: việc gửi email và việc ghi "đã gửi" là hai thao tác trên hai hệ thống khác nhau, không thể làm nguyên tử cùng nhau.

Gửi email     -> ở máy chủ SMTP
Ghi "đã gửi" -> ở database

Giữa hai thao tác này LUÔN có một khoảnh khắc mà tiến trình có thể chết.

Đây là bài toán hai tướng quân (two generals problem) — một kết quả đã được chứng minh là không giải được. Không có giao thức nào, dù phức tạp đến đâu, loại bỏ được khoảnh khắc đó. Mọi hệ thống job và message trên thực tế đều chọn at-least-once, và chuyển bài toán sang cho ứng dụng: làm job idempotent.

Hangfire, Quartz, RabbitMQ, Kafka, SQS, Azure Service Bus:
mặc định at-least-once

"Exactly-once" mà một số hệ thống quảng cáo:
thực chất là at-least-once + khử trùng lặp, và chỉ trong phạm vi hẹp
(ví dụ Kafka: chỉ trong cùng một cụm, cùng một transaction producer)

Bản sửa — ba mức idempotent:

Mức 1 — kiểm tra trạng thái trước khi làm. Đơn giản nhất, và đủ cho nhiều trường hợp:

public async Task ChayAsync(int leadId)
{
var lead = await _db.Leads.FirstAsync(l => l.Id == leadId);
if (lead.DaGuiEmailChaoMung) return; // đã làm rồi, thoát

await _emailSender.GuiAsync(lead.Email, "Chào mừng", NoiDung(lead));
lead.DaGuiEmailChaoMung = true;
await _db.SaveChangesAsync();
}

Vẫn còn khe hở — nếu chết đúng giữa GuiAsync và SaveChangesAsync thì vẫn gửi hai lần. Nhưng khe hở đã hẹp lại từ "toàn bộ thời gian chạy job" xuống "vài mili giây". Với một email chào mừng, thường là đủ.

Mức 2 — bảng khử trùng lặp với unique constraint. Đóng khe hở bằng cách đổi thứ tự:

public class EmailDaGui
{
public string KhoaIdempotency { get; set; } = null!; // unique
public DateTime GuiLuc { get; set; }
}
builder.Entity<EmailDaGui>().HasIndex(e => e.KhoaIdempotency).IsUnique();
public async Task ChayAsync(int leadId)
{
var khoa = $"email-chao-mung:{leadId}";

// ĐÁNH DẤU TRƯỚC khi gửi
_db.EmailDaGui.Add(new EmailDaGui { KhoaIdempotency = khoa, GuiLuc = DateTime.UtcNow });
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (LaViPhamUnique(ex))
{
_logger.LogInformation("Email {Khoa} đã gửi rồi, bỏ qua", khoa);
return;
}

await _emailSender.GuiAsync(...);
}

Đổi hướng của khe hở: giờ nếu chết giữa chừng, rủi ro là không gửi thay vì gửi hai lần. Với email marketing, đó là đánh đổi đúng; với email đặt lại mật khẩu thì không, và bạn nên chọn mức 1.

Unique constraint là chi tiết then chốt — nó đẩy phép kiểm tra xuống database, nơi nó nguyên tử. Kiểm tra bằng if (await _db.EmailDaGui.AnyAsync(...)) rồi mới Add sẽ có race condition giữa hai worker.

Mức 3 — idempotency key gửi cho nhà cung cấp. Chắc chắn nhất, nếu dịch vụ bên ngoài hỗ trợ:

await _emailSender.GuiAsync(new EmailRequest
{
To = lead.Email,
IdempotencyKey = $"chao-mung:{leadId}", // SendGrid, Stripe, Twilio đều có
});

Nhà cung cấp tự khử trùng lặp. Đây là cách duy nhất loại bỏ hẳn khả năng gửi hai lần, vì phép kiểm tra và hành động xảy ra ở cùng một nơi.

Ba việc phải làm cho mọi job:

  1. Coi "job có thể chạy lại" là điều kiện đầu vào, không phải trường hợp ngoại lệ. Mọi job phải chạy đúng khi được gọi hai lần liên tiếp.
  2. Giới hạn số lần thử lại, nếu không một job luôn thất bại sẽ chạy mãi:
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public class GuiEmailJob { }
  1. Viết test gọi job hai lần:
[Fact]
public async Task Job_chay_hai_lan_chi_gui_mot_email()
{
await _job.ChayAsync(123);
await _job.ChayAsync(123);

_emailSender.Received(1).GuiAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}

Test này nên là bắt buộc cho mọi job. Nó rẻ, và nó biến một giả định ngầm thành một khẳng định được kiểm chứng.


Bài 2 — Đổi kiểu phá job đang xếp hàng​

Xếp hàng một job nhận DTO, thêm một thuộc tính bắt buộc vào DTO đó, deploy, và xác nhận job cũ thất bại khi deserialize.

Tiêu chí hoàn thành: bạn nêu được quy tắc thiết kế tham số job để tránh hẳn loại lỗi này.

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

Gợi ý. Job nằm trong database bao lâu trước khi chạy? Code nào sẽ đọc nó?

Lời giải — tái hiện:

public record GuiEmailRequest(string Email, string TieuDe);

BackgroundJob.Enqueue<GuiEmailJob>(j => j.ChayAsync(new GuiEmailRequest("an@company.com", "Chào")));

Job được lưu vào bảng HangFire.Job dưới dạng JSON:

{
"Type": "Crm.Jobs.GuiEmailJob, Crm.Worker",
"Method": "ChayAsync",
"Arguments": ["{\"Email\":\"an@company.com\",\"TieuDe\":\"Chào\"}"]
}

Deploy phiên bản mới với một thuộc tính bắt buộc:

public record GuiEmailRequest(string Email, string TieuDe, string TenantId);
System.Text.Json.JsonException: JSON deserialization for type 'GuiEmailRequest'
was missing required properties including: TenantId.

Job 4821 đã thất bại. Đang thử lại lần 1/3...
Job 4821 đã thất bại. Đang thử lại lần 2/3...
Job 4821 đã thất bại. Đang thử lại lần 3/3...
Job 4821 chuyển sang trạng thái Failed.

Job thử lại ba lần rồi chết hẳn — và ba lần thử đều thất bại vì cùng một lý do không bao giờ tự khỏi.

Vì sao điều này xảy ra. Job được serialize lúc xếp hàng và deserialize lúc chạy, và hai thời điểm đó có thể cách nhau rất xa:

t=0        Xếp hàng bằng code phiên bản 2.4 -> JSON lưu vào database
t=0..N Job nằm chờ: hàng đợi tắc, job có lịch, job đang retry với backoff
t=N Deploy phiên bản 2.5
t=N+1 Worker phiên bản 2.5 đọc JSON của phiên bản 2.4 -> thất bại

Khoảng cách N có thể là vài giây, nhưng cũng có thể là nhiều ngày:

Loại jobKhoảng cách điển hình
Enqueue bình thườngVài giây
Enqueue khi hàng đợi tắcVài phút tới vài giờ
Job đang retry với backoffTới vài giờ
Schedule có thời điểmTuỳ bạn đặt — có thể là tháng
Job trong dead-letter chờ xử lýKhông giới hạn

Nghĩa là: JSON của job là một API công khai giữa các phiên bản ứng dụng của bạn. Đổi nó là đổi một hợp đồng, và nó phải được xử lý như mọi thay đổi hợp đồng khác.

Quy tắc thiết kế tham số job — đây là câu trả lời chính:

Truyền id, đừng truyền object. Job tự nạp dữ liệu nó cần.

// Tránh — object serialize vào hàng đợi
BackgroundJob.Enqueue<GuiEmailJob>(j => j.ChayAsync(new GuiEmailRequest(...)));

// Nên — chỉ id nguyên thuỷ
BackgroundJob.Enqueue<GuiEmailJob>(j => j.ChayAsync(leadId, "chao-mung"));
public async Task ChayAsync(int leadId, string loaiEmail)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == leadId);
if (lead is null)
{
_logger.LogWarning("Lead {Id} không còn tồn tại, bỏ qua job", leadId);
return;
}
// ... nạp mọi thứ khác từ database
}

Năm lợi ích, và bốn cái đầu không liên quan gì tới serialize:

  1. Không có schema để phá. int và string serialize ổn định qua mọi phiên bản.
  2. Dữ liệu luôn mới nhất lúc chạy. Job xếp hàng lúc 8 giờ, chạy lúc 11 giờ, dùng dữ liệu của 11 giờ. Với object serialize, bạn gửi email chứa dữ liệu của 8 giờ — có thể đã sai.
  3. Payload nhỏ. Bảng HangFire.Job không phình theo kích thước object, và Hangfire dashboard không chậm dần.
  4. Xử lý được bản ghi đã bị xoá. Job kiểm tra được và thoát gọn gàng, thay vì gửi email cho một khách hàng vừa yêu cầu xoá tài khoản.
  5. Không rò rỉ dữ liệu nhạy cảm vào database job. Object serialize có thể chứa email, số điện thoại, hay nội dung riêng tư — và bảng job thường không được bảo vệ ở mức như bảng nghiệp vụ, còn Hangfire dashboard thì hiển thị tham số ra màn hình.

Điểm 2 và 5 đáng nhấn mạnh: chúng là lý do nên truyền id ngay cả khi bạn không bao giờ định đổi schema.

Khi bắt buộc phải truyền object — ví dụ dữ liệu không còn trong database lúc job chạy — hãy áp dụng cùng quy tắc như mọi hợp đồng:

public record GuiEmailRequest
{
public required string Email { get; init; }
public required string TieuDe { get; init; }

// Thuộc tính mới PHẢI nullable hoặc có giá trị mặc định
public string? TenantId { get; init; }
public int PhienBan { get; init; } = 1;
}
public async Task ChayAsync(GuiEmailRequest req)
{
// Job cũ không có TenantId -> suy ra, đừng thất bại
var tenantId = req.TenantId ?? await SuyRaTenantAsync(req.Email);
// ...
}

Và với thay đổi không tương thích được, dùng expand–contract như với schema database:

Phiên bản 2.5:  thêm phương thức ChayV2Async, GIỮ ChayAsync cũ.
Code mới xếp hàng vào ChayV2Async.
Phiên bản 2.6: chờ cho tới khi mọi job cũ đã chạy xong hoặc hết hạn.
Phiên bản 2.7: xoá ChayAsync cũ.

Đây chính là mẫu ở bài 13.12, áp cho một loại hợp đồng khác.

Kiểm tra trước khi deploy — luôn hỏi hàng đợi còn gì:

SELECT j.Id, j.CreatedAt, j.InvocationData, s.Name AS TrangThai
FROM HangFire.Job j
JOIN HangFire.State s ON s.Id = j.StateId
WHERE s.Name IN ('Enqueued', 'Scheduled', 'Processing')
ORDER BY j.CreatedAt;

Nếu có job đang chờ mà bạn vừa đổi chữ ký của nó, deploy sẽ làm chúng thất bại. Đưa câu truy vấn này vào checklist triển khai — nó mất 5 giây và bắt được một loại lỗi mà không công cụ nào khác thấy.


Bài 3 — Múi giờ của recurring job​

Đăng ký recurring job "0 8 * * *" không khai múi giờ và ghi lại thời điểm nó thật sự chạy theo giờ Việt Nam.

Tiêu chí hoàn thành: bạn nêu được vì sao dùng UTC cho mọi thứ không giải quyết được bài toán này, và biết khi nào bắt buộc phải khai múi giờ thật.

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

Gợi ý. "8 giờ sáng" là 8 giờ ở đâu?

Lời giải:

RecurringJob.AddOrUpdate<BaoCaoJob>(
"bao-cao-hang-ngay",
j => j.ChayAsync(),
"0 8 * * *"); // không khai múi giờ
Máy dev (TZ=Asia/Ho_Chi_Minh):   chạy lúc 08:00 giờ Việt Nam
Container production (TZ=UTC): chạy lúc 08:00 UTC = 15:00 giờ Việt Nam

Báo cáo "buổi sáng" đến vào giữa chiều. Và nó chạy đúng trên máy dev, nên lỗi chỉ lộ ra sau khi lên production.

Mặc định của Hangfire là TimeZoneInfo.Utc. Container .NET chính thức đặt TZ=UTC nếu không cấu hình gì, nên hai môi trường diễn giải cùng một chuỗi cron theo hai cách khác nhau.

Bản sửa:

var mgVietNam = TimeZoneInfo.FindSystemTimeZoneById("Asia/Ho_Chi_Minh");

RecurringJob.AddOrUpdate<BaoCaoJob>(
"bao-cao-hang-ngay",
j => j.ChayAsync(),
"0 8 * * *",
new RecurringJobOptions { TimeZone = mgVietNam });

Trên Windows, id múi giờ là "SE Asia Standard Time". Từ .NET 6, TimeZoneInfo.FindSystemTimeZoneById chấp nhận id IANA trên cả hai nền tảng, nên hãy dùng "Asia/Ho_Chi_Minh" và chỉ nó.

Vì sao dùng UTC cho mọi thứ không giải quyết được bài toán này — đây là phần chính.

"Luôn dùng UTC" là lời khuyên đúng cho lưu trữ thời điểm: một sự kiện đã xảy ra có một thời điểm tuyệt đối, và UTC là cách biểu diễn nó không mơ hồ.

Nhưng lịch chạy job không phải là một thời điểm đã xảy ra. Nó là một ý định nghiệp vụ, và ý định đó được phát biểu bằng giờ địa phương:

"Gửi báo cáo lúc 8 giờ sáng"        -> 8 giờ sáng theo giờ NGƯỜI NHẬN
"Chốt sổ cuối ngày lúc 23:59" -> theo giờ nơi đặt cơ sở kinh doanh
"Không gửi tin nhắn sau 21 giờ" -> theo giờ người dùng
"Nhắc nhở vào thứ Hai đầu tuần" -> "đầu tuần" cũng phụ thuộc lịch địa phương

Chuyển "8 giờ sáng giờ Việt Nam" thành "1 giờ sáng UTC" hoạt động — cho tới khi có thay đổi múi giờ.

Hai tình huống mà cách quy đổi sang UTC hỏng:

1. Giờ mùa hè (DST). Việt Nam không dùng DST, nhưng phần lớn thế giới thì có:

Yêu cầu: "chạy lúc 9 giờ sáng giờ New York"

Quy đổi cứng sang UTC:
Mùa đông (EST, UTC-5): 9 giờ sáng = 14:00 UTC ĐÚNG
Mùa hè (EDT, UTC-4): 14:00 UTC = 10 giờ sáng SAI, lệch 1 tiếng

Hai lần mỗi năm, job chạy sai giờ — và không ai đổi gì cả. Khai múi giờ thật thì thư viện tự xử lý, vì nó biết quy tắc DST.

2. Chính phủ đổi quy tắc múi giờ. Chuyện này xảy ra thường xuyên hơn người ta tưởng: các nước thay đổi offset, bỏ hoặc thêm DST, đổi ngày chuyển mùa. Dữ liệu IANA được cập nhật vài lần mỗi năm. Một hằng số UTC viết cứng trong code sẽ sai từ ngày quy tắc đổi, và không có gì cảnh báo bạn.

Nguyên tắc rút ra:

Loại dữ liệuLưu và xử lý bằng
Thời điểm sự kiện đã xảy raUTC
Hạn chót, thời gian hết hạnUTC
Khoảng thời gian, độ đoKhông có múi giờ
Lịch chạy jobGiờ địa phương + tên múi giờ IANA
Giờ làm việc, giờ yên lặngGiờ địa phương + tên múi giờ IANA

Hai dòng cuối là ngoại lệ có thật của quy tắc "luôn UTC" — và chúng là ngoại lệ vì chúng mô tả ý định trong tương lai, không phải sự kiện trong quá khứ.

Khi nào bắt buộc phải khai múi giờ thật:

  1. Job gửi thứ gì đó cho con người — email, thông báo, tin nhắn.
  2. Job gắn với ranh giới ngày — chốt sổ, tổng kết ngày, đặt lại hạn mức.
  3. Job gắn với giờ làm việc — chỉ chạy trong giờ hành chính, tránh giờ cao điểm.
  4. Hệ thống phục vụ nhiều múi giờ, mỗi tenant một giờ khác nhau.

Ngược lại, job thuần kỹ thuật thì UTC là đúng và đơn giản nhất: dọn dẹp, backup, đồng bộ, làm nóng cache.

Với hệ thống nhiều múi giờ — một job cho mỗi múi giờ:

foreach (var mg in await LayCacMuiGioCuaTenantAsync(ct))
{
RecurringJob.AddOrUpdate<BaoCaoJob>(
$"bao-cao:{mg.Id}",
j => j.ChayChoMuiGioAsync(mg.Id),
"0 8 * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById(mg.Id) });
}

Mỗi tenant nhận báo cáo lúc 8 giờ sáng theo giờ của họ.

Ba việc nên làm:

1. Đặt TZ tường minh trong container, dù có khai múi giờ trong code hay không:

ENV TZ=Asia/Ho_Chi_Minh
RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/*

Gói tzdata là bắt buộc trên ảnh Alpine và một số ảnh slim — thiếu nó thì FindSystemTimeZoneById ném TimeZoneNotFoundException lúc chạy.

2. Log cả hai giờ ở mỗi lần job chạy:

var utc = DateTime.UtcNow;
var vn = TimeZoneInfo.ConvertTimeFromUtc(utc, _mgVietNam);
_logger.LogInformation("Job bắt đầu lúc {Vn:HH:mm} giờ VN ({Utc:HH:mm} UTC)", vn, utc);

Không có dòng này, việc xác minh job chạy đúng giờ trở thành một bài toán số học trong đầu, và người trực lúc 2 giờ sáng sẽ làm sai.

3. Test khẳng định lần chạy kế tiếp:

[Fact]
public void Bao_cao_hang_ngay_phai_chay_luc_8h_gio_Viet_Nam()
{
var lich = Cronos.CronExpression.Parse("0 8 * * *");
var mg = TimeZoneInfo.FindSystemTimeZoneById("Asia/Ho_Chi_Minh");

var ke = lich.GetNextOccurrence(
new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero), mg);

TimeZoneInfo.ConvertTime(ke!.Value, mg).Hour.Should().Be(8);
}

Test này chạy trên mọi máy và mọi CI, bất kể TZ của môi trường — nên nó bắt được đúng loại lỗi đã mô tả ở đầu bài.

Tự kiểm tra​

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

Hangfire khác BackgroundService ở điểm nào?

Job được lưu vào database nên sống sót qua restart, có retry tự động, có lịch cron, có dashboard xem lịch sử và chạy lại thủ công, và chống chạy trùng khi nhiều instance. Đổi lại nó phụ thuộc database và phức tạp hơn.

Vì sao chỉ nên truyền id vào job?

Ba lý do. Tham số được serialize nên đối tượng lớn làm bảng job phình. Dữ liệu là bản chụp lúc tạo job nên có thể đã cũ khi job chạy. Và đổi hình dạng kiểu khiến job đã xếp hàng không deserialize được và thất bại vĩnh viễn. Job tự nạp dữ liệu mới nhất khi chạy thì an toàn hơn.

Vì sao job phải idempotent?

Vì Hangfire đảm bảo at-least-once chứ không phải exactly-once. Job chạy hai lần khi worker chết giữa chừng và job được giao lại, khi retry sau lỗi, hoặc khi ai đó bấm requeue trên dashboard. Phải kiểm tra trạng thái trước khi thực hiện và ghi nhận sau.

Nên retry những lỗi nào?

Chỉ lỗi tạm thời như lỗi mạng hay lỗi 5xx từ dịch vụ ngoài. Lỗi dữ liệu như 400 thì retry không giúp gì, chỉ tốn tài nguyên và làm nhiễu dashboard; nên kết thúc job và cảnh báo để người xử lý.

Múi giờ của recurring job mặc định là gì?

UTC. Nên biểu thức cron 8 giờ sáng thực ra là 8 giờ UTC, tức 15 giờ ở Việt Nam. Phải khai rõ TimeZone trong RecurringJobOptions; đây là lỗi rất hay gặp.

Dashboard Hangfire mặc định an toàn không?

Không đủ. Mặc định chỉ cho truy cập local, nhưng sau reverse proxy thì local có thể là IP của proxy, nghĩa là dashboard mở cho tất cả. Phải khai authorization filter tường minh, và nhớ dashboard hiển thị cả tham số job nên đừng truyền dữ liệu nhạy cảm.

Kết luận​

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

  1. Chỉ truyền id vào job. Entity trong tham số là ba loại vấn đề cùng lúc.
  2. At-least-once nghĩa là job sẽ chạy hai lần. Mọi job phải idempotent.
  3. Bảo vệ dashboard tường minh — mặc định "chỉ local" không đúng sau reverse proxy.

Tham khảo​

Điều hướng​

Bài liên quan​