Thực tế, dưới hai interface này là hai cơ chế xử lý khác nhau: một bên thao tác trực tiếp trên dữ liệu nằm trong RAM, một bên âm thầm build expression rồi dịch thành SQL đẩy xuống database.

Đôi lúc, chỉ một lần .ToList() sai chỗ cũng đủ làm hệ thống kéo toàn bộ dữ liệu bảng về memory rồi mới đi filter bằng C#. Thử "mổ bụng" sự khác biệt giữa IEnumerableIQueryable và coi vì sao nhìn gần giống nhau nhưng lại tạo ra performance khác biệt lớn trong dự án thực tế.

LINQ đã lừa mình

Có bao giờ nhìn vào hai đoạn code này, và thấy nó gần như giống nhau?

var users = _context.Users
    .Where(x => x.Age > 18)
    .Take(10);

var users = _context.Users
    .ToList()
    .Where(x => x.Age > 18)
    .Take(10);

Nhìn qua chỉ khác mỗi .ToList()

Nhưng runtime behavior hai đoạn code này thì khác hoàn toàn.

Một bên sẽ generate SQL:

SELECT TOP(10) * 
FROM Users 
WHERE Age > 18

Bên còn lại có thể:

SELECT * FROM Users

Tiếp theo, nó sẽ kéo toàn bộ về RAM, sau đó mới bắt đầu filter bằng C#.

Đây cũng lý do khiến nhiều hệ thống:

  • Query chậm bất thường
  • RAM tăng mạnh
  • CPU app server tăng cao
  • Database traffic lớn vô lý

Trong khi nhìn lại LINQ vẫn thấy... bình thường, không có gì vô lý.

Và cái LINQ nó lừa mình ở chỗ:

IEnumerableIQueryable dùng syntax gần như nhau, nhưng phía dưới là hai cơ chế hoàn toàn khác nhau.

IEnumerable - dữ liệu đã trong memory

Khi code như vậy:

IEnumerable<User>

Nghĩa là dữ liệu lúc này đã nằm trong RAM.

IEnumerable không biết gì về:

  • SQL
  • Database
  • Network
  • Query optimization

Nó chỉ biết:

Tao đang có một đống object C# trong memory

Ví dụ:

var users = _context.Users.ToList();

Ngay khi .ToList() chạy:

Database
    ↓
Kéo dữ liệu về RAM
    ↓
Biến thành List<User>

Từ thời điểm này:

users.Where(x => x.Age > 18)

Sẽ filter bằng C#.

Nghĩa là:

  • CPU app server xử lý
  • Loop chạy trong memory
  • Dữ liệu đã load lên hết rồi

Lúc này LINQ chỉ còn thao tác collection bình thường.

IQueryable - query nhưng sao chưa chạy

Ví dụ:

var users = _context.Users
    .Where(x => x.Age > 18);

Nhìn qua tưởng query đã chạy rồi đúng không?!

Không.

Ở thời điểm này, Entity Framework vẫn chưa query database.

Lúc này nó chỉ đang:

  • Ghi nhận điều kiện query
  • Build expression tree (Debug đặt con trỏ ngay dòng này là thấy)
  • Chuẩn bị generate SQL

Nói đơn giản hơn:

IQueryable chưa giữ dữ liệu thật.

Nó chỉ đagn giữ "bản thảo" query. Đây cũng là lý do có khái niệm:

Deferred Execution.

Deferred Execution - query chưa chạy khi người nghĩ

Ví dụ:

var users = _context.Users
    .Where(x => x.Age > 18);

SQL vẫn chưa được gửi xuống database.

Chỉ khi gặp những thứ như:

.ToList()
.First()
.Count()
.Any()

Query lúc này mới thật sự chạy.

Ví dụ:

var query = _context.Users
    .Where(x => x.Age > 18);

Console.WriteLine("Chưa query DB");

var result = query.ToList();

Flow nó sẽ chạy như vậy:

Build expression tree
    ↓
Chưa query gì cả
    ↓
Gặp ToList()
    ↓
Generate SQL
    ↓
Query database

Đây cũng là lý do nhiều người vô tình query database nhiều lần mà không biết.

var query = _context.Users
    .Where(x => x.Age > 18);

var count = query.Count();

var users = query.ToList();

Có phải code này nó đang bắn xuống database với hai query khác nhau?!

Cú lừa của ToList()

Ví dụ:

var users = _context.Users
    .ToList()
    .Where(x => x.Age > 18);

Nhiều người sẽ nghĩ:

"Where đang nằm đó, chắc database sẽ filter"

Không.

Ngay khi .ToList() xuất hiện:

IQueryable
    ↓
Query database
    ↓
Kéo toàn bộ data về RAM
    ↓
Biến thành IEnumerable/List

Sau thời điểm đó:

.Where()
.OrderBy()
.Take()

Đều chạy bằng C# trong memory.

Ví dụ một bảng:

5 triệu rows

Application có thể:

  • Kéo 5 triệu record đó về RAM
  • Ngốn memory cực khủng
  • Filter bằng CPU app server

Trong khi database đáng lẽ sẽ làm tốt hơn rất nhiều.

Đây là kiểu bug "vô hại" nhưng lại phá performance rất mạnh ở production.

Expression Tree - thứ đứng sau IQueryable

Khi viết:

.Where(x => x.Age > 18)

lambda này đang chạy.

Không hẳn vậy.

Với IQueryable, lambda lúc này thường chưa được execute ngay.

Nó sẽ thành:

Expression<Func<User, bool>>

Nói đơn giản, thay vì chạy code, framework sẽ lưu "cấu trúc" của đoạn query.

Ví dụ:

Age
 >
18

Entity Framework đọc expression tree rồi transalte thành SQL.

Flow thật của nó:

LINQ
   ↓
Expression Tree
   ↓
EF Core Query Provider
   ↓
Generate SQL
   ↓
Database

Đây là lý do không phải mọi method C# đều translate được sang SQL.

Ví dụ:

.Where(x => MyCustomMethod(x.Name))

Entity Framework có thể báo lỗi:

Cannot translate expression to SQL

Vì database lúc này hoàn toàn không biết MyCustomMethod() là gì.

AsEnumerable() - điểm chuyển từ database sang memory

Một method nhìn rất vô hại khác:

.AsEnumerable()

Ví dụ:

_context.Users
    .Where(x => x.Age > 18)
    .AsEnumerable()
    .Where(x => x.Name.StartsWith("D"));

Flow nó chạy:

WHERE Age > 18 -> chạy ở SQL
    ↓
Kéo data về RAM
    ↓
Name.StartsWith("D") -> chạy bằng C#

Sau .AsEnumerable():

  • Query không còn translate sang SQL nữa
  • Mọi thứ phía sau chạy trong memory

Đây là chỗ rất dễ bị bỏ qua khi debug performance.

IQueryable mạnh, nhưng không phải magic

.Where()
.Select()
.Join()
.GroupBy()
.OrderBy()
.Skip()
.Take()

Nhiều người sau khi hiểu IQueryable bắt đầu chain LINQ khắp nơi, Framework vẫn generate SQL được.

Nhưng đôi khi SQL sẽ tạo ra rất "khủng"

Ví dụ:

  • Nested subquery
  • Multiple join
  • Query plan rất nặng
  • Execution chậm

Lúc này:

  • SQL khó debug
  • Không đoán được performance
  • Query optimize rất khó

IQueryable rất mạnh, nhưng mạnh do người viết, không phải lúc nào LINQ cũng xử lý cho SQL tối ưu.

Kết luận

IEnumerable<T>
IQueryable<T>

Nhìn bề ngoài, nó giống hệt nhau. Thậm chí synxtax LINQ cũng giống nhau.

.Where()
.Select()
.OrderBy()

Nhưng phía dưới lại là hai cơ chế xử lý hoàn toàn khác nhau:

  • IEnumerable thao tác dữ liệu đã nằm trong RAM
  • IQueryable build query để đẩy xuống database

Chỉ một lần .ToList() hoặc .AsEnumerable() đặt sai chỗ sẽ làm:

  • Query kéo cả bảng
  • RAM tăng mạnh
  • CPU app server đốt vô ích
  • System chậm đi

Hiểu được ranh giới không những giúp viết LINQ “ngầu” hơn. Nhưng nó cho một thứ quan trọng hơn nhiều:

Khả năng nhìn ra dữ liệu đang được xử lý ở đâu.

Khi application bắt đầu:

  • Query chậm
  • Generate SQL kỳ lạ
  • RAM tăng bất thường
  • Database bị spam query
  • EF Core kéo cả bảng về memory

Lúc này sẽ biết chính xác vấn đề đang nằm ở tầng nào thay vì debug trong tuyệt vọng.