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 ?
- {Primary key | Unique index} defined on TenantNum
and CustomerNum will prevent any duplicates
- 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.
- Regarding
X
lock behavior: this is possible because two [concurent] X
locks are incompatibles. See table bellow with "Requested mode" and [Granted mode]:

- For above reasons, only one connection/TX can update within
dbo.[Master]
a tenant row with a new customer number.

-- 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).