Estudo Aprofundado
PySpark
Da arquitetura do Spark ao Structured Streaming, passando por DataFrame API, Spark SQL, performance e
integração com o ecossistema Databricks (Delta Lake,
Unity Catalog).
Cada tópico traz exemplo prático, quando usar e link para a documentação oficial.
pip install pysparkdf.explain(True) antes de otimizar às cegasFundamentos
Como o Spark pensa antes de escrever a primeira linha
Todo programa PySpark começa por uma SparkSession, o ponto de entrada único para DataFrame, SQL e streaming.
O driver planeja o trabalho e distribui tarefas para os executors, que rodam em paralelo nos nós do cluster.
No Databricks a sessão (spark) já vem criada automaticamente em cada notebook.
from pyspark.sql import SparkSession spark = (SparkSession.builder .appName("estudo-pyspark") .getOrCreate()) # No Databricks, `spark` já existe pronto no notebook — não precisa criar
RDD (Resilient Distributed Dataset) é a estrutura de dados original do Spark: uma coleção distribuída sem schema, manipulada com funções Python puras. DataFrame é construído sobre o RDD, tem schema (colunas e tipos) e passa pelo otimizador Catalyst — na prática, roda mais rápido que o RDD equivalente na quase totalidade dos casos.
# RDD — baixo nível, sem otimizador rdd = spark.sparkContext.textFile("/dados/log.txt") contagem = rdd.flatMap(lambda l: l.split(" ")).count() # DataFrame — schema + Catalyst Optimizer df = spark.read.json("/dados/log.json") df.groupBy("nivel").count().show()
Transformações (filter, select, join) não executam nada — só constroem um plano lógico.
Só quando uma ação (show, count, write) é chamada, o Catalyst Optimizer reescreve o plano lógico em plano físico e o Spark executa de fato.
select, filter, withColumn, join, groupBy, orderByshow, count, collect, take, write, toPandasdf.explain(True) mostra plano lógico, otimizado e físico lado a ladoDataFrame API
O dia a dia de quem escreve PySpark
spark.read e df.write são os pontos de entrada e saída de qualquer pipeline.
O formato muda o método (.parquet(), .json(), .table()) ou o .format() genérico.
inferSchema ainda soma uma passada extra só para detectar os tipos. Por isso a recomendação padrão é declarar o schema explicitamente com StructType em produção.delta (formato padrão das tabelas) em vez de CSV/JSON para dados que serão lidos/atualizados repetidamente.df = (spark.read .format("parquet") .option("mergeSchema", True) .load("/Volumes/raw/vendas/")) (df.write .format("delta") .mode("overwrite") .saveAsTable("bronze.vendas"))
A maior parte da manipulação de colunas passa pelo módulo pyspark.sql.functions, importado por convenção como F para não sobrescrever funções nativas do Python.
F.col("valor") > 100 não é avaliada na hora — ela constrói uma árvore de expressão (um objeto Column) que fica anexada ao plano lógico. É justamente por existir essa árvore, e não um valor Python já calculado, que o Catalyst consegue mover um filtro para antes de um join ou empurrá-lo para dentro do leitor de Parquet — otimizações que seriam impossíveis se a filtragem já tivesse acontecido em Python puro.import pyspark.sql.functions as F resultado = (df .select("cliente_id", "valor", "data") .filter(F.col("valor") > 100) .withColumn("ano", F.year("data")) .withColumnRenamed("valor", "valor_total"))
select / filter (where) / withColumn / drop / alias / castF.col("nome") em vez de string solta em expressões complexas
groupBy().agg() resume linhas em grupos. Já as window functions calculam algo por linha sem colapsar o DataFrame — essencial para ranking, acumulados e comparação com a linha anterior.
groupBy().agg(), o Spark faz uma agregação parcial dentro de cada partição antes do shuffle (equivalente a um combiner do MapReduce), reduzindo o volume de dados que trafega pela rede. Já uma window function não colapsa linhas: ela particiona e ordena os dados (também via shuffle, quando partitionBy é usado) mas mantém uma linha de saída para cada linha de entrada, com o valor da função calculado sobre a janela ao redor dela.from pyspark.sql.window import Window import pyspark.sql.functions as F # Agregação simples vendas_por_regiao = df.groupBy("regiao").agg(F.sum("valor").alias("total")) # Ranking por partição (window function) janela = Window.partitionBy("regiao").orderBy(F.col("valor").desc()) top3 = (df .withColumn("rank", F.row_number().over(janela)) .filter(F.col("rank") <= 3))
Join é a operação mais cara de um pipeline Spark porque normalmente exige shuffle (redistribuir dados entre executors). Quando um dos lados é pequeno, um broadcast join evita o shuffle inteiramente.
ANALYZE TABLE, e o AQE pode revisar essa escolha em tempo de execução com números reais.from pyspark.sql.functions import broadcast # Join padrão (inner, left, right, outer, semi, anti) resultado = pedidos.join(clientes, "cliente_id", "left") # Broadcast join — clientes é pequena, evita shuffle resultado = pedidos.join(broadcast(clientes), "cliente_id")
Dados vindos de APIs e sistemas de origem raramente são planos: struct representa um objeto aninhado com campos nomeados, array uma lista de valores do mesmo tipo, e map um dicionário chave-valor — os três aparecem o tempo todo ao ler JSON. O acesso a um campo de struct usa notação de ponto (coluna.campo), e funções como explode, array_contains e as higher-order functions (transform, filter) operam sobre arrays e maps sem precisar "achatar" o DataFrame manualmente em Python.
StructType do schema como qualquer outro tipo — não são um recurso à parte, e o Catalyst consegue fazer pruning até de campos individuais dentro de um struct ao ler Parquet/Delta (nested column pruning), lendo só os campos realmente usados. explode é uma transformação especial: para cada linha com um array de N elementos, ela gera N linhas de saída — é a única operação comum da DataFrame API que aumenta o número de linhas.explode numa coluna com arrays de tamanho muito variável (um pedido com 2 itens, outro com 50 mil) é uma fonte clássica de skew — a linha do array gigante vira uma partição desproporcional depois do explode.import pyspark.sql.functions as F df2 = (df .select("pedido_id", "cliente.nome", "cliente.email") # dot notation em struct .withColumn("item", F.explode("itens")) # 1 linha por item do array .withColumn("tem_desconto", F.array_contains("tags", "promo")))
Spark SQL
Misturando SQL com PySpark no mesmo pipeline
Qualquer DataFrame pode virar uma view temporária e ser consultado com SQL puro — os dois caminhos (DataFrame API e SQL) passam pelo mesmo Catalyst Optimizer, então não há perda de performance ao escolher um ou outro.
global_temp). O texto SQL passa por um parser próprio do Spark (baseado em ANTLR) que gera exatamente o mesmo tipo de plano lógico não resolvido que as chamadas da DataFrame API geram — por isso as duas formas de escrever a mesma query têm performance idêntica.df.createOrReplaceTempView("vendas_tmp") resultado = spark.sql(""" SELECT regiao, SUM(valor) AS total FROM vendas_tmp GROUP BY regiao ORDER BY total DESC """)
Performance e Otimização
Onde a maioria dos problemas de PySpark realmente mora
repartition() faz shuffle completo para redistribuir dados (aumenta ou diminui partições).
coalesce() só junta partições existentes, sem shuffle — muito mais barato para reduzir o número de arquivos de saída.
spark.sql.shuffle.partitions (200 por padrão) para decidir em quantas partições reparticionar os dados — um número fixo, pensado para um volume médio de dados, que raramente é ideal para todo tipo de carga. O recurso coalesce automático de partições pós-shuffle do AQE existe justamente para corrigir esse valor fixo em tempo real, sem que você precise acertar o número manualmente.# Redistribui em 200 partições por cliente_id (shuffle) df2 = df.repartition(200, "cliente_id") # Reduz para 8 arquivos de saída sem shuffle df2.coalesce(8).write.format("delta").save("/tmp/saida")
Como o Spark é lazy, um DataFrame usado várias vezes seria recalculado do zero a cada ação. cache() guarda o resultado em memória (com fallback em disco) para reaproveitar entre ações.
cache() é um atalho para persist(StorageLevel.MEMORY_AND_DISK): tenta guardar em memória e usa disco como fallback quando não cabe. Outros níveis (MEMORY_ONLY, DISK_ONLY, variantes _SER serializadas) trocam velocidade de acesso por uso de memória. Como o Spark também é lazy para cache, nada é de fato armazenado até uma ação materializar o DataFrame — por isso é comum ver um count() logo após o cache(), só para forçar essa materialização..unpersist() quando não precisar mais — memória de cache não liberada é uma causa comum de OOM em cluster.df_base = spark.read.table("silver.pedidos").cache() df_base.count() # materializa o cache total_a = df_base.filter("regiao = 'A'").count() total_b = df_base.filter("regiao = 'B'").count() df_base.unpersist() # libera a memória
df.explain(True) mostra o plano lógico, o plano lógico otimizado e o plano físico de uma query.
O Adaptive Query Execution (AQE), ativo por padrão, reotimiza o plano em tempo de execução usando estatísticas reais coletadas após cada shuffle — troca sort-merge join por broadcast join, ajusta o número de partições pós-shuffle e trata skew automaticamente.
df.explain(True) # plano lógico, otimizado e físico # AQE já vem ativo por padrão; para conferir: spark.conf.get("spark.sql.adaptive.enabled")
Shuffle é a redistribuição de dados entre executors (em join, groupBy, repartition) — a operação mais cara do Spark, com I/O de disco e rede. Skew acontece quando uma chave concentra dados demais, deixando uma única tarefa muito mais lenta que as outras. É a causa nº 1 de "job travado em 199/200 tasks".
# Salting manual para quebrar uma chave muito concentrada import pyspark.sql.functions as F df_salgado = df.withColumn( "chave_salgada", F.concat(F.col("chave"), F.lit("_"), (F.rand() * 10).cast("int")) )
Além do broadcast join automático, você pode fazer o broadcast manual de qualquer valor somente-leitura — um dicionário Python pequeno, por exemplo — para que ele seja copiado uma única vez para cada executor. Accumulators são o caminho inverso: contadores de escrita-only que cada executor incrementa localmente e que o driver soma automaticamente no final, úteis para métricas simples como "quantas linhas vieram com um campo inválido".
sc.broadcast(valor)) é serializado uma única vez e fica em cache em cada executor pelo tempo de vida da aplicação — diferente de uma variável Python comum referenciada dentro de uma UDF, que seria serializada de novo a cada tarefa. Accumulators só têm garantia de valor correto quando incrementados dentro de ações; dentro de transformações, o Spark pode reexecutar uma tarefa (por falha ou execução especulativa) e incrementar o accumulator mais de uma vez para a mesma linha.tabela_cambio = sc.broadcast({"USD": 5.10, "EUR": 5.55}) linhas_invalidas = sc.accumulator(0) @F.udf("double") def converter(valor, moeda): if moeda not in tabela_cambio.value: linhas_invalidas.add(1) return None return valor * tabela_cambio.value[moeda]
A memória de heap de um executor é dividida pelo Unified Memory Manager entre memória de execução (usada por shuffles, joins, ordenações e agregações) e memória de armazenamento (cache/persist), com fronteira dinâmica entre as duas. Quando uma operação precisa de mais memória de execução do que está disponível, o Spark não quebra na hora — ele derrama (spill) parte dos dados para disco e continua, só que bem mais devagar.
spark.memory.fraction; a fração mínima garantida para cache é controlada por spark.memory.storageFraction. Ativar memória off-heap (spark.memory.offHeap.enabled) tira parte da memória de execução da gestão do garbage collector da JVM, reduzindo pausas de GC em cargas pesadas de shuffle. O Photon vai além: gerencia sua própria memória fora do heap da JVM inteiramente, para os operadores que suporta.OutOfMemoryError no driver quase sempre vem de collect(), toPandas() ou um broadcast maior do que o esperado trazendo dados demais para um único processo — raramente é falta de memória nos executors.# Spill visível no Spark UI (aba Stages), nas colunas # "Shuffle Spill (Memory)" e "Shuffle Spill (Disk)" spark.conf.get("spark.memory.fraction") # padrão: 0.6 spark.conf.get("spark.memory.offHeap.enabled") # padrão: false
A aba Jobs mostra um job por ação disparada; Stages quebra cada job nos pontos de shuffle, com duração, dados lidos/escritos e spill por tarefa; a aba SQL / DataFrame mostra o plano físico com métricas reais sobrepostas em cada operador; e Executors mostra uso de memória e atividade de garbage collection por executor.
explain(True), só que com números reais de execução em vez de estimativas — dá pra ver exatamente em qual operador (um join, uma agregação, um scan) o tempo está concentrado, sem precisar ler o plano físico em texto.Escritas frequentes (streaming, jobs incrementais) tendem a gerar muitos arquivos pequenos numa tabela Delta, deixando leituras mais lentas. OPTIMIZE compacta esses arquivos em arquivos maiores; ZORDER BY reorganiza os dados dentro dos arquivos para que valores próximos de uma coluna fiquem nos mesmos arquivos, melhorando o data skipping em filtros por essa coluna.
-- Compactação + Z-Order (tabelas existentes) OPTIMIZE silver.pedidos ZORDER BY (cliente_id); -- Liquid Clustering (recomendado para tabelas novas) CREATE TABLE silver.pedidos_v2 CLUSTER BY (cliente_id);
Photon é o motor de execução vetorizado do Databricks, escrito em C++ e compatível com as mesmas APIs Spark/DataFrame — não exige reescrever código, só ativar no cluster ou warehouse. Em vez de processar linha a linha na JVM, ele processa lotes de dados em formato colunar usando instruções vetorizadas (SIMD) da CPU.
Structured Streaming
Dados contínuos tratados como uma tabela infinita
A mesma DataFrame API usada em batch funciona em streaming — o Spark trata o stream como uma tabela que cresce infinitamente. O checkpoint guarda o progresso (offsets processados) para garantir tolerância a falhas e semântica exactly-once.
stream = (spark.readStream .format("cloudFiles") .option("cloudFiles.format", "json") .load("/Volumes/raw/eventos/")) (stream.writeStream .format("delta") .option("checkpointLocation", "/Volumes/checkpoints/eventos") .trigger(processingTime="30 seconds") .toTable("bronze.eventos"))
Em streaming, eventos podem chegar atrasados. O watermark define até quando o Spark espera dados atrasados antes de descartar o estado de uma janela — sem ele, o estado acumulado em memória cresceria para sempre.
import pyspark.sql.functions as F agregado = (stream .withWatermark("timestamp_evento", "10 minutes") .groupBy(F.window("timestamp_evento", "5 minutes"), "tipo") .count())
Structured Streaming só tem sink nativo para um conjunto limitado de formatos (Delta, Kafka, console, memory). foreachBatch dá acesso a cada micro-batch como um DataFrame batch comum dentro de uma função Python, liberando o uso de qualquer API batch — grava em múltiplas tabelas, faz upsert via MERGE, escreve em JDBC, ou manda o mesmo batch para destinos diferentes.
foreachBatch pode ser chamada mais de uma vez para o mesmo batch_id em cenários de falha e reprocessamento — o Spark garante que cada micro-batch é tentado ao menos uma vez, não que a função roda exatamente uma vez. Para semantica exactly-once de ponta a ponta, as escritas dentro da função precisam ser idempotentes: um MERGE que checa o batch_id já processado, em vez de um INSERT simples.foreachBatch, um reprocessamento após falha pode gravar o mesmo batch duas vezes no destino.def escrever_batch(df_batch, batch_id): (DeltaTable.forName(spark, "silver.pedidos") .alias("t") .merge(df_batch.alias("s"), "t.id = s.id AND t.batch_id = s.batch_id") .whenNotMatchedInsertAll() .execute()) stream.writeStream.foreachBatch(escrever_batch).start()
PySpark no ecossistema Databricks
UDFs, Pandas API on Spark e Delta Lake
Uma UDF Python roda linha a linha, com serialização entre JVM e Python a cada chamada — lenta. Uma Pandas UDF processa em lotes (batches) usando Apache Arrow, o que a torna até 100x mais rápida.
pyspark.sql.functions — para tudo que já é nativo (soma, string, data), use a função do Spark, não uma UDF.import pandas as pd import pyspark.sql.functions as F from pyspark.sql.types import DoubleType # Pandas UDF — processa em lote via Arrow @F.pandas_udf(DoubleType()) def com_desconto(valor: pd.Series) -> pd.Series: return valor * 0.9 df.withColumn("valor_final", com_desconto("valor"))
Permite escrever com a sintaxe do pandas (antigo projeto Koalas) rodando distribuído no Spark por baixo — bom caminho de migração para quem já tem código pandas e precisa escalar sem reescrever tudo em DataFrame API.
pyspark.pandas mantém um InternalFrame que embrulha um DataFrame Spark comum, mas acrescenta uma coluna de ordenação implícita — necessária porque o Spark, ao contrário do pandas, não garante ordem estável entre partições distribuídas. É essa camada que permite emular o comportamento posicional do pandas (como iloc) em cima de um motor que naturalmente não tem noção de posição.import pyspark.pandas as ps psdf = ps.read_parquet("/Volumes/raw/vendas/") psdf["margem"] = psdf["receita"] - psdf["custo"] print(psdf.groupby("regiao")["margem"].mean())
O formato padrão de tabela no Databricks. A biblioteca delta-spark expõe DeltaTable, usado para upsert (MERGE), viajar no tempo entre versões e compactar arquivos pequenos.
_delta_log, uma sequência de arquivos JSON e checkpoints Parquet) registra cada commit feito na tabela e é a fonte única de verdade sobre seu estado atual. As garantias ACID vêm de controle de concorrência otimista: um writer lê a versão mais recente, escreve seus arquivos de dados, e só confirma o commit depois de checar se nenhum outro writer commitou uma mudança conflitante nesse intervalo — se houve conflito, a escrita falha e pode ser repetida, em vez de corromper a tabela.from delta.tables import DeltaTable tabela = DeltaTable.forName(spark, "silver.clientes") (tabela.alias("t") .merge(novos_dados.alias("s"), "t.id = s.id") .whenMatchedUpdateAll() .whenNotMatchedInsertAll() .execute()) # Time travel spark.read.format("delta").option("versionAsOf", 3).table("silver.clientes")
Por padrão, o Delta aplica schema enforcement: uma escrita com uma coluna nova ou tipo incompatível falha, protegendo a tabela contra corrupção silenciosa. mergeSchema (adiciona colunas novas automaticamente num append) e overwriteSchema (substitui o schema inteiro num overwrite) relaxam essa proteção quando a evolução é esperada.
addNewColumns (padrão) reinicia o stream automaticamente ao encontrar uma coluna nova, aplicando o novo schema a partir dali; rescue guarda qualquer dado que não bate com o schema esperado numa coluna à parte (_rescued_data) em vez de falhar ou descartar; failOnNewColumns e none dão mais controle explícito para pipelines onde uma mudança de schema deveria virar um evento, não ser absorvida silenciosamente.mergeSchema=true aceita colunas novas sem perguntar — ótimo para flexibilidade, mas pode mascarar uma mudança de schema upstream que deveria virar um alerta. Em pipelines críticos, prefira schema explícito + o modo rescue do Auto Loader a um mergeSchema cego.(df.write .format("delta") .option("mergeSchema", True) .mode("append") .saveAsTable("silver.pedidos")) # Auto Loader — evolução de schema em streaming (spark.readStream.format("cloudFiles") .option("cloudFiles.format", "json") .option("cloudFiles.schemaEvolutionMode", "rescue") .load("/Volumes/raw/pedidos/"))
Transformações PySpark são mais fáceis de testar quando são funções puras — recebem um DataFrame e devolvem outro, sem ler ou escrever tabelas por dentro. Isso permite testar a lógica com pytest e dados de exemplo pequenos, sem precisar de um cluster real nem de dados de produção.
scope="session") cria uma SparkSession local (master("local[*]")) uma única vez para toda a suíte de testes, evitando o custo de inicializar o Spark a cada teste. Para comparar DataFrames, o Spark 4.x já traz nativamente pyspark.testing.assertDataFrameEqual; bibliotecas como chispa fazem o mesmo com mensagens de diff mais legíveis quando o teste falha, mostrando exatamente quais linhas e colunas divergem.# tests/conftest.py import pytest from pyspark.sql import SparkSession @pytest.fixture(scope="session") def spark(): return SparkSession.builder.master("local[*]").getOrCreate() # tests/test_transforms.py from pyspark.testing import assertDataFrameEqual from meu_pacote.transforms import com_desconto def test_com_desconto(spark): entrada = spark.createDataFrame([(100.0,)], ["valor"]) esperado = spark.createDataFrame([(90.0,)], ["valor_final"]) assertDataFrameEqual(com_desconto(entrada), esperado)