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

推荐订阅源

腾讯CDC
The Cloudflare Blog
IT之家
IT之家
V
V2EX
雷峰网
雷峰网
MyScale Blog
MyScale Blog
P
Proofpoint News Feed
Stack Overflow Blog
Stack Overflow Blog
博客园 - Franky
Engineering at Meta
Engineering at Meta
S
SegmentFault 最新的问题
GbyAI
GbyAI
Microsoft Azure Blog
Microsoft Azure Blog
博客园 - 司徒正美
云风的 BLOG
云风的 BLOG
小众软件
小众软件
博客园 - 叶小钗
Blog — PlanetScale
Blog — PlanetScale
C
Check Point Blog
A
About on SuperTechFans
B
Blog
月光博客
月光博客
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in 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】实体追踪——Entry中记录的数据 【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】实体状态与变更追踪
东邪独孤 · 2026-01-25 · via 博客园 - 东邪独孤

好长时间没有水文章了,请容老周解释一下。因为最近老周进了两个厂,第一个厂子呆了八天左右,第二个厂子还在调试。管理很严格,带的电子设备都要登记、办手续。当初觉得雷神笔记本的屏幕大,在车间调试代码方便,所以登记了这个型号。但这个游戏本功耗大,而且充电只能充到 83% 就充不进去了。只能白天在车间调试时用,其他时间玩手机。手机是那个 23800 mAH 的坦克3,所以电量多得是,充一次随便玩。在厂里很无聊,老周还另带了一台某宝买的开源掌机……扯远了。

第二个厂子的项目很诡异,老周甚至怀疑有人故意捣乱。他们工人自己测试的时候,总是报莫名其妙的错;但是,只要老周过去和他们一起测,就一切正常。反正现在是测不出到底啥问题。从日志中记录的异常看,都是 Modbus TCP 连接超时。把 time out 改为 50 分钟,也照样在无限连接中。老周觉得是人为拨了网线的可能性更大。反正只要老周在现场就没问题,所以耗了近一个月也没结果。所以老周就请了四天假玩玩,不管他们同不同意,四天后老周准时回去报到。

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

记得上一篇水文中,老周说了把一个实体映射到多个表的话题。注意,一实体一数据表的原则是不变的,这种特殊情况可以用在你这几个表可以组成一个整体,并且经常一起使用的,这样你在查询时就不用联合了,一般是一对一关系的。

熟悉老周的人都知道,老周分享的都是纯知识和纯技术的东西。至于实际开发中怎么用,那是你的事。实际应用是没办法写教程的,你得看具体情况,灵活运用,不存在一个教程包万能的道理。做项目我从小周做成了老周,虽然没做过什么大项目,但小 Case 是不少的(吹吹牛皮)。你别小看那些杂七杂八的项目,哪个不是要六边形战士,哪个不是软硬结合,哪个不是既485又CAN又PLC又单片机的。别看它小,WinForms、Web、STM32(珠海极海的 APM32 也遇到过)、串口、Esp8266 全用上都是常见的事。这年头,不学点 C 语言连小项目都搞不起,哪像那些互联网巨头那么爽,天天盯着 HTML + CSS 玩。

老周一直觉得,经验其实不重要的,跟一两周的项目你都有经验了,关键还得是基础扎实、技术过硬,这样才能来什么活接什么活。至于说基础问题,干活的家伙,实战更重要,理论的东西其实知道是啥就好,咱们又不用写论文评职称。你理论知识说得一套一套的,真用的时候不会用,那有啥用?不要排斥实用主义,实用主义其实是正确的,技术学了就是拿来用的,不用就没意义了。学习是分两种的:一种是内修——比如琴棋书画,这是文化底蕴,个人气质。这种你不必学了就要用(但也可以用),更重要的是养心养神,自我调整;另一种就是工作干活用的,叫技能,属于外修。从小老师都教我们要内外兼修。

好了,不扯了。在开始今天的主题前,咱们补一个内容:既然实体能分布到多个表中,那反过来呢?能把多个实体映射到一个表中吗?当然可以了,官方称作“表拆分”。同样的道理,一般也是一对一的关系。

光说不练,惨过失恋。咱们直接用实例来说明。假设下面有两个实体。

public class Person
{
    public int PsID { get; set; }
    public string Name { get; set; } = null!;
    public int Age {  get; set; }
    // 导航属性
    public PersonInfo OtherInfo { get; set; } = null!;
}

