使用 Entity Framework Core 的 Blazor 并发问题

Posted

技术标签:

【中文标题】使用 Entity Framework Core 的 Blazor 并发问题【英文标题】:Blazor concurrency problem using Entity Framework Core 【发布时间】:2020-05-02 00:25:21 【问题描述】:

我的目标

我想创建一个新的 IdentityUser 并显示已通过同一 Blazor 页面创建的所有用户。此页面有:

    通过您的表单将创建一个 IdentityUser 第三方的网格组件 (DevExpress Blazor DxDataGrid),显示所有使用 UserManager.Users 属性的用户。该组件接受 IQueryable 作为数据源。

问题

当我通过表单(1)创建新用户时,会出现以下并发错误:

InvalidOperationException:在前一个操作完成之前在此上下文上启动了第二个操作。不保证任何实例成员都是线程安全的。

我认为问题与 CreateAsync(IdentityUser user)UserManager.Users 指的是同一个 DbContext

的事实有关

这个问题与第三方组件无关,因为我用一个简单的列表替换了同样的问题。

重现问题的步骤

    使用身份验证创建新的 Blazor 服务器端项目

    使用以下代码更改 Index.razor:

    @page "/"
    
    <h1>Hello, world!</h1>
    
    number of users: @Users.Count()
    <button @onclick="@(async () => await Add())">click me</button>
    <ul>
    @foreach(var user in Users) 
    
        <li>@user.UserName</li>
    
    </ul>
    
    @code 
        [Inject] UserManager<IdentityUser> UserManager  get; set; 
    
        IQueryable<IdentityUser> Users;
    
        protected override void OnInitialized()
        
            Users = UserManager.Users;
        
    
        public async Task Add()
        
            await UserManager.CreateAsync(new IdentityUser  UserName = $"test_Guid.NewGuid().ToString()" );
        
    
    

我注意到了什么

如果我将实体框架提供程序从 SqlServer 更改为 Sqlite,则该错误将永远不会显示。

系统信息

ASP.NET Core 3.1.0 Blazor 服务器端 Entity Framework Core 3.1.0 基于 SqlServer 提供者

我已经看到的

Blazor A second operation started on this context before a previous operation completed:建议的解决方案对我不起作用,因为即使我将 DbContext 范围从 Scoped 更改为 Transient 我仍然使用相同的 UserManager 实例及其包含DbContext 的同一个实例 *** 上的其他人建议为每个请求创建一个新的 DbContext 实例。我不喜欢这个解决方案,因为它违反了依赖注入原则。无论如何,我无法应用此解决方案,因为 DbContext 包装在 UserManager 中 Create a generator of DbContext:这个解决方案和上一个很像。 Using Entity Framework Core with Blazor

我为什么要使用 IQueryable

我想传递一个 IQueryable 作为我的第三方组件的数据源,因为它可以将分页和过滤直接应用于查询。此外,IQueryable 对 CUD 很敏感 操作。

【问题讨论】:

你可以调试一下,看看哪条线启动了两次。可能是 Add 方法? 不幸的是,我无法捕获 Visual Studio 调用堆栈窗口输出,因为当我单击“单击我”按钮时,Visual Studio 将崩溃并报告我在帖子中写的异常。无论如何,我创建了一个小项目,您可以克隆并尝试它。请记住在 Startup.cs 文件 github.com/Blackleones/BlazorProblemReleatedTo18340 中更改第 35 行 对我来说它不会崩溃并且尝试 catch 在 Add 方法中显示以下异常:Invalid attempt to call ReadAsync when reader is closed. 可能 CreateAsync 与 ReadAsync 冲突? 谢谢。我尝试通过终端运行示例,但遇到了与您之前提到的相同的异常。无论如何,你可以看到 CreateAsync 是来自 Identity Framework 的 UserManager 服务的一种方法,所以我不知道如何在不重写另一个 UserManager 的情况下解决这个问题。 我没有使用 MARS 【参考方案1】:

更新(2020 年 8 月 19 日)

在这里你可以找到documentation about how to use Blazor and EFCore together

更新(2020 年 7 月 22 日)

EFCore 团队在 Entity Framework Core .NET 5 Preview 7 中引入 DbContextFactory

[...] 这种解耦对于推荐使用 IDbContextFactory 的 Blazor 应用程序非常有用,但在其他场景中也可能有用。

如果您有兴趣,可以在Announcing Entity Framework Core EF Core 5.0 Preview 7阅读更多内容

更新(2020 年 7 月 6 日)

