Entity Framework Core 深度Include查询性能优化及复杂查询最佳实践问询
Hey, let's tackle this EF Core performance issue you're facing with all those nested Include/ThenInclude calls. I've dealt with similar heavy association scenarios before, so here are some proven best practices and index optimization tips to get your query running smoother:
Looking at your code, you're repeatedly calling Include(u => u.BuildDetail).ThenInclude(u => u.Casing) for every sub-property of Casing. While EF Core does try to optimize duplicate joins, cleaning up this code makes it easier to maintain and reduces the chance of unnecessary joins.
You can group includes for the same parent navigation property like this:
protected virtual IQueryable<RecommendedBuild> QueryWithDetails => _queryWithDetails ??= _db.RecommendedBuilds // Group all Casing-related includes under a single BuildDetail include .Include(u => u.BuildDetail) .ThenInclude(u => u.Casing) .ThenInclude(c => c.HardwareImage) .Include(u => u.BuildDetail.Casing) .ThenInclude(c => c.CasingType) .Include(u => u.BuildDetail.Casing) .ThenInclude(c => c.Color) .Include(u => u.BuildDetail.Casing) .ThenInclude(c => c.SidePanelWindow) // ... rest of your includes, grouped similarly for other entities like CpuCooler, Cpu, etc. .Include(u => u.RecommendedBuildCategory);
This keeps your code cleaner and helps EF Core generate more efficient SQL by avoiding redundant join logic.
The root cause of slow multi-table joins is almost always missing or inefficient indexes. Here's what you need to focus on:
- Primary Key Indexes: Ensure all entities have their primary keys indexed (EF Core creates these by default, but double-check if any were manually removed).
- Foreign Key Indexes: Every foreign key used in a
JOIN(likeBuildDetail.CasingId,Casing.ManufacturerId,FrontPanelUsbs.CasingId) needs a non-clustered index. These indexes eliminate full-table scans during joins.
Example forBuildDetail.CasingId:CREATE NONCLUSTERED INDEX IX_BuildDetail_CasingId ON BuildDetail (CasingId); - Covering Indexes: If your query only uses specific fields from a table, create a covering index that includes those fields to avoid "key lookups" (which force the database to fetch data from the main table after using the index).
Example forCasing(includes all fields your query accesses):CREATE NONCLUSTERED INDEX IX_Casing_QueryIncludes ON Casing (Id) INCLUDE (HardwareImageId, CasingTypeId, ColorId, SidePanelWindowId, ManufacturerId); - Filtered Indexes: For optional associations (where the foreign key is often
NULL), create a filtered index to narrow down the indexed data. For example, ifCasing.SidePanelWindowIdis mostlyNULL:CREATE NONCLUSTERED INDEX IX_Casing_SidePanelWindow ON Casing (SidePanelWindowId) WHERE SidePanelWindowId IS NOT NULL;
- Use Split Queries: When your query includes multiple collection navigation properties (like
RamsandStorages, bothList<T>), multipleLEFT JOINs create a cartesian product, which blows up the number of rows returned and kills performance. EF Core 3.0+ solves this withAsSplitQuery():
This splits your single massive query into multiple smaller queries, each loading one collection, avoiding the cartesian product issue.protected virtual IQueryable<RecommendedBuild> QueryWithDetails => _queryWithDetails ??= _db.RecommendedBuilds // ... all your includes ... .AsSplitQuery(); - Lazy Loading (Carefully): If you don't need all associations immediately, enable lazy loading (install
Microsoft.EntityFrameworkCore.Proxiesand configureUseLazyLoadingProxies()in your DbContext). This loads associations only when you access them, but watch out for N+1 query problems—combine it withIncludefor frequently used associations. - Explicit Loading: For rarely used deep associations, load the main entity first, then explicitly load the needed data:
var build = _db.RecommendedBuilds.FirstOrDefault(b => b.Id == id); _db.Entry(build).Reference(b => b.BuildDetail.Casing).Load(); _db.Entry(build.BuildDetail.Casing).Collection(c => c.FrontPanelUsbs).Load();
- No-Tracking Queries: If you're only reading data (not modifying it), use
AsNoTracking()to skip EF Core's change tracking overhead:public RecommendedBuild GetBuildById(string id) { return QueryWithDetails.AsNoTracking().Where(b => b.Id == id).FirstOrDefault(); } - Projection Queries: If you don't need the full entity, project only the fields you need with
Select(). This reduces data transfer and query time:var buildSummary = _db.RecommendedBuilds .Where(b => b.Id == id) .Select(b => new { b.Id, b.Name, CasingName = b.BuildDetail.Casing.Name, CpuManufacturer = b.BuildDetail.Cpu.Manufacturer.Name // Add only the fields you actually use }) .FirstOrDefault();
- Analyze the Execution Plan: Use your database's query execution plan tool (like SQL Server Management Studio's "Include Actual Execution Plan") to spot bottlenecks like full table scans or expensive key lookups. This will tell you exactly which indexes are missing.
- Avoid Over-Joining: Double-check if you really need all those
Includecalls. Sometimes you're loading data that's never used in the application—trim those out to simplify the query.
内容的提问来源于stack exchange,提问作者Ryan Teh

