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

14.12 — Mini case study

Tóm tắt

Một sự cố xảy ra đúng vào ngày scale out: job gửi email nhắc lịch chạy hàng ngày lúc 8 giờ sáng, hoạt động hoàn hảo 8 tháng với một instance. Ngày lên 3 instance, 12.000 khách hàng nhận 3 email giống hệt nhau trong cùng một phút. Nguyên nhân đơn giản tới mức dễ bỏ qua: recurring job đăng ký bằng IHostedService chạy trên mỗi instance, và mỗi instance không biết gì về các instance khác. Bài này trình bày ba mức giải pháp — từ khoá phân tán tới thiết kế không cần khoá — và một nguyên tắc quan trọng hơn cả ba: job phải idempotent, vì khoá phân tán không bao giờ an toàn tuyệt đối.

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

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

  • Nhận ra job chạy trùng khi scale out.
  • Dùng khoá phân tán cho recurring job.
  • Thiết kế job idempotent không cần khoá.
  • Chọn giữa Hangfire và BackgroundService.

Nội dung bài học​

14.12.1 — Sự cố​

public sealed class EmailNhacLichJob(IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var now = DateTime.UtcNow;
var nextRun = now.Date.AddHours(8);
if (nextRun <= now) nextRun = nextRun.AddDays(1);

await Task.Delay(nextRun - now, ct);
await SendAppointmentRemindersAsync(ct); // chạy trên MỌI instance
}
}
}
08:00:00  Instance A chạy job -> gửi 12.000 email
08:00:01 Instance B chạy job -> gửi 12.000 email
08:00:01 Instance C chạy job -> gửi 12.000 email

Tổng: 36.000 email, 12.000 khách nhận 3 lần

BackgroundService chạy trên mọi instance — đó là thiết kế của nó. Với công việc xử lý hàng đợi, đó là điều bạn muốn. Với công việc chạy theo lịch, đó là bug.

Hậu quả ngoài phiền toái: 24.000 email thừa làm tăng tỷ lệ báo cáo spam, và nhà cung cấp email có thể hạ uy tín tên miền gửi.

14.12.2 — Mức 1: khoá phân tán​

protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await WaitUntilScheduledTimeAsync(ct);

// Chỉ MỘT instance lấy được khoá
await using var handle = await _lock.AcquireAsync(
"job:appointment-reminders",
ttl: TimeSpan.FromMinutes(30),
ct);

if (handle is null)
{
_logger.LogDebug("Instance khác đang chạy job, bỏ qua lượt này");
continue;
}

await SendAppointmentRemindersAsync(ct);
}
}

Giải quyết được vấn đề ngay, nhưng có một giới hạn phải hiểu: khoá phân tán không an toàn tuyệt đối. Nếu instance A bị GC pause dài hơn TTL, khoá hết hạn và instance B lấy được — rồi A tỉnh dậy và tiếp tục chạy (bài 19.8).

Với job gửi email, xác suất đó thấp nhưng khác không. Vì vậy khoá là lớp thứ nhất, không phải lớp duy nhất.

14.12.3 — Mức 2: job idempotent​

Đây là lớp thật sự đảm bảo, và nó dựa vào database chứ không dựa vào giả định về thời gian:

public async Task SendAppointmentRemindersAsync(CancellationToken ct)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);

var pending = await _db.Appointments
.Where(a => a.Date == today.AddDays(1))
.Where(a => !_db.SentEmails.Any(e =>
e.AppointmentId == a.Id && e.Kind == "Reminder")) // CHƯA gửi
.ToListAsync(ct);

foreach (var appointment in pending)
{
try
{
// Ghi trước — UNIQUE INDEX trên (AppointmentId, Kind)
_db.SentEmails.Add(new SentEmail
{
AppointmentId = appointment.Id,
Kind = "Reminder",
SentAtUtc = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
continue; // instance khác đã gửi — bỏ qua
}

await _emailSender.SendAsync(appointment, ct);
}
}

Giờ job chạy ba lần cũng chỉ gửi mỗi email một lần, vì unique constraint của database quyết định — không phụ thuộc vào khoá có hoạt động đúng hay không (bài 17.3).

Thứ tự quan trọng: ghi bản ghi "đã gửi" trước rồi mới gửi email. Ngược lại, nếu tiến trình chết giữa hai bước thì email đã gửi nhưng không có dấu vết, và lần chạy sau gửi lại.

Đánh đổi: nếu ghi thành công mà gửi email thất bại, email đó không được gửi lại. Với thông báo quan trọng, cần thêm cột trạng thái và một job dọn những bản ghi "đã đánh dấu nhưng chưa gửi thành công".

14.12.4 — Mức 3: dùng Hangfire​

Hangfire lưu job trong database nên nó tự lo việc chỉ chạy một lần:

builder.Services.AddHangfire(config => config.UseSqlServerStorage(cs));
builder.Services.AddHangfireServer();

RecurringJob.AddOrUpdate<IEmailService>(
"appointment-reminders",
s => s.SendAppointmentRemindersAsync(CancellationToken.None),
"0 8 * * *", // cron: 8h sáng mỗi ngày
new RecurringJobOptions { TimeZone = TimeZoneInfo.Local });

Nhiều server Hangfire cùng chạy sẽ tranh nhau lấy job từ database, và chỉ một thắng.

BackgroundServiceHangfire
Chạy trên mọi instanceCó — cần tự xử lýKhông — tự điều phối
Lưu trữ bềnKhôngCó (database)
RetryTự viếtCó sẵn
DashboardKhôngCó
Lịch chạy cronTự viếtCó sẵn
Phụ thuộc thêmKhôngDatabase schema riêng

Khuyến nghị: dùng BackgroundService cho việc chạy liên tục trên mọi instance (outbox dispatcher, consumer hàng đợi). Dùng Hangfire cho việc chạy theo lịch hoặc một lần (bài 14.7).

Và vẫn cần idempotent: Hangfire đảm bảo at-least-once, không phải exactly-once. Khi một server chết giữa chừng, job được giao lại cho server khác — và phần đã làm có thể bị làm lại.

14.12.5 — Cái bẫy: múi giờ​

// SAI — trên container thường là UTC, không phải giờ Việt Nam
"0 8 * * *" // 8h UTC = 15h Việt Nam

Container Linux gần như luôn chạy ở UTC. Job "8 giờ sáng" thực tế chạy lúc 3 giờ chiều.

RecurringJob.AddOrUpdate<IEmailService>(
"appointment-reminders",
s => s.SendAppointmentRemindersAsync(CancellationToken.None),
"0 8 * * *",
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("SE Asia Standard Time")
});

Trên Linux, id múi giờ là "Asia/Ho_Chi_Minh". Từ .NET 6, FindSystemTimeZoneById chấp nhận cả hai định dạng trên cả hai hệ điều hành — nhưng đừng giả định, hãy kiểm tra trên môi trường thật.

Và lưu dữ liệu luôn dùng UTC. Chỉ đổi sang giờ địa phương ở tầng hiển thị và ở lịch chạy job.

14.12.6 — Giám sát job​

_meter.CreateCounter<long>("job.runs").Add(1,
new KeyValuePair<string, object?>("job", "email-nhac-lich"),
new KeyValuePair<string, object?>("result", "success"));

_meter.CreateHistogram<double>("job.duration").Record(sw.Elapsed.TotalSeconds,
new KeyValuePair<string, object?>("job", "email-nhac-lich"));

Ba cảnh báo nên có:

Cảnh báoVì sao
Job không chạy trong khoảng dự kiếnQuan trọng nhất — job chết im lặng
Thời gian chạy tăng bất thườngDữ liệu tăng hoặc có truy vấn chậm
Tỷ lệ thất bại tăngPhụ thuộc bên ngoài có vấn đề

Cảnh báo đầu tiên đáng nhấn mạnh: job không chạy là loại sự cố im lặng nhất. Không có lỗi, không có gì trong log — chỉ là email không được gửi, và có thể vài ngày sau mới có người hỏi.

Cách cài đơn giản: mỗi lần chạy thành công thì cập nhật một mốc thời gian, và cảnh báo khi mốc đó quá cũ.

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

Danh sách rà soát background job

  • •Job chạy theo lịch không dùng BackgroundService thuần khi có nhiều instance.
  • •Job idempotent, chạy hai lần không gây tác dụng phụ trùng.
  • •Chống trùng dựa vào unique constraint, không chỉ dựa vào khoá.
  • •Ghi bản ghi đã xử lý trước khi thực hiện tác dụng phụ.
  • •Múi giờ của lịch chạy được khai báo tường minh.
  • •Dữ liệu lưu theo UTC, chỉ đổi ở tầng hiển thị.
  • •Có cảnh báo khi job không chạy trong khoảng dự kiến.
  • •Có giám sát thời gian chạy và tỷ lệ thất bại.

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

Bài 1 — Tái hiện sự cố​

Chạy hai instance có cùng BackgroundService theo lịch và đếm số lần công việc được thực hiện.

Tiêu chí hoàn thành: bạn giải thích được vì sao lỗi này không thể phát hiện trên máy dev, và nêu được ba thay đổi hạ tầng làm nó xuất hiện.

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

Gợi ý. Máy dev chạy mấy instance?

Lời giải:

public class GuiEmailHangNgayJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var bayGio = DateTime.Now;
var lanToi = bayGio.Date.AddDays(bayGio.Hour >= 8 ? 1 : 0).AddHours(8);
await Task.Delay(lanToi - bayGio, ct);

var khachHang = await LayKhachHangCanGuiAsync(ct);
foreach (var kh in khachHang)
await _emailSender.GuiAsync(kh.Email, "Bản tin hàng ngày", NoiDung(kh));

_logger.LogInformation("Đã gửi {SoLuong} email", khachHang.Count);
}
}
}
dotnet run --urls http://localhost:5001 &
dotnet run --urls http://localhost:5002 &
[instance-1] 08:00:00  Đã gửi 12000 email
[instance-2] 08:00:00 Đã gửi 12000 email

24.000 email cho 12.000 khách hàng. Với ba instance, 36.000.

Vì sao lỗi này không thể phát hiện trên máy dev. Vì điều kiện gây lỗi — nhiều instance cùng chạy — không tồn tại trên máy dev:

Máy dev:          dotnet run      -> 1 tiến trình
Unit test: -> 0 tiến trình chạy BackgroundService
Integration test: WebApplicationFactory -> 1 tiến trình
Docker Compose: thường 1 replica
Production: 3–10 pod

Và BackgroundService không có gì sai khi chạy một mình. Code hoàn toàn đúng, dễ đọc, không có race condition nội bộ, không ném exception. Nó chỉ sai khi có bản sao thứ hai của chính nó, và bản sao đó do hạ tầng tạo ra chứ không do code.

Đây là một loại lỗi đặc biệt: lỗi nằm ở giả định về môi trường triển khai, không nằm ở code. Không có công cụ phân tích tĩnh nào phát hiện được, và không có test nào trong quy trình thông thường tái hiện được.

Điều làm nó tệ hơn: lỗi thường xuất hiện sau khi mọi thứ đã chạy tốt nhiều tháng. Hệ thống ban đầu triển khai một instance, chạy ổn định, rồi một ngày có người scale lên hai — vì lưu lượng tăng, hoặc vì bật rolling update, hoặc vì thêm một vùng nữa. Không ai nghĩ tới job nền lúc đó.

Ba thay đổi hạ tầng làm nó xuất hiện:

1. Scale lên nhiều replica. Rõ ràng nhất:

spec:
replicas: 3 # từ 1 lên 3

2. Bật rolling update — và đây là trường hợp bất ngờ nhất. Kể cả với replicas: 1, trong lúc triển khai có hai pod cùng sống:

strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # pod mới lên TRƯỚC khi pod cũ xuống
maxUnavailable: 0
t=0    pod cũ đang chạy
t=1 pod mới khởi động -> HAI pod cùng chạy trong 30–60 giây
t=2 pod cũ nhận SIGTERM