Microsoft 发布了一个关于 Blazor(两种型号)和 Entity Framework Core 的有趣的新 video。请看 19:20,他们在讨论如何使用 EFCore 管理并发问题


一般解决方案

我向 Daniel Roth BlazorDeskShow - 2:24:20 询问了这个问题,这似乎是 Blazor 服务器端 的设计问题。 DbContext 默认生存期设置为Scoped。因此,如果您在同一页面中至少有两个组件正在尝试执行 async 查询,那么我们将遇到异常:

InvalidOperationException:在前一个操作完成之前在此上下文中启动了第二个操作。不保证任何实例成员都是线程安全的。

关于这个问题有两个解决方法

(A) 将 DbContext 的生命周期设置为 Transient
services.AddDbContext<ApplicationDbContext>(opt =>
    opt.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")), ServiceLifetime.Transient);
(B) 正如 Carl Franklin 建议的那样(在我的问题之后):使用返回 DbContext 的新实例的静态方法创建单例服务。

无论如何,每个解决方案都有效,因为它们创建了 DbContext 的新实例。

关于我的问题

我的问题与DbContext 并不严格相关,而是与UserManager&lt;TUser&gt; 相关,它的生命周期为Scoped。将 DbContext 的生命周期设置为 Transient 并没有解决我的问题,因为 ASP.NET Core 在我第一次打开会话时创建了一个 UserManager&lt;TUser&gt; 的新实例,并且它一直存在到我不关闭它为止。这个UserManager&lt;TUser&gt; 在同一页面的两个组件中。然后我们遇到了之前描述的同样的问题:

两个组件拥有相同的 UserManager&lt;TUser&gt; 实例,其中包含一个瞬态 DbContext

目前,我用另一种解决方法解决了这个问题:

我不直接使用UserManager&lt;TUser&gt;,而是通过IServiceProvider创建它的一个新实例,然后它就可以工作了。 I am still looking for a method to change the UserManager's lifetime 而不是使用IServiceProvider

提示:注意服务的生命周期

这是我学到的。不知道对不对。

【讨论】:

感谢您的解释。几个月前,我一直在围绕 UserManager 解决这个确切的问题。到目前为止,这还没有真正解决 - 我只有一些解决方法。 只有一个补充:目前我又遇到了这个问题。我已经将所有服务生命周期设置为瞬态但仍然存在并发错误。 我可以尝试以某种方式帮助您吗? GitHub 上有示例代码吗? 抱歉没有样品。我想出了解决方法,在 OnParametersSetAsync 中加载数据时出现错误 - 我可以将代码移动到 OnInitializedAsync 而不会出现任何错误。 嗨莱昂纳多。我一直在努力解决这个问题。您是否有关于如何使用 IServiceProvider 的代码示例。自己尝试过,但仍然出现错误,尽管我确定我没有正确实现它。【参考方案2】:

我下载了您的示例并且能够重现您的问题。问题是因为 Blazor 将在您从 EventCallback 调用的代码中立即重新渲染组件 await(即您的 Add 方法)。

public async Task Add()

    await UserManager.CreateAsync(new IdentityUser  UserName = $"test_Guid.NewGuid().ToString()" );

如果您在Add 的开头和Add 的末尾添加一个System.Diagnostics.WriteLine,然后在Razor 页面的顶部和底部添加一个,您将看到以下输出当您单击按钮时。

//First render
Start: BuildRenderTree
End: BuildRenderTree

//Button clicked
Start: Add
(This is where the `await` occurs`)
Start: BuildRenderTree
Exception thrown

您可以像这样阻止这种中间方法重新渲染......

protected override bool ShouldRender() => MayRender;

public async Task Add()

    MayRender = false;
    try
    
        await UserManager.CreateAsync(new IdentityUser  UserName = $"test_Guid.NewGuid().ToString()" );
    
    finally
    
        MayRender = true;
    

这将防止在您的方法运行时重新渲染。请注意,如果您将Users 定义为IdentityUser[] Users,您将不会看到此问题,因为直到await 完成并且未延迟评估后,数组才会设置,因此您不会遇到此重入问题。

我相信您想使用IQueryable&lt;T&gt;,因为您需要将它传递给第 3 方组件。问题是,不同的组件可以在不同的线程上渲染,所以如果你将IQueryable&lt;T&gt;传递给其他组件,那么

    它们可能会在不同的线程上呈现并导致相同的问题。 他们很可能在使用IQueryable&lt;T&gt; 的代码中包含await,而您将再次遇到同样的问题。

理想情况下,您需要让第 3 方组件有一个向您询问数据的事件,为您提供某种查询定义(页码等)。我知道 Telerik Grid 和其他人一样这样做。

这样你就可以做到以下几点

    获取锁 应用过滤器运行查询 解除锁定 将结果传递给组件

您不能在异步代码中使用lock(),因此您需要使用SpinLock 之类的东西来锁定资源。

private SpinLock Lock = new SpinLock();

private async Task<WhatTelerikNeeds> ReadData(SomeFilterFromTelerik filter)

  bool gotLock = false;
  while (!gotLock) Lock.Enter(ref gotLock);
  try
  
    IUserIdentity result = await ApplyFilter(MyDbContext.Users, filter).ToArrayAsync().ConfigureAwait(false);
    return new WhatTelerikNeeds(result);
  
  finally
  
    Lock.Exit();
  

【讨论】:

非常感谢!是的,我需要这个,因为我使用的是第 3 方组件(DevEx)并且我正面临这个问题。目前,我已经使用DbContextFactory 解决了丢失更改跟踪器的副作用。你的解决方案很聪明,我喜欢它。我有一个问题:如果您在同一页面上有两个组件,它们都与 DbContext 交互,该怎么办?我认为在那种情况下SpinLock 不能解决问题,它应该被共享.. 对吗?它似乎更复杂。顺便说一句,你给了我一个新的观点,我喜欢它! 如果您将IQueryable&lt;T&gt; 传递给其他组件,那么它将无法正常工作。相反,您必须让组件向您的组件询问数据+发送某种过滤器,然后在您的主要组件中评估它(在锁内)。 对不起,我说的是你的例子。目前,第 3 方库得到了改进,因此现在它有一个请求过滤器、分页等的事件。但即使在这种情况下,我也可以有两个不同的组件尝试请求对同一个 dbcontext 实例执行查询.他们有可能同时尝试与 dbcontext 交互,所以这就是我问你是否应该共享 SpinLock 的原因。让我知道,如果我不明白你的想法。 SpinLock 应该在您的 OwningComponentBase&lt;MyDbContext&gt; 后代组件上,这最终应该是执行任何依赖组件的查询。【参考方案3】:

也许不是最好的方法,但将异步方法重写为非异步可以解决问题:

public void Add()

  Task.Run(async () => 
      await UserManager.CreateAsync(new IdentityUser  UserName = $"test_Guid.NewGuid().ToString()" ))
      .Wait();                                   

它确保 UI 仅在新用户创建后更新。


Index.razor 的完整代码

@page "/"
@inherits OwningComponentBase<UserManager<IdentityUser>>
<h1>Hello, world!</h1>

number of users: @Users.Count()
<button @onclick="@Add">click me. I work if you use Sqlite</button>

<ul>
@foreach(var user in Users.ToList()) 

    <li>@user.UserName</li>

</ul>

@code 
    IQueryable<IdentityUser> Users;

    protected override void OnInitialized()
    
        Users = Service.Users;
    

    public void Add()
    
        Task.Run(async () => await Service.CreateAsync(new IdentityUser  UserName = $"test_Guid.NewGuid().ToString()" )).Wait();            
    

【讨论】:

我已经尝试过您的解决方案,但单击按钮时没有任何反应。这很奇怪,因为没有创建用户并且没有任何异常 我已经在您的 Git 项目中完成了这项工作,并且按预期工作,没有任何例外。 谢谢,我又试了一次,成功了。顺便说一句,这是一种解决方法,但代价很高:锁定用户界面渲染。 期待更好的想法! 一旦找到更好的解决方案,我会及时通知您。无论如何,谢谢!【参考方案4】:

我发现您的问题正在寻找有关您遇到的相同错误消息的答案。

我的并发问题似乎是由于在我尝试调用 DbContext.SaveChangesAsync() 的同时(或由于这一事实)触发了视觉树重新渲染的更改。

我通过像这样覆盖组件的 ShouldRender() 方法解决了这个问题:

    protected override bool ShouldRender()
    
        if (_updatingDb)
         
            return false; 
        
        else
        
            return base.ShouldRender();
        
    

然后,我将 SaveChangesAsync() 调用封装在适当地设置私有 bool 字段 _updatingDb 的代码中:

        try
        
            _updatingDb = true;
            await DbContext.SaveChangesAsync();
        
        finally
        
            _updatingDb = false;
            StateHasChanged();
        

对 StateHasChanged() 的调用可能需要也可能不需要,但我将其包括在内以防万一。

这解决了我的问题,该问题与根据是否正在编辑数据字段选择性地呈现绑定的输入标签或仅文本有关。其他读者可能会发现他们的并发问题也与触发重新渲染的事情有关。如果是这样,这种技术可能会有所帮助。

【讨论】:

您的解决方案很有趣,我认为它解决了很多问题。我的问题有点不同,因为它与 dbContex.saveChangesAsync() 没有严格的关系。问题是我在同一页面上有两个组件,每个组件都在对数据库进行异步调用。拥有lifetime = scoped 的dbContext 然后这两个组件生成一个竞争条件【参考方案5】:

嗯,我有一个非常相似的场景,我“解决”我的是将所有内容从 OnInitializedAsync() 移动到

protected override async Task OnAfterRenderAsync(bool firstRender)

    if(firstRender)
    
        //Your code in OnInitializedAsync()
        StateHasChanged();
    

似乎解决了,但我不知道要找出证明。我想只是跳过初始化让组件成功构建,然后我们可以更进一步。

/******************************更新*************** *****************/

我仍然面临这个问题,似乎我给出了错误的解决方案。当我检查这个Blazor A second operation started on this context before a previous operation completed 时,我的问题很清楚。因为我实际上正在使用 dbContext 操作处理很多组件初始化。根据@dani_herrera 的说法,如果您有超过 1 个组件一次执行 Init,则可能会出现问题。 当我接受他的建议将我的 dbContext 服务更改为Transient 时,我就摆脱了这个问题。

【讨论】:

【参考方案6】:

@Leonardo Lurci 已经在概念上进行了介绍。如果你们还不想迁移到 .NET 5.0 预览版,我建议您查看 Nuget 包“EFCore.DbContextFactory”,文档非常简洁。本质上它模拟了 AddDbContextFactory。当然,它会为每个组件创建一个上下文。

【讨论】:

【参考方案7】:

到目前为止,这对我来说工作正常,没有任何问题......

我只通过一个新的DbContext.InvokeAsync 方法与我的DbContext 交互来确保单线程访问,该方法使用SemaphoreSlim 来确保一次只执行一个操作。

我选择SemaphoreSlim是因为你可以await它。

不是这个

return Db.Users.FirstOrDefaultAsync(x => x.EmailAddress == emailAddress);

这样做

return Db.InvokeAsync(() => ...the query above...);
// Add the following methods to your DbContext
private SemaphoreSlim Semaphore  get;  = new SemaphoreSlim(1);

public TResult Invoke<TResult>(Func<TResult> action)

    Semaphore.Wait();
    try
    
        return action();
    
    finally
    
        Semaphore.Release();
    


public async Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> action)

    await Semaphore.WaitAsync();
    try
    
        return await action();
    
    finally
    
        Semaphore.Release();
    


public Task InvokeAsync(Func<Task> action) =>
    InvokeAsync<object>(async () =>
    
        await action();
        return null;
    );

public void InvokeAsync(Action action) =>
    InvokeAsync(() =>
    
        action();
        return Task.CompletedTask;
    );

【讨论】:

【参考方案8】:

@Leonardo Lurci 有一个很好的答案,有多种解决方案。我会就每一个解决方案发表我的意见,我认为这是最好的。

    使 DBContext 瞬态 - 这是一种解决方案,但并未针对这种情况进行优化..

    Carl Franklin 建议 - 单例服务将无法控制上下文的生命周期,并且将依赖服务请求者在使用后处置上下文。

    他们谈论将 DBContext Factory 注入到具有 IDisposable 接口的组件中的 Microsoft 文档,以在组件被销毁时释放上下文。这不是一个很好的解决方案,因为它会发生很多问题,例如:执行上下文操作并在组件完成该操作之前离开组件,会释放上下文并抛出异常..

最后。到目前为止,最好的解决方案是将 DBContext Factory 注入组件中,是的,但是当您需要它时,您可以使用下面的 using 语句创建一个新实例:

public async Task GetSomething()

   using var context = DBFactory.CreateDBContext();

   return await context.Something.ToListAsync();

由于 DbFactory 在创建新的上下文实例时进行了优化,因此没有显着的开销,使其成为比瞬态上下文更好的选择和更好的性能,它还因为“使用”语句而在方法结束时处理上下文。

希望有用。

【讨论】:

以上是关于使用 Entity Framework Core 的 Blazor 并发问题的主要内容,如果未能解决你的问题,请参考以下文章

Entity Framework Core 性能优化

在 Entity Framework Core 中使用 [ComplexType]

在 Entity Framework Core 中使用 SQL 视图

使用 Entity Framework Core 更新相关数据

使用 Entity Framework Core 自动增加部分主键

如何使用 Entity Framework Core 模拟异步存储库