Post Categories

Background

Designing Stored Procedures that are safe for multiple subscribers (to call simultaneously) can be challenging. For example let’s say that you want multiple worker processes to poll a shared work queue that’s encapsulated as a SQL Table. This is a common scenario and through experience you’ll find that you want to use Table Hints to prevent unwanted locking when performing simultaneous queries on the same table. There are three table hints to consider: NOLOCK, READPAST and UPDLOCK. Both NOLOCK and READPAST table hints allow you to SELECT from a table without placing a LOCK on that table. However, SELECTs with the READPAST hint will ignore any records that are locked due to being updated/inserted (or otherwise “dirty”), whereas a SELECT with NOLOCK ignores all locks including dirty reads. For the initial update of the flag (that marks the record as available for subscription) I don’t use the NOLOCK Table Hint because I want to be sensitive to the “active” records in the table and I want to exclude them. I use an Update Lock (UPDLOCK) in conjunction with a WHERE clause that uses a sub-select with a READPAST Table Hint in order to explicitly lock the records I’m updating (UPDLOCK) but not place a lock on the table when selecting the records that I’m going to update (READPAST).

UPDATES should be allowed to lock the rows affected because we’re probably changing a flag on a record so that it is not included in a SELECT from another subscriber. On the UPDATE statement we should explicitly use the UPDLOCK to guard against lock escalation. A SELECT to check for the next record(s) to process can result in a shared read lock being held by more than one subscriber polling the shared work queue (SQL table). It is expected that more than one worker process (or server) might try to process the same new record(s) at the same time. When each process then tries to obtain the update lock, none of them can because another process has a shared read lock in place. Thus without the UPDLOCK hint the result would be a lock escalation deadlock; however with the UPDLOCK hint this condition is mitigated against.

Note that using the READPAST table hint requires that you also set the ISOLATION LEVEL of the transaction to be READ COMMITTED (rather than the default of SERIALIZABLE).

Guidance

In the Stored Procedure that returns records to the multiple subscribers:

Perform the UPDATE first. Change the flag that makes the record available to subscribers. Additionally, you may want to update a LastUpdated datetime field in order to be able to check for records that “got stuck” in an intermediate state or for other auditing purposes.

In the UPDATE statement use the (UPDLOCK) Table Hint on the UPDATE statement to prevent lock escalation.

In the UPDATE statement also use a WHERE Clause that uses a sub-select with a (READPAST) Table Hint to select the records that you’re going to update.

In the UPDATE statement use the OUTPUT clause in conjunction with a Temporary Table to isolate the record(s) that you’ve just updated and intend to return to the subscriber. This is the fastest way to update the record(s) and to get the records’ identifiers within the same operation.

Finally do a set-based SELECT on the main Table (using the Temporary Table to identify the records in the set) with either a READPAST or NOLOCK table hint. Use NOLOCK if there are other processes (besides the multiple subscribers) that might be changing the data that you want to return to the multiple subscribers; or use READPAST if you're sure there are no other processes (besides the multiple subscribers) that might be updating column data in the table for other purposes (e.g. changes to a person’s last name). NOLOCK is generally the better fit in this part of the scenario.

See the following as an example:

CREATE PROCEDURE [dbo].[usp_NewCustomersSelect]

AS

BEGIN

-- OVERRIDE THE DEFAULT ISOLATION LEVEL

SET TRANSACTION ISOLATION LEVEL READ COMMITTED

-- SET NOCOUNT ON

SET NOCOUNT ON

-- DECLARE TEMP TABLE

-- Note that this example uses CustomerId as an identifier;

-- you could just use the Identity column Id if that’s all you need.

DECLARE @CustomersTempTable TABLE

(

CustomerId NVARCHAR(255)

)

-- PERFORM UPDATE FIRST

-- [Customers] is the name of the table

-- [Id] is the Identity Column on the table

-- [CustomerId] is the business document key used to identify the

-- record globally, i.e. in other systems or across SQL tables

-- [Status] is INT or BIT field (if the status is a binary state)

-- [LastUpdated] is a datetime field used to record the time of the

-- last update

UPDATE [Customers] WITH (UPDLOCK)

SET

[Status] = 1,

[LastUpdated] = GETDATE()

OUTPUT

[INSERTED].[CustomerId]

INTO @CustomersTempTable

WHERE ([Id] = (SELECT TOP 100 [Id]

FROM [Customers] WITH (READPAST)

WHERE ([Status] = 0)

ORDER BY [Id] ASC))

-- PERFORM SELECT FROM ENTITY TABLE

SELECT

[C].[CustomerId],

[C].[FirstName],

[C].[LastName],

[C].[Address1],

[C].[Address2],

[C].[City],

[C].[State],

[C].[Zip],

[C].[ShippingMethod],

[C].[Id]

FROM [Customers] AS [C] WITH (NOLOCK), @CustomersTempTable AS [TEMP]

WHERE ([C].[CustomerId] = [TEMP].[CustomerId])

END

In a system that has been designed to have multiple status values for records that need to be processed in the Work Queue it is necessary to have a “Watch Dog” process by which “stale” records in intermediate states (such as “In Progress”) are detected, i.e. a [Status] of 0 = New or Unprocessed; a [Status] of 1 = In Progress; a [Status] of 2 = Processed; etc.. Thus, if you have a business rule that states that the application should only process new records if all of the old records have been processed successfully (or marked as an error), then it will be necessary to build a monitoring process to detect stalled or stale records in the Work Queue, hence the use of the LastUpdated column in the example above. The Status field along with the LastUpdated field can be used as the criteria to detect stalled / stale records. It is possible to put this watchdog logic into the stored procedure above, but I would recommend making it a separate monitoring function. In writing the stored procedure that checks for stale records I would recommend using the same kind of lock semantics as suggested above. The example below looks for records that have been in the “In Progress” state ([Status] = 1) for greater than 60 seconds:

CREATE PROCEDURE [dbo].[usp_NewCustomersWatchDog]

AS

BEGIN

-- TO OVERRIDE THE DEFAULT ISOLATION LEVEL

SET TRANSACTION ISOLATION LEVEL READ COMMITTED

-- SET NOCOUNT ON

SET NOCOUNT ON

DECLARE @MaxWait int; SET @MaxWait = 60

IF EXISTS (SELECT 1

FROM [dbo].[Customers] WITH (READPAST)

WHERE ([Status] = 1)

AND (DATEDIFF(s, [LastUpdated], GETDATE()) > @MaxWait))

BEGIN

SELECT 1 AS [IsWatchDogError]

END

ELSE

BEGIN

SELECT 0 AS [IsWatchDogError]

END

END

Downloads

The zip file below contains two SQL scripts: one to create a sample database with the above stored procedures and one to populate the sample database with 10,000 sample records.