SQLServerCentral Article

Introducing the Set-based Loop

,

It’s a common assumption that when used in SQL, WHILE loops (often used to traverse a cursor) are bad for performance. While most of the time this seems to be true, the problem isn’t the WHILE loop, but rather the operations performed in it. The performance problem comes from the iterative processing or RBAR (RBAR is a “Modenism” for “Row-By-Agonizing-Row”. Coined by Jeff Moden). As it turns out, WHILE loops are often used by programmers with procedural programming background and lesser understanding of set-based methodologies, which also are more likely to use them in conjunction with single row or RBAR operations. Such programmers fail to understand that SQL is a Declarative language and that SQL Server is a relational database engine optimized for non-sequential set-based queries.

In this article, I’ll try to show the difference between a RBAR loop, a recursive CTE and a set-based loop. When the latter was applied to a real life problem, it improved performance of a query that ran for over 25 hours and wasn’t able to complete, to a query that finished in two minutes. The table had over 15 million rows and was constantly growing.

The original problem

Let me give some background information. A money lending business requires that clients belong to a group in order to obtain a loan. Clients can belong to different groups, but will be assigned to an employee based on its original group. The information available consists of a table with clients and their current group affiliation and a table showing group history movements. There’s no way to directly identify the first group for each client and therefor there’s no way to obtain the correct employee for that client without traversing the hierarchy. Each solution presented in here will traverse the hierarchy to define the original group for each client and allow us to assign the correct employee.

The following code will generate sample data to give you an idea on how the data was presented.

USE Test; --Be sure that you're on a safe database. You could use tempdb as well.
GO
IF OBJECT_ID( 'dbo.GroupsHistory') IS NOT NULL --Prevent errors on reruns
    DROP TABLE dbo.GroupsHistory;
CREATE TABLE dbo.GroupsHistory( --Create the table with relevant columns
    MemberId            int NOT NULL,
    GroupId             int NOT NULL,
    PreviousMemberId    int NULL,
    PreviousGroupId     int NULL,
    StartDate           date NOT NULL,
    Level               int NOT NULL --This column wasn't available on the original problem. It's here to help with test data generation.
);
IF OBJECT_ID( 'dbo.Groups') IS NOT NULL --Prevent errors on reruns
    DROP TABLE dbo.Groups;
CREATE TABLE dbo.Groups( --Create the table with relevant columns
    ClientId            int IDENTITY NOT NULL,
    MemberId            int NOT NULL,
    GroupId             int NOT NULL,
    OriginalGroupId     int NULL,
    OriginalMemberId    int NULL
);
--Insert first level of test data
WITH E(n) AS(
    SELECT n FROM (VALUES(0),(0),(0),(0),(0),(0),(0),(0),(0),(0))E(n)
),
TestData AS(
    SELECT ABS( CHECKSUM(NEWID())) % 100000 CurrentGroupId,
        DATEADD( dd, ABS( CHECKSUM(NEWID())) % 365, '20100101') StartDate
    FROM E a, E b, E c, E d, E f, E g
)
INSERT INTO GroupsHistory(MemberId, GroupId, StartDate, Level)
SELECT ROW_NUMBER() OVER(PARTITION BY CurrentGroupId ORDER BY (SELECT NULL)),
    CurrentGroupId,
    StartDate,
    1
FROM TestData;
--Generate the next levels for test data
DECLARE @Level int = 1;
WHILE @Level < 6
BEGIN
    INSERT INTO GroupsHistory(MemberId, GroupId, PreviousMemberId, PreviousGroupId, StartDate, Level)
    SELECT TOP 50 percent
        ROW_NUMBER() OVER( PARTITION BY GroupId ORDER BY (SELECT NULL)),
        ABS( CHECKSUM(NEWID())) % (100000 / @Level) + ( @Level * 100000),
        MemberId,
        GroupId,
        DATEADD( dd, ABS( CHECKSUM(NEWID())) % 365, StartDate) StartDate,
        @Level + 1
    FROM dbo.GroupsHistory
    WHERE Level = @Level
    ORDER BY NEWID();
    SET @Level += 1;
END;
INSERT INTO dbo.Groups(GroupId, MemberId)
SELECT GroupId, MemberId
FROM dbo.GroupsHistory g
WHERE NOT EXISTS(
    SELECT 1
    FROM GroupsHistory h
    WHERE g.GroupId = h.PreviousGroupId
    AND g.MemberId = h.PreviousMemberId);

This will generate between 800,000 and 900,000 clients (according to my tests) with their corresponding history which will contain almost 2 million rows. This volume of data will allow us to evaluate performance in a noticeable way even if it doesn’t completely imitate the original data.

The problematic solution

The initial solution had a cursor with a nested loop that would update each client with the previous group until there wasn’t a previous group to update. This wonderful piece of procedural code ran for 25 hours and didn’t even update half of the clients’ original groups. When it ran for over 12 hours during the night and the following day, someone decided to ask for help.

I’ve rebuild the code from memory as the original code was destroyed, shredded and completely erased so it wouldn’t return again. It is a classic example of RBAR on steroids that can only crawl instead of run.

DECLARE @ClientId int,
    @CurrentGroupId int,
    @CurrentMemberId int,
    @GroupId int,
    @MemberId int;
DECLARE GroupMembers CURSOR FOR
SELECT g.ClientId, g.GroupId, g.MemberId
FROM dbo.Groups g
WHERE OriginalGroupId IS NULL
OR OriginalMemberId IS NULL;
OPEN GroupMembers;
FETCH NEXT FROM GroupMembers INTO @ClientId, @CurrentGroupId, @CurrentMemberId;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @GroupId = @CurrentGroupId;
    SET @MemberId = @CurrentMemberId;
    WHILE EXISTS(
        SELECT h.GroupId, h.MemberId
        FROM dbo.GroupsHistory h
        WHERE h.GroupId = @GroupId
        AND h.MemberId = @MemberId)
    BEGIN
        SELECT @GroupId = PreviousGroupId,
            @MemberId = PreviousMemberId
        FROM GroupsHistory h
        WHERE h.GroupId = @GroupId
        AND h.MemberId = @MemberId;
        IF @GroupId IS NULL
            BREAK;
        UPDATE g SET
            OriginalGroupId = @GroupId,
            OriginalMemberId = @MemberId
        FROM Groups g
        WHERE ClientId = @ClientId
    END
    FETCH NEXT FROM GroupMembers INTO @ClientId, @CurrentGroupId, @CurrentMemberId;
END;
CLOSE GroupMembers;
DEALLOCATE GroupMembers;

I’d love to tell you how long would this take to run, but I’m not sure that I have enough patience to let it run for completion. I can tell you that it will update about 128 clients per minute on my laptop. Extrapolating, this it would take more than 100 hours of execution time for the code to complete. Considering that the sample data was generated in less than one minute, this approach is simply ridiculous.

So, what’s wrong with this approach?

If you say that the problem is that there’s a nested loop within the cursor, you’d be correct, but the problem goes beyond that. This approach is updating the table, row by row, once for every row the history table has. This means that it doesn’t matter if there were only one hundred clients, if the history table has 15 million rows, it will update the Groups table 15 million times.

The “set-based” solution

When seeing this kind of recursive structure, one might be tempted to use a recursive CTE (rCTE) instead – and that was my first choice as well. Recursive CTEs are a powerful tool when you need to traverse a hierarchy with a single statement, so it seems something logical to use it here because it would only update the table once. My original recursive CTE approach looked something like this:

WITH rCTE AS(
    SELECT g.ClientId, g.GroupId, g.MemberId, h.PreviousGroupId, h.PreviousMemberId
    FROM Groups g
    JOIN GroupsHistory h ON g.GroupId = h.GroupId AND g.MemberId = h.MemberId
    UNION ALL
    SELECT g.ClientId, g.GroupId, g.MemberId, h.PreviousGroupId, h.PreviousMemberId
    FROM rCTE g
    JOIN GroupsHistory h ON g.PreviousGroupId = h.GroupId AND g.PreviousMemberId = h.MemberId  
)
UPDATE g SET
    OriginalGroupId = r.GroupId,
    OriginalMemberId = r.MemberId
FROM dbo.Groups g
JOIN rCTE r ON g.ClientId = r.ClientId
WHERE r.PreviousGroupId IS NULL;