Nếu job chạy đúng trong cửa sổ đó, nó chạy hai lần. Với job hằng ngày lúc 8 giờ và một lần deploy lúc 8 giờ, bạn có một sự cố — và nó không lặp lại ở lần deploy sau, nên gần như không thể chẩn đoán.

3. Triển khai nhiều vùng hoặc blue-green. Hai môi trường cùng trỏ vào một database, mỗi bên chạy job của mình.

Ngoài ra còn có: Kubernetes tự khởi động lại pod sau OOM trong khi pod cũ chưa thoát hẳn, autoscaler tăng replica lúc cao điểm, và một pod bị treo không phản hồi SIGTERM nhưng vẫn chạy được job.

Cách sửa — theo thứ tự nên áp dụng, và nên có cả ba:

1. Job idempotent — lớp nền, không thể bị bỏ qua (bài 2).

2. Khoá phân tán — ngăn chạy song song:

await using var khoa = await _redLock.CreateLockAsync(
"job:email-hang-ngay", TimeSpan.FromMinutes(30), TimeSpan.Zero, TimeSpan.Zero);

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

3. Tách worker ra khỏi API — cách sạch nhất về kiến trúc:

# API: scale theo lưu lượng
apiVersion: apps/v1
kind: Deployment
metadata: { name: crm-api }
spec: { replicas: 5 }

---
# Worker: đúng một, và không rolling update
apiVersion: apps/v1
kind: Deployment
metadata: { name: crm-worker }
spec:
replicas: 1
strategy:
type: Recreate # dừng hẳn pod cũ TRƯỚC khi tạo pod mới

strategy: Recreate là chi tiết quyết định ở đây: nó loại bỏ hẳn cửa sổ hai pod cùng sống trong lúc triển khai. Cái giá là một khoảng gián đoạn ngắn của worker — chấp nhận được, vì job nền không phục vụ request trực tiếp.

Tách worker còn ba lợi ích ngoài chuyện này: scale hai thành phần độc lập, job nặng không ảnh hưởng độ trễ của API, và tài nguyên cấp riêng cho từng loại.

Và một dòng code nên có trong mọi job nền:

_logger.LogInformation("Job {Ten} bắt đầu trên instance {Instance} lúc {Luc:O}",
nameof(GuiEmailHangNgayJob), Environment.MachineName, DateTime.UtcNow);

Không có Environment.MachineName trong log, hai lần chạy trên hai pod trông y hệt hai lần chạy trên một pod — và bạn mất dấu vết duy nhất có thể dẫn tới nguyên nhân.


Bài 2 — Làm job idempotent bằng unique constraint​

Thêm bảng theo dõi với unique constraint, chạy lại bài 1 và xác nhận chỉ thực hiện một lần.

Tiêu chí hoàn thành: bạn chọn được khoá idempotency đúng mức chi tiết, và giải thích được vì sao unique constraint mạnh hơn khoá phân tán.

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

Gợi ý. Khoá idempotency nên định danh "một lần chạy job" hay "một email cho một khách hàng"?

Lời giải — mức thô, một khoá cho cả lần chạy:

public class LanChayJob
{
public int Id { get; set; }
public string Khoa { get; set; } = null!; // unique
public DateTime BatDau { get; set; }
public DateTime? KetThuc { get; set; }
}
builder.Entity<LanChayJob>().HasIndex(x => x.Khoa).IsUnique();
var khoa = $"email-hang-ngay:{DateTime.UtcNow:yyyy-MM-dd}";

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

await GuiEmailChoTatCaAsync(ct);
[instance-1] 08:00:00  Đã gửi 12000 email
[instance-2] 08:00:00 Job email-hang-ngay:2026-09-25 đã chạy hôm nay, bỏ qua

Nhưng mức thô có một lỗ hổng nghiêm trọng: nếu instance 1 chết sau khi gửi 3.000 email, khoá đã tồn tại và không ai gửi 9.000 email còn lại. Job "thành công" theo nghĩa không chạy hai lần, nhưng 9.000 khách hàng không nhận được gì.

