Optimize Large SQL Server Insert, Update and Delete Processes by Using Batches


By:   |   Updated: 2018-08-23   |   Comments (6)   |   Related: More > T-SQL

Problem

Sometimes you must perform DML processes (insert, update, delete or combinations of these) on large SQL Server tables. If your database has a high concurrency these types of processes can lead to blocking or filling up the transaction log, even if you run these processes outside of business hours. So maybe you were tasked to optimize some processes to avoid large log growths and minimize locks on tables. How can this be done?

Solution

We will do these DML processes using batches with the help of @@ROWCOUNT.  This also give you the ability to implement custom “stop-resume” logic. We will show you a general method, so you can use it as a base to implement your own processes.

Please note that we will not focus on indexes on this tip, of course this can help queries, but I want to show you a worst-case scenario and index creation is another topic.

Basic algorithm

The basic batch process is something like this:

DECLARE @id_control INT
DECLARE @batchSize INT
DECLARE @results INT

SET @results = 1 --stores the row count after each successful batch
SET @batchSize = 10000 --How many rows you want to operate on each batch
SET @id_control = 0 --current batch 

-- when 0 rows returned, exit the loop
WHILE (@results > 0) 
BEGIN
   -- put your custom code here
   SELECT * -- OR DELETE OR UPDATE
   FROM <any Table>
   WHERE <your logic evaluations>
   (
      AND <your PK> > @id_control
      AND <your PK> <= @id_control + @batchSize
   )
   -- very important to obtain the latest rowcount to avoid infinite loops
   SET @results = @@ROWCOUNT

   -- next batch
   SET @id_control = @id_control + @batchSize
END

To explain the code, we use a WHILE loop and run our statements inside the loop and we set a batch size (numeric value) to indicate how many rows we want to operate on each batch.

For this approach, I am assuming the primary key is either an int or a numeric data type, so for this algorithm to work you will need that type of key. So for alphanumeric or GUID keys, this approach won't work, but you can implement some other type of custom batch processing with some additional coding.

So, with the batch size and the key control variable, we validate the rows in the table are within the range.

Important Note: Your process will need to always operate on at least some rows in each batch.  If a batch does not operate on any rows, the process will end as row count will be 0. If you have a situation where only some rows from a large table will be affected, it is better and more secure to use the index/single DML approach. Another approach for these cases is to use a temporary table to filter the rows to be processed and then use this temp table in the loop to control the process.

Our example setup

We will use a test table [MyTestTable] with this definition:

CREATE TABLE [dbo].[MyTestTable](
   [id] [bigint] IDENTITY(1,1) NOT NULL,
   [dataVarchar] [nvarchar](50) NULL,
   [dataNumeric] [numeric](18, 3) NULL,
   [dataInt] [int] NULL,
   [dataDate] [smalldatetime] NULL,
 CONSTRAINT [PK_MyTestTable] PRIMARY KEY CLUSTERED 
(
   [id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO

It contains random information and 6,000,000 records.

Executing a SELECT statement

Here we execute a simple SELECT statement over the entire table. Note, I enabled statistics IO and cleared the data cache first so we have better results for comparison.

DBCC DROPCLEANBUFFERS 

SET STATISTICS IO ON

SELECT *
FROM [dbo].[MyTestTable]
WHERE dataInt > 600

These are the IO results:

Table 'MyTestTable'. Scan count 1, logical reads 65415, physical reads 2, read-ahead reads 65398, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

The SELECT took 1:08 minutes and retrieved 2,395,317 rows.

Base select statement

SELECT Statement using batches

For the same SELECT we implement the following process to do it in batches:

DBCC DROPCLEANBUFFERS 

SET STATISTICS IO ON

DECLARE @id_control INT
DECLARE @batchSize INT
DECLARE @results INT

SET @results = 1
SET @batchSize = 100000
SET @id_control = 0

WHILE (@results > 0)
BEGIN
   -- put your custom code here
   SELECT *
   FROM [dbo].[MyTestTable]
   WHERE dataInt > 600
   AND id > @id_control
      AND id <= @id_control + @batchSize
   
   -- very important to obtain the latest rowcount to avoid infinite loops
   SET @results = @@ROWCOUNT

   -- next batch
   SET @id_control = @id_control + @batchSize
END

The IO results (for each batch):

Table 'MyTestTable'. Scan count 1, logical reads 1092, physical reads 0, read-ahead reads 1088, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

If we multiply it for 60 batches performed it should be around 65,500 logical reads (approximately the same as before, this makes sense since is the same data we are accessing).

But if we look at the overall execution time, it improves by around 10 seconds, with the same number of rows:

batch select time

A SELECT statement is probably not the best way to demonstrate this, so let's proceed with an UPDATE statement.

UPDATE Statement using batches

We will do an UPDATE on a varchar field with random data (so our test is more real), after clearing the cache, we will execute the code.

This is a screenshot of the transaction log before the operation.

Original T-Log size before executing processes
DBCC DROPCLEANBUFFERS 

BEGIN TRAN;

UPDATE [dbo].[MyTestTable]
SET dataVarchar = N'Test UPDATE 1'
WHERE dataInt > 200;

COMMIT TRAN;

The execution took 37 seconds on my machine.

simple update execution time

To find the rows affected, we perform a simple count and we get 4,793,808 rows:

SELECT COUNT(1)
FROM [dbo].[MyTestTable]
WHERE dataVarchar = N'Test UPDATE 1'
rows affected by the simple UPDATE

 Checking the log size again, we can see it grew to 1.5 GB (and then released the space since the database is in SIMPLE mode):

Log usage after the first UPDATE execution

Let's proceed to execute the same UPDATE statement in batches.  We will just change the text Test UPDATE 1 for Test UPDATE 2, this time using the batch process. I also shrunk the transaction log to its original size and perform a cache cleanup before executing.

DBCC DROPCLEANBUFFERS
 
DECLARE @id_control INT
DECLARE @batchSize INT
DECLARE @results INT

SET @results = 1
SET @batchSize = 1000000
SET @id_control = 0

WHILE (@results > 0)
BEGIN
   -- put your custom code here
   BEGIN TRAN;

   UPDATE [dbo].[MyTestTable]
   SET dataVarchar = N'Test UPDATE 2'
   WHERE dataInt > 200
   AND id > @id_control
   AND id <= @id_control + @batchSize

   -- very important to obtain the latest rowcount to avoid infinite loops
   SET @results = @@ROWCOUNT

   COMMIT TRAN;
   
   -- next batch
   SET @id_control = @id_control + @batchSize
END

This time the query execution was 18 seconds, so there was an improvement in time.

execution time by the UPDATE in batches

As we can see there was an improvement with the log spaced used.  This time the log grew to 0.43 GB.

log size after the execution of the UPDATE using batches.

The last thing to verify is the number of rows affected. We can see we have the same row count as the UPDATE above, 4,793,808 rows.

Rows affected by the second batch execution

As you can see, for very large DML processes, running in smaller batches can help on execution time and transaction log use.

The only drawback of this method is that your key must be a sequential number and there must ne at least one row in each batch, so the process does not end before being applied to all data.

Next Steps
  • You can determine if your process can use this batch method just running the SELECT statements and comparing the number of expected rows with the results.
  • You can increase/decrease the batch size to suit your needs, but for it to have meaning the batch size must be less than 50% of the expected rows to be processed.
  • This process can be adapted to implement a “stop-retry” logic so already processed rows can be skipped if you decide to cancel the execution.
  • It also supports multi-statement processes (in fact, this is the real-world use of this approach) and you can achieve this with a “control” table having all the records to work with and update accordingly.
  • If you want to implement an “execution log” you can achieve this by adding PRINT statements. Just now that this could slow down some processes, especially for very small batch sizes.


Last Updated: 2018-08-23


get scripts

next tip button



About the author
MSSQLTips author Eduardo Pivaral Eduardo Pivaral is an MCSA, SQL Server Database Administrator and Developer with over 15 years experience working in large environments.

View all my tips




Post a comment or let the author know this tip helped.

All comments are reviewed, so stay on subject or we may delete your comment. Note: your email address is not published. Required fields are marked with an asterisk (*).

*Name
*Email
Email me updates

Signup for our newsletter

I agree by submitting my data to receive communications, account updates and/or special offers about SQL Server from MSSQLTips and/or its Sponsors. I have read the privacy statement and understand I may unsubscribe at any time.





Sunday, December 01, 2019 - 4:10:50 AM - Carl Back To Top

I think if there are gaps in the ID column fo the table being updated, this approach will fail.

For example MyTestTable does not contain any records with ID 1 - 1000000, but the ID starts from 1000001, there will be no updates performed. One possible approach is to generate a sequential id, guarateed to start from 1, using ROW_NUMBER()


Wednesday, September 25, 2019 - 10:46:39 AM - Eduardo Pivaral Back To Top

Hi Jacob,

yes, you can add some code before to calculate how many rows you have and then based on that assign a more tuned batch size, but this also needs human intervention, cannot be automated at all, since it depends on what your batch process needs to do (it is not the same to just update a int column, that perform a complex string calculation), so... it depends on each case.

There are several ways to obtain the row count for a table, you can check this article that explains them very well:

https://www.brentozar.com/archive/2014/02/count-number-rows-table-sql-server/


Wednesday, September 25, 2019 - 8:11:57 AM - Jacob Back To Top

Informative article. Could you have performed a count on the number of records in the table and then used a partitioning variable to determine your block sizes or does the math get messy?


Thursday, January 24, 2019 - 9:14:23 AM - Paul Nemoi Back To Top

Thank a lot for your post, it was very helpful


Thursday, August 23, 2018 - 9:48:59 AM - Eduardo Pivaral Back To Top

Hi Sureindran,

For non numeric key columns, or where you need to work just with one part of your data, you must use another approach using TOP and/or a temporary table.

I will work on another tip to show you an example of those cases (even if the table does not have primary key defined)


Thursday, August 23, 2018 - 9:28:34 AM - Sureindran Nadesan Back To Top

 How about non identity column, no numeric column?



download

























get free sql tips

I agree by submitting my data to receive communications, account updates and/or special offers about SQL Server from MSSQLTips and/or its Sponsors. I have read the privacy statement and understand I may unsubscribe at any time.



Learn more about SQL Server tools