It looks simpler than the previous RBAR option and it’s a single statement. With the sample data presented, it ran in 1 minute and 30 seconds, not bad for an improvement. There was one small problem. When I ran this update against the real data, it was still running after four hours and didn’t complete. Since it was a single statement, not a single row was updated. Even if it had completed in five hours, it would be a lot of time and something had to be done to improve the process.

The plot twist

I might have been too dramatic with the timings presented earlier for the RBAR solution. Of course we could improve the query by adding some indexes, improving the cursor declaration instead of using the default values and using an explicit transaction. First, I defined the cursor as static, forward only and read only. Then I created the following indexes:

CREATE CLUSTERED INDEX CI_Groups ON dbo.Groups(ClientId ASC);
CREATE NONCLUSTERED INDEX IX_GroupsHistory ON dbo.GroupsHistory( MemberId ASC, GroupId ASC)
INCLUDE ( PreviousMemberId, PreviousGroupId);

I ran my tests again and got good news and bad news. The bad news was that half of the rows didn’t have the columns OriginalGroupId and OriginalMemberId updated. The fix was easy, because I realized that those where the clients without history, so I added an update at the end to correct the issue and I finally had a complete solution. The code ended up looking like this:

DECLARE @ClientId int,
    @CurrentGroupId int,
    @CurrentMemberId int,
    @GroupId int,
    @MemberId int;
BEGIN TRAN;
DECLARE GroupMembers CURSOR STATIC FORWARD_ONLY READ_ONLY
FOR
SELECT g.ClientId, g.GroupId, g.MemberId
FROM dbo.Groups g
WHERE OriginalGroupId IS NULL
OR OriginalMemberId IS NULL;
OPEN GroupMembers;
FETCH NEXT FROM GroupMembers INTO @ClientId, @CurrentGroupId, @CurrentMemberId;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @GroupId = @CurrentGroupId;
    SET @MemberId = @CurrentMemberId;
    WHILE EXISTS(
        SELECT h.GroupId, h.MemberId
        FROM dbo.GroupsHistory h
        WHERE h.GroupId = @GroupId
        AND h.MemberId = @MemberId)
    BEGIN
        SELECT @GroupId = PreviousGroupId,
            @MemberId = PreviousMemberId
        FROM GroupsHistory h
        WHERE h.GroupId = @GroupId
        AND h.MemberId = @MemberId;
        IF @GroupId IS NULL
            BREAK;
        UPDATE g SET
            OriginalGroupId = @GroupId,
            OriginalMemberId = @MemberId
        FROM Groups g
        WHERE ClientId = @ClientId;
        SET @GroupId = NULL;
        SET @MemberId = NULL;
    END
    FETCH NEXT FROM GroupMembers INTO @ClientId, @CurrentGroupId, @CurrentMemberId;
END;
CLOSE GroupMembers;
DEALLOCATE GroupMembers;
UPDATE g SET
    OriginalGroupId = GroupId,
    OriginalMemberId = MemberId
FROM Groups g
WHERE OriginalGroupId IS NULL;
COMMIT TRAN;

The good news is that the query completed in 50 seconds. That’s simply awesome considering how the code was crawling with despair and now we have a code that’s running faster than the rCTE. How is it possible that a RBAR approach is running faster than a supposed set-based solution? There’s a problem when using recursive CTEs, the optimizer can’t estimate the correct the number of rows and it won’t create optimal plans. This problem might not occur every time, depending on different factors, but this problem is beyond the scope of this article.

There’s also a heavy usage of tempdb to create the worktable needed for this operation. The indexes didn’t have a major impact on the rCTE as it went down from 90 seconds to 70 seconds, still slower than the improved RBAR approach.

You could say that 50 seconds is fast enough for a process that ran for hours without completing, but I can assure you that fast enough isn’t fast enough when there’s still room for improvement.

The Set-based Loop

I was able to identify the problems for the previous approaches, so I just needed to give the engine the possibility to get the estimates correct and to avoid RBAR. The solution was to go back to a loop but thinking in sets. Basically, instead of doing one update per row, it would do one update per level. Even if this approach seemed to be more resource intensive, it would give the optimizer the capabilities to estimate the rows correctly. The updates turned out like this:

UPDATE g SET
    OriginalGroupId = ISNULL( h.PreviousGroupId, h.GroupId), --Uses ISNULL for first time clients
    OriginalMemberId = ISNULL( h.PreviousMemberId, h.MemberId)
FROM dbo.Groups g
JOIN dbo.GroupsHistory h ON g.GroupId = h.GroupId AND g.MemberId = h.MemberId;
WHILE @@ROWCOUNT > 0 --Update until there's no previous group for any Client
    UPDATE g SET
        OriginalGroupId = h.PreviousGroupId,
        OriginalMemberId = h.PreviousMemberId
    FROM dbo.Groups g
    JOIN dbo.GroupsHistory h ON g.OriginalGroupId = h.GroupId AND g.OriginalMemberId = h.MemberId
    WHERE h.PreviousGroupId IS NOT NULL;

Once more, the code is simpler than the original option. The performance was even better as it took only 17 seconds to run on my laptop. This means that this query completed over four times faster than the rCTE and almost three times faster than the improved RBAR approach. On real data, it took two minutes to run and up to five minutes after a few validations were added according to some business rules that weren’t included before. I don’t want to make any assumptions, but it also seems to scale without problems, although the differences in data distribution might affect the performance as well.

Testing

Of course, a single run wouldn’t prove much because some factors (such as the current server workload) could affect the timing. That’s why I had to be sure that the results would be consistent and not just mere luck. Creating a test harness to prove that an improvement is real and not a simple perception is as important as the improvement itself. That’s why I created the following test (the full script is available in the Resources).

USE Test; --Be sure that you're on a safe database. You could use tempdb as well.
GO
SET NOCOUNT ON;
IF OBJECT_ID( 'dbo.GroupsHistory') IS NOT NULL --Prevent errors on reruns
    DROP TABLE dbo.GroupsHistory;
CREATE TABLE dbo.GroupsHistory( --Create the table with relevant columns
    MemberId            int NOT NULL,
    GroupId             int NOT NULL,
    PreviousMemberId    int NULL,
    PreviousGroupId     int NULL,
    StartDate           date NOT NULL,
    Level               int NOT NULL --This column wasn't available on the original problem. It's here to help with test data generation.
);
IF OBJECT_ID( 'dbo.Groups') IS NOT NULL --Prevent errors on reruns
    DROP TABLE dbo.Groups;
CREATE TABLE dbo.Groups( --Create the table with relevant columns
    ClientId            int IDENTITY NOT NULL,
    MemberId            int NOT NULL,
    GroupId             int NOT NULL,
    OriginalGroupId     int NULL,
    OriginalMemberId    int NULL
);
--Insert first level of test data
WITH E(n) AS(
    SELECT n FROM (VALUES(0),(0),(0),(0),(0),(0),(0),(0),(0),(0))E(n)
),
TestData AS(
    SELECT ABS( CHECKSUM(NEWID())) % 100000 CurrentGroupId,
        DATEADD( dd, ABS( CHECKSUM(NEWID())) % 365, '20100101') StartDate
    FROM E a, E b, E c, E d, E f, E g
)
INSERT INTO GroupsHistory(MemberId, GroupId, StartDate, Level)
SELECT ROW_NUMBER() OVER(PARTITION BY CurrentGroupId ORDER BY (SELECT NULL)),
    CurrentGroupId,
    StartDate,
    1
FROM TestData;
--Generate the next levels for test data
DECLARE @Level int = 1;
WHILE @Level < 6
BEGIN
    INSERT INTO GroupsHistory(MemberId, GroupId, PreviousMemberId, PreviousGroupId, StartDate, Level)
    SELECT TOP 50 percent
        ROW_NUMBER() OVER( PARTITION BY GroupId ORDER BY (SELECT NULL)),
        ABS( CHECKSUM(NEWID())) % (100000 / @Level) + ( @Level * 100000),
        MemberId,
        GroupId,
        DATEADD( dd, ABS( CHECKSUM(NEWID())) % 365, StartDate) StartDate,
        @Level + 1
    FROM dbo.GroupsHistory
    WHERE Level = @Level
    ORDER BY NEWID();
    SET @Level += 1;
