Có một kiểu bug trong .NET mà mình gặp đủ nhiều để gọi nó là kinh điển: app chạy êm, không exception, log không một dòng đỏ, nhưng dữ liệu trả về sai một cách khó hiểu. Sửa trong DB rồi mà API vẫn trả giá trị cũ. Một user thoáng thấy dữ liệu của user khác. Restart service thì hết, vài tiếng sau tái diễn.
Lần gần nhất, thủ phạm là một dòng đăng ký DI: service đáng lẽ Scoped bị nhốt vô một Singleton. Tài liệu Microsoft gọi đúng tên hiện tượng này là captive dependency — service bị giữ làm con tin trong object sống lâu hơn nó rất nhiều.
Chuyện bắt đầu từ một dòng AddSingleton
Đoạn code gây lỗi trông vầy, và nếu bạn thấy nó hợp lý thì mình với bạn cùng phe — hồi đó mình cũng thấy hợp lý:
builder.Services.AddDbContext<AppDbContext>(o => o.UseNpgsql(cs));
builder.Services.AddSingleton<IReportCache, ReportCache>();
public class ReportCache : IReportCache
{
private readonly AppDbContext _db; // Scoped, bị nhốt ở đây
private readonly Dictionary<string, Report> _cache = new();
public ReportCache(AppDbContext db) => _db = db; // tiêm đúng một lần
public async Task<Report?> GetAsync(int id)
{
if (_cache.TryGetValue($"r{id}", out var hit)) return hit;
var report = await _db.Reports.FindAsync(id); // change tracker dùng chung
_cache[$"r{id}"] = report!;
return report;
}
}
Ý định ban đầu tốt: cache báo cáo cho nhanh. Nhưng AddSingleton bảo container tạo ReportCache đúng một lần cho cả vòng đời app — muốn tạo nó, container resolve AppDbContext đúng một lần rồi giữ luôn trong field _db.
Từ giây phút đó, mọi request dùng chung một DbContext. Không scope nào dispose nó. Change tracker phình dần theo số entity đã đọc, và dữ liệu cũ nằm sẵn trong tracker nên FindAsync trả bản cũ thay vì query lại. Còn cái Dictionary kia không thread-safe, hai request ghi cùng lúc là có chuyện.
Container sống theo scope, không sống theo ý mình
Ba lifetime có ba cách chết khác nhau:
- Transient — instance mới mỗi lần resolve, không ai giữ thì GC dọn. Nhưng tiêm Transient vô Singleton cũng bị nhốt y vậy: object do Singleton giữ thì sống theo Singleton.
- Scoped — một instance cho mỗi scope, thường là mỗi HTTP request, dispose khi request xong.
- Singleton — một instance cho cả process, tạo đúng một lần.
Ảnh: Ba kệ với ba thứ tuổi thọ khác nhau — tia lửa vụt tắt, vòng ruy băng vài nhịp, cột đá đứng mãi. Ba lifetime của DI cũng chia đời sống y như vậy, và trộn nhầm là bắt đầu rắc rối. — ảnh minh hoạ do AI tạo (Grok Imagine)
Luật của container chỉ có một chiều: Singleton không được phụ thuộc Scoped. Chiều ngược lại thì vô tư, vì object sống ngắn giữ object sống dài. Còn chiều kia là kéo dài tuổi thọ của thứ không được thiết kế để sống lâu — như DbContext, một unit of work có state: change tracker, connection, transaction.
Vì sao Development ném lỗi còn Production thì im
Ở Development, container mặc định bật ValidateScopes. App không khởi động nổi, và bạn nhận đúng câu này: Cannot consume scoped service 'AppDbContext' from singleton 'IReportCache'.
Nhưng cờ đó mặc định tắt ở các môi trường khác. Nên đúng đoạn code làm dev không build nổi lại chạy ngon trên Production — khởi động sạch, health check xanh, bug nằm im chờ traffic đủ đông.
Bật lại một lần cho luôn:
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = true;
o.ValidateOnBuild = true;
});
Chi phí chỉ nằm ở lúc khởi động, đổi lại container kiểm tra toàn bộ graph ngay khi build. Mình bật ở mọi môi trường, kể cả Production, từ sau lần đó.
Ba dấu hiệu nhận ra khi review
Ảnh: Kính lúp soi sơ đồ dây và lộ ra một đường ống ẩn nối nhiều phòng về chung một van — đúng cách một Singleton âm thầm chia sẻ tài nguyên cho mọi request. — ảnh minh hoạ do AI tạo (Grok Imagine)
- Class Singleton mà có field state —
Dictionary,List, biến đếm, cache tự chế. State dùng chung thì phải thread-safe và phải có chiến lược hết hạn. - Constructor nhận
DbContext,IHttpContextAccessorhayIUserContext— ba thứ này gắn chặt với request. Nhận chúng trong Singleton gần như luôn là captive dependency. - Triệu chứng "restart là hết" — dữ liệu cũ, dữ liệu lẫn giữa user, memory tăng theo thời gian uptime. Đây là chữ ký của object sống quá lâu.
Ba cách sửa, chọn theo nhu cầu
Cách một: nếu Singleton chỉ cần đọc dữ liệu theo yêu cầu, đừng tiêm DbContext, tiêm IServiceScopeFactory rồi tự mở scope ngắn.
public class ReportCache : IReportCache
{
private readonly IServiceScopeFactory _scopes;
public ReportCache(IServiceScopeFactory scopes) => _scopes = scopes;
public async Task<Report?> GetAsync(int id)
{
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Reports.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id);
}
}
Cách hai gọn hơn cho đúng bài toán DbContext: đổi sang AddDbContextFactory, rồi tiêm IDbContextFactory<AppDbContext>. Factory là Singleton, còn DbContext do bạn tạo ra và tự dispose trong using.
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseNpgsql(cs));
// trong class: IDbContextFactory<AppDbContext> _factory;
await using var db = await _factory.CreateDbContextAsync();
Cách ba: nếu state thật sự phải theo request thì đừng cố giữ Singleton — đổi ReportCache sang Scoped, hoặc thay bằng IMemoryCache (thread-safe, có TTL), hoặc Redis nếu chạy nhiều instance.
Ảnh: Mỗi phòng kín giữ chìa khoá và ánh sáng riêng thay vì dùng chung một van — hình dung cách chia scope đúng, nơi mỗi request có vòng đời riêng của nó. — ảnh minh hoạ do AI tạo (Grok Imagine)
Chốt
Container không tự cứu bạn ở Production. Nó làm đúng những gì bạn khai báo, kể cả khi khai báo đó kéo dài tuổi thọ của một object không được thiết kế cho việc đó.
Ba việc mình làm cố định trong mọi project .NET từ đó: bật ValidateScopes + ValidateOnBuild ở mọi môi trường, grep AddSingleton xem constructor của nó nhận gì, và coi IHttpContextAccessor trong Singleton là cờ đỏ cho tới khi chứng minh được ngược lại.
Bạn thử mở Program.cs của project đang chạy xem — có dòng AddSingleton nào nhận DbContext trong constructor không?
📋 Phụ lục thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| Captive dependency | Service sống ngắn bị object sống dài giữ lại, nên tồn tại lâu hơn dự định |
| Scope | Vùng đời sống, thường tương ứng một HTTP request trong ASP.NET Core |
ValidateScopes |
Cờ buộc container ném lỗi khi Singleton tiêm Scoped; mặc định chỉ bật ở Development |
ValidateOnBuild |
Kiểm tra toàn bộ graph DI ngay khi build container thay vì lúc resolve |
IServiceScopeFactory |
Factory tạo scope thủ công, để code dài hạn mở scope ngắn hạn |
IDbContextFactory<T> |
Factory tạo DbContext mới mỗi lần gọi, tự dispose theo using |
IHttpContextAccessor |
Cầu nối tới HttpContext của request hiện tại — chỉ đúng trong scope request |
