6

我在一个系统中遇到了一个有趣的问题,由于架构更改,单个线程中的第一个数据库事务阻止了第二个数据库事务完成,直到发生超时。

为了测试这一点,我创建了一个测试数据库:

CREATE DATABASE StackOverflow
GO

USE StackOverflow

ALTER DATABASE StackOverflow SET ALLOW_SNAPSHOT_ISOLATION ON
ALTER DATABASE StackOverflow 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, '')

在第一个测试中,修改表 One 中的行的事务已启动,但尚未提交。另一个事务正在插入到表 2 中,引用同一行的列在表 1 的第一个事务中被修改。第二个事务将永远挂起,直到第一个事务被提交。

SQL Management Studio 测试事务挂起

事务等待的原因是由于第一个事务持有的 LCK_M_S 键锁。

SQL Management Studio 活动监视器 LCK_M_S 键锁

在我的第二个测试中,一个修改表 3 中一行的事务已启动,但尚未提交,就像在第一个测试中一样。另一个事务正在插入到表 4 中,引用同一行的列在表 3 的第一个事务中被修改。除了这一次,表四引用表三中的代理键而不是主键。交易立即完成,不受第一笔交易的影响。

SQL Management Studio 测试事务挂起没有问题

我需要帮助理解为什么在引用在第一个事务中修改的表的单独表中插入一行时,后一个事务总是被前一个事务阻止。我认为明显无益的答案是由于外键约束。但为什么?尤其是因为这是快照隔离,为什么后者的事务完全关心前者呢?它引用的行已经存在,并且外键可以很容易地验证,正如第二个测试所证明的那样,其中引用代理键的外键可以顺利完成。

4

1 回答 1

5

答案相当简单。

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

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

其次,更新不会影响 FK 中涉及的唯一密钥。更新能够在目标键值上放置共享锁,因为更新会话对不同唯一索引中的键具有独占键锁。

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

消息 3960,级别 16,状态 2,第 10 行快照隔离事务由于更新冲突而中止。您不能使用快照隔离直接或间接访问数据库“StackOverflow”中的表“dbo.One”来更新、删除或插入已被另一个事务修改或删除的行。重试事务或更改更新/删除语句的隔离级别。

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

这可能有点棘手,因为 SNAPSHOT 事务并没有真正在您运行 BEGIN TRANSACTION(有点像隐式事务)的时间点开始,相关的时间点是事务的时间点首先读取或更改数据库。例如

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
于 2018-11-09T15:55:33.593 回答