END;
INSERT INTO dbo.Groups(GroupId, MemberId)
SELECT GroupId, MemberId
FROM dbo.GroupsHistory g
WHERE NOT EXISTS(
    SELECT 1
    FROM dbo.GroupsHistory h
    WHERE g.GroupId = h.PreviousGroupId
    AND g.MemberId = h.PreviousMemberId);
--Create a timings table
IF OBJECT_ID('dbo.Timings') IS NOT NULL
    DROP TABLE dbo.Timings;
CREATE TABLE dbo.Timings( With_Index bit, rbar_seconds int, rCTE_seconds int, While_seconds int);
GO
DECLARE
    @SecondsRBAR int,
    @SecondsrCTE int,
    @SecondsWhile int,
    @Time   datetime;
--Clean the procedure cache
DBCC FREEPROCCACHE WITH NO_INFOMSGS; 
--Initialize the columns
UPDATE g SET
    OriginalGroupId = NULL,
    OriginalMemberId = NULL
FROM dbo.Groups g
WHERE OriginalGroupId IS NOT NULL;
--Clean the buffer to read from disk and avoid possible misinterpretations
CHECKPOINT; --Ensure that the dirty pages are written out to disk.
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
--Set the timer
SET @Time = GETDATE();

--RCTE Update
WITH rCTE AS(
    SELECT g.ClientId, g.GroupId, g.MemberId, h.PreviousGroupId, h.PreviousMemberId
    FROM dbo.Groups g
    JOIN dbo.GroupsHistory h ON g.GroupId = h.GroupId AND g.MemberId = h.MemberId
    UNION ALL
    SELECT g.ClientId, g.GroupId, g.MemberId, h.PreviousGroupId, h.PreviousMemberId
    FROM rCTE g
    JOIN dbo.GroupsHistory h ON g.PreviousGroupId = h.GroupId AND g.PreviousMemberId = h.MemberId   
)
UPDATE g SET
    OriginalGroupId = r.GroupId,
    OriginalMemberId = r.MemberId
FROM dbo.Groups g
JOIN rCTE r ON g.ClientId = r.ClientId
WHERE r.PreviousGroupId IS NULL;
--Get the time in seconds
SET @SecondsrCTE = DATEDIFF(ss, @Time, GETDATE());
--Initialize the columns
UPDATE g SET
    OriginalGroupId = NULL,
    OriginalMemberId = NULL
FROM dbo.Groups g
WHERE OriginalGroupId IS NOT NULL;
--Clean the buffer to read from disk and avoid possible misinterpretations
CHECKPOINT; --Ensure that the dirty pages are written out to disk.
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
--Set the timer
SET @Time = GETDATE();
--Set-based loop Update
BEGIN TRAN;
UPDATE g SET
    OriginalGroupId = ISNULL( h.PreviousGroupId, h.GroupId), --Uses ISNULL for first time clients
    OriginalMemberId = ISNULL( h.PreviousMemberId, h.MemberId)
FROM dbo.Groups g
JOIN dbo.GroupsHistory h ON g.GroupId = h.GroupId AND g.MemberId = h.MemberId;
WHILE @@ROWCOUNT > 0 --Update until there's no previous group for any Client
    UPDATE g SET
        OriginalGroupId = h.PreviousGroupId,
        OriginalMemberId = h.PreviousMemberId
    FROM dbo.Groups g
    JOIN dbo.GroupsHistory h ON g.OriginalGroupId = h.GroupId AND g.OriginalMemberId = h.MemberId
    WHERE h.PreviousGroupId IS NOT NULL;
COMMIT TRAN;
--Get the time in seconds
SET @SecondsWhile = DATEDIFF(ss, @Time, GETDATE());
--Insert the times into our timings table
INSERT INTO dbo.Timings
    (with_index, rbar_seconds, rCTE_seconds, while_seconds)
VALUES(0, @SecondsRBAR,@SecondsrCTE, @SecondsWhile);
--Run the test 10 times
GO 10
--Create indexes   
CREATE CLUSTERED INDEX CI_Groups ON dbo.Groups(ClientId ASC);
CREATE NONCLUSTERED INDEX IX_GroupsHistory ON [dbo].[GroupsHistory] ([MemberId],[GroupId]) INCLUDE ([PreviousMemberId],[PreviousGroupId]);
GO

DECLARE
    @SecondsRBAR int,
    @SecondsrCTE int,
    @SecondsWhile int,
    @Time   datetime;
