Stocker des millions de lignes de données dénomalisées ou de la magie SQL?


8

Mon expérience DBA ne va pas beaucoup plus loin que le simple stockage + récupération de données de style CMS - donc cela peut être une question stupide, je ne sais pas!

J'ai un problème pour lequel je dois rechercher ou calculer les prix des vacances pour une certaine taille de groupe et un certain nombre de jours dans une certaine période de temps. Par exemple:

Combien coûte une chambre d'hôtel pour 2 personnes pour 4 nuits à tout moment en janvier?

J'ai des données de prix et de disponibilité pour, disons, 5000 hôtels stockés comme suit:

Hotel ID | Date | Spaces | Price PP
-----------------------------------
     123 | Jan1 | 5      | 100
     123 | Jan2 | 7      | 100
     123 | Jan3 | 5      | 100
     123 | Jan4 | 3      | 100
     123 | Jan5 | 5      | 100
     123 | Jan6 | 7      | 110
     456 | Jan1 | 5      | 120
     456 | Jan2 | 1      | 120
     456 | Jan3 | 4      | 130
     456 | Jan4 | 3      | 110
     456 | Jan5 | 5      | 100
     456 | Jan6 | 7      |  90

Avec ce tableau, je peux faire une requête comme ceci:

SELECT hotel_id, sum(price_pp)
FROM hotel_data
WHERE
    date >= Jan1 and date <= Jan4
    and spaces >= 2
GROUP BY hotel_id
HAVING count(*) = 4;

résultats

hotel_id | sum
----------------
     123 | 400

La HAVINGclause ici garantit qu'il y a une entrée pour chaque jour entre mes dates souhaitées qui dispose des espaces disponibles. c'est à dire. L'hôtel 456 avait 1 espace disponible le 2 janvier, la clause HAVING en retournerait 3, donc nous n'avons pas de résultat pour l'hôtel 456.

Jusqu'ici tout va bien.

Cependant, existe-t-il un moyen de connaître toutes les 4 périodes de nuit en janvier où il y a de l'espace disponible? Nous pourrions répéter la requête 27 fois - en incrémentant les dates à chaque fois, ce qui semble un peu gênant. Ou une autre solution pourrait être de stocker toutes les combinaisons possibles dans une table de recherche comme ceci:

Hotel ID | total price pp | num_people | num_nights | start_date
----------------------------------------------------------------
     123 |            400 | 2          | 4          | Jan1
     123 |            400 | 2          | 4          | Jan2
     123 |            400 | 2          | 4          | Jan3
     123 |            400 | 3          | 4          | Jan1
     123 |            400 | 3          | 4          | Jan2
     123 |            400 | 3          | 4          | Jan3

Etc. Nous devons limiter le nombre maximum de nuits et le nombre maximum de personnes que nous recherchons - par exemple, nuits max = 28, personnes max = 10 (limité au nombre de places disponibles pour cette période définie à partir de cette date).

Pour un hôtel, cela pourrait nous donner 28 * 10 * 365 = 102000 résultats par an. 5000 hôtels = 500 m de résultats!

Mais nous aurions une requête très simple pour trouver le séjour de 4 nuits le moins cher à Jan pour 2 personnes:

SELECT
hotel_id, start_date, price
from hotel_lookup
where num_people=2
and num_nights=4
and start_date >= Jan1
and start_date <= Jan27
order by price
limit 1;

Existe-t-il un moyen d'effectuer cette requête sur la table initiale sans avoir à générer la table de recherche des lignes de 500 m!? par exemple générer les 27 résultats possibles dans une table temporaire ou une autre magie de requête interne?

Pour le moment, toutes les données sont conservées dans une base de données Postgres - si besoin est, nous pouvons déplacer les données vers quelque chose de plus approprié? Je ne sais pas si ce type de requête correspond aux modèles de carte / réduction pour les bases de données de style NoSQL ...

Réponses:


6

Vous pouvez faire beaucoup avec les fonctions de fenêtre . Présentation de deux solutions : une avec et une sans vue matérialisée.

Cas de test

S'appuyant sur ce tableau:

CREATE TABLE hotel_data (
   hotel_id int
 , day      date  -- using "day", not "date"
 , spaces   int
 , price    int
 , PRIMARY KEY (hotel_id, day)  -- provides essential index automatically
);

Les jours par hotel_iddoivent être uniques (appliqués par PK ici), sinon le reste n'est pas valide.

Index multicolonne pour la table de base:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);

Notez l'ordre inversé par rapport au PK. Vous aurez probablement besoin des deux index, pour la requête suivante, le 2ème index est essentiel. Explication détaillée:

Requête directe sans MATERIALIZED VIEW

SELECT hotel_id, day, sum_price
FROM  (
   SELECT hotel_id, day, price, spaces
        , sum(price)      OVER w * 2   AS sum_price
        , min(spaces)     OVER w       AS min_spaces
        , last_value(day) OVER w - day AS day_diff
        , count(*)        OVER w       AS day_ct
   FROM   hotel_data
   WHERE  day BETWEEN '2014-01-01'::date AND '2014-01-31'::date
   AND    spaces >= 2
   WINDOW w AS (PARTITION BY hotel_id ORDER BY day
                ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING) -- adapt to nights - 1
   ) sub
WHERE  day_ct = 4
AND    day_diff = 3  -- make sure there is not gap
AND    min_spaces >= 2
ORDER  BY sum_price, hotel_id, day;
-- LIMIT 1 to get only 1 winner;

Voir aussi la variante de @ ypercube aveclag() , qui peut remplacer day_ctet day_diffavec une seule vérification.

Comment?

  • Dans la sous-requête, ne considérez que les jours dans votre délai ("en janvier" signifie que le dernier jour est inclus dans le délai).

  • Le cadre des fonctions de fenêtre s'étend sur la ligne actuelle plus les lignes suivantes num_nights - 1( 4 - 1 = 3) (jours). Calculez la différence en jours , le nombre de lignes et le minimum d'espaces pour vous assurer que la plage est suffisamment longue , sans espace et a toujours suffisamment d'espaces .

    • Malheureusement, la clause frame des fonctions de fenêtre n'accepte pas les valeurs dynamiques, elle ne peut donc pas être paramétrée pour une instruction préparée.ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING`
  • J'ai soigneusement rédigé toutes les fonctions de fenêtre de la sous-requête pour réutiliser la même fenêtre, en utilisant une seule étape de tri.

  • Le prix qui en résulte sum_priceest déjà multiplié par le nombre de places demandées.

Avec MATERIALIZED VIEW

Pour éviter d'inspecter de nombreuses lignes sans chance de succès, enregistrez uniquement les colonnes dont vous avez besoin, plus trois valeurs calculées redondantes de la table de base. Assurez-vous que le MV est à jour. Si vous n'êtes pas familier avec le concept, lisez d'abord le manuel .

CREATE MATERIALIZED VIEW mv_hotel AS
SELECT hotel_id, day
     , first_value(day) OVER (w ORDER BY day) AS range_start
     , price, spaces
     ,(count(*)    OVER w)::int2 AS range_len
     ,(max(spaces) OVER w)::int2 AS max_spaces

FROM  (
   SELECT *
        , day - row_number() OVER (PARTITION BY hotel_id ORDER BY day)::int AS grp
   FROM   hotel_data
   ) sub1
WINDOW w AS (PARTITION BY hotel_id, grp);
  • range_start stocke le premier jour de chaque plage continue à deux fins:

    • pour marquer un ensemble de lignes en tant que membres d'une plage commune
    • pour montrer le début de la gamme à d'autres fins possibles.
  • range_lenest le nombre de jours dans la plage sans espace.
    max_spacesest le maximum d'espaces ouverts de la plage.

    • Les deux colonnes sont utilisées pour exclure immédiatement les lignes impossibles de la requête.
  • J'ai converti les deux en smallint(max. 32768 devrait être suffisant pour les deux) pour optimiser le stockage: seulement 52 octets par ligne (y compris l'en-tête de tuple de tas et l'identificateur d'élément). Détails:

Index multicolonne pour MV:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (range_len, max_spaces, day);

Requête basée sur MV

SELECT hotel_id, day, sum_price
FROM  (
   SELECT hotel_id, day, price, spaces
        , sum(price)      OVER w * 2   AS sum_price
        , min(spaces)     OVER w       AS min_spaces
        , count(*)        OVER w       AS day_ct
   FROM   mv_hotel
   WHERE  day BETWEEN '2014-01-01'::date AND '2014-01-31'::date
   AND    range_len >= 4   -- exclude impossible rows
   AND    max_spaces >= 2  -- exclude impossible rows
   WINDOW w AS (PARTITION BY hotel_id, range_start ORDER BY day
                ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING) -- adapt to $nights - 1
   ) sub
WHERE  day_ct = 4
AND    min_spaces >= 2
ORDER  BY sum_price, hotel_id, day;
-- LIMIT 1 to get only 1 winner;

Ceci est plus rapide que la requête sur la table car plus de lignes peuvent être éliminées immédiatement. Encore une fois, l'indice est essentiel. Étant donné que les partitions sont sans espace ici, la vérification day_ctest suffisante.

