【问题标题】:SQL Server - Inconsistencies with how transactions are handled when Read Committed Snapshot is enabledSQL Server - 启用读取已提交快照时处理事务的方式不一致
【发布时间】:2021-01-05 16:32:46
【问题描述】:

概述:

在执行更新时,SQL 似乎从另一个会话中的不完整事务中读取“脏”信息,即使我们试图仅读取已提交的数据。一些非常具体的 发生此问题的标准必须为真(很难重现 - 但我们可以在此处重现)。

如何重现行为

SQL2017 或 SQL2019 或 Azure SQL

  1. 确定一个用于测试的数据库并打开 Read Committed Snapshot。 (设置 READ_COMMITTED_SNAPSHOT ON WITH NO_WAIT)

  2. 在数据库中运行 ScenarioPrep.SQL 脚本(如下)以创建测试对象。

    • 这将创建 2 个表 - 一个“父”表和一个“子”表。
    • 请注意,父表有两个条目,“地球”和“火星”
    • 请注意,子表有 1 个条目,其 id 名称为“Earth”的父行。火星没有孩子。
  3. 准备同时运行剩余的两个脚本(如下),首先打开两个额外的 SSMS 会话,One.sql 在一个窗口中,Two.sql 在另一个窗口中。

    • 请注意,One.sql 设置初始条件,然后将两个更新语句包装在一个事务中。
    • 请注意,Two.sql 尝试更新也在第一个脚本中更新的记录。
  4. 执行 One.sql 脚本。此脚本在事务中间有一个延迟,以帮助重现测试条件。

  5. 在 One.sql 运行时(特别是在延迟期间),在第二个窗口中执行 Two.sql。

  6. 注意第二个窗口中的意外结果。地球记录意外更新。更新不应该成功,因为在任何时候都没有一个已提交的事务,其父“地球”记录的状态为 2,子记录的状态为 0。在事务之前,地球及其子记录都在状态为0。交易后,地球和它的孩子都处于状态2。

但是,Two.sql 确实成功更新了“地球”记录,因为它以某种方式将父级读取为状态 2,而子级在延迟期间读取为 0 状态——但这不是已提交的事务此时。这是更新发生时不应该看到的“脏”状态。更新应该看到在另一个会话中提交事务之前或之后的状态。

预期的结果是 Two.sql 脚本不会更新任何记录,因为在任何时候都没有一个 COMMITTED 事务,其中 earth 处于状态 2 而其子级处于状态 0。事实上,在大多数情况下条件,这证明是正确的。

观察

