tisdag 24 maj 2016

Används din databas?

När man träffa kunder så är det vanligt att man inte riktigt vet om sina databaser faktiskt används eller inte och i vilken utsträckning. För att få en överblick av hur det ser ut så brukar jag schemalägga ett jobb som samlar information från DMV´en sys.dm_db_index_usage_stats. Denna dynamic viewe visa löpande information om hur index och tabeller (HEEP´s) används. Jag anser att detta bör kunna ge en bra bild på hur din databas används. Du kan även använda denna information för att se om index faktiskt kan tas bort.

Börja med att skapa en tabell i valfri databas för att logga datat över tiden.

CREATE TABLE [dbo].[IndexUsage](
            [CollectedTime] [datetime] NULL,
            [LastCollectedTime] [datetime] NULL,
            [DatabaseName] [varchar](256) NULL,
            [SchemaName] [varchar](50) NULL,
            [TableName] [varchar](256) NULL,
            [IndexName] [varchar](256) NULL,
            [user_seeks] [bigint] NULL,
            [init_user_seeks] [bigint] NULL,
            [user_scans] [bigint] NULL,
            [init_user_scans] [bigint] NULL,
            [user_updates] [bigint] NULL,
            [init_user_updates] [bigint] NULL,
            [row_count] [bigint] NULL,
            [init_row_count] [bigint] NULL,
            [size_in_mb] [decimal](18, 2) NULL,
            [init_size_in_mb] [decimal](18, 2) NULL
) ON [PRIMARY]

Sedan använder vi sp_MSForEachDB för att köra mot samtliga databaser. Det är två olika skript jag slår ihop i en union. Det ena är för att hämta användande från  sys.dm_db_index_usage_stats och det andra är för att hämta samtliga index från sys.indexes, även dom som då aldrig används. Kör följande kod via ett SQL jobb.

CREATE TABLE #Temp1 (
CollectedTime DATETIME,
DatabaseName VARCHAR(256),
SchemaName VARCHAR(50),
TableName VARCHAR(256),
IndexName VARCHAR(256),
user_seeks BIGINT,
user_scans BIGINT,
user_updates BIGINT,
row_count BIGINT,
size_in_mb DECIMAL(18,2)
)

EXEC sp_MSForEachDB 'USE [?];
WITH CTE AS (
SELECT d.name as DatabaseName,
OBJECT_SCHEMA_NAME(i.object_id) AS SchemaName,
t.name AS TableName,
ISNULL(i.name, ''HEEP'') AS IndexName,
ISNULL(s.user_seeks, 0) as user_seeks,
ISNULL(s.user_scans, 0) as user_scans,
ISNULL(s.user_updates, 0) as user_updates,
ps.row_count,
CAST((ps.reserved_page_count * 8)/1024. as decimal(12,2)) AS size_in_mb
FROM sys.dm_db_index_usage_stats AS s
            INNER JOIN sys.databases d
                         ON d.database_id = s.database_id
            INNER JOIN sys.indexes as i
                         ON s.object_id = i.object_id
                         AND s.index_id = i.index_id
            INNER JOIN sys.tables t
                         ON s.object_id = t.object_id
            INNER JOIN sys.dm_db_partition_stats ps
                         ON i.object_id = ps.object_id
                         AND i.index_id = ps.index_id
WHERE objectproperty(s.object_id,''IsUserTable'') = 1 AND s.database_id = DB_ID()
UNION
SELECT DB_NAME() AS DatbaseName,
OBJECT_SCHEMA_NAME(i.object_id) AS SchemaName,
OBJECT_NAME(i.object_id) AS TableName,
ISNULL(i.name, ''HEEP'') AS IndexName,
0 as user_seeks,
0 as user_scans,
0 as user_updates,
ps.row_count,
CAST((ps.reserved_page_count * 8)/1024. as decimal(12,2)) as size_in_mb
FROM sys.indexes i 
            INNER JOIN sys.objects o
                         ON i.object_id = o.object_id
            LEFT OUTER JOIN sys.dm_db_index_usage_stats s
                         ON s.object_id = i.object_id
                         AND i.index_id = s.index_id
            INNER JOIN sys.dm_db_partition_stats ps
                         ON i.object_id = ps.object_id AND i.index_id = ps.index_id
WHERE OBJECTPROPERTY(i.object_id, ''IsUserTable'') = 1
AND s.object_id IS NULL
)
INSERT INTO #Temp1
SELECT GETDATE(), DatabaseName, SchemaName, TableName, IndexName,
SUM(user_seeks) as user_seeks, SUM(user_scans) as user_scans, SUM(user_updates) as user_updates, SUM(row_count) as row_count, SUM(size_in_mb) as size_in_mb
FROM CTE
GROUP BY DatabaseName, SchemaName, TableName, IndexName
ORDER BY DatabaseName, TableName'

