Search

LINQ Performance: 10 Lỗi Thường Gặp Và Cách Fix Trong Dự Án Thực Tế

LINQ Performance: 10 Lỗi Thường Gặp Và Cách Fix Trong Dự Án Thực Tế

LINQ là một trong những thứ tuyệt vời nhất của C#. Viết query bằng C# thay vì string SQL, có IntelliSense, có compile-time check — developer nào mà không thích. Nhưng chính sự tiện lợi đó lại là cái bẫy: LINQ giấu đi quá nhiều thứ đang xảy ra bên dưới, và khi bạn không hiểu nó sinh ra SQL gì, performance sẽ âm thầm chết mà bạn không hay biết.

Bài viết này tổng hợp 10 lỗi LINQ mà mình gặp đi gặp lại trong các dự án .NET + EF Core + PostgreSQL — từ code review trong team cho đến debug production. Mỗi lỗi đều có ví dụ code cụ thể, giải thích tại sao nó chậm, và cách fix.


Lỗi 1: Gọi ToList() quá sớm — kéo cả bảng về rồi mới lọc

Đây là lỗi kinh điển nhất, và cũng là lỗi mình thấy nhiều nhất ở developer mới làm việc với EF Core.

Code lỗi:

var orders = await _context.Orders.ToListAsync();
var filtered = orders.Where(o => o.Status == "Pending" && o.CreatedDate > DateTime.UtcNow.AddDays(-30));

Vấn đề: ToListAsync() kéo toàn bộ bảng Orders về memory, sau đó mới filter bằng LINQ to Objects. Nếu bảng có 500,000 records, bạn vừa load 500,000 records vào RAM chỉ để lấy ra vài trăm. SQL sinh ra chỉ đơn giản là SELECT * FROM "Orders" — không có WHERE gì cả.

Cách fix:

var filtered = await _context.Orders
    .Where(o => o.Status == "Pending" && o.CreatedDate > DateTime.UtcNow.AddDays(-30))
    .ToListAsync();

Filter trước, ToListAsync() sau. SQL giờ có WHERE clause, database chỉ trả về records cần thiết.

Quy tắc: ToList(), ToArray(), AsEnumerable() là ranh giới giữa LINQ to Entities (chạy trên database) và LINQ to Objects (chạy trên memory). Mọi thứ sau ranh giới này đều chạy trên memory. Đẩy ranh giới này càng xa cuối query càng tốt.


Lỗi 2: N+1 Query — kẻ giết performance thầm lặng

N+1 là lỗi mà bạn có thể không phát hiện được bằng cách đọc code — phải nhìn vào SQL log mới thấy.

Code lỗi:

var orders = await _context.Orders
    .Where(o => o.Status == "Pending")
    .ToListAsync();

foreach (var order in orders)
{
    Console.WriteLine($"Order {order.Id} - Customer: {order.Customer.Name}");
    // Mỗi lần truy cập order.Customer → 1 query SELECT riêng
}

Vấn đề: Nếu có 100 orders, EF Core chạy 1 query lấy orders + 100 query lấy customer cho từng order = 101 queries. Mỗi query có overhead riêng (network roundtrip, query parsing, execution plan...). Với 1,000 orders, bạn có 1,001 queries — page load từ 200ms nhảy lên 5-10 giây.

Điều đáng sợ là code trông hoàn toàn bình thường. Bạn không thấy query nào trong code cả — EF Core lazy loading âm thầm chạy query khi bạn truy cập navigation property.

Cách fix — dùng Include (Eager Loading):

var orders = await _context.Orders
    .Include(o => o.Customer)
    .Where(o => o.Status == "Pending")
    .ToListAsync();

Giờ EF Core sinh 1 query duy nhất với JOIN, lấy cả Orders lẫn Customers trong một lần.

Cách fix tốt hơn — dùng Select projection khi không cần toàn bộ entity:

