【问题标题】:Detecting dirty reads from a stored procedure检测存储过程中的脏读
【发布时间】:2017-02-22 03:56:31
【问题描述】:

我有 100 个线程,每个线程都调用下面定义的存储过程。

如何防止脏读?

SET QUOTED_IDENTIFIER OFF
SET ANSI_NULLS OFF
GO

ALTER procedure GetNextCerealIdentity
    (@NextKey int output, @TableID int)
AS
    declare @RowCount int, @Err int

    set nocount on

    select  
        @NextKey = 0

    begin transaction

Again:
    /*Update CfgCerealNumber Table */
    UPDATE CfgCerealNumber 
    SET CerealNumber = CerealNumber + 1  
    WHERE CerealNumberID = @TableID

    SELECT 
        @RowCount = @@RowCount, 
        @Err = @@Error      /*Obtain updated Cereal number previously incremented*/

    IF @Err <> 0            /* If Error gets here then exit         */
    BEGIN
        RAISERROR ('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 16, 1, @Err, @TableID)
        ROLLBACK TRANSACTION

        set nocount off
        return 1
    END

    IF @RowCount = 0                /* No Record then assume table is not   */
                                /* been initialized for TableID Supplied*/
    BEGIN
        RAISERROR('No Table Record Exists in CfgCerealNumber for ID:%d   ', 16, 1, @TableID)
        set nocount off
        Rollback Transaction
        return 1
    END

    /*Obtain updated Cereal number previously incremented*/
    SELECT @NextKey = CerealNumber 
    FROM CfgCerealNumber 
    WHERE CerealNumberID = @TableID

    SELECT @Err = @@Error                       /*Obtain updated Cereal number previously incremented*/

    IF @Err <> 0                            /* If Error gets here then exit         */
    BEGIN
        RAISERROR('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 16, 1, @Err, @TableID)
        Rollback Transaction    
        set nocount off
        return 1
    END

    commit transaction
    set nocount off
    return 0
GO

看起来这部分存储过程在并行运行时大约有 0.01% 的时间返回相同的值:

SELECT @NextKey = CerealNumber 
FROM CfgCerealNumber 
WHERE CerealNumberID = @TableID

我的代码结构是通过将更新包装在事务中来防止脏读。

如何防止脏读?

【问题讨论】:

  • 嗯......您使用 ReadCommitted 运行的任何查询都不会给出脏读。那就是定义。并发是这里的问题。这种事情已经被一遍又一遍地讨论过了,而且在 DIY 身份识别过程中存在太多漏洞。为了什么目的?当行被删除时,您仍然会有间隙。一个身份或序列更容易处理,因为所有的漏洞都已经被填满了。
  • 脏读是指您的查询返回的数据是未提交事务的一部分。如果你有 ReadCommitted 你不可能得到脏读。当多个线程在完全相同的时刻调用它时,您会遇到竞争条件。当你得到重复时会发生什么,更新语句在表被锁定之前在多个线程中执行。这就是为什么您在 0.01% 的时间内看到相同的值。竞争条件是那些被身份和序列堵塞的令人讨厌的漏洞之一。
  • 你说你做到了。 0.01% 来自您的帖子。如果不是这样,那么这里的问题是什么?
  • @SeanLange 为什么下面的 guzman 不同意您的观点:AFAIK,您的原始 proc 可能返回 dups 的唯一原因是它是否在 READ UNCOMMITTED 隔离级别中调用,这允许脏读。无论并发线程数和隔离级别如何,我发布的版本都不会为给定的 SerialNumberID 返回相同的值。
  • SQL 是一个支持 ACID 属性的事务系统(谷歌“SQL ACID”了解详细信息)。为了支持这一点,它将在更新行中的列之前锁定该行。如果出现第二个更新请求,它会锁定并等待第一个请求完成(事务已提交)。这适用于声明事务和隐式事务。

标签: sql sql-server tsql


【解决方案1】:

如果您需要更新并返回您更新的内容,那么我会使用the OUTPUT clause

UPDATE CfgCerealNumber 
SET CerealNumber = CerealNumber + 1 
OUTPUT INSERTED.CerealNumber
WHERE CerealNumberID = @TableID;

如果需要额外检查,可以在从存储过程返回结果集之前输出到声明的表变量中。

另一种选择是先在表上创建一个阻塞锁,然后更新:

SELECT @CerealNumber = CerealNumber + 1 
FROM CfgCerealNumber WITH (HOLDLOCK, UPDLOCK) 
WHERE CerealNumberID = @TableID;

UPDATE CfgCerealNumber
SET CerealNumber = @CerealNumber
WHERE CerealNumberID = @TableID;

但我会放下钱,因为我看到这仍然会导致问题。我不太相信它。

【讨论】:

    【解决方案2】:

    您可以使用Books Online 中所述的@variable = column = expression 语法来避免此问题。此外,由于语句在单语句自动事务中执行,您可以避免显式事务。

    SET QUOTED_IDENTIFIER ON;
    SET ANSI_NULLS ON;
    GO
    
    CREATE PROCEDURE GetNextSerialIdentity
          @NextKey int output
        , @TableID int
    AS
    SET NOCOUNT ON;
    
    UPDATE dbo.CfgSerialNumber
    SET @NextKey = SerialNumber = SerialNumber + 1
    WHERE SerialNumberID = @TableID;
    
    IF @@ROWCOUNT = 0
    BEGIN
    RAISERROR ('No Table Record Exists in CfgCerealNumber for ID:%d   ', 
                      16,1, @TableID);
    END
    GO
    

    【讨论】:

    • 胆子!我不确定这有什么帮助: SET @variable = column = expression 将变量设置为与列相同的值。这与 SET @variable = column, column = expression 不同,后者将变量设置为列的更新前值。 --- 这并不能解决 sean 描述的并发问题——假设这里存在并发问题
    • AFAIK,您的原始 proc 可能返回 dups 的唯一原因是它是否在 READ UNCOMMITTED 隔离级别中调用,这允许脏读。无论并发线程数和隔离级别如何,我发布的版本都不会为给定的 SerialNumberID 返回相同的值。
    • 还有另一种语法可以返回更新后的数字。它是:UPDATE dbo.CfgSerialNumber SET @NextKey = SerialNumber + 1, SerialNumber = @NextKey WHERE SerialNumberID = @TableID这是一个原子动作,不能多次返回同一个值,而且还会返回更新后的值而不是原来的值。
    • 此外,“双等号”语法取决于版本。我知道它在 2008SP2 或更早版本中不起作用,并且不记得它是否在 2012 年起作用。
    • @LaughingVergil,这种语法至少从 2005 年就存在 msdn.microsoft.com/en-us/library/ms177523(v=sql.90).aspx 或者可能是 2000 年 sqlteam.com/article/creating-a-sequential-record-number-field
    【解决方案3】:

    你需要替换这个语句

    UPDATE CfgCerealNumber Set CerealNumber = CerealNumber + 1  
    WHERE CerealNumberID = @TableID
    

    通过这个:

    declare @CerealNumber int
    
    SELECT @CerealNumber = CerealNumber  + 1
    FROM CfgCerealNumber WITH (READCOMMITTED, READPAST, ROWLOCK) 
    WHERE CerealNumberID = @TableID
    
    if @CerealNumber is not null
        UPDATE CfgCerealNumber Set CerealNumber = @CerealNumber
        WHERE CerealNumberID = @TableID
    else
        raiserror ('Row was locked by another update (no dirty read and no deadlock happen) or no Table Record Exists in CfgCerealNumber for ID:%d   ', 
                  16,1, @TableID)
    

    这些表提示 READCOMMITTED、READPAST、ROWLOCK 将确保您没有脏读和死锁

    它还可以让您决定是否仍要进行更新

    READCOMMITTED
    通过使用锁定或行版本控制,指定读取操作符合 READ COMMITTED 隔离级别的规则。如果数据库选项 READ_COMMITTED_SNAPSHOT 为 OFF,则数据库引擎会在读取数据时获取共享锁,并在读取操作完成时释放这些锁。如果数据库选项 READ_COMMITTED_SNAPSHOT 为 ON,则数据库引擎不会获取锁并使用行版本控制。

    READPAST
    指定数据库引擎不读取被其他事务锁定的行。指定 READPAST 时,会跳过行级锁。也就是说,数据库引擎跳过行而不是阻塞当前事务,直到锁被释放。例如,假设表 T1 包含一个整数列,其值为 1、2、3、4、5。如果事务 A 将值 3 更改为 8 但尚未提交,则 SELECT * FROM T1 (READPAST) 产生值 1、2、4、5。READPAST 主要用于在实现使用 SQL Server 表的工作队列时减少锁定争用。使用 READPAST 的队列读取器会跳过被其他事务锁定的队列条目到下一个可用队列条目,而不必等到其他事务释放它们的锁。

    行锁
    指定在通常采用页锁或表锁时采用行锁。在以 SNAPSHOT 隔离级别操作的事务中指定时,除非 ROWLOCK 与其他需要锁的表提示(例如 UPDLOCK 和 HOLDLOCK)结合使用,否则不会采用行锁。

    Source MSDN Table Hints (Transact-SQL)

    您可能还需要使用 UPDLOCK 和/或 HOLDLOCK

    【讨论】:

    • @l--''''''---------'''''''''''' 如果你有SNAPSHOT 隔离级别
    【解决方案4】:

    sp_getapplock 将确保事务具有排他锁。更新和读取将在下一个线程使用之前提交,因此不会有任何脏读。

    SET QUOTED_IDENTIFIER OFF
    SET ANSI_NULLS OFF
    GO
    
        ALTER procedure GetNextCerealIdentity(@NextKey int output,@TableID int)
        AS
        declare @RowCount int, @Err int
        set nocount on
        select  @NextKey = 0
        begin transaction
       --ADDED CODE
        EXEC sp_getapplock @Resource='MyLock', @LockMode='Exclusive'
                    , @LockOwner='Transaction', @LockTimeout = 15000
        Again:
        /*Update CfgCerealNumber Table */
        UPDATE CfgCerealNumber Set CerealNumber = CerealNumber + 1  WHERE CerealNumberID = @TableID
        select  @RowCount = @@RowCount, @Err = @@Error      /*Obtain updated Cereal number previously incremented*/
    
        if @Err <> 0                            /* If Error gets here then exit         */
            begin                        
            raiserror ('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 
                   16,1, @Err, @TableID)
                    Rollback Transaction    
            set nocount off
            return 1
            end
    
        if @RowCount = 0                        /* No Record then assume table is not   */
                                        /* been initialized for TableID Supplied*/
            begin
            raiserror ('No Table Record Exists in CfgCerealNumber for ID:%d   ', 
                          16,1, @TableID)
            set nocount off
                    Rollback Transaction
            return 1
            end
    
        /*Obtain updated Cereal number previously incremented*/
        SELECT @NextKey = CerealNumber 
         From CfgCerealNumber WHERE CerealNumberID = @TableID
    
        select   @Err = @@Error                     /*Obtain updated Cereal number previously incremented*/
    
        if @Err <> 0                            /* If Error gets here then exit         */
            begin                        
            raiserror ('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 
                   16,1, @Err, @TableID)
                    Rollback Transaction    
            set nocount off
            return 1
            end
    
        commit transaction
        set nocount off
        return 0
    

    【讨论】:

    • 好像这样会引入死锁?
    • sp_getapplock 可能会导致死锁,具体取决于代码,但通常使用此过程来避免死锁。在这种情况下,如果没有子过程,最好避免 CfgCerealNumber 的更新和选择之间的问题。
    【解决方案5】:

    开始事务/提交事务将确保您没有脏读

    性能上有一个缺点,如果该过程是从另一个事务内部运行的,则在提交最外部的事务之前不会释放写锁。这将序列化所有线程并阻止并发。

    看这个例子(假设执行时间较长):

    begin tran
        ...
        exec GetNextCerealIdentity ... ; -- the write lock is established
        ...
    commit tran -- the write lock is released
    

    可以在事务结束之前释放锁,但您必须使用过程创建应用程序锁 GetNextCerealIdentity 过程中的 sp_getAppLocksp_releaseAppLock

    这可能非常棘手,您必须注意,否则您可能会遇到死锁或一些脏读

    您必须在程序开始时执行 sp_getAppLock,并在最后执行 sp_releaseAppLock(在 return 之前。在您的示例中,您有很多return's 所以你将不得不在很多点释放锁)

    在出现错误时也不要忘记释放锁。锁将在事务结束时释放,但您想在程序结束时释放它! :-)

    您必须确保您的应用程序锁是唯一持有计数器(CfgCerealNumber)的人。

    通常 SQL Server 会在表上设置写锁,并会干扰您的锁,因为写锁将在事务结束时释放,而不是在您的过程结束时释放。

    您必须将过程更改为事务级别 READ UNCOMMITED,以便代码中的 UPDATE 不会生成写锁。记得在释放应用程序锁的同时返回 COMMITTED。

    如果您在独占模式中获得锁,您将确定只有一个连接能够对表 CfgCerealNumber 执行更新/选择

    你可以给锁起任何你想要的名字。我使用了与表相同的名称(CfgCerealNumber),但这并不重要。最重要的是,您必须为最初的 get 和您放入代码中的所有 release 使用相同的名称。

    ALTER procedure GetNextCerealIdentity(@NextKey int output,@TableID int)
    AS
    declare @RowCount int, @Err int
    set nocount on
    select  @NextKey = 0
    
    -- replace begin tran with:    
    EXEC sp_getapplock @Resource = 'CfgCerealNumber', @LockMode = 'Exclusive';  
    SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
    
    /*Update CfgCerealNumber Table */
    UPDATE CfgCerealNumber Set CerealNumber = CerealNumber + 1  
    WHERE CerealNumberID = @TableID
    select  @RowCount = @@RowCount, @Err = @@Error  /*Obtain updated Cereal number previously incremented*/
    
    if @Err <> 0   /* If Error gets here then exit         */
        begin                        
        raiserror ('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 
               16,1, @Err, @TableID)
        -- replace Rollback Transaction with:
        SET TRANSACTION ISOLATION LEVEL READ COMMITTED
        EXEC sp_releaseapplock @Resource = 'CfgCerealNumber';  
        set nocount off
        return 1
        end
    
    if @RowCount = 0 /* No Record then assume table is not   */
                     /* been initialized for TableID Supplied*/
        begin
        raiserror ('No Table Record Exists in CfgCerealNumber for ID:%d   ', 
                      16,1, @TableID)
        set nocount off
    
        -- replace Rollback Transaction with:
        SET TRANSACTION ISOLATION LEVEL READ COMMITTED
        EXEC sp_releaseapplock @Resource = 'CfgCerealNumber';  
    
        return 1
        end
    
    /*Obtain updated Cereal number previously incremented*/
    SELECT @NextKey = CerealNumber 
     From CfgCerealNumber WHERE CerealNumberID = @TableID
    
    select   @Err = @@Error /*Obtain updated Cereal number previously incremented*/
    
    if @Err <> 0  /* If Error gets here then exit         */
        begin                        
        raiserror ('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 
               16,1, @Err, @TableID)
        -- replace Rollback Transaction with:
        SET TRANSACTION ISOLATION LEVEL READ COMMITTED
        EXEC sp_releaseapplock @Resource = 'CfgCerealNumber';  
        set nocount off
        return 1
        end
    
    -- replace commit transaction with:
    SET TRANSACTION ISOLATION LEVEL READ COMMITTED
    EXEC sp_releaseapplock @Resource = 'CfgCerealNumber';  
    
    set nocount off
    return 0
    GO
    

    如果你像这样改变过程,我之前的例子不会出现并发问题:

    begin tran
        ...
        exec GetNextCerealIdentity ... ; -- the lock is established AND released
        ...
    commit tran -- common "write locks" are released
    

    一个可能的补充是使用 BEGIN/END TRY .. BEGIN/END CATCH 结构,以便在出现意外异常时也释放锁(这将给另一个优点:你会从过程中有一个单点退出,因此您将有一个单点,您必须在其中放置指令以释放锁并放回先前的事务隔离级别。

    查看以下链接: (sp_getAppLock) https://msdn.microsoft.com/en-us/library/ms189823.aspx 和 (sp_releaseAppLock) https://technet.microsoft.com/en-us/library/ms178602.aspx

    【讨论】:

      【解决方案6】:

      一种选择是使用 sp_getapplock 系统存储过程并使用 sql server 的内置锁定来确保对资源的序列化访问。

      CREATE PROC MyCriticalWork(@MyParam INT)      
      AS
          DECLARE @LockRequestResult INT
          SET @LockRequestResult=0
      
          DECLARE @MyTimeoutMiliseconds INT
          SET @MyTimeoutMiliseconds=5000--Wait only five seconds max then timeouit
      
          BEGIN TRAN
      
          EXEC @LockRequestResult=SP_GETAPPLOCK 'MyCriticalWork','Exclusive','Transaction',@MyTimeoutMiliseconds
          IF(@LockRequestResult>=0)BEGIN
      
              /*
              DO YOUR CRITICAL READS AND WRITES HERE
              */
      
              --Release the lock
              COMMIT TRAN
          END ELSE
              ROLLBACK TRAN   
      

      【讨论】:

      • 我同意。这是在这些情况下的最佳解决方案。 (贴在下面;-)
      【解决方案7】:

      Bacon Bits 击败了我,但使用 OUTPUT 子句将是解决您的赛车问题的最简单方法。当然锁定也是一种选择,尽管我认为它的开销会略高。也就是说,使用IDENTITY 列或SEQUENCE 比尝试手动实现此功能要容易得多。

      我冒昧地将答案放入您的代码中并添加了一些注释:

      SET QUOTED_IDENTIFIER OFF
      SET ANSI_NULLS OFF
      GO
      
      ALTER procedure GetNextCerealIdentity(@NextKey int output,@TableID int)
      AS
      set nocount on
      
      DECLARE @RowCount int, @Err int
      DECLARE @output TABLE (NextKey int)
      
      begin transaction
      
          /*Update CfgCerealNumber Table */
          UPDATE CfgCerealNumber WITH (UPDLOCK) 
             Set CerealNumber = CerealNumber + 1
          OUTPUT inserted.CerealNumber INTO @output (NextKey)
           WHERE CerealNumberID = @TableID
      
          select @RowCount = @@RowCount, /*Obtain updated Cereal number previously incremented*/ 
                 @Err = @@Error      
      
          if @Err <> 0                            /* If Error gets here then exit         */
              begin                        
                  Rollback Transaction    
                  raiserror ('GetNextCerealIDSeries Failed with Error: %d TableID: %d ', 16,1, @Err, @TableID)
                  return -1
              end
      
          if @RowCount = 0                        /* No Record then assume table is not   */
                                          /* been initialized for TableID Supplied*/
              begin
                  Rollback Transaction
                  raiserror ('No Table Record Exists in CfgCerealNumber for ID:%d   ', 16,1, @TableID)
                  return -1
              end
      
      COMMIT TRANSACTION
      
      
      /*Obtain updated Cereal number previously incremented*/
      SELECT @NextKey = NextKey 
       From @output
      
      return 0
      GO
      

      备注:

      • 在退出存储过程之前无需再次执行SET NOCOUNT OFF。当您超出范围时,此设置将返回到您进入存储过程之前的状态。
      • 我不确定您是否需要 WITH (UPDLOCK),但它肯定不会有什么坏处。
      • 我将事务打开的时间尽可能短,没有理由从事务内部的表变量中获取值。
      • 我认为首先执行ROLLBACK 然后执行RaisError() 更安全,因为后者可能会导致某些客户端软件断开连接和/或您可能位于TRY...CATCH 中。两者都会中断命令流,最终导致事务计数不匹配。
      • YMMV 但我一直被告知使用负返回码以防出错。可能会使用正返回码来表示行数。虽然我从未见过实际使用过的返回码。

      【讨论】:

        【解决方案8】:

        如前所述,您可以使用自动增量内置功能,例如标识列或序列。

        如果您不希望这样,您需要以串行方式访问表:使用应用程序锁或其他能力。

        例如,您可以将提示添加到对表(在事务中)的 FIRST 访问,如下所示:

        UPDATE CfgCerealNumber
        Set CerealNumber = CerealNumber + 1
        FROM CfgCerealNumber with (tablockx, holdlock)
        WHERE CerealNumberID = @TableID
        

        这将保证在所有并行线程中对表的顺序访问。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2012-07-23
          • 1970-01-01
          • 1970-01-01
          • 2021-07-12
          • 2010-10-30
          • 2015-04-01
          相关资源
          最近更新 更多