views:

1069

answers:

5

I'm trying to implement your basic UPSERT functionality, but with a twist: sometimes I don't want to actually update an existing row.

Essentially I'm trying to synchronize some data between different repositories, and an Upsert function seemed like the way to go. So based largely on Sam Saffron's answer to this question, as well as some other research and reading, I came up with this stored procedure:

(note: I'm using MS SQL Server 2005, so the MERGE statement isn't an option)

CREATE PROCEDURE [dbo].[usp_UpsertItem] 
    -- Add the parameters for the stored procedure here
    @pContentID varchar(30) = null, 
    @pTitle varchar(255) = null,
    @pTeaser varchar(255) = null 
AS
BEGIN
    -- SET NOCOUNT ON added to prevent extra result sets from
    -- interfering with SELECT statements.
    SET NOCOUNT ON;

    BEGIN TRANSACTION

     UPDATE dbo.Item WITH (SERIALIZABLE)
     SET Title = @pTitle,
      Teaser = @pTeaser
     WHERE ContentID = @pContentID

     IF @@rowcount = 0
      INSERT INTO dbo.Item (ContentID, Title, Teaser)
      VALUES (@pContentID, @pTitle, @pTeaser)

    COMMIT TRANSACTION
END

I'm comfortable with this for a basic Upsert, but I'd like to make the actual update conditional on the value of another column. Think of it as "locking" a row so that no further updates may be made by the Upsert procedure. I could change the UPDATE statement like so:

UPDATE dbo.Item WITH (SERIALIZABLE)
SET Title = @pTitle,
    Teaser = @pTeaser
WHERE ContentID = @pContentID
AND RowLocked = false

But then the subsequent Insert would fail with a unique constraint violation (for the ContentID field) when it tries to insert a row that already exists but wasn't updated because it was "locked".

So does this mean that I no longer have a classic Upsert, i.e. that I'll have to select the row every time to determine whether it can be updated or inserted? I'm betting that's the case, so I guess what I'm really asking for is help getting the transaction isolation level correct so that the procedure will execute safely.

A: 

You could switch the order of the update/insert around. So you do the insert within a try/catch and if you get a constraint violation then do the update. It feels a little dirty though.

Steve Weet
I always thought that you weren't supposed to rely on error handlers for "normal" processing, i.e. if I know that a typical use case will raise an exception, then I should check for that condition and handle it before it raises an exception. So I agree, it does feel a little dirty ;) If I can get the isolation level right (I'm still reading) then the logic is fairly straightforward - but I lose the original advantage of the upsert (i.e. no extra DB read).
Matt
+2  A: 

I slapped together the following script to proof this trick I used in years past. If you use it, you'll need to modify it to suit your purposes. Comments follow:

/*
CREATE TABLE Item
 (
   Title      varchar(255)  not null
  ,Teaser     varchar(255)  not null
  ,ContentId  varchar(30)  not null
  ,RowLocked  bit  not null
)


UPDATE item
 set RowLocked = 1
 where ContentId = 'Test01'

*/


DECLARE
  @Check varchar(30)
 ,@pContentID varchar(30)
 ,@pTitle varchar(255)
 ,@pTeaser varchar(255)

set @pContentID = 'Test01'
set @pTitle     = 'TestingTitle'
set @pTeaser    = 'TestingTeasier'

set @check = null

UPDATE dbo.Item
 set
   @Check = ContentId
  ,Title  = @pTitle
  ,Teaser = @pTeaser
 where ContentID = @pContentID
  and RowLocked = 0

print isnull(@check, '<check is null>')

IF @Check is null
    INSERT dbo.Item (ContentID, Title, Teaser, RowLocked)
     values (@pContentID, @pTitle, @pTeaser, 0)

select * from Item

The trick here is that you can set values in local variables within an Update statement. Above, the "flag" value gets set only if the update works (that is, the update criteria are met); otherwise, it won't get changed (here, left at null), you can check for that, and process accordingly.

As for the transaction and making it serializable, I'd like to know more about what must be encapsulated within the transaction before suggesting how to proceed.

-- Addenda, follow-up from second comment below -----------

Mr. Saffron's ideas are a thorough and solid way of implementing this routine since your primary keys are defined outside and passed into the database (i.e. you're not using identity columns--fine by me, they are often overused).

I did some more testing (added a primary key constraint on column ContentId, wrap the UPDATE and INSERT in a transaction, add the serializable hint to the update) and yes, that should do everything you want it to. The failed update slaps a range lock on that part of the index, and that will block any simultaneous attempts to insert that new value in the column. Of course, if N requests are submitted simultaneously, the "first" will create the row, and it will be immediately updated by the second, third, etc.--unless you set the "lock" somewhere along the line. Good trick!

(Note that without the index on the key column, you'd lock the entire table. Also, the range lock may lock the rows on "either side" of the new value--or maybe they won't, I didn't test that one out. Shouldn't matter, since the duration of the operation should [?] be in single-digit milliseconds.)

Philip Kelley
To mention, in the original sample code you update table Item, but inserted into table MailItem; aren't upserts supposed to be applied against the same table?
Philip Kelley
The mismatched table names are a typo (now corrected). I knew you could set local variable with a SELECT, but I'd never tried it with an UPDATE, so that might just do the trick. Regarding the serializable transaction, my (admittedly imperfect) understanding is that if you don't use some sort of lock you can get unique key constraint violations and that "UPDATE with (serializable)" appropriately does this w/out deadlocks. I'm working from the example in the linked question (above) and still reading/trying to make sure I understand exactly what that does.
Matt
Updated my answer with feedback on the above comment.
Philip Kelley
@Philip, thanks for the update and the plain language about hints and locks. However, when I tested I realized your approach using "IF @Check is null" is functionaly equivalent to the original "IF @@rowcount = 0" because @Check will only have a value if the row is updated. When the row exists but isn't updated, @check is null and I get a unique constraint violation from the subsequent insert. So your code works as described, it just doesn't solve my problem ;) Still, it helped, so +1.
Matt
+2  A: 

