abp学习笔记 - 工作单元保存时机

简介

我改了一个实体,它什么时候,写了哪些列,要不要调用UpdateAsync?

仓储方法自带工作单元

官方原文说到,If you call a repository method and there is no UOW started yet, it automatically begins a new transactional UOW

眼见为实

复制代码
    /// <summary>
    /// 论断:没有环境工作单元时,单次仓储调用会自己开一个工作单元,并在调用结束时提交。
    /// </summary>
    [Fact]
    public async Task Lesson02_SingleRepositoryCall_OutsideAnyUnitOfWork_CommitsByItself()
    {
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        await repository.InsertAsync(NewResult());

        (await CountAsync()).ShouldBe(1);
    }

AutoSave

仓储的InsertAsync/UpdateAsync/DeleteAsync有autoSave参数,设为true时就在调用时立刻SaveChanges,官方建议是只为了保存时用它,不要手动调用SaveChangesAsync()。

  1. 默认是false
    改动留在DbContext的跟踪中,等UOW结束时才写入
  2. autoSave:true 只是写入
    不等于提交,在嵌套事务中,写进去的行仍然是未提交的状态

四种写法,四种结果

同一个动作: 改字段并保存,有4种写法,但结果完全不同

11 写法 写了哪些列 风险
A 在UOW中取出实体=>修改=>在UOW中结束 只更新了有变化的列 低
B 取出实体(没有UOW)=>修改=>调用UpdateAsync 所有列,Update把整行标记成已修改 无法只更新真正变化的列,全量更新在并发情况下会互相覆盖
C 取出实体(没有UOW)=>修改=>什么都不调 什么都不写 改动丢失
D 在UOW中取出实体=>修改=>调用UpdateAsync=>在UOW中结束 所有列,Update把整行标记成已修改 同B

眼见为实 UoW 内的变更被保存

复制代码
    /// <summary>
    /// 论断:在同一个工作单元里取出并修改被跟踪的实体,工作单元完成时会自动保存,不需要调用 UpdateAsync。
    /// 并且 EF 只写"有变化的列"(对比下面整行写回的测试)。
    /// 这是修改并保存实体的首选写法。
    /// </summary>
    [Fact]
    public async Task Lesson02_EntityChangedInsideAnExplicitUnitOfWork_IsSavedOnComplete()
    {
        var id = await SeedSubmittedResultAsync();

        await WithUnitOfWorkAsync(async () =>
        {
            var tracked = await GetRequiredService<IRepository<AssetProcessResult, int>>().GetAsync(id);
            tracked.MarkWarrantyAPCreated(); // 没有 UpdateAsync:变更跟踪器会在工作单元完成时写入
        });

        (await ReloadAsync(id)).WarrantyAPNotifiedDate.ShouldNotBeNull();
    }
    private async Task<int> SeedSubmittedResultAsync()
    {
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();
        var inserted = await repository.InsertAsync(NewResult().MarkLaborWarrantySubmitted(), autoSave: true);
        return inserted.Id;
    }
	private async Task<AssetProcessResult> ReloadAsync(int id) =>
        await WithUnitOfWorkAsync(async () => await GetRequiredService<IRepository<AssetProcessResult, int>>().GetAsync(id));

眼见为实 工作单元之外的改动丢失

复制代码
	/// <summary>
    /// 论断:在工作单元之外取出实体并修改,如果不调用 UpdateAsync,改动不会保存(静默丢失)。
    /// 原因:GetAsync 的工作单元随调用结束,实体已经脱离跟踪(脱管),之后改的只是内存里的对象。
    /// </summary>
    [Fact]
    public async Task Lesson02_EntityChangedOutsideAUnitOfWork_IsNotSaved_WithoutAnUpdateCall()
    {
        var id = await SeedSubmittedResultAsync();
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        var detached = await repository.GetAsync(id); // 这次调用的工作单元随之结束
        detached.MarkWarrantyAPCreated();             // 改的是脱管对象,没有人保存

        (await ReloadAsync(id)).WarrantyAPNotifiedDate.ShouldBeNull();
    }

眼见为实 UpdateAsync 并发调用导致被覆盖

