Archivia milioni di righe di dati denomalizzati o qualche magia SQL?


8

La mia esperienza DBA non va molto oltre la semplice memorizzazione + il recupero di dati in stile CMS - quindi questa potrebbe essere una domanda sciocca, non lo so!

Ho un problema per cui devo cercare o calcolare i prezzi delle vacanze per una determinata dimensione del gruppo e un certo numero di giorni in un determinato periodo di tempo. Per esempio:

Quanto costa una camera d'albergo per 2 persone per 4 notti in qualsiasi momento a gennaio?

Ho dei dati su prezzi e disponibilità per, per esempio, 5000 hotel archiviati in questo modo:

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

Con questa tabella, posso fare una query in questo modo:

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;

risultati

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

La HAVINGclausola qui si assicura che ci sia una voce per ogni singolo giorno tra le mie date desiderate che ha gli spazi disponibili. vale a dire. Hotel 456 aveva 1 spazio disponibile su Jan2, la clausola HAVING restituiva 3, quindi non otteniamo un risultato per hotel 456.

Fin qui tutto bene.

Tuttavia, c'è un modo per scoprire tutti i periodi di 4 notti a gennaio dove c'è spazio disponibile? Potremmo ripetere la query 27 volte - incrementando le date ogni volta, il che sembra un po 'imbarazzante. O un altro modo potrebbe essere quello di memorizzare tutte le possibili combinazioni in una tabella di ricerca in questo modo:

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

E così via. Dovremmo limitare il numero massimo di notti e il numero massimo di persone che cercheremmo, ad esempio numero massimo di notti = 28, numero massimo di persone = 10 (limitato al numero di spazi disponibili per quel determinato periodo a partire da quella data).

Per un hotel, questo potrebbe darci 28 * 10 * 365 = 102000 risultati all'anno. 5000 hotel = 500 milioni di risultati!

Ma avremmo una domanda molto semplice per trovare il soggiorno di 4 notti più economico a Jan per 2 persone:

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;

Esiste un modo per eseguire questa query sulla tabella iniziale senza dover generare la tabella di ricerca delle righe da 500 m !? ad esempio, generare i 27 possibili risultati in una tabella temporanea o in qualche altra magia interiore simile?

Al momento tutti i dati sono conservati in un DB Postgres - se necessario per questo scopo, possiamo trasferirli in qualcos'altro più adatto? Non sono sicuro se questo tipo di query si adatta alla mappa / riduce i modelli per i DB di stile NoSQL ...

Risposte:


6

Puoi fare molto con le funzioni della finestra . Presentando due soluzioni : una con e una senza vista materializzata.

Caso di prova

Sulla base di questa tabella:

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
);

I giorni per hotel_iddevono essere unici (applicati da PK qui), altrimenti il ​​resto non è valido.

Indice a più colonne per la tabella di base:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);

Nota l'ordine inverso rispetto al PK. Probabilmente avrai bisogno di entrambi gli indici, per la seguente query, il secondo indice è essenziale. Spiegazione dettagliata:

Query diretta senza 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;

Vedi anche la variante di @ ypercube conlag() , che può sostituire day_cte day_diffcon un solo controllo.

Come?

  • Nella sottoquery, considera solo i giorni nel tuo intervallo di tempo ("in gennaio" significa che l'ultimo giorno è incluso nell'intervallo di tempo).

  • Il frame per le funzioni della finestra comprende la riga corrente più le righe successive num_nights - 1( 4 - 1 = 3) (giorni). Calcola la differenza in giorni , il conteggio delle righe e il minimo di spazi per assicurarti che l'intervallo sia abbastanza lungo , senza gap e che abbia sempre abbastanza spazi .

    • Sfortunatamente, la clausola frame delle funzioni della finestra non accetta valori dinamici, quindi non può essere parametrizzata per un'istruzione preparata.ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING`
  • Ho accuratamente redatto tutte le funzioni della finestra nella sottoquery per riutilizzare la stessa finestra, usando un singolo passo di ordinamento.

  • Il prezzo risultante sum_priceè già moltiplicato per il numero di spazi richiesti.

