C# 高性能写法(三):LINQ
LINQ 的优势是表达清晰,性能问题通常不在 LINQ 本身,而在查询执行了多少次、遍历了多少数据,以及是否创建了不必要的对象。普通业务代码应优先保证可读性,只有进入高频路径后,才需要根据测量结果选择更底层的写法。
1. 🔁 避免重复枚举
大部分 LINQ 操作采用延迟执行。调用 Where、Select 时通常只是创建查询,真正的计算发生在 foreach、Count、ToList 等枚举操作中。
1.1 同一个查询可能执行多次
var validUsers = users.Where(static user => user.IsEnabled);
int count = validUsers.Count();
User? first = validUsers.FirstOrDefault();
上面的查询可能遍历两次,筛选条件也会执行两次。如果数据来自迭代器、文件或其他外部来源,重复枚举还可能重复触发 I/O 或产生不同结果。
1.2 需要复用时再物化
如果结果确定会被多次使用,可以主动保存为数组或列表:
User[] validUsers = users
.Where(static user => user.IsEnabled)
.ToArray();
int count = validUsers.Length;
User? first = validUsers.FirstOrDefault();
物化会增加一次 O(n) 遍历和相应的内存分配,因此它不是默认优化。查询只使用一次时,应继续保留延迟执行。
2. ⏹️ 使用能够提前结束的操作
只需要判断是否存在符合条件的元素时,使用 Any:
bool exists = users.Any(static user => user.IsEnabled);
Any 找到第一个符合项后就会停止。相比之下,Count(predicate) > 0 需要统计所有符合项。
同样的原则也适用于其他操作:
- 查找第一个元素使用
First或FirstOrDefault。 - 判断是否全部满足条件使用
All。 - 判断是否包含某个值使用
Contains。 - 获取最大或最小元素使用
Max、Min、MaxBy或MinBy。
对于数组和 List<T>,仅判断集合是否为空时,可以直接读取 Length 或 Count,语义更明确:
if (users.Count > 0)
{
// 集合不为空。
}
3. 🧱 避免不必要的中间集合
每次调用 ToList 或 ToArray 都会立即执行此前的查询,并为结果分配新的集合。
UserDto[] result = users
.Where(static user => user.IsEnabled)
.ToList()
.Select(static user => new UserDto(user.Id, user.Name))
.ToArray();
如果不需要中间列表,可以直接连接查询:
UserDto[] result = users
.Where(static user => user.IsEnabled)
.Select(static user => new UserDto(user.Id, user.Name))
.ToArray();
以下情况才适合主动创建中间集合:
- 查询结果需要多次枚举。
- 需要保存某一时刻的数据快照。
- 后续代码必须使用
List<T>或数组提供的能力。 - 数据源可能在枚举期间发生变化。
4. 🎯 先减少数据,再执行昂贵操作
查询顺序不仅影响可读性,也决定业务代码执行多少次。能够提前过滤的数据,应尽量在昂贵的转换之前过滤:
var result = orders
.Where(static order => order.IsPaid)
.Select(static order => CreateReport(order));
如果先调用 Select,CreateReport 可能会处理最终被过滤掉的订单。现代 .NET 能够优化部分 Where 与 Select 组合,但无法消除业务方法内部的计算和分配。
同理,在只需要少量结果时,应尽早使用 Take,避免后续操作消费更多数据。不过,如果前面存在 OrderBy、GroupBy 等必须读取大量输入的操作,Take 并不能让整个查询立即变成低成本操作。
5. 🔍 为重复查找选择合适的集合
在 LINQ 条件中反复调用 List<T>.Contains,每次查找都可能线性扫描列表:
var result = users.Where(user => allowedIds.Contains(user.Id));
当待查集合较大或查询会重复执行时,可以先建立 HashSet<T>:
HashSet<int> allowedSet = allowedIds.ToHashSet();
var result = users.Where(user => allowedSet.Contains(user.Id));
HashSet<T> 的查找通常接近 O(1),但创建集合本身需要时间和内存。数据量很小、只查找一次时,直接使用原集合可能更划算。
如果需要根据同一个键反复查询一组元素,可以考虑一次性创建 Lookup<TKey, TElement>,避免每次都执行 Where:
ILookup<int, Order> ordersByUser = orders.ToLookup(
static order => order.UserId);
IEnumerable<Order> userOrders = ordersByUser[userId];
6. 📊 不要为一个结果排序全部数据
只需要最大或最小元素时,直接使用对应的聚合操作:
Order? newest = orders.MaxBy(static order => order.CreatedAt);
相比先调用 OrderByDescending 再取第一个元素,MaxBy 更准确地表达了意图,也不需要构造完整的排序结果。
完整排序通常是 O(n log n),而查找最大或最小元素只需要一次 O(n) 扫描。只有确实需要有序结果时,才使用 OrderBy 或 OrderByDescending。
7. 🧩 注意 Lambda 捕获产生的闭包
Lambda 引用了方法中的局部变量时,编译器通常需要创建闭包对象保存这些变量:
int minimumScore = 60;
var passed = students.Where(student => student.Score >= minimumScore);
在普通请求处理中,这点分配通常不值得牺牲代码清晰度。但如果查询位于调用频率极高的路径中,应确认闭包是否成为可观测成本。
不需要捕获外部状态时,可以使用 static Lambda,让编译器直接阻止意外捕获:
var passed = students.Where(static student => student.Score >= 60);
static Lambda 不是让 LINQ 自动变快的开关,它的主要价值是明确无捕获约束。
8. 🔥 热路径中考虑使用循环
LINQ 查询可能包含迭代器对象、委托调用和中间状态。对于数据量很小但调用频率极高的底层代码,普通循环往往更容易控制执行过程和内存分配:
static int SumPositive(ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int value in values)
{
if (value > 0)
{
sum += value;
}
}
return sum;
}
不要仅凭代码形态就把所有 LINQ 改成循环。现代 .NET 已经为数组、列表和常见查询组合提供了多种优化,实际差异取决于数据源、数据量和调用频率。重写前应使用基准测试确认收益。
9. 🗄️ 区分 Enumerable 与 Queryable
Enumerable 操作的是进程内对象,性能重点是遍历次数、算法复杂度和内存分配。Queryable 通常会把表达式树交给数据库等查询提供程序,性能重点则变成生成的 SQL、网络传输、索引和执行计划。
var users = dbContext.Users
.Where(user => user.IsEnabled)
.Select(user => new { user.Id, user.Name })
.Take(100);
对于数据库查询,应尽量在切换到 IEnumerable<T> 之前完成筛选、投影和分页。过早调用 AsEnumerable 或 ToList,可能把本应由数据库完成的工作转移到应用进程。
判断数据库中是否存在记录时通常也应使用 AnyAsync,而不是先执行 CountAsync 再与零比较。最终仍要检查查询提供程序生成的 SQL,不能把内存 LINQ 的结论直接套用到数据库查询上。
10. ⚙️ 不要盲目使用 PLINQ
AsParallel 会增加任务调度、分区和结果合并成本。它更适合数据量较大、单个元素计算较重、元素之间相互独立的 CPU 密集型任务。
以下场景通常不适合 PLINQ:
- 数据量很小或每个元素的处理非常简单。
- 查询主要等待数据库、文件或网络 I/O。
- 必须严格保持顺序。
- 查询内部共享可变状态。
- 应用本身已经在并行处理大量请求。
并行度越高不代表吞吐量越高。使用 PLINQ 前,应在接近生产环境的负载下测量整体吞吐量和延迟。
11. ✅ LINQ 优化顺序
遇到 LINQ 性能问题时,可以按下面的顺序检查:
- 查询是否被重复枚举。
- 是否创建了不必要的
List<T>、数组或分组结果。 - 能否使用
Any、First、MaxBy等提前结束或单次扫描的操作。 - 重复查找是否应该使用
HashSet<T>、Dictionary<TKey, TValue>或Lookup<TKey, TElement>。 - 昂贵转换是否发生在过滤之前。
- Lambda 是否捕获了不必要的外部状态。
- 如果是
IQueryable<T>,检查实际生成的查询语句和执行计划。 - 最后再通过基准测试判断是否值得改成循环、Span 或并行查询。
LINQ 的价值在于让数据处理逻辑更接近业务意图。高性能写法不是完全避开 LINQ,而是让每次枚举、排序和分配都确实有必要。