Skip to content

5.3 - LINQ ​

Tóm tắt: LINQ là bộ phương thức Where, Select, OrderBy, GroupBy... để lọc, biến đổi và tổng hợp dữ liệu bằng vài dòng thay cho vòng lặp. Bài này đi từ LINQ trên danh sách trong bộ nhớ tới LINQ trên database. Hai tính chất của LINQ giải thích gần như mọi bất ngờ mà người ta gặp với nó. Thứ nhất, nó chạy trễ: Where không lọc gì cả cho tới khi ai đó bắt đầu duyệt, nên duyệt hai lần là chạy lại toàn bộ hai lần. Thứ hai, IEnumerable và IQueryable trông giống hệt nhau nhưng chạy ở hai nơi khác nhau — IQueryable dịch biểu thức thành SQL, còn IEnumerable xử lý trong bộ nhớ. Gọi .ToList() sớm một dòng là biến một câu WHERE trong database thành việc kéo cả bảng về rồi mới lọc. Đây là lỗi hiệu năng phổ biến nhất mà LINQ gây ra, và nó không báo lỗi gì cả.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Dùng Where, Select, OrderBy/ThenBy, GroupBy, Join, ToDictionary, SelectMany trên danh sách trong bộ nhớ.
  • Giải thích đánh giá trễ và chỉ ra thời điểm một truy vấn LINQ thật sự chạy.
  • Phân biệt IEnumerable với IQueryable và nói được câu lệnh chạy ở đâu.
  • Nhận ra bẫy duyệt nhiều lần và bẫy .ToList() đặt sai chỗ.
  • Chọn đúng giữa First, FirstOrDefault, Single, SingleOrDefault.
  • Viết truy vấn nhóm và tổng hợp mà không kéo dữ liệu thừa.

Cần biết trước: bài 1.8 (LINQ và lambda lần đầu, deferred execution), bài 5.2 (generic, IEnumerable<T>). Phần LINQ trên database (từ mục 5.3.5) dùng EF Core: đọc bài 8.10 trước sẽ dễ hơn, nhưng không bắt buộc.

Nội dung bài học ​

5.3.1 — LINQ trên dữ liệu trong bộ nhớ ​

Mỗi phương thức LINQ nhận một lambda, một hàm viết gọn tại chỗ: c => c.City == "HN" nghĩa là "nhận c, trả về c.City == "HN"". Lambda thật ra là một delegate (biến giữ một hàm), chủ đề của bài 5.4. Ở bài này chỉ cần đọc được nó.

Chương trình dưới chạy được ngay: tạo project bằng dotnet new console, dán vào Program.cs, rồi dotnet run. Dữ liệu là vài khách hàng và deal (cơ hội bán hàng) của CRM.

csharp
// Deal.cs
public record Customer(int Id, string Name, string City, string? Tier);
public enum DealStatus { Open, Won, Lost }
public record Deal(int Id, int CustomerId, decimal Value, DealStatus Status);
csharp
// Program.cs
List<Customer> customers =
[
    new(1, "Công ty An Phát",  "HN",  "Gold"),
    new(2, "Bình Minh Food",   "HCM", "Silver"),
    new(3, "Cửa hàng Cúc",     "HN",  null),
    new(4, "Đại Dương Logistics", "DN", "Gold"),
];

List<Deal> deals =
[
    new(101, 1, 120_000_000m, DealStatus.Won),
    new(102, 1,  45_000_000m, DealStatus.Lost),
    new(103, 2,  80_000_000m, DealStatus.Won),
    new(104, 2,  30_000_000m, DealStatus.Open),
    new(105, 4, 200_000_000m, DealStatus.Won),
    new(106, 4,  15_000_000m, DealStatus.Won),
];

// 1. Where + Select: lọc rồi lấy vài cột, kết quả là anonymous type
var hanoi = customers
    .Where(c => c.City == "HN")
    .Select(c => new { c.Id, c.Name });
foreach (var c in hanoi) Console.WriteLine($"HN: {c.Id} {c.Name}");

// 2. OrderBy + ThenBy: sắp theo thành phố, cùng thành phố thì theo tên
foreach (var c in customers.OrderBy(c => c.City).ThenBy(c => c.Name))
    Console.WriteLine($"{c.City,-4} {c.Name}");

// 3. Tổng hợp: Count, Sum, Average, Any
var won = deals.Where(d => d.Status == DealStatus.Won).ToList();
Console.WriteLine($"Deal thắng: {won.Count}, tổng {won.Sum(d => d.Value):N0}, trung bình {won.Average(d => d.Value):N0}");
Console.WriteLine($"Có deal đang mở? {deals.Any(d => d.Status == DealStatus.Open)}");

