Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi
Chọn lifetime là chọn thời điểm container tạo instance và thời điểm nó dispose. Transient tạo mới mỗi lần resolve, Scoped một instance cho mỗi HTTP request, Singleton một instance cho cả vòng đời process. Quy tắc quan trọng nhất: service sống lâu không được nhận trực tiếp service sống ngắn. Inject một repository Scoped vào một Singleton là bạn vừa giữ DbContext đó sống tới lúc app tắt. Cách sửa là inject IServiceScopeFactory rồi tự mở scope khi cần.
Dòng đăng ký gây ra lỗi này trông vô hại tới mức review không ai dừng lại:
builder.Services.AddDbContext<CrmDbContext>(o => o.UseSqlServer(conn)); // Scoped
builder.Services.AddScoped<ICustomerRepository, EfCustomerRepository>();
builder.Services.AddSingleton<CustomerCacheRefresher>(); // ← thuốc nổ
App qua được CI và lên staging. Lên môi trường có tải, log bắt đầu xuất hiện A second operation was started on this context instance before a previous operation completed, hoặc Cannot access a disposed context instance, hoặc tệ hơn là không có exception nào cả mà chỉ có dữ liệu cũ trả về mãi không đổi. Bài này giải thích cơ chế đằng sau, và code của cả bản hỏng lẫn bản sửa.
Ba lifetime thực sự khác nhau ở chỗ nào
Container không quản lý "kiểu" service, nó quản lý instance và thời điểm dispose instance đó. Ba lifetime chỉ là ba câu trả lời cho câu hỏi "lần resolve tiếp theo có được dùng lại instance cũ không".
| Lifetime | Tạo instance mới khi nào | Được dispose khi nào | Hợp với loại service nào |
|---|---|---|---|
Transient | Mỗi lần resolve | Khi scope resolve nó bị dispose | Stateless, rẻ, không giữ tài nguyên |
Scoped | Lần đầu resolve trong mỗi scope | Khi scope kết thúc | DbContext, Unit of Work, repository, ngữ cảnh người dùng |
Singleton | Lần đầu resolve trong cả app | Khi host shutdown | Cache in-memory, cấu hình đã parse, factory |
Có một chi tiết hay bị bỏ qua: container chịu trách nhiệm dispose những instance nó tạo ra mà implement IDisposable. Mỗi scope giữ một danh sách các disposable đã tạo trong scope đó. Nghĩa là một service Transient implement IDisposable mà bạn resolve từ root provider sẽ nằm trong danh sách của root scope và chỉ được giải phóng lúc app tắt — Transient không hề đồng nghĩa với "được thu hồi ngay". Bảng so sánh chi tiết hơn nằm ở 7.5 — Service Lifetimes.
"Scope" trong một HTTP request là cái gì
Trong ASP.NET Core, scope không phải khái niệm trừu tượng mà là một object có thật. Với mỗi request đi vào, hạ tầng tạo một IServiceScope, gắn ServiceProvider của scope đó vào HttpContext.RequestServices, chạy toàn bộ pipeline middleware, rồi dispose scope khi response đã hoàn tất.
Hệ quả trực tiếp: trong cùng một request, controller, service nghiệp vụ và hai repository khác nhau đều nhận cùng một CrmDbContext. Đó chính là thứ làm cho unit of work hoạt động — bạn sửa entity ở repository A, thêm entity ở repository B, rồi gọi SaveChangesAsync một lần và cả hai nằm chung một transaction. Lý do DbContext được đăng ký Scoped mặc định nằm ở đây, chứ không phải vì nó "nặng" (13.3 — DbContext và entity configuration).
Hệ quả thứ hai quan trọng hơn: ngoài request thì không có scope nào tự sinh ra. Background job, hosted service, consumer message queue, code chạy lúc startup — tất cả đều sống ở root scope. Nếu ở đó bạn cần một service Scoped, bạn phải tự tạo scope.
Captive dependency: vì sao Singleton nuốt luôn DbContext
Đây là bản hỏng, rất phổ biến vì nó biên dịch được và chạy được:
// BUG: Singleton nhận thẳng một dependency Scoped
public sealed class CustomerCacheRefresher
{
private readonly ICustomerRepository _repo; // Scoped!
private readonly ConcurrentDictionary<Guid, CustomerDto> _cache = new();
public CustomerCacheRefresher(ICustomerRepository repo) => _repo = repo;
public async Task RefreshAsync(Guid tenantId, CancellationToken ct)
{
foreach (var c in await _repo.GetActiveAsync(tenantId, ct))
_cache[c.Id] = c;
}
}
Container resolve CustomerCacheRefresher đúng một lần, vào lần đầu có ai đó cần tới nó. Tại đúng khoảnh khắc ấy nó dựng luôn một EfCustomerRepository kèm một CrmDbContext và nhét vào field. Instance Singleton sống tới lúc app tắt, nên cái DbContext bên trong cũng vậy. Nói cách khác, lifetime hiệu dụng của một dependency bằng lifetime của consumer sống lâu nhất giữ nó. Service Scoped đã bị "bắt giữ" — tên gọi captive dependency đến từ đó.
Vẽ theo trục thời gian thì thấy rõ cái DbContext bị bắt giữ sống dài hơn mọi thứ khác bao nhiêu: