background success stories

Postgresql : détecter efficacement les locks

Contexte

Le but de cet article n’est pas de détailler les différents modes de verrouillage et d’isolation des objets (se référer à la doc officielle Postgresql pour cela).

L’objectif, ici, est de détecter et d’analyser efficacement les sessions bloquées qui sont en attente de libération d’un verrou posé par une autre session, lors d’une transaction en cours. Quelle session est bloquée ? Sur quel objet ? Quelle est la session bloquante ? Depuis combien de temps ?

Ce problème est trop souvent sous-estimé dans le tuning sous Postgresql ou dans la supervision car, dans la majorité des situations, les locks ne sont pas (ou mal) détectés, la supervision ne remonte aucun problème de performance lié à une surconsommation des ressources du serveur (mémoire, CPU, disque).

De ce fait, bien souvent, on n’arrive pas à comprendre pourquoi, dans certains cas, une requête peut être longue et prendre 1 heure de plus qu’habituellement car elle a perdu du temps à attendre la libération d’un verrou.

Postgresql est en autocommit implicite, mais il est fréquent qu’une application utilise explicitement des transactions afin de garantir la cohérence de ses données. C’est précisément ce mécanisme de transaction qui, dans certains cas, peut entraîner des verrous dans une base Postgresql.

Symptômes

L’apparition d’un lock bloquant se manifeste souvent par les symptômes suivants :

  • Une requête, habituellement rapide ou instantanée, paraît soudainement longue et interminable, sans raison apparente ;
  • Aucune charge particulière n’est constatée sur l’instance Postgresql ou le serveur ;
  • Le process Unix, correspondant à la session qui exécute la requête, ne consomme pas de CPU ;
  • Facultatif : un ou plusieurs process Unix rattachés à Postgresql, ont un statut « idle in transaction ».

La vue pg_locks, native dans Postgresql, est malheureusement très limitée et ne fournit pas toujours les informations qui permettraient de comprendre quelle autre session est responsable du lock, et surtout sur quel objet.

 

Détection d’un verrou simple

Exemple d’une session bloquée car elle attend qu’une autre session termine sa transaction et libère le verrou.

base_test=# CREATE SCHEMA schema_test;
CREATE SCHEMA
base_test=# CREATE TABLE schema_test.employes (prenom VARCHAR(50), salaire NUMERIC);
CREATE TABLE
base_test=# INSERT INTO schema_test.employes VALUES ('Michel',1500);
INSERT 0 1

 

Session 1 : on ouvre une session et on démarre une transaction pour modifier le salaire de Michel, mais sans fermer la transaction (sans COMMIT ni ROLLBACK) :

base_test=# BEGIN;
BEGIN
base_test=# UPDATE schema_test.employes SET salaire = 1600 WHERE prenom = 'Michel';
UPDATE 1

 

Le process Unix correspondant à la session 1 affiche le statut « idle in transaction » :

[postgres@mon_serveur]$ ps -ef | grep "idle in transaction" | grep -v grep
postgres 23596 1625 0 14:43 ? 00:00:00 postgres: postgres base_test [local] idle in transaction

Ce problème peut arriver lorsqu’une application ferme mal ses transactions et/ou sessions en cours, et des verrous restent sur les objets Postgresql.

 

Session 2 : on ouvre une autre session et on démarre une transaction pour modifier le salaire de Michel à une autre valeur que la précédente :

base_test=# BEGIN;
BEGIN
base_test=# UPDATE schema_test.employes SET salaire = 2000 WHERE prenom = 'Michel';

L’update ne rend pas la main : la session 2 est bloquée car elle attend que la session 1 libère le lock posé sur la ligne, dans la table schema_test.employes.
Tant que la session 1 n’a pas fait de commit, rollback, ou n’a pas été killée, la session 2 reste bloquée.

 

Le process Unix correspondant à la session 2 affiche le statut « UPDATE waiting ».

[postgres@mon_serveur]$ ps -ef | grep "waiting" | grep -v grep
postgres 23776 1625 0 14:44 ? 00:00:00 postgres: postgres base_test [local] UPDATE waiting

