我可以依靠顺序读取SQL Server身份值吗?


24

TL; DR:下面的问题归结为:插入行时,在生成Identity值和锁定聚集索引中的相应行键之间是否存在机会之窗,外部观察者可以看到更新的值 Identity并发交易插入的价值?(在SQL Server中。)

详细版本

我有一个带有Identity名为的列的SQL Server表CheckpointSequence,这是表的聚集索引(也具有许多其他非聚集索引)的键。通过多个并发进程和线程将行插入表中(处于隔离级别READ COMMITTED,并且没有IDENTITY_INSERT)。同时,有些进程会定期从聚簇索引中读取行,并按该CheckpointSequence列排序(也处于隔离级别READ COMMITTED,并且该READ COMMITTED SNAPSHOT选项处于关闭状态)。

我目前依靠这样的事实,即读取过程永远不会“跳过”检查点。我的问题是:我可以依靠这个财产吗?如果没有,我该怎么做才能实现?

示例:当插入具有标识值1、2、3、4和5 的行时,阅读器在看到具有值4的行之前必须看不到具有值5的行。测试表明该查询包含一个ORDER BY CheckpointSequence子句(和一个WHERE CheckpointSequence > -1子句),只要要读取第4行但尚未提交,就可靠地阻塞,即使第5行已经提交。

我认为至少从理论上讲,这里可能存在一种竞争条件,可能会导致这一假设被打破。不幸的是,关于多个并发事务的工作方式的文档Identity并没有太多说明Identity,而只是说“每个新值都是基于当前的种子和增量生成的”。和“特定交易的每个新值都与表上的其他并发交易不同”。(MSDN

我的推理是,它必须以这种方式工作:

  1. 事务开始(显式或隐式)。
  2. 生成标识值(X)。
  3. 根据身份值在聚集索引上获取相应的行锁(除非发生锁升级,在这种情况下整个表都被锁定)。
  4. 该行已插入。
  5. 事务已提交(可能要花很多时间),因此将再次删除该锁。

我认为在第2步和第3步之间,有一个很小的窗口,

  • 并发会话可以生成下一个标识值(X + 1)并执行所有其余步骤,
  • 因此,允许阅读者恰好在该时间点阅读值X + 1,而忽略了X的值。

当然,这种可能性似乎很小。但仍然-可能会发生。可以吗

(如果您对上下文感兴趣:这是NEventStore的SQL Persistence Engine的实现。NEventStore实现一个仅附加事件存储,其中每个事件都获得一个新的,升序的检查点序列号。客户端从按检查点排序的事件存储中读取事件为了执行各种计算,一旦处理了带有检查点X的事件,客户就只考虑“较新的”事件,即带有检查点X + 1及更高版本的事件,因此,至关重要的是永远不能跳过事件,因为它们再也不会被考虑了,我目前正在尝试确定Identity基于-checkpoint的实现是否满足此要求。这些是所使用确切SQL语句SchemaWriter的query读者查询。)

如果我是对的,并且可能出现上述情况,那么我只能看到两种处理方式,但都不令人满意:

  • 在看到X之前看到检查点序列值X + 1时,关闭X + 1并稍后再试。但是,由于Identity当然会产生间隙(例如,当事务回滚时),因此X可能永远不会出现。
  • 因此,相同的方法,但是在n毫秒后接受间隔。但是,我应该假定n的值是多少?

还有更好的主意吗?


您是否尝试过使用Sequence而不是identity?对于身份,我认为您无法可靠地预测哪个插入将获得特定的身份值,但是使用序列应该不成问题。当然,这会改变您现在的工作方式。
Antoine Hernandez

@SoleDBAGuy序列是否会使我上面描述的竞争条件更有可能?我生成一个新的Sequence值X(替换上面的步骤2),然后插入一行(步骤3和4)。2和3之间,有可能别人会产生下一个序列值X + 1,提交它,阅读器读取值X + 1之前,我什至将我行与序列值X.
法比安Schmied

Answers:


26

插入行时,在生成新的Identity值与锁定聚集索引中的相应行键之间是否有机会之窗,外部观察者可以看到并发事务插入的更新的Identity值?

是。

身份值分配独立于所包含的用户事务。这是即使回滚事务也会消耗标识值的原因之一。增量操作本身受锁存器保护以防止损坏,但这就是保护的程度。

在实现的特定情况下,将在插入的用户事务被激活之前(以及在采取任何锁定之前CMEDSeqGen::GenerateNewValue)进行身份分配(对的调用)。

通过同时运行两个插入和附加的调试器,可以使我在递增和分配标识值后冻结一个线程,从而能够重现以下情况:

  1. 会话1获得一个身份值(3)
  2. 会话2获得一个身份值(4)
  3. 会话2执行其插入和提交(因此第4行是完全可见的)
  4. 会话1执行其插入和提交(第3行)

在第3步之后,使用row_number进行锁定读取已提交的查询返回以下内容:

屏幕截图

在您的实现中,这将导致Checkpoint ID 3被错误地跳过。

机会不多的窗口相对较小,但存在。与挂接调试器相比,提供了一个更现实的方案:执行查询线程可以在上述步骤1之后产生调度程序。这允许第二个线程在恢复原始线程执行插入之前分配一个标识值,然后进行插入和提交。

为了清楚起见,在分配标识值之后和使用标识值之前,没有锁或其他同步对象来保护标识值。例如,在上面的步骤1之后,并发事务可以使用T-SQL函数看到新的标识值,就像IDENT_CURRENT表中存在该行之前(甚至是未提交的)一样。

从根本上讲,关于身份值的保证没有比记录的更多

  • 每个新值都是基于当前种子和增量生成的。
  • 特定事务的每个新值都与表上的其他并发事务不同。

就是这样

如果需要严格的事务FIFO处理,您可能别无选择,只能手动进行序列化。如果应用程序的单一要求较少,则可以有更多选择。在这方面,问题不是100%明确的。不过,您可能会在Remus Rusanu的“ 使用表作为队列”一文中找到一些有用的信息。


7

保罗·怀特(Paul White)回答绝对正确时,有可能暂时“跳过”身份信息行。这只是一小段代码,您可以自己复制这种情况。

创建一个数据库和一个测试表:

create database IdentityTest
go
use IdentityTest
go
create table dbo.IdentityTest (ID int identity, c1 char(10))
create clustered index CI_dbo_IdentityTest_ID on dbo.IdentityTest(ID)

在C#控制台程序中对该表执行并发插入和选择:

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Threading;

namespace IdentityTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var insertThreads = new List<Thread>();
            var selectThreads = new List<Thread>();

            //start threads for infinite inserts
            for (var i = 0; i < 100; i++)
            {
                insertThreads.Add(new Thread(InfiniteInsert));
                insertThreads[i].Start();
            }

            //start threads for infinite selects
            for (var i = 0; i < 10; i++)
            {
                selectThreads.Add(new Thread(InfiniteSelectAndCheck));
                selectThreads[i].Start();
            }
        }

        private static void InfiniteSelectAndCheck()
        {
            //infinite loop
            while (true)
            {
                //read top 2 IDs
                var cmd = new SqlCommand("select top(2) ID from dbo.IdentityTest order by ID desc")
                {
                    Connection = new SqlConnection("Server=localhost;Database=IdentityTest;Integrated Security=SSPI;Application Name=IdentityTest")
                };

                try
                {
                    cmd.Connection.Open();
                    var dr = cmd.ExecuteReader();

                    //read first row
                    dr.Read();
                    var row1 = int.Parse(dr["ID"].ToString());

                    //read second row
                    dr.Read();
                    var row2 = int.Parse(dr["ID"].ToString());

                    //write line if row1 and row are not consecutive
                    if (row1 - 1 != row2)
                    {
                        Console.WriteLine("row1=" + row1 + ", row2=" + row2);
                    }
                }
                finally
                {
                    cmd.Connection.Close();
                }
            }
        }

        private static void InfiniteInsert()
        {
            //infinite loop
            while (true)
            {
                var cmd = new SqlCommand("insert into dbo.IdentityTest (c1) values('a')")
                {
                    Connection = new SqlConnection("Server=localhost;Database=IdentityTest;Integrated Security=SSPI;Application Name=IdentityTest")
                };

                try
                {
                    cmd.Connection.Open();
                    cmd.ExecuteNonQuery();
                }
                finally
                {
                    cmd.Connection.Close();
                }
            }
        }
    }
}

当读取线程之一“缺少”条目时,此控制台将为每种情况打印一行。


1
不错的代码,但是您只检查连续的id(“如果行1和行不连续,则//写行”)。您的代码可能会打印出一些空白。这并不意味着这些差距将在以后填补。
ypercubeᵀᴹ

1
因为代码不会触发IDENTITY产生差距的情况(例如回滚事务),所以打印的行确实显示了“已跳过”的值(或者至少在我运行并在计算机上检查时显示了)。非常好的repro示例!
Fabian Schmied '16

5

最好不要期望身份是连续的,因为在许多情况下可能会留下空白。最好将身份视为一个抽象数字,并且不要在其上附加任何业务含义。

基本上,如果回滚INSERT操作(或显式删除行),则可能会出现间隙;如果将表属性IDENTITY_INSERT设置为ON,则可能会出现重复。

在以下情况下可能会出现间隙:

  1. 记录被删除。
  2. 尝试插入新记录时发生错误(回滚)
  3. 具有显式值的更新/插入(identity_insert选项)。
  4. 增量值大于1。
  5. 事务回滚。

列上的identity属性从未保证:

•独特性

•交易中的连续价值。如果值必须是连续的,则事务应在表上使用排他锁或使用SERIALIZABLE隔离级别。

•服务器重启后的连续值。

•重用值。

如果由于这个原因您不能使用身份值,请创建一个单独的表来保存一个当前值,并管理对表的访问和应用程序的编号分配。这确实有可能影响性能。

https://msdn.microsoft.com/zh-CN/library/ms186775(v=sql.105).aspx
https://msdn.microsoft.com/zh-CN/library/ms186775(v=sql.110) .aspx


我认为差距不是我的主要问题-我的主要问题是提高价值的可见度。(也就是说,对于按身份值6之前的那个值排序的查询,不能看到身份值7。)
Fabian Schmied

1
我已经看到了身份值的提交,例如:
1、2、5、3、4。– stacylaray

当然,这很容易重现,例如使用Lennart的答案中的方案。我苦苦挣扎的问题是,在使用带有子句的查询(碰巧是聚簇索引的顺序)时,是否可以观察到提交顺序ORDER BY CheckpointSequence。我认为这归结为一个问题,即Identity值的生成是否与INSERT语句所采取的锁相关联,或者这些仅仅是SQL Server一个接一个地执行的两个不相关的动作。
法比安·施密德

1
什么是查询?如果使用读提交,那么在您的示例中,order by将显示1、2、3、5,因为它们已提交而4尚未提交,即脏读。另外,您对NEventStore的解释是:“因此,至关重要的是永远不能跳过事件,因为永远不会再考虑它们。”
stacylaray

该查询已在上面给出(gist.github.com/fschmied/47f716c32cb64b852f90)-已进行分页,但简化为一个简单的SELECT ... FROM Commits WHERE CheckpointSequence > ... ORDER BY CheckpointSequence。我不认为此查询会读取到锁定的第4行之后,还是会?(在我的实验中,当查询尝试获取第4行的KEY锁定时,它会阻塞。)
Fabian Schmied

1

我怀疑这有时会导致麻烦,当服务器承受重负载时,麻烦会变得更糟。考虑两个事务:

  1. T1:插入T ...-说5被插入
  2. T2:插入T ...-说6被插入
  3. T2:提交
  4. 读者看到6但看不到5
  5. T1:提交

在上述情况下,您的LAST_READ_ID将为6,因此将永远不会读取5。


我的测试似乎表明这种情况不是问题,因为Reader(步骤4)在尝试读取值为5的行时将阻塞(直到T1释放其锁)。
Fabian Schmied

您可能是对的,我不太了解SQL Server中的锁定机制(因此我怀疑我的回答)。
Lennart's

取决于读者的隔离级别。据我同时看到,块,或者只看到6
迈克尔·格林

0

运行此脚本:

BEGIN TRAN;
INSERT INTO dbo.Example DEFAULT VALUES;
COMMIT;

以下是扩展事件会话捕获的我看到的已获取和释放的锁:

name            timestamp                   associated_object_id    mode    object_id   resource_type   session_id  resource_description
lock_acquired   2016-03-29 06:37:28.9968693 1585440722              IX      1585440722  OBJECT          51          
lock_acquired   2016-03-29 06:37:28.9969268 7205759890195415040     IX      0           PAGE            51          1:1235
lock_acquired   2016-03-29 06:37:28.9969306 7205759890195415040     RI_NL   0           KEY             51          (ffffffffffff)
lock_acquired   2016-03-29 06:37:28.9969330 7205759890195415040     X       0           KEY             51          (29cf3326f583)
lock_released   2016-03-29 06:37:28.9969579 7205759890195415040     X       0           KEY             51          (29cf3326f583)
lock_released   2016-03-29 06:37:28.9969598 7205759890195415040     IX      0           PAGE            51          1:1235
lock_released   2016-03-29 06:37:28.9969607 1585440722              IX      1585440722  OBJECT          51      

请注意,在刚创建的新行的X键锁之前获取的RI_N KEY锁。由于RI_N锁不兼容,因此这种短暂的范围锁将阻止并发插入获取另一个RI_N KEY锁。您在第2步和第3步之间提到的窗口无关紧要,因为在新生成的键的行锁定之前获取了范围锁定。

只要您SELECT...ORDER BY在所需的新插入的行之前开始扫描READ COMMITTED,只要READ_COMMITTED_SNAPSHOT关闭了数据库选项,我就会期望您在默认隔离级别中期望的行为。


1
根据technet.microsoft.com/zh-cn/library/…,两个锁RangeI_N兼容的,即不要互相阻塞(该锁主要用于在现有的可序列化读取器上进行阻塞)。
Fabian Schmied

@FabianSchmied,有趣。该主题与technet.microsoft.com/en-us/library/ms186396(v=sql.105).aspx中的锁兼容性矩阵冲突,该矩阵显示锁不兼容。您提到的链接中的插入示例确实陈述了与我的答案中的跟踪所示相同的行为(短暂的插入范围锁定用于测试排他键锁定之前的范围)。
Dan Guzman

1
实际上,矩阵说“ N”表示“没有冲突”(不是“不兼容”):)
Fabian Schmied

0

根据我对SQL Server的理解,默认行为是第二​​个查询在提交第一个查询之前不显示任何结果。如果第一个查询执行ROLLBACK而不是COMMIT,则列中将缺少ID。

基本配置

数据库表

我创建了具有以下结构的数据库表:

CREATE TABLE identity_rc_test (
    ID4VALUE INT IDENTITY (1,1), 
    TEXTVALUE NVARCHAR(20),
    CONSTRAINT PK_ID4_VALUE_CLUSTERED 
        PRIMARY KEY CLUSTERED (ID4VALUE, TEXTVALUE)
)

数据库隔离级别

我使用以下语句检查了数据库的隔离级别:

SELECT snapshot_isolation_state, 
       snapshot_isolation_state_desc, 
       is_read_committed_snapshot_on
FROM sys.databases WHERE NAME = 'mydatabase'

这为我的数据库返回了以下结果:

snapshot_isolation_state    snapshot_isolation_state_desc   is_read_committed_snapshot_on
0                           OFF                             0

(这是SQL Server 2012中数据库的默认设置)

测试脚本

使用标准SQL Server SSMS客户端设置和标准SQL Server设置执行以下脚本。

客户端连接设置

已根据READ COMMITTEDSSMS中的“查询选项” 将客户端设置为使用事务隔离级别。

查询1

在带有SPID 57的“查询”窗口中执行以下查询

SELECT * FROM dbo.identity_rc_test
BEGIN TRANSACTION [FIRST_QUERY]
INSERT INTO dbo.identity_rc_test (TEXTVALUE) VALUES ('Nine')
/* Commit is commented out to prevent the INSERT from being commited
--COMMIT TRANSACTION [FIRST_QUERY]
--ROLLBACK TRANSACTION [FIRST_QUERY]
*/

查询2

在查询窗口中以SPID 58执行以下查询

BEGIN TRANSACTION [SECOND_QUERY]
INSERT INTO dbo.identity_rc_test (TEXTVALUE) VALUES ('Ten')
COMMIT TRANSACTION [SECOND_QUERY]
SELECT * FROM dbo.identity_rc_test

查询未完成,正在等待eXclusive锁在PAGE上释放。

确定锁定的脚本

该脚本显示两个事务在数据库对象上发生的锁定:

SELECT request_session_id, resource_type,
       resource_description, 
       resource_associated_entity_id,
       request_mode, request_status
FROM sys.dm_tran_locks
WHERE request_session_id IN (57, 58)

结果如下:

58  DATABASE                    0                   S   GRANT
57  DATABASE                    0                   S   GRANT
58  PAGE            1:79        72057594040549300   IS  GRANT
57  PAGE            1:79        72057594040549300   IX  GRANT
57  KEY         (a0aba7857f1b)  72057594040549300   X   GRANT
58  KEY         (a0aba7857f1b)  72057594040549300   S   WAIT
58  OBJECT                      245575913           IS  GRANT
57  OBJECT                      245575913           IX  GRANT

结果表明,查询窗口一(SPID 57)在数据库上具有共享锁(S),在对象上具有预期的eXlusive(IX)锁,在其要插入的PAGE上具有意图的eXlusive(IX)锁,并且具有eXclusive已插入但尚未提交的KEY上的锁(X)。

由于存在未提交的数据,因此第二个查询(SPID 58)在DATABASE级别上具有共享锁(S),在对象上具有意图共享(IS)锁,在页面上具有意图共享(IS)锁,而共享(S) )以请求状态WAIT锁定KEY。

摘要

第一个查询窗口中的查询将执行而无需提交。由于第二个查询只能进行READ COMMITTED数据查询,因此它一直等待直到发生超时或直到第一个查询中的事务已提交为止。

据我了解,这是Microsoft SQL Server的默认行为。

您应该观察到,如果第一个语句为COMMIT,则SELECT语句的后续读取确实按顺序排列ID。

如果第一个语句执行了ROLLBACK,则您会在序列中找到一个丢失的ID,但ID的顺序仍然是升序的(前提是您使用ID列的default或ASC选项创建了INDEX)。

更新:

(坦率地说)是的,您可以依靠身份列正常运行,直到遇到问题为止。关于SQL Server 2000和 Microsoft网站上的标识列只有一个HOTFIX

如果您不能依靠Identity列正确更新,我认为Microsoft网站上将会有更多修补程序或补丁。

如果您拥有Microsoft支持合同,则可以随时打开一个咨询案例并询问其他信息。


1
感谢您的分析,但是我的问题是,在下一个Identity值的生成与行的KEY锁的获取之间是否存在时间窗口(并发读取/写入器可能会落入其中)。我不认为您的观察证明这是不可能的,因为无法在那个超短时间窗口内停止查询执行并分析锁。
Fabian Schmied

不,您无法停止声明,但是我的(缓慢)观察是快速/正常情况下发生的情况。一个SPID一旦获取了用于插入数据的锁,另一SPID将无法获取相同的锁。更快的语句将具有已按顺序获取锁和ID的优势。释放锁定后,下一条语句将收到下一个ID。
约翰又名hot2use

1
在正常情况下,您的观察结果符合我自己的观点(也符合我的期望),这很高兴知道。我不知道是否有例外情况不会举行。
Fabian Schmied
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.