web-dev-qa-db-fra.com

Comment obtenir efficacement "la ligne correspondante la plus récente"?

J'ai un modèle de requête qui doit être très courant, mais je ne sais pas comment lui écrire une requête efficace. Je veux rechercher les lignes d'une table qui correspondent à "la date la plus récente pas après" les lignes d'une autre table.

J'ai une table, inventory disons, qui représente l'inventaire que je détiens un certain jour.

date       | good | quantity
------------------------------
2013-08-09 | Egg  | 5
2013-08-09 | pear | 7
2013-08-02 | Egg  | 1
2013-08-02 | pear | 2

et une table, "prix" disons, qui détient le prix d'un bien un jour donné

date       | good | price
--------------------------
2013-08-07 | Egg  | 120
2013-08-06 | pear | 200
2013-08-01 | Egg  | 110
2013-07-30 | pear | 220

Comment puis-je efficacement obtenir le prix "le plus récent" pour chaque ligne du tableau d'inventaire, c'est-à-dire.

date       | pricing date | good | quantity | price
----------------------------------------------------
2013-08-09 | 2013-08-07   | Egg  | 5        | 120
2013-08-09 | 2013-08-06   | pear | 7        | 200
2013-08-02 | 2013-08-01   | Egg  | 1        | 110
2013-08-02 | 2013-07-30   | pear | 2        | 220

Je connais une façon de procéder:

select inventory.date, max(price.date) as pricing_date, good
from inventory, price
where inventory.date >= price.date
and inventory.good = price.good
group by inventory.date, good

puis joignez cette requête à nouvea à l'inventaire. Pour les grandes tables, même la première requête (sans rejoindre encore à l'inventaire) est très lente. Cependant, le même problème est rapidement résolu si j'utilise simplement mon langage de programmation pour émettre une requête max(price.date) ... where price.date <= date_of_interest ... order by price.date desc limit 1 pour chaque date_of_interest De la table d'inventaire, donc je sais qu'il n'y a pas d'obstacle de calcul. Je préférerais cependant résoudre tout le problème avec une seule requête SQL, car cela me permettrait de poursuivre le traitement SQL sur le résultat de la requête.

Existe-t-il un moyen standard de le faire efficacement? Il semble que cela doit arriver souvent et qu'il devrait y avoir un moyen d'écrire une requête rapide pour cela.

J'utilise Postgres, mais une réponse SQL générique serait appréciée.

59
Tom Ellis

Cela cela dépend beaucoup selon les circonstances et les exigences exactes. Considérez mon commentaire à la question .

Solution simple

Avec DISTINCT ON dans Postgres:

SELECT DISTINCT ON (i.good, i.the_date)
       i.the_date, p.the_date AS pricing_date, i.good, p.price
FROM   inventory  i
LEFT   JOIN price p ON i.good = p.good AND i.the_date >= p.the_date
ORDER  BY i.good, i.the_date, p.the_date DESC;

Résultat ordonné.

Ou avec NOT EXISTS en SQL standard (fonctionne avec tous les SGBDR que je connais):

SELECT i.the_date, p.the_date AS pricing_date, i.good, i.quantity, p.price
FROM   inventory  i
LEFT   JOIN price p ON p.good = i.good AND p.the_date <= i.the_date
WHERE  NOT EXISTS (
   SELECT 1 FROM price p1
   WHERE  p1.good = p.good
   AND p1.the_date <= i.the_date
   AND p1.the_date >  p.the_date
   );

Même résultat, mais avec un ordre de tri arbitraire - sauf si vous ajoutez ORDER BY.
Selon la distribution des données, les exigences exactes et les indices, l'un ou l'autre peut être plus rapide.
Généralement, DISTINCT ON est le vainqueur et vous obtenez un résultat trié par-dessus. Mais dans certains cas, d'autres techniques de requête sont (beaucoup) plus rapides encore. Voir ci-dessous.

Les solutions avec sous-requêtes pour calculer les valeurs max/min sont généralement plus lentes. Les variantes avec CTE sont généralement encore plus lentes.

Les vues simples (comme proposé par une autre réponse) n'aident pas du tout les performances dans Postgres.

SQL Fiddle.


Solution appropriée

Cordes et collation

Tout d'abord, vous souffrez d'une disposition de table sous-optimale. Cela peut sembler trivial, mais normaliser votre schéma peut aller très loin.

