9.9 - Background Processing với Hosted Service
Tóm tắt:
BackgroundServicelà cách chạy việc nền trong cùng tiến trình với API — dùng chung DI container, cấu hình và logger. Nó đúng cho việc định kỳ nhẹ và việc tiêu thụ hàng đợi trong bộ nhớ. Ba cái bẫy quyết định nó chạy được trên production hay không. Một: worker là singleton, nên mọi service scoped phải lấy quaIServiceScopeFactory. Hai: exception thoát khỏiExecuteAsyncgiết worker vĩnh viễn mà ứng dụng vẫn chạy bình thường — không ai phát hiện. Ba: chạy 3 instance nghĩa là job định kỳ chạy 3 lần, nên mọi job có tác dụng phụ đều cần khoá phân tán.
Mục tiêu bài học
Sau bài này bạn có thể:
- Viết
BackgroundServiceđúng cách với scope DI và xử lý lỗi. - Dùng
PeriodicTimerthay choTask.Delaytrong vòng lặp. - Ngăn job chạy trùng khi có nhiều instance.
- Shutdown gọn gàng mà không cắt việc đang chạy.
- Quyết định khi nào phải chuyển sang Hangfire hoặc Quartz.
Nội dung bài học
9.9.1 — Ba loại việc nền
| Loại | Công cụ |
|---|---|
| Định kỳ (dọn dẹp, gửi báo cáo hằng ngày) | BackgroundService + PeriodicTimer |
| Theo sự kiện (gửi email sau khi tạo đơn) | Channel + BackgroundService — bài 6.8 |
| Việc dài, cần theo dõi và retry | Hangfire, Quartz, hoặc message broker |
Bài này tập trung loại thứ nhất; loại thứ hai đã nói ở Module 6; loại thứ ba ở mục 9.9.6.
9.9.2 — PeriodicTimer thay cho Task.Delay
// CÁCH CŨ — trôi dần theo thời gian
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken); // mất 8 giây
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
// Chu kỳ thực tế: 5 phút 8 giây, rồi 5 phút 16 giây...
}// .NET 6+ — chu kỳ ổn định
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await DoWorkAsync(stoppingToken);
}PeriodicTimer đếm từ lần tick trước, không phải từ lúc công việc xong, nên chu kỳ không trôi. Nó cũng không cấp phát một Task mới mỗi vòng, và WaitForNextTickAsync trả false khi token bị huỷ — nên vòng lặp tự kết thúc gọn gàng, không cần try/catch OperationCanceledException chỉ để thoát.
Với lịch cụ thể (7 giờ sáng mỗi ngày), PeriodicTimer không đủ — bạn phải tính khoảng chờ tới lần chạy kế tiếp:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = _timeProvider.GetUtcNow();
var next = now.Date.AddHours(7);
if (now >= next) next = next.AddDays(1);
try
{
await Task.Delay(next - now, stoppingToken);
}
catch (OperationCanceledException) { break; }
await RunSafelyAsync(stoppingToken);
}
}Cách này có hai vấn đề mà bạn phải biết: múi giờ (dùng UTC thì "7 giờ sáng" ở Việt Nam là 0 giờ UTC, và giờ mùa hè ở nơi khác sẽ lệch), và Task.Delay không sống qua restart — deploy lúc 6 giờ 59 nghĩa là bỏ lỡ lần chạy hôm đó. Nếu lịch quan trọng, dùng Quartz với trigger cron và kho lưu trữ bền vững.
Tiêm TimeProvider thay vì gọi DateTime.UtcNow để test được — xem bài 7.1.
9.9.3 — Scope và xử lý lỗi
public sealed class DailyDigestWorker(
IServiceScopeFactory scopeFactory,
ILogger<DailyDigestWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await RunAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // tắt máy bình thường
}
catch (Exception ex)
{
logger.LogError(ex, "Gửi digest thất bại, sẽ thử lại ở chu kỳ sau");
// KHÔNG ném lại — một lần lỗi không được giết worker
}
}
}
private async Task RunAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
var email = scope.ServiceProvider.GetRequiredService<IEmailService>();
var since = DateTime.UtcNow.AddDays(-1);
var newLeads = await db.Customers.CountAsync(
c => c.Status == "lead" && c.CreatedAt >= since, ct);
var recipients = await db.Users
.Where(u => u.Role == "sales" && u.DigestEnabled)
.Select(u => new { u.Email, u.Name })
.ToListAsync(ct);
foreach (var user in recipients)
{
ct.ThrowIfCancellationRequested();
await email.SendAsync(user.Email, $"CRM Digest — {newLeads} lead mới", ..., ct);
}
logger.LogInformation("Đã gửi digest cho {Count} người", recipients.Count);
}
}Hai điểm sống còn:
1. IServiceScopeFactory, không tiêm thẳng DbContext. BackgroundService được đăng ký singleton; tiêm một service scoped vào constructor là captive dependency (bài 7.4). Quan trọng không kém: scope mới cho mỗi lần chạy nghĩa là DbContext mới, change tracker sạch — không thì nó tích luỹ entity qua hàng tháng.
2. try/catch bao quanh, và không throw lại. Exception thoát khỏi ExecuteAsync khiến worker dừng vĩnh viễn. Ứng dụng web vẫn chạy, health check vẫn xanh, và không ai biết job đã chết — cho tới khi có người hỏi vì sao một tháng nay không nhận được báo cáo.
Từ .NET 6, BackgroundServiceExceptionBehavior.StopHost là mặc định — exception chưa xử lý sẽ dừng cả ứng dụng. Đó thực ra là hành vi tốt hơn: sập ồn ào còn hơn chết im lặng. Nhưng đừng dựa vào nó; hãy tự bắt lỗi.
Ghép hai điểm trên lại, vòng đời của worker trông như sau:


