Auditer les processus bloqués : l’événement blocked process report

SQL Server 2005 a introduit un évènement capturable par SQL Profiler et par les Notifications d’évènements, qui permet de retrouver les processus qui ont été bloqués. Avec la sortie de SQL Server 2008 et l’introduction des Évènements Étendus, il nous est également possible de capturer cet évènement.

Dans cet article, je vous propose de voir comment paramétrer SQL Server pour capturer cet évènement, puis de vous aider à interpréter le document XML qui décrit la situation de blocage.

Toute exécution de requête résulte en l’acquisition de verrous, dont la granularité (ligne, page, clé, partition, table, base de données, ou encore et plus rarement compilation) et le mode (partagé, exclusif, de mise à jour, ou d’intention) varient. Le verrouillage de ressources est une opération de base pour le moteur de stockage de tout moteur de bases de données SQL, qui garantit l’intégrité de la base de données à travers toutes les opérations de manipulation de données que celle-ci supporte. Cependant, il arrive que lorsqu’un processus a posé un verrou sur une ressource, d’autres processus doivent simultanément accéder à cette même ressource : ces processus sont alors bloqués. Comme il est possible que le processus bloquant ne libère ledit verrou qu’après une durée relativement longue, les effets possibles d’une telle situation sur nos applications peuvent aller de requêtes dont la durée d’exécution est élevée jusqu’à l’expiration du délai d’attente d’une réponse pour l’exécution d’un requête : le fameux timeout.

Il est néanmoins possible, à l’aide d’une trace SQL Profiler ou des Évènements Étendus, de capturer ces situations, qui sont décrites par un document XML.

Configurer l’instance SQL Server

Le seuil de durée de blocage à partir de laquelle on souhaite capturer un tel évènement est configurable à l’aide de l’option d’instance blocked process threshold. On accède à ce paramètre :

– soit à l’aide de SQL Server Management Studio (SSMS), après un clic-droit sur l’instance dans l’Explorateur d’Objets (F8), et avoir choisi l’option Propriétés :

– soit à l’aide de la procédure stockée système sp_configure :

1
2
3
SELECT  *
FROM    sys.configurations
WHERE   name LIKE '%block%'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- Activation de la modification d'options d'instance avancées
EXEC sys.sp_configure 'show advanced options', 1
GO
RECONFIGURE
GO

-- Configuration du seuil de durée de blocage à 5 secondes
EXEC sys.sp_configure 'blocked process threshold', 5
GO
RECONFIGURE;
GO

-- Désactivation de la modification d'options d'instance avancées
EXEC sys.sp_configure 'show advanced options', 0
GO
RECONFIGURE
GO

La console de SSMS affiche, à l’exécution de ce lot de requêtes :

Veillez à ne pas mettre un seuil trop bas : sans cela, on aurait le processus d’arrière plan LOCK MONITOR qui s’exécuterait constamment. Or ce dernier, qui est aussi en charge de détecter les deadlocks, est gourmand en ressources CPU. En s’exécutant fréquemment, il pourrait donc impacter négativement les performances générales de l’instance SQL Server. D’un autre côté, 5 secondes est une durée très longue sur une base de données qui supporte une charge de travail OLTP conséquente. On trouve ce processus avec la requête suivante :

1
2
3
4
5
6
7
8
SELECT  session_id
        , status
        , command
        , wait_type
        , wait_time
        , last_wait_type
FROM    sys.dm_exec_requests
WHERE   command = 'LOCK MONITOR'

Si l’on exécute maintenant cette requête de façon répétée, on voit que la valeur de la colonne wait_time croit jusqu’à atteindre 5000, mais ne dépasse jamais cette valeur : cette colonne affiche sa valeur en millisecondes, et montre bien que le processus cherche les processus bloqués toutes les 5 secondes.

Il faut aussi noter qu’avec cette configuration, si un processus reste bloqué 15 secondes, l’évènement sera généré 3 fois pour la même situation de blocage.

Capture avec SQL Profiler

Une fois cela fait, nous démarrons SQL Profiler, et dans l’onglet de sélection des évènements, dans la catégorie Errors and Warnings (et non pas dans la catégorie Locks), nous trouvons l’évènement Blocked process report :

