June 2007 - Posts

You’ve probably heard the term banded around but do you know what it means and what it means to the performance of your application? I’m going to demonstrate a page split, use DBCC PAGE to show what happens, SQL Profiler to show the locks and talk round what’s going on.

Terms

IAM      Index Allocation Map
(See http://blogs.msdn.com/sqlserverstorageengine/archive/2006/06/24/645803.aspx for a good talk about these)
GAM     Global Allocation Map
SGAM    Shared Global Allocation Map
PFS       Page Free Space
 (See http://blogs.msdn.com/sqlserverstorageengine/archive/2006/07/08/under-the-covers-gam-sgam-and-pfs-pages.aspx for a good talk about these)
Page     8Kbytes of data
Extent   8 Pages (totals 64Kbytes)

Background

A page is 8Kbytes of data which can be index related, data related, large object binary (lob’s) etc...

When you insert rows into a table they go on a page, into ‘slots’, your row will have a row length and you can get only so many rows on the 8Kbyte page. What happens when that row’s length increases because you entered a bigger product name in your varchar column for instance, well, SQL Server needs to move the other rows along in order to make room for your modification, if the combined new length of all the rows on the page will no longer fit on that page then SQL Server grabs a new page and moves rows to the right or left of your modification onto it – that is called a ‘page split’.

Example

Create a new database, a new table and put some rows in it.

use master

go

drop database pagesplit

go

 

create database pagesplit

go

use pagesplit

go

 

create table mytest (

    something_to_see_in_data    char(5)        not null constraint pk_mytest primary key clustered,

    filler                      varchar(3000)  not null

)

go

 

insert mytest ( something_to_see_in_data, filler ) values( '00001', replicate( 'A', 3000 ) )

insert mytest ( something_to_see_in_data, filler ) values( '00002', replicate( 'B', 1000 ) )

insert mytest ( something_to_see_in_data, filler ) values( '00003', replicate( 'C', 3000 ) )

go

Now look at the contents of one of your data pages, use DBCC IND to identify what pages are targeted by our object.

DBCC IND ( 0, 'mytest', 1);

GO

 

 

 

We can see that page 80 and 73 have data pertaining to our object, looking at the output, a PageType of 10 indicates an IAM page and a PageType of 1 a data page. I’m not going to go into IAM’s because I’d lose the focus of what I’m talking about, but I’ve put a reference at the top for further reading.

 

So here we will concentrate on the data page (page 73).

 

dbcc traceon( 3604 )        --  Output to the console so we can see output

go

 

dbcc page( 0, 1, 73, 1 )    --  page ( <0 for current db>, <file>, <page>, <level of detail> )

go

Page 73 contains all our data; the row offset table is shown below...

OFFSET TABLE:

 

Row - Offset                        

2 (0x2) - 4128 (0x1020)             

1 (0x1) - 3112 (0xc28)              

0 (0x0) - 96 (0x60)                 

Before we update our middle row to force a page split we should get SQL Profiler running so we can capture the Locks used so we can identify when the split has occurred and the locks in use.

Start SQL Profiler, New Trace, select ‘blank trace’, now on the events selection bit – choose Locks (Lock:Aquired, Lock:Released and Lock:Esculation), on TSQL choose SQL:StmtStarting and SQL:StmtCompleted. Now remember to set the filter to the SPID that you are going to run the test in.

First, let’s update a row that does not cause a page split...

update mytest

    set filler = replicate( 'B', 1000 )

where something_to_see_in_data = '00002'

Looking at the profiler trace (below) we can see there is a simple exclusive lock on the page and key being updated. The important part here is that no other pages are locked, ok, in reality we may have indexes on the columns being updated that would indeed cause locks but I’m keeping it simple!

Lets now update the middle row but this time make it so that the combined length of the rows currently on the page will no longer fit into 8Kbytes.

update mytest

    set filler = replicate( 'B', 3000 )

where something_to_see_in_data = '00002'

Before looking at the profiler output, let’s take a look and see what pages we now have allocated to our object...

DBCC IND ( 0, 'mytest', 1);

GO

 



Interestingly we now have 2 more, page 89 and page 109, looking at the PageType again, page 109 is a data page (type of 1) and 89 is an index page (type of 2); for a single data page there is no balanced tree to speak of because there is a one to one relationship between the root node of the index and the data page. Also, look at the page chain (columns NextPagePID and PrevPagePID ), 109 links to 73 and vice versa. Our IAM is still page 80.

Visualisation of what’s going on

Looking at the Profiler trace we see a number of things have happened...

1)    The exclusive lock is taken on the page and key being updated.

2)    The IAM page is locked (page 80), well, one of the slots in it – this is because we are being allocated some new data pages and IAM keeps track of these, you will notice that the lock is acquired and released within the transaction and not at the end, this keeps contention low; imagine how much slower inserts would be on a table if you also locked the IAM page until the end of the transaction – it would be disaster.

3)    The new index page is locked (page 89), again – notice how that lock is also released before the statement commits, why? Well, you aren’t updating the index keys so effectively there really isn’t a change so a rollback doesn’t really mean anything. One of the recommendations I make is that you be careful what columns you index – be aware of the additional locking time incurred when you are updating columns in an index.

4)    The new data page is locked (page 109), notice how that lock is kept until after the update has finished (the transaction completes).

5)    The page is split – some of the rows (id 2 and 3) are moved from page 73 to page 109, locks aren’t taken on the keys, in fact if we start a transaction and update row ‘3’ thus to prevent another connection from updating or deleting the row then SQL Server still (under the covers) moves the row to the other page – clever.

Summing Up

A page split does require resources and on a system that has continual page splits then that is going to affect performance and scalability.

Concurrency (blocking) problems are kept to a minimum by some internal tricks so they aren’t as expensive as they used to be in previous editions of SQL Server.

How do you avoid them? There are strategies for doing this, one such strategy is to use a low fill factor when you create and index, but that will be at the expense of read operations and because you usually have more reads to writes then that’s bad.

Personally, the usual thing I recommend (and by no means is this a one box fits all recommendation) is that you defrag indexes using DBCC INDEXDEFRAG (SQL 2000) or the equivalent ALTER INDEX REORGANIZE (SQL 2005), other things are to pre-populate columns so the row length doesn't grow when you update, don't use NULL's everywhere. If you have say a varchar type column that is frequently updated to different sizes then perhaps consider putting that in its own table.

Paul Randal has covered a lot of stuff in this area so make sure you check his blog out.

Background Reading

http://blogs.msdn.com/sqlserverstorageengine/archive/2006/12/13/More-undocumented-fun_3A00_-DBCC-IND_2C00_-DBCC-PAGE_2C00_-and-off_2D00_row-columns.aspx

 

I've found a really good venue here in Harpenden.

It's effectively central for a lot of folks...

M1 Junction 9 its 8 min drive

25 minutes from Kings Cross

15 minutes from A1(M)

15 minutes from Luton airport.

I'm thinking of an evening or afternoon event with some really good content.

Anyway, what do people think? I know a lot of folk work in London, but a lot of folk live on the outskirts too and venues are easier to get outside London.

Feel free to email tonyrogerson@torver.net with all your ideas and suggestions.

Tony.

Thankfully in my travels I see less and less heaps (a table without a clustered index), frankly they should be banned, taken out of the engine and a clustered index made mandatory. In this post I look at row hopscotch (row fragmentation) and the effects it has on application performance (IO’s and concurrency), you also see stark evidence at just why you should always have a clustered index.

A database is made up of a series of 8KB pages; a group of 8 pages is called an extent.

A heap is a table without a clustered index, the data is inserted anywhere in the page chain, so if you are deleting rows, those free ‘slots’ can be reused (this is where row hopscotch occurs).

