如何在EF Core 6中嵌套事务?

3

在某些情况下,我已经在我的存储库函数中使用了事务,因为有时候我需要将数据插入到两个表中,我希望整个操作失败时都会回滚。

现在我遇到了这样的情况:我必须将多个存储库/函数的调用包装在另一个事务中,但是当其中一个函数已经在内部使用了事务时,我会收到错误信息The connection is already in a transaction and cannot participate in another transaction

我不想从存储库函数中删除事务,因为这意味着我必须在服务层中实现对哪些存储库函数需要事务的了解。另一方面,似乎当存储库函数已经在内部使用事务时,我不能在事务中使用它们。以下是我面临此问题的示例:

// Reverse engineered classes
public partial class TblProject
{
    public TblProject()
    {
        TblProjectStepSequences = new HashSet<TblProjectStepSequence>();
    }
    
    public int ProjectId { get; set; }

    public virtual ICollection<TblProjectStepSequence> TblProjectStepSequences { get; set; }
}

public partial class TblProjectTranslation
{
    public int ProjectId { get; set; }
    public string Language { get; set; }
    public string ProjectName { get; set; }

    public virtual TblProject Project { get; set; }
}

public partial class TblProjectStepSequence
{
    public int SequenceId { get; set; }
    public int ProjectId { get; set; }
    public int StepId { get; set; }
    public int SequencePosition { get; set; }

    public virtual TblStep Step { get; set; }
    public virtual TblProject Project { get; set; }
}

// Creating a project in the ProjectRepository
public async Task<int> CreateProjectAsync(TblProject project, ...)
{
    using (var transaction = this.Context.Database.BeginTransaction())
    {
        await this.Context.TblProjects.AddAsync(project);
        await this.Context.SaveChangesAsync();
        // Insert translations... (project Id is required for this)
        await this.Context.SaveChangesAsync();
        transaction.Commit();
        
        return entity.ProjectId;
    }
}

// Creating the steps for a project in the StepRepository
public async Task<IEnumerable<int>> CreateProjectStepsAsync(int projectId, IEnumerable<TblProjectStepSequence> steps)
{
    await this.Context.TblProjectStepSequences.AddRangeAsync(steps);
    await this.Context.SaveChangesAsync();

    return steps.Select(step =>
    {
        return step.SequenceId;
    }
    );
}

// Creating a project with its steps in the service layer
public async Task<int> CreateProjectWithStepsAsync(TblProject project, IEnumerable<TblProjectStepSequence> steps)
{
    // This is basically a wrapper around Database.BeginTransaction() and IDbContextTransaction
    using (Transaction transaction = await transactionService.BeginTransactionAsync())
    {
        int projectId = await projectRepository.CreateProjectAsync(project);
        await stepRepository.CreateProjectStepsAsync(projectId, steps);

        return projectId;
    }
}

有没有一种方法可以在内部事务不知道可能存在外部事务的情况下将多个事务嵌套在彼此内部?

我知道从技术角度来看,可能无法真正嵌套这些事务,但我仍然需要一个解决方案,它要么使用存储库的内部事务,要么使用外部事务(如果存在),以便于在需要使用事务的存储库函数时,我不会意外地忘记使用一个事务。


3
如果其中一个插入失败,我希望整个操作都失败。最好的方法是设计你的代码使所有内容在一个SaveChanges调用中保存。通常情况下,仓储层在这里更像是一个障碍而不是帮助。话虽如此,如果没有看到示例,我们无法帮助你。 - Gert Arnold
1
不,你必须将项目、翻译和步骤作为一个对象图创建,并一次性保存它们。我已经在你其他关于同一主题的问题中说过类似的话了。你的架构使得无法按照EF的预期方式使用它。 - Gert Arnold
2
@PanagiotisKanavos 那你有什么建议呢?到目前为止,我能找到的都是我不应该做的事情,但没有一个可行的解决方案。因为我的项目中有许多其他数据源,而不仅仅是单个数据库,所以我需要在某个时候将EF Core抽象出来。 - Chris
2
@PanagiotisKanavos 我已经在一个月前更新了我的代码,通过在调用 SaveChanges() 之前直接添加相关实体来考虑这一点。然而,由于不同的原因,我仍然遇到“只调用一次SaveChanges()”方法的问题。例如,我有一个实体,其中一个列的值应该设置为包含实体ID的字符串,当创建一个新实体时。由于在保存更改到数据库之前我不知道ID,所以我需要在获取ID以设置其他列的值之前调用 SaveChanges() - Chris
2
@PanagiotisKanavos - 批评是可以的。但是,与其写一堆关于我们做错了什么的评论,你不如花时间发表一个好的答案,你同意吗?;-) - Matt
显示剩余23条评论
3个回答