var orderSummaries = await _context.Orders
    .Where(o => o.Status == "Pending")
    .Select(o => new OrderSummaryDto
    {
        OrderId = o.Id,
        CustomerName = o.Customer.Name,
        Total = o.TotalAmount
    })
    .ToListAsync();

Cách này còn tốt hơn Include vì: chỉ SELECT đúng cột cần thiết thay vì SELECT *, EF Core tự sinh JOIN mà không cần Include, và DTO nhẹ hơn entity.

Mẹo phát hiện N+1: Bật EF Core logging trong development để thấy SQL được sinh ra:

optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);

Nếu thấy cùng một pattern query lặp lại hàng chục lần — đó là N+1.


Lỗi 3: Select toàn bộ entity khi chỉ cần vài cột

Code lỗi:

var invoices = await _context.Invoices
    .Where(i => i.TenantId == tenantId)
    .ToListAsync();

// Chỉ dùng Id và InvoiceNumber để hiển thị dropdown
var options = invoices.Select(i => new { i.Id, i.InvoiceNumber });

Vấn đề: Load toàn bộ entity (có thể 20-30 cột, bao gồm cả cột Description kiểu text dài) về memory, rồi chỉ lấy 2 cột. Với PostgreSQL, mỗi cột thừa đều tốn bandwidth truyền từ database đến application server.

Trong dự án mình từng làm, bảng Invoice có cột HtmlContent lưu nội dung hóa đơn dạng HTML — trung bình 50KB mỗi record. Load 200 invoices = 10MB dữ liệu truyền qua mạng, trong khi chỉ cần 200 dòng gồm Id + InvoiceNumber.

Cách fix:

var options = await _context.Invoices
    .Where(i => i.TenantId == tenantId)
    .Select(i => new { i.Id, i.InvoiceNumber })
    .ToListAsync();

SQL giờ là SELECT "Id", "InvoiceNumber" FROM "Invoices" WHERE ... — nhẹ hơn hàng chục lần.


Lỗi 4: Dùng Contains() với list lớn

Code lỗi:

var customerIds = await GetActiveCustomerIdsAsync(); // Trả về 5,000 IDs

var orders = await _context.Orders
    .Where(o => customerIds.Contains(o.CustomerId))
    .ToListAsync();

Vấn đề: EF Core chuyển Contains() thành WHERE "CustomerId" IN (1, 2, 3, ..., 5000). Với 5,000 phần tử, câu SQL rất dài, database phải parse cả đống literal values, và execution plan bị ảnh hưởng. PostgreSQL xử lý IN clause tốt hơn SQL Server, nhưng khi list lên đến hàng chục ngàn phần tử, performance vẫn giảm rõ rệt.

Cách fix — dùng JOIN thay vì IN:

var orders = await _context.Orders
    .Where(o => _context.Customers
        .Where(c => c.IsActive)
        .Select(c => c.Id)
        .Contains(o.CustomerId))
    .ToListAsync();

EF Core sinh SQL với subquery hoặc JOIN — database xử lý hiệu quả hơn nhiều so với IN clause khổng lồ.

Nếu list IDs đến từ bên ngoài (không phải từ database): Chia nhỏ list thành các batch:

var allOrders = new List<Order>();
foreach (var batch in customerIds.Chunk(500))
{
    var batchOrders = await _context.Orders
        .Where(o => batch.Contains(o.CustomerId))
        .ToListAsync();
    allOrders.AddRange(batchOrders);
}

Chunk(500) chia list thành các nhóm 500 phần tử — mỗi query có IN clause vừa phải.


Lỗi 5: Không dùng AsNoTracking cho read-only query

Code lỗi:

var products = await _context.Products
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync();

// Chỉ hiển thị danh sách, không sửa gì
return products.Select(p => new ProductDto { ... });

Vấn đề: Mặc định, EF Core tracking mọi entity được load — lưu bản copy trong Change Tracker để biết entity nào thay đổi khi gọi SaveChanges(). Nếu bạn chỉ đọc dữ liệu (hiển thị danh sách, export report...), Change Tracker làm việc thừa: tốn memory để lưu bản copy, tốn CPU để so sánh mỗi khi có query mới.

