SQL Server Service Broker: a small change in the activation procedure can spike CPU usage and why benchmarking matters

In this post, I will demonstrate how a small change in Service Broker code can significantly increase CPU usage. This post may be helpful for those troubleshooting high CPU consumption related to Service Broker if their case is similar to mine. 

CPU before and after the bug in Service Broker got fixed


However, the main purpose of the post is to highlight that even when following Microsoft's documentation to setup some database feature, things can still go wrong. This is why benchmarking and testing are crucial steps before deploying new features to your production system.


Additionally, it is interesting to see that a small change in the code can completely alter the service broker behavior. If you'd like, you can code along with me and see the results for yourself, provided you have SQL Server installed.


Architecture

The service broker setup I will use is as follows:

  • There are two services and two corresponding queues and their activation procedures: the initiator and the target
  • A conversation sends a message from the initiator to the target
  • Whenever the initiator or the target receive a message, we log a record
  • This setup involves a 5 second conversation timer. As timeout occurs, the initiator receives a dialog timer message and both sides end the conversation.

Code demonstration (what works)

Create a database

First, let's create a database dedicated for our demo and enable Query Store to capture some performance data.


  -- Create a database
  CREATE DATABASE ServiceBrokerDemo
  GO

  -- Enable Query Store
  ALTER DATABASE ServiceBrokerDemo SET QUERY_STORE (OPERATION_MODE = READ_WRITE)  
  GO
  

Create a log table

We will create a log table to record messages sent to each side of the conversation:


  USE ServiceBrokerDemo
  GO 
  
  DROP TABLE IF EXISTS DemoServiceBrokerLog;
  CREATE TABLE DemoServiceBrokerLog (
      ID INT IDENTITY(1,1) PRIMARY KEY,
      Date DATETIME,
      MessageTypeName sysname,
      ActivationFunction sysname, 
      MessageBody xml
  );
  

Create message queues


  USE ServiceBrokerDemo
  GO

  CREATE QUEUE dbo.InitiatorQueue WITH STATUS=ON ON [PRIMARY]
  GO

  CREATE QUEUE dbo.TargetQueue WITH STATUS=ON ON [PRIMARY]
  GO
  

Create activation procedures

The following procedure is activated when messages arrive at the target queue. It logs messages and performs tasks, handling 'endofconversation' and 'message' types:

Notice the while loop, it is used so the broker won't reactivate the procedure every time new message arrives. If there is no message being dequeued (@@ROWCOUNT=0), break from the loop and end the execution of the procedure.

CREATE OR ALTER PROCEDURE [dbo].[TargetQueueProcess]
AS
BEGIN
	SET NOCOUNT ON;
 
	DECLARE @conversationHandle UNIQUEIDENTIFIER
	DECLARE @messagetypename NVARCHAR(256)
	DECLARE @messagebody XML
 
	DECLARE @errorNumber AS int;
	DECLARE @errorMessage AS nvarchar(3000);
 
	WHILE (1=1)
	BEGIN
		;RECEIVE TOP(1) @conversationHandle = conversation_handle
			 , @messagetypename = message_type_name
			 , @messagebody = message_body
		  FROM [dbo].[TargetQueue]
		IF (@@ROWCOUNT=0)
		BEGIN
			BREAK
		END
 
		IF @conversationHandle IS NOT NULL
		BEGIN
			-- Log
			INSERT INTO DemoServiceBrokerLog VALUES (
						getdate(),
						@messageTypeName,
						'TargetQueueProcess', 
						@messagebody
				);
 
 
			IF @messageTypeName = 'http://my.dba.aventure/dbo/DemoServiceBroker/endofconversation' 
			BEGIN
				-- End the conversation 
				-- The initiator will receive an EndDialog message afterwards
				END CONVERSATION @conversationHandle;
			END
			ELSE IF @messageTypeName = 'http://my.dba.aventure/dbo/DemoServiceBroker/message' 
			BEGIN
				BEGIN TRY 
					-- Process the message
					PRINT ('Do the actual task in here')
				END TRY 
				BEGIN CATCH 
					-- Capture the error
					SELECT @errorNumber = ERROR_NUMBER()
					, @errorMessage = ERROR_MESSAGE()
 
					-- End the conversation with an error
					-- The initiator will receive an Error message afterwards
					END CONVERSATION @conversationHandle WITH ERROR = @errorNumber DESCRIPTION = @errorMessage;
				END CATCH 
			END
		END
	END