仅当以下所有条件都为真时才会出现问题:

  1. 数据库处于读取提交快照模式。
  2. 存在 Mars 记录(即使它不受任何交易的影响
  3. Mars 记录的状态为 2(部分匹配 Two.sql 中的 where 子句)
  4. Mars 记录在表中比 Earth 记录更早(主键值较低)。
  5. 执行的操作是 UPDATE(将更新替换为 select 会产生预期的结果)。
  6. 在连接两个或多个表的过程中发生更新。
  7. Two.SQL 的事务模式是 Read Committed(具有讽刺意味的是,Read Uncommitted 会正确阻塞,直到另一个事务完成并按预期工作)。
  8. 当然,在第一个事务中必须有足够的延迟才能发生测试场景。

如上所述,以下任何更改都会导致问题消失: 更改父表中火星和地球记录的顺序。 从父表中删除 mars 记录(证明不相关的记录会影响另一个记录的更新方式。) 将火星记录置于 2 以外的状态。 将更新更改为其他一些操作,例如选择。 开启 Read Committed Snapshot 模式。

当然,从 One.sql 中的两个更新语句中删除事务会导致问题一直出现,但在这种情况下这是可以预料的。将两个更新语句包装在事务中的全部目的是避免这种情况。

回购代码

以下是重现该行为所需的三个代码文件:

ScenarioPrep.sql:

drop table if exists dbo.parent
drop table if exists dbo.child

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Parent](
    Id int NOT NULL,
    [Name] [varchar](100) NULL,
    [StatusID] [int] NULL
 CONSTRAINT [PK_Parent] PRIMARY KEY CLUSTERED 
(
    [Id] ASC
)WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO



SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Child](
    
    ID INT NOT NULL,
    [ParentID] INT NULL,
    [StatusID] [int] NULL,
 CONSTRAINT [PK_Child] PRIMARY KEY CLUSTERED 
(
    [Id] ASC
)WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

INSERT INTO dbo.parent(ID,name,statusid)
values
    (200,'Earth',0),--"Earth" must have an id (guid or int) greater than "Mars" for behavior to occur. Use comparison to know which guid is greater. String compare may give different results.
    (100,'Mars',0)
    
    
    

INSERT INTO dbo.child(ID,ParentID,StatusID)
VALUES
    (201,(SELECT ID from dbo.parent where name = 'Earth'),0)
    
    
GO

一个.sql:

--Reset test scenario - put only mars in a status of 2. 

update dbo.parent set statusid = 2 where name = 'Mars'--make sure mars partially matches where clause
update dbo.parent set statusid = 0 where name = 'Earth'--make sure earth starts in 0 status
update dbo.child set statusid = 0 --make sure earth's child starts in 0

--Here's where the actual test begins.
--Note: At this point, before the transaction below, the commited transactions have earth and its child both in a status of 0

        begin transaction --wrap in a transaction. We want all or nothing here.
         
             update dbo.parent set statusid = 2 where name = 'Earth'

             waitfor delay '00:00:15';
             --Note: At this point, only uncommited/dirty data shows earth in 2 and its child in 0. There are no commited transactions reflecting this state.
             --it is during this moment we rung BugTwo.sql
             update c set statusid = 2 from dbo.child c
             inner join dbo.parent p
             on c.parentid = p.id


        commit transaction
--Note: after the transaction, earch and it's child are both in a status of 2.
--If we are reading only commited data, we should not see a scenario where earth is 2 and its child is 0.

两个.Sql:

 --Note: make sure read committed snapshot is on before running these tests.

--read only commited data...

--run this while one.sql is running!!

set transaction isolation level read committed
        
    update p set statusid = 1300--a status that will only occurr if the conditions below are met.
    --select * --replacing the update with a select causes the strange behavior to go away.
    from dbo.parent p
    inner join child c
    on p.id = c.parentid
    where p.statusid=2 --where earth is 2
    and c.statusid=0 --and its child is 0
    --note that there are no committed transactions that reflect this scenario. We would expecte this not to update any records.
    
    
        
--yet earth gets updated...
SELECT * FROM dbo.parent where statusid = 1300
--we are attempting to read committed data only, so why did we see and uncommited scenario?

【问题讨论】:

    标签: sql-server transactions read-committed-snapshot


    【解决方案1】:

    您对 READ COMMITTED TRANSACTION ISOLATION 状态的作用感到困惑......

    此隔离级别保证您不会有一些脏读,但此隔离级别引起的锁仅适用于每个语句,而不适用于所有事务。

    通过使用 REPEATABLE READ 隔离级别,锁定的行将得到维护,直到事务完成。

    我在工程课程中系统地使用以下测试来展示这些不同级别的隔离: https://sqlpro.developpez.com/isolation-transaction/

    步骤 0 - 在 SSMS 窗口中 - 创建数据库:

    USE master
    GO
    
    IF EXISTS (SELECT *
               FROM   master.sys.databases
               WHERE  name = 'DB_ISO_LEVEL')
       DROP DATABASE DB_ISO_LEVEL
    GO
    
    CREATE DATABASE DB_ISO_LEVEL
    GO
    
    USE DB_ISO_LEVEL
    GO
    
    CREATE TABLE T_ISO ( COL INT)
    GO
    
    INSERT INTO T_ISO VALUES (1)
    INSERT INTO T_ISO VALUES (2)
    INSERT INTO T_ISO VALUES (3)
    GO
    
    USE master
    GO
    

    过去并立即运行它...

    第 1 步 - 在另一个 SSMS 窗口中 - 第一个事务:

    USE DB_ISO_LEVEL
    GO
    
    SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED 
    BEGIN TRANSACTION TRAN1
    
    DECLARE @TOTAL INT
    
    SELECT @TOTAL = SUM(COL)
    FROM   T_ISO
    
    WAITFOR DELAY '00:00:20'
    
     
    Sélectionnez
    
    SELECT @TOTAL = @TOTAL - SUM(COL)
    FROM   T_ISO
    
    SELECT @TOTAL AS TOTAL
    
    COMMIT TRANSACTION
    SET TRANSACTION ISOLATION LEVEL READ COMMITTED
    
    USE master
    GO
    

    还没有运行... 第 2 步 - 第二个事务 - 在第三个 SSMS 窗口中

    USE DB_ISO_LEVEL
    GO
    
    BEGIN TRANSACTION TRAN2
    
    UPDATE T_ISO
    SET    COL = COL + 1
    
    WAITFOR DELAY '00:00:20'
    
    ROLLBACK TRANSACTION
    
    USE master
    GO
    

    现在运行第 1 步和 5 秒后的第 2 步,这将产生脏读(预期值为 0...)

    运行数据库删除和创建(窗口 1 中的步骤 0)。

    通过READ COMMITTED修改第1步的隔离级别并运行第1步,5秒后第2步。脏读消失,值真的是0...

    【讨论】:

    • 也许您来自 Oracle 或 PostGreSQL,无法正确运行此事务隔离级别并改为执行 REPEATBALE READ...
    • 您的答案适用于锁定已提交的读取隔离,而不是已提交的读取快照隔离 (RCSI),这就是问题所在。跨度>
    【解决方案2】:

    这不是错误,但可能违反直觉。然而,它是正确性所必需的。

    在行版本控制隔离级别之一下修改数据的语句的行为(轻微)记录在Transaction Locking and Row Versioning Guide

    在使用行版本控制的已提交读事务中,选择要更新的行是使用阻塞扫描完成的,其中在读取数据值时在数据行上获取更新 (U) 锁。这与不使用行版本控制的已提交读事务相同。如果数据行不满足更新条件,则释放该行的更新锁,并锁定并扫描下一行。

    在您的示例中,会话 2 中的更新使用更新语义读取 表,以确保我们使用最新提交的状态修改记录。这是避免丢失更新所必需的。因此,更新会看到 parent 记录,因为它是在会话 1 中的事务提交之后。

    这仅适用于 parent 表,因为它是更新的目标。会话 2 中的 child 表是使用读取提交的快照语义(在语句开始时提交的版本)读取的,因为它不是更新的目标。

    您可以在 Paul White 的 Data Modifications under Read Committed Snapshot Isolation 中找到更多详细信息。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2013-08-16
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-07-27
      • 2020-01-08
      • 2011-02-14
      相关资源
      最近更新 更多