1

概述:

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

如上所述,以下任何更改都会导致问题消失: 更改父表中 Mars 和 Earth Record 的顺序。从父表中删除 mars 记录(证明不相关的记录会影响另一个记录的更新方式。)将 Mars 记录置于 2 以外的状态。将 Update 更改为其他一些操作,例如 select。开启已提交读快照模式。

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

回购代码

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

场景准备.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?
4

2 回答 2

0

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

在行版本控制隔离级别之一下修改数据的语句的行为(轻微)记录在事务锁定和行版本控制指南中:

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

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

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

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

于 2021-04-08T10:17:21.960 回答
0

您对 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...

于 2021-01-05T17:13:41.497 回答