No artigo anterior, desmontamos a arquitetura de uma aplicação Apache Spark e entendemos a relação entre:
Application
↓
Driver
↓
Cluster Manager
↓
Executors
↓
Tasks
Mas ficou uma pergunta importante.
Se eu tenho vários Executors disponíveis, como o Spark consegue dividir os dados para processá-los em paralelo?
A resposta começa em um dos conceitos mais importantes — e muitas vezes subestimados — do Spark:
Partições
Entender partições mudou bastante a maneira como passei a enxergar processamento distribuído.
Porque um cluster pode ter dezenas de máquinas, centenas de cores e muita memória.
Nada disso garante que esses recursos serão utilizados eficientemente.
No fim das contas, existe uma relação fundamental:
Dados
↓
Partitions
↓
Tasks
↓
Parallelism
↓
Performance
E é isso que vamos explorar.
1. O que é uma partição?
Um dataset distribuído não é tratado necessariamente como um único bloco gigantesco.
Spark divide logicamente os dados em partições.
Imagine um dataset:
Dataset
│
├── Partition 0
├── Partition 1
├── Partition 2
└── Partition 3
Essas partições podem ser processadas paralelamente pelos Executors.
Por exemplo:
DATASET
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Partition 0 Partition 1 Partition 2
│ │ │
▼ ▼ ▼
Executor A Executor B Executor C
Isso nos leva a uma relação extremamente importante:
Em uma determinada Stage, cada partição normalmente será processada por uma Task.
Podemos pensar:
Partition 0 ───► Task 0
Partition 1 ───► Task 1
Partition 2 ───► Task 2
Partition 3 ───► Task 3
Portanto:
Partitions
↓
Tasks
↓
Parallelism
Essa relação ajuda a explicar muita coisa sobre performance no Spark.
2. Partição não é a mesma coisa que arquivo
Esse é um ponto importante.
Imagine que temos:
/data/
├── vendas_01.parquet
├── vendas_02.parquet
├── vendas_03.parquet
└── vendas_04.parquet
É tentador pensar:
1 arquivo = 1 partição Spark
Mas essa relação não é necessariamente verdadeira.
A forma como Spark cria partições durante a leitura depende de fatores como:
- formato dos dados;
- tamanho dos arquivos;
- fonte de dados;
- configuração da aplicação;
- capacidade de divisão da fonte;
- operações realizadas posteriormente.
Além disso, novas operações podem alterar completamente o particionamento.
Por isso precisamos separar mentalmente:
ARMAZENAMENTO
Arquivos
↓
Blocos / objetos / arquivos físicos
de:
EXECUÇÃO SPARK
Dataset
↓
Partitions
↓
Tasks
Partições pertencem ao modelo de execução distribuída do Spark.
3. Descobrindo quantas partições existem
Com RDDs podemos verificar diretamente:
rdd = sc.parallelize(range(100))
print(rdd.getNumPartitions())
Também podemos escolher uma quantidade inicial:
rdd = sc.parallelize(
range(100),
4
)
Agora temos, conceitualmente:
100 elementos
│
▼
4 partitions
│
├── P0
├── P1
├── P2
└── P3
Com DataFrames também podemos acessar o RDD subjacente para inspeção:
df.rdd.getNumPartitions()
Isso é útil durante análise e troubleshooting.
Mas saber quantas partições existem é apenas o começo.
A pergunta realmente importante é:
Essas partições estão adequadas para o workload?
4. Partições determinam oportunidades de paralelismo
Imagine um cluster com:
4 Executors
4 cores por Executor
Temos potencialmente:
4 × 4 = 16 cores
Simplificando, podemos pensar em até 16 Tasks executando simultaneamente, dependendo das configurações e dos recursos disponíveis.
Agora imagine que nosso Stage possui apenas:
4 partitions
Teremos aproximadamente:
Task 1
Task 2
Task 3
Task 4
Mesmo tendo capacidade para mais execução concorrente.
Visualmente:
Capacidade disponível
Core 01 → Task
Core 02 → Task
Core 03 → Task
Core 04 → Task
Core 05 → ocioso
Core 06 → ocioso
Core 07 → ocioso
Core 08 → ocioso
...
Core 16 → ocioso
Temos um cluster relativamente grande, mas pouco paralelismo.
Agora considere:
64 partitions
Podemos ter ondas sucessivas de Tasks:
16 Tasks
↓
16 Tasks
↓
16 Tasks
↓
16 Tasks
Os recursos podem ser utilizados de maneira muito mais eficiente.
Mas isso não significa que:
Quanto mais partições, melhor.
Porque existe o outro extremo.
5. Partições demais também são um problema
Imagine um dataset relativamente pequeno dividido em:
100.000 partitions
Agora Spark precisa administrar:
100.000 Tasks
Mesmo que cada Task processe pouquíssimos dados.
Cada Task possui algum custo operacional.
Existe overhead relacionado a:
- scheduling;
- serialização;
- comunicação;
- inicialização;
- acompanhamento;
- coordenação;
- metadados.
Podemos chegar a uma situação como:
Task
├── 20 ms processando dados
└── overhead operacional significativo
Multiplique isso por dezenas ou centenas de milhares de Tasks.
O problema deixa de ser processamento.
Passa a ser coordenação.
Esse é um padrão comum em sistemas distribuídos:
Paralelismo tem custo.
6. Partições grandes demais
Agora vamos para o outro extremo.
Imagine:
Dataset: 1 TB
Partitions: 4
Simplificando:
Partition 1 ≈ 250 GB
Partition 2 ≈ 250 GB
Partition 3 ≈ 250 GB
Partition 4 ≈ 250 GB
Isso pode gerar vários problemas.
Cada Task terá uma quantidade enorme de dados para processar.
Podemos ter:
- baixa utilização do cluster;
- pressão de memória;
- spills para disco;
- garbage collection;
- Tasks muito longas;
- maior custo de recuperação em caso de falha.
Visualmente:
Cluster
Executor 1 → ████████████████████████
Executor 2 → ████████████████████████
Executor 3 → ████████████████████████
Executor 4 → ████████████████████████
Executor 5 → ocioso
Executor 6 → ocioso
Executor 7 → ocioso
Executor 8 → ocioso
O cluster possui recursos.
Mas não existem Tasks suficientes para utilizá-los.
7. Partições pequenas demais
Agora imagine o inverso:
Dataset: 1 TB
Partitions: 1.000.000
Teríamos uma quantidade gigantesca de Tasks.
Conceitualmente:
1.000.000 partitions
↓
1.000.000 tasks
↓
scheduling
↓
coordenação
↓
overhead
Mesmo que o cluster consiga executar muitas Tasks paralelamente, gerenciar essa quantidade pode ser extremamente caro.
Portanto, procuramos equilíbrio.
Poucas partitions
↓
baixo paralelismo
Tasks muito grandes
Partitions demais
↓
overhead excessivo
Tasks muito pequenas
Quantidade adequada
↓
bom paralelismo
Tasks administráveis
Não existe um número universal perfeito.
O tamanho adequado depende do workload.
8. O problema mais perigoso: partições desbalanceadas
Até agora assumimos algo parecido com:
P1 = 1 GB
P2 = 1 GB
P3 = 1 GB
P4 = 1 GB
Mas dados reais raramente são tão bonitos.
Podemos encontrar:
P1 = 500 MB
P2 = 600 MB
P3 = 550 MB
P4 = 18 GB
Agora imagine quatro Tasks:
Task 1 → 20 segundos
Task 2 → 23 segundos
Task 3 → 21 segundos
Task 4 → 8 minutos
O Stage não termina quando as três primeiras Tasks acabam.
Ele precisa esperar a última.
Visualmente:
Task 1 ████ ✓
Task 2 █████ ✓
Task 3 ████ ✓
Task 4 ███████████████████████████████████████...
Isso é um sinal clássico de desequilíbrio.
E uma das causas pode ser:
Data Skew
9. O que é Data Skew?
Imagine dados de vendas agrupados por estado.
Suponha:
SP → 60 milhões
RJ → 15 milhões
MG → 10 milhões
BA → 5 milhões
AC → 200 mil
RR → 100 mil
Se uma operação distribuir dados utilizando estado como chave, algumas partições podem ficar muito maiores que outras.
Podemos terminar com:
Partition SP █████████████████████████████████
Partition RJ █████████
Partition MG ██████
Partition BA ███
Partition AC ▏
Partition RR ▏
Os Executors responsáveis pelas partições pequenas terminam rapidamente.
Enquanto isso, uma Task continua trabalhando.
O cluster pode ficar assim:
Executor 1 → idle
Executor 2 → idle
Executor 3 → idle
Executor 4 → idle
Executor 5 → █████████████ trabalhando
Esse fenômeno é importante porque mostra que:
Ter muitas partições não garante paralelismo eficiente.
Precisamos também de boa distribuição dos dados entre elas.
10. repartition()
Spark oferece mecanismos para alterar o particionamento.
Um deles é:
df = df.repartition(100)
Conceitualmente:
ANTES
P1
P2
P3
P4
repartition(100)
↓
DEPOIS
P1
P2
P3
...
P100
Mas existe um detalhe fundamental.
Redistribuir dados entre partições pode exigir movimentação pela rede.
Ou seja:
repartition()
↓
Shuffle
Imagine:
Executor A Executor B
[A, A, B, C] [A, B, C, C]
repartition
↓
Executor A Executor B
[A, A, A] [B, B]
[C, C, C]
Dados precisaram viajar entre executors.
E rede não é gratuita.
Por isso:
repartition()não deve ser usado como um botão mágico de performance.
Ele resolve alguns problemas criando outro custo: shuffle.
11. Repartition por coluna
Também podemos particionar logicamente usando uma ou mais colunas:
df = df.repartition(
"customer_id"
)
Ou especificar quantidade:
df = df.repartition(
200,
"customer_id"
)
Isso pode ser útil quando operações posteriores dependem dessa chave.
Mas novamente precisamos pensar na distribuição.
Se customer_id estiver bem distribuído:
customer_id
↓
hash
↓
P1 P2 P3 P4 P5 ...
podemos conseguir uma distribuição razoável.
Se a chave tiver forte skew, o resultado pode continuar ruim.
Particionar não corrige automaticamente uma distribuição ruim.
12. coalesce()
Outro método importante é:
df = df.coalesce(10)
Normalmente utilizamos coalesce() quando queremos reduzir a quantidade de partições.
Por exemplo:
ANTES
P1 P2 P3 P4 P5 P6 P7 P8
coalesce(4)
↓
DEPOIS
P1 P2 P3 P4
A grande diferença conceitual é que coalesce() pode reduzir partições evitando uma redistribuição completa dos dados.
Compare:
repartition()
↓
redistribuição ampla
↓
shuffle
com:
coalesce()
↓
combinação de partitions
↓
pode evitar full shuffle
Isso pode tornar coalesce() mais barato quando o objetivo é simplesmente reduzir paralelismo.
Mas também existe uma consequência.
13. O perigo de usar coalesce(1)
Esse comando aparece bastante:
df.coalesce(1)
Por quê?
Normalmente porque alguém quer:
"Salvar tudo em um único arquivo."
Funciona em determinados cenários.
Mas imagine:
100 partitions
↓
coalesce(1)
↓
1 partition
↓
1 Task
Acabamos de destruir praticamente todo o paralelismo daquela etapa.
Imagine processar:
200 GB
em uma única partição.
Podemos transformar um processamento distribuído em um enorme gargalo.
Por isso, quando encontro:
df.coalesce(1)
em um pipeline de produção, minha primeira pergunta é:
Por que precisamos de exatamente um arquivo?
Às vezes existe uma justificativa.
Muitas vezes existe apenas uma dependência legada ou uma decisão que nunca foi questionada.
14. repartition() vs. coalesce()
Uma forma simples de lembrar:
repartition() coalesce()
Aumentar ✓ não é o uso típico
Reduzir ✓ ✓
Shuffle normalmente sim pode evitar full shuffle
Distribuição melhor pode ficar desigual
Custo maior geralmente menor
Isso não significa:
coalesce = bom
repartition = ruim
Significa:
Cada operação resolve um problema diferente.
Se preciso redistribuir os dados de forma mais uniforme, repartition() pode ser exatamente o que quero.
Se tenho muitas partições pequenas e apenas quero consolidá-las, coalesce() pode ser mais apropriado.
15. Partições e Shuffle
Agora começamos a conectar os capítulos.
Considere:
resultado = (
df
.groupBy("customer_id")
.count()
)
Para contar todos os registros do mesmo cliente, Spark precisa garantir que dados da mesma chave possam ser processados conjuntamente.
Imagine:
Executor A
customer 1
customer 2
customer 3
Executor B
customer 1
customer 4
customer 2
Precisamos reorganizar:
ANTES
Executor A Executor B
1 1
2 4
3 2
↓ SHUFFLE ↓
DEPOIS
Partition A
1
1
Partition B
2
2
Partition C
3
Partition D
4
É nesse momento que rede, disco, memória e particionamento começam a interagir.
Por isso shuffle é uma das operações mais caras e importantes de entender no Spark.
16. Partições e arquivos pequenos
Particionamento também aparece em outro problema clássico de Data Engineering:
Small Files Problem
Imagine um pipeline que grava:
/year=2026/month=10/day=23/
part-000001.parquet
part-000002.parquet
part-000003.parquet
...
part-200000.parquet
Temos centenas de milhares de arquivos minúsculos.
Isso pode prejudicar:
- leitura;
- listagem de arquivos;
- planejamento;
- metadados;
- armazenamento;
- engines de consulta;
- Data Lakes.
Muitas vezes a quantidade de arquivos gerados está relacionada à quantidade de partições no momento da escrita.
Por exemplo:
df.write.parquet(
"/data/output"
)
As partições do DataFrame influenciam a quantidade de tarefas de escrita e, consequentemente, podem influenciar a quantidade de arquivos produzidos.
Isso conecta performance computacional com arquitetura de armazenamento.
17. Particionamento de execução ≠ particionamento de armazenamento
Aqui existe uma distinção importante.
Podemos ter:
df.repartition(100)
Isso está relacionado ao particionamento do DataFrame durante a execução.
Mas também podemos escrever:
df.write \
.partitionBy("ano", "mes") \
.parquet("/data/vendas")
Agora estamos falando de organização física/lógica dos dados no armazenamento:
/data/vendas/
ano=2025/
mes=12/
ano=2026/
mes=01/
ano=2026/
mes=02/
São conceitos relacionados, mas diferentes.
Uma forma útil de separar:
repartition()
↓
EXECUÇÃO
partitionBy()
↓
ARMAZENAMENTO
Confundir os dois pode levar a decisões ruins de arquitetura.
18. E quanto ao tamanho ideal de uma partição?
Essa é uma pergunta clássica:
Quantos MB uma partição Spark deveria ter?
A resposta correta é:
depende.
Você encontrará recomendações práticas em diferentes contextos, frequentemente trabalhando com partições na ordem de dezenas ou centenas de megabytes.
Mas transformar um número em regra universal é perigoso.
Precisamos considerar:
- tipo de operação;
- volume de dados;
- formato;
- compressão;
- memória dos Executors;
- quantidade de cores;
- número de Executors;
- custo de serialização;
- skew;
- shuffle;
- origem dos dados;
- destino dos dados.
Em vez de decorar:
Partition = X MB
prefiro pensar:
A Task termina rápido demais?
↓
Talvez partitions pequenas demais
A Task demora excessivamente?
↓
Talvez partition grande
Algumas Tasks demoram muito mais?
↓
Talvez skew
Muitos cores ociosos?
↓
Talvez poucas partitions
Scheduler sobrecarregado?
↓
Talvez partitions demais
O Spark UI ajuda muito nessa análise.
19. Um exemplo prático
Imagine:
Dataset = 800 GB
Cluster:
10 Executors
4 cores por Executor
Temos aproximadamente:
40 cores
Agora suponha:
8 partitions
Temos no máximo oito Tasks para aquela etapa.
Então:
40 cores disponíveis
8 Tasks
Grande parte do cluster ficará ociosa.
Agora imagine:
800 partitions
Teríamos aproximadamente:
1 GB por partition
Ainda pode ser grande dependendo da operação.
Se utilizarmos:
3200 partitions
teríamos aproximadamente:
250 MB por partition
Isso poderia ser um ponto inicial interessante para investigação.
Mas atenção:
não estou dizendo que 3200 é a configuração correta.
Estou mostrando como raciocinar.
A validação real vem das métricas:
Spark UI
│
├── Task Duration
├── Input Size
├── Shuffle Read
├── Shuffle Write
├── Spill
├── GC Time
└── Executor Utilization
Engenharia de performance não deveria ser:
"Vi alguém usando 3200 partitions."
Deveria ser:
"Escolhi um ponto inicial, medi o comportamento e ajustei baseado em evidências."
20. O número de cores não conta toda a história
Suponha:
Executors: 10
Cores por Executor: 4
Total: 40 cores
Podemos pensar inicialmente:
40 cores
↓
até aproximadamente 40 Tasks simultâneas
Mas isso não significa que:
40 partitions
é necessariamente ideal.
Por quê?
Porque se temos exatamente 40 Tasks e uma delas é muito lenta:
39 Tasks → 30 segundos
1 Task → 8 minutos
depois dos primeiros 30 segundos grande parte do cluster pode ficar ociosa esperando aquela última Task.
Ter múltiplas ondas de Tasks pode ajudar na utilização dos recursos.
Novamente:
não existe uma fórmula universal.
Existe análise de workload.
21. Quando particionamento vira arquitetura
Esse assunto parece inicialmente uma otimização de baixo nível.
Mas não é.
Particionamento influencia:
PARTITIONING
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Parallelism Memory Network
│ │ │
▼ ▼ ▼
Tasks Spill Shuffle
│ │ │
└──────────────┼──────────────┘
▼
PERFORMANCE
│
▼
COST
Em cloud isso fica ainda mais importante.
Um pipeline ineficiente não significa apenas:
"demorou mais."
Pode significar:
"custou mais."
Se um cluster fica ativo durante duas horas em vez de vinte minutos, existe impacto financeiro.
Por isso performance também é uma preocupação de arquitetura e FinOps.
22. Um exemplo de raciocínio de troubleshooting
Imagine que um Job demora 45 minutos.
No Spark UI encontramos:
Stage 8
Tasks: 500
Median Duration: 20 s
Max Duration: 31 min
Isso é um enorme sinal.
Não começaria aumentando memória aleatoriamente.
Perguntaria:
Por que uma Task levou 31 minutos
quando a mediana foi 20 segundos?
Possíveis hipóteses:
Task lenta
│
├── partition muito grande?
├── data skew?
├── shuffle excessivo?
├── spill para disco?
├── garbage collection?
├── problema no executor?
└── problema de I/O?
Esse é o tipo de raciocínio que quero desenvolver ao longo desta série.
Não decorar configurações.
Mas saber onde procurar.
23. repartition() também pode piorar tudo
Imagine um pipeline:
df = spark.read.parquet("/data/events")
df = df.repartition(5000)
resultado = (
df
.filter("event_type = 'purchase'")
.groupBy("customer_id")
.count()
)
Talvez alguém tenha adicionado:
repartition(5000)
porque:
"Mais partições deixam Spark mais rápido."
Mas acabamos de introduzir potencialmente uma redistribuição cara antes mesmo da operação principal.
Podemos ter:
Read
↓
Repartition
↓
SHUFFLE
↓
Filter
↓
GroupBy
↓
SHUFFLE
↓
Aggregation
Talvez tenhamos criado dois movimentos grandes de dados onde não precisávamos.
Esse é um exemplo perfeito de uma regra importante:
Otimização sem compreender o plano de execução pode virar desotimização.
24. O melhor repartition() às vezes é nenhum
Antes de escrever:
df.repartition(...)
eu perguntaria:
Qual problema estou tentando resolver?
Se a resposta for:
"Não sei, mas acho que melhora performance."
Não é uma boa justificativa.
Perguntas melhores:
Tenho poucas partitions?
Tenho muitas?
Existe skew?
Preciso distribuir por determinada chave?
Estou preparando os dados para um join?
Estou gerando small files?
Existe um shuffle inevitável logo depois?
Qual é o plano físico?
Só depois escolheria a estratégia.
25. O que mudou no Spark moderno
Existe uma diferença importante entre estudar Spark historicamente e trabalhar com versões modernas.
Hoje o Spark possui mecanismos muito mais sofisticados de otimização.
Um deles é o:
Adaptive Query Execution — AQE
Com AQE, Spark pode utilizar informações coletadas durante a própria execução para ajustar partes do plano.
Entre outras capacidades, isso pode ajudar com:
- coalescing de shuffle partitions;
- otimização de joins;
- tratamento de determinados cenários de skew.
Isso representa uma evolução enorme.
Historicamente, muitas decisões precisavam ser tomadas antecipadamente com informações limitadas.
Hoje Spark consegue adaptar determinadas decisões durante a execução.
Mas isso não elimina a necessidade de entender partições.
Na verdade, torna o conhecimento ainda mais útil.
Porque agora podemos entender o que o engine está tentando otimizar por nós.
26. Como penso em partições hoje
Meu modelo mental é aproximadamente este:
DATASET
│
▼
PARTITIONS
│
┌──────────┼──────────┐
│ │ │
▼ ▼ ▼
Size Balance Count
│ │ │
└──────────┼──────────┘
▼
TASKS
│
▼
PARALLELISM
│
┌──────────┼──────────┐
▼ ▼ ▼
CPU Memory Network
│ │ │
└──────────┼──────────┘
▼
PERFORMANCE
│
▼
COST
Não penso:
"Qual número de partitions devo usar?"
Penso:
"Como os dados estão sendo distribuídos e como isso afeta a execução?"
Essa é uma pergunta muito melhor.
27. Checklist rápido de particionamento
Quando encontro problemas de performance, algumas perguntas ajudam bastante:
- Quantas partições existem?
- Qual o tamanho médio?
- Qual o tamanho máximo?
- As Tasks possuem duração semelhante?
- Existe uma ou poucas Tasks muito mais lentas?
- Todos os cores estão sendo utilizados?
- Existe shuffle?
- Quanto está sendo lido e escrito no shuffle?
- Existe spill para disco?
- Há indícios de data skew?
- Algum
repartition()foi adicionado sem necessidade? - Existe
coalesce(1)? - Estamos gerando arquivos pequenos demais?
- O problema está na execução ou no layout do armazenamento?
Essas perguntas transformam:
"Meu Spark está lento."
em uma investigação muito mais objetiva.
28. A principal lição deste capítulo
Se eu pudesse resumir tudo em uma frase:
O cluster fornece capacidade. As partições determinam quanto dessa capacidade conseguimos explorar.
Um cluster enorme com particionamento ruim continua sendo um sistema mal utilizado.
Podemos ter:
100 CPUs disponíveis
e apenas:
4 Tasks
Ou:
100 CPUs disponíveis
e:
1.000.000 Tasks minúsculas
Nos dois casos existe desperdício.
O objetivo é encontrar uma distribuição que permita ao Spark executar trabalho suficiente em paralelo sem transformar coordenação, memória ou rede no novo gargalo.
Próximo artigo
Neste capítulo apareceu repetidamente uma palavra:
Shuffle
Ela surgiu em:
repartition()
em:
groupBy()
em:
reduceByKey()
e também aparecerá quando falarmos sobre:
join()
Shuffle é uma das operações mais importantes para entender performance em Apache Spark.
No próximo artigo vamos descobrir:
- o que realmente acontece durante um shuffle;
- por que dados precisam atravessar a rede;
- narrow vs. wide dependencies;
- shuffle read;
- shuffle write;
- agregações;
- joins;
- impacto sobre memória e disco;
- spill;
- e por que reduzir movimentação de dados costuma ser uma das melhores otimizações possíveis.
Vamos seguir o fluxo:
Partitions
↓
Wide Transformation
↓
Shuffle Write
↓
Network
↓
Shuffle Read
↓
New Partitions
↓
Next Stage
É aqui que começamos a entrar de verdade em performance tuning de Spark.
[← Parte 2: Por dentro da arquitetura — Driver, Executors, Jobs, Stages e Tasks]
Próximo: Parte 4 — Shuffle no Apache Spark: onde a performance começa a ficar cara
Top comments (0)