10

I have a table like this:

+----+-----------+------+-------+--+
| id | Part      | Seq  | Model |  |
+----+-----------+------+-------+--+
| 1  | Head      | 0    | 3     |  |
| 2  | Neck      | 1    | 3     |  |
| 3  | Shoulders | 2    | 29    |  |
| 4  | Shoulders | 2    | 3     |  |
| 5  | Stomach   | 5    | 3     |  |
+----+-----------+------+-------+--+

How can I insert another record with the next seq after Stomach for Model 3. So here is what the new table suppose to look like:

+----+-----------+------+-------+--+
| id | Part      | Seq  | Model |  |
+----+-----------+------+-------+--+
| 1  | Head      | 0    | 3     |  |
| 2  | Neck      | 1    | 3     |  |
| 3  | Shoulders | 2    | 29    |  |
| 4  | Shoulders | 2    | 3     |  |
| 5  | Stomach   | 5    | 3     |  |
| 6  | Groin     | 6    | 3     |  |
+----+-----------+------+-------+--+

Is there a way to craft an insert query that will give the next number after the highest seq for Model 3 only. Also, looking for something that is concurrency safe.

Luke101
  • 63,072
  • 85
  • 231
  • 359

9 Answers9

12

If you do not maintain a counter table, there are two options. Within a transaction, first select the MAX(seq_id) with one of the following table hints:

  1. WITH(TABLOCKX, HOLDLOCK)
  2. WITH(ROWLOCK, XLOCK, HOLDLOCK)

TABLOCKX + HOLDLOCK is a bit overkill. It blocks regular select statements, which can be considered heavy even though the transaction is small.

A ROWLOCK, XLOCK, HOLDLOCK table hint is probably a better idea (but: read the alternative with a counter table further on). The advantage is that it does not block regular select statements, ie when the select statements don't appear in a SERIALIZABLE transaction, or when the select statements don't provide the same table hints. Using ROWLOCK, XLOCK, HOLDLOCK will still block insert statements.

Of course you need to be sure that no other parts of your program select the MAX(seq_id) without these table hints (or outside a SERIALIZABLE transaction) and then use this value to insert rows.

Note that depending on the number of rows that are locked this way, it is possible that SQL Server will escalate the lock to a table lock. Read more about lock escalation here.

The insert procedure using WITH(ROWLOCK, XLOCK, HOLDLOCK) would look as follows:

DECLARE @target_model INT=3;
DECLARE @part VARCHAR(128)='Spine';
BEGIN TRY
    BEGIN TRANSACTION;
    DECLARE @max_seq INT=(SELECT MAX(seq) FROM dbo.table_seq WITH(ROWLOCK,XLOCK,HOLDLOCK) WHERE model=@target_model);
    IF @max_seq IS NULL SET @max_seq=0;
    INSERT INTO dbo.table_seq(part,seq,model)VALUES(@part,@max_seq+1,@target_model);
    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    ROLLBACK TRANSACTION;
END CATCH

An alternative and probably a better idea is to have a counter table, and provide these table hints on the counter table. This table would look like the following:

CREATE TABLE dbo.counter_seq(model INT PRIMARY KEY, seq_id INT);

You would then change the insert procedure as follows:

DECLARE @target_model INT=3;
DECLARE @part VARCHAR(128)='Spine';
BEGIN TRY
    BEGIN TRANSACTION;
    DECLARE @new_seq INT=(SELECT seq FROM dbo.counter_seq WITH(ROWLOCK,XLOCK,HOLDLOCK) WHERE model=@target_model);
    IF @new_seq IS NULL 
        BEGIN SET @new_seq=1; INSERT INTO dbo.counter_seq(model,seq)VALUES(@target_model,@new_seq); END
    ELSE
        BEGIN SET @new_seq+=1; UPDATE dbo.counter_seq SET seq=@new_seq WHERE model=@target_model; END
    INSERT INTO dbo.table_seq(part,seq,model)VALUES(@part,@new_seq,@target_model);
    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    ROLLBACK TRANSACTION;
END CATCH

The advantage is that fewer row locks are used (ie one per model in dbo.counter_seq), and lock escalation cannot lock the whole dbo.table_seq table thus blocking select statements.

You can test all this and see the effects yourself, by placing a WAITFOR DELAY '00:01:00' after selecting the sequence from counter_seq, and fiddling with the table(s) in a second SSMS tab.


