惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园 - 三生石上(FineUI控件)
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
月光博客
月光博客
博客园 - 【当耐特】
Hugging Face - Blog
Hugging Face - Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
爱范儿
爱范儿
大猫的无限游戏
大猫的无限游戏
S
SegmentFault 最新的问题
博客园_首页
雷峰网
雷峰网
量子位
有赞技术团队
有赞技术团队
博客园 - 叶小钗
博客园 - 聂微东
V
V2EX
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 司徒正美
小众软件
小众软件
The Cloudflare Blog
阮一峰的网络日志
阮一峰的网络日志
Apple Machine Learning Research
Apple Machine Learning Research
Jina AI
Jina AI
人人都是产品经理
人人都是产品经理

博客园 - 东邪独孤

【ASP.NET Core】Cookie 验证中回调用 URL 自动跳转的问题 【ASP.NET Core】在 Windows Forms 中运行 Web 服务器 【EF Core】级联删除行为 【EF Core】继承策略——TPC 【EF Core】继承策略——TPT 【EF Core】继承策略——TPH 【EF Core】使用自定义的值比较器 【EF Core】值转换器 【EF Core】直接更新数据 【EF Core】实体状态与变更追踪 【EF Core】将一个实体映射到多个表的正确方法 【EF Core】“Code First”方案下以编程方式生成迁移 【EF Core】“DB First”方案下用编程方式生成数据库模型代码 【EF Core】三种方法记录生成的 SQL 语句 【EF Core】未定义实体类的数据库模型 【EF Core】“多对多”关系与跳跃导航 【EF Core】FromExpression 方法有什么用? 【EF Core】通过 DbContext 选项扩展框架 【EF Core】框架底层的数据库连接管理 【EF Core】再谈普通实体关系与 Owned 关系的区别 【EF Core】实体类的依赖注入 【EF Core】优化后的模型 【EF Core】使用外部 Model 【EF Core】聊聊“复合”属性 【EF Core】为 DatabaseFacade 扩展“创建”与“删除”数据表功能 【EF Core】带主键实体与无主键实体 【EF Core】框架是如何识别实体类的属性和主键的
【EF Core】实体追踪——Entry中记录的数据
东邪独孤 · 2026-02-12 · via 博客园 - 东邪独孤

这回终于可以 Free 了,刚好快过年了,工厂的机器也很人性化地坏了,需要供应商维修,不用测试项目了。所以老周也回家快活了几天。其实他们自己有开发团队,小改小测的他们完全可以自己弄,非要找老周麻烦。

咱们接着上次的话题聊,上次老周给大伙伴们胡诌了一番有关实体状态追踪的基础。这一次咱们把注意力放到名为 EntityEntry 的对象上。咦,这名怎么看着这么奇葩?咱们不管它奇不奇葩,只要知道它负责保存实体对象的属性值就行了。

毕竟实体类通常就是一个普通类,EF Core 需要状态追踪功能,总不能让开发者自己去跟踪吧,所以,EF 内部会用字典数据结构来保存实体的各个属性的值。字典是个好东西,啥都能放。有时候在写 Web API 时,一些返回 JSON 结构是动态的,为它们都定义一个类来序列化是不明智的,直接拼装 JSON 有点麻烦,这时候用字典就很爽。

当实体从数据库查询出来时,EF 先为实体对象创建一个快照,表明它的原始数据。然后,当你对实体进行各种搔操作之后,调用一下 DetectChanges 方法,它会扫描实体对象各个属性的值,并和当初创建的快照比较,以确定实体是否被修改(或删除)。

为了让初学的大伙伴们好理解,咱们做个对比实验。

假设有这么个实体类,它表示一本书的信息。

public class Book
{
    public int BookId { get; set; }
    public string Name { get; set; } = null!;
    public string ISBN { get; set; } = null!;
    public string Author { get; set; } = null!;
    public int PubYear {  get; set; }
}

然后是实现数据库上下文。