--Clean the procedure cache
DBCC FREEPROCCACHE WITH NO_INFOMSGS; 
--Initialize the columns
UPDATE g SET
    OriginalGroupId = NULL,
    OriginalMemberId = NULL
FROM dbo.Groups g
WHERE OriginalGroupId IS NOT NULL;
--Clean the buffer to read from disk and avoid possible misinterpretations
CHECKPOINT; --Ensure that the dirty pages are written out to disk.
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
--Set the timer
SET @Time = GETDATE();
DECLARE @ClientId int,
    @CurrentGroupId int,
    @CurrentMemberId int,
    @GroupId int,
    @MemberId int;
BEGIN TRAN;
DECLARE GroupMembers CURSOR STATIC FORWARD_ONLY READ_ONLY
FOR
SELECT g.ClientId, g.GroupId, g.MemberId
FROM dbo.Groups g
WHERE OriginalGroupId IS NULL
OR OriginalMemberId IS NULL;
OPEN GroupMembers;
FETCH NEXT FROM GroupMembers INTO @ClientId, @CurrentGroupId, @CurrentMemberId;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @GroupId = @CurrentGroupId;
    SET @MemberId = @CurrentMemberId;
    WHILE EXISTS(
        SELECT h.GroupId, h.MemberId
        FROM dbo.GroupsHistory h
        WHERE h.GroupId = @GroupId
        AND h.MemberId = @MemberId)
    BEGIN
        SELECT @GroupId = PreviousGroupId,
            @MemberId = PreviousMemberId
        FROM GroupsHistory h
        WHERE h.GroupId = @GroupId
        AND h.MemberId = @MemberId;
        IF @GroupId IS NULL
            BREAK;
        UPDATE g SET
            OriginalGroupId = @GroupId,
            OriginalMemberId = @MemberId
        FROM Groups g
        WHERE ClientId = @ClientId;
        SET @GroupId = NULL;
        SET @MemberId = NULL;
    END;
    FETCH NEXT FROM GroupMembers INTO @ClientId, @CurrentGroupId, @CurrentMemberId;
END;
CLOSE GroupMembers;
DEALLOCATE GroupMembers;
UPDATE g SET
    OriginalGroupId = GroupId,
    OriginalMemberId = MemberId
FROM Groups g
WHERE OriginalGroupId IS NULL;
COMMIT TRAN;
--Get the time in seconds
SET @SecondsRBAR = DATEDIFF(ss, @Time, GETDATE());
--Initialize the columns
UPDATE g SET
    OriginalGroupId = NULL,
    OriginalMemberId = NULL
FROM dbo.Groups g
WHERE OriginalGroupId IS NOT NULL;
--Clean the buffer to read from disk and avoid possible misinterpretations
CHECKPOINT; --Ensure that the dirty pages are written out to disk.
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
--Set the timer
SET @Time = GETDATE();
--RCTE Update
WITH rCTE AS(
    SELECT g.ClientId, g.GroupId, g.MemberId, h.PreviousGroupId, h.PreviousMemberId
    FROM dbo.Groups g
    JOIN dbo.GroupsHistory h ON g.GroupId = h.GroupId AND g.MemberId = h.MemberId
    UNION ALL
    SELECT g.ClientId, g.GroupId, g.MemberId, h.PreviousGroupId, h.PreviousMemberId
    FROM rCTE g
    JOIN dbo.GroupsHistory h ON g.PreviousGroupId = h.GroupId AND g.PreviousMemberId = h.MemberId   
)
UPDATE g SET
    OriginalGroupId = r.GroupId,
    OriginalMemberId = r.MemberId
FROM dbo.Groups g
JOIN rCTE r ON g.ClientId = r.ClientId
WHERE r.PreviousGroupId IS NULL;
--Get the time in seconds
SET @SecondsrCTE = DATEDIFF(ss, @Time, GETDATE());
--Initialize the columns
UPDATE g SET
    OriginalGroupId = NULL,
    OriginalMemberId = NULL
