我可以依赖按顺序读取 SQL Server 标识值吗?

Fab*_*ied 27 sql-server concurrency identity locking

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

详细版

我有一个 SQL Server 表,其中有一个Identity名为的列CheckpointSequence,它是该表的聚集索引(它还具有许多其他非聚集索引)的键。行由多个并发进程和线程(在隔离级别和没有)插入到表中。同时,有进程定期从聚集索引中读取行,按该列排序(也在隔离级别,关闭该选项)。READ COMMITTEDIDENTITY_INSERTCheckpointSequenceREAD COMMITTEDREAD COMMITTED SNAPSHOT

我目前依赖于读取过程永远不能“跳过”检查点的事实。我的问题是:我可以依赖这个属性吗?如果没有,我该怎么做才能使它成为现实?

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

我相信至少在理论上,这里可能存在竞争条件,可能会导致这个假设被打破。不幸的是,Identity关于Identity在多个并发事务的上下文中如何工作的文档并没有太多说明,它只说“每个新值都是基于当前的种子和增量生成的”。和“特定事务的每个新值都不同于表上的其他并发事务。” (微软)

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

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

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

  • 并发会话可以生成下一个身份值 (X+1) 并执行所有剩余步骤,
  • 从而允许恰好在那个时间点到达的读者读取值 X+1,而忽略了 X 的值。

当然,这种可能性似乎极低;但仍然 - 它可能发生。或者可以吗?

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

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

  • 在看到 X 之前看到检查点序列值 X+1 时,关闭 X+1 并稍后再试。然而,因为Identity当然会产生间隙(例如,当事务回滚时),X 可能永远不会到来。
  • 所以,同样的方法,但接受 n 毫秒后的差距。但是,我应该假设 n 的值是多少?

有什么更好的想法吗?

Pau*_*ite 30

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

是的。

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

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

通过同时运行两个插入并附加调试器以允许我在标识值递增和分配后立即冻结一个线程,我能够重现以下场景:

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

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

截屏

在您的实现中,这将导致错误地跳过检查点 ID 3。

误机的窗口相对较小,但确实存在。提供一个比附加调试器更现实的场景:执行查询线程可以在上述步骤 1 之后生成调度程序。这允许第二个线程在原始线程恢复执行其插入之前分配标识值、插入和提交。

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

从根本上说,围绕身份值没有比记录更多的保证:

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

真的是这样。

如果需要严格的事务 FIFO 处理,您可能别无选择,只能手动序列化。如果应用程序的要求较少,则您有更多选择。在这方面,问题并不是 100% 清楚。不过,您可能会在 Remus Rusanu 的文章Using Tables as Queues 中找到一些有用的信息。


小智 8

由于保罗怀特回答完全正确,有可能暂时“跳过”身份行。这里只是一小段代码,可以为您自己重现这个案例。

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

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)
Run Code Online (Sandbox Code Playgroud)

在 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();
                }
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

当其中一个读取线程“错过”一个条目时,此控制台为每种情况打印一行。


sta*_*ray 5

最好不要期望身份是连续的,因为有很多场景会留下间隙。最好将身份视为一个抽象的数字,而不对其附加任何商业意义。

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

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

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

列上的标识属性从未保证:

• 独特性

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

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

• 值的重用。

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

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