Postgresql fournit nativement la vue pg_locks, qui permet de lister les verrous posés sur les objets, et de voir si les sessions ont bien accès aux objets ou si elles sont en attente.

postgres=# SELECT * FROM pg_locks;
   locktype   | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction | pid   | mode                    | granted | fastpath
--------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-------+-------------------------+---------+----------
relation      | 189239   | 11695    | null | null  | null       | null          | null    | null  | null     | 5/1283998          | 24847 | AccessShareLock         | t       | t
virtualxid    | null     | null     | null | null  | 5/1283998  | null          | null    | null  | null     | 5/1283998          | 24847 | ExclusiveLock           | t       | t
relation      | 189239   | 189501   | null | null  | null       | null          | null    | null  | null     | 4/1291735          | 23776 | RowExclusiveLock        | t       | t
virtualxid    | null     | null     | null | null  | 4/1291735  | null          | null    | null  | null     | 4/1291735          | 23776 | ExclusiveLock           | t       | t
relation      | 189239   | 189501   | null | null  | null       | null          | null    | null  | null     | 3/887688           | 23596 | RowExclusiveLock        | t       | t
virtualxid    | null     | null     | null | null  | 3/887688   | null          | null    | null  | null     | 3/887688           | 23596 | ExclusiveLock           | t       | t
relation      | 16706    | 18729    | null | null  | null       | null          | null    | null  | null     | 2/156124           | 24594 | AccessShareLock         | t       | t
virtualxid    | null     | null     | null | null  | 2/156124   | null          | null    | null  | null     | 2/156124           | 24594 | ExclusiveLock           | t       | t
relation      | 16706    | 18649    | null | null  | null       | null          | null    | null  | null     | 2/156124           | 24594 | ShareUpdateExclusiveLock| t       | f
transactionid | null     | null     | null | null  | null       | 53086065      | null    | null  | null     | 4/1291735          | 23776 | ShareLock               | f       | f
tuple         | 189239   | 189501   | 0    | 4     | null       | null          | null    | null  | null     | 4/1291735          | 23776 | ExclusiveLock           | t       | f
transactionid | null     | null     | null | null  | null       | 53111063      | null    | null  | null     | 4/1291735          | 23776 | ExclusiveLock           | t       | f
transactionid | null     | null     | null | null  | null       | 53086065      | null    | null  | null     | 3/887688           | 23596 | ExclusiveLock           | t       | f

 

En filtrant sur la colonne « granted IS false », on peut récupérer le PID Unix de la session bloquée :

postgres=# SELECT * FROM pg_locks WHERE granted IS false;
locktype      | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction | pid   | mode      | granted | fastpath
--------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-------+-----------+---------+----------
transactionid | null     | null     | null | null  | null       | 53086065      | null    | null  | null     | 4/1291735          | 23776 | ShareLock | f       | f

 

On retrouve bien, dans cet exemple, le PID 23776, qui correspond au process Unix au statut « UPDATE waiting » repéré précédemment.

Néanmoins, le résultat de cette vue n’est pas très parlant car, à part faire un kill de la session bloquante, on ne peut pas récupérer facilement les informations sur la session bloquante, la session bloquée, l’objet sur lequel le lock est posé… Et donc le cas risque de se reproduire si l’on n’a pas compris ni corrigé l’origine du problème.

C’est là qu’intervient la requête magique, qui permet de récupérer d’un seul coup toutes les informations sur :

  • La session bloquante (PID, user Postgresql, hostname ou adresse IP, dernière requête SQL lancée, heure de début de la session, heure de début de la requête bloquante) ;
  • La session bloquée (PID, user Postgresql, hostname ou adresse IP, dernière requête SQL lancée, heure de début de la session, heure de début de la requête bloquée) ;
  • La durée depuis laquelle la session bloquée est en attente ;
  • Les informations sur l’objet verrouillé par la session bloquante, sur lequel le lock est posé : schéma de l’objet, nom de l’objet, type d’objet (table, index, séquence, vue).

 

Attention : la requête est à lancer, dans une autre session, sur la base concernée (« base_test » dans l’exemple). En effet, la vue pg_class, qui permet d’avoir les informations sur l’objet locké, est spécifique à chaque base. Ainsi, la requête ci-dessous ne renverrait aucun résultat si on la lançait sur une autre base de l’instance, où aucun lock se serait présent.

