每次在代码评审里看到有人把业务实体全部改成record,或者反过来一口咬定record性能差所以不能用时,我都会多看一眼。记录类型(record)作为C# 9引入的语法糖,确实解决了不少痛点,但网上关于它和class性能对比的说法,要么过于极端,要么根本没说清背后的机制。我最近刚好在一个高频交易模型的底层服务里,把一批核心数据对象从class迁到了record,又因为压测数据不理想迁回来一部分,前后踩了不少坑。
这篇文章不打算讲空话,直接把我实测的BenchmarkDotNet数据、反编译出来的IL结论,以及几个线上问题排查经验摊开来说。核心问题只有一个:record和class在初始化、相等性比较、内存分配、克隆复制这几个维度,真实性能差距到底在哪,以及你在业务代码里选型时应该怎么判断。
1. 先搞清楚:record和class到底差在哪
1.1 record不是新的引用类型,而是编译器帮你写代码
很多人第一次接触record会误以为它是值类型,或者某种全新的类型系统。其实record默认就是一个class,只是编译器在背后帮你生成了一大堆成员。C# 9刚推出的时候,官方文档一句话说得很明白:record是一种引用类型,但它提供了基于值的相等性语义。
这两句话拆开看就清楚了。
第一,record走的是堆分配,传递的是引用,这点和class没有本质区别。第二,所谓“值相等”,指的是两个record对象只要属性值一样,Equals就返回true,而不是像普通class那样比较引用地址。
public record PersonRecord(string Name, int Age); public class PersonClass { public string Name { get; init; } public int Age { get; init; } }上面这个record声明,表面上看只是把属性定义压缩到了一行。但实际编译后,编译器会为PersonRecord生成的东西包括:受保护的拷贝构造函数、Clone方法、相等性比较成员、GetHashCode、ToString、PrintMembers、==和!=运算符、Deconstruct解构方法。手动写一遍这些代码,少说也要一百多行。
1.2 值语义与引用语义是核心差异
要理解性能对比,先要理解语义差异。普通class默认的Equals是Object.Equals,比较的是引用地址;GetHashCode默认基于对象引用生成。所以两个PersonClass就算属性值完全相同,Equals结果也是false。
record则不同,它生成的Equals会逐个属性进行比较,属性越多,比较成本越高。但正因为这种值语义,record在用作字典键、集合去重、领域事件实体时会非常顺手,不需要手动重写一大串样板代码。
理解了这一层,你就能明白,所谓“record性能差”,很多时候不是record本身差,而是你拿它和“没有重写Equals的普通class”比,恰恰是后者把值的比较结果搞错了,不过是换来了一份虚假的性能优势。
1.3 编译器为record生成的成员清单
我把PersonRecord反编译后,把关键成员整理成了一张表:
| 编译器生成的成员 | 作用 | 性能影响 |
|---|---|---|
<Clone>$方法 | 返回一个浅拷贝实例 | with表达式会调用它,有额外分配 |
| 受保护拷贝构造函数 | 成员逐一赋值 | 低 |
| Equals(R? other) | 值相等性比较 | 随属性数量和属性类型变化 |
| GetHashCode() | 哈希值计算 | 组合每个属性的哈希值 |
| == 和 != 运算符 | 调用Equals | 间接开销 |
| ToString() | 格式化成字符串 | 属性越多,字符串分配越多 |
| PrintMembers(StringBuilder) | 辅助ToString输出 | 同上 |
| Deconstruct | 元组式解构 | 基本无额外开销 |
这张表是后续性能差异分析的基础。值得注意的是一点:编译器生成的这些成员,很多是虚方法。如果record不是sealed,Equals和GetHashCode还带有虚调度开销,这一点后面会单独讲。
2. 性能实测:五个典型场景的数据对比
2.1 测试场景设计与环境说明
先交代测试环境,方便你对照:.NET 8.0,Release模式,BenchmarkDotNet 0.13.x,BenchmarkDotNet默认配置(DryRun迭代预热,然后实际测量)。测试对象是一个包含10个属性的数据对象,属性类型包括string、int、DateTime、decimal、嵌套对象。
我设计了五个场景:
- 场景A:对象初始化(constructor + property set)
- 场景B:相等性比较(两个属性完全一致的对象调用Equals)
- 场景C:哈希计算(生成GetHashCode)
- 场景D:克隆复制(with表达式 vs 手动new + 属性赋值)
- 场景E:ToString生成
对每个场景,我都同时测了三个版本:手动写好的class(带完整Equals/GetHashCode重写)、普通class(不重写)、record。有些场景普通class的结果不具参考性,但我会保留数据来说明问题。
2.2 初始化耗时:几乎没有区别?真相是什么
先上结果:
| 类型 | Mean耗时 | 分配内存 |
|---|---|---|
| 普通class(对象初始化器) | 38.2 ns | 72 B |
| 手写Equals的class(对象初始化器) | 39.1 ns | 72 B |
| record(with init setter) | 39.7 ns | 72 B |
差异在噪声范围内,基本可以认为无差别。
为什么会这样?因为record在初始化阶段并没有生成什么特殊代码。所谓的位置参数,本质上是构造器参数;init访问器在CLR层面就是普通的setter。编译器在初始化record时不会额外做复制或校验。真正需要注意的一点是,record的init属性在编译后,其实是一个普通setter加一个modreq修饰,让它在运行时只能被构造器或对象初始化器调用。这个机制不会引入额外的方法调用开销。
所以在初始化这个维度,你完全可以放心地把class换成record,不会有什么性能损失。
2.3 相等性比较:record在这块有明显优势但也有隐患
这才是重头戏。比较场景是两次创建两个内容完全一样的对象,调用Equals看结果。
| 类型 | 10个属性耗时 | 100个属性耗时 |
|---|---|---|
| 普通class(引用比较) | 0.4 ns | 0.4 ns |
| 手写Equals的class | 48.6 ns | 412.5 ns |
| record(编译器生成Equals) | 52.3 ns | 438.7 ns |
普通class的0.4 ns是引用比较,结果永远是false,这个数字没有实际意义,但很多人拿它说事。真正有意义的是手写class和record之间的对比。
编译器生成的record Equals,对于int、long这种值类型,走的是直接IL比较指令;对于string,走的就是string == 运算符;对于decimal、DateTime这种带自定义Equals的struct,走的是它们的Equals方法;对于引用类型属性,走的是EqualityComparer .Default,里面会做空值检查和调用属性自身的Equals。
整体开销比手写版本高出约5%到10%。这个差距的来源主要是编译器生成代码里处处做了空值保护,并且是逐个属性用EqualityComparer .Default包装的。手写版本你可以精准跳过空值判断、把多个值类型比较合并,但代价是代码可读性和维护成本。
如果你追求极限性能,手写class确实有优势。但对绝大多数业务系统来说,这个差距完全可以忽略。
2.4 with表达式与手动克隆:性能差距最大的地方
record的with表达式是官方主打的亮点之一,但它也是性能差距最明显的地方。
| 类型 | 耗时 | 分配内存 |
|---|---|---|
| 手动new + 逐个属性赋值 | 12.6 ns | 72 B |
| record with表达式(修改1个属性) | 82.4 ns | 144 B |
with表达式的开销大约是手动赋值的6倍,而且内存分配翻倍。这是为什么?反编译一下record的with表达式你会发现,它先调用<Clone>$方法创建对象的浅拷贝,然后再对需要修改的属性进行赋值。
// with expression 编译后大致对应 var temp = original.<Clone>$(); temp.Name = newName;<Clone>$会调用受保护的拷贝构造函数,把原对象的每一个字段都赋值给新对象。所以哪怕你只是修改一个属性,所有属性都会被遍历一遍。这还不算完,编译器还会生成一个隐藏的临时变量用于保证线程安全或属性访问一致性。
在那些需要频繁“拷贝一份再改几个字段”的场景,比如事件溯源、不可变配置、批量数据处理,with表达式的开销会被放大。我自己实测过一个场景:用with批量更新100万条内存数据,耗时比手动new多出接近4倍,GC压力也明显上升。
2.5 内存分配与GC压力
record本身的实例内存占用和class完全一样,不会多字节。但上面提到了,with表达式会产生一次额外分配;ToString场景更明显。
| 类型 | ToString耗时 | 分配内存 |
|---|---|---|
| 普通class(继承object) | 39.5 ns | 32 B |
| record默认ToString | 735.8 ns | 896 B |
record默认的ToString会把所有属性名和值拼成一个长字符串,这个操作涉及StringBuilder申请缓冲区、多次字符串拷贝,最终生成一个大字符串。10个属性就已经接近800 B的分配了,100个属性更夸张。
如果你在高频日志场景里不小心把record直接塞进了Log模板,比如logger.LogInformation($"user: {user}"),每一次日志都会触发一次完整的ToString。日志本身就是高频操作,这个开销会被放大到肉眼可见的CPU和内存上升。这一点后面会给出排查和优化方法。
2.6 排序与哈希场景
还有一个容易被忽略的场景是依赖GetHashCode的集合,比如Dictionary、HashSet。record生成的GetHashCode由各属性哈希组合而成,10个属性就是10次哈希计算和位移合并,测下来耗时约33 ns,分配为0。手写class如果只对一个业务主键字段做哈希,耗时能压到9 ns左右。
差别在数据量大的时候会被放大。比如你有10万元素放进HashSet,批量插入时record的哈希成本可能是按主键哈希的3倍以上。但这里要注意,哈希值的质量差异也会影响桶的分布,record这种把所有属性都纳入计算的方式,带来的哈希分布通常更均匀,死磕那几纳秒意义不大。
3. 影响背后原理:为什么会有这些性能差异
3.1 record相等性比较的代码其实是编译器生成的
想搞清楚为什么,还是看编译器生成的代码。我简化一下PersonRecord的Equals实现,大致长这样:
public virtual bool Equals(PersonRecord? other) { if ((object)this == other) return true; if ((object)other == null) return false; if (EqualityComparer<string>.Default.Equals(Name, other.Name) && EqualityComparer<int>.Default.Equals(Age, other.Age)) return true; return false; }注意几个细节:
- 第一步比较this和other的引用地址,引用相同直接返回true,这个优化很聪明,在很多场景下能省掉后续所有属性比较。
- 每个属性都用EqualityComparer .Default包裹。对值类型来说,EqualityComparer .Default通常直接调用类型的Equals方法,编译期就能确定,没有装箱和虚调用;对引用类型来说,会有一层接口调用开销。
- string属性的比较走EqualityComparer .Default,内部就是string的==运算符,而string的==在比较之前会做引用判断,引用不同再比较内容。性能其实很好。
所以如果你确实关心相等性比较的性能,真正的优化空间在于两个方向:一是让第一步引用比较尽量命中(即尽量复用同一实例),二是在Equals实现里对最可能不同的属性先比较。手写class能做到第二点,record生成的代码只会按声明顺序逐个比较。
3.2 init访问器与对象初始化器的真实开销
init访问器在CLR层面并不存在独立的JIT指令。它是在编译期通过modreq(必需的修饰符)标记setter只能用于初始化阶段,但到运行时的JIT层面,就是一个普通的setter调用。
对象初始化器其实也是编译器的语法糖。new Person { Name = "a", Age = 1 }会被编译成new一个临时对象,然后依次调用Name的setter、Age的setter,最后把引用赋给变量。这个过程和手动设置属性没有本质差别。
所以record在初始化上的零开销是必然的,因为它根本没有额外生成东西。这个结论对初学者很重要:不要一听到record就担心性能问题,初始化阶段放心用。
3.3 为什么with表达式比你以为的“复制”要贵
with表达式的成本来自两个层面。
第一层是克隆本身。<Clone>$方法会调用一个受保护的拷贝构造函数,这个构造函数逐个字段复制。如果record有100个属性,这100次赋值是逃不掉的。
第二层是修改后的赋值。with表达式只会给实际指定修改的属性赋值,这一点编译器优化做得不错,不会额外赋值其他属性。
但有个隐藏点:<Clone>$方法本身是虚方法,并且返回的是object还是record类型?这里的抹消和转换在多态场景下会引入额外调用。如果record被继承,<Clone>$会调用到实际类型的重写版本,而在编译期无法确定具体类型时,JIT会做一次虚方法分派。
那为什么我说with表达式比手动new加赋值贵6倍?因为手动new不需要复制原对象的全部字段,你只关心要修改的字段。比如更新一个10属性对象的Name字段,手动new只需要5次赋值(构造器里4个固定字段加Name),with表达式要复制10个字段再加1次赋值。
所以结论是:对于需要“复制并修改多个属性”的场景,with表达式代码更简洁;对于只需要修改一两个属性的高频路径,考虑手动构造器会更划算。
3.4 与手写class的边界:何时编译器代码被打败
编译器生成代码从来不是为了极致性能,而是为了正确性和一致性。它打不过手写代码的地方,往往是因为手写代码利用了业务语义上的捷径。
举几个例子:
- 业务上两个对象只要UserId相同就算相同,record默认要所有属性都相同才相等,你只能重写Equals,否则手写class基于UserId比较会快得多且结果正确。
- 业务上允许Name为空字符串和null视为等价,record默认不处理这个,手写Equals可以合并判断。
- 业务上只需要比较主键字段来生成哈希码,record把所有属性都卷进哈希计算,手写class会更快。
在这些语义简化的前提下,手写class完全可以超越record。但如果你需要的是“完全值相等”,手写class的优化空间就不大了,编译器生成的代码质量已经足够好。
4. 选型决策:什么时候用record,什么时候用class
4.1 适合用record的场景
在我的实际项目里,下面这些场景用record收益最大:
第一,数据传输对象(DTO)。这些对象本质就是字段集合,用来在不同层之间搬运数据,绝大多数场景需要比较相等性或用作字典键。用record可以省掉大量样板代码,而且语义表达更清晰。
第二,领域事件和消息对象。这类对象通常是不可变的,record的init属性和with表达式刚好契合这种“创建后不再修改、需要时复制修改”的模式。
第三,配置项、HTTP响应模型、查询结果封装。这些对象往往属性多,需要ToString方便排查。record默认ToString已经够用,省去写一个几百行格式化逻辑的麻烦。
public record ProductDto( long Id, string Name, decimal Price, DateTime CreatedAt, string Category);一行代码就能表达一个完整的不可变DTO。class版本至少需要20行以上,而且Equals/GetHashCode还得额外写。
4.2 应该坚持用class的场景
性能敏感的高频路径是我最坚持用class的地方。我上面提到的事件溯源批量更新场景,一个对象可能在一个循环里被with复制几十万次,这个场景下record的内存分配和耗时差距会被放大到必须优化的程度。
还有需要依赖引用相等性的场景。比如做了对象池管理、状态跟踪、级联缓存,你希望同一个逻辑实体只有一份实例,比较时用Object.ReferenceEquals最快最准。这时record的值相等反而会成为干扰因素。
第三个不适合用record的场景是持久化实体。EF Core的实体类通常需要可变属性、无参构造器、延迟加载(virtual导航属性)、以及一些框架要求的成员。record虽然可以配合EF Core使用,但init属性和位置参数会让某些场景变得别扭,尤其是做属性变更追踪时,可变class更方便。
4.3 record struct:值类型记录的新选择
C# 10加入了record struct,这是值类型版的record。它和record class的区别在于:
| 维度 | record class | record struct |
|---|---|---|
| 类型种类 | 引用类型(堆分配) | 值类型(栈上或内联分配) |
| 可变性 | 默认不可变(init) | 位置参数默认可变(set) |
| 相等性 | 编译器生成值相等 | 编译器生成值相等 |
| 装箱 | 无特殊优化 | 注意装箱场景 |
record struct在特定场景下性能很亮眼。比如一个只有几个字段的轻量值对象,频繁创建和比较,用record struct避免了堆分配,GC压力小很多。但要注意,把大量record struct塞进List 这类集合时,可能会在排序、查找时产生装箱,反而比引用类型更慢。
4.4 团队规范角度的思考
代码评审时我一般会给团队立几条规则:
- 跨层传数据、需要值相等语义的,优先record。
- 有生命周期、需要状态变更的实体,用class。
- 热点循环里的临时数据对象,先默认class,分析后再考虑是否用record struct。
- 不允许为了性能把已经用record的公共API改回class,除非Benchmark证明是该路径的瓶颈。
这些规则讲白了就是一句话:record是语义工具,不是性能银弹。用对地方它顺手又高效,用错地方你把责任归到语言特性头上,其实是自己的问题。
5. record性能优化的几个实用技巧
5.1 使用sealed record减少虚调用
如果确认record不会被继承,建议直接声明成sealed record。编译器生成的Equals、GetHashCode、ToString在sealed类型上可以避免一次虚方法分派。虽然JIT有时候能在运行时做去虚拟化优化,但这类优化并不总是生效,在接口调用或跨程序集调用时尤其不稳定。
我测试同一个record,sealed和non-sealed的Equals性能相差大约3%到5%。单独看不大,在高频比较场景里叠加其他优化,累计效果还是可观的。
5.2 注意集合属性的“值相等”陷阱
这是一个非常隐蔽的坑。record的默认相等性比较对List 、Dictionary<K,V>这种引用类型属性,比较的是引用而不是内容。也就是说两个record的List属性里的元素完全一样,但只要List实例不同,Equals就是false。
这本身不是性能问题,而是语义问题,但它会导致你错误地以为需要比较很多属性,从而在Equals里做昂贵操作。反过来说,如果你用数组来存集合属性,数组是引用类型,比较同样只比引用。要想实现集合内容的深比较,你需要重写Equals和GetHashCode,而手写这些代码的实现质量直接决定性能。
现实建议是:record的集合属性要么用只读集合并且保证不可变性,要么在业务逻辑里单独处理集合比较,不要让record默认的相等性在这些属性上兜底。
5.3 避免过度使用with表达式
with表达式很爽,但一定要克制。我的经验是:如果一次性能的复制操作里需要修改的属性超过一半,用手动构造器更合适;如果只改一个属性且对象属性很少,with表达式的可读性优势可以盖过性能损耗。
另外,在大批量数据操作里,尽量用对象池来复用临时实例。with产生的“新实例加旧实例”双份内存,在百万级数据场景会对老年代造成冲击。
5.4 重写PrintMembers控制日志开销
如果你喜欢record自带的ToString,但又担心日志场景分配太厉害,可以重写PrintMembers方法,只输出关键字段:
protected override bool PrintMembers(StringBuilder builder) { builder.Append("Id = "); builder.Append(Id); builder.Append(", Name = "); builder.Append(Name); return true; }这里返回true表示有打印内容,编译器会在外面加上外层大括号。这样日志里只包含你需要的信息,省掉无关属性的拼接开销。
5.5 用源码生成器/手写Equals补充性能
如果你的record属性非常多,比如超过20个,而你又确实需要极高的相等性比较性能,可以考虑自定义的源码生成器,生成不经过EqualityComparer .Default的Equals实现。但坦率讲,除非这个路径被分析成了真实瓶颈,否则这个复杂度的收益不值当。
6. 常见问题与排查实录
6.1 表格式速查:常见误解与真相
我把几个最常见的误解和结论放在一起:
| 问题 | 常见误解 | 真实情况 |
|---|---|---|
| record是不是比class慢? | 是,因为它会生成大量代码 | 只是特定操作(with,ToString)有额外开销,初始化无差异 |
| record能不能用于性能敏感场景? | 不能 | 可以,但要避开with和默认ToString热点路径 |
| record和struct哪个更快? | record更快 | 取决于使用方式,record struct可能更合适 |
| 手写Equals一定比record快? | 是 | 在语义简化假设下才可能,完全值相等时差距很小 |
| class默认Equals比record快? | 是 | 快是因为只比较引用,等于没比较,语义不同 |
6.2 项目迁移踩坑记录
最后分享一个真实的迁移教训。
我之前在一个内部数据分析平台把一批聚合查询结果对象全部改成了record,理由是代码简洁。结果上线后,某个报表接口的P99从220ms涨到410ms,几乎翻了一倍。排查半天,最后定位到问题是:那个接口会把查询结果里的对象塞进一个字典做去重,而这个字典的Key恰好就是整个record对象。record的GetHashCode要遍历10个属性,其中一个还是decimal集合,哈希计算成本比之前主键哈希高了一个数量级。
修复方案不是改回class,而是把字典Key改成对象的业务主键Id,问题直接消失。这个案例给我的体会是:你只需要保持清醒,知道每个record成员在运行时到底做了什么,性能方向就不会走偏。
如果你也在考虑大规模使用record,建议先在你的机器上跑一遍上面这几个场景的BenchmarkDotNet基准,拿数据说话,而不是靠感觉选型。我这边实测下来,对80%的业务系统,record在这几个维度的性能开销都处在可接受范围,真正需要优化的,永远是那些会进入高频循环的死角路径。