6

Background:

This is a multi-tenant application, so that a normal identity column will not work. All tables have a unique client identifier Clients.id. So each client can have many customers. This column is not included below for simplicity.

We want to generate a unique customer number starting at 1000.

We store the current (last) generated number in a table called Master. Let's say Master.CustomerNumber. So numbers will go 1001, 1002 etc. and the last one is stored there.

So each time we add a customer, we have a query that looks up the current value, increment it, and insert it in Customer.Number.

NOTE: we are using SQL Server 2008. We have multiple servers in a cluster.

What is the best approach to insure that if two customers are added at the same time that each gets a unique customer number? Stored procedure, locking, CFLOCKING?

How do I insure that this process is 'single-threaded' and that the same number is not issued twice?

I do have a unique index on Customer.Number+Clients.id. I am interested in the implementation of how to guarantee uniqueness when generated.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
Rodrigo Murillo
  • 13,080
  • 2
  • 29
  • 50
  • Use an update with an output – paparazzo May 24 '16 at 16:50
  • 3
    Any reason an identity column would not work? – Leigh May 24 '16 at 17:30
  • 1
    Possible duplicate of [Set start value for column with autoincrement](http://stackoverflow.com/questions/1280705/set-start-value-for-column-with-autoincrement) – Scott Stroz May 24 '16 at 17:51
  • @Leigh Yes I should have explained this is a shared/tenant type solution. So Customers are per each Tenant, which I had not shown for simplicity. – Rodrigo Murillo May 24 '16 at 19:27
  • I have clarified my question to clarify that normal identity/autoincrement columns will not work in a multi-tenant DB. These values need to be unique for each Client in the DB, who in turn have many Customers – Rodrigo Murillo May 24 '16 at 19:44
  • 1
    Better options include, each client having their own database or having a many to many relationship between clients and customers. – Dan Bracuk May 25 '16 at 11:44
  • Are you storing 'customer' data and 'tenant' data in the same table? if not, an auto incrementing key would work. If so, you may need to rethink your DB architecture. – Scott Stroz May 25 '16 at 12:25
  • Anyway I am not sure to understand why you can't use an identity column, each time you have a new customer you can just insert the new customer in a table "Customers" and you can get the new IDENTITY value from that table and then insert it in the client table. But maybe you want consecutive values for Customer Number ? If that is the case what happens when you delete a customer ? i guess it is better to stay with the idea you need an identification value, not a consecutive number. – S.Bozzoni May 25 '16 at 17:39
  • Yes I need consecutive numbers – Rodrigo Murillo May 25 '16 at 17:46
  • Ok i am understanding now, i guess you want a customer id restarting from 1 (or 1000) for each client, and to be consecutive. This is just for inventory pourpose or to be printed on reports, database doesn't need a consecutive ID, it needs only an unique identification. So in SQL Server i would optate to just print and calculate that consecutive number in reports. if i need more, i would create a trigger that renumber another field, not the customer ID field, infact in any case you need a trigger that renumbers customers for example when you delete a customer in the middle. – S.Bozzoni May 25 '16 at 22:55
  • S.Bozzi yes a trigger makes sense. I would not want to renumber customers however! Can you suggest the format/implementation for a trigger? – Rodrigo Murillo May 26 '16 at 13:54
  • I added a trigger solution on the latest infromation i had. You can use even the Master table solution with that trigger. Your choice. – S.Bozzoni May 26 '16 at 18:11
  • M confused or this question is related to ColdFusion or not? – Leo the lion May 28 '16 at 09:14
  • try unique and auto increment column start with 1000 – Rahul Vyas May 28 '16 at 09:21
  • You need to clarify, because there is confusion among the answers, comments, etc. Must each client get its own sequence starting sequentially at customer # 1000? Some answers/comments depend on global uniqueness (with gaps for each client), others answers/comments reflect each client with a sequence starting at 1000. – Gary Walker May 28 '16 at 16:01
  • 1
    It's a bad idea to get database keys and 'business keys' mixed up. Alarm bells ring when I hear statements like 'my database key must be four characters'. A business key exhibits those kind of constraints: must start at 1000, must have no gaps. must start with A etc. Don't try and make this a database key. Just use an identity for the database key and a separate generated column for your business key because your business key will no doubt change (like when you get to your 1999th customer) but your database key must not – Nick.Mc Jun 02 '16 at 05:36

4 Answers4

3

You could use following solution:

CREATE TABLE dbo.[Master] (
    -- Foreign key to dbo.Tenant table ?
    -- Only one row for every tenant is allowed => PK on tenant identifier
    TenantNum   INT NOT NULL
        CONSTRAINT PK_Master PRIMARY KEY CLUSTERED (TenantNum),
    -- LastCustomerNum = last generated value for CustomerNum
    -- NULL means no value was generated
    LastCustomerNum INT NULL, 
    -- It will create one clustered unique index on these two columns
    InitialValue INT NOT NULL
        CONSTRAINT DF_Master_InitialValue DEFAULT (1),
    Step        INT NOT NULL
        CONSTRAINT DF_Master_Step DEFAULT (1)
);
GO

CREATE PROCEDURE dbo.p_GetNewCustomerNum
@TenantNum          INT,
@NewCustomerNum     INT OUTPUT,
@HowManyCustomerNum INT = 1 -- Ussualy, we want to generate only one CustomerNum 
AS
BEGIN
    BEGIN TRY
        IF @TenantNum IS NULL
            RAISERROR('Invalid value for @TenantNum: %d', 16, 1, @TenantNum);
        IF @HowManyCustomerNum IS NULL OR @HowManyCustomerNum < 1
            RAISERROR('Invalid value for @HowManyCustomerNum: %d', 16, 1, @HowManyCustomerNum)

        -- It updated the LastCustomerNum column and it assign the new value to @NewCustomerNum output parameter
        UPDATE  m
        SET     @NewCustomerNum 
                    = LastCustomerNum 
                    = CASE WHEN LastCustomerNum IS NULL THEN InitialValue - Step ELSE LastCustomerNum END 
                        + Step * @HowManyCustomerNum
        FROM    dbo.[Master] AS m
        WHERE   m.TenantNum = @TenantNum

        IF @@ROWCOUNT = 0
            RAISERROR('@TenantNum: %d doesn''t exist', 16, 1, @TenantNum);
    END TRY
    BEGIN CATCH
        -- ReThrow intercepted exception/error
        DECLARE @ExMessage NVARCHAR(2048) = ERROR_MESSAGE()
        RAISERROR(@ExMessage, 16, 1)
        -- Use THROW for SQL2012+
    END CATCH
END
GO

Usage (no gaps):

BEGIN TRAN
...
DECLARE @cn INT
EXEC dbo.p_GetNewCustomerNum
    @TenantNum          = ...,
    @NewCustomerNum     = @cn OUTPUT,
    [@HowManyCustomerNum = ...]
...
INSERT INTO dbo.Customer(..., CustomerNum, ...)
VALUES (..., @cs, ...)
COMMIT

Note: if you don't use transactions to generate the new customer number and then to insert this values into Customer table then they could get gaps.

How it works ?

  1. {Primary key | Unique index} defined on TenantNum and CustomerNum will prevent any duplicates
  2. Under default isolation level (READ COMMITTED) but also under READ UNCOMMITTED, REPETABLE READ and SERIALIZABLE, the UPDATE statement require and X lock. If we have two concurent SQL Server sessions (and transactions) which try to generate a new CustomerNum then first session will successfully get the X lock on tenant row and the second session will have to wait till first session (and transaction) will end (COMMIT or ROLLBACK). Note: I assumed that every session has one active transaction.
  3. Regarding X lock behavior: this is possible because two [concurent] X locks are incompatibles. See table bellow with "Requested mode" and [Granted mode]: enter image description here
  4. For above reasons, only one connection/TX can update within dbo.[Master] a tenant row with a new customer number.

enter image description here

-- Tests #1

-- It insert few new and "old" tenants
INSERT  dbo.[Master] (TenantNum) VALUES (101)
INSERT  dbo.[Master] (TenantNum, LastCustomerNum) VALUES (102, 1111)

SELECT * FROM dbo.[Master]
/*
TenantNum   LastCustomerNum InitialValue Step
----------- --------------- ------------ -----------
101         NULL            1            1
102         1111            1            1
*/
GO


-- It generate one CustomerNum for tenant 101
DECLARE @cn INT
EXEC p_GetNewCustomerNum 101, @cn OUTPUT 
SELECT @cn AS [cn]
/*
cn
-----------
1
*/
GO
-- It generate second CustomerNums for tenant 101
DECLARE @cn INT
EXEC p_GetNewCustomerNum 101, @cn OUTPUT 
SELECT @cn AS [cn]
/*
cn
-----------
2
*/
GO
-- It generate three CustomerNums for tenant 101
DECLARE @cn INT
EXEC p_GetNewCustomerNum 101, @cn OUTPUT, 3
SELECT @cn AS [cn]
/*
cn
-----------
5           <-- This ID means that following range was reserved [(5-3)+1, 5] = [3, 5] = {3, 4, 5}; Note: 1 = Step
*/
GO
-- It generate one CustomerNums for tenant 102
DECLARE @cn INT
EXEC p_GetNewCustomerNum 102, @cn OUTPUT 
SELECT @cn AS [cn]
/*
cn
-----------
1112
*/
GO
-- End status of Master table
SELECT * FROM dbo.Master
/*
TenantNum   LastCustomerNum InitialValue Step
----------- --------------- ------------ -----------
101         5               1            1
102         1112            1            1
*/
GO

.

-- Tests #2: To test concurent sesssions / TX you could use bellow script
-- Step 1: Session 1
BEGIN TRAN
    -- It generate three CustomerNums for tenant 101
    DECLARE @cn INT
    EXEC p_GetNewCustomerNum 101, @cn OUTPUT
    SELECT @cn AS [cn] -- > It generates @cn 6



-- Step 2: Session 2
BEGIN TRAN
    -- It generate three CustomerNums for tenant 101
    DECLARE @cn INT
    EXEC p_GetNewCustomerNum 101, @cn OUTPUT -- Update waits for Session 1 to finish
    SELECT @cn AS [cn]
COMMIT


-- Step 3: Session 1
COMMIT -- End of first TX. Check Session 2: it'll output 7.

First end note: to manage transactions and exceptions I would use SET XACT_ABORT ON and/or BEGIN TRAN ... END CATCH. Discussion about this topic is beyond the the purpose of this answer.

Second end note: see updated section "How it works ?" (bullet 3 and 4).

Bogdan Sahlean
  • 19,233
  • 3
  • 42
  • 57
3

I have not reviewed the existing solutions because they are quite long and elaborate. Wouldn't the following be all you need?

CREATE TABLE MasterDB.dbo.Sequences (ClientId INT NOT NULL PRIMARY KEY, LastGeneratedNumber INT NOT NULL)

DECLARE @nextId INT; //Holds the newly allocated ID

UPDATE MasterDB.dbo.Sequences
SET LastGeneratedNumber = LastGeneratedNumber + 1, @nextId = LastGeneratedNumber + 1
WHERE ClientId = 1234

This is correct under any isolation level and any index structure. The row holding the ID information will be U or X locked by the engine.

In case there never has been an ID generated this update statement will not do anything. You can solve that by using MERGE or by using control flow. I recommend MERGE.

Or, you insert a row whenever you create a new client. Set LastGeneratedNumber = 1000 - 1.

There is no need to use stored procedures but you certainly can. There is almost no performance difference to executing this T-SQL as a batch from the application. Do what's more convenient to you.

If you make this T-SQL part of your main transaction the ID assignment will be transactional. It will potentially roll back and it will serialize customer creation. If you don't like that use a separate transaction. That way IDs might be lost, though. This is unavoidable in any solution.

A variation the UPDATE given above is:

UPDATE MasterDB.dbo.Sequences
SET LastGeneratedNumber = LastGeneratedNumber + 1
OUTPUT INSERTED.LastGeneratedNumber
WHERE ClientId = 1234

You could use one sequence per client. This requires that your application executes DDL. This can be awkward. Also, you cannot make ID generation transactional. There is less control. I would not recommend that but it's possible.

usr
  • 168,620
  • 35
  • 240
  • 369
1

I know this is little bit late, but still hope it helps you :)

