EF Core 上下文共享事务





5.00/5 (3投票s)
如何在多个 Entity Framework 上下文之间共享事务。
引言
在 .NET Core 中,使用 Entity Framework 时,我们通常将 DbContext
注册为服务,并让实例本身创建和管理其数据库连接。
当一个操作需要两个或多个上下文时,即使这些连接使用相同的连接字符串连接到同一个数据库,它们各自也拥有并管理自己的连接。
现在设想一个注册订单的操作。它使用 OrderContext
创建并存储新订单。但它也可能需要使用 StockContext
来更新(减少)订购产品的库存。这两个操作都属于单个事务:它们应该一起成功或一起失败。
但正如前面所说,每个上下文都管理自己的连接。因此,我们可以在每个连接上创建事务,但不能创建跨越两个上下文的单个事务(除非我们使用分布式事务,而我们不打算这样做)。
最终,总有一种可能性是其中一个事务被提交而另一个事务未被提交(死锁、连接丢失、代码错误、并发问题等)。
我们不讨论涉及两个上下文的操作的设计质量。让我们来寻找一个实际的解决方案。
测试用例
首先,让我们创建一个测试用例:我们将创建两个包含计数的表:OrderCounter
和 StockCounter
。这两个表都有一个 Id
和 Value
列,以及一行 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
数据库显示两个计数器分别为 11
和 99
。.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
呢?
好吧,让我们创建一个扩展方法来注册一个将使用 SqlConnectionSource
的 DbContext
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
注册为作用域服务。还将 OrderContext
和 StockContext
注册为使用名为“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 日:初始版本