Một trang tóm tắt đơn hàng chạy trong 40 mili giây trên cơ sở dữ liệu phát triển, nhưng mất 11 giây trong môi trường thật. Mã điều khiển trông gọn gàng, truy vấn LINQ có vẻ đúng, các cột cần thiết đều đã có chỉ mục và toàn bộ kiểm thử đều thành công. Thế nhưng trang vẫn phản hồi rất chậm.
Nguyên nhân thường là một lỗi quen thuộc nhưng khó nhìn thấy: vấn đề truy vấn N+1.
var orders = await context.Orders
.Where(o => o.OrderDate >= startOfMonth)
.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine($"{order.OrderNumber}: {order.Customer.Name}");
}
Truy vấn đầu tiên tải danh sách đơn hàng. Sau đó, mỗi lần truy cập order.Customer lại có thể tạo thêm một lệnh SELECT. Với 200 đơn hàng, ứng dụng không chạy một truy vấn mà chạy tổng cộng 201 truy vấn.
Giả sử mỗi lượt đi đến cơ sở dữ liệu mất 3 mili giây, 200 lượt tuần tự đã tạo ra 600 mili giây chỉ để chờ mạng, chưa tính thời gian xử lý thật sự của cơ sở dữ liệu. Với 5.000 đơn hàng, cùng đoạn mã sẽ tạo ra 5.001 truy vấn.
N+1 tồn tại lâu trong mã nguồn vì mỗi dòng đều hợp lệ, cơ sở dữ liệu kiểm thử thường có ít dữ liệu và ORM che giấu hàng loạt câu lệnh SQL phía sau thao tác đọc thuộc tính.
Mục lục
Bốn dạng của vấn đề N+1
Nhiều người đồng nhất N+1 với tải lười. Cách hiểu này đúng nhưng chưa đủ. Có bốn dạng phổ biến.
1. Tải lười bên trong vòng lặp
Mỗi lần truy cập navigation property trong vòng lặp lại phát sinh một truy vấn ngầm. Dạng này cần lazy-loading proxy hoặc ILazyLoader mới xảy ra.
2. Vòng lặp truy vấn tường minh
Không cần proxy hay cơ chế ngầm nào cả. Lập trình viên tự viết truy vấn bên trong foreach:
foreach (var row in importedRows)
{
var existing = context.Customers
.FirstOrDefault(c => c.Code == row.Code);
}
Đây vẫn là N+1, nhưng việc tắt tải lười không thể giải quyết vì trong trường hợp này không có tải lười. Cách đúng là chuyển sang xử lý theo tập dữ liệu.
3. Bão truy vấn do bộ tuần tự hóa
Nếu API trả thẳng entity đang được theo dõi trong khi tải lười được bật, bộ tuần tự hóa JSON có thể đi qua từng navigation property để tạo phản hồi. Các truy vấn phát sinh sau khi phương thức điều khiển đã hoàn tất, khiến lớp này thường bị bỏ sót khi lập hồ sơ hiệu năng.
4. N+1 ở phía ghi
Gọi SaveChanges một lần cho từng entity trong vòng lặp cũng là cùng một lỗi, chỉ khác là nó tác động đến thao tác chèn và cập nhật thay vì đọc dữ liệu.
Điểm chung của cả bốn dạng là: nhiều lượt đi đến cơ sở dữ liệu theo từng phần tử, trong khi một thao tác theo tập dữ liệu có thể hoàn thành công việc.
Phát hiện: biến phần vô hình thành dữ liệu đo được
Đừng chẩn đoán N+1 bằng cảm giác. Hãy đếm số truy vấn. Một trang hoặc tạo ra số truy vấn có giới hạn, hoặc không.
Ghi log SQL
optionsBuilder
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
Chỉ bật EnableSensitiveDataLogging trong môi trường phát triển. Khi chạy trang, nếu thấy cùng một câu SELECT đến bảng Customers lặp lại nhiều lần, chỉ thay đổi giá trị tham số, bạn đã tìm thấy N+1.
Đếm truy vấn bằng interceptor
public sealed class QueryCountInterceptor : DbCommandInterceptor
{
private int _count;
public int Count => _count;
public void Reset() => Interlocked.Exchange(ref _count, 0);
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
Interlocked.Increment(ref _count);
return base.ReaderExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _count);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
Sau đó đưa bộ đếm vào kiểm thử tích hợp:
[Fact]
public async Task Order_summary_stays_within_query_budget()
{
_queryCounter.Reset();
await _client.GetAsync("/orders/summary");
Assert.True(
_queryCounter.Count <= 10,
$"Order summary issued {_queryCounter.Count} queries; budget is 10.");
}
Đây là một trong những thói quen có giá trị nhất để ngăn N+1 quay trở lại. Lỗi từng âm thầm tạo ra 200 truy vấn nay sẽ làm hỏng quá trình xây dựng ngay khi có người đưa nó trở lại.
Biến tải lười thành lỗi
optionsBuilder.ConfigureWarnings(warnings =>
warnings.Throw(
CoreEventId.NavigationLazyLoading,
CoreEventId.DetachedLazyLoadingWarning));
Trong môi trường cấm tải lười, mọi lần tải lười sẽ lập tức gây lỗi thay vì âm thầm làm chậm ứng dụng.
Gắn nhãn truy vấn
var orders = await context.Orders
.TagWith("OrderSummary: monthly customer roll-up")
.Where(o => o.OrderDate >= startOfMonth)
.ToListAsync();
Nhãn được đưa vào SQL dưới dạng chú thích. Khi truy vấn xuất hiện trong SQL Server Extended Events hoặc Query Store, bạn có thể tìm ngược lại đúng vị trí mã nguồn đã tạo ra nó.
Cấp độ khắc phục 1: Eager Loading với Include
Với dạng tải lười trong vòng lặp, hãy nói rõ dữ liệu liên quan mà đường xử lý cần:
var orders = await context.Orders
.Where(o => o.OrderDate >= startOfMonth)
.Include(o => o.Customer)
.ToListAsync();
Một truy vấn có JOIN thay thế cho 201 truy vấn. Khi vòng lặp chạy, tên khách hàng đã có sẵn.
Có thể lọc collection khi tải:
var orders = await context.Orders
.Include(o => o.Items.Where(i => i.IsActive))
.ToListAsync();
Với quan hệ gần như luôn cần, có thể dùng AutoInclude:
modelBuilder.Entity<Order>()
.Navigation(o => o.Customer)
.AutoInclude();
var orders = await context.Orders
.IgnoreAutoIncludes()
.ToListAsync();
AutoInclude tiện lợi nhưng cũng che giấu chi phí tải dữ liệu. Một truy vấn trông đơn giản có thể kéo thêm nhiều bảng liên quan. Khi cần entity thuần, hãy dùng IgnoreAutoIncludes.
Include cũng có giới hạn: nó tải toàn bộ entity. Nếu trang chỉ cần ba cột từ đơn hàng và một cột từ khách hàng, nhiều dữ liệu không cần thiết vẫn được truyền qua mạng và được theo dõi trong bộ nhớ.
Cấp độ khắc phục 2: Cartesian Explosion và Split Query
Include giải quyết N+1, nhưng dùng bất cẩn có thể tạo ra vấn đề khác.
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.ToListAsync();
Một đơn hàng có 10 mặt hàng và 5 khoản thanh toán có thể tạo ra 50 dòng kết quả do tích Descartes. Các cột của đơn hàng bị lặp lại trên tất cả các dòng. Với 1.000 đơn hàng, ứng dụng có thể nhận 50.000 dòng chỉ để biểu diễn 15.000 bản ghi liên quan.
Trong trường hợp này, dùng truy vấn tách:
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.AsSplitQuery()
.ToListAsync();
Mỗi collection được tải bằng một tập kết quả riêng, sau đó EF Core ghép chúng trong bộ nhớ. Đổi lại, ứng dụng tạo nhiều lượt đi đến cơ sở dữ liệu hơn. Nếu không có transaction bao quanh, các truy vấn cũng không bảo đảm cùng đọc một ảnh chụp nhất quán.
Có thể đặt mặc định cho toàn ứng dụng:
optionsBuilder.UseSqlServer(
connectionString,
o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
Sau đó dùng AsSplitQuery hoặc AsSingleQuery để ghi đè ở từng truy vấn.
Cấp độ khắc phục 3: Projection, giải pháp mạnh nhất
Include và split query vẫn tải entity. Projection loại entity khỏi bài toán và chỉ lấy đúng các cột cần thiết:
var summary = await context.Orders
.Where(o => o.OrderDate >= startOfMonth)
.Select(o => new OrderSummaryDto
{
OrderNumber = o.OrderNumber,
CustomerName = o.Customer.Name,
Total = o.Total
})
.ToListAsync();
Truy vấn này không tải Customer thành entity, không theo dõi thay đổi và không để lại navigation property cho vòng lặp hoặc bộ tuần tự hóa truy cập. EF Core tạo một câu SQL trả về đúng các cột cần dùng.
Nên đẩy phép tính tổng hợp xuống cơ sở dữ liệu:
var summary = await context.Orders
.Where(o => o.OrderDate >= startOfMonth)
.Select(o => new OrderSummaryDto
{
OrderNumber = o.OrderNumber,
CustomerName = o.Customer.Name,
LineItemCount = o.Items.Count,
Total = o.Items.Sum(i => i.Quantity * i.UnitPrice)
})
.ToListAsync();
Phép đếm và phép tính tổng được thực hiện trên máy chủ. Các mặt hàng không cần truyền về ứng dụng.
Projection cũng có thể lồng nhau:
var orders = await context.Orders
.Select(o => new OrderDto
{
OrderNumber = o.OrderNumber,
Items = o.Items.Select(i => new ItemDto
{
Sku = i.Sku,
Quantity = i.Quantity
}).ToList()
})
.ToListAsync();
Kiểu dữ liệu không phải entity không được theo dõi. Vì vậy không có snapshot, identity resolution hay đồ thị entity chiếm thêm bộ nhớ.
Khi nào dùng explicit loading?
Nếu đã có một entity trong bộ nhớ và chỉ cần tải một quan hệ theo điều kiện, explicit loading là lựa chọn phù hợp:
var order = await context.Orders
.FirstAsync(o => o.Id == id);
if (order.RequiresAudit)
{
await context.Entry(order)
.Collection(o => o.Items)
.LoadAsync();
}
Không đặt đoạn tải này trong vòng lặp. Làm vậy sẽ tự tạo lại N+1.
Có nên tắt lazy loading?
Lazy loading thường được bật bằng proxy:
optionsBuilder
.UseLazyLoadingProxies()
.UseSqlServer(connectionString);
Hoặc thông qua ILazyLoader. Cả hai cách đều biến việc đọc navigation property thành một truy vấn ngầm.
Trong dịch vụ và API, tắt lazy loading thường là lựa chọn an toàn hơn. Khi API trả entity, bộ tuần tự hóa có thể tiếp tục truy cập navigation property sau khi phương thức điều khiển hoàn tất. Cách khắc phục mang tính cấu trúc là trả DTO bằng projection thay vì trả entity.
Lazy loading vẫn hữu ích trong ứng dụng máy tính để bàn có context tồn tại lâu hoặc trong các tập lệnh khám phá dữ liệu. Hãy quyết định dựa trên vòng đời của context, không dựa trên thói quen.
Nếu muốn tắt, hãy bỏ gói proxy hoặc bỏ lời gọi bật proxy, đặt LazyLoadingEnabled thành false và cấu hình cảnh báo tải lười thành lỗi để các trường hợp còn sót lại bị phát hiện ngay.
N+1 khi tra cứu danh sách và giới hạn tham số
Hãy xem một quy trình nhập 5.000 bản ghi:
foreach (var row in importedRows)
{
var existing = context.Customers
.FirstOrDefault(c => c.Code == row.Code);
}
Đây là 5.000 lượt đi đến cơ sở dữ liệu. Cách viết theo tập dữ liệu là:
var codes = importedRows
.Select(r => r.Code)
.ToList();
var existing = await context.Customers
.Where(c => codes.Contains(c.Code))
.ToListAsync();
Với danh sách nhỏ, cách này thường rất tốt. Nhưng EF Core 10 mặc định có thể tạo một tham số vô hướng cho mỗi phần tử. SQL Server có giới hạn 2.100 tham số cho mỗi câu lệnh. Truy vấn với 500 khóa có thể thành công, trong khi truy vấn với 5.000 khóa sẽ phát sinh lỗi thay vì chỉ chậm hơn.
Một cách xử lý là chia danh sách thành các nhóm:
var results = new List<Customer>();
foreach (var chunk in codes.Chunk(2000))
{
var found = await context.Customers
.Where(c => chunk.Contains(c.Code))
.ToListAsync();
results.AddRange(found);
}
Các lựa chọn khác là chuyển lại chế độ tham số hóa collection sang dạng JSON hoặc dùng bảng tạm để nối dữ liệu. Với khóa ghép, chẳng hạn Email và PhoneNumber, việc dùng Contains còn khó hơn. Ghép các cột thành một chuỗi duy nhất có thể làm mất lợi ích của chỉ mục và biến tìm kiếm thành quét toàn bảng.
Entity Framework Extensions phù hợp ở đâu?
Entity Framework Extensions không phải giải pháp cho N+1 do lazy loading. Include, split query và projection đã giải quyết các dạng đọc kinh điển bằng công cụ có sẵn trong EF Core.
Thư viện này có giá trị ở những trường hợp native EF Core xử lý chưa gọn, đặc biệt là danh sách tra cứu rất lớn và khóa ghép.
WhereBulkContains và BulkRead
var existing = context.Customers
.WhereBulkContains(importedRows)
.ToList();
Phương thức này đưa danh sách vào bảng tạm rồi thực hiện nối dữ liệu, thay vì tạo một mệnh đề IN với hàng nghìn tham số. Vì vậy nó không bị giới hạn 2.100 tham số của SQL Server.
var existing = context.Customers
.BulkRead(codes);
Với khóa ghép:
var existing = context.Customers
.BulkRead(importedRows, x => new { x.Email, x.PhoneNumber });
Cũng có thể lọc entity liên quan trong Include:
var orders = context.Orders
.Include(o => o.Items)
.WhereBulkContains(o => o.Items, productIds, i => i.ProductId)
.ToList();
Để tìm các bản ghi không tồn tại:
var missing = context.Customers
.WhereBulkNotContains(importedRows)
.ToList();
Lọc ngay trên danh sách trong bộ nhớ
Khi cần biết phần tử nào trong danh sách nhập đã tồn tại:
var toUpdate = context.Customers
.WhereBulkContainsFilterList(importedRows);
var toInsert = context.Customers
.WhereBulkNotContainsFilterList(importedRows);
Hai phương thức này thay thế hàng nghìn lần kiểm tra tồn tại bằng một số lượng truy vấn rất nhỏ.
Giới hạn cần biết
- Theo tài liệu chính thức, các phương thức này hỗ trợ SQL Server và PostgreSQL.
- Các chiến lược ánh xạ kế thừa TPH, TPT và TPC không được hỗ trợ bởi những phương thức này.
- Chúng không nối trực tiếp với
ExecuteUpdatehoặcExecuteDeletecủa EF Core. - Bảng tạm có chi phí khởi tạo cố định. Với vài trăm phần tử,
Containsthuần có thể đơn giản và nhanh hơn.
Ngưỡng chuyển đổi giữa hai cách cần được đo trên lược đồ và dữ liệu thật của ứng dụng, không nên lấy một con số chung làm quy tắc tuyệt đối.
Entity Framework Extensions là thư viện thương mại có bản dùng thử miễn phí theo thời hạn tại entityframework-extensions.net.
N+1 ở phía ghi
Gọi SaveChanges cho từng entity, cập nhật từng dòng hoặc tự viết vòng lặp upsert đều thuộc cùng một họ lỗi N+1. Hãy đưa SaveChanges ra ngoài vòng lặp. Với khối lượng lớn, cân nhắc BulkSaveChanges, BulkInsert, BulkUpdate hoặc BulkMerge.
Kết quả đo hiệu năng
Các số liệu dưới đây được đo bằng BenchmarkDotNet 0.15.4 trên .NET 10, kết nối đến một máy chủ SQL Server độc lập. Mỗi lần đo dùng một DbContext mới và dữ liệu được tạo từ cùng một seed.
Bảng 1: Hiển thị danh sách cha cùng dữ liệu con
| Số bản ghi cha | Vòng lặp tải lười | Include | Include và SplitQuery | Projection |
|---|---|---|---|---|
| 200 | 381,252 ms | 17,003 ms | 15,455 ms | 6,920 ms |
| 1.000 | 1.717,090 ms | 52,188 ms | 39,218 ms | 12,034 ms |
| 5.000 | 7.348,441 ms | 265,661 ms | 155,730 ms | 37,699 ms |
Projection thường nhanh nhất vì chỉ lấy các cột cần thiết, không tạo entity đầy đủ và không dùng change tracker.
Bảng 2: Tra cứu danh sách khóa trong bộ nhớ
| Số khóa | Vòng lặp từng phần tử | Contains | Contains chia nhóm | WhereBulkContains |
|---|---|---|---|---|
| 500 | 109,506 ms | 4,303 ms | 5,752 ms | 19,725 ms |
| 2.000 | 687,329 ms | 48,164 ms | 32,506 ms | 17,503 ms |
| 5.000 | 2.048,023 ms | Lỗi giới hạn tham số | 81,672 ms | 25,349 ms |
| 50.000 | 18.852,338 ms | Lỗi giới hạn tham số | 815,603 ms | 88,957 ms |
Điểm quan trọng không chỉ là số mili giây. Truy vấn Contains có thể chạy tốt với 500 khóa nhưng hoàn toàn thất bại với 5.000 khóa.
Hướng dẫn lựa chọn
- Vòng lặp đọc navigation property: dùng Include, ThenInclude hoặc projection.
- Nhiều collection cùng cấp: dùng AsSplitQuery để tránh nhân số dòng.
- Trang chỉ cần một số cột: dùng Select để tạo DTO.
- API trả entity và phát sinh truy vấn khi tuần tự hóa: trả DTO và tắt lazy loading.
- Tra cứu từng phần tử trong danh sách nhỏ: dùng Contains.
- Danh sách lớn hoặc khóa ghép: dùng cách chia nhóm, điều chỉnh chế độ tham số hóa hoặc cân nhắc WhereBulkContains và BulkRead.
- Phân loại phần tử đã tồn tại: dùng WhereBulkContainsFilterList và WhereBulkNotContainsFilterList.
- SaveChanges trong vòng lặp: đưa ra ngoài vòng lặp hoặc dùng thao tác hàng loạt.
- Nhà cung cấp không phải SQL Server hay PostgreSQL: dùng Contains chia nhóm hoặc cơ chế native phù hợp.
Lưu ý khi đưa lên môi trường thật
Hãy đặt ngân sách truy vấn trong CI. Interceptor kết hợp với kiểm thử tích hợp và giới hạn theo từng endpoint là cách hiệu quả để ngăn N+1 quay trở lại.
Các global query filter, bao gồm filter có tên trong EF Core 10, vẫn được kết hợp với mọi cách khắc phục ở trên. Khi SQL sinh ra khác dự kiến, hãy kiểm tra filter trước khi nghi ngờ Include hoặc projection.
AsNoTracking hữu ích trên đường đọc có materialize entity. Nó không tạo thêm lợi ích cho projection vì kết quả projection vốn không được theo dõi.
Không phải mọi tra cứu theo từng phần tử đều cần join hoặc bulk read. Dữ liệu tham chiếu thay đổi rất ít có thể phù hợp hơn với bộ nhớ đệm như IMemoryCache.
Cuối cùng, hãy đo ngưỡng chuyển đổi. Dùng WhereBulkContains cho danh sách 50 phần tử có thể là thói quen máy móc. Dùng nó cho danh sách 50.000 phần tử có thể là lựa chọn kỹ thuật hợp lý.
Kết luận
N+1 là một lỗi có bốn biểu hiện: vòng lặp tải lười, bão truy vấn khi tuần tự hóa, vòng lặp tra cứu và vòng lặp ghi dữ liệu.
Include và projection xử lý các dạng đọc kinh điển. Truy vấn theo tập dữ liệu xử lý các vòng lặp tra cứu. Bulk write xử lý các vòng lặp ghi. Cách phòng ngừa hiệu quả nhất là đếm truy vấn, đặt ngân sách trong kiểm thử và biến tải lười thành lỗi trong môi trường phát triển.
EF Core 10 đã có đầy đủ công cụ để giải quyết N+1 thông thường mà không cần thư viện trả phí. Hãy dùng Include, split query và projection trước. Entity Framework Extensions phù hợp hơn ở các điểm khó như danh sách vượt giới hạn tham số, khóa ghép, phân loại tồn tại trong một lần xử lý và các thao tác ghi hàng loạt.
Ưu tiên sửa vị trí của SaveChanges. Sau đó đếm số truy vấn. Khi đã nhìn thấy vấn đề, việc chọn công cụ phù hợp sẽ trở nên rõ ràng.
Nội dung được tài trợ, hợp tác cùng ZZZ Projects.



