65.9K
CodeProject 正在变化。 阅读更多。
Home

EF Core 上下文共享事务

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2022 年 11 月 26 日

CPOL

6分钟阅读

viewsIcon

14318

如何在多个 Entity Framework 上下文之间共享事务。

引言

在 .NET Core 中,使用 Entity Framework 时,我们通常将 DbContext 注册为服务,并让实例本身创建和管理其数据库连接。

当一个操作需要两个或多个上下文时,即使这些连接使用相同的连接字符串连接到同一个数据库,它们各自也拥有并管理自己的连接。

现在设想一个注册订单的操作。它使用 OrderContext 创建并存储新订单。但它也可能需要使用 StockContext 来更新(减少)订购产品的库存。这两个操作都属于单个事务:它们应该一起成功或一起失败。

但正如前面所说,每个上下文都管理自己的连接。因此,我们可以在每个连接上创建事务,但不能创建跨越两个上下文的单个事务(除非我们使用分布式事务,而我们不打算这样做)。

最终,总有一种可能性是其中一个事务被提交而另一个事务未被提交(死锁、连接丢失、代码错误、并发问题等)。

我们不讨论涉及两个上下文的操作的设计质量。让我们来寻找一个实际的解决方案。

测试用例

首先,让我们创建一个测试用例:我们将创建两个包含计数的表:OrderCounterStockCounter。这两个表都有一个 IdValue 列,以及一行 Id 1。使用两个独立的上下文,每当我们增加一个计数器(在 OrderCounter 中)的值时,我们将减少另一个计数器(在 StockCounter 中)的值。

这是创建 SQL Server 数据库的代码

CREATE DATABASE [TransDemo]
GO

USE [TransDemo]
GO

CREATE SCHEMA [order]
GO

CREATE TABLE [order].[OrderCounter]
(
    [Id] int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    [Value] INT
)
GO

INSERT INTO [order].[OrderCounter] ([Value]) VALUES (0)
GO

CREATE SCHEMA [stock]
GO

CREATE TABLE [stock].[StockCounter]
(
    [Id] int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    [Value] INT
)
GO

INSERT INTO [stock].[StockCounter] ([Value]) VALUES (100)
GO

在测试期间,我们可以使用以下脚本始终恢复初始状态

UPDATE [order].[OrderCounter] SET [Value] = 0 WHERE [Id] = 1

UPDATE [stock].[StockCounter] SET [Value] = 100 WHERE [Id] = 1 

到目前为止,数据库的部分就完成了。

对于我们的测试用例,我们将创建一个 .NET 6 的“ASP.NET Core Web API”项目。确保选中“启用 OpenAPI 支持”。这将为我们提供一个 Swagger UI,我们可以用它来测试我们的 API。

现在我们需要创建两个上下文。一个用于管理订单计数器

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;

namespace MyWebApi
{
    public class OrderContext : DbContext
    {
        public OrderContext(DbContextOptions<OrderContext> options)
            : base(options)
        { }

        public DbSet<OrderCounter> OrderCounters { get; set; }
    }

    [Table(nameof(OrderCounter), Schema = "order")]
    public class OrderCounter
    {
        public int Id { get; set; }

        public int Value { get; set; }
    }
}

另一个,除了命名之外,与前者相同,用于管理库存计数器

using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;

namespace MyWebApi
{
    public class StockContext : DbContext
    {
        public StockContext(DbContextOptions<StockContext> options)
            : base(options)
        { }

        public DbSet<StockCounter> StockCounters { get; set; }
    }

    [Table(nameof(StockCounter), Schema = "stock")]
    public class StockCounter
    {
        public int Id { get; set; }

        public int Value { get; set; }
    }
}

由于我们是从 SQL 脚本创建的数据库,因此不需要创建迁移。

我们确实仍然需要将上下文注册为服务并定义连接字符串。在 appsettings.json 中,我们将定义一个“DefaultConnection”连接字符串

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(local);Database=TransDemo;
     Trusted_Connection=True;MultipleActiveResultSets=true;Encrypt=False;"
  },
  "AllowedHosts": "*"
}