PS1: Using ROW_NUMBER() OVER (PARTITION BY model ORDER BY ID) is not a good way. If rows are deleted/added, or ID's changed the sequence would change (consider invoice id's that should never change). Also in terms of performance having to determine the row numbers of all previous rows when retrieving a single row is a bad idea.

PS2: I would never use outside resources to provide locking, when SQL Server already provides locking through isolation levels or fine-grained table hints.

TT.
  • 15,774
  • 6
  • 47
  • 88
  • in your opinion is this a heavy transaction? – Luke101 Feb 16 '16 at 00:58
  • @Luke101 Answer revised to account for heavyness of the transaction. – TT. Feb 16 '16 at 10:14
  • Hello, I am going to implement this solution and see how it works. I'll let you know. – Luke101 Feb 18 '16 at 00:10
  • I like (and I use) the counter table approach, however your implementation has a little flaw, a primary key violation error may occur when two concurrent sessions try to get the next seq for a non-existent model. The statement that may fail is `INSERT INTO dbo.counter_seq(model,seq)VALUES(@target_model,@new_seq)` – Jesús López Feb 20 '16 at 07:55
  • @JesúsLópez That is simply not true. You can easily verify this (and I just did) starting with an empty counter table, having this script twice with one having a `WAITFOR DELAY` after the retrieval of the counter. Start the one with the wait, then launch the other. You will see that the second will wait for the first to complete, specifically on the retrieval of the counter. Both will finish successfully, without any duplicates. – TT. Feb 20 '16 at 08:02
  • @JesúsLópez You will see from `EXEC sp_lock` that an exclusive range lock was placed on the key. – TT. Feb 20 '16 at 08:07
  • My apologies, you are right and I'm wrong. I checked that, it doesn't hapen – Jesús López Feb 20 '16 at 08:30
  • @JesúsLópez No worries, your remark helps highlight that this solution is solid. – TT. Feb 20 '16 at 08:49
  • I have implemented this solution and all is well. I wish I could award you more but I orginally set award at 50 – Luke101 Feb 22 '16 at 15:22
  • @TT. trying to use this to solve my problem. in my case i have Seq but thats sort of random at the moment, so trying to start 900001 to end (but in table there are numbers like 904852, 901235, etc.. random). Lets see if i can do it successfully. – RAM4 Jul 18 '17 at 16:33
4

The correct way to handle such insertions is to use an identity column or, if you prefer, a sequence and a default value for the column.

However, you have a NULL value for the seq column, which does not seem correct.

The problem with a query such as:

Insert into yourtable(id, Part, Seq, Model)
    Select 6, 'Groin', max(Seq) + 1, 3 
    From yourtable;

is that two such queries, running at the same time, could produce the same value. The recommendation is to declare seq as a unique, identity column and let the database do all the work.

Gordon Linoff
  • 1,242,037
  • 58
  • 646
  • 786
  • Yes, some sequence numbers can be null. Also, all of them can be null as well. Is there a way around that? – Luke101 Feb 08 '16 at 14:41
  • If the values are intended to enumerate records for a given model, why would `NULL` values be allowed? – Gordon Linoff Feb 08 '16 at 15:15
  • I have changed my table to not have NULL values. Now I am faced with the problem of concurrency. Is this solution concurrency safe? Also, the increment must happen only for Model 3. – Luke101 Feb 15 '16 at 16:25
  • @Luke101 . . . The current solution is to use an `identity` column or a sequence. – Gordon Linoff Feb 15 '16 at 16:42
  • Yes, I am using an identity on the id field. I must increment the `Seq` field only for `Model: 3` not whole table. Your solution will find max for whole table. Also, i read somewhere that max is not safe for concurrency. Is that true? – Luke101 Feb 15 '16 at 16:51
  • @Luke101 . . . (1) You can add `where model = 3` to the query; (2) That is correct, this is not safe for concurrent updates. You either need to do locking with expensive transactions to do the calculation when you fetch the data. – Gordon Linoff Feb 15 '16 at 17:41
3