public class MyDb : DbContext
{
    public DbSet<Book> Books { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=shop.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var bookEnt = modelBuilder.Entity<Book>();
        // 主键名称
        bookEnt.HasKey(x => x.BookId).HasName("PK_Book");
        // 字符串长度
        bookEnt.Property(a => a.Name).HasMaxLength(65);
        bookEnt.Property(b => b.Author).HasMaxLength(20);
        bookEnt.Property(c => c.ISBN).HasMaxLength(15);
        bookEnt.ToTable("tb_Books", t =>
        {
            // 约束
            t.HasCheckConstraint("CK_Pubyear", "\"PubYear\" >= 2020 AND \"PubYear\" <= 2080");
        });
    }
}

上面那些都是常规操作了,大家瞄两眼就行。下面代码创建数据库并插入一条数据。

using MyDb context = new();
context.Database.EnsureCreated();

        Book bb = new()
        {
            Name = "回魂术",
            Author = "老周",
            ISBN = "551269882",
            PubYear = 2028
        };
context.Books.Add(bb);
context.SaveChanges();

下面重头戏来了。咱们从数据库中查询出这条记录,然后改变 PubYear 属性的值。

 var theBook = context.Books.FirstOrDefault();
 if (theBook == null) return;

 // 打印一次追踪
 Console.WriteLine(context.ChangeTracker.ToDebugString());
 // 更改属性
 theBook.PubYear = 2030;
 // 再打印一次
 Console.WriteLine(context.ChangeTracker.ToDebugString());

ChangeTracker.ToDebugString 方法方便测试,可以直接观察框架对实体对象的更改记录。两次调用的输出如下:

Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2028

Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2030 Originally 2028

很明显,EF 并不知道咱们修改了实体,所以,调用一下 DetectChanges 方法会触发一次扫描和比较。

// 打印一次追踪
Console.WriteLine(context.ChangeTracker.ToDebugString());
// 更改属性
theBook.PubYear = 2030;
context.ChangeTracker.DetectChanges();
// 再打印一次
Console.WriteLine(context.ChangeTracker.ToDebugString());

这次 EF 就知道实体被修改了。

Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2028

Book {BookId: 1} Modified
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2030 Modified Originally 2028

由于 PubYear 属性被更新,使得实体的状态变更为 Modified。

那,为什么我调用 SaveChanges 方法时 EF 能顺利生成更新 SQL 呢,因为这个方法会先 DetectChanges,再根据实体的状态来生成更新语句。

但是,如果你在代码里面把 AutoDetectChangesEnabled 属性设置为 false,那么调用 SaveChanges 方法是不会更新的。

using MyDb context = new();
// 注意这一行
context.ChangeTracker.AutoDetectChangesEnabled = false;

// 找出第一条记录
Book onebook = context.Books.First();
// 打印一次
Console.WriteLine($"{onebook.Name}, {onebook.PubYear}");
// 改一下年份
onebook.PubYear = 2031;
// 提交更改
context.SaveChanges();
// 再查询一次
Book otherOne = context.Books.First(b => b.BookId == onebook.BookId);

// 再次打印
Console.WriteLine($"{otherOne.Name}, {otherOne.PubYear}");
// 看看实体追踪信息
Console.WriteLine(context.Entry(otherOne).DebugView.LongView);

看看调试信息。

回魂术, 2028
回魂术, 2031
Book {BookId: 1} Unchanged
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2031 Originally 2028

虽然 EF 追踪到 PubYear 属性被改为 2031,但注意它现在的状态是 Unchanged,所以 SaveChanges 不会更新数据库。

大伙伴们,这里你千万别犯糊涂,把概念搞混了。AutoDetectChangesEnabled 属性设置为 false 只表明 EF 在 SaveChanges 方法中不会自动扫描检测实体的状态,可人家没说不会追踪实体的变更哟

你如果只是查询数据,不更改数据,不需要追踪实体状态(以提升不太明显的性能),那么你不应该关闭 AutoDetectChangesEnabled 属性,而应该设置 QueryTrackingBehavior 属性。

context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

禁用追踪后,更新数据库就跟 Sugar 一样,需要你开手动档。你只需要调用一下 Update 方法,将实体状态变为 Modified 就行,缺点是生成的 UPDATE 语句会把所有字段都 SET 一遍。