public class PersonInfo
{
    private int InfoID {  get; set; }        // 既做主键也做外键
    /// <summary>
    /// 体重
    /// </summary>
    public float Weight { get; set; }
    /// <summary>
    /// 身高
    /// </summary>
    public float Height {  get; set; }
    /// <summary>
    /// 民族
    /// </summary>
    public string? Ethnicity {  get; set; }
}

待会儿咱们要做的是把这两个实体映射到一个表中,所以为了安全,你可以让 PersonInfo 实体的 InfoID 属性变成私有成员,这可以防止三只手的人意外修改主键值。因为这个实体的 ID 值必须始终与 Person 的 ID 一致。

下面代码是数据库上下文类。

public class DemoDbContext : DbContext
{
    public DbSet<Person> People { get; set; }
    public DbSet<PersonInfo> PersonInfos { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=恭喜发财.db")
                                .LogTo(
                                    // 输出日志的委托
                                    action: msg => Console.WriteLine(msg),
                                    // 过滤器,只显示即将执行的命令日志,可以看到SQL语句
                                    filter: (eventId, _) => eventId.Id == RelationalEventId.CommandExecuting
                                );
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 配置实体
        modelBuilder.Entity<Person>(pse =>
        {
            pse.Property(e => e.PsID).HasColumnName("person_id");
            pse.Property(b => b.Name).HasMaxLength(16).IsRequired().HasColumnName("person_name");
            pse.Property(d => d.Age).HasColumnName("person_age");
            // 主键
            pse.HasKey(w => w.PsID).HasName("PK_Person");
            // 表名
            pse.ToTable("tb_people");
        });
        modelBuilder.Entity<PersonInfo>(pie =>
        {
            pie.Property("InfoID").HasColumnName("person_id").ValueGeneratedNever();
            pie.Property(r => r.Height).HasColumnName("info_height");
            pie.Property(i => i.Weight).HasColumnName("info_weight");
            pie.Property(k => k.Ethnicity).HasMaxLength(10).HasColumnName("info_ethnic");
            // 主键
            pie.HasKey("InfoID").HasName("PK_Person");
            // 同一个表名
            pie.ToTable("tb_people");
        });

        // 两实体的关系
        modelBuilder.Entity<Person>().HasOne(n => n.OtherInfo)
                                                          .WithOne()
                                                          // info --> person
                                                          .HasForeignKey<PersonInfo>("InfoID").HasConstraintName("FK_PersonInfo")
                                                          // person --> info
                                                          .HasPrincipalKey<Person>(p => p.PsID);
    }
}

基本代码相信各位能看懂的。和配置一般实体区别不大,但要注意几点:

1、两个实体所映射的表名要相同。这是F话了,都说映射到同一个表了,表名能不一样的?

2、两个实体中作为主键的属性名可以不同,但类型要相同(可以减少翻车事故);更重要的是:一定要映射到同一个列名。因为映射后,两个实体作为主键的属性会合并;再者,主键的约束名称也要相同,不解释了,一样的道理。

modelBuilder.Entity<Person>(pse =>
{
    pse.Property(e => e.PsID).HasColumnName("person_id");
    ……
    // 主键
    pse.HasKey(w => w.PsID).HasName("PK_Person");
    // 表名
    pse.ToTable("tb_people");
});
modelBuilder.Entity<PersonInfo>(pie =>
{
    pie.Property("InfoID").HasColumnName("person_id").ValueGeneratedNever();
    ……
    // 主键
    pie.HasKey("InfoID").HasName("PK_Person");
    // 同一个表名
    pie.ToTable("tb_people");
});

对于第二个实体,ValueGeneratedNever 方法可以不调用,EF 会自动感知到不需要自动生成列值。

3、两个实体配置为一对一关系,这个和常规实体操作一样。

然后在 Main 方法中测试一下。

static void Main(string[] args)
{
    using var context = new DemoDbContext();
    context.Database.EnsureDeleted();
    context.Database.EnsureCreated();
    // 打印数据库模型
    Console.WriteLine(context.Model.ToDebugString());
}

