在 EF6 中,如何确保序列化事务?

时间:2021-01-28 20:00:08

标签: c# .net entity-framework

我们有一个使用 Entity Framework 6 的 ASP.NET/MVC 4.5 网站,我们遇到了一个问题,即多个用户试图创建新的事件记录,但由于他们试图使用相同的事件编号而发生冲突。< /p>

上下文有点复杂。

我们有一个 [事件] 记录,其中包含有关事件的元数据:

CREATE TABLE [dbo].[Incident] (
    [customerid] [nvarchar](32) NOT NULL,
    [incidentnumber] [nvarchar](32) NOT NULL,
    [currentrevisionnumber] [int] NOT NULL,
    CONSTRAINT [PK_IMincident] PRIMARY KEY CLUSTERED 
    (
        [customerid] ASC,
        [incidentnumber] ASC
    )
)

但是关于[事件]的大部分数据都包含在[事件内容]记录中:

CREATE TABLE [dbo].[IncidentContent] (
[customerid] [nvarchar](32) NOT NULL,
[incidentnumber] [nvarchar](32) NOT NULL,
[revisionnumber] [int] IDENTITY(1,1) NOT NULL,
// assorted fields recording information about the incident.
CONSTRAINT [PK_IMincidentContent] PRIMARY KEY CLUSTERED 
(
    [customerid] ASC,
    [incidentnumber] ASC,
    [revisionnumber] ASC
)

请注意,[revisionnumber] 是一个 IDENTITY 字段,但它本身并不是主键。不要问我为什么。

此设计背后的目的是维护关于事件记录的数据如何演变的完整历史记录。一旦写入,事件内容记录就永远不会改变。更新时会创建一个新的 IncidentContent 记录,带有新的 [revisionnumber],并且 [Incident].[currentrevisionnumber] 设置为新的 [revisionnumber]。

[customerid] 很简单,它表明这个数据是针对哪个客户的。我们有多个客户使用此系统。

[事件编号] 更复杂。它是一个生成的标识符,YYDDDNNN,其中 YY 是年份的最后两位数字,DDD 是一年中的第几天,NNN 是一个序列号,每天从 1 开始,对于每个客户。

为了跟踪我们有一个 [IncidentSequence] 表:

CREATE TABLE [dbo].[IncidentSequence] (
    [customerid] [nvarchar](32) NOT NULL,
    [sequence] [bigint] NOT NULL,
    [lastupdatedtutc] [datetime] NOT NULL,
    CONSTRAINT [incidentPrimaryKey] PRIMARY KEY CLUSTERED 
    (
    [customerid] ASC,
    [sequence] ASC
)

然后我们有一个使用该表生成下一个[事件编号]的存储过程:

CREATE PROCEDURE [dbo].[sp_getNextIncidentNumber]

    @customerid NVARCHAR(32),
    @currentdate DATETIME,
    @incidentnumber NVARCHAR(12) OUTPUT
AS

BEGIN

DECLARE 

    @yr AS CHAR(2),
    @cnt AS TINYINT,
    @doy AS VARCHAR(3),
    @sequence AS VARCHAR(3),
    @cdate AS DATETIME,
    @ldate AS DATETIME;

    IF @customerid IS NULL 
      SET @customerid = 'SYSTEM';
    IF @currentdate IS NULL 
      SET @currentdate = GETUTCDATE();
    
    SET @yr = (YEAR( GETDATE() ) % 100 );
    SET @cdate = @currentdate;
    SET @doy = DATEDIFF(day,STR(YEAR(@cdate),4)+'0101',@cdate)+1;
    SET @doy = REPLICATE('0', 3 - LEN(@doy)) + @doy;


    /* Check to see if customer ID exists */
    SELECT @cnt = COUNT(*) FROM [IncidentSequence]
    WHERE [customerid] = @customerid;
    
    /*NEW CUSTOMER ID*/
    IF (@cnt = 0)
    BEGIN
     INSERT INTO [IncidentSequence]([customerid], [sequence], [lastupdatedtutc])
       VALUES(@customerid, 0, @cdate);
    END
    /*NEW CUSTOMER ID*/

    /*DETERMINE IF WE NEED TO RESET THE SEQUENCE NUMBER*/
    SELECT @ldate = [lastupdatedtutc], @sequence = [sequence]
    FROM [IncidentSequence]
    WHERE customerid = @customerid;
    
    IF DATEADD(dd, 0, DATEDIFF(dd, 0, @cdate)) <> DATEADD(dd, 0, DATEDIFF(dd, 0, @ldate))
    BEGIN
        UPDATE [IncidentSequence] 
        SET [sequence] = 1,
        [lastupdatedtutc] = @cdate
        WHERE [customerid] = @customerid;
    END
    ELSE 
    BEGIN    
    
        /* UPDATE THE SEQUENCE */
        UPDATE [IncidentSequence] 
        SET [sequence] = [sequence] + 1,
        [lastupdatedtutc] = @cdate
        WHERE [customerid] = @customerid;
    END

    SELECT @sequence = [sequence]
    FROM [IncidentSequence]
    WHERE [customerid] = @customerid;

    SET @sequence = REPLICATE('0', 3 - LEN(@sequence)) + @sequence;

    SET @incidentnumber = @yr + @doy + @sequence;

    RETURN

END

GO

最后,我们有 C# 代码,尝试获取新的 [事件编号],编写新的 [事件内容],从新的 [事件内容] 中获取 [修订编号],并编写新的 [事件],设置其[currentrevisionnumber],在单个事务中,以便创建两个记录并更新 IncidentSequence,或者既不创建记录又不更新 IncidentSequence。

public void InsertIncidentModel(Incident incident, IncidentContent incidentContent, 
    string customerid, DateTime? localDt)
{
    using (var db = new IncidentsDbContext())
    {
        using (var transaction = db.Database.BeginTransaction())
        {
            try
            {
                var incidentnumberParameter = new ObjectParameter("incidentnumber", typeof(string));
                db.sp_getNextIncidentNumber(customerid, localDt, incidentnumberParameter);
                var incidentnumber = incidentnumberParameter.Value as string;

                incident.currentrevisionnumber = -1;
                incident.incidentnumber = incidentnumber;
                db.IMincidents.Add(incident);

                incidentContent.incidentnumber = incidentnumber;
                db.IMincidentContents.Add(incidentContent);
                db.SaveChanges();

                incident.currentrevisionnumber = incidentContent.revisionnumber;
                db.SaveChanges();

                transaction.Commit();
            }
            catch (Exception ex)
            {
                transaction.Rollback();
                this._logger.logException(ex, "Exception caught in transaction, rolling back");
                throw;
            }
        }
    }
}

我们的问题是,当两个用户同时创建事件时,他们有时似乎以相同的事件编号结束,从而导致错误。

很明显,本文的原作者希望将其包装在事务中可以防止这种情况发生 - SQL 的锁定会阻止一个事务,直到另一个事务完成。在这种情况下,我不确定是不是这种情况。

运行此 C# 代码的两个事务如何导致具有相同 [incidentnumber] 的两个不同事件?

我该如何解决这个问题?

我们从 [IncidentSequence] 中读到的有两个地方:

/* Check to see if customer ID exists */
SELECT @cnt = COUNT(*) FROM [IncidentSequence]
WHERE [customerid] = @customerid;

...

/*DETERMINE IF WE NEED TO RESET THE SEQUENCE NUMBER*/
SELECT @ldate = [lastupdatedtutc], @sequence = [sequence] FROM [IncidentSequence]
WHERE [customerid] = @customerid;

我在想,也许我们用一个read替换它们,并检查@ldate是否为NULL,以确定我们是否需要插入一条记录,然后添加一个UPDLOCK提示?

SELECT @ldate = [lastupdatedtutc], @sequence = [sequence]
FROM [IncidentSequence] WITH (UPDLOCK)
WHERE [customerid] = @customerid;

这应该对 [IncidentSequence] 记录保持锁定,直到事务结束。

想法?

我是否需要为事务设置不同的隔离级别?

using (var db = new IncidentsDbContext())
{
    using (var transaction = db.Database.BeginTransaction(IsolationLevel.Serializable))
    {

1 个答案:

答案 0 :(得分:1)

您需要使用限制性锁读取表以正确序列化事务。通常,您在事务的第一次查询时执行此操作,但这里仅靠 UPDLOCK 是不够的。它还需要holdlock。例如

/* Check to see if customer ID exists */
SELECT @cnt = COUNT(*) FROM [IncidentSequence] with (updlock,holdlock)
WHERE [customerid] = @customerid;

这将使用限制性 U 锁读取,而不是使用行版本或宽松 S 锁。即使在该@customerid 没有当前行的情况下,holdlock 也会强制范围锁定。

您也可以将 BEGIN TRAN/COMMIT TRAN 添加到存储过程,以防客户端尚未启动事务,或者使用错误强制执行,例如

if @@trancount = 0 
 throw 60000, 'This procedure must be called with a tranaction', 1

<块引用>

我是否需要为事务设置不同的隔离级别?

没有。没有隔离级别会导致读取器阻塞读取器。 SERIALIZABLE 将“解决”问题,但只能通过创建死锁来强制事务的序列化,这很不方便。但 SERIALIZABLE + 死锁重试也是一个解决方案。