Row Level Security Performance Implications

Table of Contents

A conversation had me wondering about performance implications in SQL Server RLS feature, specifically on how to mitigate the performance impact;
TL; DR; ? Since security functions are actually Inline Table Valued Functions (ITVF), if you write them in a decent way the queries will run in the most efficent way possibile, avoiding RBAR processing.

Let’s set the case scene; I’m starting from the ColdRoomTemperatures_Archive table in the WideWorldImporters sample database, why? I already had the DB restored and ready to go, it has a reasonable number of rows and the column ColdRoomSensorNumber it’s a prefect candidate for the example I had in mind.

Here’s the data that you’ll be expecting

I copied the data above in a Test DB, doubled the number of rows to get to around 6M rows, and added a couple of indexes:

CREATE UNIQUE CLUSTERED INDEX CX ON Temperatures(ColdRoomTemperatureID)

CREATE INDEX ix_ColdRoomSensorNumber ON Temperatures(ColdRoomSensorNumber) INCLUDE (Temperature)

The query that we’re going to look at is a simple aggregate of the average temperature:

SELECT ColdRoomSensorNumber, AVG(Temperature)
FROM dbo.Temperatures
GROUP BY ColdRoomSensorNumber

It creates the following plan, nice and easy:

CPU Time 1594ms, Elapsed Time 1594ms

Let’s create the users that we’ll be testing the Security policy to, first:

CREATE USER User1 WITHOUT LOGIN
CREATE USER User2 WITHOUT LOGIN
CREATE USER User3 WITHOUT LOGIN
CREATE USER User4 WITHOUT LOGIN

GRANT SHOWPLAN TO User1 --Just so I can get the actual execution plan when executing the query as this user
GRANT SELECT ON Temperatures TO User1
GRANT SELECT ON Temperatures TO User2
GRANT SELECT ON Temperatures TO User3
GRANT SELECT ON Temperatures TO User4

Then, it’s the turn of a security function and a security policy, I’ll start from the Hardcoded one, because I want to leave the best for last:

CREATE FUNCTION [RLSHardCoded]
(@ColdRoomSensorNumber INT
)
RETURNS TABLE
WITH SCHEMABINDING
AS
     RETURN
     SELECT
            1 AS [UserAccess]
     WHERE (USER_NAME() = 'User1' AND @ColdRoomSensorNumber = 1)
     OR (USER_NAME() = 'User2' AND @ColdRoomSensorNumber = 2)
     OR (USER_NAME() = 'User3' AND @ColdRoomSensorNumber = 3)
     OR (USER_NAME() = 'User4' AND @ColdRoomSensorNumber = 4)
GO

CREATE SECURITY POLICY SensorPolicyHardCoded
ADD FILTER PREDICATE dbo.[RLSHardCoded](ColdRoomSensorNumber) ON dbo.Temperatures
WITH (STATE = ON)

Now, let’s execute the same query as above and see how things play out:

EXECUTE AS USER = 'User1'
SELECT ColdRoomSensorNumber, AVG(Temperature)
FROM dbo.Temperatures
GROUP BY ColdRoomSensorNumber
REVERT

CPU Time: 1338ms Elapsed Time: 1338ms

Hey, nice job optimizer, I was expecting way worse results!
We basically get a tree with a bunch of merge joins, of which in our code only one branch will actually return any rows.
The downside is that our base table will be accessed one time for each predicate, but since we’re seeking in every case it isn’t as dramatic, however, this approach isn’t well maintainable, as we all know that you shouldn’t hardcode things in the code, you silly.

So, let’s take a nicer approach and create a lookup table with some sample data:

CREATE TABLE [dbo].[RLSLookup](
	[Username] [varchar](20) NOT NULL,
	[ID] [int] NOT NULL,
 CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED 
(
	[Username] 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
INSERT [dbo].[RLSLookup] ([Username], [ID]) VALUES (N'User1', 1)
GO
INSERT [dbo].[RLSLookup] ([Username], [ID]) VALUES (N'User2', 2)
GO
INSERT [dbo].[RLSLookup] ([Username], [ID]) VALUES (N'User3', 3)
GO
INSERT [dbo].[RLSLookup] ([Username], [ID]) VALUES (N'User4', 4)
GO

Now we can write a decent Security function and policy:

CREATE FUNCTION [RLSLookupFun] (@ColdRoomSensorNumber INT)
RETURNS TABLE
WITH SCHEMABINDING
AS
     RETURN
     SELECT
            1 AS [UserAccess]
     FROM   [dbo].[RLSLookup]
     WHERE  [Username] = USER_NAME()
            AND [ID] = @ColdRoomSensorNumber;

GO

CREATE SECURITY POLICY SensorPolicyLookup
ADD FILTER PREDICATE dbo.[RLSLookupFun] (ColdRoomSensorNumber) ON dbo.Temperatures
WITH (STATE = ON)

and execute the same exact query as above:

CPU Time: 503ms Elapsed Time: 505ms
The yellow triangle is warning us that we were granted 1MB of memory and we used 0K, nothing to worry about.

Much nicer!
We get a nested loop join, in which we’re actually looping only once, since the outer table result is used only as a seek predicate for the inner table, on which we already have a pret-a-porter index, which is as close as possibile to adding a predicate on the original query.
As the number of possibile users (or single users accessing multiple data) this approach gets exponentially better than hard-coding values in multiple ways.

Important note!

While testing out the code for this post, I realized that one of my pillars when dealing with performance “Keep datatypes consistent” is more relevant than ever.

Quick example:
Let’s simply take the same hardcoded function as above, and change only the input data type from INT to TINYINT; it’s not even that big of a deal you would say, uh? (keep in mind, the ColdRoomSensorNumber column in the base table is INT)

DROP SECURITY POLICY SensorPolicyHardCoded;
GO

ALTER FUNCTION [RLSHardCoded]
(@ColdRoomSensorNumber TINYINT)
RETURNS TABLE
WITH SCHEMABINDING
AS
     RETURN
     SELECT
            1 AS [UserAccess]
     WHERE (USER_NAME() = 'User1' AND @ColdRoomSensorNumber = 1)
     OR (USER_NAME() = 'User2' AND @ColdRoomSensorNumber = 2)
     OR (USER_NAME() = 'User3' AND @ColdRoomSensorNumber = 3)
     OR (USER_NAME() = 'User4' AND @ColdRoomSensorNumber = 4);

GO

CREATE SECURITY POLICY SensorPolicyHardCoded
ADD FILTER PREDICATE dbo.[RLSHardCoded](ColdRoomSensorNumber) ON dbo.Temperatures
WITH (STATE = ON)
GO

Then run again the same select query, with these results:

CPU Time: 132769ms Elapsed Time: 16731ms
DAMN SON!

A totally different plan, with a totally different and wasteful approach!
In this case, the optimizer isn’t able to use the existing index, so it basically scans the whole table and applies the predicate of the Security Function row by row, parallelizing the work and consuming a lot of CPU time too.
In all of this:

  • You don’t get any warning
  • You don’t get any complicit conversion message in the operator “mouse-hover” details
  • You only see that there is an implicit conversion going on by exploring the properties of the “Compute Scalar” node (or by using SentryOne Plan Explorer)

Another reason to design your objects carefully, as in this case the optimizer won’t even help you with little yellow triangles, only awful performances and wasted CPU Cycles.
As someone says, “Love with your heart, use your head for everything else!”

comments powered by Disqus

Related Posts

Quick Tip: How to find the address of the PowerBI local tabular instance

Super quick tip, I’ve seen this done via CMD or DaxStudio, but not via PowerShell (probably because I haven’t searched so much since I’ve done it myself), but here we are:

Read More

Use SQL Server on Docker for Windows, the easy way

I wanted to install and test SQL Server 2019 on a new machine, without the hassle to create a VM from scratch, since I had none of my resources from this new place, that’s when I remembered about Docker and how Microsoft is slowing reaching feature parity for SQL on LInux , moreover, I didn’t need any of the unsupported stuff, so.

Read More

Creating a Perfmon and Filestats reports in PowerBI [Part 1]

If you work for a big company, you may have a bunch of instances/databases under your control, you ran whatever monitoring tool has been provided and everybody is happy; But if your’re like me and you’re working for a big number of customers, each one with a different version of SQL and own configuration/security policies, with no monitoring tool directly available, and have to answer the question “what happened to the instance the other night” you may be a little bit overwhelmed.

Read More