Gỡ lỗi Entity Framework Core: 8 Phản mẫu Truy vấn Thực tế (và Cách Khắc phục)

Đây là bài viết của tôi cho sự kiện C# Advent 2025. Hãy xem tất cả các bài viết tuyệt vời!

Tôi xin chúc các bạn một Giáng sinh vui vẻ, Kỳ nghỉ Hạnh phúc, Lễ Hanukkah vui tươi, Kwanzaa ấm áp, và cuối cùng là Festivus vui vẻ. Tôi sẽ không chia sẻ “Lời than phiền” hay thách đấu ai trong “Kỳ tích sức mạnh” đâu.

Entity Framework Core là một thư viện tuyệt vời cho các thao tác CRUD với cơ sở dữ liệu. Thật tuyệt khi sử dụng LINQ để tạo truy vấn và lấy dữ liệu. Điều khiến tôi ngạc nhiên nhất là nhiều nhà phát triển sẽ không xem xét các truy vấn lấy dữ liệu, ngay cả khi chúng có thể không “nhanh nhất”. Các nhà phát triển không cố tình làm gì sai cả. Họ thiếu kỹ năng hoặc không có góc nhìn để giải quyết một số phản mẫu (anti-patterns) trong bài viết này. Tôi hy vọng bạn sẽ nhận được nhiều giá trị như tôi đã có khi tạo ra bài nói chuyện tại hội nghị và bản demo ASP.NET Core.

Bản demo “Bad Book Store” này cố tình mô hình hóa một schema SQL “tồi” để làm lộ ra các phản mẫu phổ biến trong truy vấn Entity Framework Core. Mỗi kịch bản đều cho thấy: hình dạng LINQ, lý do nó chậm (SQL Server xử lý nó thế nào), và hai cấp độ khắc phục: các biện pháp giảm nhẹ nhanh bên phía DB và các thay đổi mô hình dữ liệu phù hợp giúp EF Core nhanh ngay từ thiết kế.

Bạn có thể tìm thấy bản demo tại kho GitHub sau: https://github.com/cwoodruff/DebuggingEFCoreMSSQL (dự án nằm trong thư mục demo)

Các tệp liên quan để xem qua:

  • Pages/Demo/Index.cshtml.cs — 8 truy vấn demo và giải thích
  • Data/BadBookStoreContext.cs — Mô hình EF Core; chú ý các trường ngày/giờ & số được khai báo là nvarchar
  • Data/FixScripts.cs — Các bản sửa T‑SQL (cột tính toán + chỉ mục)

Bạn có thể tìm thấy tệp BAK và script SQL để tạo cơ sở dữ liệu tại đây: https://github.com/cwoodruff/DebuggingEFCoreMSSQL/tree/master/database

Tôi khuyên bạn nên tạo cơ sở dữ liệu MSSQL (có dữ liệu) để tự tay chạy demo và xem lại mã nguồn cho bài viết này.

Truy vấn 1 — Đơn hàng theo CustomerEmail + Khoảng Ngày (Ngày lưu dưới dạng Chuỗi)

EF LINQ shape:

var start = "2023-01-01";
var end   = "2025-12-31";
var q1 = db.Orders
    .Where(o => o.CustomerEmail == "customer008@example.com"
             && string.Compare(o.OrderDate!, start) >= 0
             && string.Compare(o.OrderDate!, end)   <  0
             && o.OrderStatus == "Completed")
    .Select(o => new { o.OrderId, o.OrderDate, o.OrderTotal });

Tại sao nó chậm

  • OrderDate là nvarchar(30). So sánh chuỗi giết chết khả năng sargability và không có đường dẫn tìm kiếm tổng hợp (composite seek path).
  • Dự kiến sẽ có index/clustered scans, số lượng logical reads cao.

Fix V1 (nhanh bên DB)

  • Tạo một cột tính toán (computed) datetime2 từ chuỗi và đánh chỉ mục với email:
  • ALTER TABLE Orders ADD OrderDate_dt AS TRY_CONVERT(datetime2(3), OrderDate) PERSISTED;
  • CREATE INDEX IX_Orders_CustomerEmail_OrderDate_dt ON dbo.Orders(CustomerEmail, OrderDate_dt) INCLUDE (OrderTotal, OrderStatus);

Cách khắc phục đúng (model + EF)

  • Lưu OrderDate dưới dạng datetime2 trong bảng. Trong EF, ánh xạ nó thành DateTime/DateTime?.
  • Thêm chỉ mục tổng hợp trong model builder/migration:
modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerEmail, o.OrderDate });
  • Viết các vị từ (predicate) phạm vi bình thường trên DateTime — EF sẽ dịch thành một range scan có thể tìm kiếm (seekable).

