Armazenar milhões de linhas de dados desanomalizados ou alguma mágica SQL?
-
11-12-2019 - |
Pergunta
Minha experiência como DBA não vai muito além do simples armazenamento + recuperação de dados no estilo CMS - então essa pode ser uma pergunta boba, não sei!
Tenho um problema em que preciso pesquisar ou calcular preços de férias para um determinado tamanho de grupo e um determinado 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 em qualquer momento de janeiro?
Tenho dados de preços e disponibilidade de, digamos, 5.000 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, 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
O HAVING
A cláusula aqui garante que haja uma entrada para cada dia entre as datas desejadas que tenha vagas disponíveis.ou seja.O hotel 456 tinha 1 vaga disponível em 2 de janeiro, a cláusula HAVING retornaria 3, portanto não obtemos resultado para o hotel 456.
Até agora tudo bem.
Porém, há como saber todos os períodos de 4 noites de janeiro em que há vagas disponíveis?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 ex.máximo de noites = 28, máximo de pessoas = 10 (limitado ao número de vagas disponíveis para o período definido a partir dessa data).
Para um hotel, isso poderia nos dar 28*10*365=102.000 resultados por ano.5.000 hotéis = 500 milhões de resultados!
Mas teríamos uma consulta muito simples para encontrar a estadia de 4 noites mais barata 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 realizar esta consulta na tabela inicial sem precisar gerar a tabela de pesquisa de 500m de linhas!?por exemplo.gerar os 27 resultados possíveis em uma tabela temporária ou alguma outra mágica de consulta interna?
No momento, todos os dados são mantidos em um banco de dados Postgres - se for necessário para esse fim, podemos mover os dados para outro lugar mais adequado?Não tenho certeza se esse tipo de consulta se ajusta aos padrões de mapeamento/redução para bancos de dados de estilo NoSQL...
Solução
Você pode fazer muito com funções de janela.Apresentando duas soluções:um com e outro 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
);
Dias por hotel_id
devemos ser exclusivo (aplicado por PK aqui), ou o resto é inválido.
Índice de múltiplas colunas para tabela base:
CREATE INDEX mv_hotel_mult_idx ON mv_hotel (day, hotel_id);
Observe a ordem invertida em comparação com o PK.Você provavelmente precisará de ambos os í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 Variante de @ypercube com lag()
, que pode substituir day_ct
e day_diff
com um único cheque.
Explicar
Na subconsulta, considere apenas os dias dentro do seu 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 a próxima
num_nights - 1
(4 - 1 = 3
) linhas (dias).Calcule o diferença em 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 tem espaços suficientes.- Infelizmente, a cláusula frame das funções de janela não aceita valores dinâmicos, então
ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING`
não pode ser parametrizado para uma instrução preparada.
- Infelizmente, a cláusula frame das funções de janela não aceita valores dinâmicos, então
Elaborei cuidadosamente todas as funções de janela na subconsulta para reutilizar a mesma janela, usando um solteiro etapa de classificação.
O preço resultante
sum_price
já está multiplicado pelo número de espaços solicitados.
Com MATERIALIZED VIEW
Para evitar a inspeção de muitas linhas sem chance de sucesso, salve apenas as colunas necessárias mais três valores calculados redundantes da tabela base.Certifique-se de que o MV esteja atualizado.Se você não está 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 duas finalidades:- para marcar um conjunto de linhas como membros de um intervalo comum
- para mostrar o início do intervalo para possíveis outros fins.
range_len
é o número de dias no intervalo sem intervalo.
max_spaces
é o máximo de espaços abertos no intervalo.- Ambas as colunas são usadas para excluir imediatamente linhas impossíveis da consulta.
Eu lancei ambos para
smallint
(máx.32768 deve ser suficiente para ambos) para otimizar o armazenamento:apenas 52 bytes por linha (incl.cabeçalho de tupla de heap e ponteiro de item).Detalhes:
Índice multicoluna 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 são sem intervalos aqui, verificando day_ct
basta.
SQL violino demonstrando ambos.
Uso repetido
Se você usar muito, eu criaria uma função SQL e passaria apenas parâmetros.Ou um PL/pgSQL funcionar com SQL dinâmico e EXECUTE
para permitir a adaptação da cláusula frame.
Alternativa
Tipos de intervalo com date_range
armazenar intervalos contínuos em uma única linha pode ser uma alternativa - complicada no seu caso, com possíveis variações de preços ou espaços por dia.
Respostas relacionadas
Outras dicas
Outra forma, usando o 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
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 fornecer o resultado que você está procurando sem a necessidade de estruturas extras, embora dependendo do tamanho dos dados de entrada, da estrutura do seu índice e do brilho do planejador de consultas, a consulta interna pode resultar em um spool no disco.Você pode achar que é suficientemente eficiente.Embargo:minha experiência é com o MS SQL Server e os recursos de seu planejador de consultas, então a sintaxe acima pode precisar de ajustes apenas nos nomes das funções (ypercube ajustou a sintaxe para que seja presumivelmente compatível com postgres agora, consulte o histórico de respostas para a variante TSQL).
O acima encontrará estadias que começam em janeiro, mas continuam até fevereiro.Adicionar uma cláusula extra ao teste de data (ou ajustar o valor da data final) resolverá facilmente isso, se não for desejável.
Independentemente do HotelID, você poderia usar uma tabela somatória, com uma coluna calculada, assim:
Não há chaves primárias ou estrangeiras nesta tabela, pois ela é usada apenas para calcular rapidamente múltiplas combinações de valores.Se você precisar ou quiser mais de um valor calculado, crie uma nova visualização com um novo nome de visualização para cada um dos valores de mês em combinação com cada um dos valores PP de Pessoas e Preço:
EXEMPLO DE PSEUDO CÓDIGO
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)
)
Coluna Soma = 2400
Por último, junte a visualização ao HotelID.Para fazer isso você precisará armazenar uma lista de todos os HotelIDs em SummingTable (eu fiz na tabela acima), mesmo que HotelID não seja usado para calcular na visualização.Igual a:
MAIS PSEUDO CÓDIGO
SELECT HotelID, NumOfDays, SummedColumn AS Total
FROM NightPeriods2People3DaysPricePP400
INNER JOIN Hotels
ON SummingTable.HotelID = Hotels.HotelID