Em projetos de Engenharia de Dados, aumentar os recursos do cluster nem sempre resolve um pipeline lento. Muitas vezes, o gargalo está no volume lido, na redistribuição dos dados ou na estratégia de execução escolhida para uma transformação.
O PySpark permite processar grandes volumes de dados distribuídos, mas seu desempenho depende de decisões que vão além da sintaxe Python. Neste artigo, vamos explorar cinco práticas que ajudam a identificar desperdícios, controlar custos e melhorar a performance — sempre com medição antes e depois.
Antes de começar: os trechos a seguir são exemplos didáticos. Os ganhos dependem do volume, formato, distribuição dos dados e configuração do cluster. Não substituem benchmarks no seu ambiente.
1. Leia apenas as colunas e linhas necessárias
Otimizar começa reduzindo o trabalho que precisa ser feito. Se uma transformação usa apenas três colunas, evite carregar dezenas de campos que não serão utilizados.
from pyspark.sql import functions as F
transacoes = (
spark.read.parquet("/dados/transacoes")
.filter(F.col("status") == "APROVADA")
.select("id_cliente", "data_transacao", "valor")
)
Formatos colunares, como Parquet, podem tirar proveito do column pruning (leitura das colunas necessárias) e, dependendo do filtro e da fonte, de predicate pushdown. O Catalyst também consegue reorganizar automaticamente parte das operações; por isso, não se deve presumir que apenas mudar a ordem textual do código produzirá ganhos.
Como validar: confira no plano de execução se o FileScan lê apenas os campos necessários e se os filtros são enviados à fonte quando possível.
2. Controle o particionamento e reduza shuffles
No Spark, os dados são divididos em partições que podem ser processadas em paralelo. Operações como groupBy, distinct e diversos tipos de join podem exigir um shuffle, isto é, movimentação de dados entre executores.
# Redistribui pela chave: pode introduzir um shuffle.
redistribuido = transacoes.repartition(100, "id_cliente")
# Reduz partições sem redistribuição completa em muitos casos.
compactado = redistribuido.coalesce(20)
O repartition() é útil quando há um motivo claro para redistribuir dados ou aumentar o paralelismo. O coalesce() pode reduzir a quantidade de partições com menor movimentação, mas uma redução excessiva cria tarefas grandes e diminui o paralelismo.
Também fique atento ao data skew: quando algumas chaves concentram muitos registros, poucos executores recebem a maior parte do trabalho.
Como validar: use o Spark UI para verificar duração das tarefas, volumes de shuffle e assimetrias entre partições. Evite definir 100 ou 20 como padrões universais: esses números são apenas ilustrativos.
3. Avalie Broadcast Join quando uma tabela é pequena
Imagine um dataset grande de transações que precisa ser enriquecido com uma tabela pequena de categorias. Em um join tradicional, o Spark pode redistribuir ambos os conjuntos de dados.
Com um Broadcast Join, a tabela menor é enviada aos executores e pode evitar o shuffle do dataset maior:
from pyspark.sql.functions import broadcast
resultado = transacoes.join(
broadcast(dim_categorias),
on="id_categoria",
how="inner"
)
O broadcast() é uma dica explícita ao otimizador, não uma melhoria automática em qualquer situação. A tabela transmitida precisa ser pequena o suficiente para a memória disponível. Além disso, o Spark pode escolher essa estratégia sozinho, com base nas estatísticas e configurações de otimização.
Como validar: execute resultado.explain(mode="formatted") e procure a estratégia de join, como BroadcastHashJoin. Compare com a execução sem hint.
4. Prefira funções nativas às Python UDFs convencionais
Uma Python UDF é útil quando uma regra não pode ser expressa facilmente com as funções existentes. Porém, UDFs convencionais podem adicionar custos de serialização e comunicação entre processos Python e a JVM, além de limitar certas otimizações.
Para padronizar uma coluna, por exemplo, uma função nativa é mais simples:
from pyspark.sql import functions as F
clientes = clientes.withColumn(
"nome_normalizado",
F.upper(F.col("nome"))
)
Antes de escrever uma UDF, pesquise funções como when, regexp_replace, coalesce, to_date e outras expressões do Spark SQL. Para casos específicos em que uma função personalizada é necessária, avalie alternativas vetorizadas, como Pandas UDFs, e teste seu custo real.
Como validar: compare o plano de execução, o tempo por tarefa e o uso de CPU/memória nas duas abordagens.
5. Use o plano de execução e o Spark UI para medir
Não existe otimização confiável sem observabilidade. O método explain() ajuda a entender quais operações o Spark planeja executar:
resultado.explain(mode="formatted")
Procure por trocas de dados (Exchange), leituras (Scan), agregações, ordenações e estratégias de join.
Depois, acompanhe a execução real pelo Spark UI. Métricas úteis incluem:
- Tempo total do job e duração dos stages.
- Bytes lidos e escritos.
- Volume de shuffle read/write.
- Spill em memória ou disco.
- Distribuição do tempo das tarefas e possíveis stragglers.
O Adaptive Query Execution (AQE) também pode ajustar decisões do plano durante a execução com base em estatísticas observadas. Confira se está habilitado no ambiente, em vez de assumir que sempre estará:
spark.conf.get("spark.sql.adaptive.enabled")
Como validar: mantenha comparações reproduzíveis: mesmo conjunto de dados, configurações registradas e múltiplas execuções quando necessário. Alterações de cache ou concorrência do cluster podem distorcer os resultados.
Checklist rápido antes de aumentar o cluster
- O pipeline lê somente o que realmente precisa?
- Há shuffles evitáveis ou chaves com grande concentração de registros?
- Os joins estão utilizando estratégias adequadas ao tamanho das tabelas?
- Existem UDFs Python que poderiam virar expressões nativas?
- Temos um baseline de tempo, shuffle e consumo para comparar?
Conclusão
Otimizar pipelines PySpark não significa decorar parâmetros nem acrescentar chamadas de repartition() indiscriminadamente. A maior oportunidade costuma estar em reduzir o volume de dados movimentado e entender onde o processamento está gastando tempo e recursos.
Comece pela leitura, observe o particionamento e os joins, prefira funções nativas quando possível e utilize o Spark UI para confirmar os ganhos.
A regra mais importante: meça antes, mude uma variável por vez e meça novamente.
Referências
Conteúdo educacional com exemplos ilustrativos. Não contém dados de produção ou informações de clientes.