复制代码
    /// <summary>
    /// 论断:对脱管实体调用 UpdateAsync 会把"所有列"写回数据库,所以一个过期的副本会擦掉别人的改动(丢失更新)。
    /// 意图:复现 stage 上保修提交时间只剩 1 行的根因------PP 同步用 UpdateAsync 整行写回,覆盖了保修列。
    /// 步骤:两个写入方同时读到同一行 → 写入方 1 保存 AP 创建时间 → 写入方 2 用旧副本改了一个不相关的字段并 UpdateAsync
    ///       → 写入方 1 的改动被擦掉。
    /// </summary>
    [Fact]
    public async Task Lesson02_UpdateAsyncWritesEveryColumn_SoAStaleCopyOverwritesAnotherWritersChange()
    {
        var id = await SeedSubmittedResultAsync();
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        var stale = await repository.GetAsync(id); // 两个写入方同时读到这一行
        var other = await repository.GetAsync(id);

        other.MarkWarrantyAPCreated();
        await repository.UpdateAsync(other);       // 写入方 1:保存"AP 已创建"

        stale.SetModificationInfo(7);
        await repository.UpdateAsync(stale);       // 写入方 2:只想改修改人,却把所有列的旧值写了回去

        var saved = await ReloadAsync(id);
        saved.ModifyUserId.ShouldBe(7);
        saved.WarrantyAPNotifiedDate.ShouldBeNull("整行写回擦掉了写入方 1 的改动:这就是看到的丢失更新");
    }

眼见为实 UoW 内的变更,再调用UpdateAsync

复制代码
    /// <summary>
    /// 论断:即使在工作单元里取出的是【被跟踪】的实体,再调用 UpdateAsync 也会把【所有列】写回,
    /// 写回的是"取出那一刻"读到的值,所以会擦掉别人在这段时间里提交的改动。
    /// 步骤:外层 UoW 取出实体 → 另一个独立的写入方(requiresNew)提交了 AP 创建时间
    ///       → 外层只改了修改人,并调用 UpdateAsync → AP 创建时间被旧值(空)覆盖。
    /// 意图:回答"在 UoW 里改被跟踪的实体,还要不要调 UpdateAsync"------不要,调了反而有害。
    /// </summary>
    [Fact]
    public async Task Lesson02_UpdateAsyncOnATrackedEntity_StillWritesEveryColumn()
    {
        var id = await SeedSubmittedResultAsync();
        var manager = GetRequiredService<IUnitOfWorkManager>();
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        using (var outer = manager.Begin())
        {
            var tracked = await repository.GetAsync(id); // 被外层 UoW 的 DbContext 跟踪

            await CommitApCreatedByAnotherWriterAsync(id, manager, repository);

            tracked.SetModificationInfo(7);
            await repository.UpdateAsync(tracked);       // 多余的调用:把所有列的"取出时的值"写回
            await outer.CompleteAsync();
        }

        var saved = await ReloadAsync(id);
        saved.ModifyUserId.ShouldBe(7);
        saved.WarrantyAPNotifiedDate.ShouldBeNull("UpdateAsync 把取出时的旧值(空)写回,擦掉了别人提交的 AP 创建时间");
    }
    /// <summary>
    /// 模拟"另一个写入方":开一个独立的工作单元(requiresNew,自己的 DbContext),改 AP 创建时间并提交。
    /// 它在外层 UoW 取出实体之后、外层保存之前执行。
    /// </summary>
    private static async Task CommitApCreatedByAnotherWriterAsync(
        int id, IUnitOfWorkManager manager, IRepository<AssetProcessResult, int> repository)
    {
        using var other = manager.Begin(requiresNew: true);
        var theirCopy = await repository.GetAsync(id);
        theirCopy.MarkWarrantyAPCreated();
        await other.CompleteAsync();
    }
	/// <summary>
