关于 dotnet-linq-optimization
面向 .NET 开发者的 LINQ 性能优化技能,讲解 EF Core 查询执行位置、延迟执行和分配成本,并介绍编译查询、手动循环与 Span 替代方案。
主要功能
- 01区分 IQueryable 服务端与 IEnumerable 内存执行
- 02运用 EF Core 编译查询
- 03识别延迟执行与重复枚举问题
- 04分析 LINQ 分配并评估手动循环
- 05使用 Span 进行低分配处理
- 06优化投影、分页与批量操作
适用场景
- 01避免查询过早切换到客户端执行
- 02优化高频 EF Core 查询
- 03排查重复执行的延迟查询
- 04减少热路径集合处理的分配
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-linq-optimization安装后在 Claude Code 中输入/dotnet-linq-optimization确认已加载。
详细介绍
dotnet-linq-optimization
面向 .NET 应用的 LINQ 性能模式。涵盖 IQueryable<T> 服务端求值与 IEnumerable<T> 客户端物化之间的关键区别、EF Core 热路径中的编译查询、延迟执行陷阱、LINQ-to-Objects 分配模式以及何时改用手动循环,还有基于 Span 的零分配处理替代方案。
不在讨论范围内: EF Core DbContext 生命周期、迁移、拦截器和连接弹性——请参阅 [skill:dotnet-efcore-patterns]。战略性数据架构(仓储模式、读写分离、N+1 治理)——请参阅 [skill:dotnet-efcore-architecture]。Span 和 Memory 基础知识——请参阅 [skill:dotnet-performance-patterns]。微基准测试设置——请参阅 [skill:dotnet-benchmarkdotnet]。
交叉参考:[skill:dotnet-efcore-patterns] 介绍 EF Core 上下文中的编译查询和 DbContext 用法;[skill:dotnet-performance-patterns] 介绍 Span/Memory 基础知识和 ArrayPool 模式;[skill:dotnet-benchmarkdotnet] 介绍如何衡量 LINQ 优化的影响。
IQueryable 与 IEnumerable 的物化
LINQ 性能方面影响最大的决策是求值发生的位置:数据库服务端(IQueryable<T>)还是应用内存中(IEnumerable<T>)。
问题
// DANGEROUS: Materializes entire table into memory, then filters in C#
IEnumerable<Order> orders = dbContext.Orders;
var recent = orders.Where(o => o.CreatedAt > cutoff).ToList();
// SQL: SELECT * FROM Orders (no WHERE clause!)
// CORRECT: Filter executes on the database server
IQueryable<Order> orders = dbContext.Orders;
var recent = orders.Where(o => o.CreatedAt > cutoff).ToList();
// SQL: SELECT ... FROM Orders WHERE CreatedAt > @cutoff
何时发生物化
| 操作 | 影响 |
|---|---|
ToList()、ToArray()、ToDictionary() | 执行查询,将结果加载到内存中 |
foreach / await foreach | 执行查询,流式传输结果 |
AsEnumerable() | 从服务端求值切换为客户端求值 |
Count()、Any()、First()、Single() | 执行查询,返回标量 |
IQueryable 上的 Where()、Select()、OrderBy() | 构建表达式树(不执行) |
IEnumerable 上的 Where()、Select()、OrderBy() | 延迟进行内存中求值 |
常见错误
// MISTAKE 1: AsEnumerable() before filtering
var results = dbContext.Orders
.AsEnumerable() // <-- switches to client evaluation
.Where(o => o.Total > 100) // runs in memory, not SQL
.ToList();
// MISTAKE 2: Calling a C# method in IQueryable predicate
var results = dbContext.Orders
.Where(o => IsHighValue(o)) // Cannot translate to SQL; throws or falls back
.ToList();
// FIX: Use expression-compatible predicates or call after materialization
var results = dbContext.Orders
.Where(o => o.Total > 100) // SQL-translatable
.AsEnumerable()
.Where(o => IsHighValue(o)) // C# logic after materialization
.ToList();
// MISTAKE 3: Projecting too many columns
var names = dbContext.Orders.ToList().Select(o => o.CustomerName);
// Loads ALL columns, then picks one in memory
// FIX: Project before materializing
var names = dbContext.Orders.Select(o => o.CustomerName).ToList();
// SQL: SELECT CustomerName FROM Orders
检测清单
- 在
Where/Select之前出现任何AsEnumerable()或转换为IEnumerable<T>,都可能绕过服务端求值 - EF Core 在回退到客户端求值时,会以 Warning 级别记录
Microsoft.EntityFrameworkCore.Query日志 - 开发期间启用
ConfigureWarnings(w => w.Throw(RelationalEventId.MultipleCollectionIncludeWarning))
EF Core 热路径的编译查询
编译查询可消除每次调用时编译表达式树的开销。对于每秒执行数千次的查询,它可以显著降低开销。
标准编译查询
public sealed class OrderRepository(AppDbContext db)
{
// Compiled once, reused across all calls
private static readonly Func<AppDbContext, Guid, Task<Order?>>
s_findById = EF.CompileAsyncQuery(
(AppDbContext ctx, Guid id) =>
ctx.Orders.FirstOrDefault(o => o.Id == id));
private static readonly Func<AppDbContext, DateTime, IAsyncEnumerable<Order>>
s_findRecent = EF.CompileAsyncQuery(
(AppDbContext ctx, DateTime cutoff) =>
ctx.Orders
.Where(o => o.CreatedAt > cutoff)
.OrderByDescending(o => o.CreatedAt));
public Task<Order?> FindByIdAsync(Guid id) =>
s_findById(db, id);
public IAsyncEnumerable<Order> FindRecentAsync(DateTime cutoff) =>
s_findRecent(db, cutoff);
}
何时使用编译查询
| 场景 | 使用编译查询? |
|---|---|
| 高频查找(身份验证、缓存) | 是 |
| 管理员仪表板查询(低频) | 否——开销可以忽略 |
| 带动态谓词的查询(用户搜索) | 否——无法对查询形状进行参数化 |
Include() 会变化的查询 | 否——Include 会改变表达式树形状 |
限制
- 编译查询不能使用动态
Include(),也不能使用会改变表达式树形状的条件Where()子句 - 参数必须是简单类型(不能是复杂对象或集合)
- 对于单个结果,
EF.CompileAsyncQuery返回Task<T>;对于集合,返回IAsyncEnumerable<T>
延迟执行陷阱
LINQ 使用延迟执行:查询运算符会构建一个仅在使用结果时才执行的管道。这种机制功能强大,但也会带来微妙的 Bug。
多次枚举
// BUG: Enumerates the database query twice
IQueryable<Order> query = dbContext.Orders.Where(o => o.Status == Status.Active);
var count = query.Count(); // Executes SQL (1st query)
var items = query.ToList(); // Executes SQL again (2nd query)
// FIX: Materialize once
var items = dbContext.Orders
.Where(o => o.Status == Status.Active)
.ToList();
var count = items.Count; // In-memory, no SQL
循环中的闭包捕获
// BUG: All queries capture the same loop variable 'i' by reference
var queries = new List<IQueryable<Order>>();
for (int i = 0; i < statuses.Length; i++)
{
queries.Add(dbContext.Orders.Where(o => o.Status == statuses[i]));
// 'i' is captured by reference -- all queries use final value of i
}
// FIX: Copy to a local variable inside the loop body
for (int i = 0; i < statuses.Length; i++)
{
var localStatus = statuses[i];
queries.Add(dbContext.Orders.Where(o => o.Status == localStatus));
}
注意:C# 5+ 中,foreach 循环变量的作用域按迭代划分,不会出现此 Bug。for 循环的索引变量会在各次迭代间共享,因此在循环中构建延迟执行的 LINQ 查询时,这是一种常见陷阱。
方法返回值中的延迟执行
// DANGEROUS: Returns an unevaluated query -- caller may not realize
// the DbContext could be disposed before enumeration
public IEnumerable<Order> GetActiveOrders()
{
return dbContext.Orders.Where(o => o.Status == Status.Active);
// Not evaluated yet -- DbContext may be disposed when caller iterates
}
// SAFE: Materialize before returning
public async Task<List<Order>> GetActiveOrdersAsync(CancellationToken ct)
{
return await dbContext.Orders
.Where(o => o.Status == Status.Active)
.ToListAsync(ct);
}
LINQ-to-Objects 分配模式
对内存集合使用 LINQ 运算符会分配迭代器、委托和中间集合。在每秒处理数千个项目的热点路径中,这些分配可能导致 GC 压力。
分配来源
| 操作 | 分配 |
|---|---|
Where()、Select() | 迭代器对象 + 委托 |
ToList()、ToArray() | 新集合 + 可能的扩容 |
OrderBy() | 为排序进行完整复制 |
GroupBy() | 字典 + 分组对象 |
SelectMany() | 迭代器 + 内部迭代器 |
| Lambda 捕获局部变量 | 每个捕获作用域分配一个闭包对象 |
LINQ 分配何时值得关注
对大多数代码而言,LINQ 分配可以忽略不计。仅在以下情况进行优化:
- 处理发生在热点路径中(每秒调用数千次)
- BenchmarkDotNet 显示
Allocated字节数显著 - GC 指标(每秒 Gen0 回收次数)表明存在压力
手动循环替代方案
// LINQ: Allocates iterator + delegate + List<T>
var result = items
.Where(x => x.IsActive)
.Select(x => x.Name)
.ToList();
// Manual loop: Single List<T> allocation, no iterator/delegate overhead
var result = new List<string>(items.Count);
foreach (var item in items)
{
if (item.IsActive)
{
result.Add(item.Name);
}
}
// LINQ: Allocates iterator + delegate + bool boxing (Any)
var hasActive = items.Any(x => x.IsActive);
// Manual loop: Zero allocations beyond the enumerator
var hasActive = false;
foreach (var item in items)
{
if (item.IsActive)
{
hasActive = true;
break;
}
}
在不放弃 LINQ 的情况下减少分配
改用手动循环之前,请考虑以下折中步骤:
// 1. Use Array.Find / Array.Exists for arrays (no iterator allocation)
var first = Array.Find(items, x => x.IsActive);
var exists = Array.Exists(items, x => x.IsActive);
// 2. Pre-size collections when count is known
var result = new List<string>(items.Length);
result.AddRange(items.Where(x => x.IsActive).Select(x => x.Name));
// 3. Use static lambdas to avoid delegate allocation (C# 9+)
var result = items.Where(static x => x.IsActive).ToList();
// Note: static lambdas prevent accidental closure capture
// but the delegate is already cached by the compiler for
// non-capturing lambdas; the main benefit is enforcement
基于 Span 的集合处理替代方案
对于性能要求最高的场景,Span<T> 和 ReadOnlySpan<T> 支持基于栈且零分配的处理。这些 API 与 LINQ 不兼容,但能覆盖常见模式。
Span 搜索与筛选
// Zero-allocation contains check on an array
ReadOnlySpan<int> values = stackalloc int[] { 1, 2, 3, 4, 5 };
bool found = values.Contains(3);
// Zero-allocation index search
int index = values.IndexOf(4);
用于字符串处理的 MemoryExtensions
// Zero-allocation split and iterate
ReadOnlySpan<char> csv = "alice,bob,charlie";
foreach (var segment in csv.Split(','))
{
ReadOnlySpan<char> value = csv[segment];
// Process each value without allocating strings
}
// Zero-allocation trim and compare
ReadOnlySpan<char> input = " hello ";
bool match = input.Trim().SequenceEqual("hello");
何时优先使用 Span 而非 LINQ
| 场景 | 方式 |
|---|---|
| 在紧密循环中解析 CSV/日志行 | ReadOnlySpan<char> + Split |
| 搜索已排序数组 | Span<T>.BinarySearch |
| 处理来自 I/O 的字节缓冲区 | 对 ReadOnlySpan<byte> 进行切片 |
| 集合上的一般业务逻辑 | LINQ(可读性优先于微优化) |
如需了解完整的 Span/Memory 模式和 ArrayPool 用法,请参阅 [skill:dotnet-performance-patterns]。
查询优化模式
实体化前投影
始终只选择所需的列:
// BAD: Loads entire entity graph
var orders = await dbContext.Orders
.Include(o => o.Lines)
.Include(o => o.Customer)
.ToListAsync(ct);
var summaries = orders.Select(o => new
{
o.Id,
o.Customer.Name,
Total = o.Lines.Sum(l => l.Price * l.Quantity)
});
// GOOD: Project in the query -- single SQL with computed columns
var summaries = await dbContext.Orders
.Select(o => new
{
o.Id,
CustomerName = o.Customer.Name,
Total = o.Lines.Sum(l => l.Price * l.Quantity)
})
.ToListAsync(ct);
使用键集(Seek)方法分页
// Offset pagination: O(N) -- server must skip rows
var page = await dbContext.Orders
.OrderBy(o => o.Id)
.Skip(pageSize * pageNumber)
.Take(pageSize)
.ToListAsync(ct);
// Keyset pagination: O(1) -- index seek
var page = await dbContext.Orders
.Where(o => o.Id > lastSeenId)
.OrderBy(o => o.Id)
.Take(pageSize)
.ToListAsync(ct);
批量操作
// BAD: N UPDATE statements (one per tracked entity change)
foreach (var order in orders)
{
order.Status = OrderStatus.Archived;
}
await dbContext.SaveChangesAsync(ct);
// Generates N individual UPDATE statements in a single round-trip
// GOOD: EF Core 7+ ExecuteUpdateAsync (single SQL statement)
await dbContext.Orders
.Where(o => o.CreatedAt < cutoff)
.ExecuteUpdateAsync(
s => s.SetProperty(o => o.Status, OrderStatus.Archived),
ct);
Agent 常见陷阱
- 筛选前不要将 IQueryable 转换为 IEnumerable——这会悄然将服务端 SQL 求值切换为客户端内存求值,可能加载整张表。检查是否存在
AsEnumerable()、显式转换,或接受IEnumerable<T>的方法签名。 - 不要从存储库方法返回 IQueryable——调用方可以继续组合运算符,但枚举时 DbContext 可能已经释放。请返回已实体化的集合(
List<T>)或使用IAsyncEnumerable<T>。 - 没有基准测试时不要优化 LINQ 分配——对大多数业务逻辑而言,LINQ 迭代器的开销可以忽略不计。在用手动循环替换 LINQ 前,请使用 [skill:dotnet-benchmarkdotnet]
[MemoryDiagnoser]验证分配确实值得关注。 - 不要对动态谓词使用编译查询——编译查询会缓存表达式树的形状。如果每次调用时查询形状都发生变化(条件包含项、动态筛选器),编译查询会抛出异常或产生错误结果。
- 不要多次枚举延迟查询——每次枚举都会重新执行底层数据源(数据库查询、网络调用)。如果结果会被使用多次,请用
ToList()实体化。 - 深度分页时不要使用
Skip()/Take()——数据库上的偏移分页复杂度为 O(N)。请使用键集(Seek)分页,通过Where子句筛选最后看到的键,以确保性能不受页数深度影响。