概述:
SQL 似乎在执行更新时从另一个会话中的不完整事务中读取“脏”信息,即使我们试图只读取已提交的数据。一些非常具体的标准必须是真实的,这个问题才会发生(很难重现 - 但我们可以在这里这样做)。
如何重现行为
SQL2017 或 SQL2019 或 Azure SQL
确定要测试的数据库并打开 Read Committed Snapshot。(设置 READ_COMMITTED_SNAPSHOT ON WITH NO_WAIT)
在数据库中运行 ScenarioPrep.SQL 脚本(如下)以创建测试对象。
- 这将创建 2 个表——一个“父”表和一个“子”表。
- 请注意,父表有两个条目,“地球”和“火星”
- 请注意,子表有 1 个条目,其父行的 id 名为“Earth”。火星没有孩子。
首先打开两个附加的 SSMS 会话,准备同时运行剩余的两个脚本(如下),其中 One.sql 在一个窗口中,Two.sql 在另一个窗口中。
- 请注意,One.sql 设置初始条件,然后在事务中包含两个更新语句。
- 请注意,Two.sql 尝试更新也在第一个脚本中更新的记录。
执行 One.sql 脚本。此脚本在事务中间有一个延迟,以帮助重现测试条件。
在 One.sql 运行时(特别是在延迟期间),在第二个窗口中执行 Two.sql。
请注意第二个窗口中的意外结果。地球记录意外更新。更新不应该成功,因为在任何时候都没有一个已提交的事务,其父“地球”记录的状态为 2,子记录的状态为 0。在事务之前,地球及其子记录都在状态为 0。交易后,地球和它的孩子都处于状态 2。
但是,Two.sql 确实成功更新了“地球”记录,因为它以某种方式将父级读取为状态为 2,而子级在延迟期间读取为状态为 0——但此时这不是一个提交的事务. 这是更新发生时不应该看到的“脏”状态。更新应该看到在另一个会话中提交事务之前或之后的状态。
预期的结果是 Two.sql 脚本不会更新任何记录,因为在任何时候都没有一个 COMMITTED 事务,其中 earth 处于状态 2 而它的孩子处于状态 0。事实上,在大多数情况下,这证明是真的。
观察
仅当以下所有条件都为真时,才会出现此问题:
- 数据库处于已提交读快照模式。
- 火星记录存在(即使它不受任何交易的影响)
- Mars 记录的状态为 2(部分匹配 Two.sql 中的 where 子句)
- Mars 记录在表中比 Earth 记录更早(具有较低的主键值)。
- 执行的操作是 UPDATE(用 select 替换更新会产生预期的结果)。
- 更新发生在跨两个或多个表的联接期间。
- Two.SQL 的事务模式是 Read Committed(具有讽刺意味的是,Read Uncommitted 正确阻塞,直到另一个事务完成并按预期工作)。
- 当然,在第一个事务中必须有足够的延迟才能发生测试场景。
如上所述,以下任何更改都会导致问题消失: 更改父表中 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?