I gave my introductory session on transactions at all three of the South African SQL Saturdays in 2016, as well as at SQL Saturday Oregon in October 2017, and something that came up in most of them was the ‘manual sequence’, the idea of using a column in a table to store a max value and using that in place of an identity column or sequence object.
To be clear, I don’t think this is a good idea. The identity column works well if a sequential series of numbers are needed. If the number sequence needs to that spans tables, then the sequence object is a good replacement.
But, there will always be some requirements that insist on gap-less sequences, or insist on not using identity (probably for ‘compatibility’ reasons), so let’s see how to do it properly.
To start, the common attempt (taken from a random Stack Overflow answer)
DECLARE @next INT SET @next = (SELECT (MAX(id) + 1) FROM Table1) INSERT INTO Table1 VALUES (@next)
or, a slightly different form
DECLARE @next INT SELECT @next = SequenceNumber + 1 FROM Table1 UPDATE Table1 SET SequenceNumber = @Next; -- Then use @Next in another table for an insert
This doesn’t work. Oh, to be sure it’ll work in testing, but once we get some heavy concurrent access, its flaws become apparent.
To test the first one, I’m going to use a table that just has an ID (supposed to be unique) and a second column to record which session_id did the insert
CREATE TABLE TestSequence ( ManualID INT NOT NULL, SessionID INT )
And then run this 100 times from 10 different sessions
DECLARE @next INT SET @next = (SELECT (MAX(ManualID) + 1) FROM TestSequence) INSERT INTO TestSequence VALUES (@next, @@SPID)
And it doesn’t work because the select statement takes a shared lock. Shared locks are shared, and so multiple sessions can read the same max value from the table, then write back that same value+1 to the table, either generating duplicate rows or primary key/unique constraint violations (hopefully the latter)
So how do we fix it?
One option is to wrap the two statement in a transaction and add the UPDLOCK hint to the select. This ensures that no one else will be able to read the same max value from the table, but depending on indexes it could also cause some blocking and resultant slow queries.
Another way is to make the insert (or update) and the select a single atomic operation, by returning the inserted (or updated) value from the insert (or update) statement. We can use the OUTPUT clause for this.
Option one would have code similar to this:
BEGIN TRANSACTION DECLARE @next INT; SET @next = (SELECT (MAX (ManualID) + 1) FROM TestSequence WITH (TABLOCKX, HOLDLOCK)); INSERT INTO TestSequence VALUES (@next, @@SPID); COMMIT TRANSACTION
And option 2 looks like
INSERT INTO TestSequence OUTPUT inserted.ManualID SELECT MAX(ManualID) + 1 FROM TestSequence WITH (TABLOCKX, HOLDLOCK)
The locking hints are, unfortunately, necessary. I tried several variations with less restrictive hints and they either:
– Produced duplicates
– Deadlocked when the table was small
– Deadlocked all the time
None of which are desired, hence the use of an exclusive table lock to serialise access. Of course, the restrictive locks will make this slow under concurrent usage. An index on ManualID will help, a bit.
Now we can test both of those the same way we tested the first version. An easy way to see whether there are any duplicates is to check the count and the distinct count.
To reiterate something I said earlier, I do not recommend using this. Identity columns, with their gaps, are fine for the majority of cases, especially the use of them for artificial primary keys. Artificial keys, if used, are meaningless numbers that should not be exposed to users, and hence gaps should be irrelevant.
The need for a gap-less sequence, stored in the table, should be an exceptional one, not a common one.