MVCC dans PostgreSQL-4. Instantanés

Après avoir discuté des problèmes d' isolement et avoir fait une digression concernant la structure des données de bas niveau , la dernière fois nous avons exploré les versions des lignes et observé comment différentes opérations ont changé les champs d'en-tête de tuple.

Nous allons maintenant voir comment des instantanés de données cohérents sont obtenus à partir de tuples.

Qu'est-ce qu'un instantané de données?


Les pages de données peuvent contenir physiquement plusieurs versions de la même ligne. Mais chaque transaction ne doit voir qu'une (ou aucune) version de chaque ligne, de sorte que toutes constituent une image cohérente des données (au sens d'ACID) à partir d'un certain moment.

L'isolement dans PosgreSQL est basé sur des instantanés: chaque transaction fonctionne avec son propre instantané de données, qui "contient" les données qui ont été validées avant le moment où l'instantané a été créé et ne "contient" pas de données qui n'ont pas encore été validées à ce moment-là. Nous avons déjà vu que, bien que l'isolement résultant semble plus strict que requis par la norme, il présente toujours des anomalies.

Au niveau d'isolement de lecture validée, un instantané est créé au début de chaque instruction de transaction. Cet instantané est actif pendant l'exécution de l'instruction. Sur la figure, le moment où l'instantané a été créé (qui, comme nous le rappelons, est déterminé par l'ID de transaction) est affiché en bleu.



Aux niveaux de lecture répétable et de sérialisation, l'instantané est créé une fois, au début de la première instruction de transaction. Un tel instantané reste actif jusqu'à la fin de la transaction.



Visibilité des tuples dans un instantané


Règles de visibilité


Un instantané n'est certainement pas une copie physique de tous les tuples nécessaires. Un instantané est en fait spécifié par plusieurs nombres, et la visibilité des tuples dans un instantané est déterminée par des règles.

Le fait qu'un tuple soit visible ou non dans un instantané dépend de deux champs dans l'en-tête, à savoir xmin et xmax , c'est-à-dire les ID des transactions qui ont créé et supprimé le tuple. Des intervalles comme celui-ci ne se chevauchent pas et, par conséquent, pas plus d'une version représente une ligne dans chaque instantané.

Les règles de visibilité exactes sont assez compliquées et prennent en compte de nombreux cas et extrêmes différents.
Vous pouvez facilement vous en assurer en consultant src / backend / utils / time / tqual.c (dans la version 12, la vérification a été déplacée vers src / backend / access / heap / heapam_visibility.c).

Pour simplifier, nous pouvons dire qu'un tuple est visible lorsque dans l'instantané, les modifications apportées par la transaction xmin sont visibles, tandis que celles apportées par la transaction xmax ne le sont pas (en d'autres termes, il est déjà clair que le tuple a été créé, mais on ne sait pas encore s'il a été supprimé).

En ce qui concerne une transaction, ses modifications sont visibles dans l'instantané, que ce soit cette transaction qui a créé l'instantané (il voit ses propres modifications non encore validées) ou la transaction a été validée avant la création de l'instantané.

Nous pouvons représenter graphiquement les transactions par segments (de l'heure de début à l'heure de validation):



Ici:

  • Les modifications de la transaction 2 seront visibles car elles ont été effectuées avant la création de l'instantané.
  • Les modifications de la transaction 1 ne seront pas visibles car elle était active au moment de la création de l'instantané.
  • Les modifications de la transaction 3 ne seront pas visibles car elles ont démarré après la création de l'instantané (qu'elle soit terminée ou non).

Malheureusement, le système ignore le temps de validation des transactions. Seule son heure de début est connue (qui est déterminée par l'ID de la transaction et marquée d'un trait pointillé dans les figures ci-dessus), mais l'événement d'achèvement n'est écrit nulle part.

Tout ce que nous pouvons faire est de connaître l'état actuel des transactions lors de la création de l'instantané. Ces informations sont disponibles dans la mémoire partagée du serveur, dans la structure ProcArray, qui contient la liste de toutes les sessions actives et de leurs transactions.

Mais nous ne pourrons pas déterminer a posteriori si une certaine transaction était active au moment de la création de l'instantané. Par conséquent, un instantané doit stocker une liste de toutes les transactions actives en cours.

