简介
我改了一个实体,它什么时候,写了哪些列,要不要调用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()。
- 默认是
false
改动留在DbContext的跟踪中,等UOW结束时才写入 - 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?
既然被跟踪的实体不需要它,而且还做不到按需更新,为什么还会有这个方法呢?
主要是这三个原因
- 没有变更跟踪的数据库
abp的仓储接口要求和数据库提供者无关,比如ongoDB 没有变更跟踪,改了对象必须调 UpdateAsync 才会保存(官方事件总线页:"since MongoDB has not a change tracking system",在 "How It Was Implemented?" 一节)。接口里必须有它,EF Core 只是实现它 - 脱管实体
实体在别的 UoW 里取出、来自缓存、由 DTO 映射出来(new 出来再设 Id)、用 AsNoTracking 读出来------EF 不知道哪些列变了。UpdateAsync 把它 Attach 上来并标成"全部已修改" - 立即写入
有时候确实是需要分段提交,使用UpdateAsync(entity, autoSave: true) 可以立刻 SaveChanges,让后面的查询看到。
Update的设计前提就是:我不知道哪些列变了,请整行更新,这正是脱管实体需要的行为。但与EF的状态跟踪相遇,就成了多余甚至有害了,这需要团队取舍,按需更新这个功能确实很香。
最佳实践
| 实体的状态 | 推荐做法 |
|---|---|
| 在当前 UoW 里取出(被跟踪) | 直接改,不要调 UpdateAsync |
| 已脱管,且这张表只有你一个写入方 | 可以调 UpdateAsync |
| 已脱管,且别人也会写这几列 | 不要整行写回:重新在 UoW 里取出再改,或者拆表 |
| 想要部分列更新而不先取出 | EF Core 7+ 有 ExecuteUpdateAsync,在此之前的版本只能通过拓展来执行原生SQL来实现 |
事实上,没有银弹,只有取舍,ABP 官方的最佳实践其实是"总是调用 UpdateAsync",因为不是所有数据库都支持变更跟踪,而且你的团队说不定哪天就要换ORM框架(虽然我从来没遇到过),但根据仓储接口要求和数据库提供者无关的原则,到时候要对所有写入的地方追加UpdateAsync,这也是一项不小的工作量
因此我的建议是:
- 多个流程会写同一张表,且实体是在当前 UoW 里取出的:不调用 UpdateAsync。
- 只有一个写入方的表,且 UoW 很短、中间没有外部调用:可以按官方习惯调用,也可以不调,团队选一个并保持一致。
- 已脱管的实体:必须调用,否则都不会更新。
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 对照)。