We do have same situation over here... And we solved it by having a common table in a separate database, which contains only three columns (i.e. tablename, columnname and LastIndex). Now, we use a separate SP to always getting new number from this table with separate transaction (as it should always commit, regardless your main Insert function true/false). So, this will always return with new ID to any request, and that new Index will be used for inserting a record.

Let me know if you need any sample on this.

Nirav
  • 21
  • 3
0

You want to use a Sequence, for example:

CREATE SEQUENCE Customer_Number_Seq
 AS INTEGER
 START WITH 1
 INCREMENT BY 1
 MINVALUE 1000
 MAXVALUE 100
 CYCLE;

and then possibly something like :

 CREATE TABLE Customers
(customer_nbr INTEGER DEFAULT NEXT VALUE FOR Customer_Number_Seq,
 .... other columns ....

The documentation has more details.

BadZen
  • 4,083
  • 2
  • 25
  • 48
  • 1
    Not available in 2008 – paparazzo May 24 '16 at 16:52
  • Ah, I see. Check out https://blogs.msdn.microsoft.com/askjay/2012/10/08/sequence-objects-in-sql-2012-and-sql-2008/ perhaps, for a way to hack in equivalent functionality easily then... (no sequence support as late as 2008??? -tsk-tsk-) – BadZen May 24 '16 at 16:53