Armazenar milhões de linhas de dados desnomalizados ou alguma mágica de SQL?

8

Minha experiência com DBA não vai muito além do simples armazenamento e recuperação de dados no estilo CMS - portanto, essa pode ser uma pergunta boba, não sei!

Tenho um problema no qual preciso pesquisar ou calcular preços de férias para um determinado tamanho de grupo e um certo número de dias dentro de um determinado período de tempo. Por exemplo:

Quanto custa um quarto de hotel para 2 pessoas por 4 noites a qualquer momento em janeiro?

Tenho dados de preços e disponibilidade para, digamos, 5000 hotéis armazenados da seguinte forma:

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

Com esta tabela, eu posso fazer uma consulta assim:

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;

resultados

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

A HAVINGcláusula aqui garante que haja uma entrada para todos os dias entre minhas datas desejadas e que tenha os espaços disponíveis. ie O hotel 456 tinha 1 espaço disponível em Jan2, a cláusula HAVING retornaria 3, portanto não obtemos um resultado para o hotel 456.

Por enquanto, tudo bem.

No entanto, existe uma maneira de descobrir todos os períodos de 4 noites em janeiro em que há espaço disponível? Poderíamos repetir a consulta 27 vezes - incrementando as datas a cada vez, o que parece um pouco estranho. Ou outra maneira poderia ser armazenar todas as combinações possíveis em uma tabela de pesquisa como esta:

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 assim por diante. Teríamos que limitar o número máximo de noites e o número máximo de pessoas que procuraríamos - por exemplo, noites máximas = 28, pessoas máximas = 10 (limitado ao número de espaços disponíveis para esse período definido a partir dessa data).

Para um hotel, isso pode nos dar 28 * 10 * 365 = 102000 resultados por ano. 5000 hotéis = resultados de 500 milhões!

Mas teríamos uma consulta muito simples para encontrar a estadia mais barata de 4 noites em janeiro para 2 pessoas:

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 uma maneira de executar essa consulta na tabela inicial sem precisar gerar a tabela de pesquisa de 500m de linha !? por exemplo, gerar os 27 resultados possíveis em uma tabela temporária ou em alguma outra mágica de consulta interna?

No momento, todos os dados são mantidos em um banco de dados do Postgres - se necessário, para isso, podemos movê-los para algo mais adequado? Não tenho certeza se este tipo de consulta se encaixa nos padrões de mapa / redução para DBs no estilo NoSQL ...

Guy Bowden
fonte

Respostas:

6

Você pode fazer muito com as funções da janela . Apresentando duas soluções : uma com e outra sem visão materializada.

Caso de teste

Com base nesta tabela:

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

Os dias por hotel_iddevem ser únicos (impostos por PK aqui) ou o restante é inválido.

Índice de várias colunas para a tabela base:

CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);

Observe a ordem inversa em comparação com a PK. Você provavelmente precisará dos dois índices; para a consulta a seguir, o segundo índice é essencial. Explicação detalhada:

Consulta direta sem 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;

Veja também a variante do @ ypercube comlag() , que pode substituir day_cte day_diffcom uma única verificação.

Quão?

  • Na subconsulta, considere apenas os dias dentro do período ("em janeiro" significa que o último dia está incluído no período).

  • O quadro para as funções da janela abrange a linha atual mais as próximas num_nights - 1( 4 - 1 = 3) linhas (dias). Calcule a diferença de dias , a contagem de linhas e o mínimo de espaços para garantir que o intervalo seja longo o suficiente , sem intervalos e sempre tenha espaços suficientes .

    • Infelizmente, a cláusula de quadro das funções da janela não aceita valores dinâmicos, portanto não pode ser parametrizada para uma instrução preparada.ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING`
  • Elaborei cuidadosamente todas as funções da janela na subconsulta para reutilizar a mesma janela, usando uma única etapa de classificação.

  • O preço resultante sum_pricejá é multiplicado pelo número de espaços solicitados.

Com MATERIALIZED VIEW

Para evitar inspecionar muitas linhas sem chance de êxito, salve apenas as colunas necessárias mais três valores calculados redundantes da tabela base. Verifique se o MV está atualizado. Se você não estiver familiarizado com o conceito, leia o manual primeiro .

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 armazena o primeiro dia de cada intervalo contínuo para dois propósitos:

    • para marcar um conjunto de linhas como membros de um intervalo comum
    • para mostrar o início do intervalo para outros fins possíveis.
  • range_lené o número de dias no intervalo sem intervalos.
    max_spacesé o máximo de espaços abertos no intervalo.

    • Ambas as colunas são usadas para excluir linhas impossíveis da consulta imediatamente.
  • Eu converto ambos para smallint(máximo. 32768 deve ser suficiente para ambos) para otimizar o armazenamento: apenas 52 bytes por linha (incluindo cabeçalho de tupla de pilha e identificador de item). Detalhes:

Índice de várias colunas para MV:

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

Consulta baseada em 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;

Isso é mais rápido que a consulta na tabela, porque mais linhas podem ser eliminadas imediatamente. Novamente, o índice é essencial. Como as partições não têm intervalos aqui, a verificação day_cté suficiente.

SQL Fiddle demonstrando ambos .

Uso repetido

Se você usá-lo muito, eu criaria uma função SQL e passaria apenas parâmetros. Ou uma função PL / pgSQL com SQL dinâmico e EXECUTEpara permitir a adaptação da cláusula de quadro.

Alternativa

Os tipos de intervalo date_rangepara armazenar intervalos contínuos em uma única linha podem ser uma alternativa - complicada no seu caso, com possíveis variações de preços ou espaços por dia.

Palavras-chave:

Erwin Brandstetter
fonte
@GuyBowden: Melhor é o inimigo do bem. Considere a resposta amplamente reescrita.
Erwin Brandstetter
3

Outra maneira, usando a LAG()função:

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 ;

Teste em: SQL-Fiddle

ypercubeᵀᴹ
fonte
Solução muito elegante! Provavelmente muito rápido com um índice de várias colunas (spaces, day), talvez até um índice de cobertura (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;

deve obter o resultado que você está procurando sem a necessidade de estruturas extras, embora, dependendo do tamanho dos dados de entrada, da sua estrutura de índice e da luminosidade do planejador de consultas, a consulta interna possa resultar em um spool para o disco. Você pode achar que é suficientemente eficiente. Advertência: meu conhecimento é sobre o MS SQL Server e os recursos de seu planejador de consultas, portanto , a sintaxe acima pode precisar de duas vezes, apenas nos nomes das funções (o ypercube ajustou a sintaxe para que, provavelmente, seja compatível com o postgres agora, consulte o histórico de respostas da variante TSQL) .

O exposto acima encontrará estadias que começam em janeiro, mas continuam em fevereiro. A adição de uma cláusula extra ao teste de data (ou o ajuste do valor final da data) lidará facilmente com isso, se não for desejável.

David Spillett
fonte
1

Independentemente do HotelID, você pode usar uma tabela de soma, com uma coluna calculada, da seguinte forma:

SummingTable Rev3

Não há chaves primárias ou estrangeiras nesta tabela, pois ela é usada apenas para calcular rapidamente várias combinações de valores. Se você precisar ou desejar mais de um valor calculado, crie uma nova visualização com um novo nome de visualização para cada valor do mês em combinação com cada um dos valores PP de pessoas e preços:

EXEMPLO DE CÓDIGO 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

Por fim, junte-se à vista do HotelID. Para fazer isso, você precisará armazenar uma lista de todos os IDs do hotel em SummingTable (fiz na tabela acima), mesmo que o HotelID não seja usado para calcular na exibição. Igual a:

MAIS CÓDIGO PSEUDO

SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID
eyoung100
fonte