Quelqu'un pourrait-il expliquer un comportement bizarre lors de l'exécution de millions de MISES À JOUR?

8

Quelqu'un pourrait-il m'expliquer ce comportement? J'ai exécuté la requête suivante sur Postgres 9.3 fonctionnant en mode natif sur OS X. J'essayais de simuler un comportement où la taille de l'index pouvait augmenter beaucoup plus que la taille de la table, et j'ai trouvé quelque chose de plus bizarre.

CREATE TABLE test(id int);
CREATE INDEX test_idx ON test(id);

CREATE FUNCTION test_index(batch_size integer, total_batches integer) RETURNS void AS $$
DECLARE
  current_id integer := 1;
BEGIN
FOR i IN 1..total_batches LOOP
  INSERT INTO test VALUES (current_id);
  FOR j IN 1..batch_size LOOP
    UPDATE test SET id = current_id + 1 WHERE id = current_id;
    current_id := current_id + 1;
  END LOOP;
END LOOP;
END;
$$ LANGUAGE plpgsql;

SELECT test_index(500, 10000);

J'ai laissé cela s'exécuter pendant environ une heure sur ma machine locale, avant de commencer à recevoir des avertissements de problème de disque d'OS X. J'ai remarqué que Postgres aspirait environ 10 Mo / s de mon disque local et que la base de données Postgres consommait un grand total de 30 Go de ma machine. J'ai fini par annuler la requête. Quoi qu'il en soit, Postgres ne m'a pas renvoyé l'espace disque et j'ai interrogé la base de données pour obtenir des statistiques d'utilisation avec le résultat suivant:

test=# SELECT nspname || '.' || relname AS "relation",
    pg_size_pretty(pg_relation_size(C.oid)) AS "size"
  FROM pg_class C
  LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
  WHERE nspname NOT IN ('pg_catalog', 'information_schema')
  ORDER BY pg_relation_size(C.oid) DESC
  LIMIT 20;

           relation            |    size
-------------------------------+------------
 public.test                   | 17 GB
 public.test_idx               | 14 GB

Cependant, la sélection dans le tableau n'a donné aucun résultat.

test=# select * from test limit 1;
 id
----
(0 rows)

