Red Skies At Night
I know it’s hard to believe, but I still see a lot of people using cursors when they shouldn’t. Other times, there’s some scary dungeon part of the code that someone wrote eons ago that no one wants to go anywhere near to fix.
Sometimes there’s a decent reason, something like: We have a query that generates a really big result, and we need to update another table with it. When we do it without the cursor, there’s bad locking, it runs for a long time, the transaction log gets huge, etc.
Not bad reasons! I still think there are better ways to do that. But hey.
Not So Fast Forward
When you write cursor code, there are a bunch of options you can choose. One of them is FAST_FORWARD. It’s documented, ahem, thusly:
FAST_FORWARD
Specifies a FORWARD_ONLY, READ_ONLY cursor with performance optimizations enabled. FAST_FORWARD cannot be specified if SCROLL or FOR_UPDATE is also specified.
Friends, Lulu Romans, Countryhams.
OPTIMIZATIONS ARE ENABLED!
We don’t know what they are, but they’re there.
Feed Me, Seymour
Let’s say we have a pretty “big” query to feed our cursor. When we write it and validate the logic, it looks like this:
SELECT u.Id,u.DisplayName, MAX(p.Score) AS TopScore FROM dbo.Users AS u JOIN dbo.Posts AS p ON u.Id = p.OwnerUserId WHERE p.PostTypeId = 1 AND p.Score > 0 AND u.Reputation > 1000 GROUP BY u.Id, u.DisplayName;
And the query plan looks like this:
It goes parallel, as we’d expect a “big” query to do, and runs for 3 seconds. I’m not saying there’s nothing we could do here, but let’s roll with it for now.
Let’s Write Some Code!
First, we need to introduce a new table. It’s gonna hold our high scores.
CREATE TABLE dbo.HighQuestionScores ( Id INT PRIMARY KEY CLUSTERED, DisplayName NVARCHAR(40), Score BIGINT ); GO
Now we’re gonna write a stored procedure that’s gonna do a few things.
First Thing
We’re gonna check our high score table for any new Users with a Reputation over 1000
CREATE OR ALTER PROCEDURE dbo.curses AS BEGIN SET NOCOUNT ON; INSERT dbo.HighQuestionScores (Id) SELECT u.Id FROM dbo.Users AS u WHERE u.Reputation > 1000 AND NOT EXISTS ( SELECT 1/0 FROM dbo.HighQuestionScores AS h WHERE h.Id = u.Id );
Second Thing
We’re going to declare our variables for the variable gods, and open a cursor with OPTIMIZATIONS ENABLED to make sure our optimizations are optimized.
DECLARE @Id INT; DECLARE @DisplayName NVARCHAR(40); DECLARE @Score BIGINT; DECLARE crap CURSOR FAST_FORWARD READ_ONLY FOR SELECT u.Id, u.DisplayName, MAX(p.Score) AS TopScore FROM dbo.Users AS u JOIN dbo.Posts AS p ON u.Id = p.OwnerUserId WHERE p.PostTypeId = 1 AND p.Score > 0 AND u.Reputation > 1000 GROUP BY u.Id, u.DisplayName; OPEN crap; FETCH crap INTO @Id, @DisplayName, @Score;
Third Thing
We’re gonna update the high score table with new information. This looks a little silly, but people can change their names on the site.
FETCH crap INTO @Id, @DisplayName, @Score; WHILE @@FETCH_STATUS = 0 BEGIN UPDATE h SET h.DisplayName = @DisplayName, h.Score = @Score FROM dbo.HighQuestionScores AS h WHERE h.Id = @Id FETCH crap INTO @Id, @DisplayName, @Score; END; CLOSE crap; DEALLOCATE crap; END GO
Runtime!
Now we’ll run our stored procedure. We’re very cool people, after all.
EXEC dbo.curses;
The first thing we’ll notice is that it runs for about 23 seconds.
If we then, as very cool people do, examine the query plan for our procedure, we’ll notice some oddities.
EXEC sp_BlitzCache @StoredProcName = 'curses'
The first is that, well, our “big” query to feed the cursor doesn’t go parallel anymore.
Warnings
sp_BlitzCache warns us about this in two ways.
This warning comes from the XML, which is also pretty explicit:
NonParallelPlanReason="NoParallelFastForwardCursor"
If we go and run our feeder query at MAXDOP 1…
SELECT u.Id,u.DisplayName, MAX(p.Score) AS TopScore FROM dbo.Users AS u JOIN dbo.Posts AS p ON u.Id = p.OwnerUserId WHERE p.PostTypeId = 1 AND p.Score > 0 AND u.Reputation > 1000 GROUP BY u.Id, u.DisplayName OPTION(MAXDOP 1);
We’ll be saddened to learn that our Three Second Masterpiece® has turned into an Eleven Second Turd®
SQL Server Execution Times: CPU time = 10891 ms, elapsed time = 11459 ms.
Better Options?
Cursor options like FORWARD_ONLY, STATIC, and KEYSET can all produce parallel plans, and reduce the total proc runtime to about 15 seconds — remember that it was around 23 seconds with the FAST_FORWARD cursor, with OPTIMIZATIONS ENABLED!
Some optimizations, there, pal.
Thanks for reading!
Black Friday is on! Save 50% on live classes, 75% on recorded classes and SQL ConstantCare.