为什么以及有时会在肯定有 Sch-M 锁的情况下出现“缺少表错误”?

Ole*_*Dok 7 sql-server-2005 sql-server

我有一个综合测试,它重现了我们在生产环境中的一些错误。以下是重现它的 2 个脚本:

第一

DBCC TRACEOFF(-1,3604,1200) WITH NO_INFOMSGS;
SET NOCOUNT ON;
IF @@TRANCOUNT > 0 ROLLBACK
IF object_id('test') IS NOT NULL 
    DROP TABLE test
IF object_id('TMP_test') IS NOT NULL 
    DROP TABLE TMP_test
IF object_id('test1') IS NOT NULL 
    DROP TABLE test1
CREATE TABLE test(Id INT IDENTITY PRIMARY KEY)
GO
INSERT test DEFAULT VALUES
GO 2000
WHILE 1 = 1
BEGIN
    CREATE TABLE TMP_test(Id INT PRIMARY KEY)
    INSERT TMP_test SELECT * FROM test

    WAITFOR DELAY '0:00:00.1'
    BEGIN TRAN

    EXEC sp_rename 'test', 'test1'
    EXEC sp_rename 'TMP_test', 'test'
    EXEC sp_rename 'test1', 'TMP_test'


    DROP TABLE TMP_test
    commit
END
Run Code Online (Sandbox Code Playgroud)

第二

SET NOCOUNT ON;

DECLARE @c INT
WHILE 1 = 1
BEGIN
    SELECT @c = COUNT(*) FROM Test IF @@ERROR <> 0 BREAK
    SELECT @c = COUNT(*) FROM Test IF @@ERROR <> 0 BREAK

    /* and repeat this 10-20 times more*/
    SELECT @c = COUNT(*) FROM Test IF @@ERROR <> 0 BREAK
END
Run Code Online (Sandbox Code Playgroud)

所以,问题是,当我在一个会话中运行第一个脚本并让它继续运行,然后在单独的会话中运行第二个脚本时,我会得到这种类型的错误:

消息 208,级别 16,状态 1,第 13 行 无效的对象名称“测试”。

问题是 -为什么我会COMMIT在第一个脚本的循环结束时看到此错误,而如果有,则永远不会得到一个ROLLBACK

我有一种感觉,它以某种方式与情况有关,当脚本提交时,仍然存在带有名称的表,test但它是一个不同的对象,第二个脚本必须重新编译自己。这是好的。但是为什么会出现漏表错误呢?AFAIK - 当我在事务中重命名表时 - 它会将 Sch-M 锁定到 tran 端?

有人可以回答或指导我阅读我可以深入阅读并理解原因的技术论文吗?

Geo*_*son 5

非常有趣和棘手的问题。我找不到任何关于这种行为的官方文档,我怀疑可能没有(尽管如果有人纠正我,我会喜欢它!)。

我的研究使我相信,正是计划编译步骤容易受到这种竞争条件的影响。请注意,我能够在没有错误的情况下运行您的测试查询一个小时,但是如果我反复启动您的流程,我有时会立即收到错误消息。当它确实遇到错误时,它总是在计划编译时立即这样做。或者,您可以将“OPTION RECOMPILE”添加到循环中的 COUNT(*),强制在每次试验时编译新计划。使用这种方法,每次运行您的脚本时,我几乎都会立即看到错误。

我能够通过一系列受控步骤重现错误,这些步骤似乎在每次试验中都会遇到错误,无需设置循环并依赖随机性。

我还提出了一个潜在的修复程序(使用 ALTER TABLE...SWITCH),这可能会在您的生产环境中试用。现在,进入细节!

以下是重现的步骤:

-- Instructions: Process each step one at a time, following any instructions in that step

-- (1) Initial setup: Clean any relics and create the test table
DBCC TRACEOFF(-1,3604,1200) WITH NO_INFOMSGS;
SET NOCOUNT ON;
IF @@TRANCOUNT > 0 ROLLBACK
IF object_id('test') IS NOT NULL 
    DROP TABLE test
IF object_id('TMP_test') IS NOT NULL 
    DROP TABLE TMP_test
IF object_id('test1') IS NOT NULL 
    DROP TABLE test1
CREATE TABLE test(Id INT IDENTITY CONSTRAINT PK_test PRIMARY KEY)
GO
INSERT test DEFAULT VALUES
GO 2000