/// 论断:与上一条完全相同,只是【不调用】UpdateAsync:变更跟踪器只写"我改过的列"(修改人),别人提交的 AP 创建时间保留。
/// 意图:对照实验,证明区别只来自那一行 UpdateAsync。
/// </summary>
[Fact]
public async Task Lesson02_TrackedEntityWithoutUpdateAsync_WritesOnlyTheChangedColumns()
{
    var id = await SeedSubmittedResultAsync();
    var manager = GetRequiredService<IUnitOfWorkManager>();
    var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

    using (var outer = manager.Begin())
    {
        var tracked = await repository.GetAsync(id);

        await CommitApCreatedByAnotherWriterAsync(id, manager, repository);

        tracked.SetModificationInfo(7);              // 不调用 UpdateAsync
        await outer.CompleteAsync();
    }

    var saved = await ReloadAsync(id);
    saved.ModifyUserId.ShouldBe(7);
    saved.WarrantyAPNotifiedDate.ShouldNotBeNull("只写了修改人这一列,别人提交的 AP 创建时间没有被碰");
}

眼见为实 不多此一举调用UpdateAsync

复制代码
    /// <summary>
    /// 论断:两个写入方各自在自己的工作单元里修改被跟踪的实体,只写各自变化的列,两个改动都保留。
    /// 意图:和上面的丢失更新对比,说明"在工作单元里改被跟踪的实体"比"脱管实体 + UpdateAsync"安全。
    /// (注意:两次改动是先后发生的;如果要防真正的同时写同一行,还需要乐观并发或拆表。)
    /// </summary>
    [Fact]
    public async Task Lesson02_TrackedChangesInTheirOwnUnitsOfWork_WriteOnlyTheirOwnColumns()
    {
        var id = await SeedSubmittedResultAsync();

        await WithUnitOfWorkAsync(async () =>
            (await GetRequiredService<IRepository<AssetProcessResult, int>>().GetAsync(id)).MarkWarrantyAPCreated());
        await WithUnitOfWorkAsync(async () =>
            (await GetRequiredService<IRepository<AssetProcessResult, int>>().GetAsync(id)).SetModificationInfo(7));

        var saved = await ReloadAsync(id);
        saved.WarrantyAPNotifiedDate.ShouldNotBeNull();
        saved.ModifyUserId.ShouldBe(7);
    }

踩坑实录

在公司实际业务中,因为我们采购了第三方公司的产品,并且要为第三方的产品注册保修,并且我们订阅了他们提供的状态变更webhook,还有一个程序在随时随地更新。

步骤 发生了什么
1 为采购的3个资产注册保修,并把注释时间写入到Asset表
2 几乎同时,我们订阅的资产信息webhook,同时把状态变更的消息发送了过来,webhook处理程序读取到了旧的数据
3 webhook处理程序处理完毕后,调用 _assetRepository.UpdateAsync(existingAsset),把旧行整行写回
4 先写入的保修日期被旧值擦掉,导致数据出现问题

那为什么还有 UpdateAsync?

既然被跟踪的实体不需要它,而且还做不到按需更新,为什么还会有这个方法呢?

主要是这三个原因

  1. 没有变更跟踪的数据库
    abp的仓储接口要求和数据库提供者无关,比如ongoDB 没有变更跟踪,改了对象必须调 UpdateAsync 才会保存(官方事件总线页:"since MongoDB has not a change tracking system",在 "How It Was Implemented?" 一节)。接口里必须有它,EF Core 只是实现它
  2. 脱管实体
    实体在别的 UoW 里取出、来自缓存、由 DTO 映射出来(new 出来再设 Id)、用 AsNoTracking 读出来------EF 不知道哪些列变了。UpdateAsync 把它 Attach 上来并标成"全部已修改"
  3. 立即写入
    有时候确实是需要分段提交,使用UpdateAsync(entity, autoSave: true) 可以立刻 SaveChanges,让后面的查询看到。

Update的设计前提就是:我不知道哪些列变了,请整行更新,这正是脱管实体需要的行为。但与EF的状态跟踪相遇,就成了多余甚至有害了,这需要团队取舍,按需更新这个功能确实很香。

最佳实践

实体的状态 推荐做法
在当前 UoW 里取出(被跟踪) 直接改,不要调 UpdateAsync
已脱管,且这张表只有你一个写入方 可以调 UpdateAsync
已脱管,且别人也会写这几列 不要整行写回:重新在 UoW 里取出再改,或者拆表
想要部分列更新而不先取出 EF Core 7+ 有 ExecuteUpdateAsync,在此之前的版本只能通过拓展来执行原生SQL来实现