9.9.4 — Nhiều instance: job chạy trùng
3 pod, moi pod chay DailyDigestWorker
=> 3 email digest gửi tới cùng một người
=> Hoặc 3 job dọn dẹp cùng xoá một tập dữ liệuĐây là vấn đề luôn xảy ra khi scale ngang và hay bị phát hiện muộn. Ba cách xử lý:
1. Khoá phân tán bằng chính database — đơn giản nhất, không thêm hạ tầng:
private async Task<bool> TryAcquireLockAsync(string jobName, CancellationToken ct)
{
// Chỉ một thể hiện INSERT thành công nhờ ràng buộc duy nhất
try
{
_db.JobLocks.Add(new JobLock
{
JobName = jobName,
AcquiredAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddMinutes(30),
Owner = Environment.MachineName
});
await _db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException) // vi phạm ràng buộc duy nhất -> thể hiện khác đang chạy
{
return false;
}
}ExpiresAt là phần bắt buộc: nếu instance giữ khoá bị kill, khoá phải tự hết hạn, nếu không job không bao giờ chạy lại.
2. Tách thành Worker Service riêng chạy một replica. Sạch nhất về mặt vận hành: không có khoá, không có tranh chấp, và job được scale độc lập với API.
3. Dùng Hangfire hoặc Quartz với kho lưu trữ dùng chung — chúng đã giải quyết bài toán này.
Cách 2 đáng cân nhắc hơn nhiều người nghĩ. Chạy job nền trong tiến trình API còn có những vấn đề khác: một job ngốn CPU làm chậm mọi request, và bạn không thể scale API lên 10 pod mà không scale job lên 10 lần.
9.9.5 — Shutdown
public override async Task StopAsync(CancellationToken ct)
{
logger.LogInformation("Worker đang dừng, chờ việc hiện tại hoàn tất");
await base.StopAsync(ct);
}builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromSeconds(60);
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost;
});Mặc định host chỉ chờ 5 giây. Job dài hơn thế sẽ bị cắt giữa chừng ở mỗi lần deploy — và với job ghi dữ liệu theo lô, đó là dữ liệu ghi dở.
Hai việc cần làm:
- Tăng
ShutdownTimeoutcho đủ job dài nhất. - Kiểm tra token thường xuyên trong vòng lặp dài, và dừng ở một điểm an toàn:
foreach (var batch in batches)
{
if (ct.IsCancellationRequested)
{
logger.LogInformation("Dừng sau lô {Index}, sẽ tiếp tục ở lần chạy sau", index);
break; // dừng ở RANH GIỚI lô, không giữa lô
}
await ProcessBatchAsync(batch, ct);
}Điều này đòi hỏi job phải tiếp tục được từ giữa chừng — tức lưu tiến độ, không giả định luôn chạy từ đầu tới cuối.
9.9.6 — Khi nào BackgroundService không đủ
| Bạn cần | BackgroundService | Hangfire / Quartz |
|---|---|---|
| Việc định kỳ đơn giản | Đủ | Thừa |
| Retry tự động khi lỗi | Tự viết | Có sẵn |
| Job sống sót qua restart | Không | Có |
| Lịch cron phức tạp | Tự tính | Có sẵn |
| Giao diện xem job đã chạy | Không | Có |
| Chạy một job thủ công | Không | Có |
| Không chạy trùng khi nhiều instance | Tự làm | Có sẵn |
Câu hỏi quyết định: "nếu job này không chạy một lần, có ai biết không?"
Nếu câu trả lời là "không" thì bạn cần Hangfire — nó ghi lại mọi lần chạy, mọi lần thất bại, và cho bạn chạy lại. Với BackgroundService, một job im lặng thất bại là một job không ai phát hiện.
// Hangfire — đăng ký một dòng
RecurringJob.AddOrUpdate<IDigestService>(
"daily-digest", s => s.SendAsync(CancellationToken.None), "0 7 * * *");Chi tiết ở Module 14 — Caching và Background Jobs.
9.9.7 — Rà lại code của bạn
Danh sách rà soát background service
- Mọi service scoped được lấy qua IServiceScopeFactory, không tiêm vào constructor.
- Mỗi lần chạy tạo scope mới để DbContext không tích luỹ entity.
- try/catch bao quanh mỗi lần chạy và không throw lại.
- Vòng lặp định kỳ dùng PeriodicTimer, không dùng Task.Delay.
- Job có tác dụng phụ được bảo vệ khỏi chạy trùng khi nhiều instance.
- Khoá phân tán có thời hạn hết hiệu lực.
- ShutdownTimeout đủ dài cho job dài nhất.
- Vòng lặp dài kiểm tra CancellationToken và dừng ở ranh giới an toàn.
- Job dùng TimeProvider thay vì DateTime.UtcNow để test được.
- Đã trả lời được: nếu job này không chạy một lần, có ai biết không?
Bài tập áp dụng
Bài 1 — Worker chết im lặng
Bỏ try/catch trong ExecuteAsync, cho công việc ném ngoại lệ, và xác nhận ứng dụng vẫn phục vụ request bình thường trong khi công việc không bao giờ chạy lại. Ghi lại cách bạn phát hiện.
Tiêu chí hoàn thành: bạn nêu được ba cách phát hiện, và cái nào phát hiện sớm nhất.
Gợi ý và lời giải — Bài 1
Gợi ý. Bài này lặp lại vấn đề ở bài 6.8 nhưng trọng tâm ở đây là phát hiện, không phải phòng tránh.
Lời giải — bản dễ vỡ:
public class EmailWorker(IServiceScopeFactory sf, ILogger<EmailWorker> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = sf.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IEmailQueue>();
await svc.ProcessQueueAsync(ct); // ném ở đây -> thoát vòng lặp
}
}
}Cho một công việc gây ngoại lệ. Kết quả:
- HTTP vẫn phục vụ bình thường
- /health/live trả 200
- /health/ready trả 200
- Không dòng log lỗi nào (tuỳ cấu hình BackgroundServiceExceptionBehavior)
- Email không bao giờ được gửi nữaỨng dụng trông hoàn toàn khoẻ mạnh trong khi một nửa chức năng đã chết.
Ba cách phát hiện, xếp theo thời gian:
| Cách | Phát hiện sau | Cần gì |
|---|---|---|
| Health check theo nhịp tim | 1–2 chu kỳ | Một health check riêng |
| Cảnh báo theo độ dài hàng đợi | Vài phút tới vài giờ | Chỉ số hàng đợi |
| Người dùng phàn nàn | Vài giờ tới vài ngày | — |
Cách nhanh nhất — health check theo nhịp tim:
public class WorkerHeartbeat
{
private long _lastBeatUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public void Beat() => Interlocked.Exchange(ref _lastBeatUnix,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
public TimeSpan SinceLastBeat => TimeSpan.FromSeconds(
DateTimeOffset.UtcNow.ToUnixTimeSeconds() - Interlocked.Read(ref _lastBeatUnix));
}
public class WorkerHealthCheck(WorkerHeartbeat hb) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext c, CancellationToken ct)
=> Task.FromResult(hb.SinceLastBeat < TimeSpan.FromMinutes(3)
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy($"Worker im lặng {hb.SinceLastBeat.TotalMinutes:F0} phút"));
}Worker gọi hb.Beat() sau mỗi vòng. Nếu nó chết, health check chuyển sang không khoẻ sau 3 phút.
Quan trọng: đặt health check này ở nhóm nào?
- Đặt vào
readythì pod bị gỡ khỏi vòng phục vụ — sai, vì phần HTTP vẫn hoạt động tốt. - Đặt vào
livethì pod bị khởi động lại — thường là đúng, vì khởi động lại làm worker chạy lại.
Nhưng chỉ đúng khi worker chết là do lỗi tạm thời. Nếu nguyên nhân là một công việc hỏng cố định, khởi động lại chỉ tạo ra vòng lặp. Vì vậy hãy sửa gốc trước:
while (await timer.WaitForNextTickAsync(ct))
{
try
{
using var scope = sf.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IEmailQueue>();
await svc.ProcessQueueAsync(ct);
hb.Beat();
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
log.LogError(ex, "Vòng xử lý thất bại, sẽ thử lại ở chu kỳ after");
// KHÔNG ném lại — worker phải sống sót
}
}Kiểm tra hành vi mặc định của dự án:
builder.Services.Configure<HostOptions>(o =>
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.StopHost);StopHost là mặc định từ .NET 6 và là lựa chọn tốt: ngoại lệ chưa xử lý làm cả ứng dụng dừng, nên bạn biết ngay thay vì có một worker chết âm thầm. Nhiều dự án đổi nó sang Ignore — nếu dự án của bạn có dòng đó, hãy chắc rằng mọi worker đều tự bắt lỗi bên trong.
Bài 2 — Chu kỳ trôi
So sánh Task.Delay và PeriodicTimer với công việc mất 0,8 giây và chu kỳ 1 giây. Ghi thời điểm mỗi lần chạy trong 10 vòng và so sánh độ lệch.
Tiêu chí hoàn thành: bạn tính được công thức trôi và nêu được khi nào độ trôi gây hậu quả nghiệp vụ.
Gợi ý và lời giải — Bài 2
Gợi ý. await Task.Delay(1s) sau khi làm việc nghĩa là chu kỳ thật bằng thời gian làm việc + 1 giây, không phải 1 giây.
Lời giải.
// Cách 1 — Task.Delay
while (!ct.IsCancellationRequested)
{
await DoWorkAsync(ct); // 0,8 giây
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
// Cách 2 — PeriodicTimer
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
await DoWorkAsync(ct); // 0,8 giây
}Kết quả đo thật trên .NET 9, 10 vòng:
vòng Task.Delay PeriodicTimer
1 0.81s 1.80s
2 2.61s 2.80s
3 4.41s 3.80s
4 6.21s 4.80s
5 8.01s 5.80s
...
10 17.02s 10.80s
Task.Delay : lần cuối ở giây 17.02 (lẽ ra 10s -> trôi +7.02s)
PeriodicTimer : lần cuối ở giây 10.80 (trôi +0.80s)Công thức trôi. Với Task.Delay, chu kỳ thật là T + W (chu kỳ cộng thời gian làm việc). Sau n vòng, độ trôi tích luỹ là:
trôi = n × WỞ đây 10 × 0,8 = 8 giây — khớp với số đo 7,02 giây (chênh nhỏ vì vòng đầu không tính).
Với PeriodicTimer, nhịp được tính từ thời điểm cố định, nên độ trôi không tích luỹ: nó luôn bằng đúng thời gian làm việc của vòng cuối.
Khi nào độ trôi gây hậu quả nghiệp vụ:
| Tình huống | Hậu quả |
|---|---|
| Công việc chạy "mỗi giờ" | Sau một ngày, nó chạy lệch vài giờ so với dự kiến |
| Báo cáo "cuối ngày" lúc 23:00 | Trôi dần sang ngày hôm sau, báo cáo sai kỳ |
| Đồng bộ dữ liệu theo khung giờ | Bỏ lỡ khung giờ thoả thuận với đối tác |
| Thu thập chỉ số mỗi phút | Điểm dữ liệu không đều, biểu đồ sai lệch |
Dòng thứ hai là hậu quả nghiêm trọng nhất: một công việc chạy lúc 23:00 trôi dần sẽ có ngày chạy lúc 00:30 và gán dữ liệu vào sai ngày.
Hành vi của PeriodicTimer khi công việc lâu hơn chu kỳ. Nếu công việc mất 3 giây và chu kỳ là 1 giây, PeriodicTimer không dồn các nhịp bị lỡ — nó bỏ qua và trả về ngay ở lần chờ tiếp theo. Đây là hành vi đúng: bạn không muốn công việc chạy dồn dập để "bù" cho phần bị lỡ.
Khi cần chạy đúng vào thời điểm cụ thể, cả hai cách trên đều không đủ — chúng chỉ giữ khoảng cách, không giữ thời điểm. Lúc đó cần lịch theo biểu thức cron:
// Với Quartz.NET hoặc Hangfire
RecurringJob.AddOrUpdate<EndOfDayReportJob>(
"end-of-day-report",
j => j.RunAsync(CancellationToken.None),
"0 23 * * *", // 23:00 mỗi ngày
new RecurringJobOptions { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("SE Asia Standard Time") });Chú ý tham số múi giờ — không có nó, công việc chạy theo giờ UTC của máy chủ và lệch 7 tiếng so với giờ Việt Nam. Đây là lỗi rất hay gặp và chỉ lộ ra khi ai đó đối chiếu số liệu.
Bảng chọn:
| Nhu cầu | Dùng |
|---|---|
| Chạy lại sau mỗi X thời gian, không quan trọng thời điểm | PeriodicTimer |
| Chạy đúng vào giờ cụ thể | Quartz.NET hoặc Hangfire với cron |
| Chạy một lần sau khi khởi động | IHostedService.StartAsync |
| Xử lý việc đến bất chợt | Channel — bài 6.8 |
Bài 3 — Công việc chạy trùng trên nhiều thể hiện
Chạy 3 thể hiện cùng một công việc gửi email, đếm số email nhận được. Thêm khoá phân tán qua database và kiểm chứng lại.
Tiêu chí hoàn thành: bạn nêu được vì sao BackgroundService luôn chạy trên mọi thể hiện, và hai cách xử lý.
Gợi ý và lời giải — Bài 3
Gợi ý. BackgroundService là một phần của ứng dụng. Nhân bản ứng dụng lên 3 thể hiện nghĩa là nhân bản cả nó.
Lời giải — không có khoá:
public class ReminderWorker(IServiceScopeFactory sf) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = sf.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
var mail = scope.ServiceProvider.GetRequiredService<IEmailSender>();
var due = await db.Leads.Where(l => l.NeedsReminder).ToListAsync(ct);
foreach (var l in due) await mail.SendReminderAsync(l.Email, ct);
}
}
}Với 3 thể hiện: mỗi khách hàng nhận 3 email giống hệt nhau.
Tệ hơn với công việc thay đổi dữ liệu: ba thể hiện cùng đọc, cùng xử lý, cùng ghi — dẫn tới tính tiền ba lần, tạo ba bản ghi, hoặc lỗi đua.
Vì sao BackgroundService luôn chạy trên mọi thể hiện. Nó được đăng ký trong Program.cs như mọi dịch vụ khác, và mỗi thể hiện chạy cùng một Program.cs. Không có cơ chế nào trong ASP.NET Core để nói "chỉ chạy cái này ở một nơi" — đó là vấn đề điều phối, nằm ngoài phạm vi của framework.
Cách 1 — khoá phân tán qua database:
public class DistributedJobLock(CrmDbContext db)
{
public async Task<bool> TryAcquireAsync(string name, TimeSpan ttl, CancellationToken ct)
{
var expiresAt = DateTime.UtcNow.Add(ttl);
var n = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO "JobLocks" ("Name", "OwnedBy", "ExpiresAt")
VALUES ({name}, {Environment.MachineName}, {expiresAt})
ON CONFLICT ("Name") DO UPDATE
SET "OwnedBy" = {Environment.MachineName}, "ExpiresAt" = {expiresAt}
WHERE "JobLocks"."ExpiresAt" < {DateTime.UtcNow}
""", ct);
return n > 0;
}
}while (await timer.WaitForNextTickAsync(ct))
{
using var scope = sf.CreateScope();
var jobLock = scope.ServiceProvider.GetRequiredService<DistributedJobLock>();
if (!await jobLock.TryAcquireAsync("reminder", TimeSpan.FromMinutes(30), ct))
continue; // thể hiện khác đang chạy
await ProcessAsync(scope, ct);
}Chỉ một thể hiện lấy được khoá; hai thể hiện kia bỏ qua vòng đó.
Ba điểm quan trọng trong cài đặt trên:
ON CONFLICT DO UPDATE ... WHERE ExpiresAt < nowlà thao tác nguyên tử. Không có khoảng trống giữa kiểm tra và ghi — đúng nguyên tắc ở bài 2.6.- Khoá có thời hạn. Nếu thể hiện giữ khoá bị chết giữa chừng, khoá tự hết hạn và công việc chạy lại ở chu kỳ sau. Không có thời hạn thì một lần sập làm công việc dừng vĩnh viễn.
- Thời hạn phải dài hơn thời gian chạy dự kiến. Quá ngắn thì thể hiện thứ hai nhảy vào giữa chừng.
Cách 2 — dùng thư viện lập lịch có sẵn:
// Hangfire — lưu lịch trong database, tự xử lý việc chỉ chạy một nơi
builder.Services.AddHangfire(c => c.UsePostgreSqlStorage(cs));
builder.Services.AddHangfireServer();
RecurringJob.AddOrUpdate<ReminderJob>("nhac-nho", j => j.RunAsync(default), Cron.Hourly);Hangfire lưu trạng thái công việc trong database, nên nhiều thể hiện phối hợp được với nhau. Nó cũng cho sẵn thử lại, bảng điều khiển theo dõi, và lịch sử chạy.
Bảng chọn:
| Tình huống | Dùng |
|---|---|
| Một hoặc hai công việc đơn giản | BackgroundService + khoá phân tán |
| Nhiều công việc, cần theo dõi và thử lại | Hangfire hoặc Quartz.NET |
| Chạy theo lịch, không cần chạy trong ứng dụng | CronJob của Kubernetes |
| Công việc phải hoàn tất dù có sập | Hàng đợi bền vững — Module 17 |
Một cách đơn giản hay bị bỏ qua. Nếu công việc idempotent — chạy nhiều lần cho cùng kết quả — thì không cần khoá gì cả:
// Đánh dấu đã gửi TRƯỚC khi gửi, dùng cập nhật có điều kiện
var n = await db.Leads
.Where(l => l.Id == id && !l.ReminderSent)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ReminderSent, true), ct);
if (n == 0) return; // thể hiện khác đã xử lý
await mail.SendReminderAsync(email, ct);Làm cho công việc idempotent thường đơn giản hơn và bền hơn so với thêm một lớp khoá — đúng tinh thần của idempotency key ở bài 9.1.
Tự kiểm tra
Vì sao BackgroundService phải dùng IServiceScopeFactory?
Vì nó được đăng ký singleton, nên tiêm một service scoped như DbContext vào constructor là captive dependency. Ngoài ra, tạo scope mới cho mỗi lần chạy nghĩa là DbContext mới với change tracker sạch; dùng chung một instance sẽ tích luỹ entity qua hàng tháng.
Điều gì xảy ra khi exception thoát khỏi ExecuteAsync?
Worker dừng vĩnh viễn. Ứng dụng web vẫn chạy, health check vẫn xanh, và không ai biết job đã chết cho tới khi có người hỏi vì sao lâu nay không nhận được báo cáo. Từ .NET 6 mặc định là dừng cả host, điều này thật ra tốt hơn vì sập ồn ào còn hơn chết im lặng, nhưng vẫn nên tự bắt lỗi.
PeriodicTimer hơn Task.Delay ở điểm nào?
Nó đếm từ lần tick trước chứ không phải từ lúc công việc xong, nên chu kỳ không trôi dần theo thời gian xử lý. Nó cũng không cấp phát Task mới mỗi vòng, và WaitForNextTickAsync trả false khi token bị huỷ nên vòng lặp tự kết thúc gọn gàng.
Chạy nhiều instance thì job định kỳ bị gì?
Nó chạy trùng, ví dụ 3 pod là 3 email digest gửi tới cùng một người, hoặc 3 job dọn dẹp cùng xoá một tập dữ liệu. Cách xử lý là khoá phân tán có thời hạn hết hiệu lực, tách thành Worker Service riêng chạy một replica, hoặc dùng Hangfire với kho lưu trữ dùng chung.
Shutdown mặc định có vấn đề gì?
Host chỉ chờ 5 giây, nên job dài hơn thế bị cắt giữa chừng ở mỗi lần deploy, và với job ghi dữ liệu theo lô thì đó là dữ liệu ghi dở. Phải tăng ShutdownTimeout và kiểm tra CancellationToken thường xuyên để dừng ở ranh giới an toàn, tức job phải tiếp tục được từ giữa chừng.
Khi nào cần chuyển sang Hangfire hoặc Quartz?
Khi cần retry tự động, job sống sót qua restart, lịch cron phức tạp, giao diện xem lịch sử chạy, hoặc chạy lại thủ công. Câu hỏi quyết định là nếu job này không chạy một lần thì có ai biết không; nếu không thì bạn cần công cụ ghi lại mọi lần chạy và mọi lần thất bại.
Kết luận
Ba điều đáng nhớ nhất:
- Worker là singleton — scope DI lấy qua
IServiceScopeFactory, mỗi lần chạy một scope. - Exception thoát khỏi
ExecuteAsyncgiết worker. Bắt, log, và đừng ném lại. - Nhiều instance là job chạy trùng. Khoá phân tán, Worker Service riêng, hoặc Hangfire.
Tham khảo
- Background tasks with hosted services in ASP.NET Core
- Worker Services in .NET
- PeriodicTimer class
- Implement background tasks in microservices
Điều hướng
- Bài trước: 9.8 — API Testing
- Bài tiếp theo: 9.10 — Mở rộng và đào sâu
- Về module: Trang mục lục
Bài liên quan
- Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi — Ba lifetime trong DI container của ASP.NET Core khác nhau ở thời điểm tạo và thời điểm dispose instance.
- Gọi .Result khi nào thì deadlock, khi nào thì không? — Gọi .Result hay .Wait() trên một Task treo cứng ứng dụng WPF và ASP.NET Framework, nhưng chạy bình thường trong console và ASP.NET Core.
- Unit of Work trong .NET và ABP: SaveChangesAsync không phải commit — DbContext của EF Core bản thân nó đã là một Unit of Work, nên bọc thêm một interface IUnitOfWork gọi SaveChanges thường chỉ thêm lớp trung gian vô…