0

我正在回答你提出的问题:“如何在EF Core 6中嵌套事务?”

请注意,这只是一个直接的答案,而不是最佳实践和非最佳实践的评估。有很多关于最佳实践的讨论,这是合理的,因为你需要问自己什么最适合你的用例,但这并不是问题的答案(请记住,Stack Overflow只是一个问答网站,人们想要直接的答案)。

话虽如此,让我们继续谈论这个主题:

尝试使用这个帮助函数来创建一个新的事务:

public CommittableTransaction CreateTransaction() 
    => new System.Transactions.CommittableTransaction(new TransactionOptions()
    {
        IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted
    });

以Northwind数据库为例,您可以像这样使用它:

public async Task<int?> CreateCategoryAsync(Categories category)
{         
    if (category?.CategoryName == null) return null;
    using(var trans = CreateTransaction()) 
    {
        await this.Context.Categories.AddAsync(category);
        await this.Context.SaveChangesAsync();

        trans.Commit();

        return category?.CategoryID;
    }
}

然后你可以从另一个函数中调用它,例如:

/// <summary>Create or use existing category with associated products</summary>
/// <returns>Returns null if transaction was rolled back, else CategoryID</returns>
public async Task<int?> CreateProjectWithStepsAsync(Categories category)
{
    using var trans = CreateTransaction();
    
    int? catId = GetCategoryId(category.CategoryName) 
                ?? await CreateCategoryAsync(category);

    if (!catId.HasValue || string.IsNullOrWhiteSpace(category.CategoryName))
    {
        trans.Rollback(); return null;
    }
    
    var product1 = new Products()
    {
        ProductName = "Product A1", CategoryID = catId
    };
    await this.Context.Products.AddAsync(product1);
    
    var product2 = new Products()
    {
        ProductName = "Product A2", CategoryID = catId
    };
    await this.Context.Products.AddAsync(product2);

    await this.Context.SaveChangesAsync();

    trans.Commit();
    
    return catId;
}

要在 LinqPad 中运行此程序,您需要一个入口点(并且当然还需要通过 F4 添加 EntityFramework 6.x NUGET 包,然后创建一个 EntityFramework Core 连接):

// Main method required for LinqPad
UserQuery Context;

async Task Main()
{
    Context = this;
    var category = new Categories()
    {
        CategoryName = "Category A1"
        // CategoryName = ""
    };
    var catId = await CreateProjectWithStepsAsync(category);
    Console.WriteLine((catId == null) 
           ? "Transaction was aborted." 
           : "Transaction successful.");
}

这只是一个简单的例子 - 它不会检查是否存在任何同名产品,它只会创建一个新的。您可以轻松实现它,我已经在函数CreateProjectWithStepsAsync中展示了它,适用于以下类别:

int? catId = GetCategoryId(category.CategoryName) 
            ?? await CreateCategoryAsync(category);

首先,它通过名称查询类别(通过GetCategoryId(...)),如果结果为null,则会创建一个新的类别(通过CreateCategoryAsync(...))。

此外,您需要考虑隔离级别:查看System.Transactions.IsolationLevel,以确定此处使用的隔离级别(ReadCommitted)是否适合您(它是默认设置)。

它所做的是显式地创建一个事务,并注意这里我们有一个嵌套的事务。


注意:

  • 我已经使用了旧版和新版的using两种方式。选择你更喜欢的那一种。

