SQL Server如何在UPDATE期间同时返回新值和旧值?


8

在高并发期间,我们遇到了返回无意义结果的查询的问题-结果违反了发出查询的逻辑。重现了一段时间。我设法将可重现性问题分解为少数几个T-SQL。

注意:实时系统中有问题的部分由5个表,4个触发器,2个存储过程和2个视图组成。对于发布的问题,我已经将实际系统简化为更易于管理的内容。事情已经减少了,列被删除了,存储过程被内联了,视图变成了普通的表表达式,列的值改变了。说了这么长的话,虽然后面的内容会重现错误,但可能更难以理解。您必须避免怀疑为什么某种事物以这种方式构造。我在这里试图弄清楚为什么这种玩具模型中的错误情况可重复发生。

/*
The idea in this system is that people are able to take days off. 
We create a table to hold these *"allocations"*, 
and declare sample data that only **1** production operator 
is allowed to take time off:
*/
IF OBJECT_ID('Allocations') IS NOT NULL DROP TABLE Allocations
CREATE TABLE [dbo].[Allocations](
    JobName varchar(50) PRIMARY KEY NOT NULL,
    Available int NOT NULL
)
--Sample allocation; there is 1 avaialable slot for this job
INSERT INTO Allocations(JobName, Available)
VALUES ('Production Operator', 1);

/*
Then we open up the system to the world, and everyone puts in for time. 
We store these requests for time off as *"transactions"*. 
Two production operators requested time off. 
We create sample data, and note that one of the users 
created their transaction first (by earlier CreatedDate):
*/
IF OBJECT_ID('Transactions') IS NOT NULL DROP TABLE Transactions;
CREATE TABLE [dbo].[Transactions](
    TransactionID int NOT NULL PRIMARY KEY CLUSTERED,
    JobName varchar(50) NOT NULL,
    ApprovalStatus varchar(50) NOT NULL,
    CreatedDate datetime NOT NULL
)
--Two sample transactions
INSERT INTO Transactions (TransactionID, JobName, ApprovalStatus, CreatedDate)
VALUES (52625, 'Production Operator', 'Booked', '20140125 12:00:40.820');
INSERT INTO Transactions (TransactionID, JobName, ApprovalStatus, CreatedDate)
VALUES (60981, 'Production Operator', 'WaitingList', '20150125 12:19:44.717');

/*
The allocation, and two sample transactions are now in the database:
*/
--Show the sample data
SELECT * FROM Allocations
SELECT * FROM Transactions

事务都插入为WaitingList。接下来,我们将运行一个定期任务,该任务将寻找空的插槽,并将WaitingList上的任何人都更改为Booked状态。

在一个单独的SSMS窗口中,我们具有模拟的定期存储过程:

/*
    Simulate recurring task that looks for empty slots, 
    and bumps someone on the waiting list into that slot.
*/
SET NOCOUNT ON;

--Reset the faulty row so we can continue testing
UPDATE Transactions SET ApprovalStatus = 'WaitingList'
WHERE TransactionID = 60981

--DBCC TRACEON(3604,1200,3916,-1) WITH NO_INFOMSGS

DECLARE @attempts int
SET @attempts = 0;

WHILE (@attempts < 1000000)
BEGIN
    SET @attempts = @attempts+1;

    /*
        The concept is that if someone is already "Booked", then they occupy an available slot.
        We compare the configured amount of allocations (e.g. 1) to how many slots are used.
        If there are any slots leftover, then find the **earliest** created transaction that 
        is currently on the WaitingList, and set them to Booked.
    */

    PRINT '=== Looking for someone to bump ==='
    WITH AvailableAllocations AS (
        SELECT 
            a.JobName,
            a.Available AS Allocations, 
            ISNULL(Booked.BookedCount, 0) AS BookedCount, 
            a.Available-ISNULL(Booked.BookedCount, 0) AS Available
        FROM Allocations a
            FULL OUTER JOIN (
                SELECT t.JobName, COUNT(*) AS BookedCount
                FROM Transactions t
                WHERE t.ApprovalStatus IN ('Booked') 
                GROUP BY t.JobName
            ) Booked
            ON a.JobName = Booked.JobName
        WHERE a.Available > 0
    )
    UPDATE Transactions SET ApprovalStatus = 'Booked'
    WHERE TransactionID = (
        SELECT TOP 1 t.TransactionID
        FROM AvailableAllocations aa
            INNER JOIN Transactions t
            ON aa.JobName = t.JobName
            AND t.ApprovalStatus = 'WaitingList'
        WHERE aa.Available > 0
        ORDER BY t.CreatedDate 
    )


    IF EXISTS(SELECT * FROM Transactions WHERE TransactionID = 60981 AND ApprovalStatus = 'Booked')
    begin
        --DBCC TRACEOFF(3604,1200,3916,-1) WITH NO_INFOMSGS
        RAISERROR('The later tranasction, that should never be booked, managed to get booked!', 16, 1)
        BREAK;
    END