Truy vấn 2 — Nối Reviews ↔ Books theo Title (Văn bản Rộng, Không Duy nhất)

EF LINQ shape:

var q2 = from r in db.Reviews
         join b in db.Books on r.BookTitle equals b.Title
         where r.Rating >= 4
         select new { r.ReviewId, b.Isbn, b.Title, r.Rating };

Tại sao nó chậm

  • Nối trên một trường nvarchar(500) rộng, không duy nhất dẫn đến hash joins với dung lượng bộ nhớ cấp phát lớn hoặc phải quét (scan).

Fix V1 (nhanh bên DB)

  • CREATE INDEX IX_Books_Title ON dbo.Books(Title); — giúp ích cho phía probe, nhưng chỉ là giải pháp tạm thời.

Cách khắc phục đúng (model + EF)

  • Sử dụng khóa ổn định: BookId hoặc ISBN (đã là khóa chính). Biến Reviews lưu khóa ngoại đến Books.
  • Nối dựa trên navigation trong EF:
// Sau khi thêm Review.BookIsbn FK → Book
var q2 = db.Reviews
    .Where(r => r.Rating >= 4)
    .Select(r => new { r.ReviewId, r.Book!.Isbn, r.Book.Title, r.Rating });
  • Cũng nên đánh chỉ mục các cột lọc phổ biến trên Reviews (ví dụ: Rating, CustomerEmail) nếu cần.

Truy vấn 3 — Nối Cha ↔ Con Không Có Chỉ mục trên Khóa Ngoại (OrderLines.OrderId)

EF LINQ shape:

var q3 = from o in db.Orders
         join ol in db.OrderLines on o.OrderId equals ol.OrderId
         where o.OrderStatus == "Completed"
         select new { o.OrderId, ol.BookTitle, ol.Quantity, ol.UnitPrice };

Tại sao nó chậm

  • Thiếu chỉ mục trên OrderLines(OrderId) khiến nested loops phải quét lặp đi lặp lại, hoặc optimizer buộc phải dùng hash joins với full scans.

Fix V1 (nhanh bên DB)

  • CREATE INDEX IX_OrderLines_OrderId ON dbo.OrderLines(OrderId) INCLUDE (BookTitle, Quantity, UnitPrice, Currency);

Cách khắc phục đúng (model + EF)

  • Luôn đánh chỉ mục cho các khóa ngoại. Trong migrations:
migrationBuilder.CreateIndex(
    name: "IX_OrderLines_OrderId",
    table: "OrderLines",
    column: "OrderId");
  • Ưu tiên sử dụng navigation:
var q3 = db.Orders
    .Where(o => o.OrderStatus == "Completed")
    .SelectMany(o => o.OrderLines
        .Select(ol => new { o.OrderId, ol.BookTitle, ol.Quantity, ol.UnitPrice }));

Truy vấn 4 — Tồn kho theo BookISBN với Khóa Chính Tổng hợp Dạng Chuỗi

EF LINQ shape:

var q4 = db.Inventories
    .Where(i => i.BookIsbn == "978-1-4028-0009-9")
    .Select(i => new { i.WarehouseCode, i.BookIsbn, i.QuantityOnHand });

Tại sao nó chậm

  • Bảng được clustered trên (WarehouseCode, BookISBN) — một khóa tổng hợp dạng chuỗi. Lọc chỉ theo khóa thứ hai mà không có nonclustered index hỗ trợ dẫn đến clustered scans.

Fix V1 (nhanh bên DB)

  • CREATE INDEX IX_Inventory_BookISBN ON dbo.Inventory(BookISBN);

Cách khắc phục đúng (model + EF)

  • Sử dụng khóa clustered thay thế hẹp (ví dụ: InventoryId INT IDENTITY) và giữ (WarehouseCode, BookIsbn) với các nonclustered index được nhắm mục tiêu phù hợp với các mẫu truy cập.

Truy vấn 5 — Quét LIKE trên CategoryCsv (Phản mẫu CSV)

EF LINQ shape:

var category = "Programming";
var q5 = db.Books.Where(b =>
    (b.CategoryCsv ?? "") == category ||
    (b.CategoryCsv ?? "").StartsWith(category + ",") ||
    (b.CategoryCsv ?? "").EndsWith("," + category) ||
    (b.CategoryCsv ?? "").Contains("," + category + ","))
    .Select(b => new { b.Isbn, b.Title, b.CategoryCsv });

Tại sao nó chậm

  • CSV trong một cột ngăn optimizer sử dụng logic tập hợp. Hầu hết các mẫu (%LIKE%) đều dẫn đến quét. StartsWith có thể tìm kiếm được trong một số trường hợp, nhưng ranh giới CSV phá vỡ điều đó.

