EF Core实体追踪Entry中记录的数据

当实体从数据库查询出来时,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 这样的便捷方法。

相关推荐
z123456789868 小时前
2026最新两款AI编程工具深度对比实测
java·数据库·ai编程
程序猿DD8 小时前
一个 API Key,统一调用大模型、生图和联网搜索
数据库·网关
TlSfoward9 小时前
抓包代理链路下的 TLS 指纹变化分析 TLSFOWARD抓包工具
数据库·爬虫·网络协议·搜索引擎·php
数智化管理手记9 小时前
账龄分析手工统计易遗漏?自动账龄分析工具怎么搭建
大数据·网络·数据库·人工智能·数据挖掘
LabVIEW开发9 小时前
NI Package Manager安装LabVIEW时InternalServerError错误
网络·数据库·labview·labview知识·labview功能·labview程序
SelectDB9 小时前
PostgreSQL 实时同步到 Apache Doris:一站式 CDC 解决方案
数据库
Database_Cool_10 小时前
AI Agent 长期记忆怎么存?阿里云 PolarDB-X 对接 Mem0 记忆框架实践
数据库·阿里云
张小姐的猫10 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
国科安芯10 小时前
星间光链路:AS32S601型抗辐射MCU在空间激光通信终端控制中的技术实现
服务器·网络·单片机·嵌入式硬件·物联网·安全·信息与通信
会编程的土豆10 小时前
数据库范式
数据库·oracle