这只是试图掩盖问题 - 每次都使用 SaveChanges。唯一真正的解决方案是要么正确地使用 EF Core,仅在需要提交更改时才使用 SaveChanges,要么根本不使用。 - Panagiotis Kanavos
即使这个回答解决了提问者(糟糕的)问题,它也会对其他找到这个问题并没有意识到真正问题的人造成伤害。此外,除非数据库支持,否则不可能有嵌套事务。在SQL Server中,嵌套的BEGIN TRAN没有效果,ROLLBACK TRAN将回滚根事务。 - Panagiotis Kanavos
1
最后,ReadUncommitted?这本质上是一个NOLOCK和一个严重的错误。这意味着将返回脏数据和重复数据,而其他行将被忽略。Aaron Bertrand(以及其他人)在Bad Habits - NOLOCK everywhere中解释了其中的问题。 - Panagiotis Kanavos
是的。人们经常误解嵌套事务的概念,而代码则显示了对Entity Framework的整体误解和误用。SQL Server 没有嵌套事务。MySQL 没有嵌套事务Oracle也不支持嵌套事务。 - Panagiotis Kanavos
@PanagiotisKanavos - 根据这个链接,你是错的,SQL Server 确实 有嵌套事务:https://dotnettutorials.net/lesson/sql-server-savepoints-transaction/ 你可以给事务命名,这被称为保存点。该文章很好地描述了嵌套。 - Matt
显示剩余4条评论

0
你可以检查CurrentTransaction属性,然后像这样做:
var transaction = Database.CurrentTransaction ?? Database.BeginTransaction()

如果已经存在一个事务,请使用该事务,否则启动一个新的事务...
编辑:已删除Using块,请参见注释。但是,需要更多的逻辑来提交/回滚事务...

使用块将回滚事务,除非您提交它。 - David Browne - Microsoft
检查CurrentTransaction的基本思路是可行的,但正如DavidBrowne所评论的那样,现有的事务不应该放在using块中。 - grek40

-1

不要多次调用 SaveChanges

问题是由于多次调用 SaveChanges 来提交 DbContext 中所做的更改而不是在最后只调用一次引起的。这根本没必要。DbContext 是一个多实体工作单元。它甚至没有保持到数据库的开放连接。这通过消除跨连接阻塞,使整个应用程序的吞吐量提高了100-1000倍。

DbContext 跟踪对其跟踪的所有对象所做的修改,并在使用内部事务调用 SaveChanges 时将其持久化/提交。要放弃更改,只需处理 DbContext。这就是为什么所有示例都在 using 块中使用 DbContext - 实际上那就是工作单元“事务”的范围。

没有必要先“保存”父对象。EF Core 将在 SaveChanges 内自行完成此操作。

使用 Blog/Posts 示例在 EF Core 文档教程 中:


public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }

    public string DbPath { get; }

    // The following configures EF to create a Sqlite database file in the
    // special "local" folder for your platform.
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlServer($"Data Source=.;Initial Catalog=tests;Trusted_Connection=True; Trust Server Certificate=Yes");
}

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }

    public List<Post> Posts { get; } = new();
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public Blog Blog { get; set; }
}

以下的 Program.cs 会添加一个包含5篇文章的博客,但只在最后调用一次 SaveChanges


using (var db = new BloggingContext())
{
    Blog blog = new Blog { Url = "http://blogs.msdn.com/adonet" };
    IEnumerable<Post> posts = Enumerable.Range(0, 5)
                                       .Select(i => new Post {
                                            Title = $"Hello World {i}",
                                            Content = "I wrote an app using EF Core!"
                                         });
    blog.Posts.AddRange(posts);

    db.Blogs.Add(blog);

    await db.SaveChangesAsync();
}

代码从未指定或检索 ID。Add 是一个内存操作,因此没有理由使用 AddAsyncAdd 开始跟踪博客和相关的 PostInserted 状态下。

此后表格的内容为:

select * from blogs

select * from posts;
-----------------------

BlogId  Url
1   http://blogs.msdn.com/adonet

PostId  Title   Content BlogId
1   Hello World 0   I wrote an app using EF Core!   1
2   Hello World 1   I wrote an app using EF Core!   1
3   Hello World 2   I wrote an app using EF Core!   1
4   Hello World 3   I wrote an app using EF Core!   1
5   Hello World 4   I wrote an app using EF Core!   1

执行代码两次将添加另一个博客,带有另外5篇文章。