Let's first list the challenges:

  1. We cannot use a normal constraint as there are existing null values and we also need to cater for duplicates as well as gaps - if we look at the existing data. This is fine, we will figure it out ;-> in step 3
  2. We require safety for concurrent operations (thus some form or mix of transactions, isolation levels and possibly a "kinda SQL mutex".) Gut feel here is a stored proc for a couple of reasons:

    2.1 It protects more easily from sql injection

    2.2 We can control the isolation levels (table locking) more easily and recover from some issues which come with this kind of requirement

    2.3 We can use application level db locks to control the concurrency

  3. We must store or find the next value on every insert. The word concurrency tells us already that there will be contention and probably high throughput (else please stick to single threads). So we must already be thinking: do not read from the same table you want to write to in an already complicated world.

So with that short prequel, let's attempt a solution:

As a start, we are creating your original table and then also a table to hold the sequence (BodyPartsCounter) which we are setting to the last used sequence + 1:

    CREATE TABLE BodyParts
        ([id] int identity, [Part] varchar(9), [Seq] varchar(4), [Model] int)
    ;

    INSERT INTO BodyParts
        ([Part], [Seq], [Model])
    VALUES
        ('Head', NULL, 3),
        ('Neck', '1', 3),
        ('Shoulders', '2', 29),
        ('Shoulders', '2', 3),
        ('Stomach', '5', 3)
    ;

    CREATE TABLE BodyPartsCounter
        ([id] int
        , [counter] int)
    ;

    INSERT INTO BodyPartsCounter
        ([id], [counter])
    SELECT 1, MAX(id) + 1 AS id FROM BodyParts
    ;

Then we need to create the stored procedure which will do the magic. In short, it acts as a mutex, basically guaranteeing you concurrency (if you do not do inserts or updates into the same tables elsewhere). It then get's the next seq, updates it and inserts the new row. After this has all happened it will commit the transaction and release the stored proc for the next waiting calling thread.

SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
-- =============================================
-- Author:      Charlla
-- Create date: 2016-02-15
-- Description: Inserts a new row in a concurrently safe way
-- =============================================
CREATE PROCEDURE InsertNewBodyPart 
@bodypart varchar(50), 
@Model int = 3
AS
BEGIN
-- SET NOCOUNT ON added to prevent extra result sets from
-- interfering with SELECT statements.
SET NOCOUNT ON;

    BEGIN TRANSACTION;

    -- Get an application lock in your threaded calls
    -- Note: this is blocking for the duration of the transaction
    DECLARE @lockResult int;
    EXEC @lockResult = sp_getapplock @Resource = 'BodyPartMutex', 
                   @LockMode = 'Exclusive';
    IF @lockResult = -3 --deadlock victim
    BEGIN
        ROLLBACK TRANSACTION;
    END
    ELSE
    BEGIN
        DECLARE @newId int;
        --Get the next sequence and update - part of the transaction, so if the insert fails this will roll back
        SELECT @newId = [counter] FROM BodyPartsCounter WHERE [id] = 1;
        UPDATE BodyPartsCounter SET [counter] = @newId + 1 WHERE id = 1;

        -- INSERT THE NEW ROW
        INSERT INTO dbo.BodyParts(
            Part
            , Seq
            , Model
            )
            VALUES(
                @bodypart
                , @newId
                , @Model
            )
        -- END INSERT THE NEW ROW
        EXEC @lockResult = sp_releaseapplock @Resource = 'BodyPartMutex';
        COMMIT TRANSACTION;
    END;

END
GO

Now run the test with this:

EXEC    @return_value = [dbo].[InsertNewBodyPart]
    @bodypart = N'Stomach',
    @Model = 4

SELECT  'Return Value' = @return_value

SELECT * FROM BodyParts;
SELECT * FROM BodyPartsCounter

This all works - but be careful - there's a lot to consider with any kind of multithreaded app.

Hope this helps!

