×

让 LINQ 快 10 倍的 C# 写法:少分配、早过滤、别重复枚举

独孤求败 独孤求败 发表于2026-07-21 22:59:58 浏览13 评论0

抢沙发发表评论

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(1100_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#,不是放弃表达力,而是知道表达力背后的成本。


群贤毕至

访客