Entendendo o Plano de Execução no POSTGRESQL: Como Melhorar a Performance das Suas Consultas SQL – PARTE 2

Seguindo a parte 2 do artigo sobre Execution Plan (planos de execução), veremos como é o funcionamento da analise e interpretação de um plano de execução no banco de dados POSTGRESQL.

Seguindo o que comumente faço para essa analise, 2 modos podemos estabelecer para essa analise :

  1. Plano de execução em modo texto/tabular, onde é visto de forma indentada os steps.
  2. Plano de execução em modo tree (arvore), onde é visto de forma grafica os steps.

Similar ao oracle database, o POSTGRESQL utilize a seguinte metodologia para interpretação de planos de execução, O plano de execução é lido de baixo para cima e da direita para a esquerda, refletindo a sequência em que o PostgreSQL executa as operações.

No PostgreSQL, os planos de execução são lidos de baixo para cima e da direita para a esquerda. Isso significa que:

  • A operação mais profunda (mais aninhada) no plano é a primeira a ser executada.
  • As operações externas ou superiores são executadas posteriormente, processando os dados obtidos das operações inferiores.

Essa abordagem permite entender melhor o fluxo da execução da consulta e identificar possíveis gargalos de desempenho.

Para tornar a explicação mais objetiva, vamos analisar um exemplo de query, interpretar seu plano de execução e, posteriormente, verificar possíveis otimizações para tornar a execução do SQL mais eficiente.

 SELECT * FROM (
    (SELECT
        poi.supplier_product ->> 'supplier_product_name' AS name,
        poi.price AS price,
        poi.created_at AS expires_at,
        poi.supplier_product ->> 'supplier_product_manufacturer' AS manufacturer,
        poi.supplier_product ->> 'supplier_product_packing' AS packing,
        poi.supplier_product ->> 'supplier_product_pack_quantity' AS pack_quantity,
        poi.supplier_product ->> 'supplier_product_unit_purchase' AS unit_purchase,
        poi.confirmed_quantity AS quantity_price,
        sp.name AS name_supply,
        sp.identifier AS identifier_supply,
        1 AS order_by
    FROM purchase_order_items poi
    INNER JOIN purchase_orders po ON po.id = poi.purchase_order_id
    INNER JOIN buyer_products bp ON bp.buyer_id = po.buyer_id AND
        bp.code = poi.buyer_product ->> 'buyer_product_code'
    INNER JOIN products p ON p.id = bp.base_product_id
    INNER JOIN suppliers sp ON sp.id = po.supplier_id
    WHERE bp.id = 12081956
        AND poi.created_at < '2025-02-21 16:57:59.225571'
    ORDER BY poi.created_at DESC
    LIMIT 3
    )
    UNION ALL
    (SELECT
        bp.name,
        bp.source_price AS price,
        bp.created_at AS expires_at,
        '' AS manufacturer,
        '' AS packing,
        '' AS pack_quantity,
        '' AS unit_purchase,
        0 AS quantity_price,
        '' AS name_supply, 
        '' AS identifier_supply,
        2 AS order_by
    FROM public.buyer_products bp
    INNER JOIN public.products p ON p.id = bp.base_product_id
    WHERE bp.id = 12081956
        AND bp.source_price IS NOT NULL
    )
) b
ORDER BY order_by
LIMIT 3;

Para analisar os planos de execução, utilizaremos duas ferramentas que auxiliam na visualização e interpretação dos dados. Cada ferramenta apresenta o plano de uma forma diferente, permitindo uma análise mais completa:

  1. Modo Tabular: Explain Depesz – Exibe o plano de execução em formato tabular, facilitando a leitura estruturada dos passos.
    https://explain.depesz.com/

  2. Modo Árvore (Tree): Explain Dalibo – Representa o plano de execução graficamente, tornando mais intuitiva a hierarquia das operações.
    https://explain.dalibo.com/

📌 Instrução: Clique nos links acima para acessar as ferramentas e seguir o procedimento de visualização dos dois métodos.

Para visualizar o plano de execução em ambos os modos, primeiro precisamos extraí-lo em formato de texto. Para isso, basta adicionar a seguinte declaração no início da query:

EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS)
(...) SQL CODE (...)

Essa instrução fornece informações detalhadas sobre a execução da consulta, incluindo:

  • ANALYZE: Executa a query de fato e retorna estatísticas reais de tempo e quantidade de linhas processadas.
  • COSTS: Exibe os custos estimados de cada operação no plano de execução.
  • VERBOSE: Apresenta detalhes adicionais, como a estrutura da consulta e a relação das colunas envolvidas.
  • BUFFERS: Mostra informações sobre o uso do cache de memória (buffers) durante a execução.