Với danh sách lớn (1,000+ records), overhead này rất đáng kể.

Cách fix:

var products = await _context.Products
    .AsNoTracking()
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync();

AsNoTracking() tắt Change Tracker cho query này. Entity trả về là "detached" — bạn không thể gọi SaveChanges() để update chúng, nhưng query nhanh hơn và ít tốn memory hơn.

Mẹo: Nếu một DbContext chủ yếu dùng cho đọc (query-heavy API), bạn có thể set mặc định AsNoTracking cho toàn bộ context:

services.AddDbContext<ReadOnlyDbContext>(options =>
{
    options.UseNpgsql(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

Dùng context riêng cho read (NoTracking) và context riêng cho write (Tracking) — pattern này rất phổ biến trong CQRS.


Lỗi 6: GroupBy chạy trên client thay vì database

Đây là lỗi khiến nhiều người bất ngờ vì code không báo lỗi, chỉ chậm.

Code lỗi:

var monthlySales = await _context.Orders
    .GroupBy(o => new { o.CreatedDate.Year, o.CreatedDate.Month })
    .Select(g => new
    {
        Year = g.Key.Year,
        Month = g.Key.Month,
        TotalRevenue = g.Sum(o => o.TotalAmount),
        OrderCount = g.Count()
    })
    .ToListAsync();

Vấn đề: Tùy version EF Core và cách viết GroupBy, query có thể bị evaluate trên client thay vì database. Điều này có nghĩa EF Core load toàn bộ bảng Orders về rồi mới group — thay vì để database làm GROUP BY.

Với EF Core 7+, tình hình đã tốt hơn nhiều so với EF Core 3-5 (khi mà client evaluation bị throw exception thay vì chạy thầm). Nhưng vẫn có trường hợp EF Core không translate được GroupBy phức tạp sang SQL.

Cách fix — kiểm tra SQL sinh ra:

Luôn verify bằng cách xem SQL log. Nếu thấy SQL không có GROUP BY, nghĩa là grouping đang chạy trên client.

Cách fix chắc chắn — dùng raw SQL khi GroupBy phức tạp:

var monthlySales = await _context.Database
    .SqlQuery<MonthlySalesDto>($"""
        SELECT 
            EXTRACT(YEAR FROM "CreatedDate") AS "Year",
            EXTRACT(MONTH FROM "CreatedDate") AS "Month",
            SUM("TotalAmount") AS "TotalRevenue",
            COUNT(*) AS "OrderCount"
        FROM "Orders"
        WHERE "TenantId" = {tenantId}
        GROUP BY EXTRACT(YEAR FROM "CreatedDate"), EXTRACT(MONTH FROM "CreatedDate")
        ORDER BY "Year" DESC, "Month" DESC
    """)
    .ToListAsync();

Không phải mọi thứ đều phải viết bằng LINQ. Khi LINQ không sinh được SQL tối ưu, raw SQL là lựa chọn hoàn toàn hợp lý — đặc biệt với reporting query phức tạp.


Lỗi 7: Gọi Count() khi chỉ cần biết "có hay không"

Code lỗi:

if (await _context.Orders.Where(o => o.CustomerId == customerId).CountAsync() > 0)
{
    // Customer có đơn hàng
}

Vấn đề: Count() đếm toàn bộ records thỏa điều kiện. Nếu customer có 10,000 đơn hàng, database phải scan qua cả 10,000 records để trả về con số 10,000 — trong khi bạn chỉ cần biết "có ít nhất 1 hay không."

Cách fix:

if (await _context.Orders.AnyAsync(o => o.CustomerId == customerId))
{
    // Customer có đơn hàng
}

Any() sinh SQL với EXISTS — database dừng ngay khi tìm thấy record đầu tiên, không cần scan hết.

Tương tự, nếu bạn cần kiểm tra Count() == 0, hãy dùng !Any(). Và nếu cần kiểm tra Count() == 1, dùng combination khác thay vì count toàn bộ:

// Thay vì: Count() == 1
// Dùng: lấy tối đa 2 records và kiểm tra
var items = await query.Take(2).ToListAsync();
bool exactlyOne = items.Count == 1;

Lỗi 8: String concatenation trong Where — database không dùng được index

Code lỗi:

var customers = await _context.Customers
    .Where(c => (c.FirstName + " " + c.LastName).Contains(searchTerm))
    .ToListAsync();

Vấn đề: SQL sinh ra có dạng WHERE "FirstName" || ' ' || "LastName" LIKE '%search%'. Database không thể dùng index trên FirstName hay LastName vì bạn đang filter trên một expression tính toán từ hai cột. Kết quả: full table scan mỗi lần search.

Cách fix — tách điều kiện:

var customers = await _context.Customers
    .Where(c => c.FirstName.Contains(searchTerm) || c.LastName.Contains(searchTerm))
    .ToListAsync();

Giờ database có thể dùng index trên từng cột riêng biệt (nếu có index).

Cách fix tốt hơn cho PostgreSQL — dùng Full Text Search:

var customers = await _context.Customers
    .Where(c => EF.Functions.ILike(c.FirstName, $"%{searchTerm}%") 
             || EF.Functions.ILike(c.LastName, $"%{searchTerm}%"))
    .ToListAsync();

ILike là case-insensitive LIKE của PostgreSQL. Hoặc nếu bạn cần search phức tạp hơn, dùng tsvector + tsquery qua raw SQL.

Nguyên tắc chung: tránh đặt function hoặc expression tính toán lên cột trong WHERE clause. Database chỉ dùng được index khi cột đứng "trần" trong điều kiện, không bị bọc bởi function.


Lỗi 9: OrderBy sau khi đã ToList() — sort trên client

Code lỗi:

var invoices = await _context.Invoices
    .Where(i => i.TenantId == tenantId)
    .ToListAsync();

var sorted = invoices.OrderByDescending(i => i.CreatedDate)
                     .ThenBy(i => i.InvoiceNumber)
                     .ToList();

Vấn đề: Tương tự lỗi 1 — sort đang chạy trên memory thay vì database. Với dataset nhỏ (vài trăm records) thì không sao, nhưng với hàng chục ngàn records, database sort nhanh hơn C# rất nhiều vì database có index, có optimized sort algorithm, và không cần truyền dữ liệu chưa sort qua network.

Cách fix:

var invoices = await _context.Invoices
    .Where(i => i.TenantId == tenantId)
    .OrderByDescending(i => i.CreatedDate)
    .ThenBy(i => i.InvoiceNumber)
    .ToListAsync();

SQL giờ có ORDER BY "CreatedDate" DESC, "InvoiceNumber" ASC — database xử lý, và nếu có index trên CreatedDate, sort gần như miễn phí.

Lỗi tương tự: Skip()Take() cũng phải đặt trước ToList(). Nếu không, bạn load toàn bộ records rồi mới phân trang trên memory — mất hoàn toàn ý nghĩa của pagination:

// SAI — load hết rồi mới phân trang trên memory
var all = await query.ToListAsync();
var page = all.Skip(20).Take(10).ToList();

// ĐÚNG — database chỉ trả về 10 records
var page = await query.Skip(20).Take(10).ToListAsync();

Lỗi 10: Không phân biệt IQueryable và IEnumerable

Đây là lỗi nền tảng dẫn đến hầu hết các lỗi khác trong bài viết này. Nếu hiểu rõ sự khác biệt này, bạn sẽ tránh được phần lớn vấn đề performance với LINQ.

IQueryable — biểu thức chưa được thực thi. Khi bạn viết .Where().OrderBy().Select() trên IQueryable, EF Core chỉ xây dựng expression tree — chưa chạy query nào. Query chỉ chạy khi bạn "materialize" bằng ToList(), FirstOrDefault(), Count(), hoặc foreach.

IEnumerable — dữ liệu đã nằm trong memory. Mọi operation trên IEnumerable đều chạy bằng C# trên memory.

Code lỗi — chuyển sang IEnumerable quá sớm:

public IEnumerable<Order> GetPendingOrders()
{
    return _context.Orders.Where(o => o.Status == "Pending");
    // Return type IEnumerable → caller không biết đây là IQueryable
}

// Caller
var recent = _orderService.GetPendingOrders()
    .Where(o => o.CreatedDate > DateTime.UtcNow.AddDays(-7)) // Chạy trên memory!
    .OrderByDescending(o => o.CreatedDate)                     // Chạy trên memory!
    .Take(10)                                                   // Chạy trên memory!
    .ToList();

Filter thứ hai .Where(o => o.CreatedDate > ...) chạy trên memory vì method trả về IEnumerable thay vì IQueryable. EF Core load toàn bộ pending orders về rồi mới filter tiếp.

Cách fix:

public IQueryable<Order> GetPendingOrders()
{
    return _context.Orders.Where(o => o.Status == "Pending");
}

// Giờ caller có thể chain thêm filter → tất cả đều chạy trên database
var recent = await _orderService.GetPendingOrders()
    .Where(o => o.CreatedDate > DateTime.UtcNow.AddDays(-7))
    .OrderByDescending(o => o.CreatedDate)
    .Take(10)
    .ToListAsync();

Nguyên tắc: Trong repository hoặc service layer, trả về IQueryable khi bạn muốn cho phép caller thêm filter. Chỉ materialize (ToList) ở layer cuối cùng — thường là controller hoặc application service.

Tuy nhiên, có một trường phái cho rằng expose IQueryable ra ngoài repository là leak abstraction. Đây là cuộc tranh luận dài — nhưng về mặt performance, IQueryable luôn cho phép tối ưu tốt hơn vì mọi thứ đều chạy trên database.


Bonus: Cách phát hiện vấn đề LINQ performance

Biết 10 lỗi trên là tốt, nhưng quan trọng hơn là biết cách phát hiện chúng trong codebase hiện có.

Bật EF Core SQL logging

// Trong appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Mọi SQL query EF Core sinh ra sẽ hiện trong console. Đọc qua một lượt khi test một page — nếu thấy hàng chục queries cho một page load, bạn có vấn đề.

Dùng MiniProfiler

MiniProfiler hiển thị thời gian và số lượng query trên mỗi request, ngay trên giao diện web. Cực kỳ hữu ích để phát hiện N+1 và query thừa trong development.

Kiểm tra execution plan trong PostgreSQL

Với những query chậm, copy SQL từ EF Core log rồi chạy EXPLAIN ANALYZE trong pgAdmin:

EXPLAIN ANALYZE
SELECT * FROM "Orders" WHERE "Status" = 'Pending' ORDER BY "CreatedDate" DESC;

Output cho bạn biết database đang dùng index hay full table scan, ước lượng rows vs actual rows, và thời gian thực thi từng bước.


Kết luận

Nhìn lại 10 lỗi, bạn sẽ thấy một pattern chung: phần lớn vấn đề đến từ việc không kiểm soát được ranh giới giữa database và memory. Code C# trông giống nhau dù chạy trên database hay memory — đó vừa là ưu điểm vừa là nhược điểm lớn nhất của LINQ.

Ba nguyên tắc giúp tránh hầu hết các lỗi: một là luôn filter, sort, project trước khi materialize. Hai là dùng AsNoTracking() cho mọi read-only query. Ba là kiểm tra SQL sinh ra — không phải lúc nào cũng cần, nhưng mỗi khi thấy page load chậm hoặc API response lâu, SQL log là nơi đầu tiên bạn nên nhìn.

Performance tuning không phải làm một lần rồi xong. Codebase phát triển, data lớn lên, query mới được thêm vào — những lỗi này sẽ quay lại nếu team không có thói quen review SQL. Đưa EF Core logging vào quy trình development hàng ngày là cách đơn giản nhất để ngăn chặn performance regression trước khi nó lên production.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy