To sidebar

mardi 10 juillet 2018

Combien de partitions dans PostgreSQL 10 ?

Certains types de requêtes sont condamnées au seq scan (parcours de table complet), par exemple des requêtes décisionnelles portant sur un historique assez long. Mais on n’a pas forcément envie de parcourir tout l’historique non plus, et les index ne peuvent pas tout. Les tables énormes sont également peu maniables (VACUUM FULL impossible, difficulté à les répartir sur des disques différents…). Pour accélérer malgré tout ces requêtes, tout en se simplifiant l’administration, il est plus confortable de tronçonner la table en partitions, stockées parfois dans différents tablespaces, par exemple en données mensuelles que l’on pourra au besoin parcourir intégralement.

C’est le but du tout nouveau partitionnement déclaratif de PostgreSQL 10. Il y avait déjà un système de partitionnement par héritage, mais peu pratique et peu utilisé.

Mais jusqu’où peut-on monter dans le nombre de partitions ? (Spoiler : pas trop haut).

Petit test :

Config

Mon portable raisonnablement récent a 2 cœurs 4 threads et SSD fait tourner un PostgreSQL 10.3 avec shared_buffers = 512 Mo ; et pour figer les choses :

 SET maxparallelworkers to 4 ;
 SET maxparallelworkerspergather to 4 ;
 SET work_mem to '128MB' ;
 SET effectiveioconcurrency TO 100;
 SET randompagecost TO 0.1 ;  
 SET seqpagecost TO 0.1 ;

Enfin, ce paramètre ne vise qu’à accélérer les tests ; ne faites pas en prod sans savoir ce que cela implique :

  SET synchronous_commit TO off;

Sans partition

Premier exemple avec 500 millions de petites lignes dans une table monolithique de 2 212 390 blocs et 17 Go, sans index :

 CREATE TABLE bigtable (id int, x int);
 INSERT INTO bigtable SELECT i, mod (i, 777777)
 FROM generate_series (1, 500*1000*1000) i;
 \d+
 Liste des relations
 Schéma |      Nom       | Type  | Propriétaire | Taille  | Description
 public | bigtable       | table | postgres     | 17 GB   |

Ne pas oublier les statistiques, par principe après chaque insertion massive (ça ne prend qu’un quart d’heure ici, après tout) :

 VACUUM VERBOSE ANALYZE bigtable ;

Dans ce qui suit, le manque d’index est délibéré, je ne recherche que des parcours de table complets.

On commence petit, en sommant juste 100 lignes en plein milieu de la table. Je ne m’intéresse pas au contenu mais à la manière dont PostgreSQL va se débrouiller, donc je demande le plan avec tous les détails :

 EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
 SELECT sum(x)FROM bigtable
 WHERE id BETWEEN 199000001 AND 199000100 ;

