5.4 — 3. LINQ
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ể:
- 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.
Nội dung bài học
5.4.1 — Hai cú pháp, một kết quả
// Cú pháp truy vấn — gần với SQL
var q1 = from o in orders
where o.Total >= 500_000m
orderby o.CreatedAt descending
select new { o.Code, o.Total };
// Cú pháp phương thức — phổ biến hơn trong .NET hiện đại
var q2 = orders
.Where(o => o.Total >= 500_000m)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new { o.Code, o.Total });
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.4.2 — Đánh giá trễ: câu lệnh chạy lúc nào
var query = orders.Where(o => { Console.WriteLine("đang lọc"); return o.IsPaid; });
Console.WriteLine("đã tạo truy vấn"); // in ra TRƯỚC
foreach (var o 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, 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.
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.4.3 — 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 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.
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.4.4 — Bẫy 2: IQueryable biến thành IEnumerable
Đâ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.
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ọ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.4.5 — 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.4.6 — 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: 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. Trên tập dữ liệu lớn không có index, khác biệt này có thật.
5.4.7 — 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.4.8 — 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 vài chục nghìn dòng, viết 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.
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 ý. Bật log SQL:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
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 — bản sai:
var kq = 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();
SQL sinh ra:
SELECT c."Id", c."Name", c."Email", c."Revenue", c."CreatedAt", ...
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.
Bản đúng:
var kq = await db.Customers
.Where(c => c.Revenue > 50_000_000m) // vẫn là IQueryable
.Take(20)
.ToListAsync(ct); // SQL chạy tại đây
SQL sinh ra:
SELECT c."Id", c."Name", c."Email", c."Revenue", c."CreatedAt", ...
FROM "Customers" AS c
WHERE c."Revenue" > 50000000.0
LIMIT 20
So sánh trên bảng 50.000 dòng, trong đó 300 dòng thoả điều kiện:
ToList() trước | ToList() sau | |
|---|---|---|
| Số dòng truyền qua mạng | 50.000 | 20 |
| Database làm việc lọc | Không | Có, dùng được chỉ mục |
| Bộ nhớ ứng dụng | Toàn bộ bảng | 20 đối tượng |
| Thời gian | Hàng trăm mili-giây tới vài giây | Vài mili-giây |
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 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 nguon = Enumerable.Range(1, 5);
int dem = 0;
var q = nguon.Where(x => { dem++; return x % 2 == 1; });
Console.WriteLine($"sau khi khai báo Where : {dem} lần");
var c = q.Count();
Console.WriteLine($"sau Count() : {dem} lần (kết quả {c})");
var f = q.First();
Console.WriteLine($"sau First() : {dem} lần (kết quả {f})");
foreach (var _ in q) { }
Console.WriteLine($"sau foreach : {dem} lần");
Kết quả đo thật trên .NET 9:
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á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 khachVip = db.Customers.Where(c => c.Revenue > 50_000_000m); // IQueryable
if (khachVip.Any()) // SQL lần 1
{
Console.WriteLine($"Có {khachVip.Count()}");// SQL lần 2
foreach (var k in khachVip) { } // 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 khachVip = await db.Customers
.Where(c => c.Revenue > 50_000_000m)
.ToListAsync(ct); // SQL đúng một lần
if (khachVip.Count > 0)
{
Console.WriteLine($"Có {khachVip.Count}");
foreach (var k in khachVip) { }
}
Kiểm chứng: sau ToList(), dùng lại danh sách bao nhiêu lần cũng không làm bộ đếm 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ó: khachVipQuery cho IQueryable, khachVipList 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
Bật log SQL, chạy SingleOrDefault và FirstOrDefault 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:
var a = await db.Customers.FirstOrDefaultAsync(c => c.Email == email, ct);
SELECT c."Id", c."Name", c."Email", ...
FROM "Customers" AS c
WHERE c."Email" = @email
LIMIT 1
var b = await db.Customers.SingleOrDefaultAsync(c => c.Email == email, ct);
SELECT c."Id", c."Name", c."Email", ...
FROM "Customers" AS c
WHERE c."Email" = @email
LIMIT 2
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 kh = await db.Customers.SingleOrDefaultAsync(c => c.Email == email, ct);
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 don = await db.Orders.Where(o => o.CustomerId == id).FirstOrDefaultAsync(ct);
// TẤT ĐỊNH
var don = await db.Orders.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.FirstOrDefaultAsync(ct);
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
Frequently asked questions
Đá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 — 2. Generics
- Bài tiếp theo: 5.4 — 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.