// 4. GroupBy: doanh thu deal thắng theo khách hàng
var revenueByCustomer = won
    .GroupBy(d => d.CustomerId)
    .Select(g => new { CustomerId = g.Key, Revenue = g.Sum(d => d.Value), Count = g.Count() })
    .OrderByDescending(x => x.Revenue);
foreach (var r in revenueByCustomer)
    Console.WriteLine($"Khách {r.CustomerId}: {r.Count} deal, {r.Revenue:N0}");

// 5. Join: ghép deal với tên khách hàng
var dealWithName = deals.Join(customers,
    d => d.CustomerId,          // khoá bên deal
    c => c.Id,                  // khoá bên khách hàng
    (d, c) => new { d.Id, c.Name, d.Value });
Console.WriteLine($"Deal đầu tiên: {dealWithName.First()}");

// 6. ToDictionary: tra khách hàng theo Id trong O(1)
Dictionary<int, Customer> byId = customers.ToDictionary(c => c.Id);
Console.WriteLine($"Khách 4: {byId[4].Name}");

// 7. SelectMany: "trải phẳng" danh sách lồng nhau
List<string[]> tagsPerCustomer = [["vip", "b2b"], ["food"], [], ["b2b", "logistics"]];
var allTags = tagsPerCustomer.SelectMany(t => t).Distinct().Order();
Console.WriteLine($"Tag: {string.Join(", ", allTags)}");

Dán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).

Kết quả chạy trên .NET 10 (máy cài định dạng số tiếng Việt nên N0 dùng dấu chấm phân cách hàng nghìn):

text
HN: 1 Công ty An Phát
HN: 3 Cửa hàng Cúc
DN   Đại Dương Logistics
HCM  Bình Minh Food
HN   Công ty An Phát
HN   Cửa hàng Cúc
Deal thắng: 4, tổng 415.000.000, trung bình 103.750.000
Có deal đang mở? True
Khách 4: 2 deal, 215.000.000
Khách 1: 1 deal, 120.000.000
Khách 2: 1 deal, 80.000.000
Deal đầu tiên: { Id = 101, Name = Công ty An Phát, Value = 120000000 }
Khách 4: Đại Dương Logistics
Tag: b2b, food, logistics, vip
Toán tửLàm gìTương đương SQL
WhereGiữ phần tử thoả điều kiệnWHERE
SelectBiến mỗi phần tử thành thứ khácdanh sách cột sau SELECT
OrderBy, ThenBy, OrderByDescendingSắp xếp, nhiều tiêu chíORDER BY a, b
GroupByGom nhóm theo khoá, mỗi nhóm có KeyGROUP BY
JoinGhép hai danh sách theo khoáINNER JOIN
Count, Sum, Average, AnyTổng hợp ra một giá trịCOUNT, SUM, AVG, EXISTS
ToList, ToDictionaryChép kết quả vào collection
SelectManyTrải phẳng danh sách lồng nhau

new { c.Id, c.Name } tạo một anonymous type: kiểu không tên do trình biên dịch tự sinh, có hai thuộc tính Id và Name. Nó tiện cho kết quả trung gian trong một hàm; muốn trả kết quả ra ngoài hàm thì dùng record.

5.3.2 — Hai cú pháp, một kết quả ​

csharp
// Cú pháp truy vấn — gần với SQL
var q1 = from d in deals
         where d.Value >= 50_000_000m
         orderby d.Value descending
         select new { d.Id, d.Value };

// Cú pháp phương thức — phổ biến hơn trong .NET hiện đại
var q2 = deals
    .Where(d => d.Value >= 50_000_000m)
    .OrderByDescending(d => d.Value)
    .Select(d => new { d.Id, d.Value });

Trình biên dịch dịch cú pháp truy vấn thành cú pháp phương thức, nên hai câu trên giống hệt nhau sau khi biên dịch. Cú pháp truy vấn dễ đọc hơn với join nhiều bảng; phần còn lại thì cú pháp phương thức gọn hơn.

5.3.3 — Đánh giá trễ: câu lệnh chạy lúc nào ​

csharp
var query = deals.Where(d => { Console.WriteLine("đang lọc"); return d.Status == DealStatus.Won; });
Console.WriteLine("đã tạo truy vấn");      // in ra TRƯỚC

foreach (var d in query) { }               // "đang lọc" mới bắt đầu in

Where, Select, OrderBy, Take chỉ mô tả việc cần làm. Chúng không chạy gì cho tới khi có một thao tác buộc phải duyệt:

Chạy trễ (trả về truy vấn)Chạy ngay (buộc duyệt)
Where, Select, OrderByToList, ToArray, ToDictionary
Take, Skip, DistinctCount, Sum, Any, First
GroupBy, Join, SelectManyforeach