GO

MERGE INTO dbo.IndexUsage AS trg
USING #Temp1 AS src
            ON trg.DatabaseName = src.DatabaseName
            AND trg.SchemaName = src.SchemaName
            AND trg.TableName = src.TableName
            AND trg.IndexName = src.IndexName
WHEN MATCHED THEN
UPDATE SET
            trg.LastCollectedTime = src.CollectedTime,
            trg.user_seeks = src.user_seeks,
            trg.user_scans = src.user_scans,
            trg.user_updates = src.user_updates,
            trg.row_count = src.row_count,
            trg.size_in_mb = src.size_in_mb
WHEN NOT MATCHED BY TARGET THEN
INSERT (CollectedTime, DatabaseName, SchemaName, TableName, IndexName, init_user_seeks, init_user_scans, init_user_updates, init_row_count, init_size_in_mb)
VALUES (src.CollectedTime, src.DatabaseName, src.SchemaName, src.TableName, src.IndexName, src.user_seeks, src.user_scans, src.user_updates, src.row_count, src.size_in_mb);


DROP TABLE #Temp1

För att analysera datat kan man tex använda dessafrågor för att se förändring sedan starten.

SELECT CollectedTime, LastCollectedTime, DatabaseName, SchemaName, TableName, IndexName
,(user_seeks - init_user_seeks) AS user_seeks
,(user_scans - init_user_scans) AS user_scans
,(user_updates - init_user_updates) AS user_updates
,(row_count - init_row_count) AS row_count
,(size_in_mb - init_size_in_mb) AS size_in_mb
FROM [IndexUsage]
ORDER BY user_seeks DESC, user_scans DESC, user_updates DESC

WITH CTE AS (
SELECT DatabaseName, SchemaName, TableName, IndexName
,(User_seeks + user_scans + user_updates) as Activity
FROM IndexUsage
WHERE DatabaseName NOT IN ('Master','Tempdb','Model','MSDB')
)
SELECT DatabaseName, SchemaName, TableName, IndexName
,SUM(Activity) as Activity
FROM CTE
GROUP BY DatabaseName, SchemaName, TableName, IndexName

ORDER BY DatabaseName, SchemaName, TableName, IndexName

onsdag 6 april 2016

Skapa ett dynamiskt restore skript

Många gånger kan det vara bra att ha färdiga skript för att göra restore av en databas. Antingen att man snabbt skall kunna göra restore vid en server eller databas krasch eller som i detta fall som jag tänkte visa, att man kontinuerligt restora databasen till en annan server. Tex för att kunna läsa datat där eller för att ha det som en säkerhets kopia. En fattigmans lösning för high availability helt enkelt.

Låt säga att vi har två servrar, Server1 och Server2. I detta fall så jobbar vi på Server2 där kopian skall ligga. Börja med att sätta upp en linked server, för det behöver vi för att kunna läsa backupinformationen i MSDB databasen på Server1.

Nedan följer kod för att lösa det hela. Först görs en full restore med NORECOVERY. Sedan restoras alla på följande transactionsloggar från efter att full backupen gick klart fram till @Stopat tiden som är vald. Utöka proceduren med mer logik om man önska loggning till en tabell eller retry funktion ifall tex backupfilen är låst av någon orsak. Så klart kan man bygga logik så att full restoren gör en RECOVERY ifall inga transactionsloggsbackuper finns.

Vill man kan man skriva det hela som ett restore skript med PRINT istället för EXEC och lägga ut det i en textfil. Dock inte beskrivet här.

ALTER PROCEDURE [dbo].[usp_RestoreScript] @DBname VARCHAR(100)
AS

SET NOCOUNT ON

DECLARE @lastFullBackup INT, @lastFullBackupPath VARCHAR(2000), @lastFullBackupFinishDate DATETIME, @i INT, @lastLogBackup INT, @logBackupPath VARCHAR(2000), @BackupFinishDate DATETIME, @Stopat DATETIME, @Message VARCHAR(MAX), @ErrorNumber INT, @ErrorLine INT

BEGIN
BEGIN
       -- Create temp table
       CREATE TABLE #MSDBBackupHistory (
       id INT IDENTITY(1,1),
       backup_set_id INT,
       media_set_id INT,
       position INT,
       backup_start_date DATETIME,
       backup_finish_date DATETIME,
       backup_type CHAR(1),
       physical_device_name VARCHAR(1000)
       )
       -- Dump the backup info into the temp table