Abaixo, veja como o código SQL completo ficará com a inclusão do EXPLAIN na consulta.

 EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS )
 SELECT * FROM (
    (SELECT
        poi.supplier_product ->> 'supplier_product_name' AS name,
        poi.price AS price,
        poi.created_at AS expires_at,
        poi.supplier_product ->> 'supplier_product_manufacturer' AS manufacturer,
        poi.supplier_product ->> 'supplier_product_packing' AS packing,
        poi.supplier_product ->> 'supplier_product_pack_quantity' AS pack_quantity,
        poi.supplier_product ->> 'supplier_product_unit_purchase' AS unit_purchase,
        poi.confirmed_quantity AS quantity_price,
        sp.name AS name_supply,
        sp.identifier AS identifier_supply,
        1 AS order_by
    FROM purchase_order_items poi
    INNER JOIN purchase_orders po ON po.id = poi.purchase_order_id
    INNER JOIN buyer_products bp ON bp.buyer_id = po.buyer_id AND
        bp.code = poi.buyer_product ->> 'buyer_product_code'
    INNER JOIN products p ON p.id = bp.base_product_id
    INNER JOIN suppliers sp ON sp.id = po.supplier_id
    WHERE bp.id = 12081956
        AND poi.created_at < '2025-02-21 16:57:59.225571'
    ORDER BY poi.created_at DESC
    LIMIT 3
    )
    UNION ALL
    (SELECT
        bp.name,
        bp.source_price AS price,
        bp.created_at AS expires_at,
        '' AS manufacturer,
        '' AS packing,
        '' AS pack_quantity,
        '' AS unit_purchase,
        0 AS quantity_price,
        '' AS name_supply, 
        '' AS identifier_supply,
        2 AS order_by
    FROM public.buyer_products bp
    INNER JOIN public.products p ON p.id = bp.base_product_id
    WHERE bp.id = 12081956
        AND bp.source_price IS NOT NULL
    )
) b
ORDER BY order_by
LIMIT 3;

Após executar o comando EXPLAIN, o PostgreSQL gera um plano de execução em formato de texto. A seguir, veja um exemplo do plano gerado para esta query:



 Limit  (cost=120200.56..120200.57 rows=2 width=252) (actual time=207.212..207.217 rows=3 loops=1)
   Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supplier_product_packing'::t
ext)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, (1)
   Buffers: shared hit=64032
   ->  Sort  (cost=120200.56..120200.57 rows=2 width=252) (actual time=186.714..186.718 rows=3 loops=1)
         Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supplier_product_packi
ng'::text)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, (1)
         Sort Key: (1)
         Sort Method: quicksort  Memory: 25kB
         Buffers: shared hit=64032
         ->  Append  (cost=120195.05..120200.49 rows=2 width=252) (actual time=186.639..186.704 rows=4 loops=1)
               Buffers: shared hit=64032
               ->  Limit  (cost=120195.05..120195.06 rows=1 width=242) (actual time=186.637..186.640 rows=3 loops=1)
                     Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supplier_p
roduct_packing'::text)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, 1
                     Buffers: shared hit=64024
                     ->  Sort  (cost=120195.05..120195.06 rows=1 width=242) (actual time=186.619..186.621 rows=3 loops=1)
                           Output: ((poi.supplier_product ->> 'supplier_product_name'::text)), poi.price, poi.created_at, ((poi.supplier_product ->> 'supplier_product_manufacturer'::text)), ((poi.supplier_product ->> 'supp
lier_product_packing'::text)), ((poi.supplier_product ->> 'supplier_product_pack_quantity'::text)), ((poi.supplier_product ->> 'supplier_product_unit_purchase'::text)), poi.confirmed_quantity, sp.name, sp.identifier, 1
                           Sort Key: poi.created_at DESC
                           Sort Method: top-N heapsort  Memory: 26kB
                           Buffers: shared hit=64024
                           ->  Nested Loop  (cost=2.04..120195.04 rows=1 width=242) (actual time=0.193..186.435 rows=273 loops=1)
                                 Output: (poi.supplier_product ->> 'supplier_product_name'::text), poi.price, poi.created_at, (poi.supplier_product ->> 'supplier_product_manufacturer'::text), (poi.supplier_product ->> 'sup
plier_product_packing'::text), (poi.supplier_product ->> 'supplier_product_pack_quantity'::text), (poi.supplier_product ->> 'supplier_product_unit_purchase'::text), poi.confirmed_quantity, sp.name, sp.identifier, 1
                                 Inner Unique: true
                                 Buffers: shared hit=64024
                                 ->  Nested Loop  (cost=1.75..120194.70 rows=1 width=423) (actual time=0.169..181.896 rows=273 loops=1)
                                       Output: poi.supplier_product, poi.price, poi.created_at, poi.confirmed_quantity, po.supplier_id
                                       Inner Unique: true
                                       Buffers: shared hit=63194
                                       ->  Nested Loop  (cost=1.31..120192.03 rows=1 width=427) (actual time=0.154..181.249 rows=273 loops=1)
                                             Output: poi.supplier_product, poi.price, poi.created_at, poi.confirmed_quantity, po.supplier_id, bp.base_product_id
                                             Join Filter: ((bp.code)::text = (poi.buyer_product ->> 'buyer_product_code'::text))
                                             Rows Removed by Join Filter: 48357
                                             Buffers: shared hit=62374
                                             ->  Nested Loop  (cost=0.87..7304.32 rows=6352 width=19) (actual time=0.037..8.088 rows=9209 loops=1)
                                                   Output: po.id, po.supplier_id, bp.code, bp.base_product_id
                                                   Buffers: shared hit=4649
                                                   ->  Index Scan using buyer_products_pkey on public.buyer_products bp  (cost=0.44..2.68 rows=1 width=15) (actual time=0.016..0.018 rows=1 loops=1)
                                                         Output: bp.id, bp.base_product_id, bp.buyer_id, bp.created_at, bp.updated_at, bp.external_id, bp.name, bp.code, bp.status, bp.source_price, bp.identifier, bp.accept_
alternative_brands, bp.id_scub, bp.internal_scub_id, bp.detailed_description, bp.buyer_product_children
                                                         Index Cond: (bp.id = 12081956)
                                                         Buffers: shared hit=4
                                                   ->  Index Scan using index_purchase_orders_on_buyer_id on public.purchase_orders po  (cost=0.43..7100.53 rows=6704 width=12) (actual time=0.012..6.880 rows=9209 loops=1)
                                                         Output: po.id, po.supplier_id, po.total_price, po.items_count, po.created_at, po.updated_at, po.confirmation_id, po.synchronized_at, po.buyer_id, po.observation, po.
sequence, po.origin_id, po.origin_type, po.external_id, po.redirect_to_legacy, po.name, po.origin_observation, po.opened_at, po.contact, po.terms_and_conditions, po.freight_type, po.minimum_purchase, po.valid_until, po.pay
ment_method_id, po.billing_address, po.uuid, po.created_by, po.created_by_info, po.pending_download, po.text_search_vector, po.request_id, po.upload_id, po.upload_id_gr
                                                         Index Cond: (po.buyer_id = bp.buyer_id)
                                                         Buffers: shared hit=4645
                                             ->  Index Scan using index_purchase_order_items_on_purchase_order_id on public.purchase_order_items poi  (cost=0.44..9.90 rows=225 width=751) (actual time=0.002..0.006 rows=5 lo
ops=9209)
                                                   Output: poi.id, poi.purchase_order_id, poi.created_at, poi.updated_at, poi.status, poi.confirmed_quantity, poi.cancellation, poi.supplier_observation, poi.opening_quantity
, poi.supplier_product, poi.buyer_product, poi.sequence_number, poi.price, poi.comments, poi.origin, poi.uuid, poi.sequence_seller_item_number
                                                   Index Cond: (poi.purchase_order_id = po.id)
                                                   Filter: (poi.created_at < '2025-02-21 16:57:59.225571'::timestamp without time zone)
                                                   Rows Removed by Filter: 0
                                                   Buffers: shared hit=57725
                                       ->  Index Only Scan using products_pkey on public.products p  (cost=0.44..2.68 rows=1 width=4) (actual time=0.002..0.002 rows=1 loops=273)
                                             Output: p.id
                                             Index Cond: (p.id = bp.base_product_id)
                                             Heap Fetches: 0
                                             Buffers: shared hit=820
                                 ->  Index Scan using idx_sp_on_id on public.suppliers sp  (cost=0.29..0.33 rows=1 width=58) (actual time=0.002..0.002 rows=1 loops=273)
                                       Output: sp.id, sp.name, sp.created_at, sp.updated_at, sp.external_id, sp.identifier, sp.identifier_kind, sp.bio_id, sp.synchronized_at, sp.bioid_synchronized_at, sp.phone, sp.plan_nam
e, sp.metadata, sp.is_external
                                       Index Cond: (sp.id = po.supplier_id)
                                       Buffers: shared hit=828
               ->  Subquery Scan on "*SELECT* 2"  (cost=0.87..5.38 rows=1 width=252) (actual time=0.058..0.060 rows=1 loops=1)
                     Output: "*SELECT* 2".name, "*SELECT* 2".price, "*SELECT* 2".expires_at, ''::text, ''::text, ''::text, ''::text, 0, ''::character varying, ''::character varying, 2
                     Buffers: shared hit=8
                     ->  Nested Loop  (cost=0.87..5.34 rows=1 width=262) (actual time=0.054..0.055 rows=1 loops=1)
                           Output: bp_1.name, bp_1.source_price, bp_1.created_at, ''::text, ''::text, ''::text, ''::text, 0, ''::character varying, ''::character varying, 2
                           Inner Unique: true
                           Buffers: shared hit=8
                           ->  Index Scan using idx_bp_on_id_source_price on public.buyer_products bp_1  (cost=0.43..2.67 rows=1 width=66) (actual time=0.013..0.013 rows=1 loops=1)
                                 Output: bp_1.id, bp_1.base_product_id, bp_1.buyer_id, bp_1.created_at, bp_1.updated_at, bp_1.external_id, bp_1.name, bp_1.code, bp_1.status, bp_1.source_price, bp_1.identifier, bp_1.accept_
alternative_brands, bp_1.id_scub, bp_1.internal_scub_id, bp_1.detailed_description, bp_1.buyer_product_children
                                 Index Cond: (bp_1.id = 12081956)
                                 Buffers: shared hit=4
                           ->  Index Only Scan using products_pkey on public.products p_1  (cost=0.44..2.68 rows=1 width=4) (actual time=0.005..0.005 rows=1 loops=1)
                                 Output: p_1.id
                                 Index Cond: (p_1.id = bp_1.base_product_id)
                                 Heap Fetches: 0
                                 Buffers: shared hit=4
 Query Identifier: 273579094919034765
 Planning:
   Buffers: shared hit=129
 Planning Time: 3.160 ms
 JIT:
   Functions: 34
   Options: Inlining false, Optimization false, Expressions true, Deforming true
   Timing: Generation 1.963 ms, Inlining 0.000 ms, Optimization 0.883 ms, Emission 19.683 ms, Total 22.528 ms
 Execution Time: 209.321 ms
(82 rows)

Assim como no Oracle Database, o PostgreSQL utiliza a indentação, representada pelo símbolo ->, para indicar a ordem de execução das operações, sem atribuir um identificador sequencial a cada passo. Dessa forma, quanto maior a indentação antes do ->, mais interno (ou profundo) é o passo no plano de execução. Isso significa que os passos mais indentados são executados primeiro.

Com o plano de execução em formato de texto, podemos utilizar as duas ferramentas apresentadas para realizar a análise e identificar possíveis gargalos na execução da query. A seguir, exploraremos ambas as abordagens e demonstraremos como detectar os principais pontos de otimização no SQL.


Utilizaremos o plano de execução em formato de texto gerado anteriormente e o submeteremos à ferramenta Explain Depesz, que apresenta a análise no formato tabular. Você pode acessá-la através do seguinte link: Explain Depesz.
https://explain.depesz.com/

Passos para visualizar o plano de execução no Explain Depesz:

  • 🔴 Seta Vermelha: Insira um título para o plano de execução (opcional, mas facilita a organização).
  • 🟢 Seta Verde: Cole o plano de execução gerado em formato de texto.
  • 🔵 Seta Azul: Cole a query utilizada para gerar o plano de execução.
  • ⚫ Seta Preta: Após preencher os campos acima, clique em Submit para processar e visualizar o plano de execução.

Após submeter o plano de execução, observe na demonstração abaixo os principais gargalos identificados. Eles são destacados em laranja e vermelho, indicando as operações mais custosas. Além disso, os trechos grifados representam cláusulas da query que podem ser otimizadas para melhorar o desempenho.

Nested Loop (cost=1.31..120,192.03 rows=1 width=427) (actual time=0.154..181.249 rows=273 loops=1)
Output: poi.supplier_product, poi.price, poi.created_at, poi.confirmed_quantity, po.supplier_id, bp.base_product_id
Join Filter: ((bp.code)::text = (poi.buyer_product ->> ‘buyer_product_code’::text))
Rows Removed by Join Filter: 48,357
Buffers: shared hit=62,374

📌 O que significa?

  • O PostgreSQL está executando um Nested Loop Join para combinar os dados das tabelas buyer_products (bp) e purchase_order_items (poi).
  • Custo estimado: 120.192 → Indica que esta é uma das etapas mais pesadas do plano.
  • Tempo real gasto: 181.249 ms → Isso confirma que essa operação está realmente demorando muito.

🚨 Problema identificado:

  • O Nested Loop Join pode ser ineficiente, especialmente quando muitas linhas precisam ser comparadas.

🔹 Detalhes do Join e o Problema do Filtro





Join Filter: ((bp.code)::text = (poi.buyer_product ->> ‘buyer_product_code’::text))
Rows Removed by Join Filter: 48,357

📌 O que isso significa?

  • O PostgreSQL está tentando juntar bp.code com o campo JSON poi.buyer_product ->> 'buyer_product_code'.
  • Porém, 48.357 linhas foram lidas e removidas porque não atendiam ao filtro.
    Isso significa que muitos dados desnecessários foram carregados antes da filtragem.

🚨 Problema identificado:

  • O filtro de comparação JSON é extremamente ineficiente.
  • O PostgreSQL precisa percorrer todas as linhas antes de saber quais realmente fazem parte da junção.

Buffers: shared hit=62,374

📌 O que isso significa?
  • 62.374 blocos de memória foram acessados no cache do PostgreSQL.
  • Isso indica que nenhuma leitura de disco foi necessária, o que é positivo.
  • Porém, mesmo sem leituras de disco, a query ainda está lenta, o que mostra que o problema está na lógica da consulta, não apenas no hardware.

🔹 Como otimizar essa parte do plano?

Agora que sabemos que o Nested Loop Join e o filtro JSON são os principais problemas, podemos sugerir melhorias:

1️⃣ Criar um índice para melhorar a comparação JSON

A comparação (bp.code)::text = (poi.buyer_product ->> 'buyer_product_code'::text) é muito custosa, pois o PostgreSQL precisa processar os dados JSON antes de compará-los.

🚀 Solução: Criar um índice funcional no JSON

CREATE INDEX idx_poi_buyer_product_code ON purchase_order_items ((buyer_product ->> 'buyer_product_code'));

CREATE INDEX idx_poi_purchase_order_id_created_at ON purchase_order_items (purchase_order_id, created_at DESC);

📌 Impacto esperado:

  • O PostgreSQL poderá usar o índice diretamente para encontrar as correspondências.
  • Reduz drasticamente o número de linhas removidas pelo Join Filter.
  • Diminui a necessidade de percorrer todas as linhas da tabela.
  • Reduz tempo da busca no purchase_order_items.
  • Melhora a performance do Nested Loop.

📌 Problemas identificados:

  1. Join Filter está descartando muitas linhas (48.357 removidas).
  2. Nested Loop Join não é eficiente para grandes volumes de dados.
  3. Falta de um índice eficiente para buscas no JSON.
  4. Falta de um índice composto no purchase_order_items para junções rápidas.

🚀 Melhorias sugeridas:

✅ Criar um índice para otimizar a busca JSON.
✅ Usar um Hash Join em vez de Nested Loop.
✅ Criar um índice composto para melhorar as junções.

Melhoria da Análise e Utilização da Ferramenta EXPLAIN DALIBO

Agora que identificamos os gargalos e propomos soluções, vamos abordar esse problema sob uma nova perspectiva, utilizando a ferramenta EXPLAIN Dalibo.

https://explain.dalibo.com



Observe que a caixa do Nested Loop representa o principal gargalo da query, e o “Join Filter” está relacionado à condição ((bp.code)::text = (poi.buyer_product ->> 'buyer_product_code'::text)), o que indica uma junção ineficiente que impacta diretamente o desempenho da consulta.


Conclusão

Neste trabalho, exploramos duas abordagens para analisar e otimizar uma query SQL no PostgreSQL:

  1. EXPLAIN ANALYZE → Fornece um relatório detalhado sobre a execução da query, ajudando a identificar custos, tempo de execução e estatísticas importantes.
  2. EXPLAIN Dalibo → Apresenta um formato visual e intuitivo, facilitando a identificação dos pontos críticos da consulta, como Nested Loops, filtros ineficientes e ordenações desnecessárias.

Além disso, aprendemos a identificar gargalos na execução da query, como o Join Filter ineficiente e a ordenção manual sem suporte de índices. Com isso, aplicamos índices estratégicos e sugerimos a troca de algoritmos de junção para melhorar a performance.

O principal aprendizado aqui é que, antes de otimizar uma query, é essencial entender como o PostgreSQL a executa, localizar os gargalos e aplicar ajustes cirúrgicos, como a criação de índices e a escolha do tipo de junção mais eficiente. 🚀

Que tal resolver agora?