Lợi ích của việc chạy trễ: bạn ghép truy vấn theo điều kiện mà không chạy nhiều lần. Đoạn dưới là truy vấn database qua EF Core (giải thích ở mục 5.3.5): ba nhánh if chỉ ghép thêm điều kiện, câu SQL chạy một lần ở dòng cuối.

csharp
IQueryable<Order> q = _db.Orders;

if (filter.CustomerId is int id)   q = q.Where(o => o.CustomerId == id);
if (filter.From is DateTimeOffset f) q = q.Where(o => o.CreatedAt >= f);
if (filter.OnlyPaid)                q = q.Where(o => o.IsPaid);

var result = await q.ToListAsync(ct);      // MỘT câu SQL duy nhất

5.3.4 — Bẫy 1: duyệt nhiều lần ​

csharp
var expensive = orders.Where(o => SlowCheck(o));   // chưa chạy

var count = expensive.Count();       // chạy lần 1 — SlowCheck n lần
var first = expensive.First();       // chạy LẠI lần 2
foreach (var o in expensive) { }     // chạy LẠI lần 3

Với IEnumerable trong bộ nhớ, đây là lãng phí CPU. Với IQueryable trên database, đây là ba câu truy vấn.

csharp
var materialized = expensive.ToList();   // chạy đúng một lần
var count = materialized.Count;          // đọc thuộc tính, không chạy lại

Quy tắc: nếu dùng kết quả nhiều hơn một lần, hãy ToList(). Nếu chỉ duyệt một lần, đừng.

5.3.5 — Bẫy 2: IQueryable biến thành IEnumerable ​

Từ mục này tới 5.3.8, ví dụ chạy trên database qua EF Core, thư viện cho phép viết LINQ thay vì SQL (bài 8.10 dạy phần tối thiểu, Module 13 dạy kỹ). Ba điều cần biết để đọc code:

  • _db (hoặc db) là một DbContext, đại diện cho database. _db.Orders là bảng Orders, có kiểu IQueryable<Order>.
  • ToListAsync(ct) là bản bất đồng bộ của ToList(): chạy câu SQL rồi trả danh sách. await và ct (CancellationToken, dùng để huỷ) được dạy ở Module 6; lúc này cứ đọc như ToList().
  • Bài tập 1 và 3 có sẵn một project chạy được trên PostgreSQL để bạn tự xem câu SQL sinh ra.

Đây là lỗi tốn kém nhất mà LINQ gây ra.

csharp
// SAI — ToList() kéo TOÀN BỘ bảng về bộ nhớ, rồi mới lọc
var orders = _db.Orders
    .ToList()                                  // SELECT * FROM Orders  (3 triệu dòng)
    .Where(o => o.CustomerId == id)            // lọc trong bộ nhớ
    .ToList();

// ĐÚNG — lọc chạy trong database
var orders = await _db.Orders
    .Where(o => o.CustomerId == id)            // WHERE CustomerId = @id
    .ToListAsync(ct);

Hai dòng trên trông gần giống nhau và cho cùng kết quả. Khác biệt là một bên chuyển vài chục dòng qua mạng, bên kia chuyển ba triệu.

Nhìn theo ranh giới giữa database và tiến trình .NET:

Sơ đồ so sánh hai cách đặt ToList: đặt ToList trước Where thì database chạy SELECT * và ba triệu dòng đi qua mạng vào RAM của tiến trình .NET rồi mới lọc bằng Where trong bộ nhớ; để Where trên IQueryable thì điều kiện thành WHERE CustomerId = @id chạy trong database và chỉ vài chục dòng cần lấy về qua ToListAsyncSơ đồ so sánh hai cách đặt ToList: đặt ToList trước Where thì database chạy SELECT * và ba triệu dòng đi qua mạng vào RAM của tiến trình .NET rồi mới lọc bằng Where trong bộ nhớ; để Where trên IQueryable thì điều kiện thành WHERE CustomerId = @id chạy trong database và chỉ vài chục dòng cần lấy về qua ToListAsync

File gốc: nền sáng · nền tối

Cùng một lỗi có thể đến từ chỗ khác — một hàm trả về sai kiểu:

csharp
// Trả IEnumerable: mọi thao tác sau đó chạy TRONG BỘ NHỚ
public IEnumerable<Order> GetOrders() => _db.Orders;

GetOrders().Where(o => o.IsPaid).Take(10);   // kéo cả bảng rồi mới lọc

Nhận biết: IQueryable dịch biểu thức sang SQL; IEnumerable chạy delegate trong tiến trình .NET. Ranh giới giữa hai thứ là nơi dữ liệu rời khỏi database, và bạn phải biết nó nằm ở đâu.

