Mas e se o otimizador mantivesse os valores reais de custo, seletividade e outros parâmetros necessários para a execução da consulta e, quando repetido, focasse não apenas nas estatísticas coletadas padrão, mas também nas salvas após a execução anterior?
Isso é chamado de otimização de consulta adaptável e é promissor. Alguns DBMSs já usam essas tecnologias.
Empresa Postgres Professional por vários anos trabalhando na extensão AQO para PostgreSQL, que implementa (de alguma forma) a otimização adaptativa. O trabalho ainda está em andamento, mas já há algo a ser testado.
Primeiro, vamos dar uma olhada na área de assunto da otimização de consultas.
Por que o planejador pode escolher um plano abaixo do ideal
A consulta SQL pode ser executada de diferentes maneiras. Por exemplo, quando duas tabelas são unidas, isso pode ser feito de várias maneiras diferentes - usando loops aninhados, mesclando e hash. Quanto mais tabelas envolvidas na consulta, mais variações de suas junções. A tarefa do planejador é escolher um plano de execução de consulta com um custo mínimo de muitas variações.
Como já mencionado, em seu trabalho, os agendadores de muitos DBMS usam informações estatísticas que são coletadas automática ou manualmente. O planejador calcula um custo estimado com base nessas estatísticas.
Em geral, os planejadores dos SGBDs modernos funcionam bem na maioria das situações. No entanto, em alguns casos, o plano escolhido pode estar muito longe do ideal.
Por exemplo,a falta de informações estatísticas atualizadas leva ao fato de o planejador ser guiado em seu trabalho por (provavelmente) dados incorretos sobre o número de linhas nas tabelas que estão sendo unidas. A subestimação excessiva (ou superestimação) da cardinalidade leva à escolha de métodos não ideais para acessar dados em tabelas.
Outro motivo importante pode ser a falta de índices necessários . Na ausência de índices, o planejador tem uma escolha limitada de métodos de acesso a dados.
Uso de condições dependentes (correlacionadas)também pode afetar negativamente a operação do DBMS. O planejador (por padrão) assume que todas as condições em uma consulta são independentes uma da outra, ou seja, o valor de uma condição não afeta a outra de forma alguma. Isso não é sempre o caso. Se condições dependentes forem usadas (por exemplo, código postal e cidade), o planejador também calculará o custo e a cardinalidade incorretos das conexões.
O planejador também pode ser afetado pelo uso de funções no ambiente . Uma função é uma caixa preta para o planejador, ele não sabe quantas linhas a função retornará, o que também pode levar a um custo incorreto do plano.
Maneiras de influenciar o trabalho do planejador
As estatísticas reais são uma condição indispensável para o trabalho adequado do planejador. Antes de tudo, verifique se o sistema está configurado para coletar regularmente informações estatísticas.
Existem várias maneiras de corrigir as situações acima e ajudar o planejador a escolher planos de execução de consulta mais ideais.
Sem índices, o planejador tem apenas uma maneira de obter verificações de tabela sequenciais de dados (e isso nem sempre é ruim e caro). Em alguns casos, a criação dos índices necessários pode ajudar a acelerar o acesso aos dados - você não precisa verificar a tabela inteira. Mas o uso de índices (encontrar o necessário, criar, manter) não é gratuito. Idealmente, eles devem ser usados exatamente onde necessário. E onde não é necessário - não use.
Ao usar condições de conexão correlacionadas em consultas, você pode gerar estatísticas avançadas- explicitamente "avisa" o otimizador de que as condições estão relacionadas entre si. Para fazer isso, o administrador do banco de dados (ou desenvolvedor) precisa conhecer bem seus dados e monitorar as condições dependentes nas consultas, pois é difícil prever o número de combinações de colunas dependentes com antecedência. Estatísticas estendidas terão que ser criadas manualmente para cada uma dessas opções.
Ao criar uma função, você pode especificar o custo aproximado de execução e / ou uma estimativa do número de linhas retornadas pela função. Na versão 12 , tornou - se possível usar funções auxiliares para melhorar as estimativas do planejador, dependendo dos argumentos. Essa também é uma maneira manual, que nem sempre oferece um resultado ideal.
Se tudo mais falhar, você pode reescrever manualmente a consulta, por exemplo, usando visualizações materializadas, Common Table Expressions (CTE). Ou para esclarecer os requisitos da área de assunto e, possivelmente, reescrever radicalmente a lógica da consulta.
E há mais uma forma de "dica" o planejador - otimização de consultas adaptativa ( a daptive q uery o ptimization). A idéia desse método é que, depois que a consulta é executada, informações estatísticas reais são salvas e, quando a consulta fornecida (ou semelhante) é repetida, o otimizador pode confiar nela.
O DBMS Postgres Pro Enterprise é uma extensão para otimização de consulta adaptável chamada AQO... Esta extensão está publicada no github: github.com/postgrespro/aqo , você pode experimentá-lo com o PostgreSQL básico , mais sobre isso abaixo.
O princípio de operação do módulo
O módulo AQO usa aprendizado de máquina em seu trabalho. Você pode ler mais sobre o princípio de operação no artigo de Oleg Ivanov, Usando o Machine Learning para aumentar o desempenho do PostgreSQL, e em mais detalhes na apresentação Otimização de consulta adaptável (relatório no YouTube ).
A essência desse método é descrita brevemente abaixo:
Para estimar o custo, o planejador precisa de uma estimativa de cardinalidade e, para isso, por sua vez, é necessária uma estimativa da seletividade das condições.
Para condições simples (como "attribute = constant" ou "attribute> constant"), o planejador possui um modelo pelo qual estima a seletividade. Para fazer isso, ele usa informações estatísticas: o número de valores de atributos exclusivos, histogramas etc.
Para condições compostas de elementos simples usando conectivos lógicos, o planejador usa fórmulas facilmente calculadas:
- sel (não A) = 1 - sel (A)
- sel (não A) = 1 - sel (A)
- sel (A e B) = sel (A) * sel (B)
- sel (A ou B) = sel (não (não A e não B)) = 1 - (1 - sel (A)) * (1 - sel (B))
Essas fórmulas assumem a independência (não correlacionada) das condições A e B, razão pela qual obtemos estimativas incorretas no caso em que essa suposição é violada.
O AQO complica a fórmula: introduz seu próprio coeficiente para cada condição simples. Usando o aprendizado de máquina (usando a regressão do vizinho mais próximo), o AQO seleciona esses coeficientes para que a seletividade calculada pela fórmula corresponda melhor à seletividade real que o AQO observou anteriormente.
Para isso, o módulo salva:
- , ;
- .
Em seu trabalho, o AQO distingue entre condições até constantes. Isso permite reduzir a complexidade do problema que está sendo resolvido e, além disso, na maioria dos casos, as informações ainda não são perdidas: o AQO não "vê" o valor da constante, mas "vê" a seletividade da condição.
A situação em que a perda ocorre são as condições avaliadas como constantes, independentemente dos valores específicos. Por exemplo, para algumas condições, o planejador não pode fazer estimativas razoáveis e escolhe a constante padrão (por exemplo, a seletividade da condição “expressão1 = expressão2” é sempre avaliada como 0,005 e “expressão1> expressão2” como 1/3).
Assim, o AQO melhora a estimativa de seletividade de condições complexas (e, como conseqüência, a estimativa de custos, que podem levar à escolha de um plano de execução mais adequado).
Instalando o módulo
Para experimentar a funcionalidade do módulo no PostgreSQL, você precisa usar um patch especial e, em seguida, montar o sistema a partir do código-fonte. Veja o arquivo README no github para mais detalhes .
Se o Postgres Pro Enterprise for usado, a instalação do módulo AQO continuará no modo padrão:
shared_preload_libraries = 'aqo'
Depois disso, você poderá criar uma extensão no banco de dados necessário.
Preparando o Banco de Dados
Vejamos um exemplo específico de como o módulo AQO funciona em um banco de dados demo . Usaremos um grande banco de dados contendo informações sobre voos para o ano de setembro de 2016 a setembro de 2017.
Primeiro, crie uma extensão:
CREATE EXTENSION aqo;
Em seguida, desative o processamento de consultas paralelas - para que a exibição de planos paralelos não desvie a tarefa principal:
max_parallel_workers_per_gather = 0;
Para que o planejador PostgreSQL tenha mais opções para ingressar em tabelas, crie dois índices:
CREATE INDEX ON flights (scheduled_departure );
CREATE INDEX ON ticket_flights (fare_conditions );
Ao analisar os resultados, focaremos no valor de BUFFERS como o número de páginas que você precisa ler para concluir o trabalho. Vejamos também o tempo de execução (mas o tempo em um sistema carregado e em um laptop doméstico pode ser muito diferente).
Aumente o cache do buffer e work_mem para que todo o trabalho seja realizado na RAM:
shared_buffers = '256MB';
work_mem = '256MB';
Usando o módulo AQO
Vamos formular um pedido: você precisa pegar passageiros que voaram na classe executiva a partir de uma determinada data e chegaram com um atraso de não mais de uma hora.
Executamos a solicitação sem usar o AQO (a seguir, parte das informações que não afetam o entendimento da operação do módulo foram removidas dos planos):
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT t.ticket_no
FROM flights f
JOIN ticket_flights tf ON f.flight_id = tf.flight_id
JOIN tickets t ON tf.ticket_no = t.ticket_no
WHERE f.scheduled_departure > '2017-08-01'::timestamptz
AND f.actual_arrival < f.scheduled_arrival + interval '1 hour'
AND tf.fare_conditions = 'Business';
E vamos ver o plano resultante: nesse caso, o planejador considerou o plano ideal, no qual, primeiro, usando uma verificação de bitmap ( ), obtemos um conjunto de linhas da tabela de vôos, que temos (um nó ) com um conjunto de linhas da tabela ticket_flights obtida usando o índice digitalizar ( ). O resultado será usado como um conjunto externo de linhas para a conexão final por um loop aninhado (nó ). O conjunto interno para esta conexão é obtido por uma varredura de índice exclusiva da tabela tickets ( ). A operação mais "volumosa" é obter o conjunto de linhas interno para o loop aninhado - 106.205 buffers são lidos nele.
Nested Loop (rows=33210) (actual rows=31677)
Buffers: shared hit=116830 read=1
-> Hash Join (rows=33210) (actual rows=31677)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: fare_conditions = 'Business'
-> Hash
-> Bitmap Heap Scan on flights f (rows=8331) (actual rows=7673)
Recheck Cond: scheduled_departure > '2017-08-01'
Filter: actual_arrival < scheduled_arrival + '01:00:00'::interval
-> Bitmap Index Scan on ... [flights]
Index Cond: scheduled_departure > '2017-08-01'
Buffers: shared hit=44 read=1
-> Index Only Scan ... on tickets t (rows=1 width=14) (actual rows=1 loops=31677)
Index Cond: (ticket_no = tf.ticket_no)
Buffers: shared hit=106205
Planning Time: 9.326 ms
Execution Time: 675.836 ms
Bitmap Heap Scan on flights
Hash Join
Index Scan ... on ticket_flights
Nested Loop
Index Only Scan ... on tickets
Esse plano pode ser chamado de relativamente bom, pois um número relativamente pequeno de linhas no conjunto externo é processado por uma junção de loop aninhada.
Agora vamos realizar um experimento e ver como o plano proposto irá (ou não) mudar, dependendo da alteração nas datas na solicitação. As datas são selecionadas de maneira a aumentar consistentemente o intervalo de linhas da tabela Voos que satisfazem a condição, o que leva a um erro do planejador na avaliação da cardinalidade do acesso a essa tabela. No plano acima, você pode ver que, com a primeira data, o otimizador quase não se engana em cardinalidade ( ). Vamos substituir as seguintes datas na solicitação, uma por uma:
Bitmap Heap Scan on flights f (rows=8331) (actual rows=7673)
- 01/04/2017
- 01-01-2017
- 01/08/2016
E veja o resultado:
Planos de consulta sem AQO
2017-04-01
Nested Loop (rows=31677) (actual rows=292392)
Buffers: shared hit=991756
-> Hash Join (rows=31677) (actual rows=292392)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan … on ticket_flights tf
Index Cond: fare_conditions = 'Business')
-> Hash
-> Bitmap Heap Scan on flights f (rows=7673) (actual rows=70553)
Recheck Cond: scheduled_departure > '2017-04-01'
Filter: actual_arrival < (scheduled_arrival + '01:00:00'::interval)
-> Bitmap Index Scan on ... [flights]
Index Cond: scheduled_departure > '2017-04-01'
Buffers: shared hit=160
-> Index Only Scan ... on tickets t ( rows=1 width=14) (actual rows=1 loops=292392)
Index Cond: (ticket_no = tf.ticket_no)
Buffers: shared hit=980995
Planning Time: 5.980 ms
Execution Time: 2771.563 ms
, . . , (
() — Flights , ( , ).
2017-01-01
Nested Loop (rows=187710) (actual rows=484569)
Buffers: shared hit=1640723 read=49
-> Hash Join (rows=187738) (actual rows=484569)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: fare_conditions = 'Business'
-> Hash
-> Seq Scan on flights f (rows=45352) (actual rows=116985)
Filter: scheduled_departure > '2017-01-01'::date
AND actual_arrival < scheduled_arrival + '01:00:00'::interval
-> Index Only Scan ... on tickets t (rows=1) (actual rows=1 loops=484569)
Index Cond: (ticket_no = tf.ticket_no)
Buffers: shared hit=1630118 read=49
Planning Time: 6.225 ms
Execution Time: 4498.741 ms
, . flights, ( ) .
tickets — (1 630 118).
2016-08-01
Hash Join (rows=302200) (actual rows=771441)
Hash Cond: (t.ticket_no = tf.ticket_no)
Buffers: shared hit=25499 read=34643
-> Seq Scan on tickets t (rows=2949857) (actual rows=2949857)
-> Hash
-> Hash Join (rows=302236) (actual rows=771441)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan on ticket_flights tf
Index Cond: fare_conditions = 'Business'
-> Hash
-> Seq Scan on flights f (rows=73005) (actual rows=188563)
Filter: scheduled_departure > '2016-08-01'::date)
AND actual_arrival < scheduled_arrival + '01:00:00'::interval
Planning Time: 9.990 ms
Execution Time: 3014.577 ms
(
Nested Loop (rows=31677) (actual rows=292392)
Buffers: shared hit=991756
-> Hash Join (rows=31677) (actual rows=292392)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan … on ticket_flights tf
Index Cond: fare_conditions = 'Business')
-> Hash
-> Bitmap Heap Scan on flights f (rows=7673) (actual rows=70553)
Recheck Cond: scheduled_departure > '2017-04-01'
Filter: actual_arrival < (scheduled_arrival + '01:00:00'::interval)
-> Bitmap Index Scan on ... [flights]
Index Cond: scheduled_departure > '2017-04-01'
Buffers: shared hit=160
-> Index Only Scan ... on tickets t ( rows=1 width=14) (actual rows=1 loops=292392)
Index Cond: (ticket_no = tf.ticket_no)
Buffers: shared hit=980995
Planning Time: 5.980 ms
Execution Time: 2771.563 ms
, . . , (
Bitmap Heap Scan on flights f (rows=7673) (actual rows=70553)
), , Nested Loop, , .
() — Flights , ( , ).
2017-01-01
Nested Loop (rows=187710) (actual rows=484569)
Buffers: shared hit=1640723 read=49
-> Hash Join (rows=187738) (actual rows=484569)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: fare_conditions = 'Business'
-> Hash
-> Seq Scan on flights f (rows=45352) (actual rows=116985)
Filter: scheduled_departure > '2017-01-01'::date
AND actual_arrival < scheduled_arrival + '01:00:00'::interval
-> Index Only Scan ... on tickets t (rows=1) (actual rows=1 loops=484569)
Index Cond: (ticket_no = tf.ticket_no)
Buffers: shared hit=1630118 read=49
Planning Time: 6.225 ms
Execution Time: 4498.741 ms
, . flights, ( ) .
tickets — (1 630 118).
2016-08-01
Hash Join (rows=302200) (actual rows=771441)
Hash Cond: (t.ticket_no = tf.ticket_no)
Buffers: shared hit=25499 read=34643
-> Seq Scan on tickets t (rows=2949857) (actual rows=2949857)
-> Hash
-> Hash Join (rows=302236) (actual rows=771441)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan on ticket_flights tf
Index Cond: fare_conditions = 'Business'
-> Hash
-> Seq Scan on flights f (rows=73005) (actual rows=188563)
Filter: scheduled_departure > '2016-08-01'::date)
AND actual_arrival < scheduled_arrival + '01:00:00'::interval
Planning Time: 9.990 ms
Execution Time: 3014.577 ms
(
(rows=302236) (actual rows=771441)
). , , : Hash Join Nested Loop.
Para resumir, sem usar o módulo AQO, o planejador funciona da seguinte maneira:
encontro | Buffers | Tempo, ms | Um comentário |
01/08/2017 | 116 831 | 675.836 | O loop aninhado e a junção de hash são usados. As tabelas de Voos e Bilhetes são verificadas pelo índice |
01/04/2017 | 991 756 | 2771.563 | O mesmo plano, mas não o ideal. Ao escolher o acesso por índice para as tabelas de Voos e Bilhetes, fica claro que o planejador está muito errado ao calcular a cardinalidade |
01-01-2017 | 1.640.772 | 4498.741 | O mesmo plano subótimo. Mas o planejador decide fazer uma varredura seqüencial da tabela Voos |
01/08/2016 | 60 142 | 3014.577 | O plano finalmente mudou - o otimizador percebe que precisará selecionar várias linhas das tabelas, para alternar para a varredura seqüencial das tabelas de Voos e Passagens. O loop aninhado ineficiente (neste caso) o substitui por uma junção de hash. |
Planos de consulta com o AQO
AQO. :
, :
2017-08-01
, , . AQO .
2017-04-01
Hash Join (rows=293891) (actual rows=292392)
Hash Cond: (t.ticket_no = tf.ticket_no)
Buffers: shared hit=25658 read=34640
-> Seq Scan on tickets t (rows=2949857) (actual rows=2949857)
-> Hash
-> Hash Join (rows=293734) (actual rows=292392)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: (fare_conditions)::text = 'Business'::text
-> Hash
-> Bitmap Heap Scan on flights f
Recheck Cond: scheduled_departure > '2017-04-01'::date
Filter: actual_arrival < scheduled_arrival + '01:00:00'::interval
-> Bitmap Index Scan on ... [flights]
Index Cond: scheduled_departure > '2017-04-01'::date
Buffers: shared hit=160
Planning Time: 9.652 ms
Execution Time: 2218.074 ms
“”, AQO — . Tickets . . , AQO.
2017-01-01
Hash Join (rows=484452) (actual rows=484569)
Hash Cond: (t.ticket_no = tf.ticket_no)
Buffers: shared hit=25534 read=34608
-> Seq Scan on tickets t (rows=2949857) (actual rows=2949857)
-> Hash (rows=484464) (actual rows=484569)
-> Hash Join (rows=484464) (actual rows=484569)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: fare_conditions::text = 'Business'::text
-> Hash
-> Seq Scan on flights f (rows=116971) (actual rows=116985)
Filter: scheduled_departure > '2017-01-01'::date
AND actual_arrival < scheduled_arrival + '01:00:00'::interval
Planning Time: 6.264 ms
Execution Time: 2746.485 ms
— Flights .
2016-08-01
.
SET aqo.mode = 'learn';
, :
2017-08-01
, , . AQO .
2017-04-01
Hash Join (rows=293891) (actual rows=292392)
Hash Cond: (t.ticket_no = tf.ticket_no)
Buffers: shared hit=25658 read=34640
-> Seq Scan on tickets t (rows=2949857) (actual rows=2949857)
-> Hash
-> Hash Join (rows=293734) (actual rows=292392)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: (fare_conditions)::text = 'Business'::text
-> Hash
-> Bitmap Heap Scan on flights f
Recheck Cond: scheduled_departure > '2017-04-01'::date
Filter: actual_arrival < scheduled_arrival + '01:00:00'::interval
-> Bitmap Index Scan on ... [flights]
Index Cond: scheduled_departure > '2017-04-01'::date
Buffers: shared hit=160
Planning Time: 9.652 ms
Execution Time: 2218.074 ms
“”, AQO — . Tickets . . , AQO.
2017-01-01
Hash Join (rows=484452) (actual rows=484569)
Hash Cond: (t.ticket_no = tf.ticket_no)
Buffers: shared hit=25534 read=34608
-> Seq Scan on tickets t (rows=2949857) (actual rows=2949857)
-> Hash (rows=484464) (actual rows=484569)
-> Hash Join (rows=484464) (actual rows=484569)
Hash Cond: (tf.flight_id = f.flight_id)
-> Index Scan ... on ticket_flights tf
Index Cond: fare_conditions::text = 'Business'::text
-> Hash
-> Seq Scan on flights f (rows=116971) (actual rows=116985)
Filter: scheduled_departure > '2017-01-01'::date
AND actual_arrival < scheduled_arrival + '01:00:00'::interval
Planning Time: 6.264 ms
Execution Time: 2746.485 ms
— Flights .
2016-08-01
.
Vejamos o resultado novamente:
encontro | Buffers | Tempo, ms | Um comentário |
01/08/2017 | 116 831 | 662.966 | O plano é o mesmo que sem usar o módulo |
01/04/2017 | 60298 | 2218.074 | Usando as dicas do módulo, o otimizador entende que um grande número de cadeias está planejado para ser unido e, nessa etapa, já melhora o plano, substituindo o loop aninhado por uma junção de hash |
01-01-2017 | 60142 | 2746.485 | O plano melhorou um pouco - em vez de acessar a tabela de vôos por bitmap, ele usa uma varredura seqüencial |
01/08/2016 | 60142 | 3253.861 | O plano permaneceu inalterado - o melhor plano neste caso |
Resumir
Existem vantagens e desvantagens em usar o módulo AQO para otimização de consulta adaptável.
Um dos benefícios do uso de um módulo é que você não precisa acompanhar as condições dependentes nas consultas. Em alguns casos, a velocidade de execução da consulta pode aumentar. E existem diferentes modos de usar o módulo. Por exemplo, você pode usar o AQO para otimizar apenas certos tipos de consultas.
Entre as desvantagens do módulo, pode-se destacar custos indiretos adicionais para treinamento e armazenamento de estatísticas nas estruturas do módulo. E as informações estatísticas coletadas pelo módulo não são transferidas para réplicas.
O módulo AQO não é uma bala de prata contra todos os possíveis problemas de agendamento. Por exemplo, em algumas situações, o módulo pode substituir estatísticas estendidas (se não forem criadas manualmente) ou não prestará atenção a estatísticas irrelevantes. Mas o módulo não criará os índices necessários e, além disso, não reescreverá o texto da consulta.
Portanto, você não deve incluir um módulo para todas as solicitações. Os candidatos ideais à otimização para o AQO são consultas em que o erro do planejador em calcular a cardinalidade dos nós resulta em um plano incorreto. E, por alguma razão, não é possível influenciar o refinamento dessa estimativa.