Charl
  • 982
  • 6
  • 12
  • With a slight change to the sp you can store the next sequence based on your model where the id field can be mapped to your model and store the next seq for that model. Just change the 'where id = 1' to 'where id = @model' in both select and update statements. – Charl Feb 15 '16 at 20:29
  • Does this method acquire a table lock? If so, This may be kinda slow. I have about 100 concurrent users on the site at a time. I'd say about 2-3 users may try to update the table at a time. – Luke101 Feb 16 '16 at 01:16
  • @Luke101 It will not obtain a table lock. It will however obtain a "lock" on the stored proc (the mutex). But without that you will almost certainly run into deadlocks. The complete call executes in 3 ms and that will not grow as the table grows, because we are using a singlerow table for our sequence. If you use the MAX(Seq) option, as the table grows is will take longer to find the max value as there are more rows, you will also have indexes that will have to be maintained on the seq field. No need for that. I propose you do the math on your transaction volumes and see if this fits or not. – Charl Feb 16 '16 at 05:26
  • This is great. I have implemented this and works fine. I think this is the best solution so far. wanted to wait just a couple more days before I award. Thanks – Luke101 Feb 17 '16 at 14:27
  • @Luke101 The downside here is that there's a lock to obtain a new sequence ID for **all** models. If you have three processes wanting to each insert a different model, they will all have to wait for each other. This is comparable to an exclusive table lock (on the counter table in this instance). To modify this way of working to obtain row level locking would require embedding the model-id in the `@Resource` parameter. But why bother with that if you can ask this sort of lock in the SQL language using table hints? I seriously doubt the poster is using this in production. – TT. Feb 17 '16 at 22:27
  • @Luke101 Another note is that using `sp_getapplock` does not guarantee that deadlocks will never happen. Deadlocks don't happen if your program is written correctly ie having short transactions and in one batch, accessing objects in the same order, etc (research what deadlocks are). Locks that are placed on objects by SQL Server (table, row, ...) are similar to locks acquired by `sp_getapplock` only they are not standalone but are applied to database objects. It doesn't matter if your lock is standalone or placed on a database object, deadlocks arise from incorrect programming. – TT. Feb 17 '16 at 22:42
  • @Luke101 `sp_getapplock` is used as a last resort when you are getting deadlocks and you can't change the programming due to whatever reason (legacy, complexity, time constraints, ...). There are other legitimate uses (eg locking for serializing access to an outside resource) but they don't apply in this case. If you do plan to use `sp_getapplock` I would advise you to look up some threads about it on legitimate use cases and see if they apply in your case. – TT. Feb 17 '16 at 22:54
  • @TT. Thanks for the positive criticism, that's what drives us forward. A couple of remarks on your concerns. – Charl Feb 18 '16 at 05:58
  • 1. I have used this in prod systems very successfully and a lot of guys out there with really challenging issues tend to do the same. 2. sp_getapplock is definitely not a last resort. It is purposely built to do exactly what we are doing here - provide exclusive access to a portion of sql which needs to run in sequence, with the aim at minimizing deadlocks and not just hoping they don't happen. We are locking the SP - we control what we can control. By all means, yes, nothing can guarantee no deadlocks overall, but we can try and control it by managing the planned execution path. – Charl Feb 18 '16 at 06:07
  • @Luke101 (also for @Chad) I am of course speaking from the experience I have, so the things I laid out are subjective in a sense. I have never had the need for `sp_getapplock` in the software I am currently writing. We use a `counter` table with rowlocking to establish order IDs, invoice IDs and similar IDs (tied to a financial year) that are required to start at 1 and should increase without gaps (legal requirements). If we were to experience deadlocks using rowlocks I'm pretty sure using a standalone lock at that level would also result in deadlocks. – TT. Feb 18 '16 at 06:43
  • @Luke101 (also for @Chad) But `@Chad`'s experiences are valid too of course and if it's suitable for `@Chad`'s use cases why should it not be for anyone else? I prefer to use language constructs when they are suitable. I do admit that using an `applock` is easier to understand, it is a single visible, tangible lock that you maintain. That versus SQL Server's locking levels (table, row, page, schema...), locking behaviour (shared, update, exclusive, intent, ...), lock escalation (row->page->table), isolation levels (serializable, read committed, ...) that requires more insight in the process. – TT. Feb 18 '16 at 06:56
2

I believe the best bet to handle this kind of sequence generation scenario is the counter table as TT suggested. I just wanted to show you here a slightly simplified version of TT implementation.

Tables:

CREATE TABLE dbo.counter_seq(model INT PRIMARY KEY, seq INT);
CREATE TABLE dbo.table_seq(part varchar(128), seq int, model int);

Simpler version (No SELECT statement to retrieve the current seq):

DECLARE @target_model INT=3;
DECLARE @part VARCHAR(128)='Otra MAS';

BEGIN TRY
    BEGIN TRANSACTION;
    DECLARE @seq int = 1
    UPDATE dbo.counter_seq WITH(ROWLOCK,HOLDLOCK) SET @seq = seq = seq + 1 WHERE model=@target_model;
    IF @@ROWCOUNT = 0 INSERT INTO dbo.counter_seq VALUES (@target_model, 1);
    INSERT INTO dbo.table_seq(part,seq,model)VALUES(@part,@seq,@target_model);
    COMMIT