De ce qui précède, il s'ensuit que dans PostgreSQL, il n'est pas possible de créer un instantané qui montre des données cohérentes à partir d'un certain temps en arrière, même si tous les tuples nécessaires sont disponibles dans les pages de table. La question se pose souvent de savoir pourquoi PostgreSQL manque de requêtes rétrospectives (ou temporelles; ou flashback, comme Oracle les appelle) - et c'est l'une des raisons.
Ce qui est amusant, c'est que cette fonctionnalité a d'abord été disponible, puis supprimée du SGBD. Vous pouvez lire à ce sujet dans l' article de Joseph M. Hellerstein .

Ainsi, l'instantané est déterminé par plusieurs paramètres:

  • Au moment où l'instantané a été créé, plus exactement, l'ID de la transaction suivante, mais non disponible dans le système ( snapshot.xmax ).
  • La liste des transactions actives (en cours) au moment de la création de l' snapshot.xip ( snapshot.xip ).

Pour plus de commodité et d'optimisation, l'ID de la première transaction active est également stocké ( snapshot.xmin ). Cette valeur a un sens important, qui sera discuté ci-dessous.

L'instantané stocke également quelques paramètres supplémentaires, qui ne sont toutefois pas importants pour nous.



Exemple


Pour comprendre comment l'instantané détermine la visibilité, reproduisons l'exemple ci-dessus avec trois transactions. Le tableau comportera trois lignes, où:

  • Le premier a été ajouté par une transaction qui a commencé avant la création de l'instantané mais s'est terminée après celle-ci.
  • Le second a été ajouté par une transaction qui a commencé et s'est terminée avant la création de l'instantané.
  • Le troisième a été ajouté après la création de l'instantané.

 => TRUNCATE TABLE accounts; 

La première transaction (pas encore terminée):

 => BEGIN; => INSERT INTO accounts VALUES (1, '1001', 'alice', 1000.00); => SELECT txid_current(); 
 => SELECT txid_current(); txid_current -------------- 3695 (1 row) 

