解决与“insert-if-not-exists”模式相关的数据库并发问题

Joe*_*moe 4 c# sql-server concurrency entity-framework entity-framework-core

我将 Entity Framework Core 3.1.8 与 SQL Server 2016 结合使用。

考虑以下示例(为了清晰起见,进行了简化):

数据库表定义如下:

CREATE TABLE [dbo].[Product]
(
    [Id] INT IDENTITY(1,1) NOT NULL , 
    [ProductName] NVARCHAR(500) NOT NULL,
    CONSTRAINT [PK_Product] PRIMARY KEY CLUSTERED (Id ASC) WITH (FILLFACTOR=80),
    CONSTRAINT [UQ_ProductName] UNIQUE NONCLUSTERED (ProductName ASC) WITH (FILLFACTOR=80) 
)
Run Code Online (Sandbox Code Playgroud)

以及以下 C# 程序:

using System;
using System.Linq;
using System.Reflection;

namespace CcTest
{
    class Program
    {
        static int Main(string[] args)
        {
            Product product =  null;

            string newProductName = "Basketball";

            using (CcTestContext context = new CcTestContext())
            using (var transaction = context.Database.BeginTransaction())
            {
                try
                {
                    product = context.Product.Where(p => p.ProductName == newProductName).SingleOrDefault();
                    if (product is null)
                    {
                        product = new Product { ProductName = newProductName };
                        context.Product.Add(product);
                        context.SaveChanges();
                        transaction.Commit();
                    }
                }
                catch(Exception ex)
                {
                    transaction.Rollback();
                }

            }

            if (product is null)
                return -1;
            else
                return product.Id;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

在测试期间一切都按预期工作 - 如果新产品尚不存在,则将其插入到表中。因此,我希望 [UQ_ProductName] 约束永远不会受到影响,因为一切都是作为单个事务完成的。

然而,实际上这段代码是连接到Web API的业务逻辑的一部分。所发生的情况是,使用相同新产品名称的该代码的 10 个实例几乎同时执行(执行时间在百分之一秒内相同,我们将其保存在日志表中)。其中一个成功了(新产品名称被插入到表中),但其余的失败了,出现以下异常:

违反 UNIQUE KEY 约束“UQ_ProductName”。无法在对象“dbo.Product”中插入重复的键。重复的键值为(篮球)。该语句已终止。

为什么会发生这种情况?这不正是我使用交易应该防止的吗?也就是说,我认为检查具有此类值的行是否已经存在,如果不存在则插入应该是原子操作。执行第一个 API 调用并插入行后,其余 API 调用应该检测到值已存在,并且不会尝试插入重复项。

有人可以解释一下我的实现中是否有错误吗?显然它没有按照我期望的方式工作。

Joe*_*moe 6

TLDR:单独使用事务(在任何隔离级别)并不能解决问题。

问题的根本原因在这里得到了完美的解释:https ://stackoverflow.com/a/6173482/412352

当使用可序列化事务时,SQL Server 会在读取记录/表上发出共享锁。共享锁不允许其他事务修改锁定的数据(事务将阻塞),但它允许其他事务在发出锁的事务开始修改数据之前读取数据。这就是该示例不起作用的原因 - 允许使用共享锁进行并发读取,直到第一个事务开始修改数据。

下面是始终会重现问题的代码块,并且修复也在那里(已注释掉):

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace CcTest
{
    class Program
    {
        static void Main(string[] args)
        {
            object Lock = new object();

            // Parallel loop below will reproduce the issue all the time
            // (if lock (Lock) is commented out)
            // when in function AddProduct() you assign
            // newProductName value that currently doesn't exist in the database
            Parallel.For(0, 10, index =>
            {
                //lock (Lock) // uncomment this to resolve the issue
                {
                    AddProduct(index);
                }
            });

            // Sequential loop below will aways work as expected
            //for (int index = 0; index < 10; index++)
            //    AddProduct(index);

            Console.ReadKey();
        }

        static void AddProduct( int index)
        {
            Product product = null;

            string newProductName = "Basketball"; // specify something that doesn't exist in database table
            using (CcTestContext context = new CcTestContext())
            using (var transaction = context.Database.BeginTransaction())
            {
                try
                {
                    product = context.Product.FirstOrDefault(p => p.ProductName == newProductName);

                    if (product is null)
                    {
                        product = new Product { ProductName = newProductName };
                        context.Product.Add(product);
                        context.SaveChanges();
                        transaction.Commit();
                        Console.WriteLine($"API call #{index}, Time {DateTime.Now:ss:fffffff}: Product inserted. Id={product.Id}\n");
                    }
                    else
                        Console.WriteLine($"API call #{index}, Time {DateTime.Now:ss:fffffff}: Product already exists. Id={product.Id}\n");
                }
                catch(DbUpdateException dbuex)
                {
                    transaction.Rollback();

                    if (dbuex.InnerException != null)
                        Console.WriteLine($"API call #{index}, Time {DateTime.Now:ss:fffffff}: Exception DbUpdateException caught, Inner Exception Message: {dbuex.InnerException.Message}\n");
                    else
                        Console.WriteLine($"API call #{index}, Time {DateTime.Now:ss:fffffff}: Exception DbUpdateException caught, Exception Message: {dbuex.Message}\n");
                }
                catch (Exception ex)
                {
                    transaction.Rollback();
                    Console.WriteLine($"API call #{index}, Time {DateTime.Now:ss:fffffff}: Exception caught: {ex.Message}\n");
                }
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,解决该问题的方法之一是将代码放置在关键部分。

另一种方法是不将代码放在临界区中,而是捕获异常并检查它是否是DbUpdateException. 然后您可以检查内部错误消息是否包含与违反约束相关的内容,如果是,请尝试从数据库中重新读取。

另一种方法是使用原始 SQL 并指定 SELECT 锁定提示:

https://weblogs.sqlteam.com/dang/2007/10/28/conditional-insertupdate-race-condition/

请注意:任何方法都可能产生负面影响(例如性能下降)。

其他有用的页面值得一看:

https://learn.microsoft.com/en-us/aspnet/mvc/overview/getting-started/getting-started-with-ef-using-mvc/handling-concurrency-with-the-entity-framework-in- an-asp-net-mvc-application#修改部门控制器

https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/