Il est conseillé d’activer l’affichage étendu (« \x ») si elle est lancée sous psql en mode console, pour une meilleure visibilité du résultat

base_test=# \x
Expanded display is on.
SELECT
nom_base,
schema_objet_locke,
nom_objet_locke,
type_objet_locke,
duree_bloquage,
pid_session_bloquante,
user_session_bloquante,
client_session_bloquante,
derniere_requete_session_bloquante,
heure_debut_session_bloquante,
heure_debut_requete_bloquante,
pid_session_bloquee,
user_session_bloquee,
client_session_bloquee,
derniere_requete_session_bloquee,
heure_debut_requete_bloquee,
heure_debut_session_bloquee
FROM 
(
SELECT distinct 
RANK() OVER (PARTITION BY c.pid ORDER BY g.query_start DESC) as rang,
c.datname AS nom_base, 
e.nspname AS schema_objet_locke,
d.relname AS nom_objet_locke,
CASE 
WHEN d.relkind IN ('t','r') THEN 'table' 
WHEN d.relkind = 'i' THEN 'index' 
WHEN d.relkind = 's' THEN 'sequence' 
WHEN d.relkind = 'v' THEN 'vue' 
ELSE d.relkind::text 
END AS type_objet_locke,
TO_CHAR(now()-c.query_start,'DD')||'j '||TO_CHAR(now()-c.query_start,'HH24:MI:SS') AS duree_bloquage,
g.pid AS pid_session_bloquante,
g.usename AS user_session_bloquante,
g.client_addr AS client_session_bloquante, 
g.query AS derniere_requete_session_bloquante, 
TO_CHAR(g.backend_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_session_bloquante,
TO_CHAR(g.query_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_requete_bloquante,
c.pid AS pid_session_bloquee, 
c.usename AS user_session_bloquee,
c.client_addr AS client_session_bloquee, 
c.query AS derniere_requete_session_bloquee, 
TO_CHAR(c.query_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_requete_bloquee,
TO_CHAR(c.backend_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_session_bloquee
FROM 
pg_locks AS a,
pg_locks AS b,
pg_stat_activity AS c,
pg_class AS d,
pg_namespace AS e,
pg_locks AS f,
pg_stat_activity AS g
WHERE a.pid = b.pid
AND a.pid = c.pid
AND b.relation = d.oid
AND d.relnamespace = e.oid
AND b.relation = f.relation
AND b.pid <> f.pid
AND f.pid = g.pid
AND c.query_start >= g.query_start
AND a.granted IS FALSE
AND b.relation::regclass IS NOT NULL
AND e.nspname NOT IN ('pg_catalog','pg_toast','information_schema')
AND e.nspname NOT LIKE 'pg_temp_%'
AND f.granted is true
) AS resultat
WHERE rang = 1
ORDER BY resultat.heure_debut_requete_bloquee,resultat.heure_debut_requete_bloquante ;

-[ RECORD 1 ]----------------------+------------------------------------------------------------------------
nom_base | base_test
schema_objet_locke | schema_test
nom_objet_locke | employes
type_objet_locke | table
duree_bloquage | 00j 00:04:01
pid_session_bloquante | 23596
user_session_bloquante | postgres
client_session_bloquante | null
derniere_requete_session_bloquante | UPDATE schema_test.employes SET salaire = 1600 WHERE prenom = 'Michel';
heure_debut_session_bloquante | 20181002 14:43:40
heure_debut_requete_bloquante | 20181002 14:43:44
pid_session_bloquee | 23776
user_session_bloquee | postgres
client_session_bloquee | null
derniere_requete_session_bloquee | UPDATE schema_test.employes SET salaire = 2000 WHERE prenom = 'Michel';
heure_debut_requete_bloquee | 20181002 14:44:20
heure_debut_session_bloquee | 20181002 14:44:12

On a ainsi toutes les informations nécessaires pour corriger le problème et éviter que la situation ne se reproduise à l’avenir. On peut ainsi :

  • Soit killer la session bloquante (PID 23596) si, pour une raison quelconque, la transaction ne s’est pas terminée ou est toujours en cours d’exécution ;
  • Soit corriger le traitement qui lance la session bloquante, afin de terminer proprement la transaction ou d’optimiser la durée d’exécution ;
  • Soit modifier la planification des 2 traitements pour qu’ils n’essaient plus de modifier simultanément les mêmes lignes d’une table (row-level lock).

Remarque : cet exemple montre le cas où, volontairement, une transaction est inactive et pas terminée. Mais la requête fonctionne aussi pour les transactions en cours (exemple : un UPDATE très long d’une session, qui pourrait mettre en attente la modification des mêmes lignes par une autre session).

 

Détection d’un verrou en cascade

Cas plus complexe où plusieurs sessions sont bloquées en cascade, pour détecter la session « mère » à l’origine du blocage initial

base_test=# CREATE SCHEMA schema_test;
CREATE SCHEMA
base_test=# CREATE TABLE schema_test.employes (prenom VARCHAR(50), salaire NUMERIC);
CREATE TABLE
base_test=# INSERT INTO schema_test.employes VALUES ('Michel',1500);
INSERT 0 1
base_test=# CREATE TABLE schema_test.agences (ville VARCHAR(50), code NUMERIC);
CREATE TABLE
base_test=# INSERT INTO schema_test.agences VALUES ('Paris',1);
INSERT 0 1

Session 1 : on ouvre une session et on démarre une transaction pour modifier le salaire de Michel, mais sans fermer la transaction (sans COMMIT ni ROLLBACK) :

base_test=# BEGIN;
BEGIN
base_test=# UPDATE schema_test.employes SET salaire = 1600 WHERE prenom = 'Michel';
UPDATE 1

Session 2 : on ouvre une autre session et on démarre une transaction pour modifier le code de l’agence de Paris dans la table « agences », puis le salaire de Michel, le tout dans la même transaction :

base_test=# BEGIN;
BEGIN
base_test=# UPDATE schema_test.agences SET code = 2 WHERE ville = 'Paris' ;
UPDATE 1
base_test=# UPDATE schema_test.employes SET salaire = 2000 WHERE prenom = 'Michel';

L’update de la table « agences » a abouti, mais la transaction reste bloquée sur l’update de la table employes, comme dans le cas précédent

Session 3 : on ouvre une autre session et on démarre une transaction pour modifier le code de l’agence de Paris dans la table « agences » :

base_test=# BEGIN;
BEGIN
base_test=# UPDATE schema_test.agences SET code = 3 WHERE ville = 'Paris' ;

Bilan de la situation :

  • la session 3 est bloquée par la session 2 (lock sur la ligne ville=’Paris’ dans la table « agences »)
  • la session 2 est bloquée par la session 1 (lock sur la ligne prenom=’Michel’ dans la table « employes »)

On lance, dans une autre session, la même requête que précédemment et on observe le résultat :

base_test=# \x
Expanded display is on.
SELECT
nom_base,
schema_objet_locke,
nom_objet_locke,
type_objet_locke,
duree_bloquage,
pid_session_bloquante,
user_session_bloquante,
client_session_bloquante,
derniere_requete_session_bloquante,
heure_debut_session_bloquante,
heure_debut_requete_bloquante,
pid_session_bloquee,
user_session_bloquee,
client_session_bloquee,
derniere_requete_session_bloquee,
heure_debut_requete_bloquee,
heure_debut_session_bloquee
FROM 
(
SELECT distinct 
RANK() OVER (PARTITION BY c.pid ORDER BY g.query_start DESC) as rang,
c.datname AS nom_base, 
e.nspname AS schema_objet_locke,
d.relname AS nom_objet_locke,
CASE 
WHEN d.relkind IN ('t','r') THEN 'table' 
WHEN d.relkind = 'i' THEN 'index' 
WHEN d.relkind = 's' THEN 'sequence' 
WHEN d.relkind = 'v' THEN 'vue' 
ELSE d.relkind::text 
END AS type_objet_locke,
TO_CHAR(now()-c.query_start,'DD')||'j '||TO_CHAR(now()-c.query_start,'HH24:MI:SS') AS duree_bloquage,
g.pid AS pid_session_bloquante,
g.usename AS user_session_bloquante,
g.client_addr AS client_session_bloquante, 
g.query AS derniere_requete_session_bloquante, 
TO_CHAR(g.backend_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_session_bloquante,
TO_CHAR(g.query_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_requete_bloquante,
c.pid AS pid_session_bloquee, 
c.usename AS user_session_bloquee,
c.client_addr AS client_session_bloquee, 
c.query AS derniere_requete_session_bloquee, 
TO_CHAR(c.query_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_requete_bloquee,
TO_CHAR(c.backend_start,'YYYYMMDD HH24:MI:SS') AS heure_debut_session_bloquee
FROM 
pg_locks AS a,
pg_locks AS b,
pg_stat_activity AS c,
pg_class AS d,
pg_namespace AS e,
pg_locks AS f,
pg_stat_activity AS g
WHERE a.pid = b.pid
AND a.pid = c.pid
AND b.relation = d.oid
AND d.relnamespace = e.oid
AND b.relation = f.relation
AND b.pid <> f.pid
AND f.pid = g.pid
AND c.query_start >= g.query_start
AND a.granted IS FALSE
AND b.relation::regclass IS NOT NULL
AND e.nspname NOT IN ('pg_catalog','pg_toast','information_schema')
AND e.nspname NOT LIKE 'pg_temp_%'
AND f.granted is true
) AS resultat
WHERE rang = 1
ORDER BY resultat.heure_debut_requete_bloquee,resultat.heure_debut_requete_bloquante ;

-[ RECORD 1 ]----------------------+------------------------------------------------------------------------
nom_base | base_test
schema_objet_locke | schema_test
nom_objet_locke | employes
type_objet_locke | table
duree_bloquage | 00j 00:23:46
pid_session_bloquante | 22212
user_session_bloquante | postgres
client_session_bloquante | null
derniere_requete_session_bloquante | UPDATE schema_test.employes SET salaire = 1600 WHERE prenom = 'Michel';
heure_debut_session_bloquante | 20181002 11:29:44
heure_debut_requete_bloquante | 20181002 14:16:07
pid_session_bloquee | 14736
user_session_bloquee | postgres
client_session_bloquee | null
derniere_requete_session_bloquee | UPDATE schema_test.employes SET salaire = 2000 WHERE prenom = 'Michel';
heure_debut_requete_bloquee | 20181002 14:17:16
heure_debut_session_bloquee | 20181002 14:16:59
-[ RECORD 2 ]----------------------+------------------------------------------------------------------------
nom_base | base_test
schema_objet_locke | schema_test
nom_objet_locke | agences
type_objet_locke | table
duree_bloquage | 00j 00:21:41
pid_session_bloquante | 14736
user_session_bloquante | postgres
client_session_bloquante | null
derniere_requete_session_bloquante | UPDATE schema_test.employes SET salaire = 2000 WHERE prenom = 'Michel';
heure_debut_session_bloquante | 20181002 14:16:59
heure_debut_requete_bloquante | 20181002 14:17:16
pid_session_bloquee | 15415
user_session_bloquee | postgres
client_session_bloquee | null
derniere_requete_session_bloquee | UPDATE schema_test.agences SET code = 3 WHERE ville = 'Paris' ;
heure_debut_requete_bloquee | 20181002 14:19:21
heure_debut_session_bloquee | 20181002 14:19:07

La session avec le PID 22212 bloque la session avec le PID 14736
La session avec le PID 14736 bloque la session avec le PID 15415

Dans le cas de blocages en cascade, le premier enregistrement retourné par la requête renvoie les informations sur  la session à l’origine du blocage en cascade (la première dans la hiérarchie)

 

Conclusion

Afin de superviser intelligemment les locks, il est conseillé de faire un script qui boucle sur l’ensemble des bases de l’instance (vue pg_database), et d’exécuter régulièrement cette requête sur chacune des bases.

Si la requête renvoie au moins une ligne, c’est qu’au moins une session est bloquée par une autre, en attente de libération d’un verrou, et c’est toujours intéressant de le remonter dans la supervision car les locks peuvent parfois être à l’origine de problèmes de performances