web-dev-qa-db-fra.com

Optimiser la requête GROUP BY pour récupérer la dernière ligne par utilisateur

J'ai la table de log suivante pour les messages utilisateurs (forme simplifiée) dans Postgres 9.2:

CREATE TABLE log (
    log_date DATE,
    user_id  INTEGER,
    payload  INTEGER
);

Il contient jusqu'à un enregistrement par utilisateur et par jour. Il y aura environ 500 000 enregistrements par jour pendant 300 jours. la charge utile est en constante augmentation pour chaque utilisateur (si cela importe).

Je souhaite récupérer efficacement le dernier enregistrement de chaque utilisateur avant une date spécifique. Ma requête est:

SELECT user_id, max(log_date), max(payload) 
FROM log 
WHERE log_date <= :mydate 
GROUP BY user_id

ce qui est extrêmement lent. J'ai aussi essayé:

SELECT DISTINCT ON(user_id), log_date, payload
FROM log
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC;

qui a le même plan et est tout aussi lent.

Jusqu'à présent, j'ai un seul index sur log(log_date), mais n'aide pas beaucoup.

Et j'ai une table users avec tous les utilisateurs inclus. Je souhaite également récupérer le résultat pour certains utilisateurs (ceux avec payload > :value).

Existe-t-il un autre index que je devrais utiliser pour accélérer cela, ou tout autre moyen de réaliser ce que je veux?

42
xpapad

Pour de meilleures performances de lecture, vous avez besoin d'un index multicolonne :

CREATE INDEX log_combo_idx
ON log (user_id, log_date DESC NULLS LAST)

Pour rendre scans d'index uniquement possible, ajoutez la colonne autrement non nécessaire payload:

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST, payload)

Pourquoi DESC NULLS LAST?

Pour peu lignes par user_id ou petites tables DISTINCT ON est généralement le plus rapide et le plus simple:

Pour beaucoup lignes par user_id an ( balayage saut d'index (ou balayage index lâche ) est (beaucoup) plus efficace. Ce n'est pas implémenté jusqu'à Postgres 12 - le travail est en cours pour Postgres 1 . Mais il existe des moyens de l'imiter efficacement.

Expressions de table communes nécessite Postgres 8.4 + .
LATERAL nécessite Postgres 9.3 + .
Les solutions suivantes vont au-delà de ce qui est couvert dans le Wiki Postgres .

1. Pas de table séparée avec des utilisateurs uniques

Avec une table users distincte, les solutions dans 2. ci-dessous sont généralement plus simples et plus rapides. Sautez devant.

1a. CTE récursif avec LATERAL jointure

WITH RECURSIVE cte AS (
   (                                -- parentheses required
   SELECT user_id, log_date, payload
   FROM   log
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT l.*
   FROM   cte c
   CROSS  JOIN LATERAL (
      SELECT l.user_id, l.log_date, l.payload
      FROM   log l
      WHERE  l.user_id > c.user_id  -- lateral reference
      AND    log_date <= :mydate    -- repeat condition
      ORDER  BY l.user_id, l.log_date DESC NULLS LAST
      LIMIT  1
      ) l
   )
TABLE  cte
ORDER  BY user_id;

C'est simple pour récupérer des colonnes arbitraires et probablement mieux dans Postgres actuel. Plus d'explications dans le chapitre 2a. ci-dessous.

1b. CTE récursif avec sous-requête corrélée

WITH RECURSIVE cte AS (
   (                                           -- parentheses required
   SELECT l AS my_row                          -- whole row
   FROM   log l
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT (SELECT l                            -- whole row
           FROM   log l
           WHERE  l.user_id > (c.my_row).user_id
           AND    l.log_date <= :mydate        -- repeat condition
           ORDER  BY l.user_id, l.log_date DESC NULLS LAST
           LIMIT  1)
   FROM   cte c
   WHERE  (c.my_row).user_id IS NOT NULL       -- note parentheses
   )
SELECT (my_row).*                              -- decompose row
FROM   cte
WHERE  (my_row).user_id IS NOT NULL
ORDER  BY (my_row).user_id;

Pratique pour récupérer un colonne unique ou le ligne entière. L'exemple utilise le type de ligne entier du tableau. D'autres variantes sont possibles.

Pour affirmer qu'une ligne a été trouvée dans l'itération précédente, testez une seule colonne NOT NULL (comme la clé primaire).

Plus d'explications sur cette requête dans le chapitre 2b. ci-dessous.

En relation:

2. Avec une table users distincte

La disposition du tableau n'a pas d'importance tant qu'une seule ligne par _ user_id est garanti. Exemple:

CREATE TABLE users (
   user_id  serial PRIMARY KEY
 , username text NOT NULL
);

Idéalement, la table est triée physiquement en synchronisation avec la table log. Voir:

Ou il est suffisamment petit (faible cardinalité) pour que cela ne compte guère. Sinon, le tri des lignes dans la requête peut aider à optimiser davantage les performances. Voir l'addition de Gang Liang. Si l'ordre de tri physique de la table users correspond à l'index sur log, cela peut ne pas être pertinent.

2a. LATERAL rejoindre

SELECT u.user_id, l.log_date, l.payload
FROM   users u
CROSS  JOIN LATERAL (
   SELECT l.log_date, l.payload
   FROM   log l
   WHERE  l.user_id = u.user_id         -- lateral reference
   AND    l.log_date <= :mydate
   ORDER  BY l.log_date DESC NULLS LAST
   LIMIT  1
   ) l;

JOIN LATERAL permet de référencer les éléments FROM précédents au même niveau de requête. Voir:

Résultats en une seule recherche d'index (uniquement) par utilisateur.

Ne renvoie aucune ligne pour les utilisateurs manquants dans la table users. En règle générale, une contrainte de clé étrangère imposant l'intégrité référentielle l'exclurait.

En outre, aucune ligne pour les utilisateurs sans entrée correspondante dans log - conforme à la question d'origine. Pour conserver ces utilisateurs dans le résultat, utilisez LEFT JOIN LATERAL ... ON true au lieu de CROSS JOIN LATERAL:

Utilisez LIMIT n au lieu de LIMIT 1 pour récupérer plus d'une ligne (mais pas toutes) par utilisateur.

En fait, tous ces éléments font la même chose:

JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...

Le dernier a cependant une priorité plus faible. JOIN explicite se lie avant la virgule. Cette différence subtile peut être importante avec plus de tables de jointure. Voir:

2b. Sous-requête corrélée

Bon choix pour récupérer une seule colonne à partir d'une seule ligne . Exemple de code:

La même chose est possible pour plusieurs colonnes , mais vous avez besoin de plus d'intelligence:

CREATE TEMP TABLE combo (log_date date, payload int);

SELECT user_id, (combo1).*              -- note parentheses
FROM (
   SELECT u.user_id
        , (SELECT (l.log_date, l.payload)::combo
           FROM   log l
           WHERE  l.user_id = u.user_id
           AND    l.log_date <= :mydate
           ORDER  BY l.log_date DESC NULLS LAST
           LIMIT  1) AS combo1
   FROM   users u
   ) sub;
  • Comme LEFT JOIN LATERAL ci-dessus, cette variante inclut tous utilisateurs, même sans entrées dans log. Vous obtenez NULL pour combo1, que vous pouvez facilement filtrer avec une clause WHERE dans la requête externe si nécessaire.
    Nitpick: dans la requête externe, vous ne pouvez pas distinguer si la sous-requête n'a pas trouvé de ligne ou si toutes les valeurs de colonne sont NULL - même résultat. Tu as besoin d'un NOT NULL colonne dans la sous-requête pour éviter cette ambiguïté.

  • Une sous-requête corrélée ne peut renvoyer qu'une seule valeur . Vous pouvez encapsuler plusieurs colonnes dans un type composite. Mais pour le décomposer plus tard, Postgres exige un type composite bien connu. Les enregistrements anonymes ne peuvent être décomposés qu'en fournissant une liste de définitions de colonnes.
    Utilisez un type enregistré comme le type de ligne d'une table existante. Ou enregistrez un type composite de manière explicite (et permanente) avec CREATE TYPE. Ou créez une table temporaire (supprimée automatiquement à la fin de la session) pour enregistrer temporairement son type de ligne. Syntaxe de transtypage: (log_date, payload)::combo

  • Enfin, nous ne voulons pas décomposer combo1 au même niveau de requête. En raison d'une faiblesse dans le planificateur de requêtes, cela évaluerait la sous-requête une fois pour chaque colonne (toujours vrai dans Postgres 12). Au lieu de cela, faites-en une sous-requête et décomposez-la dans la requête externe.

En relation:

Démonstration des 4 requêtes avec 100 000 entrées de journal et 1 000 utilisateurs:
db <> violon ici - pg 11
Ancien sqlfiddle - pg 9,6

100
Erwin Brandstetter

Ce n'est pas une réponse autonome mais plutôt un commentaire à @ Erwin's réponse . Pour 2a, l'exemple de jointure latérale, la requête peut être améliorée en triant la table users pour exploiter la localité de l'index sur log.

SELECT u.user_id, l.log_date, l.payload
  FROM (SELECT user_id FROM users ORDER BY user_id) u,
       LATERAL (SELECT log_date, payload
                  FROM log
                 WHERE user_id = u.user_id -- lateral reference
                   AND log_date <= :mydate
              ORDER BY log_date DESC NULLS LAST
                 LIMIT 1) l;

La justification est que la recherche d'index est coûteuse si user_id les valeurs sont aléatoires. En triant user_id d'abord, la jointure latérale suivante serait comme un simple balayage sur l'index de log. Même si les deux plans de requête se ressemblent, le temps d'exécution serait très différent, en particulier pour les grandes tables.

Le coût du tri est minime surtout s'il y a un index sur le user_id champ.

5
Gang Liang

Peut-être qu'un index différent sur la table aiderait. Essayez celui-ci: log(user_id, log_date). Je ne suis pas certain que Postgres fera un usage optimal avec distinct on.

Donc, je resterais avec cet index et j'essaierais cette version:

select *
from log l
where not exists (select 1
                  from log l2
                  where l2.user_id = l.user_id and
                        l2.log_date <= :mydate and
                        l2.log_date > l.log_date
                 );

Cela devrait remplacer le tri/regroupement par des recherches d'index. Cela pourrait être plus rapide.

4
Gordon Linoff