Fix V1 (không có giải pháp tốt)

  • Chỉ mục không thể sửa được bài toán kiểm tra thành viên CSV phi chuẩn hóa. Bản demo này cố tình cho thấy giới hạn của việc đánh chỉ mục.

Cách khắc phục đúng (model + EF)

  • Chuẩn hóa: Không sử dụng Books.CategoryCsv để lưu dữ liệu phân cách bằng dấu phẩy. Sử dụng: Book ↔ Category thông qua bảng cầu nối BookCategory. Sau đó viết một phép join phù hợp:
var q5 = from bc in db.BookCategories
         where bc.CategoryName == "Programming"
         join b in db.Books on bc.Isbn equals b.Isbn
         select new { b.Isbn, b.Title };
  • Trong EF Core 5+, quan hệ nhiều-nhiều có thể được mô hình hóa trực tiếp; đảm bảo đánh chỉ mục trên các cột FK của bảng cầu nối.

Truy vấn 6 — Sắp xếp ActivityLog theo Cột Ngày Dạng Văn bản

EF LINQ shape:

var q6 = db.ActivityLogs
    .OrderByDescending(a => a.HappenedAt)
    .Select(a => new { a.ActivityId, a.HappenedAt, a.Actor, a.Action });

Tại sao nó chậm

  • HappenedAt là nvarchar(30). Sắp xếp yêu cầu full sort; tập kết quả lớn có thể tràn ra tempdb.

Fix V1 (nhanh bên DB)

  • Tạo cột tính toán datetime và chỉ mục phù hợp với thứ tự sắp xếp:
  • ALTER TABLE ActivityLog ADD HappenedAt_dt AS TRY_CONVERT(datetime2(3), HappenedAt) PERSISTED;
  • CREATE INDEX IX_ActivityLog_HappenedAt_dt_DESC ON dbo.ActivityLog (HappenedAt_dt DESC);

Cách khắc phục đúng (model + EF)

  • Lưu HappenedAt dưới dạng datetime2. Trong EF, sắp xếp theo cột đã gõ kiểu; SQL Server có thể thực hiện ordered seek và tránh sort.

Truy vấn 7 — Tính toán Tiền tệ với FLOAT (Độ chính xác > Hiệu năng)

EF LINQ shape:

var sum = await db.OrderLines
    .Where(ol => ol.OrderId == orderId)
    .Select(ol => (ol.UnitPrice ?? 0) * (ol.Quantity ?? 0))
    .SumAsync();

Vấn đề ở đây là gì

  • Các cột tiền tệ dưới dạng FLOAT là không chính xác. Dấu phẩy động nhị phân không thể biểu diễn nhiều phân số thập phân; tổng số bị sai lệch và lỗi làm tròn tích lũy dần.

Fix V1

  • Không có chỉ mục hay bản sửa MSSQL nào cho các phép tính sai kiểu dữ liệu.

Cách khắc phục đúng (model + EF)

  • Sử dụng DECIMAL(19,4) (hoặc phù hợp) cho mọi nơi xuất hiện tiền tệ. Trong EF:
modelBuilder.Entity<OrderLine>()
    .Property(p => p.UnitPrice)
    .HasPrecision(19, 4);
  • Giữ phép toán trên máy chủ với các biểu thức kiểu decimal; đảm bảo phía client cũng sử dụng decimal.

Truy vấn 8 — Dò tìm JSON-like trong Orders.Meta

EF LINQ shape:

var q8 = db.Orders
    .Where(o => (o.Meta ?? "").Contains("\"source\":\"mobile\""))
    .Select(o => new { o.OrderId, o.Meta });

Tại sao nó chậm

  • nvarchar(max) cộng với %LIKE% ở đầu buộc phải full scans. SQL Server không có thống kê nào cho các thuộc tính JSON bên trong chuỗi.

Fix V1 (nhanh bên DB)

  • Chiếu thuộc tính JSON thành một cột persisted + index:
  • ALTER TABLE Orders ADD Source AS JSON_VALUE(Meta, '$.source') PERSISTED;
  • CREATE INDEX IX_Orders_Source ON dbo.Orders(Source);
  • Sau đó lọc trên Source = 'mobile' (có thể seek).

Cách khắc phục đúng (model + EF)

  • Mô hình hóa các thuộc tính quan trọng thành các cột thực, hoặc ánh xạ một cột tính toán trong EF:
modelBuilder.Entity<Order>()
    .Property<string>("Source")
    .HasComputedColumnSql("JSON_VALUE([Meta], '$.source')", stored: true);
  • Truy vấn: o => EF.Property<string>(o, "Source") == "mobile" để tận dụng chỉ mục.