事实上,没有银弹,只有取舍,ABP 官方的最佳实践其实是"总是调用 UpdateAsync",因为不是所有数据库都支持变更跟踪,而且你的团队说不定哪天就要换ORM框架(虽然我从来没遇到过),但根据仓储接口要求和数据库提供者无关的原则,到时候要对所有写入的地方追加UpdateAsync,这也是一项不小的工作量

因此我的建议是:

  1. 多个流程会写同一张表,且实体是在当前 UoW 里取出的:不调用 UpdateAsync。
  2. 只有一个写入方的表,且 UoW 很短、中间没有外部调用:可以按官方习惯调用,也可以不调,团队选一个并保持一致。
  3. 已脱管的实体:必须调用,否则都不会更新。

GetQueryableAsync 的陷阱

用GetQueryableAsyncs时,整个"取查询=>执行"必须在同一个UOW里,更好的做法是把查询封装进自定义仓储,仓储层方法内部执行查询并返回已经ToListAsync的结果。

其原因在于,GetQueryableAsync返回的只是还没执行的查询,它绑定的DbContext会随着那次调用的UOW一起释放。

眼见为实

复制代码
    /// <summary>
    /// 论断:在工作单元之外调用 GetQueryableAsync,拿到的查询无法执行(ObjectDisposedException)。
    /// 原因:GetQueryableAsync 自己开的工作单元在返回时就结束了,而查询要到 ToListAsync 才真正执行,
    /// 那时它绑定的 DbContext 已经随工作单元释放。
    /// 规则:"取查询 → 执行查询"必须在同一个工作单元里;或者把查询封装进自定义仓储方法。
    /// </summary>
    [Fact]
    public async Task Lesson02_GetQueryableAsync_OutsideAUnitOfWork_ReturnsAQueryThatCannotRun()
    {
        var repository = GetRequiredService<IRepository<AssetProcessResult, int>>();

        var query = await repository.GetQueryableAsync();

        await Should.ThrowAsync<ObjectDisposedException>(async () => await query.ToListAsync());
    }

小结

规则
1 修改并保存,首选"在同一个UOW里取出=>改=>结束",按需考虑是只写变化的列,还是调用UpdateAsync让行为统一,因为有Insert,Deleted,没有Updated感觉也有点怪
2 脱管实体 + UpdateAsync = 整行写回;多个业务同时写入的共享表里不要这么写
3 autoSave: true 是立即写入,不是提交
4 GetQueryableAsync 的查询必须在同一个 UoW 内执行

课后小练习

第 1 题 在没有任何 UoW 的地方,下面代码改动会保存吗?为什么?

csharp 复制代码
var entity = await repository.GetAsync(id);
entity.MarkWarrantyAPCreated();

答案

不会 。GetAsync 的 UoW 随调用结束,实体已脱管;之后的修改没人保存(Lesson02_EntityChangedOutside...)。要么放进一个 UoW 里,要么调用 UpdateAsync(但那会整行写回)。

第 2 题 两个处理器同时修改同一行的不同 字段,都用"取出 → 改 → UpdateAsync"。最终会怎样?怎么改才安全?
答案

后保存的一方会把所有列 的旧值写回,擦掉另一方的改动(丢失更新)。安全做法:各自在一个 UoW 里取出被跟踪的实体再改,EF 只写有变化的列;或者让两个写入方写不同的表;或者给实体加乐观并发版本戳。

第 3 题 在一个 UoW 里取出实体、修改一个字段,然后调用 UpdateAsync,最后 UoW 完成。与不调用 UpdateAsync 相比有什么区别?
答案

调用了会把所有列 写回,值是取出那一刻读到的;取出之后别人提交的改动会被擦掉。不调用时,变更跟踪器只写你改过的列(Lesson02_UpdateAsyncOnATrackedEntity_StillWritesEveryColumn 与 Lesson02_TrackedEntityWithoutUpdateAsync_WritesOnlyTheChangedColumns 对照)。