La deuxième transaction (terminée avant la création de l'instantané):

 | => BEGIN; | => INSERT INTO accounts VALUES (2, '2001', 'bob', 100.00); | => SELECT txid_current(); 
 | txid_current | -------------- | 3696 | (1 row) 
 | => COMMIT; 

Création d'un instantané dans une transaction dans une autre session.

 || => BEGIN ISOLATION LEVEL REPEATABLE READ; || => SELECT xmin, xmax, * FROM accounts; 
 || xmin | xmax | id | number | client | amount || ------+------+----+--------+--------+-------- || 3696 | 0 | 2 | 2001 | bob | 100.00 || (1 row) 

Validation de la première transaction après la création de l'instantané:

 => COMMIT; 

Et la troisième transaction (apparue après la création de l'instantané):

 | => BEGIN; | => INSERT INTO accounts VALUES (3, '2002', 'bob', 900.00); | => SELECT txid_current(); 
 | txid_current | -------------- | 3697 | (1 row) 
 | => COMMIT; 

De toute évidence, une seule ligne est encore visible dans notre instantané:

 || => SELECT xmin, xmax, * FROM accounts; 
 || xmin | xmax | id | number | client | amount || ------+------+----+--------+--------+-------- || 3696 | 0 | 2 | 2001 | bob | 100.00 || (1 row) 

La question est de savoir comment Postgres comprend cela.

Tout est déterminé par l'instantané. Regardons ça:

 || => SELECT txid_current_snapshot(); 
 || txid_current_snapshot || ----------------------- || 3695:3697:3695 || (1 row) 

Ici snapshot.xmin , snapshot.xmax et snapshot.xip sont répertoriés, délimités par deux points ( snapshot.xip est un nombre dans ce cas, mais en général c'est une liste).

Selon les règles ci-dessus, dans l'instantané, ces modifications doivent être visibles et effectuées par des transactions avec des ID xid tels que snapshot.xmin <= xid < snapshot.xmax sauf celles qui figurent sur la liste snapshot.xip . Regardons toutes les lignes du tableau (dans le nouvel instantané):

 => SELECT xmin, xmax, * FROM accounts ORDER BY id; 
  xmin | xmax | id | number | client | amount ------+------+----+--------+--------+--------- 3695 | 0 | 1 | 1001 | alice | 1000.00 3696 | 0 | 2 | 2001 | bob | 100.00 3697 | 0 | 3 | 2002 | bob | 900.00 (3 rows) 

La première ligne n'est pas visible: elle a été créée par une transaction qui figure sur la liste des transactions actives ( xip ).
La deuxième ligne est visible: elle a été créée par une transaction qui se trouve dans la plage des instantanés.
La troisième ligne n'est pas visible: elle a été créée par une transaction hors de la plage des instantanés.

 || => COMMIT; 

Modifications propres à la transaction


La détermination de la visibilité des changements de la transaction complique quelque peu la situation. Dans ce cas, il peut être nécessaire de ne voir qu'une partie de ces modifications. Par exemple: à n'importe quel niveau d'isolement, un curseur ouvert à un certain moment ne doit pas voir les modifications effectuées plus tard.

À cette fin, un en-tête de tuple a un champ spécial (représenté dans les pseudo-colonnes cmin et cmax ), qui indique le numéro de commande à l'intérieur de la transaction. cmin est le nombre pour l'insertion et cmax - pour la suppression, mais pour économiser de l'espace dans l'en-tête du tuple, il s'agit en fait d'un champ plutôt que de deux champs différents. Il est supposé qu'une transaction insère et supprime rarement la même ligne.

Mais si cela se produit, un identifiant de commande combo spécial ( combocid ) est inséré dans le même champ et le processus d'arrière-plan se souvient des cmin et cmax pour ce combocid . Mais c'est totalement exotique.

Voici un exemple simple. Commençons une transaction et ajoutons une ligne au tableau:

 => BEGIN; => SELECT txid_current(); 
  txid_current -------------- 3698 (1 row) 
 INSERT INTO accounts(id, number, client, amount) VALUES (4, 3001, 'charlie', 100.00); 

Sortons le contenu de la table, ainsi que le champ cmin (mais uniquement pour les lignes ajoutées par la transaction - pour d'autres, cela n'a aucun sens):

 => SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts; 
  xmin | cmin | id | number | client | amount ------+------+----+--------+---------+--------- 3695 | | 1 | 1001 | alice | 1000.00 3696 | | 2 | 2001 | bob | 100.00 3697 | | 3 | 2002 | bob | 900.00 3698 | 0 | 4 | 3001 | charlie | 100.00 (4 rows) 

Maintenant, nous ouvrons un curseur pour une requête qui renvoie le nombre de lignes dans la table.

 => DECLARE c CURSOR FOR SELECT count(*) FROM accounts; 

Et après cela, nous ajoutons une autre ligne:

 => INSERT INTO accounts(id, number, client, amount) VALUES (5, 3002, 'charlie', 200.00); 

La requête renvoie 4 - la ligne ajoutée après l'ouverture du curseur n'entre pas dans l'instantané des données:

 => FETCH c; 
  count ------- 4 (1 row) 

Pourquoi? Parce que l'instantané ne prend en compte que les tuples avec cmin < 1 .

 => SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts; 
  xmin | cmin | id | number | client | amount ------+------+----+--------+---------+--------- 3695 | | 1 | 1001 | alice | 1000.00 3696 | | 2 | 2001 | bob | 100.00 3697 | | 3 | 2002 | bob | 900.00 3698 | 0 | 4 | 3001 | charlie | 100.00 3698 | 1 | 5 | 3002 | charlie | 200.00 (5 rows) 
 => ROLLBACK; 

Horizon de l'événement


L'ID de la première transaction active ( snapshot.xmin ) a un sens important: il détermine «l'horizon des événements» de la transaction. Autrement dit, au-delà de son horizon, la transaction ne voit toujours que des versions de ligne à jour.

En réalité, une version de ligne obsolète (morte) ne doit être visible que lorsque la version à jour a été créée par une transaction non encore terminée et n'est donc pas encore visible. Mais toutes les transactions "au-delà de l'horizon" sont définitivement réalisées.



Vous pouvez voir l'horizon de transaction dans le catalogue système:

 => BEGIN; => SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid(); 
  backend_xmin -------------- 3699 (1 row) 

Nous pouvons également définir l'horizon au niveau de la base de données. Pour ce faire, nous devons prendre tous les instantanés actifs et trouver le plus ancien xmin parmi eux. Et il définira l'horizon, au-delà duquel les tuples morts dans la base de données ne seront jamais visibles pour aucune transaction. De tels tuples peuvent être aspirés - et c'est exactement pourquoi le concept d'horizon est si important d'un point de vue pratique.

Si une certaine transaction contient un instantané pendant une longue période, elle tiendra également l'horizon de la base de données. De plus, la simple existence d'une transaction inachevée tiendra l'horizon même si la transaction elle-même ne contient pas l'instantané.

Et cela signifie que les tuples morts dans la base de données ne peuvent pas être aspirés. De plus, il est possible qu'une transaction "longue durée" ne se croise pas du tout avec des données avec d'autres transactions, mais cela n'a pas vraiment d'importance car tous partagent un horizon de base de données.

Si nous faisons maintenant un segment représenter des instantanés (de snapshot.xmin à snapshot.xmax ) plutôt que des transactions, nous pouvons visualiser la situation comme suit:



Dans cette figure, l'instantané le plus bas se rapporte à une transaction non terminée et dans les autres instantanés, snapshot.xmin ne peut pas être supérieur à l'ID de transaction.

Dans notre exemple, la transaction a été lancée avec le niveau d'isolement Read Committed. Même s'il n'a aucun instantané de données actif, il continue de tenir l'horizon:

 | => BEGIN; | => UPDATE accounts SET amount = amount + 1.00; | => COMMIT; 
 => SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid(); 
  backend_xmin -------------- 3699 (1 row) 

Et ce n'est qu'après la finalisation de la transaction que l'horizon avance, ce qui permet d'aspirer les tuples morts:

 => COMMIT; => SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid(); 
  backend_xmin -------------- 3700 (1 row) 

Dans le cas où la situation décrite cause vraiment des problèmes et qu'il n'y a aucun moyen de la contourner au niveau de l'application, deux paramètres sont disponibles à partir de la version 9.6:

  • old_snapshot_threshold détermine la durée de vie maximale de l'instantané. Une fois ce temps écoulé, le serveur pourra aspirer les tuples morts, et si une transaction "longue durée" en a encore besoin, il obtiendra une erreur "instantané trop ancien".
  • idle_in_transaction_session_timeout détermine la durée de vie maximale d'une transaction inactive. Une fois ce délai écoulé, la transaction s'interrompt.

Exportation d'instantanés


Parfois, des situations surviennent où plusieurs transactions simultanées doivent être garanties pour voir les mêmes données. Un exemple est un utilitaire pg_dump , qui peut fonctionner en mode parallèle: tous les processus de travail doivent voir la base de données dans le même état pour que la copie de sauvegarde soit cohérente.

Bien sûr, nous ne pouvons pas nous fier à la conviction que les transactions verront les mêmes données simplement parce qu'elles ont été démarrées "simultanément". À cette fin, l'exportation et l'importation d'un instantané sont disponibles.

La fonction pg_export_snapshot renvoie l'ID d'instantané, qui peut être transmis à une autre transaction (à l'aide d'outils en dehors du SGBD).

 => BEGIN ISOLATION LEVEL REPEATABLE READ; => SELECT count(*) FROM accounts; -- any query 
  count ------- 3 (1 row) 
 => SELECT pg_export_snapshot(); 
  pg_export_snapshot --------------------- 00000004-00000E7B-1 (1 row) 

L'autre transaction peut importer l'instantané à l'aide de la commande SET TRANSACTION SNAPSHOT avant d'effectuer sa première requête. Le niveau d'isolement Lecture répétable ou Sérialisable doit également être spécifié avant car au niveau Lecture validée, les instructions utiliseront leurs propres instantanés.

 | => DELETE FROM accounts; | => BEGIN ISOLATION LEVEL REPEATABLE READ; | => SET TRANSACTION SNAPSHOT '00000004-00000E7B-1'; 

La deuxième transaction fonctionnera désormais avec l'instantané de la première et, par conséquent, affichera trois lignes (plutôt que zéro):

 | => SELECT count(*) FROM accounts; 
 | count | ------- | 3 | (1 row) 

La durée de vie d'un instantané exporté est identique à la durée de vie de la transaction d'exportation.

 | => COMMIT; => COMMIT; 

Continuez à lire .

Source: https://habr.com/ru/post/fr479512/


All Articles