Nota histórica: o título desta série faz referência à minha trajetória em Engenharia de Dados iniciada em 2012. As tecnologias e práticas apresentadas evoluíram em momentos diferentes. Este artigo revisita esses fundamentos sob a perspectiva das arquiteturas modernas de dados.

Nos capítulos anteriores, exploramos como o Apache Spark distribui o processamento entre Drivers, Executors e Tasks.

Entendemos partições, shuffle, memória, lineage, tolerância a falhas e dimensionamento de recursos.

Na Parte 11, discutimos uma questão importante:

Como utilizar CPU, memória e paralelismo de maneira eficiente?

Agora vamos examinar um fator que pode mudar significativamente o comportamento de uma aplicação sem alterar sua lógica de negócio:

O formato dos dados.

Imagine dois pipelines executando a mesma consulta sobre o mesmo conjunto de informações.

O primeiro lê arquivos CSV.

O segundo lê arquivos Parquet.

Ambos executam:

resultado = ( vendas .filter("estado = 'SP'") .groupBy("categoria") .sum("valor") ) 

A lógica é idêntica.

Mas a quantidade de dados lidos, o trabalho de interpretação e o plano de execução podem ser diferentes.

Por quê?

Porque o Spark não processa dados independentemente de como eles estão armazenados.

O armazenamento influencia diretamente a leitura.

E a leitura influencia o custo do processamento.

1. O formato dos dados faz parte da arquitetura

Quando pensamos em performance, normalmente lembramos de:

  • Quantidade de Executors.
  • Memória disponível.
  • Número de partições.
  • Estratégias de join.
  • Shuffle.
  • CPU.

Mas existe uma camada anterior:

Como os dados foram gravados?

Considere:

 DATA SOURCE | v FILE FORMAT | v DATA READER | v SPARK DATAFRAME | v QUERY PLAN | v EXECUTION | v RESULT 

O formato pode determinar:

  • Como os registros são representados.
  • Como os tipos são armazenados.
  • Quais metadados estão disponíveis.
  • Quais colunas precisam ser lidas.
  • Quais filtros podem ser aplicados antecipadamente.
  • Quanto espaço os dados ocupam.
  • Quanto trabalho de parsing será necessário.

Portanto:

Otimização de Spark não começa necessariamente no Executor. Muitas vezes começa na organização dos dados.

2. Row-based vs. Columnar

Uma das distinções mais importantes é entre armazenamento orientado a linhas e armazenamento orientado a colunas.

Imagine uma tabela:

id cliente estado valor
1 Ana SP 100
2 Bruno RJ 200
3 Carla SP 300

Em uma representação orientada a linhas, os valores de cada registro são armazenados próximos uns dos outros.

Conceitualmente:

ROW-ORIENTED [1, Ana, SP, 100] [2, Bruno, RJ, 200] [3, Carla, SP, 300] 

Em uma representação orientada a colunas, valores da mesma coluna são agrupados.

COLUMNAR id: [1, 2, 3] cliente: [Ana, Bruno, Carla] estado: [SP, RJ, SP] valor: [100, 200, 300] 

Essa diferença influencia o tipo de operação que cada formato favorece.

Se queremos recuperar registros completos, uma organização por linhas pode ser conveniente.

Se queremos calcular:

SELECT SUM(valor) FROM vendas; 

uma organização colunar pode permitir ler principalmente a coluna valor.

É por isso que formatos colunares são tão importantes em workloads analíticos.

3. CSV: simples, interoperável e caro de interpretar

CSV significa Comma-Separated Values.

É um formato textual amplamente utilizado para intercâmbio de dados.

Exemplo:

id,cliente,estado,valor 1,Ana,SP,100 2,Bruno,RJ,200 3,Carla,SP,300 

Sua principal vantagem é a simplicidade.

Arquivos CSV podem ser produzidos e consumidos por uma enorme variedade de ferramentas.

Mas existem limitações.

CSV não preserva nativamente um schema tipado como o de um formato colunar.

O valor:

100 

pode representar um inteiro, decimal ou texto, dependendo da interpretação.

Além disso, delimitadores, aspas, escapes, valores nulos e codificação precisam ser tratados corretamente.

Em grandes volumes, esse parsing pode representar um custo relevante.

4. Lendo CSV com PySpark

Um exemplo básico:

from pyspark.sql import SparkSession spark = ( SparkSession.builder .appName("FormatosDeDados") .getOrCreate() ) df_csv = ( spark.read .option("header", True) .csv("/data/vendas.csv") ) df_csv.printSchema() 

Sem schema explícito ou inferência habilitada, as colunas normalmente são interpretadas como strings.

Podemos utilizar:

df_csv = ( spark.read .option("header", True) .option("inferSchema", True) .csv("/data/vendas.csv") ) 

Mas a inferência exige examinar os dados para determinar tipos.

Em pipelines de produção, quando conhecemos o contrato dos dados, geralmente é melhor fornecer o schema.

5. Schema explícito: menos ambiguidade

Exemplo:

from pyspark.sql.types import ( StructType, StructField, IntegerType, StringType, DecimalType ) schema = StructType([ StructField("id", IntegerType(), False), StructField("cliente", StringType(), True), StructField("estado", StringType(), True), StructField("valor", DecimalType(12, 2), True) ]) df_csv = ( spark.read .option("header", True) .schema(schema) .csv("/data/vendas.csv") ) 

Agora declaramos a estrutura esperada.

Isso traz benefícios:

  • Contrato de tipos mais claro.
  • Menor dependência de inferência.
  • Comportamento mais previsível.
  • Melhor rastreabilidade.
  • Validação mais objetiva.

Entretanto, declarar um campo como não anulável não substitui uma rotina completa de qualidade de dados. A validação de registros malformados e de restrições de negócio deve ser tratada explicitamente.

Essa preocupação é particularmente importante quando recebemos arquivos de sistemas externos.

6. JSON: flexibilidade com custo de parsing

JSON é outro formato muito utilizado.

Exemplo:

{ "id": 1, "cliente": "Ana", "estado": "SP", "valor": 100 } 

Ele permite representar estruturas mais complexas, como arrays e objetos aninhados.

Exemplo:

{ "pedido_id": 101, "cliente": { "id": 20, "nome": "Ana" }, "itens": [ {"produto": "A", "quantidade": 2}, {"produto": "B", "quantidade": 1} ] } 

Essa flexibilidade é útil em APIs, eventos e integrações.

Mas existe um custo.

O Spark precisa interpretar a estrutura textual e convertê-la para os tipos internos utilizados no processamento.

Além disso, schemas variáveis podem dificultar governança e evolução de pipelines.

7. Lendo JSON no Spark

Para arquivos JSON Lines, com um objeto JSON por linha:

df_json = spark.read.json( "/data/eventos.json" ) 

Podemos fornecer um schema explícito:

from pyspark.sql.types import ( StructType, StructField, LongType, StringType ) schema_eventos = StructType([ StructField("event_id", LongType(), True), StructField("event_type", StringType(), True) ]) df_json = ( spark.read .schema(schema_eventos) .json("/data/eventos.json") ) 

É importante distinguir JSON Lines de um documento JSON convencional contendo um array ou objeto multilinha.

Para determinados arquivos JSON multilinha, podemos precisar de:

df_json = ( spark.read .option("multiLine", True) .json("/data/eventos_multilinha.json") ) 

O modo multilinha pode afetar a estratégia de leitura e o paralelismo. Por isso, não devemos habilitá-lo indiscriminadamente.

8. Parquet: armazenamento colunar para analytics

Agora chegamos a um dos formatos mais importantes no ecossistema moderno de Engenharia de Dados.

Apache Parquet é um formato colunar desenvolvido para armazenamento e processamento analítico.

Ele suporta:

  • Schema tipado.
  • Compressão.
  • Codificações eficientes.
  • Metadados.
  • Organização em grupos de linhas.
  • Leitura seletiva de colunas.
  • Estatísticas que podem auxiliar a eliminação de dados desnecessários.

Considere uma consulta:

SELECT estado, SUM(valor) FROM vendas GROUP BY estado; 

Se o dataset possui 80 colunas, mas a consulta utiliza apenas duas, o Parquet pode permitir uma leitura muito mais seletiva do que um arquivo textual tradicional.

Essa é uma de suas principais vantagens.

9. Lendo e escrevendo Parquet

Leitura:

df_parquet = spark.read.parquet( "/data/vendas_parquet" ) 

Escrita:

( df_csv.write .mode("overwrite") .parquet("/data/vendas_parquet") ) 

Agora temos um dataset armazenado em formato colunar.

O schema é preservado nos metadados do formato.

Podemos inspecioná-lo:

df_parquet.printSchema() 

E executar:

from pyspark.sql.functions import col, sum as spark_sum resultado = ( df_parquet .filter(col("estado") == "SP") .groupBy("cliente") .agg( spark_sum("valor").alias("total") ) ) resultado.show() 

Essa consulta pode se beneficiar de diferentes otimizações de leitura e execução.

10. Column Pruning

Column Pruning significa evitar a leitura de colunas que não são necessárias para a consulta.

Imagine um dataset com:

customer_id customer_name email phone address city state country product_id product_name quantity price discount tax shipping ... 

Mas a consulta utiliza apenas:

resultado = df_parquet.select( "customer_id", "price" ) 

Com um formato colunar e um leitor compatível, Spark pode solicitar apenas as colunas necessárias.

Conceitualmente:

PARQUET DATASET | +-- customer_id [READ] +-- customer_name +-- email +-- phone +-- address +-- state +-- price [READ] +-- tax +-- shipping 

Isso pode reduzir leitura, transferência e trabalho de decodificação.

A economia real depende do layout físico, da compressão, do sistema de armazenamento e do plano de execução.

11. Predicate Pushdown

Outro conceito importante é Predicate Pushdown.

Imagine:

filtrado = df_parquet.filter( col("estado") == "SP" ) 

O Spark pode encaminhar filtros compatíveis ao leitor da fonte.

Com Parquet, estatísticas e metadados podem ajudar a evitar a leitura de determinados grupos de dados que não satisfazem o filtro.

Conceitualmente:

PARQUET FILE | +-- Row Group A: estado = RJ | +-- Row Group B: estado = SP | +-- Row Group C: estado = MG 

Para uma consulta restrita a SP, metadados adequados podem permitir ignorar alguns grupos.

Mas há uma ressalva importante:

Predicate Pushdown não significa que todo filtro será resolvido integralmente pelo armazenamento.

O Spark ainda pode precisar avaliar linhas após a leitura.

O resultado depende do predicado, do formato, das estatísticas disponíveis e do leitor utilizado.

12. Como verificar o plano de leitura

Podemos utilizar:

filtrado.explain("formatted") 

No plano físico, vale procurar informações como:

ReadSchema PushedFilters PartitionFilters DataFilters 

Esses campos ajudam a entender quais colunas estão sendo solicitadas e como os filtros estão sendo tratados.

Por exemplo, um ReadSchema contendo apenas duas colunas indica que a leitura foi planejada para solicitar somente essas colunas.

Já PushedFilters mostra filtros encaminhados à fonte.

Entretanto, a presença de um filtro no plano não comprova, sozinha, quantos bytes foram efetivamente economizados.

Para isso, precisamos combinar análise do plano com métricas de execução e medições do armazenamento.

13. ORC: outro formato colunar importante

ORC — Optimized Row Columnar — também é um formato colunar voltado a workloads analíticos.

Assim como Parquet, oferece recursos como:

  • Armazenamento tipado.
  • Compressão.
  • Estatísticas.
  • Leitura seletiva.
  • Organização eficiente para consultas analíticas.

Exemplo:

df_orc = spark.read.orc( "/data/vendas_orc" ) 

Escrita:

( df_csv.write .mode("overwrite") .orc("/data/vendas_orc") ) 

ORC possui forte presença histórica no ecossistema Hive e em determinadas arquiteturas analíticas.

Parquet e ORC são ambos formatos colunares capazes de oferecer boa performance.

A escolha deve considerar o ecossistema, a compatibilidade e os workloads reais.

Não existe um vencedor universal.

14. Comparando os quatro formatos

Característica CSV JSON Parquet ORC
Representação Texto tabular Texto estruturado Binário colunar Binário colunar
Schema tipado embutido Não Não como contrato rígido Sim Sim
Estruturas aninhadas Limitadas Sim Sim Sim
Leitura seletiva de colunas Limitada Limitada Sim Sim
Compressão e codificação colunar Não Não Sim Sim
Uso frequente Intercâmbio APIs e eventos Data Lakes analíticos Analytics e ecossistema Hive
Facilidade de leitura humana Alta Alta Baixa Baixa

