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ễ:Wherekhô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,IEnumerablevàIQueryabletrông giống hệt nhau nhưng chạy ở hai nơi khác nhau —IQueryabledịch biểu thức thành SQL, cònIEnumerablexử lý trong bộ nhớ. Gọi.ToList()sớm một dòng là biến một câuWHEREtrong 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,SelectManytrê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
IEnumerablevớiIQueryablevà 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.
// 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);// 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):
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 |
|---|---|---|
Where | Giữ phần tử thoả điều kiện | WHERE |
Select | Biến mỗi phần tử thành thứ khác | danh sách cột sau SELECT |
OrderBy, ThenBy, OrderByDescending | Sắp xếp, nhiều tiêu chí | ORDER BY a, b |
GroupBy | Gom nhóm theo khoá, mỗi nhóm có Key | GROUP BY |
Join | Ghép hai danh sách theo khoá | INNER JOIN |
Count, Sum, Average, Any | Tổng hợp ra một giá trị | COUNT, SUM, AVG, EXISTS |
ToList, ToDictionary | Chép kết quả vào collection | |
SelectMany | Trả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ả
// 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
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 inWhere, 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, OrderBy | ToList, ToArray, ToDictionary |
Take, Skip, Distinct | Count, Sum, Any, First |
GroupBy, Join, SelectMany | foreach |
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.
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ất5.3.4 — Bẫy 1: duyệt nhiều lần
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 3Vớ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.
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ạiQuy 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ặcdb) là mộtDbContext, đại diện cho database._db.Orderslà bảngOrders, có kiểuIQueryable<Order>.ToListAsync(ct)là bản bất đồng bộ củaToList(): chạy câu SQL rồi trả danh sách.awaitvà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.
// 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:


Cùng một lỗi có thể đến từ chỗ khác — một hàm trả về sai kiểu:
// 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ọcNhậ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
// 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ức | Không có phần tử | Nhiều hơn một |
|---|---|---|
First() | Ném | Lấy cái đầu |
FirstOrDefault() | null / default | Lấy cái đầu |
Single() | Ném | Ném |
SingleOrDefault() | null / default | Né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
// 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:
// 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:
dotnet new console -n LinqLab
cd LinqLab
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQLThê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.
// 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);
}
}// 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:
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:
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS cKhô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:
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 @pWhere 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
SELECT TOP(@p) [c].[Id], [c].[CreatedAt], [c].[Email], [c].[Name], [c].[Revenue]
FROM [Customers] AS [c]
WHERE [c].[Revenue] > 50000000.0SQL 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ước | ToList() sau | |
|---|---|---|
| Số dòng database trả về | 50.000 | 20 |
| Database làm việc lọc | Không | Có, dùng được chỉ mục nếu có |
| Bộ nhớ ứng dụng | 50.000 đối tượng Customer | 20 đối tượng |
| Thời gian đo được | khoảng 450 ms | khoả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:
// 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:
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.
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:
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ầnGiải thích từng bước:
| Thao tác | Số lần tăng thêm | Vì sao |
|---|---|---|
Khai báo Where | 0 | Chỉ dựng mô tả, chưa chạy gì |
Count() | +5 | Phải duyệt hết để đếm |
First() | +1 | Dừng ngay ở phần tử đầu tiên thoả điều kiện |
foreach | +5 | Duyệ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:
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:
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:
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):
var a = db.Customers.FirstOrDefault(c => c.Email == email);SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS c
WHERE c."Email" = @email
LIMIT 1var b = db.Customers.SingleOrDefault(c => c.Email == email);SELECT c."Id", c."CreatedAt", c."Email", c."Name", c."Revenue"
FROM "Customers" AS c
WHERE c."Email" = @email
LIMIT 2SQL Server
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] = @emailemail 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ức | Không có kết quả | Có nhiều hơn một | LIMIT |
|---|---|---|---|
First() | Ném ngoại lệ | Trả cái đầu tiên | 1 |
FirstOrDefault() | Trả null | Trả cái đầu tiên | 1 |
Single() | Ném ngoại lệ | Ném ngoại lệ | 2 |
SingleOrDefault() | Trả null | Né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:
// 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ống | Dùng |
|---|---|
| Lọc theo khoá chính hoặc cột có ràng buộc duy nhất | SingleOrDefault |
| Lấy bản ghi mới nhất, cũ nhất, hoặc bất kỳ một trong nhiều | FirstOrDefault kèm OrderBy |
| Chắc chắn phải có kết quả, không có là lỗi lập trình | Single 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ường | Bả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:
// 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:
- 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.
.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.Any()thayCount() > 0,AsNoTracking()cho truy vấn đọc. Hai thói quen nhỏ, hiệu quả thấy ngay.
Tham khảo
- LINQ overview
- Standard query operators — danh sách đầy đủ
- Async programming in EF Core
- Entity Framework Core — phần theo dõi thay đổi và dịch truy vấn
Điều hướng
- Bài trước: 5.2 — Generics
- Bài tiếp theo: 5.4 — Delegates và Events
- Về module: Trang mục lục
Bài liên quan
- Vì sao EF Core bắn 201 query cho 1 màn hình danh sách? Cách sửa N+1 — Một màn hình 200 dòng mà log SQL ghi 201 câu lệnh là dấu hiệu của N+1 query.
- Có index rồi mà truy vấn vẫn quét toàn bảng? Sargability và một hàm bọc quanh cột — Cột đã có index, câu WHERE lọc đúng cột đó, nhưng execution plan vẫn là Seq Scan.