L'exécution de 10 000 lots de 500 correspond à 5 000 000 de lignes, ce qui devrait donner une taille de table / d'index assez petite (sur l'échelle de Mo). Je soupçonne que Postgres crée une nouvelle version de la table / index pour chaque INSERT / UPDATE qui se passe avec la fonction, mais cela semble étrange. La fonction entière est exécutée de manière transactionnelle et la table était vide pour démarrer.

Avez-vous des raisons de penser à ce comportement?

Plus précisément, les deux questions que je me pose sont les suivantes: pourquoi cet espace n'a-t-il pas encore été récupéré par la base de données et la seconde est pourquoi la base de données a-t-elle exigé autant d'espace en premier lieu? 30 Go semble beaucoup même en tenant compte de MVCC

Nikhil N
la source

Réponses:

7

Version courte

Votre algorithme ressemble à O (n * m) à première vue, mais augmente effectivement O (n * m ^ 2), car toutes les lignes ont le même ID. Au lieu de 5 millions de lignes, vous obtenez> 1,25 G de lignes

Version longue

Votre fonction se trouve dans une transaction implicite. C'est pourquoi vous ne voyez aucune donnée après l'annulation de votre requête, et aussi pourquoi elle doit conserver des versions distinctes des tuples mis à jour / insérés pour les deux boucles.

De plus, je soupçonne que vous avez un bug dans votre logique ou que vous sous-estimez le nombre de mises à jour effectuées.

Première itération de la boucle externe - current_id commence à 1, insère 1 ligne, puis la boucle interne effectue une mise à jour 10000 fois pour la même ligne, finalisant avec la seule ligne affichant un ID de 10001 et current_id avec une valeur de 10001. 10001 les versions de la ligne sont toujours conservées, car la transaction n'est pas terminée.

Deuxième itération de la boucle externe - comme current_id est 10001, une nouvelle ligne est insérée avec l'ID 10001. Vous avez maintenant 2 lignes avec le même "ID" et 10003 versions au total des deux lignes (10002 de la première, 1 de le deuxième). Ensuite, la boucle interne est mise à jour 10000 fois sur les deux lignes, créant 20000 nouvelles versions, atteignant jusqu'à 30003 tuples jusqu'à présent ...

Troisième itération de la boucle externe: l'ID actuel est 20001, une nouvelle ligne est insérée avec l'ID 20001. Vous avez 3 lignes, toutes avec les mêmes versions "ID" 20001, 30006 ligne / tuples jusqu'à présent. Ensuite, vous effectuez 10000 mises à jour de 3 lignes, créant 30000 nouvelles versions, maintenant 60006 ...

...

(Si votre espace le permettait) - 500e itération de la boucle externe, crée 5M mises à jour de 500 lignes, juste dans cette itération

Comme vous le voyez, au lieu de vos mises à jour 5M attendues, vous avez obtenu 1000 + 2000 + 3000 + ... + 4990000 + 5000000 mises à jour (plus le changement), ce qui serait 10000 * (1 + 2 + 3 + ... + 499+ 500), plus de mises à jour 1.25G. Et bien sûr, une ligne n'est pas seulement la taille de votre int, elle a besoin d'une structure supplémentaire, donc votre table et votre index dépassent la taille de dix gigaoctets.

Questions et réponses connexes:

Bruno Guardia
la source
5

PostgreSQL ne renvoie de l'espace disque qu'après VACUUM FULL, pas après un DELETEou ROLLBACK(à la suite d'une annulation)

La forme standard de VACUUM supprime les versions de lignes mortes dans les tables et les index et marque l'espace disponible pour une réutilisation future. Cependant, il ne restituera pas l'espace au système d'exploitation, sauf dans le cas spécial où une ou plusieurs pages à la fin d'une table deviennent entièrement libres et un verrou de table exclusif peut être facilement obtenu. En revanche, VACUUM FULL compacte activement les tables en écrivant une nouvelle version complète du fichier de table sans espace mort. Cela minimise la taille de la table, mais peut prendre beaucoup de temps. Il nécessite également de l'espace disque supplémentaire pour la nouvelle copie de la table, jusqu'à la fin de l'opération.

En remarque, toute votre fonction semble discutable. Je ne sais pas ce que vous essayez de tester, mais si vous voulez créer des données, vous pouvez utilisergenerate_series

INSERT INTO test
SELECT x FROM generate_series(1, batch_size*total_batches) AS t(x);
Evan Carroll
la source
Cool, cela explique pourquoi la table était toujours marquée comme consommant autant de données, mais pourquoi avait-elle besoin de tout cet espace en premier lieu? D'après ma compréhension de MVCC, il doit maintenir des versions distinctes des tuples mis à jour / insérés pour la transaction, mais il ne devrait pas avoir besoin de maintenir des versions distinctes pour chaque itération de la boucle.
Nikhil N
1
Chaque itération de la boucle génère de nouveaux tuples.
Evan Carroll
2
D'accord, mais j'ai l'impression que le MVCC ne devrait pas créer de tuples pour tous les tuples qu'il a modifiés au cours de la transaction. C'est-à-dire que lorsque le premier INSERT s'exécute, Postgres crée un seul tuple et ajoute un seul nouveau tuple pour chaque UPDATE. Étant donné que les MISES À JOUR sont exécutées 500 fois pour chaque ligne et qu'il y a 10000 INSERT, cela équivaut à 500 * 10000 lignes = 5M de tuples au moment où la transaction est validée. Maintenant, ce n'est qu'une estimation, mais peu importe 5 Mo *, disons 50 octets pour suivre chaque tuple ~ = 250 Mo, ce qui est BEAUCOUP moins de 30 Go. D'où tout cela vient-il?
Nikhil N
Aussi concernant la fonction douteuse, j'essaie de tester le comportement d'un index lorsque les champs indexés sont mis à jour plusieurs fois mais de manière monotiquement croissante, ce qui donne un index très clairsemé, mais qui est toujours ajouté sur le disque.
Nikhil N
Je suis confus quant à ce que vous pensez. Pensez-vous qu'une ligne mise à jour 18e fois dans une boucle est un tuple ou 1e8 tuples?
Evan Carroll
3

Les nombres réels après analyse de la fonction sont beaucoup plus importants car toutes les lignes du tableau ont la même valeur qui est mise à jour plusieurs fois à chaque itération.

Lorsque nous l'exécutons avec des paramètres net m:

SELECT test_index(n, m);

il y a des minsertions de lignes et des n * (m^2 + m) / 2mises à jour. Ainsi, pour n = 500et m = 10000, Postgres devra insérer seulement 10K lignes mais effectuer des mises à jour de tuple ~ 25G (25 milliards).

Étant donné qu'une ligne dans Postgres a une surcharge d'environ 24 octets, une table avec une seule intcolonne aura besoin de 28 octets par ligne plus la surcharge de la page. Donc, pour que l'opération se termine, nous aurions besoin d'environ 700 Go plus l'espace pour l'index (qui serait également de quelques centaines de Go).


Essai

Pour tester la théorie, nous avons créé une autre table test_testavec une seule ligne.

CREATE TABLE test_test (i int not null) ;
INSERT INTO test_test (i) VALUES (0);

Ensuite, nous ajoutons un déclencheur pour testque chaque mise à jour augmente le compteur de 1. (Code omis). Ensuite, nous nous exécutons la fonction, avec des valeurs plus petites, n = 50et m = 100.

Notre théorie prédit :

  • 100 inserts de rangée,
  • Mises à jour de tuple 250K (252500 = 50 * 100 * 101/2)
  • au moins 7 Mo pour la table sur disque
  • (+ espace pour l'index)

Test 1 ( testtableau d' origine , avec index)

    SELECT test_index(50, 100) ;

Après l'achèvement, nous vérifions le contenu du tableau:

x=# SELECT COUNT(*) FROM test ;
 count 
-------
   100
(1 row)

x=# SELECT i FROM test_test ;
   i    
--------
 252500
(1 row)

Et l'utilisation du disque (requête sous Index size / usage statistics in Index Maintenance ):

tablename | indexname | num_rows | table_size | index_size | unique | number_of_scans | tuples_read 
----------+-----------+----------+------------+------------+--------+-----------------+-------------
test      | test_idx  |      100 | 8944 kB    | 5440 kB    | N      |           10001 |      505003
test_test |           |        1 | 8944 kB    |            | N      |                 |            

La testtable a utilisé près de 9 Mo pour la table et 5 Mo pour l'index. Notez que la test_testtable a utilisé un autre 9 Mo! Cela est attendu car il a également subi 250 000 mises à jour (notre deuxième déclencheur a mis à jour la ligne unique de test_testchaque mise à jour d'une ligne test).

Notez également le nombre de numérisations sur table test(10K) et les lectures de tuples (500K).

Test 2 ( testtableau sans index)

Exactement comme ci-dessus, sauf que la table n'a pas d'index.

tablename | indexname | num_rows | table_size | index_size | unique | number_of_scans | tuples_read 
----------+-----------+----------+------------+------------+--------+-----------------+-------------
 test        |        |      100 | 8944 kB    |            | N      |                 |            
 test_test   |        |        1 | 8944 kB    |            | N      |                 |            

Nous obtenons la même taille pour l'utilisation du disque de la table et bien sûr aucune utilisation du disque pour les index. Le nombre d'analyses sur la table testest cependant nul et les tuples se lisent également.

Test 3 (avec facteur de remplissage inférieur)

Essayé avec fillfactor 50 et le plus bas possible, 10. Aucune amélioration du tout. L'utilisation du disque était presque identique aux tests précédents (qui utilisaient le facteur de remplissage par défaut, 100%)

ypercubeᵀᴹ
la source