A very common problem. Some approaches do not hold up under high concurrency. Described and stress tested here:

Stress testing UPSERTs

Defensive database programming: eliminating IF statements.

In such cases it is not enough to just write some code, you need to expose it to high concurrency. For example, I am not sure that I understood what CptSkippy recommends, but the following demonstrates how to stress test. Set up a table and a procedure:

CREATE TABLE [dbo].[TwoINTs](
      [ID] [int] NOT NULL,
      [i1] [int] NOT NULL,
      [i2] [int] NOT NULL,
      [i3] [int] NOT NULL
);
CREATE PROCEDURE dbo.SaveTwoINTs(@ID INT, @i1 INT, @i2 INT)
AS
BEGIN
      SET NOCOUNT ON;
      SET XACT_ABORT OFF;
      SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
      DECLARE @ret INT;
      SET @ret=0;
      BEGIN TRAN; 
IF EXISTS(SELECT 1 FROM dbo.TwoINTs WHERE ID=@ID) BEGIN
      UPDATE dbo.TwoINTs WITH (SERIALIZABLE)
      SET i1=i1+@i1, i2=i2+@i2 WHERE ID=@ID;
      SET @ret=@@ERROR;
END ELSE BEGIN
     INSERT INTO dbo.TwoINTs(ID, i1, i2, i3)VALUES(@ID, @i1, @i2, @i1);
      SET @ret=@@ERROR;
END;
COMMIT;
RETURN @ret;
END
GO

Set up two loops that execute that procedure:

CREATE PROCEDURE Testers.UpsertLoop1
AS
BEGIN
DECLARE @ID INT, @i1 INT, @i2 INT, @count INT, @ret INT;
SET @count = 0;
WHILE @count<50000 BEGIN
      SELECT @ID = COALESCE(MAX(ID),0) + 1 FROM dbo.TwoInts;
    EXEC @ret=dbo.SaveTwoINTs @ID, 1, 0;
      SET @count = @count + 1;
END;
END;
GO
CREATE PROCEDURE Testers.UpsertLoop2
AS
BEGIN
DECLARE @ID INT, @i1 INT, @i2 INT, @count INT, @ret INT;
SET @count = 0;
WHILE @count<50000 BEGIN
      SELECT @ID = COALESCE(MAX(ID),0) + 1 FROM dbo.TwoInts;
    EXEC @ret=dbo.SaveTwoINTs @ID, 0, 1;
      SET @count = @count + 1;
END;
END;

Execute these procedures in two tabs and see for yourself that you get a lot of errors:

Testers.UpsertLoop1 --run in one tab
Testers.UpsertLoop1 --run in one tab

Msg 2601, Level 14, State 1, Procedure SaveTwoINTs, Line 15
Cannot insert duplicate key row in object 'dbo.TwoINTs' with unique index 'UNQ_TwoInts_ID'.
The statement has been terminated.

Follow the links that I provided to see the approaches that actually work under concurrency.

AlexKuznetsov
@Alex +1 for the links and advice on how to stress test; I'll definitely give it a try.
Matt
A: 

CREATE PROCEDURE [dbo].[usp_UpsertItem] -- Add the parameters for the stored procedure here @pContentID varchar(30) = null, @pTitle varchar(255) = null, @pTeaser varchar(255) = null AS BEGIN -- SET NOCOUNT ON added to prevent extra result sets from -- interfering with SELECT statements. SET NOCOUNT ON;

BEGIN TRANSACTION
    IF EXISTS (SELECT 1 FROM dbo.Item WHERE ContentID = @pContentID
             AND RowLocked = false)
       UPDATE dbo.Item 
       SET Title = @pTitle, Teaser = @pTeaser
       WHERE ContentID = @pContentID
             AND RowLocked = false
    ELSE IF NOT EXISTS (SELECT 1 FROM dbo.Item WHERE ContentID = @pContentID)
            INSERT INTO dbo.Item (ContentID, Title, Teaser)
            VALUES (@pContentID, @pTitle, @pTeaser)

COMMIT TRANSACTION

END

JNappi
Horrible horrible code! Not only are you running a query twice for the same conditional operation but you're using "else if not exists" when a simple "else" would do. See CptSkippy's answer for a better example.
Chris
I agree the other solution is cleaner, but horrible, horrible...I was pointing in the right direction, no?
JNappi
A: 
BEGIN TRANSACTION

IF EXISTS(SELECT 1 FROM dbo.Item WHERE ContentID = @pContentID)
     UPDATE dbo.Item WITH (SERIALIZABLE)
     SET Title = @pTitle, Teaser = @pTeaser
     WHERE ContentID = @pContentID
     AND RowLocked = false
ELSE
     INSERT INTO dbo.Item
          (ContentID, Title, Teaser)
     VALUES
          (@pContentID, @pTitle, @pTeaser)

COMMIT TRANSACTION
CptSkippy
What is RowLocked in (AND RowLocked = false)? Is it a column in your table?
AlexKuznetsov
I stress tested what I understood as your approach, and it does not hold up in high concurrency.
AlexKuznetsov