Et on n’est pas déçu : 33 secondes passées à scanner intégralement la table, 4 workers partageant avec le processus maître la lecture et le filtrage des lignes, puis la somme.

 Finalize Aggregate  (cost=2097239.49..2097239.50 rows=1 width=8) (actual time=33015.147..33015.148 rows=1 loops=1)
   Output: sum(x)
   Buffers: shared hit=14373 read=430763 dirtied=936 written=11
   I/O Timings: read=21404.854 write=0.075
   ->  Gather  (cost=2097239.07..2097239.48 rows=4 width=8) (actual time=33015.001..33015.142 rows=5 loops=1)
         Output: (PARTIAL sum(x))
         Workers Planned: 4
         Workers Launched: 4
         Buffers: shared hit=14373 read=430763 dirtied=936 written=11
         I/O Timings: read=21404.854 write=0.075
         ->  Partial Aggregate  (cost=2096239.07..2096239.08 rows=1 width=8) (actual time=32990.839..32990.839 rows=1 loops=5)
               Output: PARTIAL sum(x)
               Buffers: shared hit=55304 read=2157086 dirtied=5855 written=30
               I/O Timings: read=107067.211 write=0.200
               Worker 0: actual time=32989.611..32989.612 rows=1 loops=1
                 Buffers: shared hit=12089 read=426532 dirtied=1361 written=19
                 I/O Timings: read=21461.396 write=0.125
               Worker 1: actual time=32986.118..32986.118 rows=1 loops=1
                 Buffers: shared hit=6723 read=416321 dirtied=1310
                 I/O Timings: read=21437.665
               Worker 2: actual time=32974.608..32974.608 rows=1 loops=1
                 Buffers: shared hit=8438 read=441747 dirtied=1278
                 I/O Timings: read=21433.336
               Worker 3: actual time=32989.476..32989.476 rows=1 loops=1
                 Buffers: shared hit=13681 read=441723 dirtied=970
                 I/O Timings: read=21329.960
               ->  Parallel Seq Scan on public.bigtable  (cost=0.00..2096239.00 rows=29 width=4) (actual time=26784.787..32990.833 rows=20 loops=5)
                     Output: x
                     Filter: 
                     Rows Removed by Filter: 99999980
                     Buffers: shared hit=55304 read=2157086 dirtied=5855 written=30
                     I/O Timings: read=107067.211 write=0.200
                     Worker 0: actual time=17474.477..32989.605 rows=6 loops=1
                       Buffers: shared hit=12089 read=426532 dirtied=1361 written=19
                       I/O Timings: read=21461.396 write=0.125
                     Worker 1: actual time=17471.005..32986.104 rows=94 loops=1
                       Buffers: shared hit=6723 read=416321 dirtied=1310
                       I/O Timings: read=21437.665
                     Worker 2: actual time=32974.603..32974.603 rows=0 loops=1
                       Buffers: shared hit=8438 read=441747 dirtied=1278
                       I/O Timings: read=21433.336
                     Worker 3: actual time=32989.472..32989.472 rows=0 loops=1
                       Buffers: shared hit=13681 read=441723 dirtied=970
                       I/O Timings: read=21329.960
 Planning time: 0.584 ms
 Execution time: 33034.340 ms

Et encore, ce pourrait être pire sans le parallélisme (on passerait à 45 secondes). Dans l’autre sens, PostgreSQL utiliserait 8 workers si maxworkersper_gather le permettait (mais je n’ai que 4 threads sur cette machine, il y en a même un de trop ici).

Notez le temps de planification ridicule d’une demi-milliseconde.

3 partitions

Avant d’en créer plus, j’ai voulu tester avec juste 3 partitions, dont une ne représentant qu’un centième de la volumétrie, soit 5 millions de lignes. On recrée la table :

 DROP TABLE bigtable ;
 CREATE TABLE bigtable (id int, x int)
 PARTITION BY RANGE (id);

Le partitionnement nouveau style (par range ici) est tout bête avec juste 3 partitions  ; il faut savoir que les bornes supérieures sont exclues :

 CREATE TABLE bigtable_grande1
 PARTITION OF bigtable
 FOR VALUES FROM (1) TO (199000000) ;
 CREATE TABLE bigtable_petite
 PARTITION OF bigtable
 FOR VALUES FROM (199000000) TO (200000001) ;
 CREATE TABLE bigtable_grande2
 PARTITION OF bigtable
 FOR VALUES FROM (200000001) TO (500000001) ;

L’insertion de données se fait de la même manière. Qu’il suffise de dire que le temps d’insertion est à peine plus élevé (autour de 10 minutes contre 9 et demi), ce qui est un des plus gros progrès par rapport à l’ancienne méthode de partionnement.

 INSERT INTO bigtable SELECT i, mod (i, 777777) FROM generate_series (1, 500*1000*1000) i;
 VACUUM VERBOSE ANALYZE bigtable ;

Chaque table (la partitionnée et ses filles) apparaissent comme des tables indépendantes :

  Schéma |       Nom        | Type  | Propriétaire | Taille   | Description
  public | bigtable         | table | postgres     | 0 bytes  |
  public | bigtable_grande1 | table | postgres     | 6915 MB  |
  public | bigtable_grande2 | table | postgres     | 10200 MB |
  public | bigtable_petite  | table | postgres     | 173 MB   |

