I\'ve got 100 threads that are each calling the stored procedure as defined below.
How do I prevent dirty reads?
SET QUOTED_IDENTIFIER OFF
SET ANSI_NULLS
You can avoid the problem by using the @variable = column = expression
syntax as described in the Books Online. Also, since the statement executes in an single-statement automatic transaction, you can avoid explicit transaction.
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
You need to replace this statement
UPDATE CfgCerealNumber Set CerealNumber = CerealNumber + 1
WHERE CerealNumberID = @TableID
by this:
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)
these table hints READCOMMITTED, READPAST, ROWLOCK will make sure that you have no dirty read and no deadlock
it will also let you decide if you still want to do an update
READCOMMITTED
Specifies that read operations comply with the rules for the READ COMMITTED isolation level by using either locking or row versioning. If the database option READ_COMMITTED_SNAPSHOT is OFF, the Database Engine acquires shared locks as data is read and releases those locks when the read operation is completed. If the database option READ_COMMITTED_SNAPSHOT is ON, the Database Engine does not acquire locks and uses row versioning.READPAST
Specifies that the Database Engine not read rows that are locked by other transactions. When READPAST is specified, row-level locks are skipped. That is, the Database Engine skips past the rows instead of blocking the current transaction until the locks are released. For example, assume table T1 contains a single integer column with the values of 1, 2, 3, 4, 5. If transaction A changes the value of 3 to 8 but has not yet committed, a SELECT * FROM T1 (READPAST) yields values 1, 2, 4, 5. READPAST is primarily used to reduce locking contention when implementing a work queue that uses a SQL Server table. A queue reader that uses READPAST skips past queue entries locked by other transactions to the next available queue entry, without having to wait until the other transactions release their locks.ROWLOCK
Specifies that row locks are taken when page or table locks are ordinarily taken. When specified in transactions operating at the SNAPSHOT isolation level, row locks are not taken unless ROWLOCK is combined with other table hints that require locks, such as UPDLOCK and HOLDLOCK.
Source MSDN Table Hints (Transact-SQL)
You might also need to use UPDLOCK and/or HOLDLOCK
As already mentioned, you can use the auto increment built-in functionality, such as identity columns or sequence.
If you don't want this, you need to make the access to the table in serial manner: using application lock or other abilities.
For example, you can add the hints to the FIRST access to the table (in the transaction) like the below:
UPDATE CfgCerealNumber
Set CerealNumber = CerealNumber + 1
FROM CfgCerealNumber with (tablockx, holdlock)
WHERE CerealNumberID = @TableID
This will guarantee the sequential access to the table in all parallel threads.
One option would be to use the sp_getapplock system stored procedure and use the built in locking of sql server to ensure serialized access to a resource.
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
sp_getapplock will ensure that the transaction has an exclusive lock. The updates and reads will commit before the next thread can use it, so there cannot be any dirty reads.
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
Bacon Bits beat me to it, but using the OUTPUT
clause will be the easiest way to work around your racing issue. Off course locking is an option too, although I think it will have slightly higher overhead. That said, using an IDENTITY
column or a SEQUENCE
is so much easier than trying to implement this functionality manually.
I took the liberty of putting the answer in your code and add a few remarks:
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
Remarks:
SET NOCOUNT OFF
again before exiting the stored procedure. As you go out of scope this setting will return back to what it was before you entered the stored procedure.WITH (UPDLOCK)
, but it certainly won't hurt.ROLLBACK
and then do a RaisError()
simply because the latter might cause the connection to be dropped by some client-software and/or you might be inside a TRY...CATCH
. Both will break the flow of the commands and you'll end up with transaction count mismatch.