Mức chi tiết — một khoá cho mỗi đơn vị công việc:

public class EmailDaGui
{
public int Id { get; set; }
public string Khoa { get; set; } = null!; // unique
public int KhachHangId { get; set; }
public DateTime GuiLuc { get; set; }
}
foreach (var kh in await LayKhachHangCanGuiAsync(ct))
{
var khoa = $"ban-tin:{kh.Id}:{DateTime.UtcNow:yyyy-MM-dd}";

_db.EmailDaGui.Add(new EmailDaGui
{
Khoa = khoa, KhachHangId = kh.Id, GuiLuc = DateTime.UtcNow,
});

try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (LaViPhamUnique(ex))
{
_db.ChangeTracker.Clear();
continue; // khách này đã nhận rồi
}

await _emailSender.GuiAsync(kh.Email, "Bản tin hàng ngày", NoiDung(kh));
}
[instance-1] gửi khách 1..3000, rồi CHẾT
[instance-1 khởi động lại] khách 1..3000 đã có khoá -> bỏ qua
khách 3001..12000 -> gửi
Tổng: đúng 12.000 email, mỗi khách đúng một lần

Chọn mức chi tiết theo hai câu hỏi:

Câu hỏiNếu có
Job có thể chết giữa chừng và cần tiếp tục được không?Mức chi tiết
Mỗi đơn vị công việc có tác dụng phụ ra ngoài không?Mức chi tiết
Job rất nhanh và nguyên tử?Mức thô là đủ
Job chỉ đọc và tổng hợp, không gửi gì ra ngoài?Mức thô là đủ

Quy tắc: mức chi tiết phải bằng mức của tác dụng phụ không thể hoàn tác. Gửi email không thể hoàn tác, và nó xảy ra ở mức từng khách hàng — nên khoá phải ở mức từng khách hàng.

Chi phí: một INSERT cho mỗi đơn vị. Với 12.000 khách, đó là 12.000 lần đi về database. Cải thiện bằng cách chèn hàng loạt trước rồi lọc:

var khachCanGui = await LayKhachHangCanGuiAsync(ct);
var ngay = DateTime.UtcNow.Date;

// Lấy danh sách đã gửi trong MỘT truy vấn
var daGui = await _db.EmailDaGui
.Where(e => e.GuiLuc >= ngay)
.Select(e => e.KhachHangId)
.ToHashSetAsync(ct);

foreach (var kh in khachCanGui.Where(k => !daGui.Contains(k.Id)))
{
// vẫn INSERT trước khi gửi — HashSet chỉ để giảm số lần thử
// ...
}

HashSet là một bộ lọc rẻ; unique constraint vẫn là thứ bảo đảm đúng đắn. Đừng bỏ INSERT đi và chỉ dựa vào HashSet — đó lại là kiểm-tra-rồi-hành-động.

Vì sao unique constraint mạnh hơn khoá phân tán — đây là phần chính của bài:

Khoá phân tán (Redis)Unique constraint (database)
Nơi thực thiRedisChính database chứa dữ liệu
Hết hạn giữa chừngCó — job dài hơn TTL thì khoá tự mởKhông bao giờ
Mất khi hạ tầng lỗiCó — Redis failover, restart, evictKhông
Network partitionCó thể cấp cho hai bênKhông thể
Sống sót khi restart ứng dụngTuỳ TTLCó, vĩnh viễn
Theo dõi được đã làm gìKhôngCó — bảng là bản ghi

Ba dòng đầu là những cách khoá phân tán thất bại trong im lặng:

Khoá TTL 10 phút, job hôm nay mất 12 phút vì dữ liệu tăng
-> phút thứ 10, khoá tự mở
-> instance 2 lấy được khoá và bắt đầu chạy
-> hai instance chạy song song, đúng vấn đề ban đầu

Và bạn không biết điều đó đã xảy ra, vì không có gì ghi lại.

Unique constraint không có khái niệm hết hạn. Một dòng đã được chèn là đã được chèn, vĩnh viễn, và nó được bảo vệ bởi cùng cơ chế bảo đảm tính toàn vẹn của mọi dữ liệu nghiệp vụ khác.