END
GO
  

Similarly, the initiator queue process handles "dialogtimer", "enddialog", and "error" messages sent to the initiator queue:

USE ServiceBrokerDemo
GO
CREATE OR ALTER PROCEDURE [dbo].[InitiatorQueueProcess]
AS
BEGIN
	SET NOCOUNT ON;
 
	DECLARE @conversationHandle UNIQUEIDENTIFIER
	DECLARE @messageTypeName SYSNAME
	DECLARE @messagebody XML
 
	DECLARE @errorCode int;
	DECLARE @errorMessage nvarchar(3000);
 
	;RECEIVE TOP(1) @conversationHandle = conversation_handle
		, @messageTypeName = message_type_name
		, @messagebody = message_body 
	FROM [dbo].[InitiatorQueue]
 
	IF @conversationHandle IS NOT NULL
	BEGIN
		INSERT INTO DemoServiceBrokerLog VALUES (
						getdate(),
						@messageTypeName,
						'InitiatorQueueProcess', 
						@messagebody
		)
 
	    IF @messageTypeName = 'http://schemas.microsoft.com/SQL/ServiceBroker/DialogTimer' 
	    BEGIN
		;SEND ON CONVERSATION @conversationHandle MESSAGE TYPE [http://my.dba.aventure/dbo/DemoServiceBroker/endofconversation];
	    END
	    ELSE IF @messageTypeName = 'http://schemas.microsoft.com/SQL/ServiceBroker/EndDialog' 
	    BEGIN
		END CONVERSATION @conversationHandle;
	    END
	    ELSE IF @messageTypeName = 'http://schemas.microsoft.com/SQL/ServiceBroker/Error'
	    BEGIN 
		-- Log the error before ending the conversation
		SET @errorCode = 
				(SELECT @messagebody.value(
				N'declare namespace 
				brokerns="http://schemas.microsoft.com/SQL/ServiceBroker/Error"; 
				(/brokerns:Error/brokerns:Code)[1]', 'int'));
 
		SET @errorMessage = (SELECT @messagebody.value(
				'declare namespace
				brokerns="http://schemas.microsoft.com/SQL/ServiceBroker/Error";
				(/brokerns:Error/brokerns:Description)[1]', 'nvarchar(3000)'));
 
		-- do something with the error 
		PRINT('log the errors to some table')
 
		-- End conversation on the initiator side
		END CONVERSATION @conversationHandle;	
	    END
	END
END
GO
  

Then we associate the two queues to the two newly created activation procedures:

  USE ServiceBrokerDemo
  GO
  ALTER QUEUE dbo.TargetQueue WITH STATUS = ON, RETENTION = OFF, ACTIVATION (STATUS = ON, PROCEDURE_NAME = dbo.TargetQueueProcess, MAX_QUEUE_READERS = 1, EXECUTE AS SELF) , POISON_MESSAGE_HANDLING (STATUS = OFF) 
  GO 
  ALTER QUEUE dbo.InitiatorQueue WITH STATUS = ON, RETENTION = OFF, ACTIVATION (STATUS = ON, PROCEDURE_NAME = dbo.InitiatorQueueProcess, MAX_QUEUE_READERS = 1, EXECUTE AS SELF) , POISON_MESSAGE_HANDLING (STATUS = OFF) 
  GO
  

Create custom message types for our application:

  CREATE MESSAGE TYPE [http://my.dba.aventure/dbo/DemoServiceBroker/message]
  CREATE MESSAGE TYPE [http://my.dba.aventure/dbo/DemoServiceBroker/endofconversation]
  

Create contracts

  CREATE CONTRACT [http://my.dba.aventure/dbo/DemoServiceBroker/contract] 
  (
      [http://my.dba.aventure/dbo/DemoServiceBroker/message] SENT BY INITIATOR, 
      [http://my.dba.aventure/dbo/DemoServiceBroker/endofconversation] SENT BY INITIATOR
  )
  

Create services corresponding to the two queues

CREATE SERVICE [http://my.dba.aventure/dbo/DemoServiceBroker/InitiatorService] 
ON QUEUE [dbo].[InitiatorQueue]	([http://my.dba.aventure/dbo/DemoServiceBroker/contract])

CREATE SERVICE [http://my.dba.aventure/dbo/DemoServiceBroker/TargetService] 
ON QUEUE [dbo].[TargetQueue] ([http://my.dba.aventure/dbo/DemoServiceBroker/contract])

  

Start a conversation and send a message from the initiator to the target

USE ServiceBrokerDemo;
 
DECLARE @conversationHandle UNIQUEIDENTIFIER
DECLARE @fromService SYSNAME = 'http://my.dba.aventure/dbo/DemoServiceBroker/InitiatorService'
DECLARE @toService SYSNAME = 'http://my.dba.aventure/dbo/DemoServiceBroker/TargetService'
DECLARE @contract SYSNAME = 'http://my.dba.aventure/dbo/DemoServiceBroker/contract'
 
-- Craft a contrived message body
DECLARE @messageBody XML = '<messagebody><msg>some message</msg></messagebody>'
 
 
BEGIN DIALOG CONVERSATION @conversationHandle
FROM SERVICE @fromService
TO SERVICE @toService
ON CONTRACT @contract
WITH ENCRYPTION = OFF;
 
BEGIN CONVERSATION TIMER (@conversationHandle) TIMEOUT = 5
 
;SEND ON CONVERSATION @conversationHandle
	MESSAGE TYPE [http://my.dba.aventure/dbo/DemoServiceBroker/message] 
	(@messageBody)

Check the log table

  SELECT * FROM DemoServiceBrokerLog
  

The result shows all messages sent and received by the initiator and the target. 

The messages sent to the initiator and the target through the conversation - SQL query result

Check the queues' status and their activated tasks

After 5 second conversation, the queues' status should be INACTIVE and there should be no activated procedures:

  SELECT * FROM sys.dm_broker_activated_tasks;
 
  SELECT *
  FROM sys.dm_broker_queue_monitors AS m
  INNER JOIN sys.service_queues q
  ON m.queue_id = q.object_id
  
The queues' states are INACTIVE and there is no activated task - Query result


Minor Tweak, Major CPU Usage Impact

Now, we will make a small change to the target queue's activation procedure. The change is in how the loop is constructed.


-- CREATE TWO ACTIVATION FUNCTIONS: for the two queues
-- This is for the target queue
CREATE OR ALTER PROCEDURE [dbo].[TargetQueueProcess]
AS
BEGIN
	SET NOCOUNT ON;
 
	DECLARE @conversationHandle UNIQUEIDENTIFIER
	DECLARE @messagetypename NVARCHAR(256)
	DECLARE @messagebody XML
 
	DECLARE @errorNumber AS int;
	DECLARE @errorMessage AS nvarchar(3000);
 
	WHILE EXISTS(SELECT 1 FROM dbo.TargetQueue)
	BEGIN
		;RECEIVE TOP(1) @conversationHandle = conversation_handle
			 , @messagetypename = message_type_name
			 , @messagebody = message_body
		  FROM [dbo].[TargetQueue]
 
		IF @conversationHandle IS NOT NULL
		BEGIN
			-- Log
			INSERT INTO DemoServiceBrokerLog VALUES (
						getdate(),
						@messageTypeName,
						'TargetQueueProcess', 
						@messagebody
				);
 
 
			IF @messageTypeName = 'http://my.dba.aventure/dbo/DemoServiceBroker/endofconversation' 
			BEGIN
				-- End the conversation 
				-- The initiator will receive an EndDialog message afterwards
				END CONVERSATION @conversationHandle;
			END
			ELSE IF @messageTypeName = 'http://my.dba.aventure/dbo/DemoServiceBroker/message' 
			BEGIN
				BEGIN TRY 
					-- Process the message
					PRINT ('Do the actual task in here')
				END TRY 
				BEGIN CATCH 
					-- Capture the error
					SELECT @errorNumber = ERROR_NUMBER()
					, @errorMessage = ERROR_MESSAGE()
 
					-- End the conversation with an error
					-- The initiator will receive an Error message afterwards
					END CONVERSATION @conversationHandle WITH ERROR = @errorNumber DESCRIPTION = @errorMessage;
				END CATCH 
			END
		END
	END
END
GO

Let's also refresh everything by turning the queues off and then turning them back on; and truncating the log table:

  -- Turn off the queues
  ALTER QUEUE dbo.InitiatorQueue WITH STATUS = OFF;
  ALTER QUEUE dbo.TargetQueue WITH STATUS = OFF;

  -- Turn on the queues
  ALTER QUEUE dbo.InitiatorQueue WITH STATUS = ON;
  ALTER QUEUE dbo.TargetQueue WITH STATUS = ON;

  -- Remove things in the log table
  TRUNCATE TABLE dbo.DemoServiceBrokerLog

  

After making all necessary changes, let's another time start a conversation and send a message through the conversation

USE  ServiceBrokerDemo
 
DECLARE @conversationHandle UNIQUEIDENTIFIER
DECLARE @fromService SYSNAME = 'http://my.dba.aventure/dbo/DemoServiceBroker/InitiatorService'
DECLARE @toService SYSNAME = 'http://my.dba.aventure/dbo/DemoServiceBroker/TargetService'
DECLARE @contract SYSNAME = 'http://my.dba.aventure/dbo/DemoServiceBroker/contract'
 
-- Craft a contrived message body
DECLARE @messageBody XML = 'some message'
 
 
BEGIN DIALOG CONVERSATION @conversationHandle
FROM SERVICE @fromService
TO SERVICE @toService
ON CONTRACT @contract
WITH ENCRYPTION = OFF;
 
BEGIN CONVERSATION TIMER (@conversationHandle) TIMEOUT = 5
 
;SEND ON CONVERSATION @conversationHandle
	MESSAGE TYPE [http://my.dba.aventure/dbo/DemoServiceBroker/message] 
	(@messageBody)
  

Let's wait for 5 seconds, then run the following code to check:

  SELECT * FROM sys.dm_broker_activated_tasks;
 
  SELECT *
  FROM sys.dm_broker_queue_monitors AS m
  INNER JOIN sys.service_queues q
  ON m.queue_id = q.object_id
  

You'll notice that the TargetQueue's state is always RECEIVES_OCCURING instead of being INACTIVE. The TargetQueueProcess procedure seems to run infinitely, even when there is no message in the target queue.

The broker keeps reactivating the target queue's procedure even there is no message in the queue - Query result


The execution count of the procedure TargetQueueProcess went up to 4 million after one hour, which means the broker constantly reactivates the procedure.

Thoughts

WHILE (1=1) ... IF (@@ROWCOUNT=0) ... BREAK and WHILE EXISTS(SELECT 1 FROM dbo.TargetQueue) are supposed to do the same thing. However, for some reason, the latter causes the broker to reactive the target queue process procedure. My guess without any proper evidence is that, somehow the cleanup after the message being processed hasn't finished. I did post a question in StackOverflow, hopefully someone can help me to find out why. Or if you reader can find the reason, please let me know.

In my company system, we have the same incorrect setup as above for many years. Until when I troubleshooted and fixed the WHILE loop, the CPU cut in half. Testing and benchmarking are important because we never think that such small code change can make a huge difference to the system resources.

Comments

Popular posts from this blog

SQL SERVER INDEX (Part 1): Index Seek vs Index Scan and Statistics

SQL SERVER INDEX (Part 4): Statistics

📐 SQL Server Performance: Troubleshooting Memory Grant Overestimation using SQL Diagnostic Manager and SQL Server execution plan