为什么在另一个快照隔离事务中插入具有引用行的外键的行会导致事务挂起?

Why does inserting a row with a foreign key referencing a row by pk modified in another snapshot isolation transaction cause the transaction to hang?

我 运行 在系统中遇到一个有趣的问题,由于模式更改,单个线程中的第一个数据库 t运行saction 阻塞第二个数据库 t运行saction从完成,直到发生超时。

为了对此进行测试,我创建了一个测试数据库:

CREATE DATABASE Whosebug
GO

USE Whosebug

ALTER DATABASE Whosebug SET ALLOW_SNAPSHOT_ISOLATION ON
ALTER DATABASE Whosebug SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE
GO

CREATE TABLE One (
    Id int CONSTRAINT pkOne PRIMARY KEY,
    A varchar(10) NOT NULL
)

CREATE TABLE Two (
    Id int CONSTRAINT pkTwo PRIMARY KEY,
    B varchar(10) NOT NULL,
    OneId int NOT NULL CONSTRAINT fkTwoToOne REFERENCES One
)
GO

-----------------------------------------------

CREATE TABLE Three (
    Id int CONSTRAINT pkThree PRIMARY KEY,
    SurrogateId int NOT NULL CONSTRAINT ThreeSurrUnique UNIQUE,
    C varchar(10) NOT NULL
)
GO

CREATE TABLE Four (
    Id int CONSTRAINT pkFour PRIMARY KEY,
    D varchar(10) NOT NULL,
    ThreeSurrogateId int NOT NULL CONSTRAINT fkFourToThree REFERENCES Three(SurrogateId)
)
GO

--Seed data
INSERT INTO One (Id, A) VALUES (1, '')
INSERT INTO Three (Id, SurrogateId, C) VALUES (3, 50, '')

在第一个测试中,修改 table 中的行的 t运行saction 已启动,但尚未提交。另一个 t运行saction 正在插入到 table Two 中,引用同一行的列在 table One 中的第一个 t运行saction 中被修改。第二个 t运行saction 将永远挂起,直到第一个 t运行saction 被提交。

t运行saction 等待的原因是第一个 t运行saction 持有 LCK_M_S 键锁。

在我的第二个测试中,一个 t运行saction 修改了 table Three 中的一行已启动,但尚未提交,就像在第一个测试中一样。另一个 t运行saction 正在插入 table Four,其中引用同一行的列在 table Three 的第一个 t运行saction 中被修改。除了这一次,table Four 引用 table Three 中的代理键而不是主键。 t运行saction 立即完成,不受第一个 t运行saction 的影响。

我需要帮助来理解为什么当在单独的 table 中插入一行引用 table 在第一个 t运行 操作中被修改。我认为明显无用的答案是因为外键约束。但为什么?特别是因为这是快照隔离,为什么后者 t运行saction 根本不关心前者?它引用的行已经存在并且可以轻松验证外键,正如第二个测试所证明的那样,其中引用代理键的外键可以毫无阻碍地完成。

答案很简单。

当查询读取以验证外键约束时,它们总是使用锁,从不使用行版本控制。想象一下,如果一个事务正在更改 PK 值,并且并发会话插入了引用 old PK 值的行。不允许根据版本存储中行的一致 版本来验证 FK 约束。如果是,那么当提交 PK 更改时,所有 FK 都必须再次验证。

在第一种情况下,更新事务在 FK 的目标索引上有一个键锁,因此并发会话无法读取 PK 值。

第二种,更新不影响FK中涉及的唯一键。更新能够在目标键值上放置一个共享锁,因为更新会话在不同唯一索引中的键上有一个独占键锁。

在第一个事务提交后的第一个示例中,第二个事务因快照隔离更新冲突而失败:

Msg 3960, Level 16, State 2, Line 10 Snapshot isolation transaction aborted due to update conflict. You cannot use snapshot isolation to access table 'dbo.One' directly or indirectly in database 'Whosebug' to update, delete, or insert the row that has been modified or deleted by another transaction. Retry the transaction or change the isolation level for the update/delete statement.

这是因为在 SNAPSHOT 隔离中,您无法读取自事务开始后已更改的行。由于 FK 验证不能使用行版本,因此它需要从事务开始 后更新的行中读取 PK。这违反了 SNAPSHOT 隔离,因为 PK 值 可能 在 SNAPSHOT 事务开始时不存在。

这可能有点难看,因为 SNAPSHOT 交易实际上 在您 运行 开始交易 (有点像隐式事务)相关的时间点是事务第一次读取或更改数据库时的时间点。 EG

if @@trancount > 0 rollback
go
set transaction isolation level snapshot
begin transaction

drop table if exists t
create table t(id int)

--in another session run
--update one set a = a+'b' where id = 1

waitfor delay '0:0:10'

insert into two(id,b,oneid) values (2,'',1) -- fails