Tuning Indexes(J): Evaluating The Server(1) Part 22 Storage Engine

it is easy to get caught in analyzing a single table or database when that specific database has 1 percent of the rows, but it is not indexed enough, while the other table or database, which contains most of the data is there unserved

so, if we want to have a holistic overview, we need to look at 2 tools:

  • performance counters
  • and wait_stats

Performance counters

now, they would not show us exactly where to look, but they would tell us where the problem might be

now we stored some of them in this series before, now we are going to discuss how to use each one of them in cases

now we used the snapshots aka copies and history aka everything table method

so we are going with that

Case 1: forwarded records per second

a forwarded record is a record in a heap, that has been stored on a page

then at some point, that record is updated

now instead of allocating a new row_id SQL server elects to keep the record id in the same place,

and it stores the extra data on another page

and it keeps a stub or a forwarding stub at the original row_id

so now we have the row_id

updated data on another page

and a pointer or a forwarding stub that locates the updated data

why it would do such a thing?

because the updated row won’t fit on the same page

why not store it in a different row_id?

to avoid updating nonclustered indexes

for more details check out our blog

now for the impact of forwarding records on heaps, you can check this out here

now let’s create a heap that has some forwarding records:

create database testing
go
use testing
go
create table forwarding (
id int identity(1,1),
col1 varchar(6000)
)
create nonclustered index ix_idd on dbo.forwarding(id);
go
insert into dbo.forwarding(col1) 
values (replicate('x',500))
go 1000
update dbo.forwarding
set col1 = replicate('x',3000)
where id % 3 = 1
go

so what we did here is to create a table that has col1 6000 kb even if it is null since it is char, not a varchar

then we update the values that have the remaining of 1 if divided by 3

so we have a forwarding record in each 3

we have a lot of pages there that only have a pointer to another page

now we are going to select* from it while we have PerfMon live monitor open

so in the start menu in windows type Performance monitor then select the live data window

then select forwarded records:

now we are going to use SQL query stress to run this select statement

select*
from dbo.forwarding

we ran it 10 times in there and this is what we got

so this shows that the whole server is asking for a lot of forwarding records, but we don’t know which database to check

now this could be recorded in PerMon and read in Excel

or we could use the method we used before for snapshots and history

to do that check out our blog

now we could do what we did in there

take a snapshot now

and then run the select 100 times

and then take another snapshot

in our first snapshot, we don’t have any forwarded records

since we were not selecting at that particular moment

now we are going to run SQL query stress for 100 seconds and see what it records between that counter_date and this one

now the last time stamp was 2025-03-21 17:49:00

so we inserted between both

now as you can see here it detected the record change

now how could we know if this value is significant?

Forwarded records per second/ batch request per second

now this ratio should not exceed 10 percent

now one of them is at row level forwarded

and the other one works per requests on the server

but their division might be a good indicator

now how could we calculate that?

(we are basing this script on Jason Strate and Edward Pollack’s in indexing method here )


with forwarded_calculations as 
(
select 
counter_date,
server_name,
MAX(iif(counter_name = 'Forwarded Records/sec', new_counter_value,null)) as forwarded,
max(iif(counter_name ='Batch Requests/sec', new_counter_value,null)) as batch_request
from dbo.counters
where counter_name in ('Forwarded Records/sec','Batch Requests/sec')
group by counter_date, server_name
)
select
counter_date,
server_name,
forwarded,
(forwarded/nullif(batch_request*10,0)) as forwarded_ratio
from forwarded_calculations;

the output was like:

now there was a lot of output, but as you can see, since we ran the same test multiple times with the same values, we got similar output for each, except maybe at the end

so you see it

you look at the forwarded records alone, we might be mislead

but if we look at it with a ratio to the total batch requests, we can make an educated guess that some heap may be affecting the whole database

now if we keep looking individually, we can’t recognize trends

but if we query like the following:

with forwarded_calculations as 
(
select 
counter_date,
server_name,
MAX(iif(counter_name = 'Forwarded Records/sec', new_counter_value,null)) as forwarded,
max(iif(counter_name ='Batch Requests/sec', new_counter_value,null)) as batch_request
from dbo.counters
where counter_name in ('Forwarded Records/sec','Batch Requests/sec')
group by counter_date, server_name
)

select
server_name,
min(forwarded) as min_forwarded,
max(forwarded) as max_forwarded,
avg(forwarded) as avg_forwarded,
min((forwarded/nullif(batch_request*10,0))) as min_forwarded_ratio,
max((forwarded/nullif(batch_request*10,0))) as max_forwarded_ratio,
avg((forwarded/nullif(batch_request*10,0))) as avg_forwarded_ratio,
format
      (
      1.*sum
           (
             iif
                ((forwarded/nullif(batch_request*10,0))>1,
                 1,
                 null
                )

            )/count(*)
            ,'0.00%'
      ) as percent_violation
from forwarded_calculations
group by server_name

we got:

now for the max, avg, and min there is no issue

but for the last aggregation:

format
      (
      1.*sum
           (
             iif
                ((forwarded/nullif(batch_request*10,0))>1,
                 1,
                 null
                )

            )/count(*)
            ,'0.00%'
      ) as percent_violation

now first we get our calculation

then the iff function

