If I understand the request correctly, the goal is to delete batches of rows, while at the same time, DML operations are occurring on rows throughout the table. The goal is to delete a batch; however, if any underlying rows contained within the range defined by said batch are locked, then we must skip that batch and move to the next batch. We must then return to any batches that were not previously deleted and retry our original delete logic. We must repeat this cycle until all required batches of rows are deleted.
As has been mentioned, it is reasonable to use a READPAST hint and the READ COMMITTED (default) isolation level, in order to skip past ranges that may contain blocked rows. I will go a step further and recommend using the SERIALIZABLE isolation level and nibbling deletes.
SQL Server uses Key-Range locks to protect a range of rows implicitly included in a record set being read by a Transact-SQL statement while using the serializable transaction isolation level...find more here:
With nibbling deletes, our goal is to isolate a range of rows and ensure that no changes will occur to those rows while we are deleting them, that is to say, we do not want phantom reads or insertions. The serializable isolation level is meant to solve this problem.
Before I demonstrate my solution, I would like to add that neither am I recommending switching your database's default isolation level to SERIALIZABLE nor am I recommending that my solution is the best. I merely wish to present it and see where we can go from here.
A few house-keeping notes:
- The SQL Server version that I am using is Microsoft SQL Server 2012 - 11.0.5343.0 (X64)
- My test database is using the FULL recovery model
To begin my experiment, I will set up a test database, a sample table, and I will fill the table with 2,000,000 rows.
SET NOCOUNT ON;
IF DATABASEPROPERTYEX (N'test', N'Version') > 0
ALTER DATABASE [test] SET SINGLE_USER
WITH ROLLBACK IMMEDIATE;
DROP DATABASE [test];
-- Create the test database
CREATE DATABASE [test];
-- Set the recovery model to FULL
ALTER DATABASE [test] SET RECOVERY FULL;
-- Create a FULL database backup
-- in order to ensure we are in fact using
-- the FULL recovery model
-- I pipe it to dev null for simplicity
BACKUP DATABASE [test]
TO DISK = N'nul';
-- Create our table
IF OBJECT_ID('dbo.tbl','U') IS NOT NULL
DROP TABLE dbo.tbl;
CREATE TABLE dbo.tbl
c1 BIGINT IDENTITY (1,1) NOT NULL
, c2 INT NOT NULL
) ON [PRIMARY];
-- Insert 2,000,000 rows
INSERT INTO dbo.tbl
SELECT TOP 2000
At this point, we will need one or more indexes upon which the locking mechanisms of the SERIALIZABLE isolation level can act.
-- Add a clustered index
CREATE UNIQUE CLUSTERED INDEX CIX_tbl_c1
ON dbo.tbl (c1);
-- Add a non-clustered index
CREATE NONCLUSTERED INDEX IX_tbl_c2
ON dbo.tbl (c2);
Now, let us check to see that our 2,000,000 rows were created
So, we have our database, table, indexes, and rows. So, let us set up the experiment for nibbling deletes. First, we must decide how best to create a typical nibbling delete mechanism.
@BatchSize INT = 100
, @LowestValue BIGINT = 20000
, @HighestValue BIGINT = 20010
, @DeletedRowsCount BIGINT = 0
, @RowCount BIGINT = 1;
SET NOCOUNT ON;
WHILE @DeletedRowsCount < ( @HighestValue - @LowestValue )
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
c1 IN (
SELECT TOP (@BatchSize)
c1 BETWEEN @LowestValue AND @HighestValue
SET @RowCount = ROWCOUNT_BIG();
SET @DeletedRowsCount += @RowCount;
WAITFOR DELAY '000:00:00.025';
As you can see, I placed the explicit transaction inside the while loop. If you would like to limit log flushes, then feel free to place it outside the loop. Furthermore, since we are in the FULL recovery model, you may wish to create transaction log backups more often while running your nibbling delete operations, in order to ensure that your transaction log can be prevented from growing outrageously.
So, I have a couple goals with this setup. First, I want my key-range locks; so, I try to keep the batches as small as possible. I also do not want to impact negatively the concurrency on my "gigantic" table; so, I want to take my locks and leave them as fast as I can. So, I recommend that you make your batch sizes small.
Now, I want to provide a very short example of this deletion routine in action. We must open a new window within SSMS and delete one row from our table. I will do this within an implicit transaction using the default READ COMMITTED isolation level.
c1 = 20005;
Was this row actually deleted?
c1 BETWEEN 20000 AND 20010;
Yes, it was deleted.
Now, in order to see our locks, let us open a new window within SSMS and add a code snippet or two. I am using Adam Mechanic's sp_whoisactive, which can be found here: sp_whoisactive
DB_NAME(resource_database_id) AS DatabaseName
DB_NAME(resource_database_id) = 'test'
AND resource_type = 'KEY'
-- Our insert
-- Our deletions
-- Our active sessions
Now, we are ready to begin. In a new SSMS window, let us begin an explicit transaction that will attempt to re-insert the one row that we deleted. At the same time, we will fire off our nibbling delete operation.
The insert code:
SET IDENTITY_INSERT dbo.tbl ON;
INSERT INTO dbo.tbl
( c1 , c2 )
( 20005 , 1 );
SET IDENTITY_INSERT dbo.tbl OFF;
Let us kick off both operations beginning with the insert and followed by our deletes. We can see the key-range locks and exclusive locks.
The insert generated these locks:
The nibbling delete/select is holding these locks:
Our insert is blocking our delete as expected:
Now, let us commit the insert transaction and see what is up.
And as expected, all transactions complete. Now, we must check to see whether the insert was a phantom or whether the delete operation removed it as well.
c1 BETWEEN 20000 AND 20015;
In fact, the insert was deleted; so, no phantom insert was allowed.
So, in conclusion, I think the true intention of this exercise is not to try and track every single row, page, or table-level lock and try to determine whether an element of a batch is locked and would therefore require our delete operation to wait. That may have been the intent of the questioners; however, that task is herculean and basically impractical if not impossible. The real goal is to ensure that no unwanted phenomena arise once we have isolated the range of our batch with locks of our own and then precede to delete the batch. The SERIALIZABLE isolation level achieves this objective. The key is to keep your nibbles small, your transaction log under control, and eliminate unwanted phenomena.
If you want speed, then don't build gigantically deep tables that cannot be partitioned and therefore be unable to use partition switching for the fastest results. The key to speed is partitioning and parallelism; the key to suffering is nibbles and live-locking.
Please let me know what you think.
I created some further examples of the SERIALIZABLE isolation level in action. They should be available at the links below.
Equality Operations - Key-Range Locks on Next Key Values
Equality Operations - Singleton Fetch of Existent Data
Equality Operations - Singleton Fetch of Nonexistent Data
Inequality Operations - Key-Range Locks on Range and Next Key Values