EF Core 8延迟加载与立即加载行为不一致问题排查
该问题可通过指定Git仓库复现
实体定义(省略部分代码)
class Folder { public virtual ICollection<File> Files { get; set; } public virtual ICollection<Log> Logs { get; set; } } class File { public Int32 FolderId { get; set; } public virtual Folder Folder { get; set; } public virtual ICollection<Log> Logs { get; set; } } class Log { public Int32? FolderId { get; set; } public virtual Folder Folder { get; set; } public Int32? FileId { get; set; } public virtual File File { get; set; } public bool IsError { get; set; } }
实体配置(已启用延迟加载)
public class FolderEFConfiguration : IEntityTypeConfiguration<Folder> { public void Configure(EntityTypeBuilder<Folder> b) { b.HasMany(x => x.Files) .WithOne(y => y.Folder) .HasForeignKey(y => y.FolderId).IsRequired(); } } public class LogEFConfiguration : IEntityTypeConfiguration<Log> { public void Configure(EntityTypeBuilder<Log> b) { //... b.HasOne(x => x.File) .WithMany(fl => fl.Logs) .HasForeignKey(x => x.FileId); b.HasOne(x => x.Folder) .WithMany(fd => fd.Logs) .HasForeignKey(x => x.FolderId); //... } }
问题表现
以下延迟加载代码在执行fd.Logs.Count()时抛出异常:
using AppContext ctx = new ActapiContext(conf.GetConnectionString("abc")); var fd = await ctx.Set<Folder>().Where(x => x.InnerRef == "XXXXX").FirstOrDefaultAsync(); Console.WriteLine(fd?.Files.Count()); Console.WriteLine(fd?.Logs.Count());
异常信息:
System.Data.SqlTypes.SqlNullValueException: Data is Null. This method or property cannot be called on Null values
而使用立即加载的代码可正常运行:
using AppContext ctx = new ActapiContext(conf.GetConnectionString("abc")); var fd = await ctx.Set<Folder>().Include(x => x.Logs).Where(x => x.InnerRef == "XXXXX").FirstOrDefaultAsync(); Console.WriteLine(fd?.Files.Count()); Console.WriteLine(fd?.Logs.Count());
排查发现问题根源:数据库变更脚本添加IsError字段时未设置NOT NULL,导致大量记录的IsError值为NULL:
alter table Logs add IsError bit default 0
修改为以下脚本后,两种加载方式均正常:
alter table Logs add IsError bit not null default 0
核心疑问:为何立即加载在两种脚本配置下都能正常运行?
两种加载方式对应的SQL语句
立即加载
-- EAGER LOADING SELECT [t].[Id], [t].[Title], [l].[Id], [l].[BlogId], [l].[Date], [l].[IsError], [l].[PostId], [l].[Text] FROM ( SELECT TOP(1) [b].[Id], [b].[Title] FROM [dbo].[Blogs] AS [b] WHERE [b].[Id] = 1 ) AS [t] LEFT JOIN [dbo].[Logs] AS [l] ON [t].[Id] = [l].[BlogId] ORDER BY [t].[Id]
延迟加载
-- LAZY LOADING SELECT TOP(1) [b].[Id], [b].[Title] FROM [dbo].[Blogs] AS [b] WHERE [b].[Id] = 1 -- 延迟加载Logs时执行的查询 exec sp_executesql N'SELECT [l].[Id], [l].[BlogId], [l].[Date], [l].[IsError], [l].[PostId], [l].[Text] FROM [dbo].[Logs] AS [l] WHERE [l].[BlogId] = @__p_0',N'@__p_0 int',@__p_0=1
答案
原因在于EF Core对两种加载模式下的数据库NULL值到非空CLR类型的映射逻辑不同:
立即加载(Include):
当使用Include进行关联查询时,EF Core在将查询结果映射到实体对象时,会自动为数据库中NULL的字段填充对应CLR类型的默认值。比如bool类型的默认值是false,所以即使数据库中IsError为NULL,EF Core会自动把它转换成false,不会抛出异常。延迟加载:
延迟加载是通过动态代理实现的,当你第一次访问fd.Logs时,EF Core会单独执行一条查询获取Logs数据。此时EF Core的结果映射逻辑更严格:因为你的Log实体中IsError是不可空的bool类型,但数据库返回了NULL值,CLR的bool无法容纳NULL,所以直接抛出SqlNullValueException。
简单来说,立即加载在映射时做了"容错"处理,自动填充默认值;而延迟加载的查询结果处理没有这个容错步骤,直接严格校验类型匹配,导致NULL值无法转换为非空CLR类型而报错。
内容的提问来源于stack exchange,提问作者tschmit