Nous configurons l’évènement comme suit, où le champ TextData contiendra le document XML décrivant la situation de blocage :

N.B. : N’oubliez pas le champ Mode, qui procure le type de verrou acquis.

Capture avec les Évènements Étendus (XE)

Pour les lecteurs qui n’ont pas encore eu le temps de se familiariser avec cette fonctionnalité, qui est vouée au remplacement futur de SQL Profiler, et dont la puissance en fait mon outil de diagnostic favori, vous pouvez vous aider de l’article que j’ai publié sur le sujet.

Si l’on souhaite capturer en permanence toute situation de blocage, l’avantage revient à XE, puisqu’on peut stocker ces évènements dans une cible dite anneau de mémoire (ring buffer). Cette structure consomme très peu de mémoire et fonctionne à l’image d’un FIFO : lorsqu’il est plein, la capture d’un nouvel évènement donne lieu à l’éviction du plus vieil évènement pour le stockage de celui-ci. Avec SQL Profiler, il est nécessaire de mettre en place une trace côté serveur et de gérer le fichier de trace et l’espace disque. En revanche, une fois que l’on a démarré la session XE, on peut « l’oublier ».

Pour les lecteurs familiers de XE et de Profiler, sachez que le document XML produit par ces deux fonctionnalités pour un même évènement de blocage est identique, ce qui n’est pas le cas avec les deadlocks (XE produit un document XML mal formé, qui rend impossible son enregistrement en document de type .xdl en vue de son interprétation graphique dans SSMS).

La définition d’une telle session est simple :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Définition de la session
CREATE EVENT SESSION blocked_process_report_RB ON SERVER
ADD EVENT sqlserver.blocked_process_report
ADD TARGET package0.ring_buffer (SET MAX_MEMORY = 4096)
WITH (MAX_DISPATCH_LATENCY = 5 SECONDS)
GO

-- Démarrage de la session
ALTER EVENT SESSION blocked_process_report_RB ON SERVER
STATE = START

-- Vérification
SELECT  name
        , create_time
FROM    sys.dm_xe_sessions
WHERE   name = 'blocked_process_report_RB'

Le test

Pour l’exemple, nous avons la trace SQL Profiler et la session XE ci-dessus actifs.
Nous créons la table suivante :

1
2
3
4
5
6
CREATE TABLE blocked_process_report_test
(
        cle varchar(16) NOT NULL
                CONSTRAINT UQ_blocked_process_report_test UNIQUE CLUSTERED
        , valeur tinyint NOT NULL
)

Puis, dans une première fenêtre de requête (A) sous SSMS, nous ajoutons la ligne suivante, sans valider la transaction :

1
2
3
4
5
6
7
8
9
10
11
BEGIN TRANSACTION
        INSERT  INTO dbo.blocked_process_report_test
        (
                cle
                , valeur
        )
        VALUES
        (
                'TEST'
                , 12
        )

Dans une seconde fenêtre de requête (B), nous exécutons :

1
2
3
UPDATE  dbo.blocked_process_report_test
SET     valeur = 15
WHERE   cle = 'TEST'

Ceci fait que la requête B doit attendre que la requête A soit terminée pour réaliser l’UPDATE, ce qui ne peut se faire que si l’on valide (COMMIT) ou annule (ROLLBACK) la transaction : nous ne le faisons pas ici, pour l’exemple. Nous laissons aussi la requête B s’exécuter 10 secondes, de façon à bien voir que l’évènement est capturé deux fois pour la même situation de blocage.

Ce que capture SQL Profiler

L’interface affiche bien deux captures de l’évènement :

Notez que les valeurs pour la colonne Duration sont données ici en microsecondes : on voit donc qu’il y a un léger décalage entre la détection du blocage et sa capture. Toutes les informations dont nous avons besoin sont donc sous nos yeux: la ressource à la source de la situation de blocage, ainsi que les requêtes impliquées dans celle-ci.

Il est possible de charger le fichier de trace dans une table à l’aide de la fonction sys.fn_trace_gettable(). Cela permet l’écriture d’une requête utilisant les méthodes SQLXML, qui extrait directement les informations intéressantes de chaque document XML. La requête est similaire à celle que je donne dans la section suivante.

Ce que capture la session XE

