Comment rendre DISTINCT ON plus rapide dans PostgreSQL?

13

J'ai une table station_logsdans une base de données PostgreSQL 9.6:

    Column     |            Type             |    
---------------+-----------------------------+
 id            | bigint                      | bigserial
 station_id    | integer                     | not null
 submitted_at  | timestamp without time zone | 
 level_sensor  | double precision            | 
Indexes:
    "station_logs_pkey" PRIMARY KEY, btree (id)
    "uniq_sid_sat" UNIQUE CONSTRAINT, btree (station_id, submitted_at)

J'essaie d'obtenir la dernière level_sensorvaleur basée sur submitted_at, pour chacun station_id. Il existe environ 400 station_idvaleurs uniques et environ 20 000 lignes par jour et par station_id.

Avant de créer un index:

EXPLAIN ANALYZE
SELECT DISTINCT ON(station_id) station_id, submitted_at, level_sensor
FROM station_logs ORDER BY station_id, submitted_at DESC;
 Unique (coût = 4347852.14..4450301.72 lignes = 89 largeur = 20) (temps réel = 22202.080..27619.167 lignes = 98 boucles = 1)
   -> Trier (coût = 4347852.14..4399076.93 lignes = 20489916 largeur = 20) (temps réel = 22202.077..26540.827 lignes = 20489812 boucles = 1)
         Clé de tri: station_id, submit_at DESC
         Méthode de tri: fusion externe Disque: 681040 Ko
         -> Seq Scan sur station_logs (coût = 0,00 à 598895,16 lignes = 20489916 largeur = 20) (temps réel = 0,023 à 3443,587 lignes = 20489812 boucles = $
 Temps de planification: 0,072 ms
 Temps d'exécution: 27690,644 ms

Création d'un index:

CREATE INDEX station_id__submitted_at ON station_logs(station_id, submitted_at DESC);

Après avoir créé l'index, pour la même requête:

 Unique (coût = 0,56..2156367,51 lignes = 89 largeur = 20) (temps réel = 0,184..16263,413 lignes = 98 boucles = 1)
   -> Index Scan en utilisant station_id__submitted_at sur station_logs (coût = 0,56..2105142,98 lignes = 20489812 largeur = 20) (temps réel = 0,181..1 $
 Temps de planification: 0,206 ms
 Temps d'exécution: 16263,490 ms

Existe-t-il un moyen d'accélérer cette requête? Comme 1 seconde par exemple, 16 secondes, c'est encore trop.

Kokizzu
la source
2
Combien d'ID de station distincts existe-t-il, c'est-à-dire combien de lignes la requête renvoie-t-elle? Et quelle version de Postgres?
ypercubeᵀᴹ
Postgre 9.6, environ 400 station_id uniques et environ 20 000 enregistrements par jour par station_id
Kokizzu
Cette requête renvoie une "dernière valeur level_sensor basée sur soumis_at, pour chaque station_id". DISTINCT ON implique un choix aléatoire, sauf dans les cas où vous n'en avez pas besoin.
philipxy

Réponses:

18

Pour seulement 400 stations, cette requête sera massivement plus rapide:

SELECT s.station_id, l.submitted_at, l.level_sensor
FROM   station s
CROSS  JOIN LATERAL (
   SELECT submitted_at, level_sensor
   FROM   station_logs
   WHERE  station_id = s.station_id
   ORDER  BY submitted_at DESC NULLS LAST
   LIMIT  1
   ) l;

dbfiddle ici
(en comparant les plans pour cette requête, l'alternative d'Abelisto et votre original)

Résultat EXPLAIN ANALYZEtel que fourni par le PO:

 Boucle imbriquée (coût = 0,56..356,65 lignes = 102 largeur = 20) (temps réel = 0,034..0,979 lignes = 98 boucles = 1)
   -> Seq Scan sur les stations s (coût = 0,00..3,02 lignes = 102 largeur = 4) (temps réel = 0,009..0,016 lignes = 102 boucles = 1)
   -> Limite (coût = 0,56..3,45 lignes = 1 largeur = 16) (temps réel = 0,009..0,009 lignes = 1 boucles = 102)
         -> Index Scan en utilisant station_id__submitted_at sur station_logs (coût = 0,56..664062,38 lignes = 230223 largeur = 16) (temps réel = 0,009 $
               Index Cond: (station_id = s.id)
 Temps de planification: 0,542 ms
 Temps d'exécution: 1,013 ms   - !!

Le seul indice dont vous avez besoin est celui que vous avez créé: station_id__submitted_at. La UNIQUEcontrainte fait uniq_sid_satégalement le travail, essentiellement. La maintenance des deux semble être une perte d'espace disque et de performances d'écriture.

J'ai ajouté NULLS LASTà ORDER BYdans la requête car submitted_atn'est pas défini NOT NULL. Idéalement, le cas échéant !, ajoutez une NOT NULLcontrainte à la colonne submitted_at, supprimez l'index supplémentaire et supprimez NULLS LASTde la requête.

Si submitted_atpossible NULL, créez cet UNIQUEindex pour remplacer à la fois votre index actuel et votre contrainte unique:

CREATE UNIQUE INDEX station_logs_uni ON station_logs(station_id, submitted_at DESC NULLS LAST);

Considérer:

Cela suppose une table distinctestation avec une ligne par pertinence station_id(généralement le PK) - que vous devriez avoir dans les deux cas. Si vous ne l'avez pas, créez-le. Encore une fois, très rapide avec cette technique rCTE:

CREATE TABLE station AS
WITH RECURSIVE cte AS (
   (
   SELECT station_id
   FROM   station_logs
   ORDER  BY station_id
   LIMIT  1
   )
   UNION ALL
   SELECT l.station_id
   FROM   cte c
   ,      LATERAL (   
      SELECT station_id
      FROM   station_logs
      WHERE  station_id > c.station_id
      ORDER  BY station_id
      LIMIT  1
      ) l
   )
TABLE cte;

Je l'utilise aussi au violon. Vous pouvez utiliser une requête similaire pour résoudre votre tâche directement, sans stationtable - si vous ne pouvez pas être convaincu de la créer.

Instructions détaillées, explication et alternatives:

Optimiser l'index

Votre requête devrait être très rapide maintenant. Seulement si vous devez encore optimiser les performances de lecture ...

Il pourrait être judicieux d'ajouter level_sensorcomme dernière colonne à l'index pour autoriser les analyses d'index uniquement , comme l' a commenté joanolo .
Con: il rend l'index plus grand - ce qui ajoute un peu de coût à toutes les requêtes qui l'utilisent.
Pro: Si vous obtenez réellement des analyses d'index uniquement, la requête à portée de main n'a pas du tout à visiter les pages de tas, ce qui la rend environ deux fois plus rapide. Mais cela peut être un gain non substantiel pour la requête très rapide maintenant.

Cependant , je ne m'attends pas à ce que cela fonctionne pour votre cas. Vous avez mentionné:

... environ 20 000 lignes par jour et par station_id.

En règle générale, cela indiquerait une charge d'écriture incessante (1 station_idtoutes les 5 secondes). Et vous êtes intéressé par la dernière ligne. Les analyses d'index uniquement ne fonctionnent que pour les pages de segment visibles par toutes les transactions (le bit dans la carte de visibilité est défini). Vous devez exécuter des VACUUMparamètres extrêmement agressifs pour que la table suive la charge d'écriture, et cela ne fonctionnera toujours pas la plupart du temps. Si mes hypothèses sont correctes, les analyses d'index uniquement sont supprimées, n'ajoutez paslevel_sensor à l'index.

OTOH, si mes hypothèses se vérifient et que votre table grandit très , un indice BRIN pourrait vous aider. En relation:

Ou, encore plus spécialisé et plus efficace: un index partiel pour les derniers ajouts seulement pour couper la majeure partie des lignes non pertinentes:

CREATE INDEX station_id__submitted_at_recent_idx ON station_logs(station_id, submitted_at DESC NULLS LAST)
WHERE submitted_at > '2017-06-24 00:00';

Choisissez un horodatage pour lequel vous savez que des lignes plus jeunes doivent exister. Vous devez ajouter une WHEREcondition de correspondance à toutes les requêtes, comme:

...
WHERE  station_id = s.station_id
AND    submitted_at > '2017-06-24 00:00'
...

Vous devez de temps en temps adapter l'index et la requête.
Réponses associées avec plus de détails:

Erwin Brandstetter
la source
Chaque fois que je sais que je veux une boucle imbriquée (souvent), l'utilisation de LATERAL est une amélioration des performances pour un certain nombre de situations.
Paul Draper
6

Essayez la méthode classique:

create index idx_station_logs__station_id on station_logs(station_id);
create index idx_station_logs__submitted_at on station_logs(submitted_at);

analyse station_logs;

with t as (
  select station_id, max(submitted_at) submitted_at 
  from station_logs 
  group by station_id)
select * 
from t join station_logs l on (
  l.station_id = t.station_id and l.submitted_at = t.submitted_at);

dbfiddle

EXPLAIN ANALYZE par ThreadStarter

 Nested Loop  (cost=701344.63..702110.58 rows=4 width=155) (actual time=6253.062..6253.544 rows=98 loops=1)
   CTE t
     ->  HashAggregate  (cost=701343.18..701344.07 rows=89 width=12) (actual time=6253.042..6253.069 rows=98 loops=1)
           Group Key: station_logs.station_id
           ->  Seq Scan on station_logs  (cost=0.00..598894.12 rows=20489812 width=12) (actual time=0.034..1841.848 rows=20489812 loop$
   ->  CTE Scan on t  (cost=0.00..1.78 rows=89 width=12) (actual time=6253.047..6253.085 rows=98 loops=1)
   ->  Index Scan using station_id__submitted_at on station_logs l  (cost=0.56..8.58 rows=1 width=143) (actual time=0.004..0.004 rows=$
         Index Cond: ((station_id = t.station_id) AND (submitted_at = t.submitted_at))
 Planning time: 0.542 ms
 Execution time: 6253.701 ms
Abelisto
la source