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}/json")
)
(
dados.write
.mode("overwrite")
.parquet(f"{diretorio}/parquet")
)
(
dados.write
.mode("overwrite")
.orc(f"{diretorio}/orc")
)
Esse diretório é apropriado para um experimento local. Em um cluster, os caminhos precisam ser acessíveis conforme o ambiente de execução.
26. Medindo o tempo de leitura e consulta
Vamos utilizar uma função simples de benchmark.
from time import perf_counter
def medir_consulta(nome, carregar):
inicio = perf_counter()
df = carregar()
resultado = (
df
.filter(col("estado_codigo") == 10)
.select("id", "valor")
.agg({"valor": "sum"})
.collect()
)
duracao = perf_counter() - inicio
print(
f"{nome}: {duracao:.3f}s | "
f"resultado={resultado}"
)
return duracao
Para evitar que o CSV dependa de inferência de schema, utilizamos o schema conhecido:
schema = dados.schema
Agora executamos:
tempos = {}
tempos["CSV"] = medir_consulta(
"CSV",
lambda: (
spark.read
.option("header", True)
.schema(schema)
.csv(f"{diretorio}/csv")
)
)
tempos["JSON"] = medir_consulta(
"JSON",
lambda: (
spark.read
.schema(schema)
.json(f"{diretorio}/json")
)
)
tempos["Parquet"] = medir_consulta(
"Parquet",
lambda: spark.read.parquet(
f"{diretorio}/parquet"
)
)
tempos["ORC"] = medir_consulta(
"ORC",
lambda: spark.read.orc(
f"{diretorio}/orc"
)
)
Os resultados dependem da máquina, do sistema de arquivos, da versão do Spark e das condições de execução.
Não devemos apresentar tempos hipotéticos como resultados medidos.
27. Como tornar o benchmark mais confiável
Uma única execução não é suficiente para conclusões definitivas.
Existem fatores como:
- Inicialização da JVM.
- Aquecimento de código.
- Cache do sistema operacional.
- Cache de metadados.
- Concorrência.
- Variações de disco.
- Quantidade de arquivos.
- Compressão.
- Planejamento da consulta.
Para um benchmark mais rigoroso, devemos:
- Executar uma rodada de aquecimento.
- Repetir cada cenário várias vezes.
- Alternar a ordem dos formatos.
- Registrar mediana e dispersão.
- Comparar volumes físicos.
- Inspecionar os planos de execução.
- Monitorar CPU, I/O e memória.
- Utilizar datasets representativos.
Também é importante separar dois experimentos diferentes:
Benchmark de escrita: mede o custo de produzir cada formato.
Benchmark de leitura: mede o custo de consultar dados já armazenados.
Misturar os dois pode esconder trade-offs importantes.
28. Como inspecionar as otimizações
Vamos comparar os planos físicos.
df_csv = (
spark.read
.option("header", True)
.schema(schema)
.csv(f"{diretorio}/csv")
)
df_parquet = spark.read.parquet(
f"{diretorio}/parquet"
)
Definimos consultas equivalentes:
consulta_csv = (
df_csv
.filter(col("estado_codigo") == 10)
.select("id", "valor")
)
consulta_parquet = (
df_parquet
.filter(col("estado_codigo") == 10)
.select("id", "valor")
)
Agora:
consulta_csv.explain("formatted")
E:
consulta_parquet.explain("formatted")
Compare:
ReadSchema
PushedFilters
DataFilters
A análise mostra como o Spark planeja a leitura de cada formato.
O objetivo não é apenas medir segundos.
É compreender por que os resultados são diferentes.
29. O formato influencia o custo do Data Lake
Imagine um Data Lake que armazena centenas de terabytes.
O formato influencia:
Storage Size
|
v
Data Scanned
|
v
I/O Operations
|
v
Compute Time
|
v
Infrastructure Cost
Um formato colunar bem organizado pode reduzir a quantidade de dados lidos em determinadas consultas.
Isso pode diminuir:
- Tempo de processamento.
- Tráfego de dados.
- Utilização de CPU.
- Custo de armazenamento.
- Custo computacional.
Mas a economia depende do workload.
Uma transformação que precisa ler todas as colunas e todos os registros pode apresentar ganhos diferentes de uma consulta altamente seletiva.
30. Parquet não resolve tudo
É importante evitar a conclusão:
Basta converter tudo para Parquet e a performance estará resolvida.
Não é assim.
Podemos ter um dataset Parquet com:
- Milhões de arquivos pequenos.
- Particionamento físico inadequado.
- Data skew.
- Compressão mal escolhida.
- Schemas inconsistentes.
- Estatísticas pouco úteis.
- Leituras repetidas desnecessárias.
- Joins caros.
- Layout incompatível com os filtros.
O formato é apenas uma dimensão da arquitetura.
Uma organização ruim pode comprometer parte das vantagens do armazenamento colunar.
31. Formato de arquivo vs. formato de tabela
Outra distinção importante nas arquiteturas modernas é separar o formato físico dos arquivos do formato de tabela.
Por exemplo:
Apache Parquet
é um formato de arquivo.
Já tecnologias como:
Apache Iceberg
Delta Lake
Apache Hudi
adicionam mecanismos de gerenciamento de tabelas e metadados, com capacidades que variam entre elas.
Esses mecanismos podem envolver:
- Controle de snapshots.
- Evolução de schema.
- Gerenciamento de arquivos.
- Operações transacionais.
- Evolução de partições.
- Manutenção e compactação.
Muitas dessas tecnologias utilizam Parquet como formato físico de dados.
Portanto:
TABLE FORMAT
|
v
TABLE METADATA
|
v
DATA FILES
|
v
PARQUET
Essa separação é fundamental para compreender arquiteturas Lakehouse.
Mas formatos de tabela merecem um capítulo próprio.
32. Como escolher um formato?
Minha decisão começa pelo objetivo.
Intercâmbio simples entre sistemas
CSV pode ser suficiente quando o contrato é claro e o volume é administrável.
Eventos, APIs e estruturas semiestruturadas
JSON pode ser conveniente, especialmente em camadas de ingestão.
Consultas analíticas recorrentes
Parquet costuma ser uma excelente opção inicial.
Ambientes com integração forte ao ecossistema ORC/Hive
ORC pode ser uma escolha natural.
Tabelas analíticas com requisitos transacionais e evolução operacional
Vale avaliar um formato de tabela apropriado, além do formato físico.
Não se trata de escolher a tecnologia mais sofisticada.
Trata-se de escolher a representação adequada ao uso.
33. Uma arquitetura por camadas
Em um Data Lake moderno, podemos encontrar:
DATA SOURCES
|
+-------------+-------------+
| | |
v v v
APIs Files Events
| | |
+-------------+-------------+
|
v
RAW LAYER
JSON / CSV / etc.
|
v
VALIDATION & ETL
|
v
CURATED LAYER
PARQUET
|
v
ANALYTICAL TABLES
|
v
SQL / BI / ML / AI
Essa é uma arquitetura conceitual, não uma regra universal.
Dependendo do sistema, podemos preservar dados brutos em formatos originais e utilizar formatos colunares em camadas de consumo.
Também podemos utilizar formatos de tabela para gerenciar datasets analíticos.
O importante é que cada camada tenha uma responsabilidade clara.
34. Checklist de produção
Antes de definir o armazenamento de um pipeline Spark, gosto de responder:
- Qual é a origem dos dados?
- O schema é estável?
- Existem estruturas aninhadas?
- Quais colunas são consultadas?
- Quais filtros são mais frequentes?
- Qual é a cardinalidade das colunas candidatas a particionamento?
- Quantos arquivos serão produzidos?
- Qual é o tamanho médio dos arquivos?
- Existe necessidade de compactação?
- Quais codecs são suportados?
- O formato permite column pruning?
- Existe predicate pushdown?
- O particionamento permite pruning?
- Como será tratada a evolução de schema?
- Existe necessidade de transações ou snapshots?
- Qual é o custo de armazenamento e leitura?
- Como a performance será medida?
Essas perguntas ajudam a evitar decisões baseadas apenas em preferência tecnológica.
35. A principal lição deste capítulo
O Spark pode executar exatamente a mesma lógica sobre formatos diferentes.
Mas o trabalho necessário para obter os dados pode variar bastante.
Uma consulta analítica não depende apenas de:
CPU
Memory
Executors
Partitions
Ela também depende de:
File Format
Compression
Metadata
Column Selection
Filter Pushdown
Physical Partitioning
File Size
Por isso:
Performance em Spark começa antes da execução: começa na forma como os dados são armazenados, organizados e disponibilizados para leitura.
36. Como penso hoje
Quando recebo um pipeline Spark lento, não começo necessariamente aumentando recursos.
Primeiro investigo:
Qual formato está sendo lido?
|
v
Quantos arquivos existem?
|
v
Qual é o tamanho médio?
|
v
O schema está correto?
|
v
Todas as colunas são necessárias?
|
v
Os filtros estão sendo aproveitados?
|
v
Existe partition pruning?
|
v
O plano físico está adequado?
|
v
Existe shuffle desnecessário?
|
v
Qual é o gargalo real?
Às vezes, melhorar o layout dos dados traz mais benefício do que adicionar Executors.
E essa é uma das lições mais importantes da Engenharia de Dados moderna.
Próximo artigo
Agora entendemos como CSV, JSON, Parquet e ORC influenciam o processamento.
Mas existe uma evolução importante que precisamos explorar:
Como transformar arquivos em tabelas analíticas confiáveis?
No próximo capítulo, vamos entrar no universo das arquiteturas Lakehouse e dos formatos de tabela.
Vamos estudar:
- Data Lake vs. Data Warehouse vs. Lakehouse.
- Apache Iceberg.
- Delta Lake.
- Apache Hudi.
- Formatos de arquivo vs. formatos de tabela.
- Snapshots.
- Evolução de schema.
- ACID.
- Time Travel.
- Compactação.
- Gerenciamento de metadados.
- Estratégias de atualização incremental.
- Integração com Spark SQL.
A pergunta central será:
Por que armazenar arquivos Parquet não é suficiente para construir uma plataforma analítica transacional e governável?
Série: Como aprendi Apache Spark
← Parte 11: Dimensionamento de recursos, paralelismo e custos
Próximo: Parte 13 — Apache Iceberg, Delta Lake e Hudi: a evolução do Data Lake para Lakehouse
Top comments (0)