INSERT INTO #MSDBBackupHistory (backup_set_id, media_set_id, position, backup_start_date, backup_finish_date, backup_type, physical_device_name)
SELECT bs.backup_set_id, bs.media_set_id, bs.position, bs.backup_start_date, bs.backup_finish_date, bs.type, RTRIM(bmf.physical_device_name) AS physical_device_name
              FROM LINKED_Server.msdb.dbo.backupset bs
                     JOIN LINKED_Server.msdb.dbo.backupmediafamily bmf
                            ON bmf.media_set_id = bs.media_set_id
       WHERE bs.database_name = @DBname
       ORDER BY bs.backup_start_date

BEGIN TRY
-- Get the last Full backup info.
SET @lastFullBackup = (SELECT MAX(id) FROM #MSDBBackupHistory WHERE backup_type='D')
SET @lastFullBackupPath = (SELECT physical_device_name FROM #MSDBBackupHistory WHERE id=@lastFullBackup)
SET @lastFullBackupFinishDate = (SELECT MAX(backup_finish_date) FROM #MSDBBackupHistory WHERE backup_type='D')

-- Restore the Full backup
DECLARE @SQL1 VARCHAR(MAX)
SET @SQL1 = 'RESTORE DATABASE ' + @DBName +' FROM DISK ='''+ @lastFullBackupPath + ''' WITH move ''LogicalName'' to ''E:\PR14_Data2\MSSQL11.DB_PR14\MSSQL\Data\DatabaseFileName.mdf'',
move ''LogicalNamelog'' to ''E:\PR14_Logs\MSSQL11.DB_PR14\MSSQL\Data\DatabaseFileName_log.ldf'',
NORECOVERY, REPLACE, STATS = 10'
EXEC (@SQL1)

--Set parameter for log restore
SET @i = (SELECT MIN(id) FROM #MSDBBackupHistory WHERE backup_type='L' and backup_start_date >= @lastFullBackupFinishDate )
SET @Stopat = (SELECT DATEADD(day, DATEDIFF(day, 0, GETDATE()), 0))
SET @BackupFinishDate = (select MAX(backup_finish_date) from #MSDBBackupHistory where backup_finish_date <= @Stopat)

-- Restore the transactionlogs
WHILE (@i <= (SELECT MAX(id) FROM #MSDBBackupHistory where backup_finish_date <= @BackupFinishDate))
BEGIN
       
       SET @logBackupPath = (SELECT physical_device_name FROM #MSDBBackupHistory WHERE id=@i)

       IF (@i = (SELECT MAX(id) FROM #MSDBBackupHistory where backup_finish_date <= @BackupFinishDate))
       SET @SQL1 = 'RESTORE LOG '+ @DBName +' FROM DISK = '''+ @logBackupPath + ''' WITH RECOVERY, STATS=5, STOPAT = '''+CAST(@Stopat as varchar(50))+''';'
       ELSE
       SET @SQL1 = 'RESTORE LOG '+ @DBName +' FROM DISK = '''+ @logBackupPath + ''' WITH NORECOVERY, STATS=5;'
       EXEC (@SQL1)

SET @i = @i + 1

END
--End restore logs  
END TRY
BEGIN CATCH
THROW;
END CATCH
END
-- remove temp objects that exist
IF OBJECT_ID('tempdb..#MSDBBackupHistory') IS NOT NULL
DROP TABLE #MSDBBackupHistory

END


torsdag 5 november 2015

Vanliga problem som DBA-n råkar utför – Parameter Sniffing

Som dba ser jag ofta att kunder råkar utför den så kallade paramater sniffingen. Applikationer och frågor i SQL tar helt plötsligt och oförklarligt mycket längre tid än vad dom brukar. När något går trögt så är det till DBA-n man vänder sig och frågar vad som är fel. Som jag ser det är det kanske inte en DBA-s roll att lösa det då det är mer en utvecklar sak men man måste så klart vara behjälplig med att lösa problemet. Så hur man än gör så är det på ens bord. I denna artikel skall jag kort beskriva vad det är, hur man identifiera och sen löser problemet.


Förklaring

Från min erfarenhet är problemet oftast orsakat av en query som har en kolumn i where klausulen med stor skillnad på data. Som i mitt exempel att man tex har ett värde av Kiruna och 1 miljon av värdet Halmstad. När det är sån stor skillnad så kommer SQL förmodligen välja olika query plans beroende på hur man skall hämta datat, för att göra det på effektivaste sättet. Tex så gör vi en select stjärna och respektive värde I where klausulen. För den enstaka raden väljer SQL en Key Lookup medans det är mer effektivt att göra en Clustred Index Scan för en miljon rader.

SELECT * FROM dbo.customer WHERE City = 'Kiruna'
SELECT * FROM dbo.customer WHERE City = 'Halmstad'



























Vad händer nu när vi skapar en stored procedure för denna query istället? Den kommer med stor sannolikhet kunna ha problem med parameter sniffing. Så låt oss testa.

CREATE PROCEDURE USP_GetCustomerByCity @City VARCHAR(50)
AS
SELECT * FROM dbo.customer WHERE City = @City

Om vi nu kör denna procedur med den höga selektiviteten, alltså värdet ’Kiruna’ kommer proceduren att kompileras för detta värde. Detta är helt i sin ordning för det är så SQL fungerar. Om vi nu kör den med det andra värdet ’Hamlstad’ så är den ju fortfarande optimerad för värdet ’Kiruna’. SQL vet att det bara finns en rad med detta värde och väljer således den query plan som är bäst för hög selektivitet. Den gör en Key Lookup.

EXEC USP_GetCustomerByCity 'Halmstad'
































Hade SQL valt en Clustred Index Scan istället hade det blivit en dramatisk skillnad i antal reads. Titta vi på outputen från Statistics IO ser det ut så här.

Table 'customer'. Scan count 1, logical reads 7316
Table 'customer'. Scan count 1, logical reads 3002730

Hur kan man hitta problem procedurerna

Det finns tyvärr inget enkelt sätt att hitta dessa problem. En möjlig väg är att titta i Plan cachen efter querys som har stora skillnader i logical reads. DMV-en sys.dm_exec_query_stats har information om tex hur många gånger en query har körts, hur mycket read, cpu mm den tagit. Om vi kör denna fråga nedan så få vi lite intressant information.



WITH CTE as (
SELECT qs.execution_count,
    SUBSTRING (qt.text,(qs.statement_start_offset/2) + 1,
                         ((CASE WHEN qs.statement_end_offset = -1
                           THEN LEN(CONVERT(NVARCHAR(MAX), qt.text)) * 2
                           ELSE qs.statement_end_offset
                           END - qs.statement_start_offset)/2) + 1
                         ) AS [Individual Query]
            ,qt.text AS [Parent Query],
            qs.last_logical_reads,
            qs.max_logical_reads,
            qs.min_logical_reads,
            qs.total_logical_reads
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS qt
WHERE qt.dbid = 28
), CTE2 as (
select *
, max_logical_reads - min_logical_reads as diff
FROM CTE)
SELECT * FROM CTE2
ORDER BY diff DESC;


Frågan returnera information från Plan Cachen för vår procedur. Den säger att den blivit kört sju gånger och som mest har den genererat 3002734 reads jämfört med sex som minst. Rätt stor skillnad här så i detta läge så skulle man kunna misstänka att denna procedur har eller kan drabbas av parameter sniffing problem.




Åtgärder

En åtgärd som jag kommit på mig själv att göra är att bara köra et sp_upstestats. En uppdatering av statistiken triggar nämligen en omkompilering. Men detta är väl inte en bra lösning i längden utan bara något man oftast gör för att det är bråttom. Det finns riktiga lösningar på det. Nedan följer några med kort förklaring.

Recompile

Skapa proceduren med optionen recompile.
CREATE PROCEDURE [dbo].[USP_GetCustomerByCity] @City VARCHAR(50)
WITH RECOMPILE
AS
SELECT * FROM dbo.customer WHERE City = @City

Optimize For

Optimera proceduren för ett värde som du vet är representant för alla värde med optionen Optimize for.
CREATE PROCEDURE [dbo].[USP_GetCustomerByCity] @City VARCHAR(50)
AS
SELECT * FROM dbo.customer WHERE City = @City
OPTION (OPTIMIZE FOR (@City = 'Halmstad'))

Plan Guide

Denna option kan vara ett sätta att lösa det om man inte har möjlighet att ändra i koden för tex en tredjeparts applikation.

Local Variable

En annan variant är att man använder en lokal variabel i sin procedur. Gör man detta vet inte SQL riktig vad den skall använda för värde och titta då i statistiken och optimera utefter det. Skall inte beskriva hur här men att göra det kan bli både bättre och sämre.

Sammanfatting

Problemet är relativt vanligt enligt min uppfattning men jag tycker att förhållandevis många utvecklare är dålig koll på vad det är. Hoppas att man i kommande SQL versioner enklare kan hitta problemen. Har tittat lite på Query Storen i SQL 2016. Där finns en del statistik över frågor så man enklare skulle kunna jämföra hur frågor körts. Lite som att använda DMV jag nämnde tidigare men den visa ju bara vad som finns i cachen, inte över tid.