END

最后在第三个SSMS连接窗口中运行它。这模拟了一个并发问题,即较早的事务从占用一个插槽到进入等待列表:

/*
    Toggle the earlier transaction back to "WaitingList".
    This means there are two possibilies:
       a) the transaction is "Booked", meaning no slots are available. 
          Therefore nobody should get bumped into "Booked"
       b) the transaction is "WaitingList", 
          meaning 1 slot is open and both tranasctions are "WaitingList"
          The earliest transaction should then get "Booked" into the slot.

    There is no time when there is an open slot where the 
    first transaction shouldn't be the one to get it - he got there first.
*/
SET NOCOUNT ON;

--Reset the faulty row so we can continue testing
UPDATE Transactions SET ApprovalStatus = 'WaitingList'
WHERE TransactionID = 60981

DECLARE @attempts int
SET @attempts = 0;

WHILE (@attempts < 100000)
BEGIN
    SET @attempts = @attempts+1

    /*Flip the earlier transaction from Booked back to WaitingList
        Because it's now on the waiting list -> there is a free slot.
        Because there is a free slot -> a transaction can be booked.
        Because this is the earlier transaction -> it should always be chosen to be booked
    */
    --DBCC TRACEON(3604,1200,3916,-1) WITH NO_INFOMSGS

    PRINT '=== Putting the earlier created transaction on the waiting list ==='

    UPDATE Transactions
    SET ApprovalStatus = 'WaitingList'
    WHERE TransactionID = 52625

    --DBCC TRACEOFF(3604,1200,3916,-1) WITH NO_INFOMSGS

    IF EXISTS(SELECT * FROM Transactions WHERE TransactionID = 60981 AND ApprovalStatus = 'Booked')
    begin
        RAISERROR('The later tranasction, that should never be booked, managed to get booked!', 16, 1)
        BREAK;
    END
END

从概念上讲,碰撞程序会一直在寻找任何空插槽。如果找到一个,它将采用上最早的交易WaitingList并将其标记为Booked

在没有并发性的情况下进行测试时,逻辑将起作用。我们有两个交易:

  • 12:00 pm:等候名单
  • 下午12:20:WaitingList

有1个分配,0个已预订的交易,因此我们将较早的交易标记为已预订:

  • 下午12:00:预订
  • 下午12:20:WaitingList

下次运行任务时,现在将占用1个插槽-因此没有任何更新。

如果我们随后更新第一笔交易,并将其放入WaitingList

UPDATE Transactions SET ApprovalStatus='WaitingList'
WHERE TransactionID = 60981

然后我们回到开始的地方:

  • 12:00 pm:等候名单
  • 下午12:20:WaitingList

注意:您可能想知道为什么我要将一项交易重新列入候补名单。这是简化玩具模型的牺牲品。在实际系统中,事务可以是PendingApproval,它也占用一个插槽。PendingApproval事务在批准后被放入等待列表。没关系 不用担心

但是,当我介绍并发性,通过具有第二窗口不断把第一笔交易回轮候名单上被黄牌警告后,那么以后的交易设法预订:

  • 12:00 pm:等候名单
  • 下午12:20:预订

玩具测试脚本会捕获此错误,并停止迭代:

Msg 50000, Level 16, State 1, Line 41
The later tranasction, that should never be booked, managed to get booked!

为什么?

问题是,为什么在这种玩具模型中会触发这种纾困条件?

第一笔交易的批准状态有两种可能的状态:

  • 已预订:在这种情况下,该插槽已被占用,以后的事务无法使用该插槽
  • WaitingList:在这种情况下,有一个空插槽,两个需要它的事务。但是,由于我们始终select最早的交易(即ORDER BY CreatedDate),因此第一笔交易应该得到它。

我认为可能是因为其他指标

我了解到,一个UPDATE开始后,和数据被修改,它可以读取旧值。在初始条件下:

  • 聚集索引Booked
  • 非聚集索引Booked

然后,我进行更新,并且在修改了聚集索引叶节点的同时,所有非聚集索引仍然包含原始值,并且仍然可以读取:

  • 聚集索引(排他锁):Booked WaitingList
  • 非聚集索引:(未锁定)Booked

但这不能解释所观察到的问题。是的,交易不再被预订,这意味着现在有一个空位。但是,该更改尚未落实,仍将完全保留。如果执行了碰撞程序,它将:

  • 块:如果快照隔离数据库选项关闭
  • 读取旧值(例如Booked):如果启用了快照隔离

无论哪种方式,颠簸的工作都不会知道有一个空插槽。

所以我不知道

我们一直在努力寻找这些荒谬的结果如何发生。

您可能不了解原始系统,但是有一组玩具可复制脚本。当检测到无效案件时,他们将纾困。为什么会被检测到?为什么会这样呢?

奖金问题

纳斯达克如何解决这个问题?cavirtex如何?mtgox如何?

tl; dr

有三个脚本块。将它们放入3个单独的SSMS选项卡中并运行它们。第二和第三脚本将引发错误。帮我弄清楚为什么会出现错误。


这可能与事务隔离级别有关。您在系统中使用什么隔离级别?
2014年

@cha默认值(已提交读)。复制并粘贴脚本,您可以确认它确实是默认级别。
伊恩·博伊德

当您的第三个标签“重置有问题的行”时,该行变为可用。因此,您的第二个标签可以在第三个标签将较早的行标记为可用之前进行分配。尝试在第3个选项卡的UPDATE上进行两项修改。
AK

Answers:


12

默认READ COMMITTED事务隔离级别可确保您的事务不会读取未提交的数据。它不能保证您再次读取(重复读取)后读取的任何数据都将保持不变,或保证不会出现新数据(虚拟)。

这些相同的注意事项适用于同一语句中的多个数据访问

您的UPDATE语句产生的计划可以Transactions多次访问该表,因此很容易受到不可重复的读取和幻象引起的影响。

多路访问

此计划有多种方法可以产生READ COMMITTED孤立预期之外的结果。

一个例子

第一个Transactions表访问将查找状态为的行WaitingList。第二次访问对状态为的条目(对于同一作业)进行计数Booked。第一次访问可能仅返回较晚的事务(此时较早的事务Booked)。当进行第二次(计数)访问时,先前的事务已更改为WaitingList。因此,下一行有资格更新Booked状态。

解决方案

有几种方法可以设置隔离语义以获得所需的结果。一种选择是启用READ_COMMITTED_SNAPSHOT数据库。这为在默认隔离级别运行的语句提供了语句级的读取一致性。在已提交读快照隔离下,不可重复的读和幻像是不可能的。

其他备注

我不得不说,尽管我不会设计这种模式或查询。为了满足所述业务需求,涉及的工作要多得多。也许这部分是由于问题简化而导致的,无论如何都是单独的问题。

您看到的行为并不表示任何类型的错误。给定所请求的隔离语义,这些脚本会产生正确的结果。这样的并发效应也不限于多次访问数据的计划。

读提交隔离级别所提供的保证要比通常假定的少得多。例如,跳过行和/或多次读取同一行是完全可能的。


我试图找出导致错误结果的操作顺序。它首先INNER连接TransactionsAllocations基础上WaitingList的地位。这种连接发生在UPDATE需要任何IXX锁定之前。因为第一个事务仍然是BookedINNER JOIN唯一的找到第二个事务。然后,它Transactions再次访问该表以LEFT OUTER JOIN对可用插槽计数。此时,第一个事务已更新为WaitingList,这意味着有一个广告位。
伊恩·博伊德

实际系统具有额外的复杂性。例如,JobNamenot(也不能)与一起存储,Transaction但与一起存储Employee。因此Transactions包含一个EmployeeID,我们必须加入。还为DayJob定义了可用的分配。因此Allocations表实际上是(TransactionDate,JobName)。最后,一个人可以在同一天进行多次交易;只需占用1个插槽。因此,实际系统会执行distinct-countby Employee,Job,Date。忽略所有这些,您会对玩具做出什么改变?也许可以重新采用。
伊恩·博伊德

2
@IanBoyd回复:第一条评论,是的(除非这不是错误的结果)。回复:第二条评论,那将是咨询工作:)
保罗·怀特9

2
@AlexKuznetsov根据我的新发现,Arnie / Carol门票假期问题可能会单独发生READ COMMITTED。去度假检查是否有分配给我的票。如果该Tickets表的检查使用索引,它将错误地认为未将票证分配给我。然后有人将票分配给我,触发器使用索引来认为我还没有休假。结果:一个有效的票证分配给度假者。有了这些新知识,我就想躺下哭泣;我的整个世界都被毁了,我写过的一切都是错误的。
伊恩·博伊德

1
@IanBoyd这就是为什么我们使用约束来执行规则(如您遇到问题的规则)的原因。两年多以前,我们已经用约束代替了最后一个触发器,从那时起,我们就享受着水密数据的完整性。另外,我们不再需要详细了解锁,隔离级别等,当然,只要您不使用MERGE,约束就可以工作。
AK
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.