SQL Fiddle démontrant les deux .

Utilisation répétée

Si vous l'utilisez beaucoup, je créerais une fonction SQL et ne passerais que des paramètres. Ou une fonction PL / pgSQL avec SQL dynamique et EXECUTEpermettant d'adapter la clause frame.

Alternative

Les types de plage avec date_rangepour stocker des plages continues sur une seule ligne peuvent être une alternative - compliquée dans votre cas avec des variations potentielles de prix ou d'espaces par jour.

En relation:


@GuyBowden: Mieux est l'ennemi du bien. Considérez la réponse largement réécrite.
Erwin Brandstetter

3

Une autre façon, en utilisant la LAG()fonction:

WITH x AS
  ( SELECT hotel_id, day, 
           LAG(day, 3) OVER (PARTITION BY hotel_id 
                             ORDER BY day)
              AS day_start,
           2 * SUM(price) OVER (PARTITION BY hotel_id 
                                ORDER BY day
                                ROWS BETWEEN 3 PRECEDING 
                                         AND CURRENT ROW)
              AS sum_price
    FROM hotel_data
    WHERE spaces >= 2
   -- AND day >= '2014-01-01'::date      -- date restrictions 
   -- AND day <  '2014-02-01'::date      -- can be added here
  )
SELECT hotel_id, day_start, sum_price
FROM x
WHERE day_start = day - 3 ;

Testez à: SQL-Fiddle


Solution très élégante! Probablement très rapide avec un index multicolonne activé (spaces, day), peut-être même un index couvrant (spaces, day, hotel_id, price).
Erwin Brandstetter

3
SELECT hotel, totprice
FROM   (
       SELECT r.hotel, SUM(r.pricepp)*@spacesd_needed AS totprice
       FROM   availability AS a
       JOIN   availability AS r 
              ON r.date BETWEEN a.date AND a.date + (@days_needed-1) 
              AND a.hotel = r.hotel
              AND r.spaces >= @spaces_needed
       WHERE  a.date BETWEEN '2014-01-01' AND '2014-01-31'
       GROUP BY a.date, a.hotel
       HAVING COUNT(*) >= @days_needed
       ) AS matches
ORDER BY totprice ASC
LIMIT 1;

devrait vous donner le résultat que vous recherchez sans avoir besoin de structures supplémentaires, bien qu'en fonction de la taille des données d'entrée, de votre structure d'index et de la luminosité du planificateur de requêtes, la requête interne peut entraîner une spoule sur le disque. Vous pouvez cependant le trouver suffisamment efficace. Mise en garde: mon expertise concerne MS SQL Server et les capacités de son planificateur de requêtes, de sorte que la syntaxe ci-dessus peut nécessiter des tweks, uniquement dans les noms de fonction (ypercube a ajusté la syntaxe afin qu'elle soit probablement compatible postgres maintenant, voir l'historique des réponses pour la variante TSQL) .

Ce qui précède trouvera des séjours qui commencent en janvier mais se poursuivent en février. L'ajout d'une clause supplémentaire au test de date (ou l'ajustement de la valeur de date de fin entrant) traitera facilement cela si cela n'est pas souhaitable.


1

Indépendamment de HotelID, vous pouvez utiliser un tableau de sommation, avec une colonne calculée, comme suit:

SummingTable Rev3

Il n'y a pas de clés primaires ou étrangères dans ce tableau, car il n'est utilisé que pour calculer rapidement plusieurs combinaisons de valeurs. Si vous avez besoin ou souhaitez plusieurs valeurs calculées, créez une nouvelle vue avec un nouveau nom de vue pour chacune des valeurs du mois en combinaison avec chacune des valeurs PP de personnes et de prix:

EXEMPLE DE CODE PSEUDO

CREATE VIEW NightPeriods2People3DaysPricePP400 AS (
SELECT (DaysInverse - DaysOfMonth) AS NumOfDays, (NumberOfPeople * PricePP * NumOfDays) AS SummedColumn 
FROM SummingTable
WHERE NumberOfPeople = 2) AND (DaysInverse = 4) AND (DaysOfMonth = 1) AND (PricePP = 400)
)

SummedColumn = 2400

Enfin, joignez la vue à l'ID d'hôtel. Pour ce faire, vous devrez stocker une liste de tous les HotelID dans SummingTable (je l'ai fait dans le tableau ci-dessus), même si HotelID n'est pas utilisé pour calculer dans la vue. Ainsi:

PLUS DE CODE PSEUDO

SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.