EF Core là công cụ mạnh, nhưng cũng là "mìn" lớn nhất trong .NET project. Nhiều senior viết EF code "chạy đúng" nhưng performance thảm hại, hoặc có bugs ẩn chỉ bùng nổ dưới load.
Mục tiêu
- Hiểu Change Tracking hoạt động thế nào (và khi nào tắt)
- Phát hiện N+1 Query - kẻ giết performance số 1
- Hiểu
SaveChanges()thực sự làm gì - Lazy Loading - tại sao nó là "trap" trong production
- Nắm vững projection vs loading full entity
1) Change Tracking - EF Core Đang Theo Dõi Bạn
csharp// Mỗi khi bạn query, EF Core tạo "snapshot" của entity
var user = await db.Users.FindAsync(1);
// → EF clone giá trị ban đầu của user vào bộ nhớ (snapshot)
// → Giờ nó theo dõi MỌI thay đổi
user.Name = "New Name";
// → EF chưa làm gì cả, chỉ ghi nhận "field Name thay đổi"
await db.SaveChangesAsync();
// → EF so sánh entity HIỆN TẠI vs SNAPSHOT
// → Phát hiện Name đổi → Generate: UPDATE Users SET Name = 'New Name' WHERE Id = 1
// → Gửi SQL
Tracking = Tốn Memory + CPU
csharp// Mỗi entity tracked = EF giữ 2 bản: original + current
// 100 entities × 2 = 200 objects trong memory
// SaveChanges: so sánh 100 cặp → O(n) comparison
// ❌ Query 10,000 products để hiển thị (READ-ONLY)
var products = await db.Products.ToListAsync();
// → EF track 10,000 objects → chậm, tốn memory, và BẠN KHÔNG CẦN track!
// ✅ AsNoTracking cho read-only queries
var products = await db.Products.AsNoTracking().ToListAsync();
// → Không snapshot, không so sánh → nhanh hơn 30-50%
// ✅ .NET 8+: AsNoTrackingWithIdentityResolution
// Như AsNoTracking nhưng vẫn deduplicate entities (cùng ID = cùng instance)
var orders = await db.Orders
.Include(o => o.Customer)
.AsNoTrackingWithIdentityResolution()
.ToListAsync();
Quy tắc đơn giản
Query → chỉ ĐỌC data? → AsNoTracking()
Query → sẽ UPDATE/DELETE? → Tracking (mặc định)
2) N+1 Query - Performance Killer Số 1
csharp// ❌ N+1 Problem: 1 query lấy orders + N queries lấy customer
var orders = await db.Orders.ToListAsync(); // Query 1: SELECT * FROM Orders
foreach (var order in orders)
{
// Mỗi lần access order.Customer → EF lazy load → 1 query PER ORDER!
Console.WriteLine(order.Customer.Name);
// Query 2: SELECT * FROM Customers WHERE Id = 1
// Query 3: SELECT * FROM Customers WHERE Id = 2
// ...
// Query N+1: SELECT * FROM Customers WHERE Id = N
}
// 100 orders = 101 queries! 💀
Cách phát hiện
1. SQL Profiler / EF Core logging → thấy SELECT lặp lại hàng trăm lần
2. MiniProfiler → hiển thị số queries per request
3. Log warning: EF Core có thể log "multiple queries detected"
Fix N+1
csharp// ✅ Fix 1: Eager Loading (Include) - load cùng 1 query
var orders = await db.Orders
.Include(o => o.Customer) // JOIN hoặc separate query
.Include(o => o.Items)
.ThenInclude(i => i.Product) // Nested include
.ToListAsync();
// → 1 query (hoặc 2-3 queries), KHÔNG phải N+1
// ✅ Fix 2: Split Query (tránh cartesian explosion)
var orders = await db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery() // Tách thành nhiều queries thay vì 1 JOIN lớn
.ToListAsync();
// → Tránh: 100 orders × 10 items × 5 payments = 5000 rows!
// → Split: Query 1 (orders), Query 2 (items), Query 3 (payments)
// ✅ Fix 3: Projection - chỉ lấy data cần thiết (BEST PERFORMANCE)
var orders = await db.Orders
.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // EF tự JOIN, chỉ lấy Name
ItemCount = o.Items.Count, // EF translate sang COUNT()
Total = o.Items.Sum(i => i.Price * i.Quantity) // SUM tại DB!
})
.ToListAsync();
// → 1 query, chỉ lấy columns cần thiết, KHÔNG load full entities
3) Lazy Loading - Trap Trong Production
csharp// Lazy Loading: EF auto-load navigation property khi bạn access
// Nghe tiện, nhưng...
var order = await db.Orders.FindAsync(1);
var customerName = order.Customer.Name; // ← Lazy load: gửi query tại đây!
// Vấn đề 1: N+1 (đã nói ở trên)
// Vấn đề 2: Performance không predictable (query ẩn)
// Vấn đề 3: Không hoạt động sau khi DbContext disposed
// Vấn đề 4: Serialization (JSON) → lazy load TẤT CẢ navigation properties!
// ❌ Serialize entity → lazy load MỌI relationship
return Ok(order); // JSON serializer access order.Customer, order.Items, order.Items[0].Product...
// → Hàng chục queries bắn ra, có thể load TOÀN BỘ database!
Recommendation
Production code:
❌ Tắt Lazy Loading
✅ Dùng Eager Loading (Include) hoặc Projection (Select)
✅ Dùng DTOs, không trả Entity ra API response
4) SaveChanges - Chuyện Gì Thực Sự Xảy Ra?
csharp// SaveChanges() làm 3 việc:
// 1. DetectChanges: scan tất cả tracked entities, so sánh vs snapshot
// 2. Generate SQL: tạo INSERT/UPDATE/DELETE statements
// 3. Execute: gửi SQL đến database TRONG 1 TRANSACTION
var user = await db.Users.FindAsync(1);
user.Name = "New Name";
var product = new Product { Name = "Widget" };
db.Products.Add(product);
db.Orders.Remove(someOrder);
await db.SaveChangesAsync();
// → 1 transaction:
// UPDATE Users SET Name = 'New Name' WHERE Id = 1
// INSERT INTO Products (Name) VALUES ('Widget')
// DELETE FROM Orders WHERE Id = 42
// → Tất cả thành công hoặc tất cả rollback
Sai lầm: SaveChanges trong loop
csharp// ❌ SaveChanges trong loop = N transactions, N round-trips
foreach (var item in items)
{
db.Products.Add(new Product { Name = item.Name });
await db.SaveChangesAsync(); // Round-trip đến DB mỗi lần! 💀
}
// 1000 items = 1000 round-trips = chậm kinh hoàng
// ✅ Batch: add tất cả, save 1 lần
foreach (var item in items)
{
db.Products.Add(new Product { Name = item.Name });
}
await db.SaveChangesAsync(); // 1 round-trip, 1 transaction
// EF Core 7+ tự động batch INSERT statements
// ✅ Bulk operations (EF Core 7+)
await db.Products
.Where(p => p.IsDiscontinued)
.ExecuteDeleteAsync(); // 1 SQL DELETE, không load entities
await db.Products
.Where(p => p.Category == "Electronics")
.ExecuteUpdateAsync(p => p
.SetProperty(x => x.Price, x => x.Price * 1.1m));
// 1 SQL UPDATE cho tất cả matching rows
5) Global Query Filters - Con Dao Hai Lưỡi
csharp// Soft delete pattern
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
// Mọi query tự động thêm: WHERE IsDeleted = 0
var products = await db.Products.ToListAsync();
// → SELECT * FROM Products WHERE IsDeleted = 0 ✅
// ⚠️ Cẩn thận: INCLUDE cũng bị filter!
var order = await db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == 1);
// → Items bị soft-delete sẽ KHÔNG được load!
// → Total tính sai nếu dựa vào Items.Sum()
// Bypass filter khi cần
var allProducts = await db.Products.IgnoreQueryFilters().ToListAsync();
6) DbContext Lifetime & Thread Safety
csharp// DbContext là NOT thread-safe!
// KHÔNG dùng 1 DbContext trên nhiều threads cùng lúc
// ❌ Shared DbContext across tasks
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var task1 = Task.Run(() => db.Users.ToListAsync()); // Thread A
var task2 = Task.Run(() => db.Orders.ToListAsync()); // Thread B
// → Race condition, corrupt internal state → random crashes!
// ✅ Mỗi thread/task dùng riêng DbContext
var task1 = Task.Run(async () =>
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Users.ToListAsync();
});
Master Checklist ✅
□ Read-only queries dùng AsNoTracking()?
□ Không có N+1? (Include hoặc Projection)
□ Không dùng Lazy Loading trong production?
□ SaveChanges() GỌI 1 LẦN, ngoài loop?
□ Bulk operations thay load → modify → save?
□ API response trả DTO, không trả Entity?
□ DbContext KHÔNG shared across threads?
□ Global filters không gây side effects trong Includes?
Common Mistakes Recap
| Mistake | Hậu quả | Fix |
|---|---|---|
| Quên AsNoTracking | Chậm 30-50% cho read queries | AsNoTracking() |
| N+1 queries | 100x queries, timeout | Include hoặc Select |
| Lazy Loading on | Unpredictable queries, serialize cả DB | Tắt, dùng eager/projection |
| SaveChanges trong loop | N round-trips | Save 1 lần cuối |
| Return Entity từ API | Circular ref, over-fetching, security leak | Return DTO |
| DbContext cross-thread | Corrupt state, random crash | 1 DbContext per thread/scope |
Tư duy master: Mỗi khi viết LINQ query, hãy nghĩ: "SQL nào sẽ được generate? Bao nhiêu queries? Load bao nhiêu data?" - EF logs SQL trong Development (
optionsBuilder.LogTo(Console.WriteLine)) - LUÔN kiểm tra.