【问题标题】:Transaction Concurrency Isolation - Why can I update a subset of another transactions records?事务并发隔离 - 为什么我可以更新另一个事务记录的子集?
【发布时间】:2012-05-04 10:29:33
【问题描述】:

我试图了解我遇到的一个问题,我认为在处理使用已提交读隔离级别的事务时应该不可能。我有一个用作队列的表。在一个线程(连接 1)中,我将多批 20 条记录插入到每个表中。每批 20 条记录在一个事务中执行。在第二个线程(连接 2)中,我执行更新以更改已插入队列的记录的状态,这也发生在事务中。当同时运行时,我的期望是受更新影响的行数(连接 2)应该是 20 的倍数,因为连接 1 在事务中以 20 行的批量插入表中的行。

但我的测试表明情况并非总是如此,有时我能够从连接 1 的批次更新记录子集。这应该是可能的还是我错过了一些关于事务、并发和隔离级别的东西?下面是我创建的一组测试脚本,用于在 T-SQL 中重现此问题。

此脚本以 20 个事务批次向表中插入 20,000 条记录。

USE ReadTest
GO

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO

SET NOCOUNT ON

DECLARE @trans_id INTEGER
DECLARE @cmd_id INTEGER
DECLARE @text_str VARCHAR(4000)

SET @trans_id = 0
SET @text_str = 'Placeholder String Value'                

-- First empty the table
DELETE FROM TABLE_A

WHILE @trans_id < 1000 BEGIN
    SET @trans_id = @trans_id + 1
    SET @cmd_id = 0

    BEGIN TRANSACTION
--  Insert 20 records into the table per transaction
    WHILE @cmd_id < 20 BEGIN
        SET @cmd_id = @cmd_id + 1

        INSERT INTO TABLE_A ( transaction_id, command_id, [type], status, text_field ) 
            VALUES ( @trans_id, @cmd_id, 1, 1,  @text_str )
    END             
    COMMIT

END

PRINT 'DONE'

此脚本更新表中的记录,将状态从 1 更改为 2,然后检查更新操作中的行数。当行数不是 20 的倍数时,print 语句会指出这一点以及受影响的行数。

USE ReadTest
GO

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO

SET NOCOUNT ON
DECLARE @loop_counter INTEGER
DECLARE @trans_id INTEGER
DECLARE @count INTEGER

SET @loop_counter = 0

WHILE @loop_counter < 100000 BEGIN

    SET @loop_counter = @loop_counter + 1
    BEGIN TRANSACTION
        UPDATE TABLE_A SET status = 2 
        WHERE status = 1
            and type = 1
        SET @count = @@ROWCOUNT
    COMMIT

    IF ( @count % 20 <> 0 ) BEGIN
--      Records in concurrent transaction inserting in batches of 20 records before commit.
        PRINT '*** Rowcount not a multiple of 20. Count = ' + CAST(@count AS VARCHAR) + ' ***'
    END

    IF @count > 0 BEGIN
--      Delete the records where the status was changed.
        DELETE TABLE_A WHERE status = 2
    END
END

PRINT 'DONE'

此脚本在名为 ReadTest 的新数据库中创建测试队列表。

USE master;
GO

IF EXISTS (SELECT * FROM sys.databases WHERE name = 'ReadTest')
  BEGIN;
  DROP DATABASE ReadTest;
  END;
GO

CREATE DATABASE ReadTest;
GO

ALTER DATABASE ReadTest
SET ALLOW_SNAPSHOT_ISOLATION OFF
GO

ALTER DATABASE ReadTest
SET READ_COMMITTED_SNAPSHOT OFF
GO

USE ReadTest
GO

CREATE TABLE [dbo].[TABLE_A](
    [ROWGUIDE] [uniqueidentifier] NOT NULL,
    [TRANSACTION_ID] [int] NOT NULL,
    [COMMAND_ID] [int] NOT NULL,
    [TYPE] [int] NOT NULL,
    [STATUS] [int] NOT NULL,
    [TEXT_FIELD] [varchar](4000) NULL
 CONSTRAINT [PK_TABLE_A] PRIMARY KEY NONCLUSTERED 
(
    [ROWGUIDE] ASC
) ON [PRIMARY]
) ON [PRIMARY]

ALTER TABLE [dbo].[TABLE_A] ADD  DEFAULT (newsequentialid()) FOR [ROWGUIDE]
GO