运行结果:

      Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "tb_people" (
          "person_id" INTEGER NOT NULL CONSTRAINT "PK_Person" PRIMARY KEY AUTOINCREMENT,
          "person_name" TEXT NOT NULL,
          "person_age" INTEGER NOT NULL,
          "info_weight" REAL NOT NULL,
          "info_height" REAL NOT NULL,
          "info_ethnic" TEXT NULL
      );

// 以下是数据库模型
Model:
  EntityType: Person
    Properties:
      PsID (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Age (int) Required
      Name (string) Required MaxLength(16)
    Navigations:
      OtherInfo (PersonInfo) Required ToDependent PersonInfo
    Keys:
      PsID PK
  EntityType: PersonInfo
    Properties:
      InfoID (int) Required PK FK AfterSave:Throw
      Ethnicity (string) MaxLength(10)
      Height (float) Required
      Weight (float) Required
    Keys:
      InfoID PK
    Foreign keys:
      PersonInfo {'InfoID'} -> Person {'PsID'} Unique Required RequiredDependent Cascade ToDependent: OtherInfo

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

下面正片开始。今天咱们说说 EF Core 中几大主要功能模块之一——追踪(叫跟踪也行)。正常情况下,EF Core 从实体被查询出来的时候开始跟踪。跟踪前会为实体的各个属性/字段的值创建一个快照(就备份一下,不是拷贝对象,而是用一个字典来存放)。然后在特定条件下,会触发比较,即比较实体引用当前各属性的值与当初快照中的值,从而确定实体的状态。

为了方便访问,DbContext 类会公开 ChangeTracker 属性,通过它你能访问到由 EF Core 创建的 ChangeTracker 实例(在Microsoft.EntityFrameworkCore.ChangeTracking 命名空间)。该类包含与实体追踪有关的信息。调用 DetectChanges 方法会触发实体的追踪扫描,方法只负责触发状态检查,不返回任何结果,调用后实体的状态自动更新。实体的状态由 EntityState 枚举表示。

1、Unchanged:实体从数据库中查询出来后就是这个状态,前提是这个实体是从数据库中查出来的,也就是说它已经在数据库中了。

2、Added:当你用 DbContext.Add 或 DbSet.Add 方法添加新实体后,实体就处在这个状态。实体只存在 EF Core 中,还没保存到数据库。提交时生成 INSERT 语句。

3、Modified:已修改。实体自从数据库中查询出来到目前为止,它的某些属性或全部属性被修改过。提交时生成 UPDATE 语句。

4、Deleted:已删除。实体已从 DbSet 中删除(还在数据库中)就是这个状态,提交后生成 DELETE 语句。

5、Detached:失踪人口,EF Core 未追踪其状态。

EF Core 内部有个名为 IStateManager 的服务接口,默认实现类是 StateManager。该类可以修改实体的状态,也可以控制开始/停止追踪实体的状态。咱们在写代码时不需要直接访问它,DbContext 以及 DbContext.ChangeTracker、DbSet 已经封装了相关访问入口。

对 DbSet 对象来说,你调用 Add、Remove、Update 等方法只是更改了实体的状态,并没有真正更新到数据库,除非你调用 SaveChanges 方法。SaveChanges 方法内部会先调用 DetectChanges 方法触发状态变更扫描,然后再根据实体的最新状态生成相应的 SQL 语句,再发送到数据库中执行。

下面以插入新实体为例,演示一下。本示例在插入新实体前、后,以及提交到数据库后都打印一次实体的状态。

先定义实体类。

public class Pet
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public string? Category {  get; set; }
}

正规流程,写数据库上下文类。

public class TestDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("data source=天宫赐福.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Pet>(et =>
        {
            et.ToTable("tb_pets");
            et.Property(g => g.Name).HasMaxLength(20);
            et.Property(k => k.Description).HasMaxLength(200);
            et.Property(q => q.Category).HasMaxLength(15);
            et.HasKey(m => m.Id).HasName("PK_PetID");
        });
    }
}

好,现在进入测试环节。

static void Main(string[] args)
{
    using var context = new TestDbContext();
    context.Database.EnsureCreated();

    // 添加一个实体
    Pet p = new() { Name = "Jack", Description = "不会游泳的巴西龟", Category = "爬行动物" };
    // 打印一下状态
    Console.WriteLine("----------- 添加前 -------------");
    Console.WriteLine(context.ChangeTracker.DebugView.LongView);
    context.Add(p);
    // 再打印一下状态
    Console.WriteLine("\n---------- 添加后 ------------");
    Console.WriteLine(context.ChangeTracker.DebugView.LongView);

    // 提交
    context.SaveChanges();

    // 再打印状态
    Console.WriteLine("\n---------- 提交后 ------------");
    Console.WriteLine(context.ChangeTracker.DebugView.LongView);
}

