The Row Version Tag: Page Splits After Turning On RCSI

A row version tag makes an updated row bigger after you turn on RCSI, even when the data is the same length. On full pages, that growth turns into page splits.

Added felt layer spreading tightly packed padding above an intact bed spring

The Monday morning surprise

Here is a story I hear a lot. A team turns on READ_COMMITTED_SNAPSHOT on Friday to stop readers from blocking writers. Everyone is happy. On Monday the nightly update job is slower and the data file has grown.

Someone blames the setting, and they are partly right. When RCSI is on, SQL Server adds a small tag to every row you modify, so it can keep a version for readers. If your pages are packed tight, that extra room has to come from somewhere.

Let me show it on a small table. First, a demo database. It is created here and dropped in the last step.

USE master;
DROP DATABASE IF EXISTS SqlAuthorityDemo;
GO
CREATE DATABASE SqlAuthorityDemo;
GO
USE SqlAuthorityDemo;

Build packed pages and take a baseline

The table has 1,000 rows with a fixed 200-character payload. Fixed-width rows make the math easy, because the update below never changes the length of the data.

I save the first measurement in a temp table, so I can compare against it later. The pages are about 97 percent full, which is what a freshly loaded table looks like.

CREATE TABLE dbo.VersionTagDemo
(
    ItemId int PRIMARY KEY CLUSTERED,
    Payload char(200) NOT NULL
);

INSERT dbo.VersionTagDemo (ItemId, Payload)
SELECT value, REPLICATE('x', 200)
FROM GENERATE_SERIES(1, 1000);

SELECT N'before' AS phase, page_count, avg_record_size_in_bytes,
       avg_page_space_used_in_percent
INTO #Before
FROM sys.dm_db_index_physical_stats
     (DB_ID(), OBJECT_ID(N'dbo.VersionTagDemo'), 1, NULL, 'DETAILED')
WHERE index_level = 0 AND alloc_unit_type_desc = 'IN_ROW_DATA';

SELECT * FROM #Before;

The first row of the screenshot further down is this result: 27 leaf pages, an average record of 211 bytes. The index_level = 0 filter means we look only at the leaf pages, where the rows live.

Turn on RCSI and see that nothing happens

Now flip the switch. The second statement confirms the setting is on. Then I measure again.

ALTER DATABASE SqlAuthorityDemo
SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

SELECT name, is_read_committed_snapshot_on
FROM sys.databases
WHERE name = N'SqlAuthorityDemo';

SELECT N'option only' AS phase, page_count, avg_record_size_in_bytes
FROM sys.dm_db_index_physical_stats
     (DB_ID(), OBJECT_ID(N'dbo.VersionTagDemo'), 1, NULL, 'DETAILED')
WHERE index_level = 0 AND alloc_unit_type_desc = 'IN_ROW_DATA';

The setting reads 1, but the table is exactly as before: 27 pages, 211 bytes. Turning RCSI on does not touch existing rows. The cost shows up only when a row is modified.

Update every row and measure again

Now I overwrite every payload with 200 new characters. Same length, same column, nothing exciting. The last column subtracts the baseline from the new record size.

UPDATE dbo.VersionTagDemo
SET Payload = REPLICATE('y', 200);

SELECT N'after update' AS phase, s.page_count, s.avg_record_size_in_bytes,
       s.avg_record_size_in_bytes - b.avg_record_size_in_bytes AS added_record_bytes,
       s.avg_page_space_used_in_percent
FROM sys.dm_db_index_physical_stats
     (DB_ID(), OBJECT_ID(N'dbo.VersionTagDemo'), 1, NULL, 'DETAILED') AS s
CROSS JOIN #Before AS b
WHERE s.index_level = 0 AND s.alloc_unit_type_desc = 'IN_ROW_DATA';
Native SSMS comparison of row size before RCSI, after enabling it, and after updating rows
All three phases: 211 bytes per record, still 211 after enabling RCSI, then 225 after the update. Leaf pages rise from 27 to 53.

Each modified record grew by 14 bytes. The pages were nearly full, so there was no room for that growth. SQL Server split pages to make room.

Look at the page count. It went from 27 to 53, almost double, and the pages are now only about 53 percent full. A page split moves half the rows to a new page. So you pay twice: more pages to read, and a lot of empty space inside them.

When the row version tag appears

What to do before you blame the setting

This demo is the worst case on purpose: full pages and every row updated at once. Your real tables are rarely that bad. Still, the check is cheap, so run it on a copy of a busy table.

If the growth hurts, a lower fill factor on the index leaves room for it. But a fill factor is applied only when you rebuild. It is not kept as a fixed amount of empty space afterward. It also makes the index bigger to read, so apply it to the tables that need it, not to everything.

One more honest note. This measures the tag on the table rows. It does not measure the row versions that SQL Server keeps for readers. That is a separate space cost, and it depends on how long your transactions stay open.

Finally, clean up. The demo created one database, so the last block drops it.

DROP TABLE IF EXISTS #Before;
GO
USE master;
DROP DATABASE IF EXISTS SqlAuthorityDemo;

Measure your own busiest table before you decide the setting is the villain.

A row version tag is not free storage, it is part of the price of RCSI.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Best Practices, SQL Performance, SQL Server
Previous Post
SQL SERVER – Detected a DTC/KTM in-doubt Transaction with UOW
Next Post
SQL Server Audit: Tracking Who Read or Changed Sensitive Tables

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.