A escolha depende da finalidade.

Para integração simples entre sistemas, CSV pode ser suficiente.

Para eventos e APIs, JSON pode ser conveniente.

Para processamento analítico recorrente, Parquet e ORC frequentemente oferecem vantagens importantes.

15. Compressão: menos bytes não significa sempre menos tempo

Formatos colunares podem utilizar diferentes codecs de compressão.

No Parquet, por exemplo, podemos configurar:

( df.write .option("compression", "snappy") .mode("overwrite") .parquet("/data/vendas_snappy") ) 

Também existem outros codecs, conforme o ambiente e a versão utilizados.

Compressão envolve um trade-off:

Menos dados armazenados | v Menos bytes para transferir | v Mais trabalho de compressão/descompressão 

Em determinadas situações, reduzir I/O compensa o custo de CPU.

Em outras, codecs mais pesados podem aumentar a latência.

Por isso, a escolha deve considerar:

  • Frequência de leitura.
  • Frequência de escrita.
  • Custo de armazenamento.
  • CPU disponível.
  • Latência.
  • Taxa de compressão.

16. Partition Pruning

Agora precisamos distinguir dois conceitos que possuem nomes semelhantes.

Na Parte 3, estudamos as partições utilizadas pelo Spark para distribuir o processamento.

Aqui vamos falar também de particionamento físico de datasets por diretórios.

Imagine um Data Lake organizado assim:

vendas/ ano=2025/ mes=01/ mes=02/ ano=2026/ mes=01/ mes=02/ 

Podemos ler:

df = spark.read.parquet( "/data/vendas" ) 

E filtrar:

resultado = df.filter( (col("ano") == 2026) & (col("mes") == 2) ) 

Quando o particionamento é reconhecido corretamente, o Spark pode evitar a leitura de diretórios que não correspondem aos filtros.

Esse mecanismo é conhecido como Partition Pruning.

17. Predicate Pushdown vs. Partition Pruning

Esses mecanismos são diferentes.

Partition Pruning utiliza informações de particionamento do dataset para eliminar arquivos ou diretórios que não precisam ser considerados.

Predicate Pushdown encaminha predicados ao leitor da fonte para permitir otimizações adicionais.

Conceitualmente:

QUERY | v Partition Pruning | v Relevant Files | v Predicate Pushdown | v Relevant Data Groups / Records | v Spark Processing 

Os dois podem trabalhar juntos.

E podem reduzir significativamente o volume de dados efetivamente processado.

18. Escrevendo dados particionados

Exemplo:

( df.write .mode("overwrite") .partitionBy("ano", "mes") .parquet("/data/vendas_particionadas") ) 

Isso pode gerar uma estrutura semelhante a:

vendas_particionadas/ ano=2025/ mes=1/ part-00000-... ano=2026/ mes=1/ part-00000-... mes=2/ part-00000-... 

A quantidade de arquivos depende do particionamento de execução, da distribuição dos dados e da escrita.

O partitionBy() não garante um único arquivo por diretório.

Essa distinção é importante.

19. O perigo do particionamento excessivo

Imagine particionar dados por:

customer_id 

quando existem milhões de clientes.

Podemos acabar criando um número enorme de diretórios e arquivos pequenos.

Conceitualmente:

customer_id=1/ customer_id=2/ customer_id=3/ ... customer_id=1000000/ 

Isso pode gerar:

  • Sobrecarga de metadados.
  • Listagem de muitos objetos.
  • Arquivos pequenos.
  • Maior custo operacional.
  • Planejamento de leitura mais caro.

Portanto:

Particionar fisicamente um Data Lake não significa simplesmente escolher todas as colunas utilizadas em filtros.

Precisamos avaliar cardinalidade, distribuição, volume por partição e padrões reais de consulta.

20. Small Files Problem

Um dos problemas mais comuns em Data Lakes é o excesso de arquivos pequenos.

Imagine dois datasets com o mesmo volume total.

Dataset A

100 arquivos de 100 MB 

Total aproximado:

10 GB 

Dataset B

100.000 arquivos de 100 KB 

Total aproximado:

10 GB 

Apesar do volume semelhante, o comportamento pode ser muito diferente.

No Dataset B, Spark e o sistema de armazenamento precisam lidar com um número muito maior de objetos.

