大家好,我是码农刚子。
EF Core 大家都用过,简单的增删改查谁都会写。但一遇到多表关联、动态筛选、复杂聚合这些场景,很多人就开始头疼了——写出来的查询要么慢得要死,要么干脆崩了。
今天刚子不跟你扯理论,直接上实战代码,把 EF Core 复杂查询的几个核心技巧给你讲明白。顺便聊聊性能优化的几条铁律,省得你下次再踩坑。
先说核心:EF Core 复杂查询的3个核心技巧
处理复杂查询,你只需要记住这几招:
- 关联查询:用
Include+ThenInclude一次性加载多级关联数据 - 动态筛选:用表达式树在运行时动态拼查询条件
- 性能优化:用
AsNoTracking、Select投影、AsSplitQuery控制数据加载
这些学会了,90% 的复杂查询场景你都能搞定。
刚子大白话:写 EF Core 查询,关键不是你写得多花哨,而是你要知道它生成的 SQL 长啥样。把 EF Core 当成一个带类型安全的 SQL 生成器,这才是正确心态。
场景一:多表关联查询(Include + ThenInclude)
基础用法:加载关联数据
例如我的博客系统:一个 Blog 有多个 Post,每个 Post 有一个 Author。
// 加载 Blog、关联的 Post、每个 Post 的 Author
var blogs = await context.Blogs
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ToListAsync();
这个查询会把三层数据一次加载出来,生成的 SQL 是一个 JOIN 查询,把三张表一次性查完。
Include 也能过滤?当然可以
EF Core 支持在 Include 里对关联集合做过滤:
// 只加载今年发布的文章
var blogs = await context.Blogs
.Include(b => b.Posts.Where(p => p.PublishDate.Year == DateTime.Now.Year))
.ToListAsync();
多级关联要多个 ThenInclude
如果需要加载更深层级的关联,继续链式调用 ThenInclude 就行:
// Blog → Posts → Author → AuthorDetails
var blogs = await context.Blogs
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ThenInclude(a => a.Details)
.ToListAsync();
划重点:Include + ThenInclude 链越长,生成的 JOIN 越复杂。如果要加载多个集合导航属性,注意笛卡尔积爆炸的问题。遇到这种情况,可以用 AsSplitQuery() 把一个大查询拆成多个小查询。
场景二:动态查询(表达式树)
为什么需要动态查询?
业务需求经常变:用户按多个条件筛选,但这些条件可能选也可能不选。用静态查询写一堆 if?太丑了,还容易漏条件。
动态查询的核心是用 Expression<Func<T, bool>> 在运行时拼接查询条件。
手写一个动态筛选器
public async Task<List<Product>> SearchProductsAsync(
string? name = null,
decimal? minPrice = null,
decimal? maxPrice = null,
int? categoryId = null)
{
var query = context.Products.AsQueryable();
if (!string.IsNullOrEmpty(name))
query = query.Where(p => p.Name.Contains(name));
if (minPrice.HasValue)
query = query.Where(p => p.Price >= minPrice.Value);
if (maxPrice.HasValue)
query = query.Where(p => p.Price <= maxPrice.Value);
if (categoryId.HasValue)
query = query.Where(p => p.CategoryId == categoryId.Value);
return await query.ToListAsync();
}
这样写没问题,但条件越多代码越臃肿。更好的方式是用表达式树工具库,或者自己封装一个 PredicateBuilder。
PredicateBuilder 的实现原理
public static class PredicateBuilder
{
public static Expression<Func<T, bool>> True<T>() { return f => true; }
public static Expression<Func<T, bool>> False<T>() { return f => false; }
public static Expression<Func<T, bool>> Or<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> expr2)
{
var invokedExpr = Expression.Invoke(expr2, expr1.Parameters);
return Expression.Lambda<Func<T, bool>>(
Expression.OrElse(expr1.Body, invokedExpr),
expr1.Parameters);
}
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> expr2)
{
var invokedExpr = Expression.Invoke(expr2, expr1.Parameters);
return Expression.Lambda<Func<T, bool>>(
Expression.AndAlso(expr1.Body, invokedExpr),
expr1.Parameters);
}
}
用起来就很优雅了:
var predicate = PredicateBuilder.True<Product>();
if (!string.IsNullOrEmpty(name))
predicate = predicate.And(p => p.Name.Contains(name));
if (minPrice.HasValue)
predicate = predicate.And(p => p.Price >= minPrice.Value);
if (maxPrice.HasValue)
predicate = predicate.And(p => p.Price <= maxPrice.Value);
var products = await context.Products
.Where(predicate)
.ToListAsync();
划重点:千万别把自定义方法塞进表达式树。EF Core 不认识你的 MyUtil.IsAdult(x),整段逻辑会被静默跳过,甚至退化为客户端求值——先查出全部数据,再在内存里过滤。性能直接崩。
场景三:分页 + 排序 + 过滤
分页是高频场景,EF Core 配合 LINQ 写起来很顺手:
public async Task<PagedResult<Product>> GetPagedProductsAsync(
int pageIndex = 1,
int pageSize = 10,
string? sortBy = "Id",
string? sortDirection = "asc",
string? searchTerm = null)
{
var query = context.Products.AsQueryable();
// 过滤
if (!string.IsNullOrEmpty(searchTerm))
query = query.Where(p => p.Name.Contains(searchTerm));
// 排序(注意:这里用了字符串反射,生产环境建议用 switch 或字典映射)
query = sortDirection?.ToLower() == "desc"
? query.OrderByDescending(GetSortExpression(sortBy))
: query.OrderBy(GetSortExpression(sortBy));
// 分页
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<Product>
{
Items = items,
TotalCount = totalCount,
PageIndex = pageIndex,
PageSize = pageSize
};
}
划重点:分页查询必须在 Skip 和 Take 之前先做排序,否则 EF Core 会抛异常。另外,GetSortExpression 这个函数要注意防止 SQL 注入,最好用白名单映射。
场景四:分组与聚合查询
按分类统计产品数量
var categoryStats = await context.Products
.GroupBy(p => p.CategoryId)
.Select(g => new
{
CategoryId = g.Key,
ProductCount = g.Count(),
AvgPrice = g.Average(p => p.Price),
TotalRevenue = g.Sum(p => p.Price * p.SalesCount)
})
.ToListAsync();
这个查询 EF Core 会翻译成一条带 GROUP BY 的 SQL 语句,直接在数据库端完成聚合计算,性能很好。
刚子大白话:能用 GroupBy 就别自己写循环算,数据库干这个比 C# 快多了。
性能优化:这 5 条铁律记住
1. 只读查询用 AsNoTracking()
EF Core 默认会跟踪每个实体的变更,这在只读场景下完全是浪费。
var products = await context.Products
.AsNoTracking()
.Where(p => p.Price > 100)
.ToListAsync();
加上 AsNoTracking,EF Core 不会记录这些实体的状态变化,内存占用和 CPU 开销都大幅降低。
2. 只取需要的字段(投影)
不要每次都 Select *,用投影只拿你真正需要的字段:
var productInfos = await context.Products
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync();
3. 用 Select 投影还能顺便加载关联数据
var orderInfos = await context.Orders
.Select(o => new
{
OrderId = o.Id,
CustomerName = o.Customer.Name,
TotalAmount = o.Items.Sum(i => i.Price * i.Quantity),
ItemCount = o.Items.Count()
})
.ToListAsync();
这种方式比 Include 更精准,因为你只拿你需要的数据,SQL 生成的 JOIN 也更精简。
4. N+1 问题用 Include 解决
// ❌ 错误:会触发 N+1 次查询
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine(order.Customer.Name); // 每次访问都触发一次查询
}
// ✅ 正确:一次性预加载
var orders = await context.Orders
.Include(o => o.Customer)
.ToListAsync();
用 Include 显式预加载关联数据,把原本 1+N 次查询压成 1 次 JOIN 查询。
5. 集合过多时用 AsSplitQuery()
如果一个查询包含多个集合导航属性,默认的单查询模式会产生笛卡尔积爆炸。这时用 AsSplitQuery() 拆分成多个 SQL:
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Comments)
.AsSplitQuery()
.ToListAsync();
EF Core 会分别查询 Blog、Posts、Comments 三张表,然后在内存中组装,避免数据重复膨胀。
复杂查询铁律
| 场景 | 推荐方案 | 注意事项 |
|---|---|---|
| 多表关联加载 | Include + ThenInclude |
链别太长,注意笛卡尔积 |
| 动态多条件筛选 | 表达式树 / PredicateBuilder | 别塞自定义方法,会被静默忽略 |
| 只读数据查询 | AsNoTracking() + Select 投影 |
减少内存开销 |
| 避免 N+1 | 预加载 + 禁用延迟加载 | 用 Include 一次搞定 |
| 多集合查询 | AsSplitQuery() |
防笛卡尔积爆炸 |
| 数据量大 | 分页 + 索引 | Skip/Take 前必须排序 |
| 复杂聚合 | GroupBy / 聚合函数 |
EF Core 会翻译成 SQL |
| 实在搞不定 | 原生 SQL (FromSqlRaw) |
最后手段,别滥用 |
刚子结语
别把 EF Core 当成黑盒。你写出来的 LINQ 查询最终都会翻译成 SQL,不理解 SQL,你就写不出高效的 EF Core 查询。
我刚学 EF Core 的时候,也踩过 N+1、笛卡尔积、客户端求值这些坑。后来我养成了一个习惯:每个复杂查询都去检查生成的 SQL 长啥样。
你可以用 EF Core 自带的日志功能:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
看一眼生成的 SQL,你就知道哪里写得不对了。
刚子的经验:写复杂查询的时候,先想清楚“我要的数据结构是什么”,再用 LINQ 去表达。把 EF Core 当成带类型安全的 SQL 生成器,别把它当成万能魔法箱。
如果你觉得这篇有用,点个赞、转给还在被 EF Core 复杂查询折磨的兄弟。
我是刚子,一个写了六年 .NET 代码的程序员。咱们下回见!