A table with a clustered index has its data stored in the order of the clustering key so any inserts are placed in order, any page free space from deletes can only be used if the inserted row clustering key values fit within the range of values already on the page, for instance, if the page contains the rows 1, 2, 3, [FREE SLOT], 5, 6 and there is only room for 1 row then you can insert the id 4 but not 7, 8.

Let’s look at some SQL, first the HEAP...

create table somedata (

    id  int not null identity primary key nonclustered,

    somestuff   char(250)   not null

)

go

Notice that we have a primary key that is nonclustered, this puts a nonclustered index on the table to enforce the uniqueness.

Let’s populate the table, note the @i % 5 simply yields true on every 5th row inserted into the table, I’m doing a random delete to force some free space somewhere in the table allocation.

set nocount on

declare @i int

set @i = 1

 

while @i <= 40000

begin

    if @i % 5 = 0

        delete somedata

        where id = ( select top 1 id

                     from somedata

                     order by newid() )

 

    insert somedata ( somestuff ) values ( '>>>>>' + cast( @i as varchar(10) ) + '<<<<<' )

 

    set @i = @i + 1

 

end

go

 

Let’s try our query, this causes a ‘Table Scan’ because we are doing the DISTINCT somestuff and the number of rows we are doing it against SQL Server decided it is cheaper to Table Scan rather than a seek and look up.

set statistics io on

 

select count( distinct somestuff )

from somedata

where id between 1 and 10000

 

set statistics io off

go

This yields the following IO stats...

Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Table 'somedata'. Scan count 1, logical reads 1146, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

We can use the index to see how expensive that is too...

select max( somestuff )

from somedata

where id between 1 and 500


Table
'somedata'. Scan count 1, logical reads 142, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 

At this point, we should take a look at a page of data to find out what the on-disk storage of rows looks like, we do this using the DBCC EXTENTINFO command and pick out the first non-mixed extent.

dbcc extentinfo( 0 )

Run down the output looking for your object (object_id( ‘somedata’) ), you want the first entry where pg_alloc = 8 and index_id = 0, make a note of the page_id and then view that page by using DBCC PAGE...

dbcc traceon( 3604 )

dbcc page( {your db name}, 1, {page_id}, 3 )


You will get a rather large output, what you are really interested in is the slots, I’ve reduced the output to make it readable, you’ll see something like this...

Slot 0 Column 0 Offset 0x4 Length 4

id = 278                            

 

Slot 1 Column 0 Offset 0x4 Length 4

id = 11556                           

 

Slot 3 Column 0 Offset 0x4 Length 4

id = 281                            

 

Slot 4 Column 0 Offset 0x4 Length 4

id = 23562                          

 

Slot 5 Column 0 Offset 0x4 Length 4

id = 38845                          

 

Slot 6 Column 0 Offset 0x4 Length 4

id = 284                            

 

Slot 7 Column 0 Offset 0x4 Length 4

id = 285                            

 

Slot 8 Column 0 Offset 0x4 Length 4

id = 24888                          


Note, ‘id’ is your data and is the id we inserted (in order!) - you can see the row hopscotch here, the high id rows you see are because we deleted rows for instance 282 and 283 and SQL Server has re-used these slots on the page for other rows being inserted. Now, take a moment and visualise this across our 32,000 row table; for the heap the rows are literally anywhere so if you do a query like BETWEEN 100 and 1000 then literally the rows at worse case could be located on every single allocated page to the table. Also, this is why SQL Server often chooses not to use an index for looking up, because seek op is fine – it’s just the secondary lookup back to the data that costs the resource.

I suppose one last thing to look at and its where the heap approach does benefit, it provides better space utilisation (that said, there is a major bug to do with open objects and how new extents get allocated that has caused a lot of hassle and performance degradation for my clients with a particular third party database, but I won’t mention it’s Sage).

This is the output from DBCC SHOWCONTIG...