// 找出第一条记录
Book onebook = context.Books.First();
// 打印一次
Console.WriteLine($"{onebook.Name}, {onebook.PubYear}");

// 改一下年份
onebook.PubYear = 2024;
// 改变状态
context.Update(onebook);

// 提交更改
context.SaveChanges();

那有没有方法让生成的 UPDATE 语句只 SET 被改动过的字段呢?大伙伴肯定猜到,老周既然这么写,那说明肯定有的。但老周希望你不要死记方法,而是思路。咱们好好想一下:EF 是不是在查询出数据到为实体建立快照,然后进行比较,以确定哪些属性(字段)被修改了。既然这样,咱们在查询实体后,手动给它弄个快照,然后再修改属性值,再提交更新不就完事了吗?好,想干就干。

// 找出第一条记录
Book onebook = context.Books.First();

context.Books.Attach(onebook);
// 记录快照
context.Entry(onebook).OriginalValues.SetValues(onebook);

// 改一下年份
onebook.PubYear = 2021;
// 打印一下状态信息
Console.WriteLine(context.Entry(onebook).DebugView.LongView);

// 严重警告:如果你把 AutoDetectChangesEnabled 属性设置为 false,那一定要调用下面这一行以扫描更改,否则只能更新个毛线
// 如果你没有改动 AutoDetectChangesEnabled 属性,它默认是打开的,那下面这行可以忽略
// context.ChangeTracker.DetectChanges();
// 提交更改
context.SaveChanges();

// 再查询一次
Book otherOne = context.Books.First(b => b.BookId == onebook.BookId);

// 再次打印状态信息
Console.WriteLine(context.Entry(otherOne).DebugView.LongView);

结果如下:

Book {BookId: 1} Modified
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2021 Modified Originally 2025
Book {BookId: 1} Detached
    BookId: 1 PK
    Author: '老周'
    ISBN: '551269882'
    Name: '回魂术'
    PubYear: 2021

生成的SQL语句如下:

UPDATE "tb_Books" SET "PubYear" = @p0
WHERE "BookId" = @p1
RETURNING 1;

因为咱们设置为不追踪实体(QueryTrackingBehavior.NoTracking),所以在查询后,要用 Attach 方法把实体连接到追踪器,并设定状态为 Unchanged。然后,context.Entry 方法获取到 EntityEntry 对象(本文的主角出场了),再往 OriginalValues 里面放点原材料(目前查询出来的值),这样快照就建立了。再然后,可以大胆地修改实体了,这时候 EF 能扫描到更改。由于咱们设置的不追踪,所以更新之后 EF 又把实体给甩了,于是实体状态又变回 Detached。

说简单点,在手动档追踪下,实体的状态经历了 Detached -> Unchanged -> Modified -> Detached 的生死轮回。

-----------------------------------------------------------------------------------------------------------------------------------------------------------

Entry 是“记录”的意思,EntityEntry 望文生义一下就是“实体记录”,它维护着实体的状态和各属性的值。一句话斯基总结:它是为实体追踪(跟踪)服务的,管理着实体相关的数据。

在 80% 的使用场景下,我们不需要用 Entry 的,走常规流程,从数据库中查询数据,自动追踪,修改后提交就完事了。不过,像影子属性这种不在实体类中的成员,你咋办?影子属性的元数据在数据库模型中,而值是保存在 EntityEntry 中。下面咱们用一个实例来说明。

咱们定义一个用户实体。

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string LogName { get; set; } = string.Empty;
    public string? Password {  get; set; }
}

然后是实现自己的数据库上下文。

public class AppContext : DbContext
{
    public DbSet<User> Users { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=demo996.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        EntityTypeBuilder<User> entUser = modelBuilder.Entity<User>();
        // 常规配置
        entUser.Property(x => x.Name).HasColumnName("u_name");
        entUser.Property(w => w.LogName).HasColumnName("log_name");
        entUser.Property(t => t.Password).HasColumnName("u_pwd");
        entUser.Property(m => m.Id).HasColumnName("u_id");
        entUser.HasKey(x => x.Id).HasName("PK_Userid");
        // 影子属性
        entUser.Property<DateTime?>("LastLog").HasColumnName("_last_log");
    }
}

