多表复杂实体的乐观并发

Posted

技术标签:

【中文标题】多表复杂实体的乐观并发【英文标题】:Optimistic concurrency on multi-table complex entity 【发布时间】:2014-03-02 11:20:45 【问题描述】:

我有一个复杂的实体(我们称之为 Thing),它在 SQL Server 中表示为多个表:一个父表 dbo.Thing 和多个子表 dbo.ThingBodyPartdbo.ThingThought 等。我们已经实现使用dbo.Thing 上的单个rowversion 列的乐观并发,使用UPDATE OUTPUT INTO technique。这一直很好,直到我们向dbo.Thing 添加了一个触发器。我正在寻求有关选择不同方法的建议,因为我相当确信我目前的方法无法修复。

这是我们当前的代码:

CREATE PROCEDURE dbo.UpdateThing
    @id uniqueidentifier,
    -- ...
    -- ... other parameters describing what to update...
    -- ...
    @rowVersion binary(8) OUTPUT
AS
BEGIN TRANSACTION;
BEGIN TRY

    -- ...
    -- ... update lots of Thing's child rows...
    -- ...

    DECLARE @t TABLE (
        [RowVersion] binary(8) NOT NULL
    );

    UPDATE dbo.Thing
    SET ModifiedUtc = sysutcdatetime()
    OUTPUT INSERTED.[RowVersion] INTO @t
    WHERE
        Id = @id
        AND [RowVersion] = @rowVersion;

    IF @@ROWCOUNT = 0 RAISERROR('Thing has been updated by another user.', 16, 1);

    COMMIT;

    SELECT @rowVersion = [RowVersion] FROM @t;

END TRY
BEGIN CATCH
    IF @@TRANCOUNT > 0 ROLLBACK;
    EXEC usp_Rethrow_Error;
END CATCH

在我们将INSTEAD OF UPDATE 触发器添加到dbo.Thing 之前,这非常有效。现在存储过程不再返回新的@rowVersion 值,而是返回未修改的旧值。我不知所措。是否有其他方法可以像上述方法一样有效和简单,但也可以与触发器一起使用?


为了说明这段代码究竟出了什么问题,请考虑以下测试代码:

DECLARE
    @id uniqueidentifier = 'b0442c71-dbcb-4e0c-a178-1a01b9efaf0f',
    @oldRowVersion binary(8),
    @newRowVersion binary(8),
    @expected binary(8);

SELECT @oldRowVersion = [RowVersion]
FROM dbo.Thing
WHERE Id = @id;

PRINT '@oldRowVersion = ' + convert(char(18), @oldRowVersion, 1);

DECLARE @t TABLE (
    [RowVersion] binary(8) NOT NULL
);

UPDATE dbo.Thing
SET ModifiedUtc = sysutcdatetime()
OUTPUT INSERTED.[RowVersion] INTO @t
WHERE
    Id = @id
    AND [RowVersion] = @oldRowVersion;

PRINT '@@ROWCOUNT = ' + convert(varchar(10), @@ROWCOUNT);

SELECT @newRowVersion = [RowVersion] FROM @t;

PRINT '@newRowVersion = ' + convert(char(18), @newRowVersion, 1);

SELECT @expected = [RowVersion]
FROM dbo.Thing
WHERE Id = @id;

PRINT '@expected = ' + convert(char(18), @expected, 1);

IF @newRowVersion = @expected PRINT 'Pass!'
ELSE PRINT 'Fail.  :('

当触发器不存在时,此代码正确输出:

@oldRowVersion = 0x0000000000016CDC

(1 row(s) affected)
@@ROWCOUNT = 1
@newRowVersion = 0x000000000004E9D1
@expected = 0x000000000004E9D1
Pass!

当触发器存在时,我们没有收到预期值:

@oldRowVersion = 0x0000000000016CDC

(1 row(s) affected)

(1 row(s) affected)
@@ROWCOUNT = 1
@newRowVersion = 0x0000000000016CDC
@expected = 0x000000000004E9D1
Fail.  :(

对于不同的方法有什么想法吗?

我假设 UPDATE 是一个原子操作,它是,除非有触发器,但显然不是。我错了吗?在我看来,这似乎真的很糟糕,每个语句背后都潜藏着潜在的并发错误。如果触发器真的是INSTEAD OF,我不应该取回正确的时间戳,就好像触发器的UPDATE 是我实际执行的那样吗?这是 SQL Server 错误吗?

【问题讨论】:

【参考方案1】:

我的一位尊敬的同事Jonathan MacCollum 向我指出了这个bit of documentation:

插入

是一个列前缀,指定插入或更新操作添加的值。 以 INSERTED 为前缀的列反映了 UPDATE、INSERT 或 MERGE 语句完成之后但在触发器执行之前的值。

据此,我假设我需要修改我的存储过程,将UPDATE 拆分为UPDATE,然后是SELECT [RowVersion] ...

UPDATE dbo.Thing
SET ModifiedUtc = sysutcdatetime()
WHERE
    Id = @id
    AND [RowVersion] = @rowVersion;

IF @@ROWCOUNT = 0 RAISERROR('Thing has been updated by another user.', 16, 1);

COMMIT;

SELECT @rowVersion = [RowVersion]
FROM dbo.Thing
WHERE Id = @id;

我认为我仍然可以放心,我的存储过程不会意外覆盖任何其他人的更改,但我不应再假设存储过程的调用者持有的数据仍然是最新的。存储过程返回的新@rowVersion 值有可能实际上是其他人更新的结果,而不是我的。所以实际上,根本没有返回@rowVersion 的意义。执行此存储过程后,调用者应重新获取Thing 及其所有子记录,以确保其数据图片一致。

... 这进一步让我得出结论,rowversion 列不是实现乐观锁定的最佳选择,遗憾的是,这是它们的唯一目的。使用手动递增的int 列会更好,查询如下:

UPDATE dbo.Thing
SET Version = @version + 1
WHERE
    Id = @id
    AND Version = @version;

Version 列在单个原子操作中被检查递增,因此其他语句没有机会在其间溜走。我不必询问数据库是什么新值,因为我告诉它是什么新值。只要Version 列包含我期望的值(并且假设所有其他更新此行的人也在遵守规则 - 正确递增Version),我可以知道Thing 仍然完全一样我离开它。至少,我认为……

【讨论】:

以上是关于多表复杂实体的乐观并发的主要内容,如果未能解决你的问题,请参考以下文章

您如何将乐观并发与 WebAPI OData 控制器一起使用

如何解决空数据库表上的乐观并发异常错误

通过乐观锁(版本号)降低并发时的锁竞争问题

并发编程--锁

Elasticsearch 基于乐观锁的并发控制 --- 2022-04-03

ElasticSearch 并发冲突+悲观锁与乐观锁+基于_version和external version进行乐观锁并发控制