Si je relance un SELECT sur 100 lignes de la petite partition centrale, PostgreSQL ignore totalement les deux grosses partitions, et ne lance que 3 workers supplémentaires pour faire le filtrage et la somme (la partition n’est pas assez grande pour en justifier un quatrième). On descend à 529 ms (62 fois mieux qu’auparavant). Notez le temps de planification toujours bien inférieur à la milliseconde.

 EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
 SELECT sum(x)FROM bigtable
 WHERE id BETWEEN 200000000 AND 200000000+100 ;
 Aggregate  (cost=27406.06..27406.07 rows=1 width=8) (actual time=527.984..527.984 rows=1 loops=1)
   Output: sum(bigtable_petite.x)
   Buffers: shared read=5881
   I/O Timings: read=344.078
   ->  Gather  (cost=1000.00..27406.05 rows=1 width=4) (actual time=3.473..527.966 rows=101 loops=1)
         Output: bigtable_petite.x
         Workers Planned: 3
         Workers Launched: 3
         Buffers: shared read=5881
         I/O Timings: read=344.078
         ->  Append  (cost=0.00..26405.95 rows=1 width=4) (actual time=383.565..514.677 rows=25 loops=4)
               Buffers: shared hit=32 read=22092
               I/O Timings: read=1332.761
               Worker 0: actual time=510.606..510.606 rows=0 loops=1
                 Buffers: shared hit=10 read=5752
                 I/O Timings: read=328.896
               Worker 1: actual time=511.069..511.069 rows=0 loops=1
                 Buffers: shared hit=10 read=4978
                 I/O Timings: read=325.920
               Worker 2: actual time=510.807..510.807 rows=0 loops=1
                 Buffers: shared hit=12 read=5481
                 I/O Timings: read=333.869
               ->  Parallel Seq Scan on public.bigtable_petite  (cost=0.00..26405.95 rows=1 width=4) (actual time=383.564..514.674 rows=25 loops=4)
                     Output: bigtable_petite.x
                     Filter: 
                     Rows Removed by Filter: 1249975
                     Buffers: shared hit=32 read=22092
                     I/O Timings: read=1332.761
                     Worker 0: actual time=510.606..510.606 rows=0 loops=1
                       Buffers: shared hit=10 read=5752
                       I/O Timings: read=328.896
                     Worker 1: actual time=511.069..511.069 rows=0 loops=1
                       Buffers: shared hit=10 read=4978
                       I/O Timings: read=325.920
                     Worker 2: actual time=510.806..510.806 rows=0 loops=1
                       Buffers: shared hit=12 read=5481
                       I/O Timings: read=333.869
 Planning time: 0.728 ms
 Execution time: 529.829 ms

Une petite partie des blocs était dans les shared buffers (j’avais pourtant pris des données « au milieu » pour éviter cet effet). Répétée souvent, la requête finit par n’utiliser que des blocs en cache et peut descendre en-dessous de 200 ms.

Mais on ne fait pas des partitions pour des requêtes de 100 lignes, il y a les index pour ça. Si nous voulons l’intégralité de la partition centrale :

 EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
 SELECT sum(x)FROM bigtable
 WHERE id BETWEEN 200000000 AND 205000000 ;