User 实体有一个影子属性 LastLog,记录用户上一次登录的时间。在实体中不需要使用,或不希望被访问的属性,在建立数据库模型时可以作为影子属性。

在查询表达式中可以使用 EF.Property 方法来获取影子属性的值,EF 类中的方法成员都会抛出异常,没有真正实现,而是通过表达式树来翻译处理。因此,在非查询语句中访问影子属性,或要修改影子属性就不能使用 EF 类了。影子属性的值保存在 Entry 中,可以用以下代码来设置 LastLog 属性的值。

// 先查询出实体
User? u = context.Users.FirstOrDefault(x => x.Name == "Teto");
if (u is null)
{
    Console.WriteLine("用户不存在");
    return;
}
// 通过 Entry 修改影子属性
DateTime theTime = DateTime.Now;
context.Entry(u).Property("LastLog").CurrentValue = theTime;
// 打印追踪信息
Console.WriteLine(context.ChangeTracker.DebugView.LongView);
// 提交保存
context.SaveChanges();

直接使用 Entry 修改属性值会自动应用实体的状态。User 实体变为 Modeified 状态。

User {Id: 3} Modified
    Id: 3 PK
    LastLog: '2026/2/12 18:25:01' Modified Originally '2026/2/12 17:41:20'
    LogName: 'teto'
    Name: 'Teto'
    Password: 'balabala'

想验证是否更新数据库,可以查询一遍整个表。

using(var context = new AppContext())
{
    Console.WriteLine("\n------ 所有用户 ------");
    foreach(User usr in context.Users)
    {
        Console.WriteLine($"""
            用户ID:{usr.Id}
            用户名:{usr.Name}
            上次登录时间:{context.Entry(usr).Property("LastLog").CurrentValue}
            """);
        Console.Write("\n");
    }
}
------ 所有用户 ------
用户ID:1
用户名:Kaito
上次登录时间:2026/2/12 17:40:45

用户ID:2
用户名:Gumi
上次登录时间:2026/2/12 17:40:13

用户ID:3
用户名:Teto
上次登录时间:2026/2/12 18:25:01

上面的做法要先查询一次,然后更新,即数据库往返两回。99.99965% 的情况下也没啥影响的,而且很多时候用户编辑数据时确实得先查后改的,毕竟编辑界面你得先显示现有的数据才方便用户去修改。如果你不想 SELECT 只想直接 UPDATE 也可以的。

 // 直接实例化
 // Id 是主键,必须赋值,明确要更新的记录
 User data = new() { Id = 2 };
 var entry = context.Entry(data);
 // 标记实体的状态为已修改
 entry.State = EntityState.Modified;
 // 先改变实体的状态再去改变某个属性的状态
 // 因为在设置实体为 Modified 时会把所有属性都设置为 Modified
 // 先设置实体再设置属性成员就不会被覆盖
 foreach (var p in entry.Properties)
 {
     if (p.Metadata.Name == "LastLog")
     {
         // 只修改这个属性
         p.IsModified = true;
         p.CurrentValue = DateTime.Now;
     }
     else
     {
         // 其他属性不改,标记为非修改状态
         p.IsModified = false;
     }
 }

 // 打印追踪信息
 Console.WriteLine(context.ChangeTracker.DebugView.LongView);
 // 提交保存
 context.SaveChanges();

在改变实体状态时,先设置整个实体为 Modified,此时由于没有初始快照做比较,实体的所有属性(不含主键)都被标记为 Modified,如果这样更新数据库的话,会把 Name、LogName、Password 等属性都更改为 null 了。所以,咱们在设置实体为 Modified 后,还要对各个属性做做手脚。用 foreach 枚举各个属性,只有 LastLog 属性才设置为 Modified,其他的属性设置为未更改,这样发送到数据库的 UPDATE 语句只会 SET LastLog 属性。

UPDATE "Users" SET "_last_log" = @p0
WHERE "u_id" = @p1
RETURNING 1;

由于咱们要更新的是影子属性,不能使用 ExecuteUpdate 这样的便捷方法。

本文到此结束。