5.3 LINQ 查询的执行真相 LINQ 查询写下的那一刻什么都没发生:IQueryable 是"待执行的描述",第一次被枚举(ToList、异步版、循环)才翻译成 SQL 发往数据库;翻译不出的片段会退化成内存计算,把过滤压力整个搬到应用进程。看懂这两条,慢查询排障就有了方法论。 数据支线的最后一站,也是全章价值峰值。本节的观察哨装在 SQL 日志窗上:每段 LINQ 在数据库眼里是什么 SQL、在哪一步发出、哪些片段根本没到数据库。三个问题贯穿全节:什么时候执行、在哪执行、执行了多少次。 打开 SQL 观察窗 不先开观察窗,后面全是空对空。让上下文把每条 SQL 打到日志(2.
LINQ 查询写下的那一刻什么都没发生:IQueryable 是"待执行的描述",第一次被枚举(ToList、异步版、循环)才翻译成 SQL 发往数据库;翻译不出的片段会退化成内存计算,把过滤压力整个搬到应用进程。看懂这两条,慢查询排障就有了方法论。
数据支线的最后一站,也是全章价值峰值。本节的观察哨装在 SQL 日志窗上:每段 LINQ 在数据库眼里是什么 SQL、在哪一步发出、哪些片段根本没到数据库。三个问题贯穿全节:什么时候执行、在哪执行、执行了多少次。
不先开观察窗,后面全是空对空。让上下文把每条 SQL 打到日志(2.3 节的日志机制直接复用):
// 注册时打开敏感日志:开发环境观察 SQL 的标准做法 builder.Services.AddSqlite<ShopContext>(connString, o => o.LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information) .EnableSensitiveDataLogging()); // 参数值也显示,仅限开发环境
// 观察窗输出示例(已简化): // SELECT p.Id, p.Name, p.Price FROM Products AS p WHERE p.Price > 100
有了这个窗口,"预测 SQL"变成可验证游戏:写 LINQ,先猜 SQL,再看输出——这个循环是本节唯一要求动手的练习。
var q = db.Products.Where(p => p.Price > 100); // 此刻零 SQL:q 只是描述 var list = await q.ToListAsync(); // 枚举一:这里才发 SQL foreach (var p in q) Console.WriteLine(p.Name); // 枚举二:又发一次同样的 SQL var count = q.Count(); // 枚举三:第三次 // 一段描述、三次执行——延迟执行的账要这样算
延迟执行的收益:描述可以先组合再执行(加分页、加排序都只是改描述),最终 SQL 一次成型。代价:每一次枚举都是一次真实往返,忘了这一点就重复查库。终结时机记一组:ToList 与 ToListAsync、Count、Any、First、foreach、是否存在返回 IActionResult 的隐式序列化——最后这个最隐蔽,接口直接返回 IQueryable 时框架在序列化阶段枚举它,SQL 在你意想不到的一刻发出(第 6 章会再遇到它)。
// 版本一:db.Products 是 IQueryable,Where 在数据库端执行 var r1 = await db.Products .Where(p => p.Price > 100) .Take(10) .ToListAsync(); // SQL:SELECT TOP 10 ... WHERE Price > 100 // 版本二:ToList 提前收网,之后的一切都在内存里 var r2 = (await db.Products.ToListAsync()) // 全表先搬进内存 .Where(p => p.Price > 100) // IEnumerable:内存过滤 .Take(10) .ToList(); // 版本一传输 10 行;版本二传输全表再丢弃大半——同一语义,两个世界
分界规则一句话:返回类型 IQueryable 的算子(Where、OrderBy、Select、Take)进描述,遇到终结算子翻译发车;一旦变成 IEnumerable(收网之后),后续算子全是内存操作。另一个坑是"翻译不出的片段":比如自定义方法 p => IsHot(p)——翻译器不认识 IsHot,新版 EF Core 直接抛错提示无法翻译(旧版会静默客户端求值);解法是把逻辑内联成表达式,或用数据库函数映射。抛错比静默退化好得多,但前提是你读得懂这条错误。

背景:订单列表页要显示每单的顾客姓名。上线两周后页面越开越慢,数据库监控显示单次页面请求的 SQL 条数随订单数线性增长。操作:观察窗抓一次页面加载:
// 原写法:循环里逐单查顾客(伪码还原) var orders = await db.Orders.ToListAsync(); // 第 1 条 SQL foreach (var o in orders) { var name = o.Customer.Name; // 每单再发 1 条 SQL! // N 条订单 = 1 + N 条 SQL,这就是 N+1 }
// 观察窗节选:一页 50 单,51 条 SQL // SELECT o.Id, o.CustomerId ... FROM Orders AS o // SELECT c.Id, c.Name ... FROM Customers AS c WHERE c.Id = 7 // SELECT c.Id, c.Name ... FROM Customers AS c WHERE c.Id = 12 // ...(重复 49 次)
结果:改用 Include 预先加载,51 条 SQL 变 1 条:
var orders = await db.Orders .Include(o => o.Customer) // JOIN 一次性带回顾客数据 .ToListAsync(); // 1 条 SQL 搞定整页
解读:N+1 的根源是导航属性"懒"——默认不加载,访问时才现查。治理三板斧:列表页用 Include 预载、确实只需要几列时用投影 Select 提前收窄(投影之后连跟踪都省了)、只读场景加 AsNoTracking 减轻跟踪开销。
变式:深嵌套(订单、明细、商品三级)Include 要克制,层级深时改投影成扁平 DTO 一步到位;统计类需求走 GroupBy 投影让聚合在库端完成——观察窗确认聚合 SQL 而不是拉全表回来在内存里算。
变更跟踪要为每个实体留快照、比较差异。只读查询(列表、报表、详情展示)不需要这些簿记,AsNoTracking 关闭跟踪,快照与比较全省。高频只读页面的标配。要注意:上下文里查询改实体再 SaveChanges 依赖跟踪,混用时想清楚这条链还在不在。
💡 关键直觉:把 IQueryable 当"购物清单"而不是"货物"——写清单不花钱,结账(枚举)才扣款,结几次账扣几次款。性能问题第一问永远是:这条链结了几次账、每次搬了多少货。