Como obtenho com eficiência “a linha correspondente mais recente”?

53

Eu tenho um padrão de consulta que deve ser muito comum, mas não sei como escrever uma consulta eficiente para ela. Quero pesquisar as linhas de uma tabela que correspondam à "data mais recente não depois" das linhas de outra tabela.

Eu tenho uma tabela, inventorydigamos, que representa o estoque que eu tenho em um determinado dia.

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

e uma tabela, digamos "preço", que detém o preço de uma mercadoria em um determinado dia

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

Como posso obter com eficiência o preço "mais recente" para cada linha da tabela de inventário, ou seja,

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

Eu conheço uma maneira de fazer isso:

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

e, em seguida, junte-se a essa consulta novamente no inventário. Para tabelas grandes, mesmo a primeira consulta (sem ingressar novamente no inventário) é muito lenta. No entanto, o mesmo problema é resolvido rapidamente se eu simplesmente usar minha linguagem de programação para emitir uma max(price.date) ... where price.date <= date_of_interest ... order by price.date desc limit 1consulta para cada uma date_of_interestda tabela de inventário, então sei que não há impedimento computacional. No entanto, eu preferiria resolver todo o problema com uma única consulta SQL, porque isso permitiria um processamento SQL adicional no resultado da consulta.

Existe uma maneira padrão de fazer isso com eficiência? Parece que ele deve aparecer com frequência e que deve haver uma maneira de escrever uma consulta rápida para ele.

Estou usando o Postgres, mas uma resposta SQL genérica seria apreciada.

Tom Ellis
fonte
3
Votou para ser migrado para o DBA.SE por ser uma questão de eficiência. Poderíamos escrever a consulta de algumas maneiras diferentes, mas isso não a tornará muito mais rápido.
precisa saber é o seguinte
5
Você realmente precisa de todos os bens para todos os dias a partir de uma única consulta? Parece um requisito improvável? O mais comum é recuperar preços para uma data específica ou o (s) preço (s) para um bem específico (em uma data específica). Essas consultas alternativas poderiam se beneficiar muito mais facilmente de índices (apropriados). Também precisamos saber: cardinalidades (quantas linhas em cada tabela?), A definição completa da tabela incl. tipos de dados, restrições, índices, ... (uso \d tblem psql), sua versão do Postgres e min. / máx. número de preços por bem.
Erwin Brandstetter
@ErwinBrandstetter Você está me pedindo para aceitar uma resposta? Não estou realmente qualificado para saber qual é o melhor, mas como o seu tem mais votos, fico feliz em aceitá-lo.
Tom Ellis
Aceite apenas se responder à sua pergunta ou funcionar para você. Você pode até deixar um comentário sobre como proceder, se isso puder ajudar casos relacionados. Se você acha que sua pergunta não foi respondida, informe-nos.
Erwin Brandstetter
11
Tenho que me desculpar, porque, apesar de ter recebido respostas excelentes, não estou mais trabalhando no problema que provocou a pergunta, de modo que não estou em lugar de julgar qual é a melhor resposta ou, se é que alguma delas. são realmente adequados para o meu caso de uso (como era). Se houver alguma ettiqueta DBA.Stackexchange que devo seguir neste caso, informe-me.
Tom Ellis

Respostas:

42

Ele depende muito das circunstâncias e necessidades exatas. Considere o meu comentário para a pergunta .

Solução simples

Com DISTINCT ONno 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;

Resultado ordenado.

Ou NOT EXISTSno SQL padrão (funciona com todos os RDBMS que eu conheço):

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

Mesmo resultado, mas com ordem de classificação arbitrária - a menos que você adicione ORDER BY.
Dependendo da distribuição dos dados, requisitos e índices exatos, qualquer um deles pode ser mais rápido.
Geralmente, DISTINCT ONé o vencedor e você obtém um resultado classificado em cima dele. Mas, em certos casos, outras técnicas de consulta são (muito) mais rápidas ainda. Ver abaixo.

Soluções com subconsultas para calcular valores máx / min são geralmente mais lentas. As variantes com CTEs são geralmente mais lentas, ainda.