Ici il nous est nécessaire d’écrire une requête faisant appel aux fonctions SQLXML pour dépouiller le document XML sans se fatiguer :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SELECT  DATEADD(hour, DATEDIFF(hour, GETUTCDATE(), GETDATE()), doc.value('(event/@timestamp)[1]', 'datetime')) AS log_date_time
        , doc.value('(event/data[@name="transaction_id"]/value)[1]', 'bigint') AS transaction_id
        , doc.value('(event/data[@name="database_name"]/value)[1]', 'nvarchar(128)') AS database_name
        , doc.value('(event/data[@name="duration"]/value)[1]', 'int') / 1000 AS duration_ms
        , doc.value('(event/data[@name="resource_owner_type"]/text)[1]', 'varchar(10)') AS resource_owner_type
        , doc.value('(event/data[@name="lock_mode"]/text)[1]', 'varchar(10)') AS lock_mode
        , doc.value('(event/data[@name="blocked_process"]/value/blocked-process-report/blocked-process/process/@waitresource)[1]', 'varchar(max)') AS wait_resource
        , doc.value('(event/data[@name="blocked_process"]/value/blocked-process-report/blocked-process/process/@isolationlevel)[1]', 'varchar(max)') AS blocked_tran_iso_level
        , doc.value('(event/data[@name="blocked_process"]/value/blocked-process-report/blocking-process/process/@isolationlevel)[1]', 'varchar(max)') AS blocking_tran_iso_level
        , doc.value('(event/data[@name="blocked_process"]/value/blocked-process-report/blocked-process/process/inputbuf)[1]', 'varchar(max)') AS blocked_statement
        , doc.value('(event/data[@name="blocked_process"]/value/blocked-process-report/blocking-process/process/inputbuf)[1]', 'varchar(max)') AS blocking_statement
FROM    (  
                SELECT          D.n.query('.') AS doc
                FROM            (
                                        SELECT          CAST(target_data AS xml) AS data
                                        FROM            sys.dm_xe_sessions AS S    
                                        INNER JOIN      sys.dm_xe_session_targets AS ST
                                                                ON S.address = ST.event_session_address
                                        WHERE           S.name = 'blocked_process_report_RB'
                                        AND             ST.target_name = 'ring_buffer'
                                ) AS sub
                CROSS APPLY     data.nodes('RingBufferTarget/event') AS D(n)
        ) AS T

La capture d’écran du résultat a été coupée en deux pour faciliter un peu la lecture :

Interprétation du résultat

Notre intérêt dans l’interprétation de ce document se portera sur la propriété waitresource, que l’on peut décrypter à l’aide de la documentation (Voir la section Waitresource).

Ici, waitresource est : KEY: 5:72057594039107584 (2158b2b5a6d5), ce qui signifie :

– Database ID : 5
– Partition ID : 72057594039107584
– (2158b2b5a6d5) : le hash de la clé d’index

Sur ce dernier point, il faut savoir que SQL Server utilise le hash, stocké sur 6 octets, de la clé de l’index qui supporte la requête. Ceci conduit SQL Server à générer des valeurs de hash identiques pour des valeurs de clé différentes, ce qui peut causer d’autre problèmes de concurrence d’accès. C’est une raison de plus pour choisir des clés d’index les plus « étroites » possible, de façon à éviter ce type de collisions.

Voyons comment retrouver la ressource qui a été verrouillée :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
USE ELSUKET
GO

SELECT          O.name AS table_name
                , I.name AS index_name
                , P.partition_number
                , P.rows
FROM            sys.partitions AS p
INNER JOIN      sys.objects AS O
                        ON O.object_id = P.object_id
INNER JOIN      sys.indexes AS I
                        ON P.object_id = I.object_id
                        AND P.index_id = I.index_id
WHERE           p.partition_id = 72057594039107584

Voyons maintenant comment retrouver la clé qui a été utilisée :

1
2
3
SELECT  *
FROM    dbo.blocked_process_report_test WITH(INDEX = UQ_blocked_process_report_test)
WHERE   %%lockres%% = '(2158b2b5a6d5)'

La fonction %%lockres%% n’est pas documentée : il est donc recommandé de l’utiliser avec parcimonie. La même méthodologie est utilisable dans le diagnostic des deadlocks.

Bon déblocage à tous !

ElSüket.

Laisser un commentaire