Và nó còn cho bạn một thứ mà khoá không bao giờ cho: bản ghi của những gì đã làm.

-- Hôm nay đã gửi cho ai?
SELECT COUNT(*) FROM EmailDaGui WHERE GuiLuc >= CAST(GETUTCDATE() AS DATE);

-- Khách nào lẽ ra phải nhận mà chưa nhận?
SELECT k.Id, k.Email
FROM KhachHang k
LEFT JOIN EmailDaGui e
ON e.KhachHangId = k.Id AND e.GuiLuc >= CAST(GETUTCDATE() AS DATE)
WHERE k.NhanBanTin = 1 AND e.Id IS NULL;

Câu thứ hai là thứ bạn cần lúc 9 giờ sáng khi có người hỏi "sao tôi không nhận được bản tin?" — và khoá phân tán không trả lời được câu đó.

Cách phối hợp đúng — hai cơ chế cho hai mục đích khác nhau:

// Khoá phân tán: TRÁNH LÃNG PHÍ — hai instance không cùng làm việc vô ích
await using var khoa = await _redLock.CreateLockAsync("job:email", TimeSpan.FromMinutes(30));
if (!khoa.IsAcquired) return;

// Unique constraint: BẢO ĐẢM ĐÚNG ĐẮN — không ai nhận email hai lần
foreach (var kh in khachHang)
{
if (!await DanhDauDaGuiAsync(kh.Id, ct)) continue;
await _emailSender.GuiAsync(kh.Email, ...);
}

Khoá là tối ưu hoá; unique constraint là bảo đảm. Nếu khoá thất bại, bạn tốn thêm chút tài nguyên. Nếu unique constraint thất bại — nó không thất bại.

Đây là cùng một nguyên tắc với cache ở bài 14.3: thành phần tối ưu hoá không bao giờ được là thứ duy nhất giữ cho hệ thống đúng.

Và nhớ dọn bảng:

DELETE FROM EmailDaGui WHERE GuiLuc < DATEADD(DAY, -90, SYSUTCDATETIME());

Giữ đủ lâu để phục vụ việc điều tra, đủ ngắn để bảng không lớn vô hạn. Với dữ liệu dùng cho đối soát hoặc tuân thủ, hãy hỏi bộ phận nghiệp vụ trước khi chọn con số.


Bài 3 — Múi giờ trong container​

Chạy một job cron trong container và ghi lại thời điểm nó thực sự chạy.

Tiêu chí hoàn thành: bạn nêu được ba chỗ múi giờ có thể sai lệch trong một hệ thống container hoá, và biết cách xác minh từng chỗ.

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

Gợi ý. Đồng hồ của container, cấu hình của ứng dụng, và dữ liệu trong database — cả ba đều có múi giờ.

Lời giải:

FROM mcr.microsoft.com/dotnet/aspnet:9.0
# Không đặt TZ
_logger.LogInformation("Job chạy lúc {Now:O}, TZ hệ thống = {Tz}",
DateTime.Now, TimeZoneInfo.Local.Id);
Máy dev:    Job chạy lúc 2026-09-25T08:00:00.0000000+07:00, TZ hệ thống = Asia/Ho_Chi_Minh
Container: Job chạy lúc 2026-09-25T08:00:00.0000000Z, TZ hệ thống = UTC

Cùng dòng log, cùng "8 giờ", hai thời điểm cách nhau bảy tiếng.

Ba chỗ múi giờ có thể sai lệch:

Chỗ 1 — múi giờ của hệ điều hành trong container.

Ảnh .NET chính thức mặc định TZ=UTC. Với ảnh Alpine hoặc một số ảnh slim, gói tzdata không được cài, nên TimeZoneInfo.FindSystemTimeZoneById ném exception:

System.TimeZoneNotFoundException: The time zone ID 'Asia/Ho_Chi_Minh' was not found
on the local computer.

Xác minh:

docker exec <container> date
docker exec <container> cat /etc/timezone
docker exec <container> ls /usr/share/zoneinfo/Asia/Ho_Chi_Minh
FROM mcr.microsoft.com/dotnet/aspnet:9.0
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
ENV TZ=Asia/Ho_Chi_Minh

Với Alpine:

RUN apk add --no-cache tzdata
ENV TZ=Asia/Ho_Chi_Minh

Chỗ 2 — múi giờ mà scheduler dùng.

Kể cả khi TZ của container đúng, scheduler có thể được cấu hình riêng:

// Hangfire: mặc định UTC bất kể TZ của hệ thống
RecurringJob.AddOrUpdate<Job>("bao-cao", j => j.ChayAsync(), "0 8 * * *",
new RecurringJobOptions { TimeZone = mgVietNam });

// Quartz: mặc định giờ hệ thống — khác Hangfire!
.WithCronSchedule("0 0 8 * * ?", x => x.InTimeZone(mgVietNam))

Hai thư viện có hai mặc định khác nhau, và đó là nguồn nhầm lẫn thường xuyên: chuyển từ Hangfire sang Quartz (hoặc ngược lại) mà giữ nguyên chuỗi cron sẽ đổi giờ chạy.

Xác minh — in ra lần chạy kế tiếp lúc khởi động:

var lich = Cronos.CronExpression.Parse("0 8 * * *");
var ke = lich.GetNextOccurrence(DateTimeOffset.UtcNow, _mgVietNam);

_logger.LogInformation("Lần chạy kế tiếp: {Utc:O} UTC = {Vn:O} giờ VN",
ke, TimeZoneInfo.ConvertTime(ke!.Value, _mgVietNam));

Dòng log này ở mỗi lần khởi động là cách rẻ nhất để phát hiện sai lệch — bạn thấy ngay, không phải chờ tới giờ chạy.

Chỗ 3 — múi giờ của dữ liệu trong database.

Kể cả khi job chạy đúng 8 giờ sáng giờ Việt Nam, nó có thể đọc sai dữ liệu:

// SAI — DateTime.Today theo giờ container (UTC)
var homNay = DateTime.Today;
var don = await _db.DonHang.Where(d => d.CreatedAt >= homNay).ToListAsync(ct);
Job chạy 08:00 giờ VN = 01:00 UTC
DateTime.Today ở container UTC = 2026-09-25 00:00 UTC = 07:00 giờ VN ngày 25

-> "hôm nay" bắt đầu từ 07:00 sáng giờ VN
-> đơn hàng từ 00:00 tới 07:00 giờ VN bị TÍNH VÀO NGÀY HÔM TRƯỚC

Báo cáo "doanh số hôm nay" thiếu bảy tiếng đầu ngày, mỗi ngày. Và con số vẫn trông hợp lý, nên không ai phát hiện cho tới khi đối chiếu với sổ sách.

Bản đúng:

var bayGioVn = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _mgVietNam);
var dauNgayVn = bayGioVn.Date;

// Chuyển ranh giới ngày theo giờ VN sang UTC để so với cột lưu UTC
var tu = TimeZoneInfo.ConvertTimeToUtc(dauNgayVn, _mgVietNam);
var den = TimeZoneInfo.ConvertTimeToUtc(dauNgayVn.AddDays(1), _mgVietNam);

var don = await _db.DonHang
.Where(d => d.CreatedAt >= tu && d.CreatedAt < den)
.ToListAsync(ct);

Quy tắc: xác định ranh giới ngày theo giờ nghiệp vụ, rồi chuyển ranh giới đó sang UTC để truy vấn. Đừng lấy ranh giới theo giờ hệ thống rồi hy vọng chúng trùng nhau.

Danh sách xác minh cho một hệ thống container hoá:

// Ghi ra lúc khởi động — bốn dòng, và chúng tiết kiệm nhiều giờ điều tra
_logger.LogInformation("""
Cấu hình thời gian:
TZ biến môi trường: {TzEnv}
TimeZoneInfo.Local: {TzLocal}
DateTime.Now: {Now:O}
DateTime.UtcNow: {UtcNow:O}
Múi giờ nghiệp vụ: {TzNghiepVu}
Giờ nghiệp vụ hiện tại: {GioNghiepVu:O}
""",
Environment.GetEnvironmentVariable("TZ") ?? "(chưa đặt)",
TimeZoneInfo.Local.Id,
DateTime.Now, DateTime.UtcNow,
_mgNghiepVu.Id,
TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _mgNghiepVu));