Le résultat est à peine plus lent (607 ms), même sans lire uniquement dans le cache de PostgreSQL (mais le cache de l’OS a pu jouer). Remarquez que le plan est subtilement différent et rajoute un nœud Partial Aggregate entre le Append et le Gather car le Append ramène plus de ligne que ci-dessus ; et on a un nœud Finalize Aggregate tout en haut (détails : https://docs.postgresql.fr/10/paral…) :

 Finalize Aggregate  (cost=31437.72..31437.73 rows=1 width=8) (actual time=605.167..605.167 rows=1 loops=1)
   Output: sum(bigtable_petite.x)
   Buffers: shared hit=66 read=5522
   I/O Timings: read=13.272
   ->  Gather  (cost=31437.41..31437.72 rows=3 width=8) (actual time=605.158..605.164 rows=4 loops=1)
       Output: (PARTIAL sum(bigtable_petite.x))
       Workers Planned: 3
       Workers Launched: 3
       Buffers: shared hit=66 read=5522
       I/O Timings: read=13.272
       ->  Partial Aggregate  (cost=30437.41..30437.42 rows=1 width=8) (actual time=600.835..600.835 rows=1 loops=4)
             Output: PARTIAL sum(bigtable_petite.x)
             Buffers: shared hit=288 read=21836
             I/O Timings: read=53.247
             Worker 0: actual time=599.521..599.521 rows=1 loops=1
               Buffers: shared hit=77 read=5420
               I/O Timings: read=12.596
             Worker 1: actual time=598.198..598.198 rows=1 loops=1
               Buffers: shared hit=72 read=5533
               I/O Timings: read=14.573
             Worker 2: actual time=600.653..600.653 rows=1 loops=1
               Buffers: shared hit=73 read=5361
               I/O Timings: read=12.805
             ->  Append  (cost=0.00..26405.95 rows=1612581 width=4) (actual time=0.021..434.163 rows=1250000 loops=4)
                   Buffers: shared hit=288 read=21836
                   I/O Timings: read=53.247
                   Worker 0: actual time=0.021..432.354 rows=1242322 loops=1
                     Buffers: shared hit=77 read=5420
                     I/O Timings: read=12.596
                   Worker 1: actual time=0.026..429.925 rows=1266730 loops=1
                     Buffers: shared hit=72 read=5533
                     I/O Timings: read=14.573
                   Worker 2: actual time=0.019..437.050 rows=1228061 loops=1
                     Buffers: shared hit=73 read=5361
                     I/O Timings: read=12.805
                   ->  Parallel Seq Scan on public.bigtable_petite  (cost=0.00..26405.95 rows=1612581 width=4) (actual time=0.020..300.729 rows=1250000 loops=4)
                         Output: bigtable_petite.x
                         Filter: 
                         Buffers: shared hit=288 read=21836
                         I/O Timings: read=53.247
                         Worker 0: actual time=0.021..298.749 rows=1242322 loops=1
                           Buffers: shared hit=77 read=5420
                           I/O Timings: read=12.596
                         Worker 1: actual time=0.025..298.769 rows=1266730 loops=1
                           Buffers: shared hit=72 read=5533
                           I/O Timings: read=14.573
                         Worker 2: actual time=0.019..302.519 rows=1228061 loops=1
                           Buffers: shared hit=73 read=5361
                           I/O Timings: read=12.805
 Planning time: 0.116 ms
 Execution time: 607.095 ms

Noter que le temps de planification reste en-dessous de la milliseconde dans toutes ces requêtes.

100 partitions

C’est plus réaliste, mais cette fois il va falloir un peu scripter pour créer les partitions (non, la création automatique de partitions n’est pas (encore ?) prévue) ; et merci au passage à Corey Huinker pour \gexec :

 SELECT 'CREATE TABLE bigtable_'||p||'
 PARTITION OF bigtable
 FOR VALUES FROM ('||p*5000000+1||') TO ('||(p+1)*5000000+1||') ;'
 FROM generate_series (0,99) p
 \gexec

Le reste de la création est identique, y compris le temps d’insertion similaire. Au final on obtient 100 partitions d’1 million de lignes et 173 Mo :

  Schéma |      Nom       | Type  | Propriétaire | Taille  | Description
  public | bigtable       | table | postgres     | 0 bytes |
  public | bigtable_0     | table | postgres     | 173 MB  |
  public | bigtable_1     | table | postgres     | 173 MB  |
  public | bigtable_10    | table | postgres     | 173 MB  |
  ...
  public | bigtable_98    | table | postgres     | 173 MB  |
  public | bigtable_99    | table | postgres     | 173 MB  |

On pourrait se dire que le SELECT de 100 lignes va ramener la même chose que ci-dessus puisqu’il porte sur une partition de même taille :

 EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
 SELECT sum(x)FROM bigtable
 WHERE id BETWEEN 199000001 AND 199000100 ;

C’est effectivement à peu près le cas, aux erreurs de mesure près :

 Finalize Aggregate  (cost=27406.34..27406.35 rows=1 width=8) (actual time=379.974..379.974 rows=1 loops=1)
   Output: sum(bigtable_39.x)
   Buffers: shared read=6097
   I/O Timings: read=240.967
   ->  Gather  (cost=27406.02..27406.33 rows=3 width=8) (actual time=379.890..379.968 rows=4 loops=1)
         Output: (PARTIAL sum(bigtable_39.x))
         Workers Planned: 3
         Workers Launched: 3
         Buffers: shared read=6097
         I/O Timings: read=240.967
         ->  Partial Aggregate  (cost=26406.02..26406.03 rows=1 width=8) (actual time=353.810..353.810 rows=1 loops=4)
               Output: PARTIAL sum(bigtable_39.x)
               Buffers: shared read=22124
               I/O Timings: read=998.870
               Worker 0: actual time=345.610..345.610 rows=1 loops=1
                 Buffers: shared read=4596
                 I/O Timings: read=252.704
               Worker 1: actual time=345.636..345.636 rows=1 loops=1
                 Buffers: shared read=7394
                 I/O Timings: read=238.834
               Worker 2: actual time=345.647..345.647 rows=1 loops=1
                 Buffers: shared read=4037
                 I/O Timings: read=266.366
               ->  Append  (cost=0.00..26405.95 rows=28 width=4) (actual time=336.382..353.804 rows=25 loops=4)
                     Buffers: shared read=22124
                     I/O Timings: read=998.870
                     Worker 0: actual time=345.608..345.608 rows=0 loops=1
                       Buffers: shared read=4596
                       I/O Timings: read=252.704
                     Worker 1: actual time=345.634..345.634 rows=0 loops=1
                       Buffers: shared read=7394
                       I/O Timings: read=238.834
                     Worker 2: actual time=345.646..345.646 rows=0 loops=1
                       Buffers: shared read=4037
                       I/O Timings: read=266.366
                     ->  Parallel Seq Scan on public.bigtable_39  (cost=0.00..26405.95 rows=28 width=4) (actual time=336.381..353.801 rows=25 loops=4)
                           Output: bigtable_39.x
                           Filter: 
                           Rows Removed by Filter: 1249975
                           Buffers: shared read=22124
                           I/O Timings: read=998.870
                           Worker 0: actual time=345.607..345.607 rows=0 loops=1
                             Buffers: shared read=4596
                             I/O Timings: read=252.704
                           Worker 1: actual time=345.634..345.634 rows=0 loops=1
                             Buffers: shared read=7394
                             I/O Timings: read=238.834
                           Worker 2: actual time=345.646..345.646 rows=0 loops=1
                             Buffers: shared read=4037
                             I/O Timings: read=266.366
 Planning time: 10.324 ms
 Execution time: 382.220 ms

Notez que le temps de planification a bondi d’un facteur 10. C’est reproductible. En fait, ça l’est même si les partitions sont vides :

 Aggregate  (cost=34.93..34.94 rows=1 width=8) (actual time=0.011..0.012 rows=1 loops=1)
   Output: sum(bigtable_39.x)
   ->  Append  (cost=0.00..34.90 rows=11 width=4) (actual time=0.008..0.008 rows=0 loops=1)
         ->  Seq Scan on public.bigtable_39  (cost=0.00..34.90 rows=11 width=4) (actual time=0.007..0.007 rows=0 loops=1)
               Output: bigtable_39.x
               Filter: 
 Planning time: 12.979 ms
 Execution time: 0.095 ms

1000 partitions

On tente avec 1000 partitions de 17 Mo. Après tout, je peux avoir besoin de beaucoup moins de lignes que les 5 millions de l’exemple précédent.

 SELECT 'CREATE TABLE bigtable_'||p||'
 PARTITION OF bigtable
 FOR VALUES FROM ('||p*500000+1||') TO ('||(p+1)*500000+1||') ;'
 FROM generate_series (0,999) p
 \gexec
  Schéma |      Nom       | Type  | Propriétaire | Taille  | Description
  public | bigtable       | table | postgres     | 0 bytes |
  public | bigtable_0     | table | postgres     | 17 MB   |
  public | bigtable_1     | table | postgres     | 17 MB   |
  ...
  public | bigtable_998   | table | postgres     | 17 MB   |
  public | bigtable_999   | table | postgres     | 17 MB   |

Le plan pour 100 lignes se simplifie : un seul worker suffit pour parcourir une petite partition, le temps d’exécution est descendu à 52 ms.

 Finalize Aggregate  (cost=5633.22..5633.23 rows=1 width=8) (actual time=49.966..49.966 rows=1 loops=1)
   Output: sum(bigtable_398.x)
   Buffers: shared read=1444
   I/O Timings: read=21.991
   ->  Gather  (cost=5633.10..5633.21 rows=1 width=8) (actual time=49.959..49.962 rows=2 loops=1)
         Output: (PARTIAL sum(bigtable_398.x))
         Workers Planned: 1
         Workers Launched: 1
         Buffers: shared read=1444
         I/O Timings: read=21.991
         ->  Partial Aggregate  (cost=4633.10..4633.11 rows=1 width=8) (actual time=38.506..38.506 rows=1 loops=2)
               Output: PARTIAL sum(bigtable_398.x)
               Buffers: shared read=2213
               I/O Timings: read=38.675
               Worker 0: actual time=30.326..30.326 rows=1 loops=1
                 Buffers: shared read=769
                 I/O Timings: read=16.685
               ->  Append  (cost=0.00..4633.06 rows=16 width=4) (actual time=15.263..38.495 rows=50 loops=2)
                     Buffers: shared read=2213
                     I/O Timings: read=38.675
                     Worker 0: actual time=30.323..30.323 rows=0 loops=1
                       Buffers: shared read=769
                       I/O Timings: read=16.685
                     ->  Parallel Seq Scan on public.bigtable_398  (cost=0.00..4633.06 rows=16 width=4) (actual time=15.262..38.489 rows=50 loops=2)
                           Output: bigtable_398.x
                           Filter: 
                           Rows Removed by Filter: 249950
                           Buffers: shared read=2213
                           I/O Timings: read=38.675
                           Worker 0: actual time=30.322..30.322 rows=0 loops=1
                             Buffers: shared read=769
                             I/O Timings: read=16.685
 Planning time: 32.769 ms
 Execution time: 52.248 ms

On pourrait se dire « les micro-partitions c’est chouette, abusons-en » !

Mais le temps de planification a encore augmenté (et il est similaire sur une table vide). En fait il atteint à présent le même ordre de grandeur que l’exécution elle-même.

10 000 partitions

Pourquoi s’arrêter en si bon chemin ? Créons 10 000 partitions de 1,8 Mo et découvrons un petit phénomène pénible entre la création de la première et celle de la dernière :

 CREATE TABLE
 Temps : 3,512 ms
 ...
 CREATE TABLE
 Temps : 57,067 ms

Le catalogue de PostgreSQL n’est manifestement pas taillé pour manipuler des dizaines de milliers de tables, ce qui est un peu gênant. Certes, on ne perd qu’une poignée de minutes dans un flux d’alimentation normalement beaucoup plus lourd.

L’insertion des 500 millions de lignes n’est pas plus longue que d’habitude, le VACUUM ANALYZE a un temps similaire.

Si je veux récupérer 100 lignes (sur 2 partitions dans cet exemple) :

  Aggregate  (cost=1544.87..1544.88 rows=1 width=8) (actual time=10.389..10.389 rows=1 loops=1)
    Output: sum(bigtable_3999.x)
    Buffers: shared hit=222 read=222
    I/O Timings: read=4.094
    ->  Append  (cost=0.00..1544.40 rows=188 width=4) (actual time=7.094..10.369 rows=200 loops=1)
        Buffers: shared hit=222 read=222
        I/O Timings: read=4.094
        ->  Seq Scan on public.bigtable_3999  (cost=0.00..772.20 rows=43 width=4) (actual time=7.093..7.099 rows=50 loops=1)
              Output: bigtable_3999.x
              Filter: 
              Rows Removed by Filter: 49950
              Buffers: shared read=222
              I/O Timings: read=4.094
        ->  Seq Scan on public.bigtable_4000  (cost=0.00..772.20 rows=145 width=4) (actual time=0.015..3.254 rows=150 loops=1)
              Output: bigtable_4000.x
              Filter: 
              Rows Removed by Filter: 49850
              Buffers: shared hit=222
Planning time: 164.257 ms
Execution time: 10.883 ms

Quelques millisecondes pour parcourir une table ridicule… et 16 fois plus à planifier le bon plan d’exécution ! Je n’ai pas cherché à creuser un éventuel effet de cache sur le catalogue. Et, là encore, la planification avec 10 000 partitions vides est aussi longue.

J’ai testé la récupération du contenu de 10 partitions sur 10 000, vides ou pleines : le temps de planification est similaire.

Exclusion de contraintes

Et si vous voulez tenter de simplifier la tâche à l’optimiseur en lui disant de ne pas chercher à optimiser en fonction des contraintes des tables (SET constraint_exclusion TO off ; au lieu du défaut partition), vous augmenterez encore ce temps de planification car il devra considérer toutes les partitions, et les parcourra toutes, pour un temps global catastrophique :

  Planning time: 235.568 ms
  Execution time: 53080.031 ms

Jointure

Quelque dixièmes de secondes de planification, ce n’est pas énorme comparé au temps d’exécution d’une requête traditionnelle classique, pourrait-on se dire. C’est tout de même énorme par rapport aux partitionnements plus légers vus précédemment. C’est aussi oublier que les tables sont rarement prises isolément. Dans le cas le pire, les tables jointes sont également partitionnées. En testant une autojointure sur la même table :

 EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
 SELECT sum(b.x) FROM bigtable a INNER JOIN bigtable b ON (a.id=b.id)
 WHERE a.id BETWEEN 300000001 AND 300000001+100 ;

Je vous épargne le plan, qui, faute d’index, consiste à faire un hash join entre la partition bigtable_6000 (a) et les 10 000 partitions (b). Le temps d’exécution de 100 s n’est pas étonnant. Mais il y a aussi ça :

  Planning time: 7031.163 ms

…qui est un peu inquiétant.

BRIN

La requête précédente bénéficierait bien d’un index. Je ne veux pas sacrifier encore plusieurs Go : un index BRIN (une nouveauté 9.5), bien compact, fera donc l’affaire. Nos données sont ici triées, c’est souvent le cas dans des tables de fait, mais ce n’est pas le plus important dans notre cas.

Malheureusement PostgreSQL 10 ne gère pas les index couvrant plusieurs partitions, il faudra donc générer 10 000 index à la main :

 SELECT 'CREATE INDEX '||relname||'_brin ON '
 ||relname ||' USING brin(id);' 
 FROM pgclass where relname like 'bigtable%' \gexec

…et cela fera donc 10 000 objets de plus à gérer.

En pratique la simple somme sur 100 lignes est bien accélérée, mais cela n’a au final aucun intérêt puisqu’on passe 100 fois plus de temps à planifier la requête qu’à l’exécuter complètement :

  Aggregate  (cost=397.79..397.80 rows=1 width=8) (actual time=3.287..3.287 rows=1 loops=1)
    ->  Append  (cost=0.36..397.56 rows=93 width=4) (actual time=0.108..3.277 rows=101 loops=1)
        ->  Bitmap Heap Scan on bigtable_8000  (cost=0.36..397.56 rows=93 width=4) (actual time=0.108..3.271 rows=101 loops=1)
              Recheck Cond: 
              Rows Removed by Index Recheck: 28827
              Heap Blocks: lossy=128
              ->  Bitmap Index Scan on bigtable8000brin  (cost=0.00..0.33 rows=25000 width=0) (actual time=0.094..0.094 rows=1280 loops=1)
                    Index Cond: 
  Planning time: 591.540 ms
  Execution time: 3.948 ms

Et en v11 (bêta 2)?

Ce qui précède a été mené avec une 10.3. J’ai eu envie de voir ce que donnait la version en développement, à paraître à l’automne.

La v11 (bêta 2) m’a craché quelques insultes, que je n’avais pas en v10, vite disparues après augmentation du paramètre indiqué :

 2018-04-22 16:45:07.721 CEST 2801 ERROR:  out of shared memory
 2018-04-22 16:45:07.721 CEST 2801 HINT:  You might need to increase maxlocksper_transaction.

Le partition pruning, une nouveauté, remplace en pratique la classique exclusion par contrainte.

Pour la recherche simple sur 100 lignes dans 10 000 partitions (sans index) :

 Planning Time: 672.276 ms
 Execution Time: 3.969 ms

Pour la requête avec autojointure (sans index) :

  Planning Time: 25156.639 ms
  Execution Time: 96427.987 ms

Bref, du délire.

Et en v11 (celle qui sortira) ?

Cette explosion des temps de planification pour un grand nombre de partitions a été reproduit et remonté par mon collègue Thomas sur hackers. Je n’ai pas cherché à comprendre le détail des discussions. Une nouvelle fonction n’était pas optimisée parce que cela ne semblait pas important à l’époque. L’important est que le problème a été reconnu, et en bonne partie corrigé il y a quelques jours.

D’après les différents essais peu scientifiques que j’ai faits, les ralentissements restent sensibles pour les très petits temps de planification (c’est sans grande importance), ou ceux déjà énormes (> 1 s). À revoir de manière plus rigoureuse avec des jeux de test plus réalistes et la version finale.

Conclusion

Dixit la doc : Partitioning using these techniques will work well with up to perhaps a hundred partitions; don’t try to use many thousands of partitions.

Cela reste donc valable à cause de ce temps de planification délirant au-delà de 1 000 partitions. Cela dit, nombre de requêtes décisionnelles de plusieurs minutes se contrefichent de quelques secondes de planification. Il y a donc peut-être quelques cas où on acceptera ce prix pour réduire la taille des partitions. À tester soigneusement.

© SQL & éléphant, after the WP Dusk To Dawn theme Propulsé par Dotclear