-- (2) Run the COUNT(*)
-- This will acquire the Sch-S lock, and we use HOLDLOCK to retain that lock.
-- This simulates a race condition where this query is running at the time the first rename occurs.
BEGIN TRANSACTION
SELECT COUNT(*) FROM Test WITH (HOLDLOCK)
GO

-- (3) In another window, run the block of code that performs the renames
-- This will be blocked, waiting for a Sch-M lock on "test" until we complete step 4 below
CREATE TABLE TMP_test(Id INT PRIMARY KEY)
INSERT TMP_test SELECT * FROM test
EXEC sp_rename 'test', 'test1'
EXEC sp_rename 'TMP_test', 'test'
EXEC sp_rename 'test1', 'TMP_test'
DROP TABLE TMP_test
GO

-- (4) Now, commit the original COUNT(*) and immediately fire off the query again
-- We use OPTION (RECOMPILE) to make sure we need to compile a new query plan
-- The COMMIT releases the Sch-S lock, allowing (3) to acquire the Sch-M lock
-- This batch will now be waiting for the Sch-S lock again, on the same object_id,
-- but that object_id will no longer point to the correct object by the time the lock
-- is acquired.
COMMIT
SELECT COUNT(*) FROM Test OPTION (RECOMPILE)
GO
Run Code Online (Sandbox Code Playgroud)

使用这些步骤,我们可以深入了解导致错误的原因。为此,我对以下事件进行了跟踪:SP:StmtStarting、SP:StmtCompleted、Lock:Acquired、Lock:Released。

我发现按顺序发生以下情况(但省略了中间的一些细节):

  1. 第一个 COUNT(*) 获取 Sch-S 锁
  2. 第一个 COUNT(*) 获取一些对统计信息的锁(大概是为了构建查询计划)
  3. 第一个 COUNT(*) 释放 Sch-S 锁(计划编译结束)
  4. 第一个COUNT(*)获取IS锁(开始执行),然后运行成功
  5. sp_rename 运行到需要获取 Sch-M 锁的点,然后被第一个尚未提交的 COUNT(*) 阻塞
  6. 第一个 COUNT(*) 已提交
  7. sp_renames 获取 Sch-M 锁
  8. 第二个 COUNT(*) 请求 Sch-S 锁并添加到 sp_rename 后面的阻塞链中
  9. sp_renames 完成(此时,第二个 COUNT(*) 正在等待 Sch-S 锁以获取不再是正确 object_id 的 object_id)
  10. 第二个 COUNT(*) 获取 Sch-S 锁
  11. 第二个 COUNT(*) 获取 sys.sysschobjs 上的 Sch-S 锁,大概是作为完整性检查,因为它已检测到(或需要确认)Sch-S 锁有问题成为“测试”
  12. 抛出 Invalid object name 'Test' 错误

但是,如果在不需要计划编译时(例如在运行循环时)发生类似的事件序列,SQL Server 实际上足够智能,可以检测到 object_id 已更改。我也跟踪了那个案例,我发现了第二个 COUNT(*) 执行以下序列的情况

  1. 获取 Sch-S 锁(在曾经是“test”的 object_id 上)
  2. 获取 sys.sysschobjs 上的 Sch-S 锁
  3. 释放这两个锁
  4. 在不同的 object_id 上获取 IS 锁;“test”的新object_id!
  5. 运行成功完成

因此,正如您所假设的那样,这种自适应逻辑看起来确实到位。但看起来它可能只适用于查询执行,而不适用于查询编译。

正如所承诺的,这里是第 (3) 节的一个替代片段,它似乎提供了一种重命名表的方法来解决并发问题(至少在我的机器上!):

-- (3) In another window, run the block of code that performs the "renames"
-- Instead of sp_rename, we use ALTER TABLE...SWITCH
-- This appears to be a more robust way of performing the same logic
CREATE TABLE test1(Id INT PRIMARY KEY)
CREATE TABLE TMP_test(Id INT PRIMARY KEY)
INSERT TMP_test SELECT * FROM test
ALTER TABLE test SWITCH TO test1
ALTER TABLE TMP_test SWITCH TO test
ALTER TABLE test1 SWITCH TO TMP_test
DROP TABLE TMP_test
DROP TABLE test1
GO
Run Code Online (Sandbox Code Playgroud)

最后,这是我发现的另一个链接,它可以帮助我思考如何在名称正在更改的表的上下文中考虑锁定: