我印象很深的一次事故:线上日志服务处理100万条日志做分组统计,直接OutOfMemory。代码看着挺标准——GroupBy 之后 OrderByDescending 再 Take(10)。可就是这十几个字符,在数据量上来之后几乎把内存吃干净了。后来我把编译后的代码反编译出来对照着看,才发现问题远不是"这行代码本身"那么简单。从那以后,我养成了一个习惯:凡是写 LINQ,先想想编译器会把它变成什么。
这篇内容,我会把查询表达式如何被编译、延迟执行如何工作、闭包和分配在哪出现、IEnumerable 与 IQueryable 到底差在哪这几个层面逐一展开,最后给出我实际测过的优化手法和排查工具链。适合写过一段时间 C#、想搞懂 LINQ 性能本质的人,也适合准备 C# 高级面试、想把"会用"变成"讲得清"的开发者。
1. 查询表达式不是魔法:编译器到底怎么翻译它
1.1 一段查询代码的"案发现场":语法糖还原
很多人写 LINQ 查询表达式,像这样:
csharp复制var result = from p in products
where p.Price > 100
orderby p.Price descending
select new { p.Name, p.Price };
这段代码读起来很"SQL",但 C# 编译器根本不认识 SQL,它只做一件事——把这套类 SQL 语法翻译成普通的方法调用链。上面的查询表达式会被编译成近似这样:
csharp复制var result = products
.Where(p => p.Price > 100)
.OrderByDescending(p => p.Price)
.Select(p => new { p.Name, p.Price });
这就是 LINQ 的第一层本质:查询表达式是纯语法糖。from、where、select 这些关键字没有自己的运行时语义,它们只是告诉编译器"去调用哪个扩展方法"。翻译规则是固定的:
from x in source对应source本身,或者Select(x => x)的前奏;where对应Where方法;select对应Select方法;orderby ... descending对应OrderByDescending;join、group by、let等也各有固定的方法映射。
你在反编译工具里看到的,永远是方法调用链,而不是查询表达式。这意味着:查询表达式能做的事,方法语法全能做,反过来也成立。有些复杂查询用方法语法更清晰,有些用查询表达式更直观,两者编译产物等价。
1.2 编译器怎么确定类型:泛型推断与扩展方法解析
翻译成方法调用之后,还有一个关键环节:编译器要确定调用的是哪个重载。products.Where(...) 能编译通过,是因为 IEnumerable<T> 上有一个 Where 扩展方法,而且编译器能通过泛型推断算出 T 是什么。
举一个容易出错的点:如果你有一个 ArrayList(非泛型集合),想用 LINQ 查询,直接写 from object x in arrayList where ... 是能编过的,但每一步都涉及装箱拆箱,性能很差。而如果你用 List<Product>,编译器会把 T 推断为 Product,整个调用链都在强类型下执行,零装箱。这提醒我们:LINQ 的性能起点,是从集合类型就开始了。
扩展方法解析的另一个细节是:编译器会先找实例方法,再找扩展方法。如果你在自己写的类上定义了一个与 LINQ 同名的方法,比如 MyCollection.Where(...),它会优先被调用。第三方库(如 MoreLINQ、System.Linq.Async)正是利用这个机制扩展 LINQ 能力的。
1.3 一个容易忽略的细节:匿名类型在查询中的角色
上面的查询里用了 new { p.Name, p.Price }。匿名类型在编译器眼里是一个内部生成的泛型类,属性名、类型、顺序都参与类型签名。编译器会在同一个程序集内复用相同结构的匿名类型,所以多次 new { p.Name, p.Price } 实际是同一个类型。
这个机制对性能有实际影响:匿名类型是引用类型,每产生一个投影结果就多一次堆分配。如果你的查询需要处理海量数据,且投影结果很快被消费,可以考虑改用 ValueTuple:
csharp复制// 原来:
var query = products.Select(p => new { p.Name, p.Price });
// 改为值元组:
var query = products.Select(p => (p.Name, p.Price));
ValueTuple 是结构体,在大多数场景下能减少堆分配,尤其配合 List<(string, decimal)> 这种存储方式,GC 压力明显下降。不过要注意,如果元组被频繁装箱(比如塞进非泛型集合、作为 object 传递),那还不如用匿名类。这一点我会在后面的优化部分再展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 yield 到状态机:LINQ 延迟执行的"发动机"与"刹车"
2.1 迭代器的真面目:状态机长什么样
除了语法糖,LINQ 另一个核心机制是迭代器。Where、Select 这类操作符的源码,并不是把结果一次性算完放进数组,而是写了 yield return。举个例子,Where 内部实现大致是:
csharp复制static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (T item in source)
{
if (predicate(item))
yield return item;
}
}
当编译器看到 yield return,它不会老老实实按这个方法体执行,而是会生成一个状态机类。这个类实现了 IEnumerable<T> 和 IEnumerator<T>,内部维护几个关键字段:
state:当前执行到哪个状态;current:当前返回的元素;- 局部变量(比如
item)被提升为字段,以便在MoveNext()之间保留值。
每次 MoveNext() 被调用,状态机从上一个 yield 的位置继续执行,直到遇到下一个 yield return 或方法结束。这个过程可以类比成"书签":每次读一段,记住读到哪一行,下次从书签继续。
.NET Core 之后的运行时对状态机还有一层优化——如果 MoveNext() 里没有真正的中断点,编译器会做"同步完成"优化,避免不必要的接口分发调用。但整体模型没变:IEnumerable<T> 本质是一台计算器,而不是一袋数据。
2.2 延迟执行的甜头与坑
延迟执行的甜头是显而易见的:你可以组合出很长的查询链:
csharp复制var query = source
.Where(x => x.Age > 18)
.Select(x => x.Name)
.OrderBy(name => name);
这一行几乎不执行任何实际工作,只是构建了一个"调用链描述"。只有当你开始枚举 query——比如 foreach、.ToList()、.Count()——时,数据才真正流动起来。这对构建复杂查询很有用,尤其是可以把查询分阶段拼装:
csharp复制IEnumerable<Order> query = orders;
if (!string.IsNullOrEmpty(status))
query = query.Where(o => o.Status == status);
if (minAmount > 0)
query = query.Where(o => o.Amount >= minAmount);
var result = query.ToList();
但延迟执行也有一个著名的大坑:重复枚举。同一个 IEnumerable 被枚举多次,每次都会重新执行完整的查询链。下面这段代码就踩过坑:
csharp复制var filtered = allItems.Where(x => x.IsValid);
Console.WriteLine(filtered.Count()); // 第一次完整执行
foreach (var item in filtered) { ... } // 第二次完整执行
如果 Where 前面的数据源是数据库查询、文件读取、网络请求这类昂贵操作,重复枚举就是灾难。更隐蔽的是,如果数据源本身是"一次性"的(比如某些 IAsyncEnumerable 数据源),第二次枚举甚至可能抛异常或拿到空集合。
关键判断:如果你不确定某个
IEnumerable会被枚举几次,且数据源昂贵,就尽早快照。快照不一定非用ToList(),如果只需要简单统计,可以用Min()、Max()、Sum()一步算完。
2.3 什么时候该踩刹车:快照与缓存的时机
"什么时候执行"这个问题,没有一刀切的答案,但有几个经验法则:
- 需要返回给调用方且可能被多次遍历时,立即
ToList()或ToArray(); - 只在内部管道中使用、且只遍历一次时,保留
IEnumerable; - 做分页、聚合、远程调用前,先
ToList()。
我自己在写仓储层接口时,会刻意把返回类型定为 IReadOnlyList<T> 而不是 IEnumerable<T>,因为 IReadOnlyList<T> 明确表达了"你拿到的是一份快照"这个语义,调用方就不会做多次枚举假设。这属于用类型约束来防坑。
ToArray() 和 ToList() 也有微妙的性能/内存差异。ToArray() 在 .NET 7+ 里对已知 ICollection<T> 数据源有优化,会直接分配精确大小的数组;ToList() 内部用 List<T> 的动态扩容机制,如果数据量预估比较大,可以先用 List<T>(capacity) 手动构造,再 AddRange,减少扩容次数。对于大多数场景差异不大,但在百万级数据量下,性能差距肉眼可见。
3. 闭包和分配:看不到的性能黑洞
3.1 闭包对象是怎么生成的
Where(p => p.Price > 100) 里的 p => p.Price > 100 看起来轻量,但它会被编译成一个委托对象。如果 lambda 没有捕获任何局部变量,编译器会缓存一个静态委托实例(<>9__0_0 之类),反复调用不会重复分配。但如果 lambda 引用了方法内的局部变量:
csharp复制var priceLimit = 100;
var result = products.Where(p => p.Price > priceLimit);
编译器会生成一个闭包类(<>c__DisplayClass0_0),字段里存着 priceLimit。执行到 Where 这一行时,new 出闭包实例,再创建指向闭包方法的委托。这个闭包对象和委托对象都是堆分配。
如果这段代码在一个被频繁调用的方法里(比如每秒几百次的请求处理),这些分配就会变成实打实的 GC 压力。冷知识:闭包字段在闭包类被回收前会一直存活,它可能把一个大对象引用带得更久。比如 lambda 捕获了一个大数组,而这个闭包实例被某个长生命周期对象持有,大数组就无法被及时回收。
3.2 经典陷阱:for 循环里的 lambda
还有一个非常经典的问题——在 for 循环里用 lambda 捕获循环变量:
csharp复制var list = new List<Func<int>>();
for (int i = 0; i < 10; i++)
{
list.Add(() => i);
}
foreach (var f in list) Console.Write(f() + " ");
在 C# 5 之前,这段代码会输出 10 10 10 ...,因为所有闭包捕获的是同一个 i 变量,循环结束后 i 停在 10。C# 5 改变了 foreach 的语义,每次迭代都生成新的循环变量,所以 foreach 里面没问题了。但 for 循环的语义没变,依然所有闭包共享同一个 i。
这个问题的性能含义在于:编译器为了正确性,可能会生成额外的闭包层级。你在 for 循环里写 lambda,编译器会把 i 提升到一个"闭包外壳"里,确保所有闭包引用同一个变量。这不仅影响正确性,也让闭包对象的生命周期被拉长。
经验做法:如果在循环里需要短期使用的局部值,把它复制给循环体内的一个新局部变量再捕获,既避免共享状态,也让闭包对象更容易被提前回收。
3.3 热路径上的分配优化:从代码里挤掉隐式分配
我在做高性能服务时,常用 BenchmarkDotNet 对核心方法做基准测试,重点盯三类分配量:
- 委托分配:每次 lambda 被创建时的堆分配;
- 迭代器对象分配:每个
yield状态机实例的堆分配; - 闭包类分配:捕获变量导致的堆分配。
如果发现某个热路径上的分配量特别高,优化手段有几种:
- 缓存委托:把 lambda 提出来作为静态只读字段,尤其是无捕获的 lambda,编译器本身会缓存,但如果你把 lambda 传给了某个还不能确定会缓存的地方(比如库函数),最好手动显式存成静态字段;
- 避免不必要的 LINQ 链:如果只是简单循环累加,直接
for比Sum()快得多。LINQ 的通用性换来的是额外的方法调用和状态机开销,在百万级循环里差距可以到好几倍; - 使用结构体实现接口:.NET 8+ 提供了
IStructuralComparable之类的接口帮助优化,但对业务代码来说,更直接的是自定义一个struct迭代器,而不是依赖yield。这个技巧比较底层,适合库作者; - 使用
CollectionExpression或池化集合:在 .NET 8 之后,[CollectionBuilder]可以让集合初始化走builder模式,减少数组复制。
这些手段的核心思路是一致的:把"每次调用都发生的分配"变成"一次性分配"。
4. IEnumerable 与 IQueryable:不要在内存里做数据库该干的事
4.1 两种执行路径的分水岭
IEnumerable<T> 和 IQueryable<T> 的差别,是很多性能事故的根源。IEnumerable<T> 的 LINQ 操作符在内存中执行,数据已经存在。IQueryable<T> 的 LINQ 操作符则构建了一棵表达式树(Expression),真正执行时,表达式树会被传给某个 provider(比如 EF Core),翻译成 SQL。
看这段代码:
csharp复制// 在内存里做过滤
var invalidOrders = allOrders.Where(o => o.Status == "Invalid").ToList();
// 在数据库里做过滤
var invalidOrders = dbContext.Orders.Where(o => o.Status == "Invalid").ToList();
第一行会把 allOrders 全量加载到内存,再逐条判断。如果 allOrders 有几十万条,那内存压力瞬间爆表。第二行由 EF Core 翻译成 WHERE Status = 'Invalid',数据库只返回符合条件的行。
很多团队踩坑的场景是:把 EF Core 的 IQueryable 过早地转成了 IEnumerable 或 List。比如:
csharp复制var list = dbContext.Orders.ToList(); // 先把所有订单拉到内存
var result = list.Where(x => x.Amount > 1000).ToList(); // 再内存过滤
这就完全背离了数据库的能力。正确做法是先构建 IQueryable 查询,让过滤、排序、分页都发生在数据库端:
csharp复制var query = dbContext.Orders.Where(x => x.Amount > 1000);
var result = await query.ToListAsync();
4.2 表达式树如何变成 SQL
IQueryable<T> 的底层是 Expression 树。你在 LINQ 里写的 Where(x => x.Amount > 1000),编译器会把 lambda 编译成表达式树(前提是参数类型是 Expression<Func<...>>),而不是委托。表达式树记录了"过滤"这个操作的结构:大于、1000、Amount 属性。
EF Core 拿到这棵树后,遍历节点,翻译成 SQL 片段。这意味着:
- 不是所有 C# 表达式都能被翻译成 SQL;
- 翻译失败的场景,EF Core 会尝试"客户端评估",导致部分数据拉回内存;
- 自定义函数、特定字符串方法、某些数学运算,很可能触发客户端评估。
客户端评估是性能杀手。你写了看似在数据库执行的查询,实际却把几千行数据拉到内存里跑函数。判断方法:启用 EF Core 的日志,看是否有 The LINQ expression ... could not be translated 警告。EF Core 3.0 之后默认会抛异常而不是静默客户端评估,但仍有不少边缘情况要靠日志确认。
我遇到过最离谱的一次:某接口把数据库表全量拉回内存做 Count,只因为代码里写的是 .AsEnumerable().Count() 而不是 .Count()。AsEnumerable() 明确告诉编译器"后面别给数据库翻译了,交给内存 LINQ",它强制切断了 IQueryable 的翻译管道。如果你要的是"让某一段逻辑用内存方法跑",那没问题;但多数人写它只是为了"绕过某个翻译报错",结果把一堆数据拉回内存。
4.3 用错执行路径的真实事故
2023 年我接手过一个报表服务,每月初跑对账要 4 小时。看代码,主循环里有一个 foreach 套着内层查询:
csharp复制var allUsers = dbContext.Users.ToList();
foreach (var order in dbContext.Orders.ToList())
{
var user = allUsers.FirstOrDefault(u => u.Id == order.UserId);
...
}
这个代码有两个问题:第一,ToList() 把所有订单全拉内存,数据量一大就直接卡死;第二,FirstOrDefault 在内存里做线性查找,每笔订单都要遍历一次用户列表。优化后的版本:
csharp复制var userDict = await dbContext.Users.ToDictionaryAsync(u => u.Id);
await foreach (var order in dbContext.Orders.AsAsyncEnumerable())
{
var user = userDict.GetValueOrDefault(order.UserId);
...
}
用字典把用户表变成哈希索引,订单改用 AsAsyncEnumerable() 流式处理,不一次性加载全部。最终这个对账任务从 4 小时压到了 40 分钟。核心就是两条:别把数据库数据提前全量拉内存;别用线性查找处理大数据集。
5. 我实测过的几种性能优化手段
5.1 避免重复枚举:一次枚举,多处复用
重复枚举是代码评审里最常见的问题。举例:
csharp复制var validItems = items.Where(IsValid);
if (validItems.Any())
{
var first = validItems.First();
Process(first);
}
foreach (var item in validItems)
{
...
}
这里 validItems 被枚举了三次(Any、First、foreach),每次都会完整跑一遍 Where。实测过一组 10 万元素的数据,三次枚举的耗时大约是 15ms、12ms、14ms,加起来比一次性 ToList() 后再操作要慢 30% 以上,而且每次枚举都要重新执行委托。
正确做法:
csharp复制var validList = items.Where(IsValid).ToList();
if (validList.Count > 0)
{
Process(validList[0]);
}
foreach (var item in validList)
{
...
}
对于数据量小的场景,重复枚举问题不大,但在热路径和大数据量下,一定要警惕。
一个小技巧:如果只需要知道"有没有至少一个元素但还要用全部",用 foreach 手动计数比 Any() + foreach 少一次枚举。比如:
csharp复制int count = 0;
foreach (var item in validItems)
{
count++;
Process(item);
}
if (count == 0) { ... }
5.2 大集合 Contains 的替代:HashSet 与局部索引
List.Contains 在数据量大时慢得离谱,因为它做线性查找,时间复杂度 O(n)。如果你在循环里反复调用 Contains,实际就是 O(n*m)。我用 BenchmarkDotNet 测过一组数据:
| 数据规模 | List.Contains 查找 1000 次 | HashSet.Contains 查找 1000 次 |
|---|---|---|
| 10,000 | 约 8 ms | 约 0.05 ms |
| 100,000 | 约 90 ms | 约 0.06 ms |
| 1,000,000 | 约 900 ms | 约 0.08 ms |
数量级差距是肉眼可见的。优化方式就是在循环外先建 HashSet 或 Dictionary:
csharp复制var validIds = validOrders.Select(o => o.Id).ToHashSet();
var result = allItems.Where(x => validIds.Contains(x.OrderId)).ToList();
这个模式在处理两个集合取交集、判断存在性时特别常见。如果你需要的是"按某个字段快速查找",用 ToDictionary 更直接。
5.3 分批处理与极简迭代器链
大数据集上,LINQ 迭代器链会带来多层状态机嵌套。一个 .Where().Select().OrderBy() 会创建至少三个迭代器对象,数据流动要经过每一层的方法调用。对于中等数据量(万级)不算什么,但到了百万级,性能损耗就变得可感。
一种做法是减少链上的操作符数量,把逻辑合并到同一个 Select 或自定义循环里。比如:
csharp复制var result = items
.Where(x => x.IsActive)
.Select(x => x.Age * 2)
.ToList();
如果 IsActive 和 Age * 2 之间没有复用,可以合并成一个 Select:
csharp复制var result = items
.Select(x => x.IsActive ? x.Age * 2 : 0)
.ToList();
但要注意,这只是牺牲可读性换性能,只适合确认是热路径的情况。一般来说,我会先用可读性好的版本跑通,再用 profiler 定位具体热点,最后在这个热点上做"极简迭代器链"改造。
另一个实用操作是分批处理:如果你要从数据库加载大量数据,不要一次性 ToList(),用分页或流式方式处理。EF Core 的 AsAsyncEnumerable()、SqlDataReader 都支持流式读取,能让内存稳定在一个低位。
5.4 选择正确的终止操作:Any、Count 与 ToList 的时机
终止操作决定查询是否立即执行,以及执行成本:
Any():只要找到第一个匹配就返回,O(1) 到 O(n) 之间,通常比Count() > 0快;Count()/LongCount():必须完整枚举所有元素,O(n);First()/FirstOrDefault():找到第一个匹配就返回,但如果没有匹配项,First()会抛异常;Single()/SingleOrDefault():必须确认集合里只有一个匹配项,所以即使找到了第一个,也要继续检查下一个,效率比First低;ToList()/ToArray():完整执行并创建快照。
记住一点:xs.Count() > 0 永远应该写成 xs.Any()。Count() 是 O(n),Any() 至少可以提前中断。类似地,如果你需要某个特定的元素且确定唯一,用 First 而不是 Single;除非你明确需要"多于一个就报错"的语义。
还有一个容易忽略的:Count() 在 ICollection<T> 上有优化,会直接返回 Count 属性,不会枚举。所以对于 List<T>,list.Count、list.Count() 性能差不多;但对于 IEnumerable,Count() 必须枚举到末尾。
6. 定位 LINQ 性能问题的思路与工具
6.1 症状驱动的排查路径
遇到 LINQ 性能问题,通常有几种症状,每种对应不同的排查方向:
| 症状 | 可能的根因 | 排查方向 |
|---|---|---|
| CPU 高 | 迭代器链过长、重复枚举、复杂 lambda | profiler 抓热路径,看是否有重复调用 |
| 内存高 / OOM | 全量拉取、闭包持有大对象、过早 ToList | 检查数据源,看是否可以先过滤再取数 |
| 数据库慢查询 | IQueryable 客户端评估、N+1 查询、索引缺失 | 查看 EF Core 生成的 SQL,检查查询是否被翻译 |
| 接口响应慢但不报错 | 重复枚举、线性查找 | BenchmarkDotNet 微基准对比优化前后 |
第一步永远不是改代码,而是先量化。用 Stopwatch 粗测、用 BenchmarkDotNet 精测、用 dotnet-counters 看 GC 和内存增速。有了数据再动手,避免"猜测驱动优化"。
6.2 用 BenchmarkDotNet 量化微性能
BenchmarkDotNet 是 .NET 生态里最常用的基准测试库。一个简单的例子:
csharp复制[MemoryDiagnoser]
public class LinqBenchmark
{
private List<int> _data;
[GlobalSetup]
public void Setup()
{
_data = Enumerable.Range(1, 100000).ToList();
}
[Benchmark(Baseline = true)]
public int ForLoop()
{
int sum = 0;
for (int i = 0; i < _data.Count; i++)
sum += _data[i];
return sum;
}
[Benchmark]
public int LinqSum()
{
return _data.Sum();
}
[Benchmark]
public int LinqWhereSum()
{
return _data.Where(x => x % 2 == 0).Sum();
}
}
运行后,你会得到耗时、分配量、GC 次数等数据。[MemoryDiagnoser] 会报告每千次操作的分配字节数,这是发现"隐藏分配"的最直接方式。
有一次我用它测一个高频调用方法,发现 Where().Select() 比手写 for 慢了 4 倍,且每次调用多分配约 48 字节。48 字节听起来很小,但如果是每秒调用 10 万次,就是每秒 4.8 MB 的 GC 压力。这种问题不加测量根本找不到。
注意:BenchmarkDotNet 的结果是相对值,不是绝对值。它在 Release 模式下运行,且会做预热和多次迭代,所以比直接
Stopwatch更可信。但它测的是"单一场景下的相对差距",最终还是要回到真实环境验证。
6.3 反编译与 IL:看编译器到底生成了什么
如果你想知道某段 LINQ 查询在编译器眼里是什么样子,用 ILSpy 或 dnSpy 反编译。步骤很简单:
- 写出你的查询代码;
- 编译成 Release 版本;
- 用 ILSpy 打开程序集,定位到对应方法;
- 把显示语言切换到 IL 或 C#(反编译后的 C# 会显示编译器生成的类名和闭包类)。
你会看到类似这样的东西(简化版):
csharp复制private static Func<Product, bool> <>9__1_0;
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int limit;
public bool <Main>b__0(Product p) => p.Price > limit;
}
看到了吗?limit 被提升到了匿名类里,lambda 变成了该类的实例方法,委托实例被创建一次并缓存到 <>9__1_0。如果你在循环里创建 lambda,编译器几乎肯定会在循环内 new 闭包实例和委托,反编译后会非常直观地看到那一堆 new 指令。
这个方法尤其适合排查"为什么这段代码会分配这么多内存"——直接把闭包类、委托实例、迭代器状态机在 IL 里数一遍,心里就有数了。
6.4 一个完整的调优案例
最后,分享一个完整的调优过程,展示上面这些思路怎么串起来。
背景:一个工单处理系统,每天要处理几十万条工单,其中有个"按客户统计逾期单量"的后台任务,跑一次要 6 分钟,经常超时。
初步排查:看代码发现,主流程里用了两次 ToList() 把客户表和工单表全量拉进内存,然后在内存里 join。第一步优化很简单——把 join 下推到数据库:
csharp复制var query = from c in dbContext.Customers
join o in dbContext.Orders on c.Id equals o.CustomerId
where o.Status == "Overdue"
group o by c.Name into g
select new { CustomerName = g.Key, Count = g.Count() };
var result = await query.ToListAsync();
跑完从 6 分钟降到 2 分钟。然后又用 profiler 发现,仍然有大量 Order 实体被加载到内存。原因是查询里 join 之后 EF Core 默认追踪实体,产生了额外查询。加上 AsNoTracking() 后,时间从 2 分钟降到 40 秒:
csharp复制var query = from c in dbContext.Customers.AsNoTracking()
join o in dbContext.Orders.AsNoTracking() on ...
再进一步,发现每次任务运行都重新创建索引,数据库端 group by 走的是索引扫描而不是 seek。给 Orders.Status 和 CustomerId 建了联合索引后,时间降到 8 秒。
这个过程里,真正用到的工具是:EF Core SQL 日志(看生成的查询)、dotnet-trace(看热点)、数据库执行计划(看索引)。LINQ 代码本身只改了两行,性能却差了 45 倍。多数性能问题不是 LINQ 语法的问题,而是执行策略的问题——把工作放对了地方,效率自然就上去了。
我在实际项目里的体会是:IQueryable 和 IReadOnlyList 是两种完全不同的"承诺"。前者承诺延迟和能力下推,后者承诺快照和内存安全。写代码时想清楚自己给的到底是哪种承诺,能少踩一半的坑。还有一个我很常用的小习惯:写完一段 LINQ,反编译看一眼——不需要每次都看,但当你觉得"这段代码开销不太对"的时候,反编译往往能直接告诉答案。LINQ 的上限,取决于你理解它的程度,而不是你会不会写那几个关键字。