【问题讨论】:

    标签: sql-server sql-server-2008 sql-server-2005 concurrency transactions


    【解决方案1】:

    可能会这样:

    1. writer/inserter 写入 20 行(不提交)
    2. 读取器/更新器读取一行(未提交 - 它丢弃它)
    3. 作者/插入者提交
    4. 读取器/更新器读取 19 行,这些行现在已提交,因此可见

    我相信只有可序列化的隔离级别(或更具并发性的快照隔离)才能解决此问题。

    【讨论】:

    • 如果隔离级别为 READ COMMITTED,为什么读取器/更新器会在步骤 2 中读取一行?据我了解,在隔离级别设置为 READ COMMITTED 的情况下,未提交事务中发生的更改不应该被外部事务看到/读取。
    • 是的,它们是不可见的。然而,它们存在于磁盘上,因此必须读取它们以推进表扫描!您只是看不到它们,因为它们会在扫描中立即被丢弃。
    • 但在这种情况下,我希望更新的行数为零,一旦另一个线程发出提交,更新行数应该返回 20。对于事务,它应该是全部或者没有能够读取/更新第一笔交易的记录,对吗?
    • 更新是两个步骤的序列:读取要更新的行并更新它们。这两个步骤不是原子发生的!让我们做一个思想实验:我们正在插入 100TB 的数据批次。最终提交待定。更新会话开始读取要更新的行。扫描表需要一段时间(小时)。插入会话提交的中途。现在所有 100TB 的行都突然/原子可见,但阅读会话只看到其中的一部分。在并发的情况下,已提交的读取不提供原子性!仅关于耐用性。有区别。
    【解决方案2】:

    你的期望完全错位了。您从未在查询中表达过“出列”正好 20 行的要求。 UPDATE 可以返回 0、19、20、21 或 1000 行并且所有结果都是正确的,只要 status 为 1 且 type 为 1。如果您期望“出队”按'enqueue' (在您的问题中以某种方式回避,但从未明确说明)那么您的 'dequeue' 操作必须包含 ORDER BY 子句。如果您添加了这样一个明确声明的要求,那么您期望“出队”总是返回一整批“入队”行(即 20 行的倍数)将更接近一个合理的期望。就目前的情况来看,正如我所说,完全错位了。

    更多讨论请见Using Tables as Queues

    我不应该担心当一个事务正在提交一个 批量 20 条插入记录,另一个并发事务仅 能够更新这些记录的一个子集,而不是全部 20 个?

    基本上问题归结为如果我在插入时选择,我会看到多少插入的行?。如果隔离级别被声明为 SERIALIZABLE,您只有权利担心。其他隔离级别都无法预测 在 UPDATE 运行时插入的行数将是可见的。只有 SERIALIZABLE 声明结果必须与一个接一个地运行两个语句相同(即序列化,因此得名)。虽然 如何 UPDATE '看到' INSERT 批处理的一部分的技术细节在您考虑物理顺序和缺少 ORDER BY 子句后很容易理解,但解释是无关紧要的。根本问题是期望是没有根据的。即使通过添加正确的 ORDER BY 和正确的聚集索引键(上面链接的文章解释了详细信息)来“修复”“问题”,期望仍然没有保证。 UPDATE “看到” 1、19 或 21 行仍然是完全合法的,尽管这不太可能发生。

    我想我一直将 READ COMMITTED 理解为仅读取已提交 数据,并且事务提交是一个原子操作,使所有 事务中发生的更改立即可用。

    没错。不正确的是期望 并发 SELECT(或更新)看到整个更改,而与它恰好在执行中的位置无关。打开 SSMS 查询并运行以下命令:

    use tempdb;
    go
    
    create table test (a int not null primary key, b int);
    go
    
    insert into test (a, b) values (5,0)
    go
    
    begin transaction
    insert into test (a, b) values (10,0)
    

    现在打开一个新的 SSMS 查询并运行以下命令:

    update test 
        set b=1
        output inserted.*
        where b=0
    

    这将阻止未提交的 INSERT。现在回到第一个查询并运行以下命令:

    insert into test (a, b) values (1,0)
    commit
    

    提交后,第二个 SSMS 查询将完成,它将返回两行,而不是三行。 QED。这是已提交的。您期望的是 SERIALIZABLE 执行(在这种情况下,上面的示例将死锁)。

    【讨论】:

    • 在我的示例中,我只是从队列中删除,由同一连接更新为状态 = 2 的记录。但是让我们从代码中删除删除以简化,我最初的担忧仍然存在,事实上,更新语句返回的行数不是 20 的倍数,这是将记录插入数据库的并发事务的批量大小。
    • 将更新与删除分开这一事实无关紧要。你的担心还是完全没有根据的。
    • 所以我不应该担心当一个事务提交一批 20 条插入的记录时,另一个并发事务只能更新这些记录的子集,而不是全部 20?请解释一下,我想了解这是怎么可能的。
    • 感谢您的更新。我想我一直将 READ COMMITTED 理解为仅读取已提交的数据,并且事务提交是原子操作,使事务中发生的所有更改一次可用。
    • 非常有趣。所以因为根据聚集索引(a列)在顶行之前插入了一行,但是在更新被阻塞之后,更新不影响该行,但是如果插入的行在顶行之后,那么该行是更新。使用这些信息,我应该将我的表的聚集索引更改为 transaction_id、command_id,它们会不断增加以升序插入表中的标识符。
    猜你喜欢
    • 2023-03-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-03-19
    • 1970-01-01
    • 1970-01-01
    • 2016-04-11
    相关资源
    最近更新 更多