Le tri par types de caractères (text, varchar, ...) doit être fait en fonction des paramètres régionaux - le ( [~ # ~] classement [~ # ~] en particulier. Votre base de données utilise très probablement un ensemble de règles locales (comme dans mon cas: de_AT.UTF-8). Découvrez avec:

SHOW lc_collate;

Cela rend les recherches de tri et d'index plus lentes. Plus vos chaînes (noms de produits) sont longues, pire c'est. Si vous ne vous souciez pas réellement des règles de classement dans votre sortie (ou de l'ordre de tri du tout), cela peut être plus rapide si vous ajoutez COLLATE "C":

SELECT DISTINCT ON (i.good COLLATE "C", i.the_date)
       i.the_date, p.the_date AS pricing_date, i.good, p.price
FROM   inventory  i
LEFT   JOIN price p ON i.good = p.good AND i.the_date >= p.the_date
ORDER  BY i.good COLLATE "C", i.the_date, p.the_date DESC;

Notez comment j'ai ajouté le classement à deux endroits.
Deux fois plus vite dans mon test avec 20k lignes chacun et des noms très basiques ('good123').

Indice

Si votre requête est censée utiliser un index, les colonnes contenant des données de caractères doivent utiliser un classement correspondant (good dans l'exemple):

CREATE INDEX inventory_good_date_desc_collate_c_idx
ON price(good COLLATE "C", the_date DESC);

Assurez-vous de lire les deux derniers chapitres de cette réponse connexe sur SO:

Vous pouvez même avoir plusieurs index avec différents classements sur les mêmes colonnes - si vous avez également besoin de marchandises triées selon un autre classement (ou le classement par défaut) dans d'autres requêtes.

Normaliser

Les chaînes redondantes (nom du bien) font également gonfler vos tables et index, ce qui rend tout encore plus lent. Avec une disposition de table appropriée, vous pourriez éviter la plupart du problème pour commencer. Pourrait ressembler à ceci:

CREATE TABLE good (
  good_id serial PRIMARY KEY
, good    text   NOT NULL
);

CREATE TABLE inventory (
  good_id  int  REFERENCES good (good_id)
, the_date date NOT NULL
, quantity int  NOT NULL
, PRIMARY KEY(good_id, the_date)
);

CREATE TABLE price (
  good_id  int     REFERENCES good (good_id)
, the_date date    NOT NULL
, price    numeric NOT NULL
, PRIMARY KEY(good_id, the_date));

Les clés primaires fournissent automatiquement (presque) tous les indices dont nous avons besoin.
En fonction des détails manquants, un index multicolonne sur price avec un ordre décroissant sur la deuxième colonne peut améliorer les performances:

CREATE INDEX price_good_date_desc_idx ON price(good, the_date DESC);

Encore une fois, le classement doit correspondre à votre requête (voir ci-dessus).

Dans Postgres 9.2 ou version ultérieure "indices de couverture" pour les analyses d'index uniquement pourrait aider davantage - en particulier si vos tables contiennent des colonnes supplémentaires, ce qui rend la table considérablement plus grande que l'indice de couverture.

Ces requêtes résultantes sont beaucoup plus rapides:

N'EXISTE PAS

SELECT i.the_date, p.the_date AS pricing_date, g.good, i.quantity, p.price
FROM   inventory  i
JOIN   good       g USING (good_id)
LEFT   JOIN price p ON p.good_id = i.good_id AND p.the_date <= i.the_date
AND    NOT EXISTS (
   SELECT 1 FROM price p1
   WHERE  p1.good_id = p.good_id
   AND    p1.the_date <= i.the_date
   AND    p1.the_date >  p.the_date
   );

DISTINCT SUR

SELECT DISTINCT ON (i.the_date)
       i.the_date, p.the_date AS pricing_date, g.good, i.quantity, p.price
FROM   inventory  i
JOIN   good       g USING (good_id)
LEFT   JOIN price p ON p.good_id = i.good_id AND p.the_date <= i.the_date
ORDER  BY i.the_date, p.the_date DESC;

SQL Fiddle.


Des solutions plus rapides

Si ce n'est pas encore assez rapide, il peut y avoir des solutions plus rapides.

CTE récursif/JOIN LATERAL/sous-requête corrélée

Surtout pour les distributions de données avec beaucoup prix par bien :

Vue matérialisée

Si vous devez exécuter cela souvent et rapidement, je vous suggère de créer une vue matérialisée. Je pense qu'il est sûr de supposer que les prix et les stocks pour les dates passées changent rarement. Calculez le résultat une fois et stockez un instantané en tant que vue matérialisée.

Postgres 9.3+ a un support automatisé pour les vues matérialisées. Vous pouvez facilement implémenter une version de base dans des versions plus anciennes.

50
Erwin Brandstetter

Pour info, j'ai utilisé mssql 2008, donc Postgres n'aura pas l'index "include". Cependant, l'utilisation de l'indexation de base illustrée ci-dessous passera des jointures de hachage aux jointures de fusion dans Postgres: http://explain.depesz.com/s/eF6 (pas d'index) http://explain.depesz.com/s/j9x (avec index sur les critères de jointure)

Je propose de diviser votre requête en deux parties. Tout d'abord, une vue (non destinée à améliorer les performances) qui peut être utilisée dans divers autres contextes qui représente la relation entre les dates d'inventaire et les dates de tarification.

create view mostrecent_pricing_dates_per_good as
select i.good,i.date i_date,max(p.date)p_date
  from inventory i
  join price p on i.good = p.good and i.date >= p.date
 group by i.good,i.date;

Ensuite, votre requête peut devenir plus simple et plus facile à manipuler pour d'autres types si la demande (comme l'utilisation des jointures gauches pour trouver l'inventaire sans dates de tarification récentes):

select i.good
       ,i.date inventory_date
       ,i.quantity
       ,p.date pricing_date
       ,p.price       
  from inventory i
  join price p on i.good = p.good
  join mostrecent_pricing_dates_per_good x 
    on i.good = x.good 
   and p.date = x.p_date
   and i.date = x.i_date

Cela donne le plan d'exécution suivant: http://sqlfiddle.com/#!3/24f23/1 no indexing

... Tous les scans avec un tri complet. Remarquez que le coût de performance des correspondances de hachage occupe une grande partie du coût total ... et nous savons que les analyses et le tri de la table sont lents (par rapport à l'objectif: la recherche d'index).

Maintenant, ajoutez des index de base pour aider les critères utilisés dans votre jointure (je ne prétends pas que ce sont des index optimaux, mais ils illustrent le point): http://sqlfiddle.com/#!3/5ec75/1 with basic indexing

Cela montre une amélioration. Les opérations de boucle imbriquée (jointure interne) ne prennent plus aucun coût total pertinent pour la requête. Le reste du coût est maintenant réparti entre les recherches d'index (une analyse de l'inventaire car nous tirons chaque ligne d'inventaire). Mais nous pouvons faire encore mieux car la requête tire la quantité et le prix. Pour obtenir ces données, après avoir évalué les critères de jointure, des recherches doivent être effectuées.

L'itération finale utilise "include" sur les index pour faciliter le glissement du plan et obtenir les données supplémentaires demandées directement à partir de l'index lui-même. Les recherches ont donc disparu: http://sqlfiddle.com/#!3/5f143/1 enter image description here

Nous avons maintenant un plan de requête où le coût total de la requête est réparti également entre les opérations de recherche d'index très rapides. Ce sera presque aussi bon que possible. Certes, d'autres experts peuvent encore améliorer cela, mais la solution dissipe quelques préoccupations majeures:

  1. Il crée des structures de données intelligibles dans votre base de données qui sont plus faciles à composer et à réutiliser dans d'autres domaines d'une application.
  2. Tous les opérateurs de requête les plus coûteux ont été exclus du plan de requête à l'aide d'une indexation de base.
6
cocogorilla

S'il vous arrive d'avoir PostgreSQL 9.3 (publié aujourd'hui), vous pouvez utiliser un LATERAL JOIN.

Je n'ai aucun moyen de tester cela, et je ne l'ai jamais utilisé auparavant, mais d'après ce que je peux dire de la documentation la syntaxe serait quelque chose comme:

SELECT  Inventory.Date,
        Inventory.Good,
        Inventory.Quantity,
        Price.Date,
        Price.Price
FROM    Inventory
        LATERAL
        (   SELECT  Date, Price
            FROM    Price
            WHERE   Price.Good = Inventory.Good
            AND     Price.Date <= Inventory.Date
            ORDER BY Price.Date DESC
            LIMIT 1
        ) p;

Ceci est fondamentalement équivalent à APPLY de SQL-Server , et il y a exemple de travail sur SQL-Fiddle à des fins de démonstration.

5
GarethD

Comme Erwin et d'autres l'ont noté, une requête efficace dépend de beaucoup de variables et PostgreSQL fait de son mieux pour optimiser l'exécution des requêtes en fonction de ces variables. En général, vous voulez d'abord écrire pour plus de clarté , puis modifier pour les performances après avoir identifié les goulots d'étranglement.

De plus, PostgreSQL propose de nombreuses astuces que vous pouvez utiliser pour rendre les choses un peu plus efficaces (index partiels pour un), donc en fonction de votre charge en lecture/écriture, vous pourrez peut-être optimiser cela très loin en examinant soigneusement l'indexation.

La première chose à essayer est simplement de faire une vue et de la rejoindre:

CREATE VIEW most_recent_rows AS
SELECT good, max(date) as max_date
FROM inventory
GROUP BY good;

Cela devrait bien fonctionner lorsque vous faites quelque chose comme:

SELECT price 
  FROM inventory i
  JOIN goods g ON i.goods = g.description
  JOIN most_recent_rows r ON i.goods = r.goods
 WHERE g.id = 123;

Ensuite, vous pouvez rejoindre cela. La requête finira par joindre la vue par rapport à la table sous-jacente, mais en supposant que vous avez un index unique le (date, bon dans cet ordre), vous devriez être prêt à partir (car ce sera un simple recherche de cache). Cela fonctionnera très bien avec quelques lignes recherchées mais sera très inefficace si vous essayez de digérer des millions de prix de marchandises.

La deuxième chose que vous pourriez faire est d'ajouter à la table d'inventaire une colonne bool la plus récente et

create unique index on inventory (good) where most_recent;

Vous voudrez alors utiliser des déclencheurs pour définir la valeur de most_recent sur false lorsqu'une nouvelle ligne pour un bien est insérée. Cela ajoute plus de complexité et plus de risques de bugs, mais c'est utile.

Encore une fois, cela dépend en grande partie de la mise en place d'index appropriés. Pour les requêtes de date les plus récentes, vous devriez probablement avoir un index sur la date, et éventuellement un index multi-colonnes commençant par la date et incluant vos critères de jointure.

Mise à jour Selon le commentaire d'Erwin ci-dessous, il semble que j'ai mal compris cela. En relisant la question, je ne sais pas du tout ce qui est demandé. Je veux mentionner dans la mise à jour quel est le problème potentiel que je vois et pourquoi cela laisse ce sujet flou.

La conception de la base de données proposée n'a pas vraiment d'IME à utiliser avec ERP et les systèmes comptables. Cela fonctionnerait dans un modèle de tarification hypothétique parfait où tout ce qui est vendu un jour donné d'un produit donné a le même prix. Cependant ce n'est pas toujours le cas. Ce n'est même pas le cas pour des choses comme les échanges de devises (bien que certains modèles prétendent que c'est le cas). Si c'est un exemple artificiel, ce n'est pas clair. S'il s'agit d'un exemple réel, il y en a plus gros problèmes avec la conception au niveau des données. Je vais supposer ici que c'est un exemple réel.

Vous ne pouvez pas supposer que la date spécifie à elle seule le prix d'un bien donné. Les prix de toute entreprise peuvent être négociés par contrepartie et parfois même par transaction. Pour cette raison, vous devez vraiment stocker le prix dans le tableau qui gère réellement l'inventaire à l'intérieur ou à l'extérieur (le tableau d'inventaire). Dans un tel cas, votre tableau date/biens/prix spécifie simplement un prix de base qui peut être sujet à changement en fonction de la négociation. Dans un tel cas, ce problème passe d'un problème de rapport à un problème transactionnel et fonctionnant sur une ligne de chaque table à la fois. Par exemple, vous pouvez ensuite rechercher le prix par défaut pour un produit donné un jour donné comme suit:

 SELECT price 
   FROM prices p
   JOIN goods g ON p.good = g.good
  WHERE g.id = 123 AND p."date" >= '2013-03-01'
  ORDER BY p."date" ASC LIMIT 1;

Avec un indice sur les prix (bon, date) cela va bien fonctionner.

Si c'est un exemple artificiel, peut-être que quelque chose de plus proche de ce sur quoi vous travaillez serait utile.

5
Chris Travers

Une autre façon serait d'utiliser la fonction de fenêtre lead() pour obtenir la plage de dates pour chaque ligne du prix de la table, puis d'utiliser between pour rejoindre l'inventaire. Je l'ai effectivement utilisé dans la vraie vie, mais principalement parce que c'était ma première idée comment résoudre ce problème.

with cte as (
  select
    good,
    price,
    date,
    coalesce(lead(date) over(partition by good order by date) - 1
            ,Now()::date) as ndate
  from
    price
)

select * from inventory i join cte on
  (i.good = cte.good and i.date between cte.date and cte.ndate)

SqlFiddle

3
Tomas Greif

Utilisez une jointure de l'inventaire au prix avec des conditions de jointure qui limitent les commandes de la tabulation des prix à celles qui sont à la date de l'inventaire ou avant, puis extrayez la date maximale et où la date est la date la plus élevée de ce sous-ensemble

Donc pour votre prix d'inventaire:

 Select i.date, p.Date pricingDate,
    i.good, quantity, price        
 from inventory I join price p 
    on p.good = i.good
        And p.Date = 
           (Select Max(Date from price
            where good = i.good
               and date <= i.Date)

Si le prix d'un bien spécifié a changé plusieurs fois le même jour et que vous n'avez vraiment que des dates et aucune heure dans ces colonnes, vous devrez peut-être appliquer plus de restrictions sur les jointures pour sélectionner un seul des enregistrements de changement de prix.

1
Charles Bretana