FROM dbo.Groups g
WHERE OriginalGroupId IS NOT NULL;
--Clean the buffer to read from disk and avoid possible misinterpretations
CHECKPOINT; --Ensure that the dirty pages are written out to disk.
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
--Set the timer
SET @Time = GETDATE();
BEGIN TRAN;
--Set-based loop Update
UPDATE g SET
    OriginalGroupId = ISNULL( h.PreviousGroupId, h.GroupId), --Uses ISNULL for first time clients
    OriginalMemberId = ISNULL( h.PreviousMemberId, h.MemberId)
FROM dbo.Groups g
JOIN dbo.GroupsHistory h ON g.GroupId = h.GroupId AND g.MemberId = h.MemberId;
WHILE @@ROWCOUNT > 0 --Update until there's no previous group for any Client
    UPDATE g SET
        OriginalGroupId = h.PreviousGroupId,
        OriginalMemberId = h.PreviousMemberId
    FROM dbo.Groups g
    JOIN dbo.GroupsHistory h ON g.OriginalGroupId = h.GroupId AND g.OriginalMemberId = h.MemberId
    WHERE h.PreviousGroupId IS NOT NULL;
COMMIT TRAN
--Get the time in seconds
SET @SecondsWhile = DATEDIFF(ss, @Time, GETDATE());
--Insert the times into our timings table
INSERT INTO dbo.Timings
    (with_index, rbar_seconds, rCTE_seconds, while_seconds)
VALUES(1, @SecondsRBAR,@SecondsrCTE, @SecondsWhile);
--Run the test 10 times
GO 10

--Query the Test Timings
WITH CTE AS(
    SELECT ROW_NUMBER() OVER(PARTITION BY with_index ORDER BY(SELECT NULL)) Iteration,
        With_Index,
        RBAR_Seconds,
        rCTE_seconds,
        While_seconds
    FROM Timings
)
SELECT Iteration,
    With_Index,
    AVG(rbar_seconds) AS  RBAR_Seconds,
    AVG(rcte_seconds) AS  rCTE_seconds,
    AVG(while_seconds) AS While_seconds
FROM CTE
GROUP BY GROUPING SETS(Iteration, with_index), (with_index);
GO
--Uncomment the following lines to clean the database after our tests.
--DROP TABLE dbo.Timings;
--DROP TABLE dbo.Groups;
--DROP TABLE dbo.GroupsHistory;

The results seemed to be clear and consistent with the set-based loop being the fastest every time. The timings for each run are shown below.

Iteration With_Index RBAR_Seconds rCTE_seconds While_seconds

--------- ---------- ------------ ------------ -------------

1         0          NULL         90           19

2         0          NULL         86           17

3         0          NULL         88           17

4         0          NULL         90           19

5         0          NULL         90           16

6         0          NULL         90           18

7         0          NULL         88           17

8         0          NULL         87           18

9         0          NULL         87           17

10        0          NULL         90           18

AVG       0          NULL         88           17

1         1          65           69           17

2         1          48           68           18

3         1          48           70           18

4         1          52           68           19

5         1          48           65           18

6         1          48           68           18

7         1          49           73           17

8         1          48           66           17

9         1          48           69           17

10        1          49           68           17

AVG       1          50           68           17

Disclaimer: I didn’t include the RBAR option without indexes for obvious reasons. I didn’t include all the tests I made that included different variations to the code which didn’t affect the performance in a significant way.

Conclusion

There’s nothing inherently wrong with a WHILE loop, it can be faster than other methods which people assume to be “pure set-based programming” such as recursive CTEs and triangular joins. The problem is when people use them to process one row at a time instead of thinking in sets.

I won’t say that a WHILE loop is the best option every single time. My intention is to make clear that not all loops are RBAR the same way that RBAR is not always a WHILE loop, as well as mentioning that RBAR is not always the evilest thing around.

There might be further problems that can be found when using these kind of hierarchies such as infinite loops and the Halloween problem, which aren’t addressed in this article but might be worth considering when implementing a similar solution.

I want to thank Jeff Moden for inviting me to write an article as it has been a great experience. I also want to thank Eirikur Eiriksson, Wayne Sheffield and Alan Burstein for their inputs on the original draft and their suggestions on including the indexes.

Thanks for reading, any feedback would be greatly appreciated.

Resources

Rate

4.71 (97)

You rated this post out of 5. Change rating

Share

Share

Rate

4.71 (97)

You rated this post out of 5. Change rating