和 Model 类似,ChangeTracker 对象也有个 DebugView,用于获取调试用的信息。这个能打印出实体以及它的各个属性的状态。

运行一遍,结果如下:

----------- 添加前 -------------


---------- 添加后 ------------
Pet {Id: -2147482647} Added
    Id: -2147482647 PK Temporary
    Category: '爬行动物'
    Description: '不会游泳的巴西龟'
    Name: 'Jack'


---------- 提交后 ------------
Pet {Id: 1} Unchanged
    Id: 1 PK
    Category: '爬行动物'
    Description: '不会游泳的巴西龟'
    Name: 'Jack'

新实体被 Add 之前,它是没有被追踪的,所以打印状态信息空白。调用 Add 方法后,它的状态就变成 Added 了。此时,你不需要调用 DetectChanges 方法,因为 Add 方法本身就会修改实体的状态。新实体还未存入数据库,所以主键 ID 赋了个负值,且是临时的。当调用 SaveChanges 方法后,提交数据库保存,并取回数据库生成的ID值,故此时 ID 的值是 1。而且,实体的状态被改回 Unchanged。这是合理的,现在新的实体已经在数据库了,而且自从插入后没有修改过,状态应当是 Unchaged。

如果你有其他想法,希望在 SaveChanges 之后实体的状态不变回 Unchaged,可以这样调用 SaveChanges 方法。

context.SaveChanges(acceptAllChangesOnSuccess: false);

acceptAllChangesOnSuccess 参数设置为 false 后,数据库执行成功后不会改变实体的当前状态。于是,数据库中插入新记录后,实体状态还是 Added。

---------- 添加后 ------------
Pet {Id: -2147482647} Added
    Id: -2147482647 PK Temporary
    Category: '爬行动物'
    Description: '不会游泳的巴西龟'
    Name: 'Jack'


---------- 提交后 ------------
Pet {Id: 1} Added
    Id: 1 PK
    Category: '爬行动物'
    Description: '不会游泳的巴西龟'
    Name: 'Jack'

这样做可能会导致逻辑错误,除非你有特殊用途,比如这样用。

using var context = new TestDbContext();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();

// 处理事件
context.ChangeTracker.Tracked += (_, e) =>
{
    var backupcolor = Console.ForegroundColor;
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine($"实体被追踪:\n{e.Entry.DebugView.LongView}\n");
    Console.ForegroundColor = backupcolor;
};
context.ChangeTracker.StateChanged += (_, e) =>
{
    var bkColor = Console.ForegroundColor;
    Console.ForegroundColor = ConsoleColor.Blue;
    Console.WriteLine($"实体(ID={e.Entry.Property(nameof(Pet.Id)).CurrentValue})状态改变:{e.OldState} --> {e.NewState}\n");
    Console.ForegroundColor = bkColor;
};

// 新实体
Pet p = new Pet { Name = "Tom", Description = "会游泳的鸟", Category = "猛禽" };
context.Add(p);
// 保存,但状态不改变
context.SaveChanges(false);
// 因为是 Added 状态,所以还可以继续insert
p.Name = "Simum";
p.Description = "三手青蛙";
p.Category = "两栖动物";
// 保存,状态改变
context.SaveChanges();

// 把它们查询出来看看
var set = context.Set<Pet>();
Console.WriteLine("\n数据库中的记录:");
foreach(var pp in set)
{
    Console.WriteLine($"{pp.Id}  {pp.Name}  {pp.Description}  {pp.Category}");
}

上面代码中,侦听了两个事件:Tracked——当 EF Core 开始跟踪某个实体时发生;当有实体的状态改变之后发生。其实还有一个 StateChanging 事件,是在实体状态即将改变时发生。总结来说就是:状态改变之前发生 StateChanging 事件,改变之后发生 StateChanged 事件。要注意,StateChanged 和 StateChanging 事件在 EF Core 首次追踪实体时不会引发。比如,刚开始追踪时状态为 Unchanged,不会引发事件,而之后状态变为 Added,就会引发事件(最开始那个状态不会触发事件)。