Con MATERIALIZED VIEW

Per evitare di ispezionare molte righe senza possibilità di successo, salvare solo le colonne necessarie più tre valori ridondanti calcolati dalla tabella di base. Assicurarsi che MV sia aggiornato. Se non si ha familiarità con il concetto, leggere prima il manuale .

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 memorizza il primo giorno di ogni intervallo continuo per due scopi:

    • per contrassegnare un set di righe come membri di un intervallo comune
    • per mostrare l'inizio dell'intervallo per possibili altri scopi.
  • range_lenè il numero di giorni nell'intervallo gapless.
    max_spacesè il massimo degli spazi aperti nell'intervallo.

    • Entrambe le colonne vengono utilizzate per escludere immediatamente le righe impossibili dalla query.
  • Ho smallinteseguito il cast di entrambi (max. 32768 dovrebbe essere sufficiente per entrambi) per ottimizzare lo spazio di archiviazione: solo 52 byte per riga (incl. Intestazione tupla heap e identificatore oggetto). Dettagli:

Indice a più colonne per MV:

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

Query basata su 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;

Questo è più veloce della query nella tabella perché è possibile eliminare immediatamente più righe. Ancora una volta, l'indice è essenziale. Poiché qui le partizioni sono senza gap , il controllo day_ctè sufficiente.

SQL Fiddle dimostrando entrambi .

Uso ripetuto

Se lo usi molto, creerei una funzione SQL e passerei solo i parametri. O una funzione PL / pgSQL con SQL dinamico e EXECUTEper consentire l'adattamento della clausola frame.

Alternativa

I tipi di intervallo con cui date_rangememorizzare intervalli continui in una singola riga potrebbero essere un'alternativa, complicata nel tuo caso con potenziali variazioni di prezzi o spazi al giorno.

Relazionato:


@GuyBowden: meglio è il nemico del bene. Considera la risposta in gran parte riscritta.
Erwin Brandstetter,

3

Un altro modo, usando la LAG()funzione:

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 ;

Test su: SQL-Fiddle


Soluzione molto elegante! Probabilmente molto veloce con un indice multicolonna acceso (spaces, day), forse anche un indice di copertura attivo (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;

dovrebbe ottenere il risultato che stai cercando senza la necessità di ulteriori strutture, anche se a seconda della dimensione dei dati di input, della struttura dell'indice e della luminosità del pianificatore di query, la query interna può comportare lo spooling su disco. Tuttavia potresti trovarlo sufficientemente efficiente. Avvertenza: la mia esperienza è con MS SQL Server e le sue capacità di planner delle query, quindi la sintassi sopra potrebbe richiedere delle modifiche se solo nei nomi delle funzioni (ypercube ha modificato la sintassi in modo che sia presumibilmente postgres compatibile ora, vedere la cronologia delle risposte per la variante TSQL) .

Quanto sopra troverà soggiorni che iniziano a gennaio ma proseguono fino a febbraio. L'aggiunta di una clausola aggiuntiva al test della data (o la modifica del valore della data di fine in corso) lo affronterà facilmente se non è desiderabile.


1

Indipendentemente da HotelID, è possibile utilizzare una tabella di riepilogo, con una colonna calcolata, in questo modo:

SummingTable Rev3

Non ci sono chiavi primarie o esterne in questa tabella, poiché viene utilizzata solo per calcolare rapidamente più combinazioni di valori. Se hai bisogno o desideri più di un valore calcolato, crea una nuova vista con un nuovo nome per ciascuno dei valori del mese in combinazione con ciascuno dei valori PP Persone e Prezzo:

ESEMPIO DI CODICE 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

Infine, unisciti alla vista per l'HotelID. Per fare ciò dovrai archiviare un elenco di tutti gli ID hotel in SummingTable (l'ho fatto nella tabella sopra), anche se HotelID non viene utilizzato per calcolare nella vista. Così:

ALTRO CODICE PSEUDO

SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.