Vistas simples (como propostas por outra resposta) não ajudam no desempenho no Postgres.

SQL Fiddle.


Solução adequada

Cordas e agrupamento

Primeiro de tudo, você sofre de um layout de tabela abaixo do ideal. Pode parecer trivial, mas normalizar seu esquema pode percorrer um longo caminho.

A classificação por tipos de caracteres ( text, varchar...) tem que ser feito de acordo com o local - o COLLATION em particular. Provavelmente, seu banco de dados usa algum conjunto local de regras (como, no meu caso de_AT.UTF-8:). Descubra com:

SHOW lc_collate;

Isso torna a classificação e as pesquisas de índice mais lentas . Quanto mais longas as cordas (nomes das mercadorias), pior. Se você realmente não se importa com as regras de agrupamento em sua saída (ou com a ordem de classificação), isso pode ser mais rápido se você adicionar 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;

Observe como adicionei o agrupamento em dois lugares.
Duas vezes mais rápido no meu teste, com 20 mil linhas cada e nomes muito básicos ('good123').

Índice

Se sua consulta deve usar um índice, as colunas com dados de caracteres precisam usar um agrupamento correspondente ( goodno exemplo):

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

Leia os dois últimos capítulos desta resposta relacionada no SO:

Você pode até ter vários índices com diferentes agrupamentos nas mesmas colunas - se também precisar de mercadorias classificadas de acordo com outro agrupamento (ou o padrão) em outras consultas.

Normalizar

Seqüências redundantes (nome de bom) também incham suas tabelas e índices, o que torna tudo ainda mais lento. Com um layout de tabela adequado, você pode evitar a maior parte do problema. Pode ficar assim:

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

As chaves primárias fornecem automaticamente (quase) todos os índices que precisamos.
Dependendo dos detalhes ausentes, um índice de pricevárias colunas ativado com ordem decrescente na segunda coluna pode melhorar o desempenho:

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

Novamente, o agrupamento deve corresponder à sua consulta (veja acima).

No Postgres 9.2 ou posterior, "índices de cobertura" para verificações apenas de índice poderia ajudar um pouco mais - especialmente se suas tabelas mantiverem colunas adicionais, tornando a tabela substancialmente maior que o índice de cobertura.

Essas consultas resultantes são muito mais rápidas:

NÃO EXISTE

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 ON

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.


Soluções mais rápidas

Se isso ainda não for rápido o suficiente, pode haver soluções mais rápidas.

CTE recursiva / JOIN LATERAL/ subconsulta correlacionada

Especialmente para distribuições de dados com muitos preços por bem :

Vista materializada

Se você precisar executar isso com frequência e rapidez, sugiro que você crie uma visualização materializada. Eu acho que é seguro assumir que preços e estoques para datas passadas raramente mudam. Calcule o resultado uma vez e armazene um instantâneo como vista materializada.

O Postgres 9.3+ tem suporte automatizado para visualizações materializadas. Você pode implementar facilmente uma versão básica em versões mais antigas.

Erwin Brandstetter
fonte
3
O price_good_date_desc_idxíndice que você recomenda melhorou drasticamente o desempenho de uma consulta semelhante à minha. Meu plano de consulta passou de um custo 42374.01..42374.86para baixo 0.00..37.12!
Cimmanon
@ cimmanon: Legal! Qual é o seu principal recurso de consulta? NÃO EXISTE? DISTINCT ON? GRUPO POR?
Erwin Brandstetter
Usando DISTINCT ON
cimmanon
6

Para sua informação, usei o mssql 2008, portanto, o Postgres não terá o índice "include". No entanto, o uso da indexação básica mostrada abaixo mudará de junções de hash para junções de mesclagem no Postgres: http://explain.depesz.com/s/eF6 (sem índice) http://explain.depesz.com/s/j9x ( com índice nos critérios de junção)

Proponho dividir sua consulta em duas partes. Primeiro, uma exibição (não destinada a melhorar o desempenho) que pode ser usada em vários outros contextos que representam o relacionamento entre as datas do inventário e as datas dos preços.

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;

Em seguida, sua consulta pode se tornar mais simples e mais fácil de manipular para outros tipos se a consulta (como usar associações à esquerda para encontrar inventário sem datas recentes de preços):

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

Isso produz o seguinte plano de execução: http://sqlfiddle.com/#!3/24f23/1 sem indexação

... Todas as verificações com uma classificação completa. Observe que o custo de desempenho das correspondências de hash ocupa grande parte do custo total ... e sabemos que as varreduras e a classificação da tabela são lentas (em comparação com a meta: o índice procura).

Agora, adicione índices básicos para ajudar nos critérios usados ​​em sua associação (não reivindico que sejam índices ideais, mas eles ilustram o ponto): http://sqlfiddle.com/#!3/5ec75/1 com indexação básica

Isso mostra melhorias. As operações de loop aninhado (junção interna) não ocupam mais nenhum custo total relevante para a consulta. O restante do custo agora está distribuído entre as buscas de índice (uma varredura do inventário porque estamos puxando cada linha do inventário). Mas podemos melhorar ainda mais porque a consulta puxa quantidade e preço. Para obter esses dados, após avaliar o critério de junção, é necessário realizar pesquisas.

A iteração final usa "include" nos índices para facilitar o deslizamento do plano e obter os dados adicionais solicitados diretamente do próprio índice. Portanto, as pesquisas se foram: http://sqlfiddle.com/#!3/5f143/1 insira a descrição da imagem aqui

Agora, temos um plano de consulta no qual o custo total da consulta é distribuído igualmente entre operações de busca de índice muito rápidas. Isso será quase o melhor que conseguir. Certamente outros especialistas podem melhorar isso ainda mais, mas a solução elimina algumas das principais preocupações:

  1. Ele cria estruturas de dados inteligíveis em seu banco de dados, que são mais fáceis de compor e reutilizar em outras áreas de um aplicativo.
  2. Todos os operadores de consulta mais caros foram levados em consideração no plano de consulta usando alguma indexação básica.
cocogorilla
fonte
3
Isso é bom (para o SQL-Server), mas a otimização para diferentes DBMS, embora tenha semelhanças, também apresenta sérias diferenças.
precisa saber é o seguinte
@ypercube isso é verdade. Eu adicionei algumas qualificações sobre o Postgres. Minha intenção era que a maior parte do processo de pensamento ilustrado aqui se aplicasse independentemente dos recursos específicos do DBMS.
Cocogorilla 9/09/13
A resposta é muito profunda, então levarei algum tempo para testá-la. Vou deixar você saber como eu vou.
Tom Ellis
5

Se você tiver o PostgreSQL 9.3 (lançado hoje), poderá usar um LATERAL JOIN.

Não tenho como testar isso, e nunca o usei antes, mas pelo que posso ver na documentação, a sintaxe seria algo como:

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;

Isso é basicamente equivalente ao APPLY do SQL Server , e há um exemplo disso no SQL-Fiddle para fins de demonstração.

GarethD
fonte
5

Como Erwin e outros observaram, uma consulta eficiente depende de muitas variáveis ​​e o PostgreSQL tenta muito otimizar a execução da consulta com base nessas variáveis. Em geral, você deseja escrever primeiro para maior clareza e depois modificar para obter desempenho depois de identificar gargalos.

Além disso, o PostgreSQL tem muitos truques que você pode usar para tornar as coisas um pouco mais eficientes (índices parciais para um), portanto, dependendo da carga de leitura / gravação, você poderá otimizar isso muito longe, procurando uma indexação cuidadosa.

A primeira coisa a tentar é apenas fazer uma visualização e juntar-se a ela:

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

Isso deve ter um bom desempenho ao fazer algo como:

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;

Então você pode participar disso. A consulta acabará juntando a visualização à tabela subjacente, mas, desde que você tenha um índice exclusivo em (data, boa nessa ordem ), você deve estar pronto (já que essa será uma simples pesquisa de cache). Isso funcionará muito bem com algumas linhas pesquisadas, mas será muito ineficiente se você estiver tentando digerir milhões de preços de mercadorias.

A segunda coisa que você pode fazer é adicionar à tabela de inventário uma coluna bool mais recente e

create unique index on inventory (good) where most_recent;

Você desejaria usar gatilhos para definir a maioria dos recentes como falsos quando uma nova linha de uma mercadoria foi inserida. Isso adiciona mais complexidade e maiores chances de erros, mas é útil.

Novamente, muito disso depende da existência de índices apropriados. Para consultas de data mais recentes, você provavelmente deve ter um índice na data e, possivelmente, um com várias colunas começando com date e incluindo seus critérios de associação.

Atualizar o comentário de Per Erwin abaixo, parece que eu não entendi isso. Relendo a pergunta, não tenho certeza do que está sendo perguntado. Quero mencionar na atualização qual é o problema em potencial que vejo e por que isso deixa isso claro.

O design do banco de dados oferecido não tem uso real do IME com ERP e sistemas de contabilidade. Funcionaria em um modelo hipotético de precificação perfeita, onde tudo o que é vendido em um determinado dia de um determinado produto tem o mesmo preço. No entanto, esse nem sempre é o caso. Não é o caso de coisas como trocas de moeda (embora alguns modelos finjam que sim). Se este é um exemplo artificial, não está claro. Se for um exemplo real, há problemas maiores com o design no nível de dados. Vou assumir aqui que este é um exemplo real.

Você não pode assumir que a data sozinha especifique o preço de um determinado produto. Os preços em qualquer empresa podem ser negociados por contraparte e, às vezes, por transação. Por esse motivo, você realmente deve armazenar o preço na tabela que efetivamente administra a entrada ou saída do estoque (a tabela de estoque). Nesse caso, sua tabela de data / mercadorias / preço apenas especifica um preço base que pode estar sujeito a alterações com base na negociação. Nesse caso, esse problema deixa de ser um problema de relatório para um que é transacional e opera em uma linha de cada tabela por vez. Por exemplo, você pode procurar o preço padrão de um determinado produto em um determinado dia como:

 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;

Com um índice de preços (bom, data), isso terá um bom desempenho.

Se este é um exemplo artificial, talvez algo mais próximo do que você está trabalhando ajudaria.

Chris Travers
fonte
A most_recentabordagem deve funcionar bem pelo preço mais recente absolutamente . Parece que o OP precisa do preço mais recente em relação a cada data de estoque.
Erwin Brandstetter
Bom ponto. Relendo, embora eu encontre algumas deficiências práticas reais nos dados propostos, mas não sei dizer se é apenas um exemplo artificial. Como exemplo, não sei dizer o que está faltando. Talvez uma atualização para apontar isso também esteja em ordem.
Chris Travers
@ ChrisTravers: É um exemplo artificial, mas não tenho liberdade para postar o esquema real com o qual estou trabalhando. Talvez você possa dizer um pouco sobre quais deficiências práticas você descobriu.
Tom Ellis
Eu não acho que precisa ser exato, mas preocupado com o problema que está sendo perdido na alegoria. Algo um pouco mais próximo seria útil. O problema é que, com os preços, é provável que o preço em um determinado dia seja o padrão e, consequentemente, você não o usaria para gerar relatórios apenas como o padrão para a entrada da transação; portanto, suas consultas interessantes geralmente são apenas algumas linhas por vez. Tempo.
Chris Travers
3

Outra maneira seria usar a função window lead()para obter o intervalo de datas para cada linha no preço da tabela e depois usá-lo betweenao ingressar no inventário. Na verdade, eu usei isso na vida real, mas principalmente porque essa foi minha primeira ideia de como resolver isso.

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

Tomas Greif
fonte
1

Use uma associação do inventário ao preço com condições de associação que limitem os registros da tabela de preços apenas àqueles que estão na data ou antes da data do inventário, extraia a data máxima e onde a data é a data mais alta desse subconjunto

Portanto, para o seu preço de estoque:

 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)

Se o preço de qualquer mercadoria especificada tiver sido alterado mais de uma vez no mesmo dia e você realmente tiver apenas datas e horários nessas colunas, poderá ser necessário aplicar mais restrições nas junções para selecionar apenas um dos registros de alteração de preço.


fonte
Infelizmente, não parece acelerar as coisas.