Và khuyến nghị cuối, đơn giản nhất và hiệu quả nhất:

Đặt TZ=UTC trong mọi container, và khai múi giờ nghiệp vụ tường minh ở mọi chỗ cần nó.

Nghe ngược đời, nhưng nó đúng vì hai lý do:

  1. TZ=UTC loại bỏ mọi khác biệt giữa các môi trường. Không còn chuyện "chạy đúng trên máy dev, sai trên production" — nếu code phụ thuộc vào TZ hệ thống, nó sẽ sai ở cả hai nơi và bạn phát hiện ngay.
  2. Nó buộc mọi chỗ cần giờ địa phương phải khai tường minh. DateTime.Now trở nên rõ ràng là sai, và người đọc code thấy _mgVietNam ở đúng những chỗ nghiệp vụ thật sự cần giờ địa phương.

Kèm theo đó, một quy tắc cấm để không ai vô tình dùng lại:

grep -rn "DateTime\.Now\|DateTime\.Today\|DateTimeOffset\.Now" --include="*.cs" src/

Mỗi kết quả phải được thay bằng DateTime.UtcNow (cho thời điểm) hoặc một phép chuyển tường minh sang múi giờ nghiệp vụ (cho ranh giới ngày và giờ hiển thị). Đưa quy tắc này vào analyzer, và bạn loại bỏ hẳn một nhóm lỗi mà việc kiểm thử thông thường không bao giờ bắt được.

Tự kiểm tra​

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

Vì sao BackgroundService gây job chạy trùng khi scale out?

Vì nó chạy trên mọi instance theo thiết kế. Với công việc xử lý hàng đợi đó là điều bạn muốn, nhưng với công việc chạy theo lịch thì mỗi instance sẽ thực hiện cùng công việc đó.

Vì sao khoá phân tán là lớp thứ nhất chứ không phải lớp duy nhất?

Vì khoá phân tán không an toàn tuyệt đối. Nếu một instance bị GC pause dài hơn TTL thì khoá hết hạn, instance khác lấy được, rồi instance đầu tỉnh dậy và tiếp tục chạy.

Vì sao ghi bản ghi đã xử lý trước khi gửi email?

Vì nếu gửi trước rồi mới ghi, tiến trình chết giữa hai bước sẽ khiến email đã gửi nhưng không có dấu vết, và lần chạy sau gửi lại. Ghi trước thì rủi ro ngược lại là email không được gửi, và điều đó phát hiện được.

Khi nào dùng BackgroundService và khi nào dùng Hangfire?

BackgroundService cho việc chạy liên tục trên mọi instance như outbox dispatcher hay consumer hàng đợi. Hangfire cho việc chạy theo lịch hoặc chạy một lần, vì nó lưu job trong database và tự điều phối giữa các server.

Hangfire có đảm bảo job chỉ chạy một lần không?

Không. Nó đảm bảo at-least-once chứ không phải exactly-once. Khi một server chết giữa chừng, job được giao lại cho server khác và phần đã làm có thể bị làm lại, nên job vẫn cần idempotent.

Vì sao cảnh báo job không chạy lại quan trọng nhất?

Vì đó là loại sự cố im lặng nhất: không có lỗi, không có gì trong log, chỉ là công việc không được thực hiện. Có thể vài ngày sau mới có người hỏi vì sao không nhận được email.

Kết luận​

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

  1. BackgroundService chạy trên mọi instance — đó là thiết kế, không phải lỗi.
  2. Khoá là lớp thứ nhất, idempotency là lớp đảm bảo dựa vào unique constraint.
  3. Cảnh báo khi job không chạy — đó là loại sự cố im lặng nhất.

Tham khảo​

Điều hướng​