I'm developing with Microsoft SQL Server 2016, and are currently facing a major performance drop when adding Row Level Security (RLS) to my database. I already think I've found the issue, which is Mr Query Optimizer who doesn't like my non deterministic filtering function very much. My question is if anyone had any experience with RLS, filtering functions, and optimizing a case like this. - Can indexing, a more clever RLS filtering function, etc improve the performance?
I use RLS to filter the returned/available rows from a query based on a filter function. Below I setup a function to filter rows based on a variable from the SESSION_CONTEXT() function. So it's much like adding a filter to the WHERE clause (except that it doesn't optimize the same, and this is way easier to apply to an existing huge application, since it's done on the database level).
Please note that the script below, and the tests, are a very simplistic version of the actual thing, but it does demonstrate a performance drop when filtering is applied. In the scripts, I've also included (commented out) some of the things I've already tried.
To setup, first run the the script below, this creates the database, sample table, filtering function and security policy.
-- note: this creates the test database 'rlstest'. when you're tired of this, just drop it.
-- initalize
SET NOCOUNT ON
GO
-- create database
CREATE DATABASE rlstest
GO
-- set database
USE rlstest
GO
-- create test table 'member'
CREATE TABLE dbo.member (
memberid INT NOT NULL IDENTITY,
ownercompanyid INT NULL
)
GO
-- create some sample rows where dbo.member.ownercompanyid is sometimes 1 and sometimes NULL
-- note 1:
-- below, adjust the number of rows to create to give you testresults between 1-10 seconds (so that you notice the drop of performance)
-- about 2million rows gives me a test result (with the security policy) of about 0,5-1sec on an average dev machine
-- note 2: transaction is merly to give some speed to this
BEGIN TRY
BEGIN TRAN
DECLARE @x INT = 2000000
WHILE @x > 0 BEGIN
INSERT dbo.member (ownercompanyid) VALUES (CASE WHEN FLOOR(RAND()*2+1)>1 THEN 1 ELSE NULL END)
SET @x = @x - 1
END
COMMIT TRAN
END TRY BEGIN CATCH
ROLLBACK
END CATCH
GO
-- drop policy & filter function
-- DROP SECURITY POLICY dbo.OwnerCompanyDataSecurityPolicy
-- DROP FUNCTION dbo.fn_filterMember
-- create filter function
CREATE FUNCTION dbo.fn_filterMember(@ownercompanyid AS INT) RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 result WHERE
@ownercompanyid IS NULL OR
(@ownercompanyid IS NOT NULL AND @ownercompanyid=CAST(SESSION_CONTEXT(N'companyid') AS INT))
-- tested: short circuit the logical expression (no luck):
-- @ownercompanyid IS NULL OR
-- (CASE WHEN @ownercompanyid IS NOT NULL THEN (CASE WHEN @ownercompanyid=CAST(SESSION_CONTEXT(N'companyid') AS INT) THEN 1 ELSE 0 END) ELSE 0 END)=1
GO
-- create & activate security policy
CREATE SECURITY POLICY dbo.OwnerCompanyDataSecurityPolicy
ADD FILTER PREDICATE dbo.fn_filterMember(ownercompanyid) ON dbo.member
WITH (STATE = ON)
Next go ahead and run the following tests. Timings can be viewed on the "Messages" tab in SQL Server Management Studio (SSMS), and if you'd like to see where filtering step is applied, be sure to include the actual execution plan.
-- tested: add a table index (no luck)
-- CREATE INDEX ix_member_test ON dbo.member (ownercompanyid)
-- test without security policy
ALTER SECURITY POLICY dbo.OwnerCompanyDataSecurityPolicy
WITH (STATE = OFF)
-- note: view timings on the "Messages" tab in SSMS
SET STATISTICS TIME ON
PRINT '*** Test #1 WITHOUT security policy. Session companyid=NULL:'
EXEC sys.sp_set_session_context @key='companyid',@value=NULL
SELECT COUNT(*) FROM member
PRINT '*** Test #2 WITHOUT security policy. Session companyid=1:'
EXEC sys.sp_set_session_context @key='companyid',@value=1
SELECT COUNT(*) FROM member
SET STATISTICS TIME OFF
-- test with security policy
ALTER SECURITY POLICY dbo.OwnerCompanyDataSecurityPolicy
WITH (STATE = ON)
SET STATISTICS TIME ON
PRINT '*** Test #3 WITH security policy. Session companyid=NULL:'
EXEC sys.sp_set_session_context @key='companyid',@value=NULL
SELECT COUNT(*) FROM member
PRINT '*** Test #4 WITH security policy. Session companyid=1:'
EXEC sys.sp_set_session_context @key='companyid',@value=1
SELECT COUNT(*) FROM member
SET STATISTICS TIME OFF