Gỡ Rối Entity Framework Core: 8 Anti‑Pattern 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 2025 C# Advent. Hãy xem tất cả các bài viết tuyệt vời!

Tôi xin gửi lời chúc Giáng Sinh an lành, Kỳ Nghỉ vui vẻ, Hanukkah hạnh phúc, 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 có thể dùng LINQ để tạo truy vấn và lấy dữ liệu. Điều làm 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, dù chúng có thể không phải là “nhanh nhất”. Các nhà phát triển không cố tình làm gì sai cả. Họ chỉ thiếu kỹ năng hoặc góc nhìn để giải quyết một số anti‑pattern 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 hội nghị và bản demo ASP.NET Core này.

Bản demo “Bad Book Store” này cố tình mô hình hóa một lược đồ SQL “tồi” để làm nổi bật các anti‑pattern phổ biến trong truy vấn Entity Framework Core. Mỗi kịch bản đều thể hiện: hình dạng LINQ, tại sao nó chậm (SQL Server làm gì với nó), và hai cấp độ sửa lỗi: cách khắc phục nhanh phía DB và những 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 GitHub repository sau: https://github.com/cwoodruff/DebuggingEFCoreMSSQL (dự án nằm trong thư mục demo)

Các file liên quan:

  • 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ố kiểu nvarchar
  • Data/FixScripts.cs — Các sửa lỗi T‑SQL (cột tính toán + chỉ mục)

Bạn có thể tìm file BAK cơ sở dữ liệu và script SQL để tạo database 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) để xem qua demo và xem lại code 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)

Hình dạng LINQ:

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 chậm:

  • OrderDatenvarchar(30). So sánh chuỗi giết chết khả năng sargability và không tồn tại đường dẫn tìm kiếm composite.
  • Kỳ vọng index/clustered scans, số lượng logical reads cao.

Cách khắc phục V1 (nhanh phía DB):

  • Tạo cột tính toán datetime2 được persisted từ chuỗi và đánh chỉ mục kèm 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 EF, ánh xạ nó thành DateTime/DateTime?.
  • Thêm chỉ mục composite trong model builder/migration:
modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerEmail, o.OrderDate });
  • Viết điều kiện khoảng bình thường trên DateTime — EF dịch thành range scan có thể seek được.

Truy vấn 2 — JOIN ReviewsBooks theo Title (Văn bản Rộng, Không Duy nhất)

Hình dạng LINQ:

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 chậm:

  • Join trên trường nvarchar(500) rộng, không duy nhất dẫn đến hash join với memory grant lớn hoặc scan.

Cách khắc phục V1 (nhanh phía 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à PK). Làm cho Reviews lưu FK đến Books.
  • Join dựa trên navigation của 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 });
  • Đồng thời đánh chỉ mục các cột lọc phổ biến trên Reviews (ví dụ: Rating, CustomerEmail) khi cần.

Truy vấn 3 — JOIN Cha ↔ Con Không Có Chỉ Mục Trên FK (OrderLines.OrderId)

Hình dạng LINQ:

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 chậm:

  • Thiếu chỉ mục trên OrderLines(OrderId) khiến nested loops phải thực hiện scan lặp đi lặp lại, hoặc optimizer phải dùng hash join với full scan.

Cách khắc phục V1 (nhanh phía 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 navigations:
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 Dưới Khóa Clustered Composite Chuỗi

Hình dạng LINQ:

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 chậm:

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

Cách khắc phục V1 (nhanh phía 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 surrogate hẹp (ví dụ: InventoryId INT IDENTITY) và giữ (WarehouseCode, BookIsbn) với các nonclustered index nhắm đúng mẫu truy cập.

Truy vấn 5 — Quét LIKE CategoryCsv (Anti‑Pattern CSV)

Hình dạng LINQ:

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 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 scan. StartsWith có thể seek được trong một số trường hợp, nhưng ranh giới CSV phá vỡ điều đó.

Cách khắc phục V1 (không có giải pháp tốt):

  • Chỉ mục không thể sửa được bài kiểm tra thành viên CSV phi chuẩn hóa. 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: BookCategory thông qua bảng cầu nối BookCategory mới. Sau đó viết 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ệ many‑to‑many có thể được mô hình hóa trực tiếp; đảm bảo có chỉ mục trên các cột FK cầu nối.

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

Hình dạng LINQ:

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

Tại sao chậm:

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

Cách khắc phục V1 (nhanh phía DB):

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 đã định kiểu; SQL Server có thể thực hiện ordered seek và tránh việc sort.

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

Hình dạng LINQ:

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

Vấn đề ở đây:

  • Cột tiền tệ dưới dạng FLOAT thì thiếu 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ố thay đổi và lỗi làm tròn tích lũy.

Cách khắc phục V1:

  • Không có cách khắc phục chỉ mục hay MSSQL nào cho sai số học.

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

  • Sử dụng DECIMAL(19,4) (hoặc phù hợp) ở mọi nơi có tiền tệ. Trong EF:
modelBuilder.Entity<OrderLine>()
    .Property(p => p.UnitPrice)
    .HasPrecision(19, 4);
  • Giữ phép tính trên server với biểu thức kiểu decimal; đảm bảo clients cũng dùng decimal.

Truy vấn 8 — Dò LIKE JSON trong Orders.Meta

Hình dạng LINQ:

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

Tại sao chậm:

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

Cách khắc phục V1 (nhanh phía DB):

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ột thực, hoặc ánh xạ 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 thể hiện anti‑pattern. Ứ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. Đây là các vị trí bạn sẽ tìm thấy code á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 code ứng dụng:

  • Cột tính toán persisted cho ngày tháng dạng chuỗi và 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)
  • Nonclustered index 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 biến các scan/sort thành seek/ordered read, cắt giảm đáng kể I/O và memory grant mà không cần chạm vào LINQ. Chúng rất tốt cho việc chẩn đoán nhanh nhưng không nên thay thế thiết kế lược đồ 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ưới dạng datetime2, không phải chuỗi.
  • Chỉ mục composite khớp với tiền tố lọc của bạn; sử dụng INCLUDE để covering.
  • Đánh chỉ mục tất cả các FK.
  • Join trên khóa, không phải văn bản rộng.
  • Chuẩn hóa tập hợp (không có CSV trong cột).
  • Ưu tiên StartsWith hơn %LIKE%, cân nhắc full‑text 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ột tính toán/persisted và đánh chỉ mục chúng.
  • Tiền tệ = decimal với precision rõ ràng.
  • Khóa clustered surrogate hẹp; tránh composite nvarchar.
  • Đo lường, so sánh, lặp lại.

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

Chỉ mục composite 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 shadow/computed property để 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);

Many‑to‑many 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 EF Core là sự hợp tác giữa hình dạng LINQ và thiết kế lưu trữ. Bạn sẽ hiếm khi “tối ưu hóa” để thoát khỏi các vấn đề về lược đồ 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 để chẩn đoán các vấn đề production, 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. Đó là nơi EF Core thực sự tỏa sáng: khi model và cơ sở dữ liệu đồng ý về ngữ nghĩa và đường dẫn truy cập.

Tags: .NET, C#, databases, dotnet, Entity Framework Core, MSSQL, programming

Chỉ mục