if it exceeds 1 then we put 1

otherwise

the output is 1

now we are going to average percentage by dividing it by the count(*)

now to get the float division we multiply by 1.0 so SQL server does not have to round up

and then we format it using the format function as a percentage

and we get our desired output

which 77.78 percent

now this is too high since our workload consists of our demo

so we need to look into the heap thing

now if it was below 1 percent we don’t need to look at it

since it is too low to yield any benefit

unless we are looking to squeeze every bit of performance

but before that, we need to look at other issues

so that is it for performance counters

we can look at the minimum and maximum

are the average closer to the minimum or the maximum

if it is the maximum

then we need to look into it

now we could do the following:

use testing
go
if object_id('tempdb..#heaps') is not null
         drop table #heaps
create table #heaps 
(
database_id int,
object_id int,
page_count iny ,
avg_page_space_used_in_percent decimal(6,3),
record_count int,
forwarded_record_count int
)

now until here not much, a temporary table based on the docs definition

use testing
go
if object_id('tempdb..#heaps') is not null
         drop table #heaps
create table #heaps 
(
database_id int,
object_id int,
page_count iny ,
avg_page_space_used_in_percent decimal(6,3),
record_count int,
forwarded_record_count int
)
----
declare find_heaps_cursor forward_only for 
select
from sys.indexes i 
where index_id = 0 
declare @index_id  int
open find_heaps_cursor
fetch next from find_heaps_cursor into @index_id
while @@fetch_status = 0
begin
    insert into #heaps
    select
    db_id(),
    object_id,
    page_count,
    avg_page_space_used_in_percent,
    record_count,
    forwarded_record_count
    from sys.dm_db_index_physical_stats(db_id(),@index_id,0,null,'detailed');
   fetch next from find_heaps_cursor into @index_id 
 end
close find_heaps_cursor
deallocate find_heaps_cursor
---

now here we declare a cursor

that inserts the index_id variable into the parameter at physical stats DMF

while the database_id = db_id()

the fetch next forward only, makes it keeps going forward until the last row she finds where @@fetch_status = 0

and what are we inserting from the DMF, the stuff we defined in the temporary table

straightforward

use testing
go
if object_id('tempdb..#heaps') is not null
         drop table #heaps
create table #heaps 
(
database_id int,
object_id int,
page_count int ,
avg_page_space_used_in_percent decimal(6,3),
record_count int,
forwarded_record_count int
)
----
declare find_heaps_cursor cursor forward_only FOR
select object_id
from sys.indexes i 
where index_id = 0 
declare @index_id  int
open find_heaps_cursor
fetch next from find_heaps_cursor into @index_id
while @@fetch_status = 0
begin
    insert into #heaps
    select
    db_id(),
    object_id,
    page_count,
    avg_page_space_used_in_percent,
    record_count,
    forwarded_record_count
    from sys.dm_db_index_physical_stats(db_id(),@index_id,0,null,'detailed');
   fetch next from find_heaps_cursor into @index_id 
 end
close find_heaps_cursor
deallocate find_heaps_cursor
---
select 
DB_NAME(database_id),
QUOTENAME(object_schema_name(OBJECT_ID)) + '.'+ QUOTENAME(object_name(object_id)) as [object_name],
page_count,
avg_page_space_used_in_percent,
record_count,
forwarded_record_count,
s.forwarded_fetch_count,
convert(decimal(7,3),100.*forwarded_record_count/record_count) as forwarded_ratio,
convert(decimal(13,3),1.*s.forwarded_fetch_count/forwarded_record_count) as forwarded_row
from #heaps hs
       cross apply (
                     select sum(e.forwarded_fetch_count) as forwarded_fetch_count
                     from index_dmo.dbo.index_operational_stats_everything e
                     where e.database_id = hs.database_id
                       and e.object_id = hs.object_id
                       and e.index_id = 0) s
where forwarded_record_count> 0 
order by page_count desc

now here, what we did after we got the temporary table that contains the physical stats we want

the newly updated heap that has a lot of forwarded records from the cursor

we are measuring how often it is being used

by our stored data

that we scripted from operational stats( for more check out this)

now we are only getting heaps

and we are calculating two ratios

one for how many records do you have as forwarded in general compared to the regular old fashion records

and how many times did we fetch it, you know fetch the row

ordered them by pages, since if the table is too small to be considered there is no need to fix it

the output was like:

so we see the database in quotes because of the quotename,

we see the percentage of forwarded in our forwarded_ratio of records that are forwarded 20 percent is high,

now of those 20 percent, each one has been accessed 309 times

that is a lot

and the pages are not much but since we are testing we are going to pay attention to this specific table

now it is using most of its space since most of the table is not forwarded

we see everything we requested, the next step is to take action

now what could we do?

change it to a non-variable column so we don’t have to deal with increase in space in page and then forward

The downside, takes more space, and the character field has a limit so It might some query errors

Create a clustered index. okay but which key should we pick

Rebuild the heap?

this is the worst since forwarded records will come back as soon as new update statements start to kick in

or the other options we talked about in here

and we that we finish our first performance counter analysis, see you in the next blog

One response to “Tuning Indexes(J): Evaluating The Server(1) Part 22 Storage Engine”

Leave a Reply

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