Phần bên phía SQL của câu chuyện này nằm ở bài hiệu năng truy vấn, và một biến thể khác của nó là vấn đề N+1.

5.3.6 — Bẫy 3: hàm không dịch được sang SQL ​

csharp
// Không dịch được — MyCustomCheck là code C#, SQL không biết nó
var orders = await _db.Orders
    .Where(o => MyCustomCheck(o.Code))
    .ToListAsync(ct);

EF Core 3.0 trở lên ném ngoại lệ thay vì âm thầm kéo cả bảng về (hành vi cũ của EF Core 2.x, vốn gây rất nhiều sự cố hiệu năng bí ẩn). Ngoại lệ là điều tốt: nó buộc bạn quyết định.

Hai hướng xử lý: viết lại điều kiện bằng thứ SQL hiểu được, hoặc gọi .AsEnumerable() tường minh để nói rõ "từ đây trở đi xử lý trong bộ nhớ" — sau khi đã lọc bớt dữ liệu.

Một lưu ý liên quan: bọc hàm quanh cột trong Where làm index không dùng được dù câu lệnh vẫn dịch sang SQL. Đó là chủ đề của bài Sargability.

5.3.7 — First, Single và các biến thể ​

Phương thứcKhông có phần tửNhiều hơn một
First()NémLấy cái đầu
FirstOrDefault()null / defaultLấy cái đầu
Single()NémNém
SingleOrDefault()null / defaultNém

Chọn theo ý định, không chọn theo thói quen:

  • Tra theo khoá chính, kỳ vọng đúng một → SingleOrDefault. Nếu có hai, dữ liệu đã sai và bạn muốn biết ngay.
  • Lấy bản ghi mới nhất sau OrderByDescending → FirstOrDefault.

Lưu ý hiệu năng: trên database, Single phải đọc hai bản ghi để xác nhận không có cái thứ hai, còn First dừng ở bản ghi đầu. Với danh sách trong bộ nhớ, Single còn phải duyệt hết danh sách để chắc chắn không có phần tử thứ hai.

5.3.8 — Vài thói quen nhỏ tạo khác biệt lớn ​

csharp
// Kiểm tra có tồn tại không
if (orders.Count() > 0) { }     // đếm HẾT rồi so sánh
if (orders.Any()) { }           // dừng ngay khi thấy phần tử đầu

// Chỉ lấy cột cần dùng
var names = await _db.Customers.ToListAsync(ct);              // SELECT *
var names = await _db.Customers.Select(c => c.Name).ToListAsync(ct);  // SELECT Name

// Không cần theo dõi thay đổi thì tắt đi
var report = await _db.Orders.AsNoTracking().Where(...).ToListAsync(ct);

AsNoTracking đáng dùng cho mọi truy vấn chỉ đọc: EF Core bỏ qua việc dựng bộ theo dõi thay đổi, tiết kiệm bộ nhớ và thời gian đáng kể trên tập dữ liệu lớn.

Và nhóm dữ liệu thì để database làm:

csharp
// Nhóm trong database — trả về vài chục dòng
var stats = await _db.Orders
    .GroupBy(o => o.CustomerId)
    .Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Total), Count = g.Count() })
    .ToListAsync(ct);

5.3.9 — Rà lại code của bạn ​

Danh sách rà soát LINQ

  • Không có ToList() nào đứng trước Where trên một IQueryable.
  • Hàm truy vấn trả về IQueryable khi người gọi còn lọc tiếp, không trả IEnumerable.
  • Kết quả dùng nhiều hơn một lần đều được ToList() trước.
  • Dùng Any() thay cho Count() > 0.
  • Truy vấn chỉ đọc đều có AsNoTracking().
  • Select chỉ lấy cột thật sự cần, không kéo cả entity.
  • Chọn Single hay First theo ý định, không theo thói quen.
  • Phép nhóm và tổng hợp chạy trong database, không chạy sau khi ToList().

Bài tập áp dụng ​

Bài 1 — Đo giá của ToList() đặt sai chỗ ​

Với một bảng 50.000 dòng, chạy hai phiên bản của cùng một truy vấn: một đặt ToList() trước Where, một đặt sau. Bật log SQL của EF Core và so sánh câu lệnh sinh ra cùng thời gian chạy.

Chuẩn bị. Bật PostgreSQL bằng docker compose up -d ở thư mục gốc repo khoá học (như bài 2.6), rồi tạo project:

bash
dotnet new console -n LinqLab
cd LinqLab
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