PostId  Title   Content BlogId
1   Hello World 0   I wrote an app using EF Core!   1
2   Hello World 1   I wrote an app using EF Core!   1
3   Hello World 2   I wrote an app using EF Core!   1
4   Hello World 3   I wrote an app using EF Core!   1
5   Hello World 4   I wrote an app using EF Core!   1
6   Hello World 0   I wrote an app using EF Core!   2
7   Hello World 1   I wrote an app using EF Core!   2
8   Hello World 2   I wrote an app using EF Core!   2
9   Hello World 3   I wrote an app using EF Core!   2
10  Hello World 4   I wrote an app using EF Core!   2

使用SQL Server XEvents Profiler可以显示这些SQL调用:

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [Blogs] ([Url])
VALUES (@p0);
SELECT [BlogId]
FROM [Blogs]
WHERE @@ROWCOUNT = 1 AND [BlogId] = scope_identity();
',N'@p0 nvarchar(4000)',@p0=N'http://blogs.msdn.com/adonet'

exec sp_executesql N'SET NOCOUNT ON;
DECLARE @inserted0 TABLE ([PostId] int, [_Position] [int]);
MERGE [Posts] USING (
VALUES (@p1, @p2, @p3, 0),
(@p4, @p5, @p6, 1),
(@p7, @p8, @p9, 2),
(@p10, @p11, @p12, 3),
(@p13, @p14, @p15, 4)) AS i ([BlogId], [Content], [Title], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([BlogId], [Content], [Title])
VALUES (i.[BlogId], i.[Content], i.[Title])
OUTPUT INSERTED.[PostId], i._Position
INTO @inserted0;
SELECT [i].[PostId] FROM @inserted0 i
ORDER BY [i].[_Position];
',N'@p1 int,@p2 nvarchar(4000),@p3 nvarchar(4000),@p4 int,@p5 nvarchar(4000),@p6 nvarchar(4000),@p7 int,@p8 nvarchar(4000),@p9 nvarchar(4000),@p10 int,@p11 nvarchar(4000),@p12 nvarchar(4000),@p13 int,@p14 nvarchar(4000),@p15 nvarchar(4000)',@p1=3,@p2=N'I wrote an app using EF Core!',@p3=N'Hello World 0',@p4=3,@p5=N'I wrote an app using EF Core!',@p6=N'Hello World 1',@p7=3,@p8=N'I wrote an app using EF Core!',@p9=N'Hello World 2',@p10=3,@p11=N'I wrote an app using EF Core!',@p12=N'Hello World 3',@p13=3,@p14=N'I wrote an app using EF Core!',@p15=N'Hello World 4'

使用不寻常的SELECT和MERGE来确保按照插入对象的顺序返回IDENTITY值,以便EF Core可以将它们分配给对象属性。在调用SaveChanges之后,所有的Blog和Post对象都将具有正确的数据库生成的ID。


1
你如何添加相关对象?SaveChanges会为你生成ID(PK)。如果你有1:n或m:n的关系,该如何获取这些键呢?并非所有数据库都使用Guids作为键... - Matt
这就是你所需要的全部代码。我已经解释过,这段代码来自EF Core入门教程,并且已经将链接发布出来了。我没有在任何地方使用GUID,而是使用了int——GUID作为主键非常糟糕。生成和执行迁移会创建带有IDENTITY默认值的ID列的表。Posts是一个相关对象。它已经是一个1:N关系。我发布了SQL跟踪以展示EF Core如何处理相关对象。甚至EF Core 6还通过自动生成桥接表来处理基于约定的M:N关系,而无需相应的类。 - Panagiotis Kanavos
现在我明白了 - EF“知道”它必须将列表转换为关系并自动生成必要的键,因此只需要在最后进行一次SaveChanges。 - Matt
然而,如果你"捆绑"了一些更新/插入操作,你可能仍然需要在一个事务中执行,因为通常情况下你希望要么所有的操作成功,要么全部失败,而不是最后一个更新操作失败但其他操作都成功(原子性)。更多关于这个主题的内容可以参考这里:https://database.guide/what-is-acid-in-databases/。 - Matt
@Matt,再次强调,这就是EF Core和NHibernate的作用。SaveChanges将所有修改捆绑在一起,并在单个数据库事务中执行它们。这就是为什么“通用存储库”反模式中的所有那些UpdateDelete方法如此危险的原因——如果这些更改是待处理的,它们很容易为每个INSERT执行42个DELETE和67个UPDATE。 - Panagiotis Kanavos
感谢您提供全面的解释! :-) - Matt

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接