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
If you need to update and return what you updated, then I would just use the OUTPUT clause:
UPDATE CfgCerealNumber
SET CerealNumber = CerealNumber + 1
OUTPUT INSERTED.CerealNumber
WHERE CerealNumberID = @TableID;
If you need additional checking, you can OUTPUT into a declared table variable before returning the result set from the stored procedure.
Another alternative would be to create a blocking lock on the table first, and then update:
SELECT @CerealNumber = CerealNumber + 1
FROM CfgCerealNumber WITH (HOLDLOCK, UPDLOCK)
WHERE CerealNumberID = @TableID;
UPDATE CfgCerealNumber
SET CerealNumber = @CerealNumber
WHERE CerealNumberID = @TableID;
But I would put money down that I've seen this still cause problems. I trust it much less.
The begin transaction / commit transaction will ensure that you have no dirty reads.
There is a drawback in performance, if the procedure is run from inside another transaction the write lock will not be released until the most external transaction will be committed. This will serialize all threads and block concurrency.
See this example (suppose it takes a long time to execute):
begin tran
...
exec GetNextCerealIdentity ... ; -- the write lock is established
...
commit tran -- the write lock is released
It is possible to release the lock before the end of the transaction, but you must create an application lock using procedures sp_getAppLock and sp_releaseAppLock inside the GetNextCerealIdentity procedure.
This can be quite tricky, you must pay attention or you can have both a deadlock or some dirty reads.
You must exec sp_getAppLock at the beginning of your procedure and sp_releaseAppLock at the end (before the return. In your example you have many return's so you will have to release the lock in many points)
Do not forget to release the lock also in case of errors. The lock will be released at the end of the transaction, but you want to release it at the end of the procedure! :-)
You must be sure that your application lock is the only one holding on the table with the counters (CfgCerealNumber).
Usually SQL Server will put a write lock on the table and will interfere with your lock because the write lock will be released at the end of the transaction and not at the end of your procedure.
You must change the procedure to a transaction level READ UNCOMMITED so that the UPDATE in your code will not generate write locks. remember to go back to COMMITTED in the same moment as you release the application lock.
If you acquire a lock in exclusive mode you will be sure that only one connection will be able to do execute the update / select on table CfgCerealNumber.
You can give the lock any name you want. I used the same name as the table (CfgCerealNumber) but it is not important. The most important thing is that you must use the same name for the initial get and for all release that you put in your code.
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
If you change the procedure like that, my previous example will not give problems with concurrency:
begin tran
...
exec GetNextCerealIdentity ... ; -- the lock is established AND released
...
commit tran -- common "write locks" are released
A possible addition is to use the BEGIN/END TRY .. BEGIN/END CATCH construct, so that you release the lock also in case of unexpected exceptions (this will give another pro: you will have a single point of exit from the procedure so you will have a single point where you must put the instructions to release the lock and put back the previous transaction isolation level.
See the following links: (sp_getAppLock) https://msdn.microsoft.com/en-us/library/ms189823.aspx and (sp_releaseAppLock) https://technet.microsoft.com/en-us/library/ms178602.aspx