Thêm file CrmDbContext.cs và thay Program.cs bằng hai đoạn dưới. Lần chạy đầu, chương trình tạo database crm_linq_lab và 50.000 khách hàng mẫu. Code dùng ToList() đồng bộ thay cho ToListAsync để chưa phải học async.

csharp
// CrmDbContext.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";
    public decimal Revenue { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class CrmDbContext(bool logSql) : DbContext
{
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseNpgsql("Host=localhost;Port=5432;Database=crm_linq_lab;Username=postgres;Password=postgres");
        if (logSql)   // in câu SQL EF Core gửi xuống database
            options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
    }
}
csharp
// Program.cs
using System.Diagnostics;

// 1. Tạo database và 50.000 khách hàng mẫu (chỉ chạy lần đầu)
using (var seed = new CrmDbContext(logSql: false))
{
    seed.Database.EnsureCreated();   // tạo database và bảng nếu chưa có — chỉ dùng cho bài thử
    if (!seed.Customers.Any())
    {
        var rnd = new Random(42);
        seed.Customers.AddRange(Enumerable.Range(1, 50_000).Select(i => new Customer
        {
            Name = $"Khách hàng {i}",
            Email = $"kh{i}@example.com",
            Revenue = i % 167 == 0 ? 60_000_000m : rnd.Next(1, 40_000_000),   // 299 khách trên 50 triệu
            CreatedAt = DateTime.UtcNow.AddDays(-rnd.Next(0, 1000)),
        }));
        seed.SaveChanges();
    }
}

using var db = new CrmDbContext(logSql: true);
db.Customers.Where(c => c.Id == 1).ToList();          // làm nóng: mở kết nối, dựng model

// 2. Bài 1 — ToList() đặt TRƯỚC Where
var sw = Stopwatch.StartNew();
var wrong = db.Customers
    .ToList()                               // SQL chạy ngay TẠI ĐÂY
    .Where(c => c.Revenue > 50_000_000m)    // lọc trong bộ nhớ
    .Take(20)
    .ToList();
Console.WriteLine($"ToList trước: {wrong.Count} dòng, {sw.ElapsedMilliseconds} ms\n");

// 3. Bài 1 — ToList() đặt SAU
sw.Restart();
var right = db.Customers
    .Where(c => c.Revenue > 50_000_000m)    // vẫn là IQueryable
    .Take(20)
    .ToList();                              // SQL chạy tại đây
Console.WriteLine($"ToList sau  : {right.Count} dòng, {sw.ElapsedMilliseconds} ms\n");

// 4. Bài 3 — First so với Single
var email = "[email protected]";
var a = db.Customers.FirstOrDefault(c => c.Email == email);
var b = db.Customers.SingleOrDefault(c => c.Email == email);
Console.WriteLine($"First: {a?.Name}, Single: {b?.Name}");
SQL Server

Bật SQL Server bằng docker compose --profile sqlserver up -d, cài gói Microsoft.EntityFrameworkCore.SqlServer thay cho gói Npgsql, và đổi dòng UseNpgsql:

csharp
options.UseSqlServer("Server=localhost,1433;Database=crm_linq_lab;User Id=sa;Password=Crm@123456;TrustServerCertificate=True");

Chạy bằng dotnet run -c Release.

Tiêu chí hoàn thành: bạn có hai câu SQL khác hẳn nhau và giải thích được ToList() đã làm gì.

Gợi ý và lời giải — Bài 1

Gợi ý. ToList() là điểm chuyển từ IQueryable sang IEnumerable. Mọi thứ viết sau nó chạy trong bộ nhớ ứng dụng.

Lời giải. Log của bản sai (ToList() trước), chạy trên PostgreSQL 16 với EF Core 10:

text
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS c

Không có WHERE, không có LIMIT. Toàn bộ bảng được truyền về ứng dụng, rồi mới lọc.

Log của bản đúng:

text
Executed DbCommand (2ms) [Parameters=[@p='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS c
WHERE c."Revenue" > 50000000.0
LIMIT @p

Where thành WHERE, Take(20) thành LIMIT @p (EF Core truyền số 20 qua tham số @p). Hằng số 50_000_000m viết thẳng trong lambda thì được ghi thẳng vào câu SQL; bài 5.7 giải thích vì sao.

SQL Server
text
SELECT TOP(@p) [c].[Id], [c].[CreatedAt], [c].[Email], [c].[Name], [c].[Revenue]
FROM [Customers] AS [c]
WHERE [c].[Revenue] > 50000000.0

SQL Server dùng TOP(@p) thay cho LIMIT, và đặt tên trong ngoặc vuông thay cho nháy kép. Bản sai cũng là SELECT ... FROM [Customers] AS [c] không có WHERE.

So sánh trên bảng 50.000 dòng, trong đó 299 dòng thoả điều kiện (database chạy cùng máy, nên chưa tính thời gian truyền qua mạng thật):

ToList() trướcToList() sau
Số dòng database trả về50.00020
Database làm việc lọcKhôngCó, dùng được chỉ mục nếu có
Bộ nhớ ứng dụng50.000 đối tượng Customer20 đối tượng
Thời gian đo đượckhoảng 450 mskhoảng 25 ms

Vì sao đây là lỗi phổ biến. Hai đoạn code trông gần như giống hệt nhau và cho cùng một kết quả. Với 100 dòng dữ liệu thử, cả hai đều chạy trong chớp mắt. Chênh lệch chỉ xuất hiện khi dữ liệu lớn lên — tức là trên production, sáu tháng sau.

Ba dấu hiệu nhận ra trong code review:

csharp
// 1. ToList() rồi mới Where
db.Customers.ToList().Where(...)

// 2. AsEnumerable() ở giữa chuỗi
db.Customers.AsEnumerable().Where(...)

// 3. Gọi một phương thức C# mà EF Core không dịch được sang SQL
db.Customers.Where(c => CalculateScore(c) > 50)   // EF Core buộc phải tải hết về

Dạng thứ ba nguy hiểm nhất, vì nó không có ToList() nào để nhìn thấy. Từ EF Core 3.0, trường hợp này ném ngoại lệ thay vì âm thầm chuyển sang đánh giá trong bộ nhớ — một thay đổi rất đáng hoan nghênh:

text
System.InvalidOperationException: The LINQ expression '...' could not be
translated. Either rewrite the query in a form that can be translated, or
switch to client evaluation explicitly by inserting a call to 'AsEnumerable'.

Cách phòng có hệ thống. Đặt quy ước: chỉ gọi ToList/ToListAsync ở cuối chuỗi truy vấn, đúng một lần. Và trong quá trình phát triển, bật log SQL rồi thỉnh thoảng đọc nó — cách nhanh nhất để phát hiện những truy vấn không như bạn tưởng. Module 13 trình bày đầy đủ, còn bài Vì sao EF Core bắn 201 query cho 1 màn hình danh sách? đo chi phí của một biến thể khác cùng họ.

Bài 2 — Đếm số lần truy vấn thật sự chạy ​

Tạo một IEnumerable với Where có bộ đếm bên trong. Gọi Count(), First() rồi foreach. Đếm số lần và giải thích.

Tiêu chí hoàn thành: bạn dự đoán đúng con số trước khi chạy, kể cả con số của First().

Gợi ý và lời giải — Bài 2

Gợi ý. Where không chạy gì khi được khai báo — nó chỉ dựng một mô tả. Mỗi thao tác kết thúc như Count, First, ToList hay foreach đều duyệt lại nguồn từ đầu. Riêng First có một đặc điểm riêng: nó dừng ngay khi tìm thấy.

Lời giải.

csharp
var source = Enumerable.Range(1, 5);
int calls = 0;
var q = source.Where(x => { calls++; return x % 2 == 1; });

Console.WriteLine($"sau khi khai báo Where : {calls} lần");
var c = q.Count();
Console.WriteLine($"sau Count()            : {calls} lần (kết quả {c})");
var f = q.First();
Console.WriteLine($"sau First()            : {calls} lần (kết quả {f})");
foreach (var _ in q) { }
Console.WriteLine($"sau foreach            : {calls} lần");

Kết quả chạy trên .NET 10:

text
sau khi khai báo Where : 0 lần
sau Count()            : 5 lần (kết quả 3)
sau First()            : 6 lần (kết quả 1)
sau foreach            : 11 lần

Giải thích từng bước:

Thao tácSố lần tăng thêmVì sao
Khai báo Where0Chỉ dựng mô tả, chưa chạy gì
Count()+5Phải duyệt hết để đếm
First()+1Dừng ngay ở phần tử đầu tiên thoả điều kiện
foreach+5Duyệt lại từ đầu

Con số của First() là phần đáng chú ý nhất. Nhiều người đoán +5, nhưng First() dừng lại ngay khi tìm được — ở đây phần tử đầu tiên là 1, vốn đã lẻ, nên chỉ cần một lần gọi. Đây là biểu hiện của việc đánh giá trễ: các toán tử LINQ kéo dữ liệu theo nhu cầu, không đẩy toàn bộ qua đường ống.

Hậu quả thực tế của việc duyệt nhiều lần:

csharp
var vipCustomers = db.Customers.Where(c => c.Revenue > 50_000_000m);   // IQueryable

if (vipCustomers.Any())                              // SQL lần 1
{
    Console.WriteLine($"Có {vipCustomers.Count()}"); // SQL lần 2
    foreach (var k in vipCustomers) { }              // SQL lần 3
}

Ba lần truy vấn database cho một việc. Tệ hơn: nếu dữ liệu thay đổi giữa các lần, ba lần cho ba kết quả khác nhau — dẫn tới lỗi rất khó tái hiện.

Sửa bằng cách hiện thực hoá một lần:

csharp
var vipCustomers = db.Customers
    .Where(c => c.Revenue > 50_000_000m)
    .ToList();                                // SQL đúng một lần

if (vipCustomers.Count > 0)
{
    Console.WriteLine($"Có {vipCustomers.Count}");
    foreach (var k in vipCustomers) { }
}

Kiểm chứng bằng bộ đếm ở trên: đặt lại calls = 0, gọi var list = q.ToList(); rồi dùng list.Count, list[0] và một vòng foreach trên list. Bộ đếm không tăng thêm:

text
sau ToList() 1 lần      : 5 lần
dùng lại list 3 lần nữa : 5 lần (không tăng)

Quy tắc thực dụng. Đánh giá trễ có lợi khi bạn đang xây dựng truy vấn qua nhiều bước và chỉ chạy một lần ở cuối. Nó có hại khi bạn dùng lại cùng một truy vấn nhiều lần. Ranh giới rất đơn giản:

Truy vấn dùng một lần → giữ trễ. Truy vấn dùng nhiều lần → ToList() một lần rồi dùng danh sách.

Một mẹo giúp đọc code dễ hơn. Đặt tên biến theo trạng thái của nó: vipCustomersQuery cho IQueryable, vipCustomersList cho danh sách đã hiện thực hoá. Người đọc biết ngay cái nào chạm database.

Bài 3 — Single so với First ​

Dùng project của Bài 1 (phần "Bài 3" trong Program.cs): chạy FirstOrDefault và SingleOrDefault trên cùng một điều kiện. So sánh câu SQL sinh ra, đặc biệt mệnh đề giới hạn số dòng.

Tiêu chí hoàn thành: bạn nêu được vì sao Single lấy về hai dòng, và khi nào nên chấp nhận chi phí đó.

Gợi ý và lời giải — Bài 3

Gợi ý. Single hứa rằng có đúng một kết quả và ném ngoại lệ nếu có nhiều hơn. Để kiểm tra lời hứa đó, nó cần bằng chứng gì?

Lời giải — SQL sinh ra (PostgreSQL 16, EF Core 10):

csharp
var a = db.Customers.FirstOrDefault(c => c.Email == email);
sql
SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS c
WHERE c."Email" = @email
LIMIT 1
csharp
var b = db.Customers.SingleOrDefault(c => c.Email == email);
sql
SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS c
WHERE c."Email" = @email
LIMIT 2
SQL Server
sql
SELECT TOP(1) [c].[Id], [c].[CreatedAt], [c].[Email], [c].[Name], [c].[Revenue]
FROM [Customers] AS [c]
WHERE [c].[Email] = @email

SELECT TOP(2) [c].[Id], [c].[CreatedAt], [c].[Email], [c].[Name], [c].[Revenue]
FROM [Customers] AS [c]
WHERE [c].[Email] = @email

email là một biến, nên EF Core truyền nó qua tham số @email chứ không ghi thẳng giá trị vào SQL. Trong code thật bạn sẽ dùng FirstOrDefaultAsync, SingleOrDefaultAsync: câu SQL giống hệt.

LIMIT 2 chứ không phải LIMIT 1. Single phải lấy về dòng thứ hai để biết có nên ném ngoại lệ hay không. Nếu chỉ lấy một dòng, nó không có cách nào phát hiện trường hợp trùng.

Bốn biến thể và ngữ nghĩa:

Phương thứcKhông có kết quảCó nhiều hơn mộtLIMIT
First()Ném ngoại lệTrả cái đầu tiên1
FirstOrDefault()Trả nullTrả cái đầu tiên1
Single()Ném ngoại lệNém ngoại lệ2
SingleOrDefault()Trả nullNém ngoại lệ2

Khi nào chấp nhận chi phí của Single. Chi phí là một dòng dữ liệu thừa — gần như không đáng kể. Đổi lại bạn nhận được một bài kiểm tra tính toàn vẹn dữ liệu chạy liên tục trên production:

csharp
// Email lẽ ra là duy nhất — nếu không, đó là dữ liệu hỏng và cần biết ngay
var customer = db.Customers.SingleOrDefault(c => c.Email == email);

Nếu một ngày có hai khách hàng cùng email — do lỗi nhập liệu, do di chuyển dữ liệu, do thiếu ràng buộc duy nhất — Single ném ngoại lệ ngay. Với First, hệ thống lặng lẽ chọn một trong hai, và sai lệch tích tụ trong nhiều tháng trước khi ai đó phát hiện.

Quy tắc chọn:

Tình huốngDùng
Lọc theo khoá chính hoặc cột có ràng buộc duy nhấtSingleOrDefault
Lấy bản ghi mới nhất, cũ nhất, hoặc bất kỳ một trong nhiềuFirstOrDefault kèm OrderBy
Chắc chắn phải có kết quả, không có là lỗi lập trìnhSingle hoặc First (bản ném ngoại lệ)
Không chắc có hay không, và không có là bình thườngBản OrDefault

Một điều bắt buộc với First. Luôn kèm OrderBy. Không có nó, database không bảo đảm thứ tự — cùng một truy vấn có thể trả về dòng khác nhau ở hai lần chạy, tuỳ vào kế hoạch thực thi và trạng thái dữ liệu:

csharp
// KHÔNG TẤT ĐỊNH — "đầu tiên" theo thứ tự nào?
var order = db.Orders.Where(o => o.CustomerId == id).FirstOrDefault();

// TẤT ĐỊNH
var order = db.Orders.Where(o => o.CustomerId == id)
                     .OrderByDescending(o => o.CreatedAt)
                     .FirstOrDefault();

Và quan trọng hơn cả. Single là lưới an toàn ở tầng ứng dụng, không thay thế được ràng buộc duy nhất trong database. Ràng buộc ở database chặn dữ liệu trùng ngay khi ghi; Single chỉ phát hiện nó khi đọc, tức là sau khi dữ liệu hỏng đã tồn tại. Hãy có cả hai.

Tự kiểm tra ​

Đánh giá trễ trong LINQ nghĩa là gì?

Where, Select, OrderBy chỉ mô tả việc cần làm chứ không chạy gì. Truy vấn chỉ thật sự chạy khi có thao tác buộc phải duyệt như ToList, Count, Any, First hoặc một vòng foreach. Nhờ đó bạn ghép thêm điều kiện theo nhánh if mà cuối cùng vẫn chỉ sinh ra một câu SQL.

Duyệt một truy vấn LINQ hai lần thì sao?

Nó chạy lại từ đầu hai lần. Với dữ liệu trong bộ nhớ đó là lãng phí CPU; với IQueryable trên database đó là hai câu truy vấn thật sự. Nếu cần dùng kết quả nhiều hơn một lần thì gọi ToList để vật chất hoá đúng một lần.

IEnumerable và IQueryable khác nhau ở đâu?

IQueryable dịch biểu thức thành SQL và để database thực thi. IEnumerable chạy delegate ngay trong tiến trình .NET. Vì thế gọi ToList quá sớm sẽ kéo toàn bộ bảng về bộ nhớ rồi mới lọc, trong khi giữ IQueryable thì điều kiện lọc chạy ngay trong database.

Vì sao hàm truy vấn không nên trả về IEnumerable?

Vì mọi thao tác LINQ sau đó sẽ chạy trong bộ nhớ chứ không đẩy xuống database. Một hàm trả IEnumerable của Orders rồi người gọi thêm Where và Take sẽ kéo cả bảng về trước. Trả IQueryable khi người gọi còn lọc tiếp, và chỉ vật chất hoá ở nơi biết rõ mình muốn gì.

Chọn giữa Single và First thế nào?

Theo ý định. Tra theo khoá chính và kỳ vọng đúng một bản ghi thì dùng SingleOrDefault, vì nếu có hai thì dữ liệu đã sai và bạn muốn biết ngay. Lấy bản ghi mới nhất sau khi sắp xếp thì dùng FirstOrDefault. Về hiệu năng, Single phải đọc hai bản ghi để xác nhận không có cái thứ hai.

AsNoTracking dùng khi nào?

Cho mọi truy vấn chỉ đọc. EF Core mặc định dựng bộ theo dõi thay đổi cho các entity lấy về để biết cái nào đã sửa. Nếu bạn không định sửa gì thì công đó là thừa, và AsNoTracking tiết kiệm được bộ nhớ lẫn thời gian đáng kể trên tập dữ liệu lớn.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. LINQ chạy trễ. Biết chính xác dòng nào buộc truy vấn chạy là biết được nó chạy mấy lần.
  2. .ToList() là ranh giới giữa database và bộ nhớ. Đặt sai một dòng là đổi vài chục bản ghi thành ba triệu.
  3. Any() thay Count() > 0, AsNoTracking() cho truy vấn đọc. Hai thói quen nhỏ, hiệu quả thấy ngay.

Tham khảo ​

Điều hướng ​

Bài liên quan ​

Phát hành theo giấy phép MIT.