Planos de query (EXPLAIN)¶
Ferramenta de desenvolvimento para a pergunta "por que esse endpoint está lento?". Cronômetro na aplicação diz quanto demorou; o plano do banco diz por quê.
Capturando um bloco¶
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository, explain_queries
async def profile_page(
session: AsyncSession,
orders: BaseRepository[OrderModel],
) -> str:
"""Captura os planos de uma paginação e devolve o relatório.
Args:
session (AsyncSession): A sessão a observar.
orders (BaseRepository[OrderModel]): Repositório de pedidos.
Returns:
str: Uma linha por statement capturado.
"""
async with explain_queries(session) as report:
await orders.paginate(filters={"status": "open"}, page=3)
return report.report()
Tudo que a sessão executar dentro do bloco é registrado. Ao sair, cada statement é explicado e o relatório é preenchido:
12.75ms cost=431.2 rows=1904 [measured] SELECT "order".id, "order".status ...
0.31ms cost=8.1 rows=1 [measured] SELECT count(*) AS count_1 FROM "order" ...
O relatório só enche na saída
Os statements são registrados durante o bloco e explicados depois. Explicar durante perturbaria justamente o que está sendo medido.
O repositório expõe o mesmo bloco como açúcar:
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository, ExplainReport
async def profile_list(orders: BaseRepository[OrderModel]) -> ExplainReport:
"""Abre o bloco pelo repositório em vez de pela sessão.
Args:
orders (BaseRepository[OrderModel]): Repositório de pedidos.
Returns:
ExplainReport: Os planos capturados.
"""
async with orders.explain() as report:
await orders.list(filters={"status": "open"})
return report
O que cada banco entrega¶
| Backend | Comando | detail |
Custo | Tempo medido |
|---|---|---|---|---|
PostgreSQL (SELECT) |
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) |
MEASURED |
sim | sim |
| PostgreSQL (escrita) | EXPLAIN (FORMAT JSON) |
ESTIMATED |
sim | não |
| SQLite | EXPLAIN QUERY PLAN |
PLAN_ONLY |
não | não |
O SQLite mostra qual índice cada passo usa — ou que não usa nenhum — e
nada mais. O SDK reporta ExplainDetail.PLAN_ONLY em vez de inventar
números; total_cost e duration_ms ficam None, não zero, porque zero
se leria como "de graça".
Escritas nunca são reexecutadas¶
EXPLAIN ANALYZE executa o que explica. Aplicá-lo ao INSERT que o
seu bloco acabou de fazer inseriria uma segunda linha. Por isso só
SELECT é analisado; qualquer outra coisa é explicada sem ANALYZE, o
que consulta o planejador sem tocar nos dados.
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository, explain_queries
async def insert_once(
session: AsyncSession,
orders: BaseRepository[OrderModel],
order: OrderModel,
) -> int:
"""Grava um pedido dentro do bloco e conta as linhas.
Args:
session (AsyncSession): A sessão a observar.
orders (BaseRepository[OrderModel]): Repositório de pedidos.
order (OrderModel): O pedido a gravar.
Returns:
int: O total de linhas — 1, porque a escrita não é reexecutada.
"""
async with explain_queries(session):
await orders.add(order) # explicado, não reexecutado
return await orders.count()
Um text() cru é classificado pela primeira palavra, e o que não for
reconhecido é tratado como escrita — o lado seguro do erro.
Se até o plano estimado for caro demais, desligue a análise:
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository, ExplainReport, explain_queries
async def estimate_only(
session: AsyncSession,
orders: BaseRepository[OrderModel],
) -> ExplainReport:
"""Coleta só estimativas do planejador, sem rodar nada duas vezes.
Args:
session (AsyncSession): A sessão a observar.
orders (BaseRepository[OrderModel]): Repositório de pedidos.
Returns:
ExplainReport: Os planos, sem tempo medido.
"""
async with explain_queries(session, analyze=False) as report:
await orders.list()
return report
Lendo o relatório¶
from tempest_fastapi_sdk import ExplainReport, QueryPlan
def worst(report: ExplainReport) -> QueryPlan | None:
"""Escolhe o statement que merece atenção primeiro.
Args:
report (ExplainReport): O relatório capturado.
Returns:
QueryPlan | None: O plano mais caro, ou ``None`` se nada foi
capturado.
"""
slowest = report.slowest
if slowest is not None:
print(slowest.summary())
print(slowest.plan_text)
print(len(report)) # quantos statements
print(report.total_duration_ms) # None se nada foi medido
return slowest
slowest usa o tempo medido; onde não há tempo, cai para o custo do
planejador — então a propriedade continua respondendo "qual eu olho
primeiro?" num backend que não cronometra nada.
Cada QueryPlan carrega ainda raw, com a saída intocada do banco, para
o que os campos tipados não cobrem (nós filhos, contagem de buffers).
Os planos sobrevivem a uma exceção
Se o bloco levantar, o que foi capturado antes da falha continua no relatório — e a query que está errando costuma ser exatamente a que você quer ver o plano.
Escopo¶
Só a sessão passada é observada, então uma requisição concorrente em outra sessão não polui o relatório.
Não é para o caminho quente
Cada SELECT analisado roda duas vezes. Isto é para desenvolvimento e
para uma sessão de profiling deliberada, não para deixar ligado em
produção.
Recapitulando¶
explain_queries(session)captura tudo do bloco e explica na saída.- PostgreSQL dá custo, tempo medido e linhas reais versus estimadas; SQLite dá o plano e o SDK diz que é só isso.
- Escrita nunca é reexecutada — a regra que impede a ferramenta de ser destrutiva.
report.slowestereport.report()para achar o culpado;plan.rawpara o resto.