Gói “Fix V1” (Những gì Demo Áp dụng)

Tôi đã thêm logic vào dự án demo để sửa 8 truy vấn có phản mẫu. Ứng dụng có thể áp dụng các thay đổi “Fix V1” này để cải thiện truy vấn EF Core. Sau đây là các vị trí bạn sẽ tìm thấy mã nguồn áp dụng các chỉnh sửa cho cơ sở dữ liệu.

Data/FixScripts.cs áp dụng các thay đổi thực tế mà không cần tái cấu trúc mã ứng dụng:

  • Các cột tính toán persisted cho ngày dạng chuỗi và các chiếu JSON:
  • Orders.OrderDate_dt = TRY_CONVERT(datetime2(3), OrderDate)
  • Orders.Source = JSON_VALUE(Meta, '$.source')
  • ActivityLog.HappenedAt_dt = TRY_CONVERT(datetime2(3), HappenedAt)
  • Các nonclustered index được nhắm mục tiêu phù hợp với các vị từ/thứ tự phổ biến:
  • IX_Orders_CustomerEmail_OrderDate_dt (INCLUDE OrderTotal, OrderStatus)
  • IX_OrderLines_OrderId (INCLUDE các cột covering)
  • IX_Books_Title, IX_Inventory_BookISBN, IX_ActivityLog_HappenedAt_dt_DESC, v.v.

Những thay đổi này chuyển đổi các thao tác quét/sắp xếp thành các thao tác đọc có thứ tự/có thể tìm kiếm, giảm đáng kể I/O và cấp phát bộ nhớ mà không cần chạm vào LINQ. Chúng rất tốt cho việc xử lý tạm thời nhưng không nên thay thế thiết kế schema phù hợp.

Danh sách Kiểm tra Nhanh Bạn Có Thể Áp Dụng Ngay Hôm Nay

  • Ngày/giờ dùng datetime2, không phải chuỗi.
  • Chỉ mục tổng hợp phù hợp với tiền tố bộ lọc của bạn; dùng INCLUDE để covering.
  • Đánh chỉ mục tất cả các khóa ngoại (FK).
  • Nối trên khóa, không phải văn bản rộng.
  • Chuẩn hóa các tập dữ liệu (không có CSV trong cột).
  • Ưu tiên StartsWith hơn %LIKE%, cân nhắc full-text search cho tìm kiếm nâng cao.
  • Chiếu các thuộc tính JSON bạn truy vấn vào các cột tính toán/persisted và đánh chỉ mục chúng.
  • Tiền tệ = decimal với độ chính xác rõ ràng.
  • Khóa clustered thay thế hẹp; tránh các khóa tổng hợp nvarchar.
  • Đo lường, so sánh, cải tiến.

Đoạn Mã EF Core Thực Tế Bạn Có Thể Áp Dụng

Chỉ mục tổng hợp qua migration:

modelBuilder.Entity<Order>()
    .HasIndex(e => new { e.CustomerEmail, e.OrderDate });

Ánh xạ cột tính toán (để khớp với biểu thức persisted trong DB):

modelBuilder.Entity<Order>()
    .Property<DateTime?>("OrderDate_dt")
    .HasColumnType("datetime2(3)")
    .HasComputedColumnSql("TRY_CONVERT(datetime2(3), [OrderDate])", stored: true);

Truy vấn sử dụng thuộc tính shadow/computed để chỉ mục được dùng:

var q = db.Orders.Where(o => EF.Property<DateTime?>(o, "OrderDate_dt") >= start
                           && EF.Property<DateTime?>(o, "OrderDate_dt") <  end);

Nhiều-nhiều thay vì CSV:

modelBuilder.Entity<BookCategory>()
    .HasIndex(bc => new { bc.Isbn, bc.CategoryName });

var booksInCategory = from bc in db.BookCategories
                      where bc.CategoryName == category
                      join b in db.Books on bc.Isbn equals b.Isbn
                      select b;

Lời Kết

Hiệu năng của EF Core là sự kết hợp giữa hình dạng LINQ và thiết kế lưu trữ. Bạn hiếm khi có thể “tối ưu hóa” để thoát khỏi các vấn đề về schema chỉ từ lớp truy vấn. Hãy sử dụng các biện pháp giảm nhẹ nhanh được trình bày ở đây để xử lý tạm thời các vấn đề sản xuất, nhưng hãy cố gắng căn chỉnh các kiểu dữ liệu, khóa và chỉ mục với cách bạn truy vấn dữ liệu của mình. Đó là lúc EF Core thực sự tỏa sáng: khi mô hình và cơ sở dữ liệu thống nhất về ngữ nghĩa và đường dẫn truy cập.