Estou trabalhando em um projeto com um conjunto de dados bastante grande. Exigimos agregados arbitrários neste conjunto de dados, que são gerados no momento da solicitação de um usuário. Aqui está uma descrição básica de nossa configuração atual no PostgresQL v11 (sim, sabemos que é EOL, a atualização está prevista para o próximo trimestre)
A estrutura básica da tabela é assim:
create table if not exists sales
(
category_a smallint, -- sequential integer values from 0 - 10000
category_b varchar(3), -- 3-digit ids (all numeric, padded with zeros)
product varchar(14), -- essentially random 14 character identifiers
location_id varchar(5), -- location id, 5-digit number (left padded with zeros)
units int, -- value of interest
sales float, -- second value of interest
primary key (category_a, category_b, product, location_id)
) partition by range (category_a);
Atualmente particionamos por A
porque eles mudam após cerca de 200 valores e são eliminados do conjunto de dados. A
partições são subparticionadas por B
. Cada uma dessas A_B
partições contém cerca de 50 a 70 milhões de linhas.
Os valores para B
não são sequenciais e possuem lacunas.
Existem muitos valores diferentes de produto, cerca de um milhão.
location_id
, existem cerca de 50 a 100 locais por categoria B
, cada um com a maioria dos produtos.
Um exemplo de consulta é semelhante a este:
select category_a, category_b, product, sum(units), sum(sales)
from sales
where category_a between 1 and 100
and sales.category_b in ('001', '010', '018', '019', '024')
and product in ('00000000000147', '00000000000900', '00000000000140', '00000000009999')
group by category_a, category_b, product;
A explicação para esta consulta indica que realizamos uma verificação sequencial completa para cada partição no conjunto de dados. Isso parece estranho, pois temos o índice único com os três valores à esquerda sendo os três nas cláusulas where e group. Não entendo por que isso não usa o índice.
Aqui está uma consulta que carregará dados de exemplo na tabela:
insert into sales
(category_a, category_b, product, location_id, units, sales)
select cat_a,
lpad(cat_b::varchar, 3, '0'),
lpad(product::varchar, 14, '0'),
lpad(location_id::varchar, 5, '0'),
(random() * 10000)::int,
(random() * 100000)::int
from generate_series(1, 50) cat_a
cross join generate_series(1, 25) cat_b
cross join generate_series(1, 10) location_id
cross join generate_series(1, 5000) product;
A explicação para essa consulta é muito longa, mas posso fornecê-la se acharmos que vai ajudar.
Essas consultas podem ser extremamente lentas (minutos, às vezes mais de 10). Terei todo o prazer em fornecer detalhes adicionais, mas esta é a informação essencial (a meu ver).
Existem alterações que podemos/devemos fazer em nossa tabela ou consulta que aumentariam o desempenho desta consulta?
Para que o índice seja utilizado, você deve ter
product
como primeira coluna sua chave primária. Para que a terceira coluna do índice seja usada efetivamente com umaIN
condição, todas as colunas do índice anteriores teriam que ser comparadas com=
, e não comIN
.Usando este código pastebin para construir dados de teste.
Usando postgres 16.1.
Um índice btree funciona como uma lista telefônica, que usarei como exemplo. As entradas são ordenadas por (sobrenome,nome). Portanto, consultas como esta:
...resulta em uma busca para (last_name=constante, first_name=first) e depois verifica a linha do índice em ordem até atingir (last_name=constant, first_name=last). Isso é rápido.
Infelizmente, para consultas como:
é mais complicado e requer um método diferente: verifique o índice para encontrar todos os sobrenomes que satisfaçam a condição e, para cada um, faça o que está descrito no parágrafo anterior. Infelizmente, este método não está implementado atualmente no postgres. Portanto, temos que dar um pouco de ajuda manual.
A consulta original leva 4300 ms.
Seus dados de exemplo preenchem apenas a tabela com categoria_a entre 1 e 50, portanto, a condição em categoria_a na consulta é redundante, mas presumo que não seja redundante na consulta real. De qualquer forma, como categoria_a é uma chave de partição, o postgres lerá apenas as partições relevantes para a consulta, portanto esta parte já está otimizada.
Não está claro se você deseja particionar por A ou A,B. Presumo que você particione apenas por A, pois é isso que está em questão.
Agora, como Laurenz Albe disse, seu índice de chave primária não é adequado para esta consulta. Você precisaria deste:
Se você particionou por A, B e não por A, precisará apenas de um índice em (produto) porque a categoria_b seria tratada no nível da partição.
Com esse índice, a consulta acima realiza uma varredura de índice em 4 ms, o que é 1.000 vezes mais rápido. Apenas citando uma parte do EXPLAIN, há um destes por partição:
Isso é ótimo, mas você precisa de um índice extra, que será bem grande. Podemos fazer isso sem o índice? Sim, trapaceando um pouco. Então, primeiro, descarto o índice criado acima e:
Se usarmos uma constante categoria_a = 1 então o postgres será capaz de usar uma varredura de índice.
O que ele realmente faz é pegar as duas listas de categoria_b e produto, percorrer ambas para enumerar todas as combinações e fazer uma pesquisa de índice para cada combinação. Ainda muito mais rápido que uma varredura sequencial.
Isso também funciona com:
Ao substituir o intervalo por um conjunto de valores conhecidos, é possível usar uma varredura de índice. No entanto, o número de combinações a enumerar é bastante grande. O Postgres não entende que pode pular todos os valores de categoria_a que não existem em uma partição, então ele executa esta pesquisa em cada partição:
Portanto, a consulta é muito mais rápida (175ms) do que a consulta all-seq-scan (4300ms), mas também é muito mais lenta do que com o índice em (category_b,product) (4ms). Ainda assim, um meio-termo interessante se esta for uma consulta ocasional e você não quiser carregar um índice enorme apenas para esta.
Agora, é claro que existe a opção de trapacear ainda mais:
Isso combina muitos níveis de design ruim, mas é executado em menos de 20 ms sem o índice extra.
Eu também tentei clickhouse.
Eu \copio dados do postgres e os carrego. A taxa de compactação é de cerca de 6:1, o que não é ideal devido às colunas de texto (armazenar números como inteiros daria um resultado melhor). Toda a tabela e índice ocupam cerca de 340 MB, contra 4,3 GB do postgres: graças à compactação é como ter 12 vezes mais RAM para cache de disco.
A consulta na pergunta leva de 20 a 70 ms, dependendo da configuração de granularidade do índice.
Mudar a ordem de armazenamento das linhas e a chave primária para (categoria_a,produto,categoria_b) parece torná-lo mais rápido, porque o produto é mais seletivo.
Provavelmente poderia ser otimizado ainda mais, mas este é apenas um teste de 10 minutos.