LINQ 的优势是表达力。它让集合处理更接近业务语言,也让代码更短。但 LINQ 不是没有成本:重复枚举、过早投影、在热路径里分配临时集合、把本该 O(1) 的查询写成 O(n),都会让性能变差。
优化 LINQ 的重点不是“禁用 LINQ”,而是知道哪些写法会悄悄放大成本。
不要重复枚举同一个序列
下面的代码看起来没问题:
var expensiveOrders = orders.Where(order => order.Total > 1000);
var count = expensiveOrders.Count();
var total = expensiveOrders.Sum(order => order.Total);如果 orders 是内存列表,影响可能还好。如果它来自数据库查询、文件流或复杂迭代器,就会重复执行。更稳妥的方式是先物化一次,或者一次循环完成。
var expensiveOrders = orders
.Where(order => order.Total > 1000)
.ToList();
var count = expensiveOrders.Count;
var total = expensiveOrders.Sum(order => order.Total);如果只需要 count 和 sum,也可以直接循环,避免中间集合。
var count = 0;
var total = 0m;
foreach (var order in orders)
{
if (order.Total <= 1000)
{
continue;
}
count++;
total += order.Total;
}先过滤,再投影
常见低效写法是先创建对象,再过滤掉大部分。
var result = orders
.Select(order => new OrderSummary(order.Id, order.CustomerName, order.Total))
.Where(summary => summary.Total > 1000)
.ToList();更好的顺序是先减少数据量,再创建新对象。
var result = orders
.Where(order => order.Total > 1000)
.Select(order => new OrderSummary(order.Id, order.CustomerName, order.Total))
.ToList();这条规则在 EF Core 查询里也很重要。过滤越早下推到数据库,传输和内存压力越小。
用 HashSet 避免重复 Contains 扫描
如果在循环里用 List 做 Contains,可能会把查询变成 O(n*m)。
var selectedUsers = users
.Where(user => selectedIds.Contains(user.Id))
.ToList();如果 selectedIds 是 List,数量又比较大,应该先转成 HashSet<T>。
var selectedIdSet = selectedIds.ToHashSet();
var selectedUsers = users
.Where(user => selectedIdSet.Contains(user.Id))
.ToList();这不是微优化,而是复杂度变化。数据量一大,差距会非常明显。
热路径里考虑 Span
对数组或连续内存做简单处理时,Span<T> 可以减少分配并提高局部性。
public static int CountPositive(ReadOnlySpan<int> values)
{
var count = 0;
foreach (var value in values)
{
if (value > 0)
{
count++;
}
}
return count;
}LINQ 很适合表达业务集合查询,但在极高频的数值处理、协议解析、图像处理里,直接循环和 Span 往往更稳定。
EF Core 里不要提前 AsEnumerable
AsEnumerable() 会把后续操作切回内存执行。很多性能问题来自过早离开数据库查询。
var users = await db.Users
.AsEnumerable()
.Where(user => user.Email.EndsWith("@example.com"))
.ToListAsync();这段代码甚至无法按预期异步执行,因为查询已经切到内存。应该尽量让可翻译的过滤留在 IQueryable 上。
var users = await db.Users
.Where(user => user.Email.EndsWith("@example.com"))
.ToListAsync();先让数据库做它擅长的过滤、排序、分页,再把结果带回应用层。
避免 ToList 满天飞
ToList() 是边界操作,不是装饰品。它会立即执行并分配集合。该物化时要物化,例如避免重复枚举或需要快照;不该物化时就让查询保持惰性。
public IEnumerable<OrderSummary> GetSummaries(IEnumerable<Order> orders)
{
return orders
.Where(order => order.Status == OrderStatus.Paid)
.Select(order => new OrderSummary(order.Id, order.CustomerName, order.Total));
}如果调用方只是继续筛选或流式写出,就不需要在方法内部 ToList()。
用 BenchmarkDotNet 验证热点
性能优化要有数据。可以用 BenchmarkDotNet 比较 LINQ、循环和 HashSet 的差异。
[MemoryDiagnoser]
publicclassLinqBenchmarks
{
privatereadonly List<int> _values = Enumerable.Range(1, 100_000).ToList();
privatereadonly List<int> _selected = Enumerable.Range(50_000, 10_000).ToList();
[Benchmark]
public int ContainsWithList()
{
return _values.Count(value => _selected.Contains(value));
}
[Benchmark]
public int ContainsWithHashSet()
{
varset = _selected.ToHashSet();
return _values.Count(value => set.Contains(value));
}
}注意这个示例里 HashSet 每次 benchmark 都创建,真实代码可以把集合提前构造并复用,效果会更好。
总结
LINQ 慢,通常不是因为 LINQ 本身,而是写法放大了枚举、分配和算法复杂度。实用规则很简单:不要重复枚举;先过滤再投影;大集合 membership 用 HashSet;EF Core 查询不要过早切到内存;热路径用基准测试决定是否改成循环或 Span。
写出快的 C#,不是放弃表达力,而是知道表达力背后的成本。