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?
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 .
Avec une table users
distincte, les solutions dans 2. ci-dessous sont généralement plus simples et plus rapides. Sautez devant.
LATERAL
jointureWITH 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.
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:
users
distincteLa 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.
LATERAL
rejoindreSELECT 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:
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
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.
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.