.NET 9 发布有一阵子了,社区里聊得最多的是 AOT、原生性能、Frozen 集合那一套。但作为一个每天都在写 LINQ 的业务开发者,我最关心的其实是 .NET 9 给 LINQ 带来的这几处变化。别小看这几个新 API 和底层重写,我把自己两个项目升级之后,某些热点查询路径的耗时肉眼可见地降了下来。这篇文章不打算做泛泛的新特性清单,而是从“实测 + 场景”的角度,把 .NET 9 里 LINQ 最值得你关注的 5 个变化拆开讲清楚,尤其是对报表、聚合、索引遍历这类高频场景,优化空间比你想象的大得多。
如果你是准备把项目从 .NET 6/8 升级到 .NET 9 的开发者,或者你手上正有一批写得很“爽”但跑起来很卡的 LINQ 链式查询,这篇文章可以帮你少踩不少坑。我会从新增的 CountBy、AggregateBy、Index 三个方法,到底层泛型特化带来的免费提速,再到 FrozenSet 这类集合与 LINQ 的组合玩法,全部过一遍,并且附上可以直接抄走的代码和压测思路。
1. 内容整体设计与思路拆解
1.1 为什么 .NET 9 要在 LINQ 上做文章
先理清一个问题:LINQ 都出了十几年了,.NET 9 为什么还在动它?
原因很简单,LINQ 是 .NET 生态里使用频率最高的 API 之一,但它的历史包袱也一直很重。早期设计为了兼容各种 IEnumerable<T>,几乎所有的操作符都走接口调用、委托调用,lambda 每次分配闭包,链式操作还会产生大量中间迭代器对象。对大多数业务系统来说,这种开销可接受;但在数据量大、调用频繁的热路径上,GroupBy、Select、Sum 这种“写得爽”的代码就会变成性能瓶颈。
.NET 团队这些年一直在做零分配基础设施,比如 Span<T>、ValueTask,但 LINQ 一直没敢大改,核心原因是兼容性:改签名容易破坏现有代码,改内部实现又怕影响所有依赖。.NET 9 的思路比较务实,一方面新增 API 来补齐“分组聚合”场景的方法缺失,另一方面在保持公开签名完全不变的前提下,对底层实现做泛型特化,让已经在跑的代码也能直接享受提速。这是双轨并行,既解决“写起来麻烦”,又解决“跑起来慢”。
1.2 五个特性的定位与选择逻辑
我梳理了 .NET 9 中 LINQ 方向上最值得关注的 5 个变化点,它们不是同一个层面的东西,但是组合起来才构成了这次升级的完整价值:
| 特性 | 解决什么问题 | 适合场景 |
|---|---|---|
CountBy 新增方法 |
分组计数的语法太啰嗦 | 日志统计、订单量报表、标签频次 |
AggregateBy 新增方法 |
分组聚合要GroupBy+二次遍历 |
销售总额、平均值、多字段汇总 |
Index 新增方法 |
遍历时拿索引要写Select((x,i)) |
UI列表渲染、分页、任务序号 |
| LINQ 内核泛型特化 | 旧代码也有性能瓶颈 | 数组/List热点链式查询 |
| 集合类型联动 | 查询数据一次性集合开销大 | 大批量Contains判断、白名单过滤 |
前面三个是“新 API”,解决的是写法问题;第四个是“内核重写”,解决的是旧代码的隐性问题;第五个是“生态配合”,告诉你怎么跟 .NET 9 的集合优化一起用。我在后面的内容里也按这个顺序展开,先教你怎么用,再告诉你为什么快。
1.3 关于“300%”的正确理解方式
先泼一盆冷水,别把“300%”理解成所有 LINQ 查询都提速三倍,这不现实。但如果你正好踩中它优化的场景,这个数字并不夸张。
我实测过的一个场景是这样的:一个订单明细表,大概 100 万行,按城市分组统计销售额。旧写法是 GroupBy(o => o.City).Select(g => g.Sum(x => x.Amount)),它干了这些事:先遍历一次构建分组查找表和列表,再遍历每个分组做 Sum。如果城市有几千个,意味着要建几千个 List<Order>,还要为每个分组创建枚举器,内存分配非常可观。
换成 .NET 9 的 AggregateBy 之后,走的是单次遍历 + 字典累加,中间不产生分组列表。在我那台普通的开发机上,实测旧写法耗时 210ms,新写法 67ms,差距差不多就是三倍。如果你的数据规模更大、分组更多,这个差距还会继续拉大。
所以,300% 不是标题党,但它的前提是“分组数多、数据量大、重复遍历少”。如果你只是拿 LINQ 查一个几十条数据的小列表,这个收益几乎感觉不到。理解了这一点,再看后面的新 API 和性能优化,思路就会清楚很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心新特性解析与实操要点
2.1 CountBy:一行代码完成分组计数
先看最直观的新方法,CountBy。它的作用是对序列按某个键分组,然后统计每个分组的元素数量,返回 IEnumerable<KeyValuePair<TKey, int>>。
签名长这样:
csharp复制public static IEnumerable<KeyValuePair<TKey, int>> CountBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
IEqualityComparer<TKey>? comparer = null);
以往我们统计一组订单里有几个城市,写法是:
csharp复制var cityCounts = orders
.GroupBy(o => o.City)
.Select(g => new KeyValuePair<string, int>(g.Key, g.Count()));
现在一行就能解决:
csharp复制var cityCounts = orders.CountBy(o => o.City);
foreach (var (city, count) in cityCounts)
{
Console.WriteLine($"{city}: {count}");
}
注意两点:
第一,返回类型不是 Dictionary,而是 IEnumerable<KeyValuePair<TKey, int>>,它是延迟执行的,和 GroupBy 一样,枚举时才真正计算。所以如果你打算多次遍历这个结果,最好先 ToList() 或 ToDictionary(),否则每次枚举都会重新聚合一次。
第二,KeyValuePair 支持解构,所以 foreach (var (city, count) in cityCounts) 这种写法是合法的,这也是新 API 在可读性上的一大提升。
2.2 AggregateBy:更灵活的键控聚合
CountBy 只是一个“计数”特例,AggregateBy 才是真正的“分组聚合”通用工具。它允许你为每个分组维护一个累加器,然后逐元素做自定义聚合,最后返回 KeyValuePair<TKey, TAccumulate> 序列。
常用重载签名:
csharp复制public static IEnumerable<KeyValuePair<TKey, TAccumulate>> AggregateBy<TSource, TKey, TAccumulate>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func,
IEqualityComparer<TKey>? comparer = null);
比如统计每个城市的订单总额:
csharp复制var cityTotals = orders.AggregateBy(
o => o.City,
0,
(total, o) => total + o.Amount);
foreach (var (city, total) in cityTotals)
{
Console.WriteLine($"{city}: {total}");
}
seed 是每个分组的初始值,这里就是 0;func 是累加逻辑,第一个参数是当前累加值,第二个参数是当前元素。整个执行过程是单次遍历,顺序处理每个元素,和 GroupBy 先建组再遍历不同,AggregateBy 从语义上更接近函数式语言里的 fold。
还有一个带 seedSelector 的重载,可以根据不同的键生成不同的初始值:
csharp复制var cityTotals = orders.AggregateBy(
o => o.City,
city => city == "北京" ? 1000 : 0,
(total, o) => total + o.Amount);
这个在需要给某些分组设置默认基础值的时候非常方便,比如给新城市一个初始配额。
要特别提醒一点:如果你是聚合多个字段,不要天真地用一个匿名类型做 seed,匿名类型的属性是只读的,没法在累加时修改。我用 AggregateBy 统计“销售额 + 订单数 + 客单价”时,一开始就踩了这个坑。正确做法是定义一个 record struct 或使用 tuple 作为累加器。比如:
csharp复制var stats = orders.AggregateBy(
o => o.City,
(Total: 0m, Count: 0),
(acc, o) => (acc.Total + o.Amount, acc.Count + 1));
2.3 Index:告别 Select 双参魔法
第三个新方法 Index 解决的是“遍历时拿索引”这个老问题。以前我们写带索引的遍历,最常规的做法是:
csharp复制foreach (var (item, index) in items.Select((item, index) => (item, index)))
{
Console.WriteLine($"{index}: {item}");
}
Select 的双参 lambda 虽然能用,但每次都要把 (item, index) 包装成元组,属于“明明很简单却写得很绕”的典型。
.NET 9 直接提供了 Index 方法:
csharp复制foreach (var (index, item) in items.Index())
{
Console.WriteLine($"{index}: {item}");
}
签名是:
csharp复制public static IEnumerable<(int Index, TSource Item)> Index<TSource>(
this IEnumerable<TSource> source);
返回的是带索引的元组序列,索引从 0 开始。这个方法在 UI 列表渲染、分页、给导出文件加序号等场景非常实用。和 Select 双参写法相比,语义更明确,代码也更短。
有一点要注意:Index 也是延迟执行的。如果源序列本身是动态的,比如 yield return 生成的序列,索引值会随着枚举实时计算,这一点和 Select((x,i)=>) 没有区别。
2.4 性能内核重写:泛型特化让旧代码也提速
如果说新增 API 是看得见的变化,那 LINQ 内核的泛型特化就是“悄悄帮你提速”的部分。.NET 9 对 LINQ 中大量操作符做了针对数组和 List<T> 的特化实现。
在旧版本里,Where().Select() 这类链式调用,执行时每个操作符都是通过 IEnumerable<T> 接口去迭代的。这意味着每次取下一个元素都要走一次接口调用,也就是虚调用。虚调用本身开销不算大,但架不住高频循环里被放大。加上 .NET 迭代器模式还会产生额外的状态机对象,链越长,对象分配越多。
. NET 9 在内部做了类似这样的处理:如果源是 T[] 或 List<T>,部分操作符会走一条强类型快速路径,直接用数组索引或 List<T> 的内部结构遍历,绕开 IEnumerator<T> 接口,减少虚调用和部分范围检查。代码上你什么都不用改,只要项目引用的是 .NET 9 的运行时,这些优化就会自动生效。
我自己实测过 List<T> 上的 Where + Select + Sum 组合,在 Release 配置下比 .NET 8 快 20% 到 50% 不等。如果你的查询里还有排序或分组,收益会更大。排序在旧版里需要额外分配比较器相关对象,新的排序路径也更省内存。
不过也别指望所有方法都有同样的提速幅度。这种特化主要覆盖数组和 List<T>,对 HashSet<T>、Dictionary<TKey, TValue> 或者其他自定义 IEnumerable<T>,能走快速路径的地方就少一些。而且提不提升,取决于你的查询是否在热路径上、链有多长、数据量有多大。
2.5 集合类型联动:FrozenSet 等带来的组合收益
最后这个变化不完全属于 LINQ 本身,但和 LINQ 配合使用效果非常好。.NET 8 引入了 FrozenSet<T> 和 FrozenDictionary<TKey, TValue>,.NET 9 继续完善了这些只读集合的 API。这类集合在创建时有一次性开销,但一旦构建完成,Contains 和 Lookup 的查找性能非常稳定,特别适合“构建一次、反复查询”的场景。
举一个很常见的例子:从接口拉回一批白名单用户 ID,要在另一个大列表里筛出在白名单内的记录。普通写法是:
csharp复制var allowedIds = externalIds.ToHashSet();
var result = allUsers.Where(u => allowedIds.Contains(u.Id));
这段代码在 .NET 8 也能跑,但你构造的是 HashSet<T>,它的查找性能虽然不错,但内部还保留了容量调整的余地,而且在 LINQ 查询中每次 Contains 都要走接口调用。如果改成:
csharp复制using System.Collections.Frozen;
var allowedIds = externalIds.ToFrozenSet();
var result = allUsers.Where(u => allowedIds.Contains(u.Id));
FrozenSet<T> 的特点是创建后内部结构完全定型,查找路径被优化到极致,也不会因为后续插入操作导致结构变化。在我一次批量导入场景里,用 FrozenSet 替换 HashSet 后,过滤 50 万条记录的耗时降了 30% 左右。它和 LINQ 的特化叠加起来,就是“300%”故事的另一个组成部分:新 API 减少中间集合 + 内核特化减少虚调用 + 集合选型减少查找开销,三个因素叠加,才凑出那个接近三倍的实测数字。
3. 实操过程与核心环节实现
3.1 从 .NET 8 升级到 .NET 9 的准备工作
新 API 的最大前提是你得真的跑在 .NET 9 上。升级步骤其实不复杂。
先安装 .NET 9 SDK。然后修改项目文件里的目标框架:
xml复制<TargetFramework>net9.0</TargetFramework>
如果你用的是 net8.0,直接改成 net9.0 就行。如果项目是类库,注意引用的上游包是否还兼容 net9.0,一般 NuGet 包问题不大,但有的老包可能需要重新编译或升级到新版本。
LangVersion 不用手动指定,.NET 9 SDK 默认支持 C# 13,你现有的代码只要没用什么过于小众的语法,都能直接编译通过。唯一要留意的是,项目里如果引用了一些老版本的 Analyzer 或 Source Generator,可能会有兼容性问题,建议先在分支里跑一遍编译和核心单元测试,再合并到主干。
3.2 场景一:订单报表分组统计(CountBy)
以实时订单流为例,现在要统计每个城市的订单量,生成报表。数据是直接从消息队列里来的,类型是 IEnumerable<Order>。
新写法:
csharp复制var orders = FetchOrdersFromQueue();
var cityCounts = orders.CountBy(o => o.City);
foreach (var (city, count) in cityCounts)
{
Console.WriteLine($"城市 {city} 的订单量: {count}");
}
旧写法:
csharp复制var cityCounts = orders
.GroupBy(o => o.City)
.Select(g => new { City = g.Key, Count = g.Count() });
两者结果一样,但 CountBy 在内部可以更直接地统计,不需要像 GroupBy 那样先为一个分组创建完整的列表,再在列表上调用 Count()。如果你的分组数量特别多,比如几万个城市,每个城市的订单量又很大,旧写法等于把订单数据复制了一份到分组列表里,内存占用会明显偏高。这也是为什么我在报表场景中优先用 CountBy。
3.3 场景二:自定义聚合的中枢数据(AggregateBy)
再上一个复杂度,统计每个城市的订单总额、订单数和平均客单价。用 AggregateBy 加一个三元组累加器实现:
csharp复制var stats = orders.AggregateBy(
o => o.City,
(Total: 0m, Count: 0),
(acc, o) => (acc.Total + o.Amount, acc.Count + 1));
foreach (var (city, data) in stats)
{
var avg = data.Count == 0 ? 0m : data.Total / data.Count;
Console.WriteLine($"{city}: 总额={data.Total}, 单数={data.Count}, 客单价={avg:F2}");
}
这里累加器用了命名元组 (Total: 0m, Count: 0),每次累加返回新的元组值。虽然从直觉上看这像是不停创建新对象,但值元组是结构体,在栈上操作的话开销很小,而且在这个实现里不需要每次都复制整个集合。
如果你要用 AggregateBy 做更复杂的聚合,不建议用匿名类型做累加器,因为属性只读,你没法在 lambda 里改写。建议用 record struct,既可以用 init 属性,也可以配合 with 表达式:
csharp复制public readonly record struct OrderStat(decimal Total, int Count);
然后:
csharp复制var stats = orders.AggregateBy(
o => o.City,
new OrderStat(0, 0),
(acc, o) => acc with { Total = acc.Total + o.Amount, Count = acc.Count + 1 });
3.4 场景三:带索引的分布式任务调度(Index)
我做一个批量任务调度模块时,需要把任务列表里的每个任务加上全局序号,方便在日志里定位。
新写法:
csharp复制foreach (var (index, task) in tasks.Index())
{
_logger.LogInformation("开始处理第 {Index} 个任务,任务编号 {TaskId}", index, task.Id);
await processor.ProcessAsync(task);
}
旧写法:
csharp复制var index = 0;
foreach (var task in tasks)
{
_logger.LogInformation("开始处理第 {Index} 个任务,任务编号 {TaskId}", index, task.Id);
index++;
await processor.ProcessAsync(task);
}
说实话,Index 不算什么革命性 API,但它把索引从“需要手动维护的外部变量”变成了“随序列流动的数据”,在并发或多次遍历的场景里,避免了外部变量被意外篡改的问题。比如在并行处理时,你就不需要为每个分区单独维护一个 index 变量了。
3.5 场景四:性能对比实测方法与记录(BenchmarkDotNet)
聊了这么多理论,用数据说话才有说服力。我用 BenchmarkDotNet 做了一组对比测试,测的是 100 万条订单按城市分组统计销售额的场景。测试代码大致长这样:
csharp复制[MemoryDiagnoser]
public class LinqBenchmark
{
private List<Order> _orders = null!;
[GlobalSetup]
public void Setup()
{
var random = new Random(42);
_orders = Enumerable.Range(0, 1_000_000)
.Select(i => new Order
{
City = $"City_{random.Next(0, 1000)}",
Amount = random.Next(1, 1000)
})
.ToList();
}
[Benchmark(Baseline = true)]
public List<KeyValuePair<string, int>> GroupBySum()
{
return _orders
.GroupBy(o => o.City)
.Select(g => new KeyValuePair<string, int>(g.Key, g.Sum(o => o.Amount)))
.ToList();
}
[Benchmark]
public List<KeyValuePair<string, int>> AggregateBySum()
{
return _orders
.AggregateBy(o => o.City, 0, (sum, o) => sum + o.Amount)
.ToList();
}
}
注意几个细节:
第一,[MemoryDiagnoser] 会输出分配情况,这样你不仅能看耗时,还能看 GC 分配了多少字节。
第二,[GlobalSetup] 里生成固定种子数据,保证每次测试的数据一致,否则结果没有可比性。
第三,数组大小要足够大,我选 100 万条就是为了让差异明显。
我测下来的典型结果(Release 模式)大致是:
| 方法 | 耗时 | 分配 |
|---|---|---|
| GroupBy + Sum | 约 210ms | 约 95MB |
| AggregateBy | 约 67ms | 约 21MB |
耗时减少约 68%,也就是接近三倍。分配更是降到了原来的四分之一左右,这对于高并发服务来说,GC 压力的改善比单纯快那几毫秒更重要。
4. 常见问题与排查技巧实录
4.1 新方法在哪些版本可用
CountBy、AggregateBy、Index 这三个新 API 是 .NET 9 专属的,你在 .NET 8 及以下版本里找不到。如果你在做类库并需要兼容多个目标框架,建议用多目标编译 + 条件编译来区分:
csharp复制#if NET9_0_OR_GREATER
var counts = orders.CountBy(o => o.City);
#else
var counts = orders
.GroupBy(o => o.City)
.Select(g => new KeyValuePair<string, int>(g.Key, g.Count()));
#endif
或者干脆把旧写法封装成扩展方法,放在自己的公共库中。如果你不想弄条件编译,也可以先写一个兼容版本的 CountBy 扩展方法,等目标框架升级后再切换。
4.2 键相等性与自定义比较器
CountBy 和 AggregateBy 默认使用 EqualityComparer<TKey>.Default 来判断键是否相等。这对大多数情况没问题,但有几个容易踩的细节。
字符串默认是区分大小写的,如果你想忽略大小写统计,需要传一个 StringComparer.OrdinalIgnoreCase:
csharp复制var cityCounts = orders.CountBy(
o => o.City,
StringComparer.OrdinalIgnoreCase);
如果你的键是自定义类型,默认会比较引用相等性,此时需要传一个实现了 IEqualityComparer<TKey> 的比较器。另外注意可空值类型,键为 null 时不要指望它自动归到某个分组,要提前定义好对 null 的处理逻辑。还有一个比较隐蔽的问题:同一个键如果前后两次计算出来的哈希码不同(比如可变对象的属性变了),会导致聚合结果错乱,所以尽量选不可变类型做键。
4.3 延迟执行需要注意的坑
CountBy、AggregateBy 和 Index 一样,都是延迟执行的。这意味着你调用方法时,聚合还没有发生,只有当你开始枚举返回的序列时,计算才会执行。
刚开始接触这些 API 的人很容易犯一个错:
csharp复制var result = orders.AggregateBy(o => o.City, 0, (sum, o) => sum + o.Amount);
// 此时 result 还没计算
// 修改 orders 里的数据再次枚举 result,结果会变
如果你需要固定一份结果快照,建议像这样提前物化:
csharp复制var snapshot = orders
.AggregateBy(o => o.City, 0, (sum, o) => sum + o.Amount)
.ToDictionary(kv => kv.Key, kv => kv.Value);
如果源序列很大,物化前要考虑内存消耗。ToDictionary 会重建一份字典结构,和延迟枚举相比多了一倍内存,但换来了结果的确定性,这在大多数业务场景里是值得的。
4.4 性能对比时容易踩的变量
我自己做性能测试时踩过不少坑,归纳成几条经验:
第一,不要用 Debug 配置测试。Debug 模式下 JIT 不会做积极的优化,结果跟生产环境差很多,可能一个本来该快三倍的场景,Debug 下只快了 10%。
第二,预热不能省。BenchmarkDotNet 会自动处理预热和迭代,但如果你是自己写 Stopwatch 测试,一定要先跑一遍让 JIT 把方法编译好,再开始计时。
第三,数据规模要够大。小数据集上可能连几微秒的差距都测不出来,而且还会被 Stopwatch 本身的精度干扰。我一般会至少准备几十万条数据。
第四,要注意 lambda 闭包捕获。如果 lambda 里捕获了外部变量,可能会产生额外的闭包对象分配。如果你要对比两个方法的“原生性能”,尽量保持 lambda 捕获的状态一致。
最后,如果用户环境不同,测试结果会有波动。最快的判断方式是看你自己的热路径:如果代码里有大量 GroupBy + Sum,升级之后直接测一条链路,比什么基准测试都直观。
4.5 迁移建议与兼容性
从旧代码迁移到新 API,不需要一次性把所有写法全部改掉,这样风险太大。我的建议是分三步走。
第一步,先升级运行时框架,让泛型特化直接生效,这个步骤不动代码,就有机会获得性能提升。
第二步,在报表和聚合这类高频查询里,把 GroupBy + Count 替换为 CountBy,把 GroupBy + Sum 替换为 AggregateBy,把 Select((x, i)) 替换为 Index。替换后跑一遍单元测试,确认结果一致。
第三步,检查是否有“构建一次、多次查询”的集合场景,把 HashSet/Dictionary 换成 FrozenSet/FrozenDictionary,配合 LINQ 使用。
对了,迁移时一定要留意一个兼容性细节:AggregateBy 的累加顺序是“源序列的枚举顺序”,和 GroupBy + Sum 的组合结果一致,但如果你用 GroupBy 之后再做 OrderBy 或 Take 这类操作,语义可能会不同,迁移前先确认你依赖的到底是“分组内的排序”还是“分组外的排序”。
最后分享一个我自己的习惯:每当有新框架版本,我都会先写一个小规模压测项目,把项目里最核心的几条 LINQ 热路径用 BenchmarkDotNet 固定下来,然后升级前后各跑一次,把耗时和内存分配记录到一个表格里。这样每次升级,哪些代码变快了、哪些没变化,都能一眼看出来。.NET 9 的这次 LINQ 升级,就是在这个测试项目里让我直观看到了接近三倍的差距。如果你也天天和 LINQ 打交道,强烈建议把这个习惯建立起来,受益的不只是升级这一次。
