写 EF Core 查询,90% 的人第一步就错了:刚子教你避开所有坑
今天刚子不跟你扯理论,直接上实战代码,把 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 次查询 // 场景:获取所有订单,并逐一输出客户名称 // 如果启用了延迟加载,以下代码会导致 1 次查询获取订单 + N 次查询获取每个订单的客户 var orders = await context.Orders.ToListAsync(); foreach (var order in orders) { Console.WriteLine(order.Customer); // 每次访问都触发一次查询 } // ✅ 正确:一次性预加载 // 场景:获取所有订单及对应的客户,仅需一次查询 var ordersWithCustomer = 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 代码的程序员。咱们下回见!
原文链接:写 EF Core 查询,90% 的人第一步就错了:刚子教你避开所有坑 - 码农刚子的开发笔记
合集: C#/.NET开发者宝典 , C#/.NET 编程指南
标签: EFCore, EF
免责声明:本内容来自平台创作者,博客园系信息发布平台,仅提供信息存储空间服务。
好文要顶 关注我 收藏该文 微信分享
码农刚子
粉丝 - 61 关注 - 11
+加关注
9
« 上一篇: 序列化 JSON 时崩了?99% 是 EF 延迟加载惹的祸,三种解法拿走不谢
» 下一篇: 推荐一个开箱即用的.NET权限管理平台:Magic.NET
posted @ 2026-04-22 08:02 码农刚子 阅读(944) 评论(9) 收藏 举报
