Yesterday I had my mind changed about the best way to do concurrency. I describe several methods in Mythbusting: Concurrent Update/Insert Solutions. My preferred method is to increase the isolation level and fine tune locks.
At least that was my preference. I recently changed my approach to use a method that gbn suggested in the comments. He describes his method as the “TRY CATCH JFDI pattern”. Normally I avoid solutions like that. There’s a rule of thumb that says developers should not rely on catching errors or exceptions for control flow. But I broke that rule of thumb yesterday.
By the way, I love the gbn’s description for the pattern “JFDI”. It reminds me of Shia Labeouf’s motivational video.

Okay, I’ll tell you the story.
The Original Defect
So there’s this table. It’s defined something like:
CREATETABLE dbo.AccountDetails( Email NVARCHAR(400)NOTNULLCONSTRAINT PK_AccountDetails PRIMARYKEY(Email), Created DATETIMENOTNULLCONSTRAINT DF_AccountDetails_Created DEFAULTGETUTCDATE(), Etc NVARCHAR(MAX)) |
And there’s a procedure defined something like:
CREATEPROCEDURE dbo.s_GetAccountDetails_CreateIfMissing( @Email NVARCHAR(400), @Etc NVARCHAR(MAX))AS DECLARE @Created DATETIME; DECLARE @EtcDetails NVARCHAR(MAX); SETTRANSACTIONISOLATIONLEVEL SERIALIZABLE SET XACT_ABORT ONBEGINTRAN SELECT @Created = Created, @EtcDetails = Etc FROM dbo.AccountDetailsWHERE Email = @Email IF @Created ISNULLBEGINSET @Created =GETUTCDATE(); SET @EtcDetails = @Etc; INSERTINTO dbo.AccountDetails(Email, Created, Etc)VALUES(@Email, @Created, @EtcDetails); END COMMIT SELECT @Email as Email, @Created as Created, @EtcDetails as Etc |
Applications executing this procedure were deadlocking with each other. If you’re keen, try to figure out why before reading ahead. It’s pretty close to the problem described in the Mythbusting post. Specifically this was method 3: increased isolation level.
Initial Fix
So I decided to fine tune locks. I added an UPDLOCK hint:
CREATEPROCEDURE dbo.s_GetAccountDetails_CreateIfMissing( @Email NVARCHAR(400), @Etc NVARCHAR(MAX))AS DECLARE @Created DATETIME; DECLARE @EtcDetails NVARCHAR(MAX); SETTRANSACTIONISOLATIONLEVEL SERIALIZABLE SET XACT_ABORT ONBEGINTRAN SELECT @Created = Created, @EtcDetails = Etc FROM dbo.AccountDetailsWITH(UPDLOCK)WHERE Email = @Email IF @Created ISNULLBEGINSET @Created =GETUTCDATE(); SET @EtcDetails = @Etc; INSERTINTO dbo.AccountDetails(Email, Created, Etc)VALUES(@Email, @Created, @EtcDetails); END COMMIT SELECT @Email as Email, @Created as Created, @EtcDetails as Etc |
Bail Early If Possible
Okay, so this solution works. It’s concurrent and it performs just fine. I realized though that I can improve this further by avoiding the transaction and locks. Basically select the row and if it exists, bail early:
CREATEPROCEDURE dbo.s_GetAccountDetails_CreateIfMissing( @Email NVARCHAR(400), @Etc NVARCHAR(MAX))AS DECLARE @Created DATETIME; DECLARE @EtcDetails NVARCHAR(MAX); SELECT @Created = Created, @EtcDetails = EtcFROM dbo.AccountDetailsWHERE Email = @Email; IF(@Created ISNOTNULL)BEGINSELECT @Email as Email, @Created as Created, @EtcDetails as Etc;RETURN;END SETTRANSACTIONISOLATIONLEVEL SERIALIZABLE SET XACT_ABORT ONBEGINTRAN SELECT @Created = Created, @EtcDetails = Etc FROM dbo.AccountDetailsWITH(UPDLOCK)WHERE Email = @Email; IF @Created ISNULLBEGINSET @Created =GETUTCDATE(); SET @EtcDetails = @Etc; INSERTINTO dbo.AccountDetails(Email, Created, Etc)VALUES(@Email, @Created, @EtcDetails); END COMMIT SELECT @Email as Email, @Created as Created, @EtcDetails as Etc; |
Take A Step Back
Okay, this is getting out of hand. The query shouldn’t have to be this complicated.
Luckily I work with a guy named Chris. He’s amazing at what he does. He questions everything without being a nitpicker (there’s a difference). He read through the Mythbusters post and followed all the links in the comments. He asked whether gbn’s JFDI pattern wasn’t better here. So I implemented it just to see what that looked like:
CREATEPROCEDURE dbo.s_GetAccountDetails_CreateIfMissing( @Email NVARCHAR(400), @Etc NVARCHAR(MAX))ASBEGINTRYINSERTINTO dbo.AccountDetails Email, Etc SELECT @Email, @Etc WHERENOTEXISTS(SELECT*FROM dbo.AccountDetailsWHERE Email = @Email ); ENDTRYBEGINCATCH-- ignore duplicate key errors, throw the rest.IF ERROR_NUMBER()<>2601 THROW; ENDCATCH SELECT Email, Created, Etc FROM dbo.AccountDetailsWHERE Email = @Email; |
Look at how much better that looks! No elevated transaction isolation levels. No query hints. The procedure itself is half as long as it used to be. The SQL is so much simpler and for that reason, I prefer this approach. I am happy in this case to use error handling for control flow.
So I checked in the change and updated my pull request. Chris’s last comment before he approved the pull request was “Looks good. Ugly pragmatism FTW.”