PAYATHON 2026

MongoDB C# Driver 投影时为何反复编译属性 setter,导致 CPU 升高?

数据小陈

排查高 CPU 时,要确认每次查询是否都会创建新的投影映射和 serializer,并因此重复编译属性 setter。注册 DTO 的 BsonClassMap,不能单凭此认定 LINQ 投影会复用该映射。现有材料也不足以认定这是 MongoDB C# Driver 3.7.1 的已确认缺陷。

一种可以先验证的绕行方式是,在服务器端只选择需要的字段,用原实体类型接收结果,再在内存中构造 DTO。

为什么 DTO 相同,仍可能重复编译?

属性 setter 委托的缓存取决于具体的 BsonMemberMap 实例。公开的驱动 main 分支源码中,BsonMemberMap.Setter 会检查实例字段 _setter;字段为空时,它会调用 GetPropertySetter(),后者通过 LambdaExpression.Compile() 创建委托。这表明缓存依附于具体实例,并非仅凭 DTO 类型名称全局共享。这里引用的是 main 分支的实现,不能直接作为 3.7.1 行为已核实的证据。BsonMemberMap 源码

如果实际运行的投影翻译路径每次都创建新的映射,即使成员完全相同,新实例仍可能需要生成 setter。这个原因假设需要通过调用栈和对象实例来验证。

还要区分表达式编译与 JIT:反复生成动态方法后进行 JIT,与同一个已有方法被反复编译,是两种不同的情况。单看 JIT CPU 热点,无法确定是哪一种。

先区分首次初始化与持续重复编译

建议按以下顺序检查:

  1. 在同一进程中先串行执行查询预热,再记录后续查询的 CPU 和 GetPropertySetter() 调用次数。
  2. 在断点或 profiler 中检查对应的 MemberInfo、所属 BsonClassMap 和实例身份,确认热点成员属于 DTO 还是原实体。
  3. 向上追踪映射的创建位置,判断是否每次执行查询都会创建新的投影 serializer。
  4. 预热后进行并发测试,比较编译次数是否随请求数持续增长。

如果开销只出现在首次访问,预热可能有效;如果编译次数在预热后仍随请求增加,应继续定位重复构建路径。

示例中的 for 循环每次都等待 ToListAsync() 完成,因此是串行执行。它可以用来检查重复查询的开销,但不能单凭它证明高并发时的行为。

用字段投影加本地 DTO 映射做对照

当前需求只复制两个字段,可以先避开查询表达式中的 new GetDbCodesRespDto:

using System.Linq;
using MongoDB.Driver;

// 复用已经初始化的 collection。
var filter = Builders<DynamicApplication>.Filter.Eq(
    p => p.Id,
    "003566cd-3a51-4a24-9842-c32b01db0600");

var projection = Builders<DynamicApplication>.Projection
    .Include(p => p.InternalId)
    .Include(p => p.DbCode);

var rows = await collection
    .Find(filter)
    .Project<DynamicApplication>(projection)
    .ToListAsync();

var result = rows.Select(p => new GetDbCodesRespDto
{
    AppId = p.InternalId,
    DbCode = p.DbCode
}).ToList();

Include 用于限制服务器返回的字段,Project 用于应用投影;这是驱动支持的字段投影方式。MongoDB 字段投影文档

查询完成后,再使用 LINQ to Objects 构造 DTO,其属性由普通 C# 代码赋值。可以用这个改法做对照实验,但在没有实测数据时,不能保证 CPU 会降低多少。

使用前需要检查:

  • DynamicApplication 是否允许只反序列化部分字段;必填成员、自定义 serializer 或构造函数约束可能使该方案不适用。
  • 未投影的属性可能保持默认值,不应将这些对象当作完整实体继续处理或写回数据库。
  • 普通字段投影默认可能包含 _id;如果不需要,可以根据实际映射显式排除。

已注册映射后,还需要做什么?

保留启动阶段的一次性 BsonClassMap 注册,并复用 MongoClient。原示例已经把注册和客户端创建放在循环外,无须把它们描述为已发现的错误。

只把表达式保存到静态字段、把查询对象移出循环,或改成 Find().Project(p => new GetDbCodesRespDto { ... }),都不能单独保证消除重复编译。复用表达式对象,并不等于复用投影翻译结果。

如果确认新的映射实例持续产生,应整理最小复现,补充两个类型的定义、BSON 特性、约定配置、.NET 运行时版本和完整热点调用栈,再向驱动项目报告。比较其他驱动版本时,应使用相同数据、预热方式和负载进行验证;现有材料没有证明某个版本已经修复该问题。

备注:内容仅供参考。