LINQ 的优势是表达清晰,性能问题通常不在 LINQ 本身,而在查询执行了多少次、遍历了多少数据,以及是否创建了不必要的对象。普通业务代码应优先保证可读性,只有进入高频路径后,才需要根据测量结果选择更底层的写法。

1. 🔁 避免重复枚举

大部分 LINQ 操作采用延迟执行。调用 WhereSelect 时通常只是创建查询,真正的计算发生在 foreachCountToList 等枚举操作中。

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 需要统计所有符合项。

同样的原则也适用于其他操作:

  • 查找第一个元素使用 FirstFirstOrDefault
  • 判断是否全部满足条件使用 All
  • 判断是否包含某个值使用 Contains
  • 获取最大或最小元素使用 MaxMinMaxByMinBy

对于数组和 List<T>,仅判断集合是否为空时,可以直接读取 LengthCount,语义更明确:

if (users.Count > 0)
{
    // 集合不为空。
}

3. 🧱 避免不必要的中间集合

每次调用 ToListToArray 都会立即执行此前的查询,并为结果分配新的集合。

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));

如果先调用 SelectCreateReport 可能会处理最终被过滤掉的订单。现代 .NET 能够优化部分 WhereSelect 组合,但无法消除业务方法内部的计算和分配。

同理,在只需要少量结果时,应尽早使用 Take,避免后续操作消费更多数据。不过,如果前面存在 OrderByGroupBy 等必须读取大量输入的操作,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) 扫描。只有确实需要有序结果时,才使用 OrderByOrderByDescending

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> 之前完成筛选、投影和分页。过早调用 AsEnumerableToList,可能把本应由数据库完成的工作转移到应用进程。

判断数据库中是否存在记录时通常也应使用 AnyAsync,而不是先执行 CountAsync 再与零比较。最终仍要检查查询提供程序生成的 SQL,不能把内存 LINQ 的结论直接套用到数据库查询上。

10. ⚙️ 不要盲目使用 PLINQ

AsParallel 会增加任务调度、分区和结果合并成本。它更适合数据量较大、单个元素计算较重、元素之间相互独立的 CPU 密集型任务。

以下场景通常不适合 PLINQ:

  • 数据量很小或每个元素的处理非常简单。
  • 查询主要等待数据库、文件或网络 I/O。
  • 必须严格保持顺序。
  • 查询内部共享可变状态。
  • 应用本身已经在并行处理大量请求。

并行度越高不代表吞吐量越高。使用 PLINQ 前,应在接近生产环境的负载下测量整体吞吐量和延迟。

11. ✅ LINQ 优化顺序

遇到 LINQ 性能问题时,可以按下面的顺序检查:

  1. 查询是否被重复枚举。
  2. 是否创建了不必要的 List<T>、数组或分组结果。
  3. 能否使用 AnyFirstMaxBy 等提前结束或单次扫描的操作。
  4. 重复查找是否应该使用 HashSet<T>Dictionary<TKey, TValue>Lookup<TKey, TElement>
  5. 昂贵转换是否发生在过滤之前。
  6. Lambda 是否捕获了不必要的外部状态。
  7. 如果是 IQueryable<T>,检查实际生成的查询语句和执行计划。
  8. 最后再通过基准测试判断是否值得改成循环、Span 或并行查询。

LINQ 的价值在于让数据处理逻辑更接近业务意图。高性能写法不是完全避开 LINQ,而是让每次枚举、排序和分配都确实有必要。