EF Core là ORM mặc định trong hệ sinh thái .NET, và phải thừa nhận rằng nó làm rất tốt việc giúp dev viết code nhanh mà không cần đụng đến SQL. Nhưng cái giá phải trả cho sự tiện lợi đó là performance — nếu bạn không hiểu chuyện gì đang xảy ra phía dưới.
Mình đã từng ngồi debug một API response time 4 giây, cuối cùng phát hiện nguyên nhân là một cái foreach tưởng chừng vô hại đang âm thầm bắn ra hơn 200 query vào database. Đó là lúc mình bắt đầu nghiêm túc với EF Core performance.
Bài viết này tập trung vào ba vấn đề phổ biến nhất: N+1 Query, Change Tracking, và Compiled Query. Không phải lý thuyết suông — toàn bộ đều từ những bug thực tế mình đã gặp.
N+1 Query — kẻ giết chết API mà không ai hay
Đây là performance trap số một của mọi ORM, không riêng gì EF Core. Và nó nguy hiểm ở chỗ: code trông hoàn toàn bình thường, chạy test với 5-10 record thì nhanh vèo, nhưng lên production với vài nghìn record thì sập.
Vấn đề
Giả sử bạn có model Order và OrderItem. Bạn muốn lấy danh sách đơn hàng kèm theo chi tiết:
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine($"Order {order.Id}:");
foreach (var item in order.Items) // đây là vấn đề
{
Console.WriteLine($" - {item.ProductName}");
}
}
Nhìn qua thì không có gì sai. Nhưng nếu bạn bật logging lên, đây là những gì EF Core thực sự làm:
-- Query 1: lấy tất cả orders
SELECT * FROM Orders;
-- Query 2: lấy items của order 1
SELECT * FROM OrderItems WHERE OrderId = 1;
-- Query 3: lấy items của order 2
SELECT * FROM OrderItems WHERE OrderId = 2;
-- Query 4: lấy items của order 3
SELECT * FROM OrderItems WHERE OrderId = 3;
-- ... lặp lại cho MỖI order
100 đơn hàng = 1 query lấy orders + 100 query lấy items = 101 query. Đó là lý do nó được gọi là N+1. Mỗi query riêng lẻ có thể chỉ mất 5-10ms, nhưng nhân lên 100 lần cộng thêm network latency thì dễ dàng lên đến vài giây.
Giải pháp: Eager Loading với Include
var orders = await db.Orders
.Include(o => o.Items)
.ToListAsync();
// giờ chỉ có 1 query (hoặc 2 query với split query)
foreach (var order in orders)
{
foreach (var item in order.Items) // Items đã được load sẵn
{
Console.WriteLine($" - {item.ProductName}");
}
}
Với .Include(), EF Core sẽ dùng JOIN hoặc một query phụ để load tất cả Items cùng lúc với Orders. Từ 101 query xuống còn 1-2 query.
Khi Include cũng không đủ
Có một cái bẫy khác: include quá nhiều navigation property sẽ tạo ra Cartesian Explosion — kết quả JOIN phình to gấp bội vì tổ hợp các bảng.
// Cartesian explosion risk
var orders = await db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.ShippingHistory)
.ToListAsync();
Nếu một order có 10 items, 3 payments, 5 shipping records thì mỗi order sẽ trả về 10 × 3 × 5 = 150 row trong result set. Nhân với 100 orders = 15.000 row cho dữ liệu mà thực tế chỉ có vài trăm record.
Giải pháp là dùng Split Query — từ EF Core 5.0:
var orders = await db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.ShippingHistory)
.AsSplitQuery() // mỗi Include thành 1 query riêng
.ToListAsync();
Thay vì một query JOIN khổng lồ, EF Core sẽ bắn ra nhiều query nhỏ riêng biệt rồi ghép kết quả lại ở phía application. Trade-off là nhiều round-trip hơn đến database, nhưng tổng data transfer nhỏ hơn đáng kể.
Giải pháp tốt hơn: Projection
Trong nhiều trường hợp, bạn không cần load toàn bộ entity. Bạn chỉ cần vài field để hiển thị. Lúc này Select là lựa chọn tối ưu nhất:
var orderSummaries = await db.Orders
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
CustomerName = o.Customer.Name,
TotalAmount = o.Items.Sum(i => i.Price * i.Quantity),
ItemCount = o.Items.Count()
})
.ToListAsync();
Query sinh ra sẽ chỉ SELECT đúng những column cần thiết, có SUM và COUNT chạy trên database chứ không pull hàng nghìn row về application để tính. Đây là cách mình recommend cho mọi API endpoint trả về danh sách.
Change Tracking — tốn RAM hơn bạn tưởng
Mặc định, mỗi khi bạn query dữ liệu qua EF Core, tất cả entity trả về đều được track bởi ChangeTracker. Nghĩa là EF Core giữ một bản copy của mỗi entity trong memory, so sánh nó với bản gốc mỗi khi bạn gọi SaveChanges() để biết field nào thay đổi.
Cơ chế này cần thiết khi bạn muốn update dữ liệu. Nhưng nếu bạn chỉ đọc — hiển thị danh sách, trả về API response, xuất report — thì tracking hoàn toàn là overhead vô ích.
Đo lường impact thực tế
Mình đã từng benchmark với một bảng 50.000 record. Kết quả:
Có tracking: ~320ms, ~45MB memory
Không tracking: ~180ms, ~28MB memory
Nhanh gần gấp đôi và tiết kiệm 40% RAM. Với những query lớn hoặc chạy thường xuyên, con số này tích lũy rất nhanh.
AsNoTracking — một dòng thay đổi mọi thứ
// Query đọc — không cần tracking
var products = await db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
// Không thể SaveChanges cho products ở trên
// nhưng bạn đâu cần — chỉ đọc mà
Nếu phần lớn query trong project đều là read-only, bạn có thể set mặc định ở DbContext:
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
// mặc định không track, chỉ track khi cần
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
Khi cần update, bạn chủ động bật tracking lại:
// cần update → bật tracking
var order = await db.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Completed;
await db.SaveChangesAsync();
Pattern này ngược lại với mặc định của EF Core (track tất cả, tắt khi cần), nhưng theo kinh nghiệm mình thì trong hầu hết ứng dụng web, 80-90% query là read-only. Đặt default NoTracking hợp lý hơn.
AsNoTrackingWithIdentityResolution
Có một trường hợp trung gian: bạn không cần track changes, nhưng query có JOIN trả về cùng một entity nhiều lần — ví dụ nhiều orders cùng một customer. Mặc định, AsNoTracking() sẽ tạo nhiều object Customer khác nhau trong memory dù chúng cùng Id.
// tránh duplicate object cho cùng entity
var orders = await db.Orders
.Include(o => o.Customer)
.AsNoTrackingWithIdentityResolution()
.ToListAsync();
// order1.Customer và order2.Customer (cùng CustomerId)
// sẽ reference đến CÙNG MỘT object trong memory
Nhanh hơn tracking đầy đủ, tiết kiệm memory hơn AsNoTracking() thuần khi có nhiều entity trùng lặp.
Compiled Query — cache execution plan
Mỗi lần bạn thực thi một LINQ query, EF Core phải làm một loạt bước: parse expression tree, generate SQL, tạo parameter, build command. Với những query đơn giản thì overhead không đáng kể, nhưng với query phức tạp chạy hàng nghìn lần mỗi phút, nó cộng dồn.
Compiled Query cho phép bạn "biên dịch" query một lần và tái sử dụng, bỏ qua bước parse và generate ở những lần gọi sau.
Cú pháp
public class ProductQueries
{
// compiled query — chỉ compile 1 lần
private static readonly Func<AppDbContext, decimal, IAsyncEnumerable<Product>>
_getExpensiveProducts =
EF.CompileAsyncQuery(
(AppDbContext db, decimal minPrice) =>
db.Products
.Where(p => p.Price >= minPrice && p.IsActive)
.OrderByDescending(p => p.Price));
public static IAsyncEnumerable<Product> GetExpensiveProducts(
AppDbContext db, decimal minPrice)
{
return _getExpensiveProducts(db, minPrice);
}
}
Sử dụng:
// trong service hoặc controller
var products = new List<Product>();
await foreach (var p in ProductQueries.GetExpensiveProducts(db, 1000m))
{
products.Add(p);
}
Khi nào nên dùng?
Compiled Query không phải silver bullet. Lợi ích chỉ rõ ràng khi:
- Query được gọi rất thường xuyên (hot path) — hàng nghìn lần mỗi phút
- Query có expression tree phức tạp — nhiều Where, Join, GroupBy
- Bạn đã tối ưu hết những thứ khác (N+1, tracking, projection) mà vẫn cần thêm performance
Với query đơn giản gọi vài lần mỗi giây, overhead của việc parse expression tree gần như không đo được. Đừng compiled query tất cả mọi thứ — nó làm code khó đọc hơn và khó maintain hơn.
Từ EF Core 6.0 trở đi, framework cũng đã tự cache compiled query nội bộ ở một mức độ nhất định. Nên lợi ích của việc tự compiled sẽ nhỏ hơn so với EF Core 3.x hay 5.0. Luôn benchmark trước khi quyết định.
Bonus: những thứ nhỏ nhưng ảnh hưởng lớn
Tránh ToList() quá sớm
// ❌ load TẤT CẢ rồi mới filter trên C#
var result = db.Products
.ToList() // 50.000 row load vào memory
.Where(p => p.Price > 100) // filter trên C#, không phải SQL
.Take(10)
.ToList();
// ✅ filter trên database
var result = await db.Products
.Where(p => p.Price > 100) // WHERE trong SQL
.Take(10) // TOP 10 trong SQL
.ToListAsync(); // chỉ load 10 row
Sai lầm này phổ biến hơn bạn tưởng. Đặc biệt khi dev viết helper method nhận vào List<T> thay vì IQueryable<T>, vô tình force evaluation quá sớm.
Dùng Count tối ưu
// ❌ load tất cả rồi đếm
var count = db.Orders.ToList().Count;
// ❌ load tất cả qua IEnumerable rồi đếm
var count = db.Orders.AsEnumerable().Count();
// ✅ đếm trên database
var count = await db.Orders.CountAsync();
Dùng Any() thay vì Count() > 0
// ❌ đếm toàn bộ chỉ để kiểm tra có hay không
if (await db.Orders.CountAsync(o => o.Status == "Pending") > 0)
// ✅ dừng ngay khi tìm thấy 1 record
if (await db.Orders.AnyAsync(o => o.Status == "Pending"))
Any() sinh ra SELECT TOP 1 thay vì SELECT COUNT(*). Với bảng hàng triệu record, sự khác biệt là đáng kể.
Pagination đúng cách
// ❌ skip/take trên collection đã load
var page = db.Products.ToList()
.Skip(100).Take(20).ToList();
// ✅ skip/take trên database
var page = await db.Products
.OrderBy(p => p.Id) // luôn có OrderBy trước Skip
.Skip(100)
.Take(20)
.ToListAsync();
Lưu ý: Skip/Take trong SQL Server sẽ dùng OFFSET...FETCH — nó chậm dần khi offset lớn. Với dataset lớn, cân nhắc dùng keyset pagination (where Id > lastId) thay vì offset pagination.
Công cụ debug query
Tất cả những tối ưu trên đều vô nghĩa nếu bạn không biết query thực tế đang chạy như thế nào. Đây là những công cụ mình dùng hàng ngày:
EF Core Logging. Thêm vào appsettings.Development.json:
{
"Logging": {
"LogLevel": {
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
Mọi SQL query sẽ được in ra console. Đơn giản và hiệu quả.
ToQueryString(). Từ EF Core 5.0, bạn có thể xem SQL mà không cần execute:
var query = db.Orders
.Include(o => o.Items)
.Where(o => o.Status == "Pending");
Console.WriteLine(query.ToQueryString());
// in ra SQL tương ứng — rất tiện để debug
MiniProfiler. Nếu muốn đo chính xác thời gian mỗi query, số lần gọi, và phát hiện duplicate query — MiniProfiler là lựa chọn không thể thiếu cho môi trường development.
Tổng kết
Ba quy tắc mình luôn áp dụng khi làm việc với EF Core:
Thứ nhất, luôn kiểm tra SQL output. Bật logging trong development, review query của mọi endpoint mới trước khi merge PR. Một cái Include thiếu có thể biến API 20ms thành API 2 giây khi data tăng lên.
Thứ hai, mặc định NoTracking, chỉ tracking khi cần update. Đơn giản mà hiệu quả, tiết kiệm cả thời gian lẫn memory.
Thứ ba, ưu tiên projection bằng Select. Đừng load cả entity 20 column khi bạn chỉ cần 4 field để hiển thị. Database làm việc filter và aggregate nhanh hơn application code rất nhiều.
EF Core không chậm. Code dùng EF Core sai cách mới chậm. Hiểu rõ chuyện gì xảy ra phía dưới, và bạn hoàn toàn có thể build ứng dụng performant mà không cần bỏ ORM để viết raw SQL.
Leave a comment
Your email address will not be published. Required fields are marked *