上面代码处理 Tracked 事件,当开始追踪某实体时,打印一下调试信息,记录某状态;处理 StateChanged 事件,在开始追踪状态后,状态发生改变之后打印变化前后的状态。

代码运行结果如下:

image

首先,new 了一个 Pet 对象,赋值,再调用 Add 方法添加到数据集合中,此时状态会被改为 Added。Tracked 事件输出第一块绿色字体,表示实体开始追踪的状态为 Added,ID 值是随机分配的负值,并说明是临时主键值。

然后调用 SaveChanges 方法并传递 false 给acceptAllChangesOnSuccess 参数,表明 INSERT 进数据库后,状态不改变,还是 Added。

然后,还是用那个实体实例,改变一下属性值,由于它的状态依旧是 Added,调用 SaveChanges() 方法时未传参数,它会调用 SaveChanges(acceptAllChangesOnSuccess: true),结果是这次实体的状态变成了 Unchanged。就是输出结果中蓝色字体那一行。此时实体的 ID=2,记住这个值,待会儿用到。

再往后,咱们 foreach 语句给 DbSet 会触发 EF Core 去查询数据库,于是,我们看到,控制台在“数据库中的记录:”一行之后又发生了 Tracked 事件,有一个 ID=1 的实体被追踪了,它刚从数据库中查询出来,就是第二块绿色字体那里。

这时候你是不是迷乎了?不是从数据库查出两条记录吗,为什么只有 ID=1 的被追踪了,ID=2 呢?其实,ID = 2 已经被追踪了。忘了吗?它前面不是从 Added 状态变为 Unchanged 状态吗。这是因为咱们这一连串操作都在同一个 DbContext 实例的生命周期进行的,EF Core 对实体的追踪不会断开。

如果你把上面的代码改成这样,那就明白了。

    static void Main(string[] args)
    {
        using (var context = new TestDbContext())
        {
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();

            // 处理事件
            context.ChangeTracker.Tracked += OnTracked;
            context.ChangeTracker.StateChanged += OnStateChanged;

            // 新实体
            Pet p = new Pet { Name = "Tom", Description = "会游泳的鸟", Category = "猛禽" };
            context.Add(p);
            // 保存,但状态不改变
            context.SaveChanges(false);
            // 因为是 Added 状态,所以还可以继续insert
            p.Name = "Simum";
            p.Description = "三手青蛙";
            p.Category = "两栖动物";
            // 保存,状态改变
            context.SaveChanges();
        }

        // 把它们查询出来看看
        using(var context2 = new TestDbContext())
        {
            // 依旧要处理事件
            context2.ChangeTracker.Tracked += OnTracked;
            context2.ChangeTracker.StateChanged += OnStateChanged;

            var set = context2.Set<Pet>();
            Console.WriteLine("\n数据库中的记录:");
            foreach (var pp in set)
            {
                Console.WriteLine($"{pp.Id}  {pp.Name}  {pp.Description}  {pp.Category}");
            }
        }
    }

    // 下面两个方法处理事件
    static void OnTracked(object? _,  EntityTrackedEventArgs e)
    {
        var backupcolor = Console.ForegroundColor;
        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine($"实体被追踪:\n{e.Entry.DebugView.LongView}\n");
        Console.ForegroundColor = backupcolor;
    }
    static void OnStateChanged(object? _,  EntityStateChangedEventArgs e)
    {
        var bkColor = Console.ForegroundColor;
        Console.ForegroundColor = ConsoleColor.Blue;
        Console.WriteLine($"实体(ID={e.Entry.Property(nameof(Pet.Id)).CurrentValue})状态改变:{e.OldState} --> {e.NewState}\n");
        Console.ForegroundColor = bkColor;
    }

现在再次运行,看看结果是不是符合你当初的期望。

image

现在的情况是:向数据库插入记录是第一个 DbContext 实例,完事后就释放了,实体追踪器自然就挂了;随后创建了第二个 DbContext 实例,这时候从数据库中查询出两条记录都是没有被追踪的,所以要启动追踪,自然就能引发两次 Tracked 事件了。

好了,各位,今天咱们就粗浅地聊到这里。后面老周还会继续讨论实体追踪的话题,本文主要是让大伙伴们了解一下实体的状态变化。