END TRY
BEGIN CATCH
    ROLLBACK TRANSACTION;
END CATCH
Community
  • 1
  • 1
Jesús López
  • 8,338
  • 7
  • 40
  • 66
0

Since you want the sequence to be based on the a specific model, just add that into the where clause when doing the select. This will ensure the Max(SEQ) pertains only to that model series. Also since the SEQ can be null wrap it in a ISNULL, so if it is null it will be 0, so 0 + 1, will set the next to 1. The basic way to do this is :

Insert into yourtable(id, Part, Seq, Model)
    Select 6, 'Groin', ISNULL(max(Seq),0) + 1, 3 
    From yourtable
    where MODEL = 3;
Holmes IV
  • 1,673
  • 2
  • 23
  • 47
0

I would not try to store the Seq value in the table in the first place.

As you said in the comments, your ID is IDENTITY, which increases automatically in a very efficient and concurrent-safe way by the server. Use it for determining the order in which rows were inserted and the order in which the Seq values should be generated.

Then use ROW_NUMBER to generate values of Seq partitioned by Model (the sequence restarts from 1 for each value of Model) as needed in the query.

SELECT
    ID
    ,Part
    ,Model
    ,ROW_NUMBER() OVER(PARTITION BY Model ORDER BY ID) AS Seq
FROM YourTable
Vladimir Baranov
  • 31,799
  • 5
  • 53
  • 90
  • In some cases the user can change the sequence of the records. – Luke101 Feb 16 '16 at 01:24
  • OK. Valid point. In this case you have to store the `Seq`. But, do you really need to have separate sequences for each `Model`? I would make `Seq` set to `ID` by default (when the row is inserted) and later allow user to swap two `Seq` values to adjust the order of two rows. – Vladimir Baranov Feb 16 '16 at 01:30
0
insert into tableA (id,part,seq,model)
values
(6,'Groin',(select MAX(seq)+1 from tableA where model=3),3)
Sunil Bharath
  • 206
  • 1
  • 7
0
create function dbo.fncalnxt(@model int)
returns int 
begin
declare @seq int
select @seq= case when @model=3 then max(id) --else
end from tblBodyParts
return @seq+1
end
--query idea To insert values, ideal if using SP to insert
insert into tblBodyParts values('groin',dbo.fncalnxt(@model),@model)

You can try this i guess. A novice shot, correct me if im wrong. i'd suggest using function to get the value in seq column based on model; you'll have to check the else case though to return another value you want, when model!=3, it'll return null now.

saik
  • 3
  • 1
0

Assuming you have following table:

CREATE TABLE tab (
    id int IDENTITY(1,1) PRIMARY KEY,
    Part VARCHAR(32) not null,
    Seq int not null,
    Model int not null
);

INSERT INTO
    tab(Part,Seq,Model)
VALUES
    ('Head', 0, 3),
    ('Neck', 1, 3),
    ('Shoulders', 2, 29),
    ('Shoulders', 2, 3),
    ('Stomach', 5, 3);

The query below will allow you to import multiple records, without ruine the model_seq

INSERT INTO
    tab (model, part, model_seq)
SELECT
    n.model,
    n.part,
    -- ensure new records will get receive the proper model_seq
    IFNULL(max_seq + model_seq, model_seq) AS model_seq
FROM
    (
        SELECT
            -- row number for each model new record
            ROW_NUMBER() OVER(PARTITION BY model ORDER BY part) AS model_seq,
            n.model,
            n.part,
            MAX(t.seq) AS max_seq
        FROM
            -- Table-values constructor allows you to prepare the
            -- temporary data (with multi rows),
            -- where you could join the existing one
            -- to retrieve the max(model_seq) if any
            (VALUES
                ('Stomach',3),
                ('Legs',3),
                ('Legs',29),
                ('Arms',1)
            ) AS n(part, model)
        LEFT JOIN
            tab
        ON
            tab.model = n.model
        GROUP BY
            n.model n.part
    ) AS t

We need row_number() to ensure if we import more than one value the order will be kept. More info about ROW_NUMBER() OVER() (Transact-SQL)

Table-value constructor is used to create a table with the new values and join the MAX model_seq for model. You could find more about table-value contructor here: Table Value Constructor (Transact-SQL)

Mita
  • 412
  • 3
  • 5