Isso pode aumentar custos de:

  • Listagem.
  • Metadados.
  • Abertura de arquivos.
  • Planejamento.
  • Scheduling.
  • Operações remotas.

Em armazenamento de objetos, esse overhead pode ser especialmente relevante.

21. Por que arquivos pequenos aparecem?

Existem várias causas possíveis:

  • Microbatches frequentes.
  • Escritas muito particionadas.
  • Muitos Executors escrevendo simultaneamente.
  • Particionamento por colunas de alta cardinalidade.
  • Pipelines incrementais sem compactação.
  • Estratégias inadequadas de saída.

Imagine um pipeline que escreve pequenos arquivos a cada minuto.

Mesmo que cada execução seja rápida, o acúmulo ao longo de meses pode produzir uma estrutura difícil de consultar.

Por isso, otimização de armazenamento não é apenas uma atividade pontual.

Também é manutenção operacional.

22. repartition() e coalesce()

Spark oferece mecanismos para modificar o número de partições de um DataFrame.

Exemplo:

df_reparticionado = df.repartition(16) 

Essa operação normalmente envolve redistribuição de dados, podendo introduzir shuffle.

Já:

df_reduzido = df.coalesce(4) 

é frequentemente utilizada para reduzir o número de partições sem um shuffle completo.

Mas existe uma ressalva:

Reduzir partições não significa necessariamente equilibrar os dados.

coalesce() pode preservar desequilíbrios e reduzir o paralelismo.

Portanto, a escolha depende do objetivo.

23. Controlando arquivos de saída

Imagine que queremos escrever um dataset com um número mais controlado de partições de execução.

Podemos utilizar:

( df.repartition(16) .write .mode("overwrite") .parquet("/data/saida") ) 

Em uma escrita simples, isso pode produzir aproximadamente um arquivo de dados por partição de escrita, mas o resultado não deve ser tratado como garantia universal.

A quantidade real de arquivos pode variar por causa de particionamento físico, limites de registros por arquivo e comportamento da plataforma.

Também existe:

( df.write .option("maxRecordsPerFile", 500_000) .mode("overwrite") .parquet("/data/saida") ) 

Esse parâmetro limita a quantidade de registros por arquivo, mas não garante um tamanho específico em MB.

O tamanho depende da estrutura e da compressibilidade dos dados.

24. Por que coalesce(1) pode ser perigoso?

É comum encontrar:

df.coalesce(1).write.csv( "/data/saida" ) 

A intenção geralmente é produzir um único arquivo.

Para datasets pequenos, isso pode ser aceitável.

Mas para grandes volumes, concentrar a escrita em uma única partição pode criar um gargalo.

Conceitualmente:

100 Partitions | v coalesce(1) | v 1 Output Partition | v Limited Parallelism 

Isso pode aumentar o tempo de escrita e a pressão de recursos.

Além disso, a API de escrita Spark normalmente produz um diretório de saída contendo arquivos de dados e metadados, não necessariamente um arquivo com o nome exato informado.

Em produção, geralmente prefiro manter a escrita distribuída e utilizar estratégias apropriadas de compactação quando necessário.

25. Um experimento reproduzível

Vamos construir um pequeno laboratório para comparar CSV, JSON, Parquet e ORC.

O objetivo não é provar que um formato será sempre mais rápido.

É aprender a medir.

Primeiro, criamos um dataset sintético:

from pyspark.sql import SparkSession from pyspark.sql.functions import ( col, concat, lit, pmod ) spark = ( SparkSession.builder .master("local[*]") .appName("BenchmarkFormatos") .getOrCreate() ) base = spark.range(0, 1_000_000) dados = base.select( col("id"), concat( lit("cliente_"), col("id").cast("string") ).alias("cliente"), pmod(col("id"), lit(27)) .cast("int") .alias("estado_codigo"), (pmod(col("id"), lit(10_000)) / 100.0) .alias("valor") ) 

Agora escrevemos nos quatro formatos:

diretorio = "/tmp/spark-format-benchmark" ( dados.write .mode("overwrite") .option("header", True) .csv(f"{diretorio}/csv") ) ( dados.write .mode("overwrite") .json(f"{diretorio}Source:https://dev.to/renanpyd/como-aprendi-apache-spark-parte-12-csv-json-parquet-e-orc-performance-comeca-no-armazenamento-5b7l

Related posts