接下来,我们在 Program.cs 中注册我们的上下文服务(如果我们使用 .NET 6 之前的项目设置,则为 Startup.cs)。在调用 builder.Build() 之前,我们添加以下代码

var connectionString = builder.Configuration
    .GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<OrderContext>(
    options => options.UseSqlServer(connectionString));

builder.Services.AddDbContext<StockContext>(
    options => options.UseSqlServer(connectionString));

还剩下最后一件事:创建增加订单计数器和减少库存计数器的操作。由于我们使用的是 Web API 项目,我们将创建一个带有 Register 操作的 Web API 控制器

using Microsoft.AspNetCore.Mvc;

namespace MyWebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class OrderingController : ControllerBase
    {
        private readonly OrderContext orderCtx;
        private readonly StockContext stockCtx;

        public OrderingController(OrderContext orderCtx, StockContext stockCtx)
        {
            this.orderCtx = orderCtx;
            this.stockCtx = stockCtx;
        }

        [HttpGet(Name = "Register")]
        public int[] Get(int quantity = 1)
        {
            try
            {
                orderCtx.OrderCounters.Find(1)!.Value += quantity;
                stockCtx.StockCounters.Find(1)!.Value -= quantity;

                orderCtx.SaveChanges();
                if (quantity >= 10)
                    throw new ApplicationException("Something went wrong.");
                stockCtx.SaveChanges();
            }
            catch (Exception)
            { }

            return new int[] {
                orderCtx.OrderCounters.Find(1)!.Value,
                stockCtx.StockCounters.Find(1)!.Value
            };
        }
    }
}

注意控制器是如何注入两个数据库上下文的。

Register 操作中,我们将以相同的数量增加订单计数器并减少库存计数器。

然后我们保存订单上下文和库存上下文的更改。但在两次保存之间,为了测试,如果数量为 10 或更多,我们会让代码失败。

这种失败会被进一步忽略,我们返回两个计数器的最新值。

运行后,我们会得到一个 Swagger UI 界面,我们可以用它来调用我们的 Web API。展开“/api/Ordering”,按下“Try it out”按钮,然后按下“Execute”按钮。

在第一次使用数量 1 运行后,您应该会得到返回的数组 [1, 99]。两者加起来是 100,因此我们的操作保持了状态一致。

现在再试一次,但使用 quantity = 10。您会得到 [11, 89],这看起来也一致。但查询数据库,您会发现事实并非如此

SELECT * FROM [order].[OrderCounter] WHERE [Id]=1
SELECT * FROM [stock].[StockCounter] WHERE [Id]=1

数据库显示两个计数器分别为 1199。.NET 被一个 SaveChanges 未执行的事实所迷惑,实际上,在内存中,计数器已经被更新了。但当您下次调用服务时,例如数量为 0,您会发现数据库是正确的。

因此,有可能一个计数器被更新而另一个没有,这就是问题所在,也是我们想要解决的问题……

解决方案

解决方案很简单:让两个上下文共享同一个数据库连接,让一个事务包围整个操作。但如何做到这一点呢?

共享连接

首先,数据库上下文不应该再自己管理连接了。因此,应该有一个某种形式的共享连接管理器。我称之为 SqlConnectionSource。这是代码

using Microsoft.Extensions.Configuration;
using System.Data.SqlClient;

namespace MyWabApi
{
    public class SqlConnectionSource : IDisposable
    {
        private readonly IConfiguration configuration;
        private readonly Dictionary<string, SqlConnection> connections = new();

        public SqlConnectionSource(IConfiguration configuration)
        {
            this.configuration = configuration;
        }

        public SqlConnection this[string name]
        {
            get
            {
                if (!connections.TryGetValue(name, out SqlConnection? conn))
                {
                    var cs = this.configuration.GetConnectionString(name);
                    return connections[name] = new SqlConnection(cs);
                }
                else
                {
                    return conn;
                }
            }
        }

        public virtual void Dispose()
        {
            GC.SuppressFinalize(this);
            foreach (var connection in connections.Values)
                connection.Dispose();
        }
    }
}