- Pages Scanned................................: 1147

- Extents Scanned..............................: 147

- Extent Switches..............................: 146

- Avg. Pages per Extent........................: 7.8

- Scan Density [Best Count:Actual Count].......: 97.96% [144:147]

- Extent Scan Fragmentation ...................: 14.29%

- Avg. Bytes Free per Page.....................: 362.4

- Avg. Page Density (full).....................: 95.52%

 

Now, let’s look at why the Clustered Index approach is superior...

drop table somedata

go

 

create table somedata (

    id  int not null identity primary key clustered,

    somestuff   char(250)   not null

)

go

 

set nocount on

declare @i int

set @i = 1

 

while @i <= 40000

begin

    if @i % 5 = 0

        delete somedata

        where id = ( select top 1 id

                     from somedata

                     order by newid() )

 

    insert somedata ( somestuff ) values ( '>>>>>' + cast( @i as varchar(10) ) + '<<<<<' )

 

    set @i = @i + 1

 

end

go

 

set statistics io on

 

select count( distinct somestuff )

from somedata

where id between 1 and 10000

 

select max( somestuff )

from somedata

where id between 1 and 500

 

set statistics io off

go


Table
'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Table 'somedata'. Scan count 1, logical reads 350, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

/

Table 'somedata'. Scan count 1, logical reads 21, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 

As you can see from the IO stats the cost of the query has dramatically dropped even on this small 11MByte table – imagine the benefits as the table grows! Because with the clustered index approach the data is ordered and such is held closer together on a smaller number of pages there are two major benefits 1) more likelihood that the page will be in cache thereby reducing the number of physical reads from disk and 2) because you are reading less pages there is less likelihood of colliding with somebody updating data and having the page locked, I know it drops to row lock but consider the overhead required if an update or insert causes a page split. Also, each logical read is 8KB which needs to go through the CPU, so the less reads the less CPU usage there will be on the box.

Looking at the DBCC PAGE output for the clustered index you can see that ‘like’ rows, our clustered index is on ‘id’, are grouped together...

Memory Dump @0x06CCC286

 

Slot 0 Column 0 Offset 0x4 Length 4

id = 182                            

 

Slot 1 Column 0 Offset 0x4 Length 4

id = 184                             

 

Slot 2 Column 0 Offset 0x4 Length 4

id = 190                            

 

Slot 3 Column 0 Offset 0x4 Length 4

id = 191                            

 

Slot 4 Column 0 Offset 0x4 Length 4

id = 193                            

 

Slot 5 Column 0 Offset 0x4 Length 4

id = 194                            

 

Slot 6 Column 0 Offset 0x4 Length 4

id = 206                            

 

Slot 7 Column 0 Offset 0x4 Length 4

id = 208               

Because of this ordering of rows, space usage isn’t as good...

- Pages Scanned................................: 1380

- Extents Scanned..............................: 175

- Extent Switches..............................: 175

- Avg. Pages per Extent........................: 7.9

- Scan Density [Best Count:Actual Count].......: 98.30% [173:176]

- Logical Scan Fragmentation ..................: 0.43%

- Extent Scan Fragmentation ...................: 1.14%

- Avg. Bytes Free per Page.....................: 1672.8

- Avg. Page Density (full).....................: 79.33%

However, one of the benefits of a clustered index is that you can actually defragment it, so running DBCC INDEXDEFRAG...

dbcc indexdefrag( 0, somedata )


We get 1128 pages moved and 249 removed and our showcontig is now...

 

- Pages Scanned................................: 1130

- Extents Scanned..............................: 144

- Extent Switches..............................: 143

- Avg. Pages per Extent........................: 7.8

- Scan Density [Best Count:Actual Count].......: 98.61% [142:144]

- Logical Scan Fragmentation ..................: 0.44%

- Extent Scan Fragmentation ...................: 21.53%

- Avg. Bytes Free per Page.....................: 251.8

-