它包含一个 SqlConnections 字典,每当请求一个连接时,它要么从其字典中返回一个现有的连接,要么创建一个新的连接,并确保每个连接字符串只有一个连接。

当被释放时,字典中的所有连接也会被释放。

这使得该类可以在我们的 Web API 或 Web 应用程序中作为作用域服务使用。

使用 SqlConnectionSource,我们可以确保只存在一个名为“DefaultConnection”的连接字符串的连接。我们可以在 Web API 调用作用域内做到这一点。

不错!但我们如何使用 SqlConnectionSource 呢?

好吧,让我们创建一个扩展方法来注册一个将使用 SqlConnectionSourceDbContext

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;

namespace MyApp
{
    public static class SqlExtensions
    {
        public static void AddScopedSqlDbContext<TDbContext>
        (this IServiceCollection services, 
         string connectionName = "DefaultConnection", 
         Action<DbContextOptionsBuilder<TDbContext>> optionsAction = null)
            where TDbContext : DbContext
        {
            services.AddScoped<DbContextOptions<TDbContext>>(serviceProvider =>
            {
                var builder = new DbContextOptionsBuilder<TDbContext>();
                var source = serviceProvider.GetRequiredService<SqlConnectionSource>();
                var connection = source[connectionName];
                builder.UseSqlServer(connection);
                optionsAction?.Invoke(builder);

                return builder
                    .Options;
            });

            services.AddScoped<TDbContext>();
        }
    }
}

这是一个相当复杂的代码,但归结为:当调用 AddScopedSqlDbContext 扩展方法时,它将给定的 DbContext 类注册为一个作用域服务(代码的最后一行)。

DbContext 期望在其构造函数中注入一个 DbContextOptions<T>。因此,其他代码用于在此作用域内配置并注册这样的 DbContextOptions,使其使用 SqlConnectionSource 服务来获取要使用的 SqlConnection

现在我们可以将 Program.cs 文件中的数据库上下文注册替换为以下代码

builder.Services.AddScoped<SqlConnectionSource>();
builder.Services.AddScopedSqlDbContext<OrderContext>("DefaultConnection");
builder.Services.AddScopedSqlDbContext<StockContext>("DefaultConnection");

这会将 SqlConnectionSource 注册为作用域服务。还将 OrderContextStockContext 注册为使用名为“DefaultConnection”的连接字符串(您实际上可以省略连接名称参数,因为“DefaultConnection”是默认值)。

不要忘记删除旧的数据库上下文注册代码,该代码使用了 AddDbContext 扩展方法。

我们现在可以再次运行我们的测试用例。但结果应该是一样的。这是正常的。我们还缺少一步:创建一个共享事务!

共享事务

这比看起来要棘手,但这是重写的 Register 操作,带有共享事务

        [HttpGet(Name = "Register")]
        public int[] Get(int quantity = 1)
        {
            var transaction = orderCtx.Database.BeginTransaction();
            stockCtx.Database.UseTransaction(transaction.GetDbTransaction());

            try
            {
                orderCtx.OrderCounters.Find(1)!.Value += quantity;
                stockCtx.StockCounters.Find(1)!.Value -= quantity;

                orderCtx.SaveChanges();
                if (quantity >= 10)
                    throw new ApplicationException("Something went wrong.");
                stockCtx.SaveChanges();

                transaction.Commit();
            }
            catch (Exception)
            {
                transaction.Rollback();
            }

            return new int[] {
                orderCtx.OrderCounters.Find(1)!.Value,
                stockCtx.StockCounters.Find(1)!.Value
            };
        }

首先,我们在订单上下文上开始一个事务。

然后,我们将告知库存上下文使用相同的事务。

try 块的末尾,我们提交事务,而在 catch 块中,我们回滚事务。

发生故障时,该操作在返回的数组中仍然报告错误的值,但这是因为事务只在数据库上操作,而不是在应用程序内存中。

但对于数据库:操作要么成功(事务已提交),两个计数器都得到一致更新,要么操作失败(事务已回滚),没有计数器被更新。

搞定!

历史

  • 2022 年 11 月 26 日:初始版本
© . All rights reserved.