Você está na página 1de 179

Tecnologias de Big Data –

Processamento de Dados Massivos

Pedro Lehman Toledo

2022
Tecnologias de Big Data – Processamento de Dados Massivos
Pedro Lehman Toledo
© Copyright do Instituto de Gestão e Tecnologia da Informação.
Todos os direitos reservados.

2
Sumário

Capítulo 1. O cenário de Big Data ..............................................................................6

Onde acessar os códigos ............................................................................................... 6

Big Data: coleta de dados massivos .............................................................................. 6

Processamento distribuído ......................................................................................... 10

Ecossistema Hadoop ................................................................................................... 14

YARN ........................................................................................................................... 17

Capítulo 2. Introdução ao Apache Spark ..................................................................20

O que é o Apache Spark? ............................................................................................ 20

Conceitos básicos de uma aplicação do Spark ........................................................... 25

Transformações, ações e Lazy Evaluation .................................................................. 29

Catalyst Optimizer e o Plano de Execução.................................................................. 31

Download e instalação do Apache Spark .................................................................... 36

Capítulo 3. Manipulando dados com Spark: Parte 1................................................43

DataFrames e Datasets ............................................................................................... 43

Tipos de dados no Spark ............................................................................................. 44

Schema e criação de DataFrames ............................................................................... 47

Leitura e escrita de dados ........................................................................................... 50

Operações básicas com DataFrames .......................................................................... 62

Trabalhando com diferentes tipos de dados .............................................................. 76

Capítulo 4. Manipulando dados com Spark: parte 2................................................89

3
Agrupamento e Agregação ......................................................................................... 89

Window Functions ...................................................................................................... 95

Joins........................................................................................................................... 102

User Defined Functions (UDF) .................................................................................. 107

Capítulo 5. Spark SQL .............................................................................................113

Visão Geral do Spark SQL .......................................................................................... 113

Databases e catalog .................................................................................................. 115

Criando Tabelas e Views ........................................................................................... 117

Fazendo queries no Spark SQL .................................................................................. 120

Capítulo 6. Otimizando Aplicações do Spark .........................................................123

Como configurar e escalar o Spark ........................................................................... 123

Persistência de dados na memória ........................................................................... 129

Estratégias de particionamento de dados ................................................................ 131

Reparticionando DataFrames ................................................................................... 133

Escolhendo o melhor tipo de Join............................................................................. 135

Capítulo 7. Deploy de Aplicações do Spark ............................................................137

Introdução e modos de execução ............................................................................. 137

Desenvolvendo aplicações Spark .............................................................................. 138

Executando o Spark em Clusters .............................................................................. 141

Exemplo Prático: lançando uma aplicação localmente ............................................ 143

Capítulo 8. Spark na Nuvem ...................................................................................148

4
Introdução ao Dataproc e GCS.................................................................................. 148

Criando um Cluster do Dataproc .............................................................................. 156

Acessando dados no GCS .......................................................................................... 165

Acessando os dados com o Spark ............................................................................. 168

Submetendo aplicações no GCP ............................................................................... 171

Capítulo 9. Conclusão .............................................................................................177

Referências………………....................................................................................................178

5
Capítulo 1. O cenário de Big Data

O objetivo desse curso é capacitar e auxiliar profissionais de dados, em especial


engenheiros de dados, no desenvolvimento de pipelines de processamento de dados
massivos, de forma com que esses processos sejam rápidos, eficientes, escaláveis e
executáveis em ambientes locais e em nuvem.

Assim, para que esse objetivo seja atingido, é necessário apresentar alguns
conceitos relacionados à manipulação de grandes quantidades de dados. Eles serão o
fundamento de uma compreensão adequada das tecnologias mais modernas utilizadas
nessa área – consequentemente, possibilitando um olhar crítico e analítico das
abordagens utilizadas em cada situação.

Nesse primeiro capítulo, será apresentado um pouco do contexto de Big Data,


os conceitos básicos de processamento distribuído e o Ecossistema Hadoop, visto que é
uma das primeiras ferramentas para o processamento de dados massivos que surgiram.

Onde acessar os códigos

Todos os recursos utilizados para produzir este material estão disponibilizados


no seguinte repositório do Github:

https://github.com/pltoledo/igti-dados-massivos

Fique à vontade para clonar o repositório e utilizá-lo durante a leitura da


apostila e para fixar ainda mais o aprendizado.

Big Data: coleta de dados massivos

A coleta de dados aparenta ser uma técnica moderna, porém é um método tão
antigo quanto a civilização, e fez-se presente durante grande parte da história da

6
humanidade. Práticas como o censo e a coleta de dados demográficos são utilizadas
desde os tempos antigos, estão registradas em diversos achados arqueológicos datados
de milhares de anos atrás (HAKKERT, 1996). Posteriormente, a coleta e o
armazenamento de dados foram preponderantes para diversas descobertas científicas,
especialmente no campo da astronomia e biologia. Por fim, com a invenção do
computador e o advento da internet, esses processos foram aperfeiçoados a ponto de
possibilitar ao ser humano uma capacidade analítica até então nunca experimentada.

Nos dias de hoje, o termo Big Data tem sido muito utilizado, mas muitas vezes
de uma maneira um tanto liberal – como normalmente acontece quando um conceito é
popularizado, mas não necessariamente entendido. Sua primeira definição formal foi
feita pelo Grupo Gartner, em 2001, e a versão mais atual dessa definição é:

Big Data são ativos de informação de alto volume, velocidade e


variedade, que demandam formas de processamento inovadores e
eficientes em termos de custo, proporcionando melhorias na geração
de insights, tomada de decisão e automatização de processos.
(GARTNER, 2001, tradução nossa)

Após esta, outras definições buscaram enriquecer ainda mais esse conceito:

Big Data refere-se a conjuntos de dados cujo tamanho é além da


capacidade de coleta, armazenamento, administração e análise das
típicas ferramentas de banco de dados.

(MCKINSEY GLOBAL INSTITUTE, 2011, tradução nossa)

(Big Data) É uma prioridade que tem o potencial de mudar


profundamente o cenário competitivo na economia globalmente
integrada de hoje. Além de fornecer formas inovadoras de encarar
desafios de negócio, Big Data e analytics instigam novas formas de

7
transformar processos, organizações, indústrias inteiras e até mesmo
a sociedade. (DEMIRKAN H; et al, 2015, tradução nossa)

Enquanto Big Data, inicialmente, referia-se às unidades de informação


coletadas e processadas massivamente, atualmente esse termo também é comumente
utilizado ao tratar-se de técnicas de análise de comportamento, mineração de dados,
análise de dados, análise preditiva e muitas outras aplicações finais às quais os dados
podem ser ingeridos.

Apesar de ser difícil definir precisamente o que é Big Data em termos do


tamanho dos dados ou o escopo de técnicas utilizadas, há uma concordância na
literatura acerca de algumas características comuns dessa categoria de dados –
descritas, normalmente, pelos famosos V’s do Big Data. Inicialmente, eram três V’s,
depois cinco, e hoje já existem autores tratando de oito e dez termos-chave para
caracterizar esse conceito. No entanto, acredito que os principais são:

• Volume: de onde vem o “Big”. A característica fundamental do Big Data é


que os dados são gerados em volumes cada vez maiores, de forma que
ferramentas mais tradicionais não conseguem processá-los e armazená-los
(pelo menos não de forma eficiente).

• Variedade: os dados são coletados dos mais diversos contextos, de forma


estruturada, semiestruturada e até não estruturada, como áudios, vídeos e
imagens, e até mesmo posts em redes sociais.

• Velocidade: os dados são gerados de forma cada vez mais rápida.


Informações como posts em redes sociais são geradas em tempo real
continuamente, e por isso é necessário que elas sejam analisadas e
processadas com a mesma rapidez.

8
• Veracidade: é necessário se certificar que os dados gerados não estejam
fora de controle. Essa característica se refere à confiabilidade e manutenção
dos dados coletados, o que se mostra um desafio cada vez maior com o
avanço das tecnologias de coleta e armazenamento de dados.

• Valor: é o que amarra todos os outros conceitos e talvez a característica mais


importante para um profissional de dados ter em mente. Não adianta
conseguir tirar o máximo dos outros V 's se não há valor para o negócio em
que os dados estão inseridos. O foco deve estar principalmente na
capacidade do consumidor final de extrair informações relevantes e
impactantes.

Figura 1 – Os 5 V’s do Big Data.

Fonte: Edureka.

Esse último V está diretamente relacionado com a parte final da definição dada
pelo Gartner: após apresentar as principais características da Big Data, é deixado bem
claro que a coleta e processamento desses dados possuem um propósito muito bem

9
definido de gerar valor para um negócio, por meio de ferramentas que possam auxiliar
na tomada de decisão, automatização de processos e insights de negócio.

Ainda que tenha potencial para ser um agente transformador do negócio, o uso
de Big Data demanda atenção e cuidados específicos. Críticos desse conceito apontam
conflitos éticos e sociais advindos de como a coleta de dados em massa pode se
configurar como uma invasão de privacidade das pessoas, especialmente se tratando de
dados que não são completamente anonimizados (BOYD e CRAWFORD, 2012). Isso leva
ao dilema do ponto de vista técnico, uma vez que quanto maior o volume de dados,
maior o desafio de manutenção e segurança, o que pode gerar um cenário de violação
de normas de privacidade estabelecidas ou até mesmo prejudicar a análise desses
dados. Acerca da análise, é válido ressaltar que, mesmo com grandes quantidades de
dados a disposição, ainda é importante ter o entendimento de como os dados foram
gerados e quais suas suposições e vieses, visto que Big Data não é sinônimo para
completude de dados, e considerar a origem da amostra analisada é mais importante
que o seu tamanho (BOYD e CRAWFORD, 2012).

Dessa forma, fica claro que a aplicação de tecnologias de Big Data tem
vantagens e desvantagens. Muitas vezes, “dados pequenos” podem ser suficientes para
responder todas as questões de negócio com uma complexidade menor e risco menor.
A escolha do que usar depende principalmente do objetivo a ser alcançado, e do valor
que isso irá gerar ao final do processo. Na próxima seção, será introduzida uma das
principais ferramentas utilizadas nos softwares modernos de processamento de grandes
volumes de dados.

Processamento distribuído

No início dos anos 2000, enquanto as tecnologias de coleta e armazenamento


tornavam-se cada vez mais baratas e eficientes, os processadores de computadores

10
enfrentavam uma mudança de paradigma: devido a grandes limitações na dissipação de
calor, fabricantes de hardware deixaram de desenvolver processadores individuais que
fossem mais potentes, e passaram a adotar a estratégia de adicionar núcleos paralelos
às CPUs que fossem capazes de serem executadas na mesma velocidade. Esse cenário
foi propício para o surgimento de ideias que fundamentaram todas as tecnologias de
processamento de dados massivos que surgiram posteriormente.

Aliado a isso, em 2005 o Google anunciou a criação do Google File System (GFS),
MapReduce e Bigtable, tecnologias que tinham como objetivo solucionar o problema de
armazenamento e computação de dados que a empresa enfrentava, visto que os bancos
de dados relacionais clássicos não eram capazes de operar com a escala em que seu
popular mecanismo de pesquisa estava sendo desenvolvido.

Enquanto o GFS e o Bigtable providenciaram armazenamento distribuído,


escalável e tolerante a falhas de dados em um cluster, o MapReduce introduziu um novo
paradigma de programação em paralelo para o processamento de dados distribuídos no
GFS e no Bigtable. Esse foi o primeiro software de processamento distribuído a ser
desenvolvido, e, apesar de ter sido um trabalho privado em sua maior parte, foram
publicados artigos que serviram de inspiração para diversas outras ferramentas mais
modernas, como o Hadoop MapReduce e o Apache Spark.

Antes de continuar, é necessário entender o conceito de computação paralela.


Na computação paralela, a execução de uma tarefa é dividida em tarefas menores, que
são designadas aos processadores do computador, de forma que elas possam ser
realizadas de forma simultânea e independente, potencialmente atingindo maior
eficiência e rapidez. O desafio desse tipo de estratégia encontra-se na maneira com que
as tarefas são quebradas e na administração dessas tarefas menores ao longo da
execução, desta forma é necessário atentar-se a como isso é feito em cada aplicação.

11
Na Figura 2 é possível ver um esquema de comparação entre a computação serial e a
computação em paralelo.

Figura 2 – Computação Serial e Paralela.

Fonte: teldat.com.

A computação distribuída segue os mesmos princípios da computação em


paralelo, com a diferença de que a paralelização é feita a partir de uma rede de
computadores interligados (também chamado de cluster), ao invés de ser somente a
nível de processadores de uma máquina. Assim, não só as tarefas são distribuídas ao
longo dos computadores (ou nós), mas os dados também são divididos e enviados via
internet para cada um deles. Essa arquitetura oferece:

• Escalabilidade: para aumentar o poder computacional basta inserir um


novo nó no cluster, o que muitas vezes é mais barato do que realizar um
upgrade no hardware dos nós já incorporados.

12
• Tolerância a falhas: é possível desenhar a arquitetura distribuída de forma
que o cluster seja capaz de incorporar tarefas à outras máquinas, caso o
processamento em um nó seja interrompido, trazendo mais confiabilidade
ao sistema.

Tal como na computação em paralelo, o processamento distribuído de dados


pode se mostrar desafiador na forma de gerenciamento das partições de dados e de
tarefas, uma vez que uma pequena alteração nos dados impacta todo o cluster. Além
disso, devido ao fato de os nós muitas vezes estarem em locais físicos diferentes e de
que os dados são transferidos entre eles por meio da internet, a segurança dos dados
mostra-se um ponto de atenção frequente.

Figura 3 – Computação Distribuída.

Fonte: Khan Academy.

Portanto, caso a tarefa a ser executada seja grande ao ponto de seu


processamento em um único nó não ser rápido e/ou eficiente o bastante, o
processamento distribuído se torna a melhor alternativa.

13
Ecossistema Hadoop

A partir do trabalho desenvolvido pelo Google por meio do GFS e MR, o Yahoo!,
que também enfrentava problemas relacionados a processamento de grandes
quantidades de dados em um mecanismo de pesquisa, criou o projeto Hadoop como a
sua própria abordagem para solucioná-los. As ferramentas desenvolvidas foram bem-
sucedidas, e em 2008, o Hadoop foi liberado como um projeto open source para a
Apache Software Foundation, a partir da qual foi desenvolvido um framework completo
de armazenamento e processamento distribuído, que está entre as mais tradicionais
ferramentas de Big Data. A capacidade de trabalhar com dados massivos – estruturados
e não estruturados, escalabilidade e o fato de ser gratuito, contribuíram para sua rápida
popularização no mercado.

A biblioteca principal conta com os seguintes módulos:

• Hadoop Common: módulo que contém os utilitários comuns a todos os


outros módulos do Hadoop.

• Hadoop Distributed File System (HDFS): módulo que contém as


funcionalidades relacionadas ao armazenamento distribuído de dados.

• Hadoop MapReduce: módulo que oferece serviços de computação


distribuída no ambiente Hadoop.

• Hadoop YARN: módulo que realiza o gerenciamento de recursos e divisão


de tarefas dentro do ambiente distribuído do Hadoop.

Além desses módulos, o framework conta com tecnologias disponíveis para


outros propósitos, como:

14
• Apache HIVE: banco de dados que utiliza uma interface de SQL no ambiente
distribuído.

• Apache Mahout: módulo para a criação de aplicações de machine learning.

• Apache Ambari: serviços de provisionamento, gerenciamento e


monitoramento de clusters no Apache Hadoop.

• Apache Oozie: serviços de agendamento de jobs.

• Apache Zookeeper: módulo para coordenar os serviços do Ecossistema


Hadoop.

Figura 4 – Computação Distribuída.

Fonte: Savvycom.

15
Os componentes apresentados foram concebidos para serem integráveis
dentro do Ecossistema Hadoop, todavia, também é possível utilizá-los em conjunto com
outras tecnologias externas, dependendo da necessidade do usuário.

Hadoop Distributed File System (HDFS)

O HDFS é o sistema de armazenamento de dados do Hadoop e provavelmente


o módulo mais importante de todo o ecossistema. Por meio dele, grandes quantidades
de dados de diferentes fontes, tipos e níveis de estruturação, podem ser armazenados
de maneira distribuída através de um cluster de computadores, em contraste com o
armazenamento de um único nó. Apesar disso, a ferramenta cria um nível de abstração
de forma que o usuário consiga enxergar somente uma grande unidade de
armazenamento.

A arquitetura do HDFS é definida em termos de dois componentes: o


NameNode e o DataNode, que definem uma relação de mestre/escravos entre os
computadores do cluster.

• NameNode: é o gerenciador de recursos e tarefas dentro da rede de


computadores, que não armazena os dados em si, mas guarda informações
relevantes sobre eles (metadados). É muito importante manter o
NameNode tolerante a falhas, realizando backups periódicos do estado do
sistema e dos metadados, já que uma falha nesse nó pode comprometer
todos os dados HDFS.

• DataNode: são os nós que de fato armazenam dados e atendem as


demandas de escrita e leitura dos usuários feitas por meio do NameNode.
Arquivos armazenados são particionados em blocos de dados (tipicamente
de 128GB) e distribuídos ao longo do cluster. Esses blocos também são
replicados e enviados para diferentes nós, segundo um fator de replicação

16
pré-estabelecido, de forma que se uma máquina falhar o dado não é
perdido.

Um ponto importante é que os DataNodes são commodity hardwares, isto é,


infraestrutura de baixo custo e fácil aquisição, o que torna o HDFS acessível
financeiramente e facilmente escalável.

YARN

O YARN (Yet Another Resource Negotiator) é o dispositivo de gerenciamento


de recursos do Ecossistema Hadoop, responsável por controlar os clusters e aplicações
em ambientes distribuídos. Por meio do YARN, é possível criar jobs de processamento
de dados armazenados no HDFS ou na cloud (como S3), utilizando ferramentas como
MapReduce ou até mesmo softwares independentes.

Os principais componentes do YARN são:

• ResourceManager: é o dispositivo responsável por gerenciar os cluster,


agendar a alocação de recursos nos nós e aceitar ou rejeitar aplicações
submetidas pelo cliente.

• NodeManager: presente em todos os nós do cluster, o NodeManager


gerencia os recursos necessários para a execução dos jobs em cada máquina
específica por meio da criação e destruição de containers. Os containers são
uma coleção de recursos físicos em um nó, por meio da qual as tarefas são
executadas.

• ApplicationMaster: presente em todos os nós do cluster, é responsável por


informar o ResourceManager a respeito do status de execução da aplicação
e da necessidade de recursos.

17
As principais vantagens de se utilizar o YARN são sua escalabilidade, alocação
dinâmica de clusters, compatibilidade com versões anteriores do Hadoop e integração
com diferentes ferramentas de processamento para diferentes cargas de trabalho,
sejam elas processamento em batch, streaming ou consultas interativas. Por causa
disso, o YARN é hoje um dos gerenciadores de recursos mais utilizados em ambientes
distribuídos.

MapReduce

O Hadoop MapReduce, assim como a implementação anterior do Google, é a


ferramenta utilizada no processamento paralelo e distribuído de volumes massivos de
dados. O MapReduce funciona em um paradigma baseado na definição dos dados em
pares chave/valor e na realização de duas operações distintas, Map e Reduce, que são
responsáveis por definir os pares e realizar operações de agregação, respectivamente.
Ou seja, inicialmente são definidos inputs das operações a partir da criação de vários
pares de chave e valor, que então são reduzidos a um conjunto menor de pares por meio
de operações de agregação.

Há ainda um processo de ordenação e agrupamento das chaves, que acontece


entre as fases de Map e Reduce, que serve como uma forma de otimizar as operações
de agregação. Abaixo é possível ver um esquema do processo completo:

18
Figura 5 – MapReduce Workflow.

Fonte: Yahoo!.

Apesar de ser capaz de suportar cargas de trabalho pesadas em diferentes tipos


e estruturas de dados, o MapReduce apresenta algumas desvantagens. Damji et al.
(2020), argumentam que o framework apresenta uma complexidade operacional que o
torna difícil de gerenciar, além do modelo de programação disponível na API ser verboso
e requerer muito código preparatório, o que torna a programação difícil e sujeita a erros.
Ainda, como os resultados intermediários de operações no MapReduce são escritos em
disco, um job que necessita de diversas tarefas acaba tendo uma performance baixa
devido às constantes operações de I/O, podendo levar horas e até mesmo dias para ser
concluído.

19
Capítulo 2. Introdução ao Apache Spark

Com o objetivo de criar um dispositivo de processamento distribuído de dados


massivos que fosse mais eficiente, mais simples e mais fácil de usar e aprender que o
Hadoop MapReduce (MR), pesquisadores da UC Berkeley começaram um projeto
chamado Spark, em 2009. O projeto agiu principalmente sob a ótica de pegar ideias do
MR, mas melhorá-las de forma que o Spark pudesse ser altamente tolerante a falhas e
extremamente paralelo, com suporte ao armazenamento em memória de computações
intermediárias entre operações de map e reduce, além de estar disponível em múltiplas
linguagens como um modelo de programação unificado (Damji et al. 2018). O resultado
foi uma ferramenta que, nos seus estágios iniciais, era 10 a 20 vezes mais rápida que o
Hadoop MapReduce para algumas cargas de trabalho. Hoje, na Apache Software
Foundation, o Spark chega a ser até 100 vezes mais rápido.

Neste capítulo serão apresentados os principais conceitos do Apache Spark,


incluindo os principais componentes de uma aplicação, aspectos importantes do
mecanismo de execução e sua forma de funcionamento. Ao final, serão apresentadas as
formas de instalação da ferramenta para máquinas Windows e Linux.

O que é o Apache Spark?

“Rápido como um raio, o Apache Spark é um mecanismo de analytics unificado


para Big Data e machine learning. ” (Databricks, tradução nossa)

O Apache Spark é um framework 100% open source de processamento


distribuído e computação em clusters, projetado especialmente para trabalhar com
quantidades massivas de dados. O framework dispõe de módulos para diferentes
objetivos, como processamento em streaming e computação de grafos, mas que foram
desenvolvidos seguindo um mesmo design. Damji et al. (2018) afirmam que a filosofia

20
do Spark gira em torno de quatro características principais: velocidade, usabilidade,
modularidade e extensibilidade.

Velocidade

O Spark busca entregar velocidade no processamento de dados desde a sua

concepção, e o seu design reflete claramente esse objetivo. As principais características


que proporcionam velocidade à ferramenta são:

• Capacidade de armazenar computações intermediárias na memória,


limitando as operações de I/O.

• Utilização de um Directed Acyclic Graph (DAG) para programar as tarefas a


serem executadas na aplicação, proporcionando um agendamento
otimizado e eficiente das tarefas.

• Otimização dos planos lógicos e físicos de execução, por meio do Catalyst


Optimizer, de forma a sempre executar as cargas de trabalho da forma mais
otimizada possível.

• Utilização de lazy evaluation permite que o Spark armazene todas as


operações sendo realizadas, o que permite fazer uso mais eficiente da
memória e replanejar a ordem das operações.

Abaixo, é possível visualizar benchmarks do Daytona GraySort Contest, um


desafio de processamento em que era necessário ordenar 100 TB de dados. A tabela
aponta que o Apache Spark realizou a operação cerca de 3x mais rápido e utilizando 10x
menos computadores do que o Hadoop MapReduce, instaurando também um novo
recorde mundial para a operação. Também é possível ver o benchmark dessa operação
de ordenação aplicado a um conjunto de dados de 1 PB.

21
Figura 6 – Daytona GraySort Benchmarks.

Fonte: Databricks.

Simplicidade

Todas as operações realizadas no Spark são realizadas sob uma abstração de


dados chamada Resilient Distributed Dataset (RDD), que representa uma coleção
imutável e particionada de registros. No entanto, esse é o nível mais baixo de abstração
dos dados, e nas APIs mais recentes. No lugar de operar diretamente nos RDDs, as
operações são realizadas em DataFrames e/ou Datasets, que são construções já
conhecidas no contexto de análise e processamento de dados, em que estes são
estruturados em linhas e colunas. Além disso, as operações implementadas seguem uma
lógica de programação simples (herdada do SQL, como veremos mais à frente), o que

22
proporciona facilidade de aprendizado tanto para profissionais veteranos, quanto para
iniciantes na área.

Modularidade

Como já foi dito, o framework é dividido em componentes distintos, mas que


atuam sobre um mecanismo unificado disponível para diferentes linguagens de
programação:

Figura 7 – Ecossistema do Spark.

Fonte: Databricks.

Apesar dessa diversidade de linguagens, o modelo de programação é o mesmo


e o usuário deve encontrar pequenas diferenças entre uma linguagem e outra. Essa
padronização faz parte do objetivo do Spark de ser um mecanismo completo, mas ainda
assim unificado, de processamento de dados massivos.

Os módulos do framework são divididos conforme seu objetivo principal:

23
• Spark SQL: módulo direcionado para o processamento de dados
estruturados de forma tabular. Oferece um mecanismo de consultas SQL
interativas num ambiente distribuído. Pode ser integrado com o restante do
ecossistema para construir pipelines completas.

• Structured Streaming: módulo de streaming, que permite a criação de


aplicações para o processamento em tempo real que suportam fluxos
constantes de grandes quantidades de dados.

• Spark ML: módulo de machine learning, que dispõe de uma variedade de


algoritmos implementados e de ferramentas para pré-processamento,
validação e construção de pipelines. Com esse módulo, é possível
desenvolver modelos escaláveis e robustos.

• GraphX: módulo para computação de grafos, que facilita a criação,


transformação e análise de grafos em escala.

Assim, é possível utilizar o Spark para toda a pipeline de dados, sem


necessidade de usar bibliotecas distintas para realizar cargas de trabalho diferentes.

Extensibilidade

O Spark é um framework de computação de dados distribuídos e, diferente do


Apache Hadoop, não se preocupa em fornecer também um dispositivo de
armazenamento, o que permite ao Spark se conectar com muitas fontes de dados, como
Apache Hadoop, Apache Cassandra, Apache HBase, MongoDB, Apache Hive, RDBMSs e
muitos outros. Além disso, o Spark consegue conectar-se com serviços de
armazenamento de dados na nuvem, como Amazon S3 e Azure Storage.

24
Figura 8 – Conectores do Spark.

Fonte: Learning Spark (Cap. 1, p. 6).

Conceitos básicos de uma aplicação do Spark

A arquitetura distribuída do Spark é dividida em dois componentes principais:


o driver e os executores.

Ao submeter uma aplicação, o driver irá executar a função principal e ficará


responsável por manter informações sobre a aplicação, responder à inputs do programa
ou usuário e analisar, distribuir e agendar tarefas nos executores. No contexto de
computação em clusters, o driver também se comunica com o gerenciador de clusters e
requisita recursos físicos para os nós (workers). Assim, esse componente é essencial

25
para a aplicação, uma vez que armazena todas as informações relevantes e dá controle
ao usuário.

Os executores são responsáveis por de fato realizar as tarefas designadas a eles


pelo driver. Assim, eles têm apenas a função de executar código e reportar sobre o
status da computação de volta para o driver.

Apesar de sua arquitetura ser própria para deploy em clusters, o Spark pode
ser executado localmente em uma só máquina e, nesse caso, a paralelização e
distribuição das tarefas ocorre em termos das threads do processador do computador.
Abaixo podemos ver a arquitetura de uma aplicação do Spark:

Figura 9 – Aplicação do Spark.

Fonte: Learning Spark (Cap. 1, p. 10)

Na imagem acima, podemos observar um componente crucial da aplicação: a


SparkSession. Esse objeto permite que o usuário tenha acesso unificado a todas as
funcionalidades do Spark, como a leitura e criação de DataFrames, realização de queries

26
do SQL, configurações da aplicação e acesso ao catálogo de metadados. Para mais,
diferentemente do que acontecia em versões anteriores, uma mesma SparkSession
pode ser utilizada para trabalhar como todos os módulos, não havendo a necessidade
de instanciar objetos diferentes para trabalhar com o Spark SQL e o ML, por exemplo.

Figura 10 – Criando uma SparkSession.

27
É interessante observar como o modelo de programação é coerente entre as
duas linguagens, o que torna fácil a transição de uma linguagem de programação para a
outra e contribui para o tema de unificação e simplicidade do framework. O Spark é
escrito primariamente em Scala e, por isso, essa é considerada a linguagem “padrão” do
framework, mesmo assim quase todas as funcionalidades também estão disponíveis
para as demais linguagens. Além disso, o código do Spark é executado por Java Virtual
Machines (JVMs) e, no caso do Python e R, ainda que o código deva ser traduzido antes
da execução, a performance permanece relativamente a mesma.

Uma vez submetida a aplicação, as operações realizadas sobre os dados são


quebradas em várias pequenas tarefas a serem realizadas. O Spark aplica esse princípio
por meio da divisão da aplicação em um ou mais jobs, e da posterior transformação de
cada um deles em um DAG, que pode ser visto essencialmente como o plano de
execução do Spark. Cada um dos DAGs gerados é formado por conjuntos de tarefas
chamadas stages, que são divididas baseado na capacidade de serem executadas de
forma serial ou paralela. Normalmente, um stage é definido pela necessidade de troca
de dados entre os executores. Por fim, as tarefas que compõe os stages são chamadas
de tasks, e constituem a menor unidade de processamento da arquitetura do Spark.

Figura 11 - Divisão das Tarefas da Aplicação

Fonte: Learning Spark (Cap. 2, p. 28)

28
Transformações, ações e Lazy Evaluation

As operações que podem ser aplicadas sobre os dados no Spark podem ser
classificadas em dois tipos: transformações e ações. Porém, para entender a diferença
entre elas é preciso antes compreender o conceito de lazy evaluation.

Lazy evaluation, de forma geral, é o princípio de que um conjunto de operações


não precisa ser executado até que seja estritamente necessário, isto é, o histórico de
operações é armazenado para que seja executado somente quando o usuário precisar
visualizar ou retornar o resultado das computações. No Spark, todas as transformações
são executadas de forma lazy, enquanto as ações são as operações que servem de
gatilho para a realização efetiva do processamento. Isso permite que:

• Sejam realizadas otimizações no plano de execução da consulta, como a


ordem em que as operações serão executadas e a divisão da tarefa em um
ou mais stages.

• Evitar o uso desnecessário de memória, uma vez que não há necessidade de


carregar todos os dados na memória para manipulá-los.

Assim, as transformações são operações avaliadas de forma lazy, que


transformam um DataFrame do Spark em um novo DataFrame, preservando os dados
originais e garantindo a propriedade de imutabilidade dos dados. Essa propriedade,
combinada com o histórico de operações inerente ao lazy evaluation, permite que o
Spark alcance um alto nível de tolerância às falhas, pois basta recorrer ao histórico de
transformações para chegar ao estado original no caso de algum problema. Alguns
exemplos de transformações:

• select()

• filter()

29
• withColumn()

As ações são operações que ativam o histórico de transformações e retornam


o DataFrame resultante. Elas são caracterizadas pela necessidade dos dados serem
exibidos de alguma forma, ou de que o resultado explícito de algum cálculo seja
retornado. Alguns exemplos de ações são:

• count()

• show()

• toPandas()

• collect()

• approxQuantile()

• save() (em conjunto com .write, para escrever dados)

Por ser uma ferramenta de processamento distribuído, frequentemente os


executores têm acesso a todos os dados ao mesmo tempo. Assim, é importante
entender também o conceito de transformações narrow e wide.

As transformações narrow são aquelas em que não é necessário que os dados


sejam movimentados entre os executores, no movimento chamado de shuffle. Logo,
uma operação narrow é aquela que não necessita de dados de outras partições para ser
realizada, basta somente o que está à disposição do executor.

Já as transformações wide são aquelas em que é necessário haver um shuffle


de dados para que os cálculos sejam feitos. Um exemplo bastante comum é a realização
de joins, em que os executores precisam trocar informações sobre as chaves presentes
em suas partições.

30
Figura 12 – Transformações Narrow e Wide.

Fonte: Learning Spark (Cap. 2, p. 31).

O shuffle de dados é um dos principais gargalos de performance em aplicações


do Spark, e, portanto, um tópico bastante importante na discussão de métodos de
otimização de processamento.

Catalyst Optimizer e o Plano de Execução

Falamos muito sobre a capacidade do Spark de otimizar consultas e operações


em bancos de dados. Nesta seção, será apresentado o dispositivo por trás disso. O
Catalyst Optimizer é o mecanismo do Spark que transforma uma query escrita pelo SQL
ou por meio das APIs de DataFrames em um plano de execução. Ele consiste de quatro
fases:

1. Análise: nessa fase, o otimizador utiliza metadados – nome de colunas, tipos de


dados, funções etc. – dos dados sendo utilizados para interpretar, validar e
complementar o código enviado pelo usuário, uma vez que o código pode estar
correto sintaticamente, mas as colunas referenciadas nas operações podem não
existir (o código nesse estado é chamado de Unresolved Logical Plan). Se o código
passar pela análise, ela vira input da fase seguinte.

31
2. Planejamento Lógico: nessa fase, o Catalyst recebe a query do usuário e
identifica formas de otimizar o processo, principalmente movendo a ordem das
operações, ainda abstraindo as transformações a serem aplicadas. O plano
otimizado é então input do planejamento físico.

3. Planejamento Físico: essa é a etapa em que o plano lógico escolhido é


transformado em diversas opções de planos físicos, que dizem respeito a como
o plano lógico será realmente executado. Então, essas opções são comparadas
utilizando um modelo de custo para escolher aquela mais eficiente.

4. Geração de Código: por fim, com a fase de planejamento terminada, o plano


final é transformado em operações de RDD, no nível mais baixo de abstração do
Spark. O código é então executado pelas JVMs dos executores.

Figura 13 – Catalyst Optimizer.

Fonte: Learning Spark (Cap. 3, p. 78)

32
Durante o desenvolvimento, é possível verificar o plano de execução do Spark
para as transformações realizadas em um DataFrame, usando o método .explain().
Observe o exemplo abaixo utilizando o banco de dados do IMDB, em que são
selecionados os 15 títulos (filmes, séries, documentários etc.) com a melhor avaliação
no site:

Figura 14 – Acessando o Plano de Execução.

Ao executar a última linha é imprimido no console o plano físico de execução:

33
Figura 15 – Plano Físico.

34
Uma análise cuidadosa do plano mostra quais operações serão realizadas
durante a execução, permitindo que o usuário identifique possíveis gargalos de
processamento e possíveis melhorias. Essa é uma parte importante da otimização do
Spark, e será abordado mais à frente.

35
Também é possível analisar o plano lógico de execução trocando a chamada ao
método para explain(True), mas via de regra o plano físico tem mais aplicações
práticas do que o plano lógico.

Download e instalação do Apache Spark

A instalação do Apache Spark é feita seguindo alguns passos simples, que são
comuns às instalações em máquinas Linux e Windows. No entanto, a instalação no
Windows requer alguns passos a mais, como será demonstrado abaixo.

Obs.: esse curso irá abordar principalmente o desenvolvimento com pyspark e,


por isso, é necessário que o Python já esteja instalado na máquina antes de começar a
instalação do Spark. Caso não tenha o Python instalado, uma das distribuições mais
comuns é a da Anaconda.

Passo 1: Instalação do Java 8 JDK

Verifique se a máquina tem o Java Development Kit (JDK) 8 ou maior instalado.


Isso pode ser feito abrindo um console e digitando o seguinte na linha de comando:

Figura 16 – Verificando a instalação do Java.

Caso o Java não esteja instalado ou a versão instalada seja anterior a 1.8, será
necessário acessar o site oficial do Java e realizar o download e instalação. Por enquanto,
o Spark só é compatível com as versões 8 e 11.

36
Uma vez verificada essa dependência, é necessário garantir que as variáveis de
ambiente estejam configuradas apropriadamente. Considerando a versão 8, as variáveis
de ambiente do Java são:

• JAVA_HOME = C:\Program FIles\Java \jdk1.8.0_201

• Adicionar à variável PATH = C:\Program Files\Java \jdk1.8.0_201\bin

Feito isso, pode-se avançar ao próximo passo.

Passo 2: Download do Apache Spark

Acesse a página de download oficial do Spark e selecione qual a versão do Spark


e do Hadoop que se deseja fazer a instalação:

Figura 17 – Download do Apache Spark.

Fonte: Apache Spark.

Uma vez selecionadas as versões, basta clicar no link do tópico “Download


Spark” para realizar o download. Feito isso, extraia os arquivos comprimidos na pasta
de sua preferência.

Obs.: é recomendado que o Spark esteja em um diretório em que o nome das


pastas não possuam caracteres especiais e/ou espaços para evitar conflitos de
referenciamento. Uma alternativa comum é salvar no disco local (Ex: C:/spark).

37
Semelhantemente à instalação do Java, é necessário criar algumas variáveis de
ambiente após a extração dos arquivos do Spark. As variáveis são:

• SPARK_HOME = C:/spark/spark-3.1.2-bin-hadoop2.7

• HADOOP_HOME = C:/spark/spark-3.1.2-bin-hadoop2.7

• Adicionar à variável PATH = C:/spark/spark-3.1.2-bin-hadoop2.7/bin

Passo 3: Download dos binários do Windows (somente para Windows)

Na instalação no Windows, é necessário realizar o download de arquivos


binários extras do Hadoop, disponíveis neste repositório, mantido por um dos
desenvolvedores do Hadoop.

Basta acessar a pasta referente à versão do Hadoop contida na instalação do


Spark e fazer o download dos arquivos winutils.exe e hadoop.dll.

Figura 18 – Download do winutils.exe.

Fonte: github.com/cdarlint/winutils.

38
Feito isso, salve os arquivos baixados no diretório SPARK_HOME/bin, que,
considerando o exemplo anterior, seria C:/spark/spark-3.1.2-bin-hadoop2.7/bin.

Por fim, existe um truque que não é obrigatório, mas que pode ser útil para
resolver problemas de alguns usuários. As etapas são as seguintes:

1. Crie a pasta C:\tmp\hive.

2. Abra o cmd como administrador e execute os seguintes comandos:

1. winutils.exe chmod -R 777 C:\tmp\hive

2. winutils.exe ls -F C:\tmp\hive

O output deve ser algo parecido com isso: drwxrwxrwx|1|LAPTOP-...

Passo 4: Testar a instalação

Abra um aplicativo de linha de comando e execute o seguinte:

Figura 19 – Abrindo o Spark Shell.

39
Observe acima que o shell padrão do Spark é em Scala. Para abrir o shell
interativo em Python, é necessário que o aplicativo suporte a execução do Python, mas,
uma vez que isso esteja garantido, basta executar:

Figura 20 – Abrindo o PySpark Shell.

É possível visualizar que ambos shells estão funcionando com a mesma versão
do Spark. Para finalizar os testes, o output do seguinte comando deve ser:

Figura 21 – Testando o Spark.

As shells do Spark estão disponíveis como um ambiente de testes interativo


para os usuários desenvolverem protótipos e aprenderem as APIs do programa. Quando

40
abertas dessa forma, as shells executam o Spark em modo local e uma SparkSession
é gerada automaticamente para o usuário.

Bônus: Executando o Spark em Jupyter Notebooks

Apesar de as shells do Spark fornecerem um bom ambiente de


desenvolvimento interativo e aprendizado, os Jupyter Notebooks são ferramentas mais
completas e úteis para esse mesmo propósito. Assim, para executar o Spark em um
notebook, é necessário instalar o pacote findspark por meio do seguinte comando:

conda install -c conda-forge findspark

Uma vez instalada a biblioteca, abra um Jupyter Notebook e execute o seguinte:

Figura 22 – Testando o PySpark no Jupyter Notebook.

O output deve ser o mesmo da shell do pyspark.

41
Com isso, está finalizada a introdução ao Spark, seus principais conceitos, forma
de funcionamento, instalação e configuração inicial. Nos próximos capítulos serão
apresentadas as principais operações de manipulação de dados com a ferramenta.

42
Capítulo 3. Manipulando dados com Spark: Parte 1

Neste capítulo será abordado o uso prático da API de DataFrames, a principal


forma de manipulação de dados com o Spark. Serão apresentadas as operações mais
básicas e comuns em um processo de ETL e seu funcionamento no contexto do Spark,
de forma que, ao final do capítulo, o leitor seja capaz de relacionar e identificar
processos análogos em outras ferramentas, como o SQL e a bibliotecas pandas do
Python.

DataFrames e Datasets

Antes de apresentar de fato as operações, é preciso entender um pouco da API


que será utilizada. O Spark implementa duas construções ligeiramente diferentes para
a manipulação de dados estruturados: DataFrame e Datasets. Segundo Zaharia e
Chambers (2018), ambos consistem em coleções de dados distribuídos em forma de
tabelas, com linhas e colunas bem definidas, de forma similar a tabelas em bancos de
dados relacionais. Assim, cada coluna deve ter o mesmo número de linhas que todas as
outras colunas, e seus valores devem pertencer a um mesmo tipo, apesar de ser possível
utilizar o valor nulo para indicar ausência de valores.

Os DataFrames e Datasets são abstrações feitas sobre os RDDs, o que faz com
que eles herdem todas as características vistas anteriormente, como a imutabilidade
dos dados e a lazy evaluation. Por isso, essas estruturas podem ser vistas como uma lista
de operações, especificadas na forma das transformações, a serem realizadas sobre os
dados representados por linhas e colunas.

A diferença entre DataFrames e Datasets está principalmente na forma como


são construídos. Os Datasets são fortemente tipados, isto é, os tipos das colunas são
verificados em tempo de compilação, enquanto, para os DataFrames, isso ocorre em
tempo de execução. Os Datasets estão disponíveis somente para as linguagens Java e

43
Scala (uma vez que R e Pyhton são linguagens com tipagem dinâmica), mas os
DataFrames também estão disponíveis em Scala. Dessa forma, os Datasets devem ser
criados utilizando uma case class (Scala) ou JavaBeans (Java).

Abaixo um exemplo da criação de um Dataset em Scala:

Figura 23 – Criando um Dataset em Scala.

Fonte: Exemplo retirado do Learning Spark (Cap.6, p. 158.)

Mesmo com as diferenças em sua construção, a manipulação de dados com


DataFrames e Datasets é a mesma. No geral, deve-se usar Datasets quando é necessário
garantir que os tipos serão checados em tempo de compilação, e nas demais situações
fica a critério do usuário escolher qual construção usar. No entanto, vale a pena ressaltar
que os DataFrames têm uma pequena vantagem em termos de otimização de execução.

Tipos de dados no Spark

Por ter implementações em diferentes linguagens, o Spark interage com os


tipos de dados da linguagem em que está sendo utilizado, mas a ferramenta também

44
implementa tipos internos de dados que se relacionam com esses tipos nativos. O Spark
dispõe de duas categorias de tipos: básicos e complexos – e há uma gama de funções
disponíveis para operar sob cada um deles. Tipicamente, os tipos complexos se diferem
dos tipos básicos por aceitarem estruturas aninhadas e de datas.

Os tipos básicos são:

Figura 24 – Tipos básicos em Python.

Fonte: Learning Spark (Cap. 3, p. 49).

E os tipos complexos:

Figura 25 – Tipos Complexos em Python.

Fonte: Learning Spark (Cap. 3, p. 50)

45
Todas as APIs possuem os mesmos tipos internos, ainda que eles possam ser
mapeados para diferentes tipos nativos, dependendo da linguagem.

Os tipos podem ser acessados da seguinte forma:

Figura 26 – Acessando os Tipos.

A principal função dos tipos acessados dessa forma é auxiliar na definição de


um schema utilizado na criação de DataFrames. Eles também podem ser utilizados para
mudar uma coluna de um tipo para o outro ao utilizar o método cast(), mas nesse
caso é mais vantajoso especificar os tipos como strings, para evitar um código muito
verboso. Exemplo:

46
Figura 27 – Mudando o tipo da coluna.

Todos os tipos podem ser especificados com strings, mas, para os tipos
complexos, muitas vezes é melhor utilizar a API disponível.

Schema e criação de DataFrames

Um schema no Spark é uma especificação de tipos das colunas de um


DataFrame. Eles são usados na leitura de dados externos e criação de DataFrames e
podem ser passados diretamente ao Spark ou podem ser inferidos. Passar um schema
na leitura traz benefícios interessantes, como:

• Evita que o Spark faça inferência de tipos, o que é custoso e demorado


dependendo do tamanho do arquivo, além de propenso a erros.

• Permite que o usuário identifique erros nos dados logo na leitura, caso os
dados não sigam o schema especificado.

Existem duas formas de criar um schema: por meio da API de tipos apresentada
anteriormente ou por meio de uma string DDL (Data Definition Language), que fica
muito mais simples e fácil de ler (Damji et al, 2020).

Usando a API de tipos:

47
Figura 28 – Definindo o Schema Programaticamente.

Usando o string DDL no mesmo exemplo anterior, é possível ver que o código
fica muito mais limpo e fácil de ler:

schema = “nome STRING, id INT”

É possível verificar o schema de um DataFrame a qualquer momento utilizando


o atributo schema ou o método printSchema(), sendo que o segundo é preferível
por exibir as informações de uma forma mais legível e formatada.

Como dito anteriormente, os schemas são utilizados na definição de


DataFrames, seja por leitura externa ou na criação a partir de dados do ambiente. É
possível criar DataFrames utilizando a SparkSession e o método
.createDataFrame().

Para criar um DataFrame, são necessários os dados e um schema. O schema


pode ser passado das duas formas apresentadas ou então como uma lista com os nomes
das colunas e, nesse caso, o Spark fará a inferência dos tipos das colunas. De forma geral,
é melhor evitar que o Spark faça inferência dos tipos, pois é um processo suscetível a
erros e que pode ser bastante demorado. Já os dados são passados como uma lista de

48
objetos iteráveis, normalmente tuplas ou listas, em que cada iterável representa uma
linha e cada elemento dentro dele representa uma coluna. Dessa forma, é possível
enxergar que essa lista é uma coletânea de indivíduos, e que cada indivíduo é uma
coletânea de variáveis. Os dados também podem ser passados como um RDD ou
pandas.DataFrame.

Figura 29 – Criando um DataFrame.

Existe ainda uma outra forma de gerar DataFrames no Spark, mas sem muitas
opções de customização. O método SparkSession.range() pode ser utilizado
para gerar um DataFrame com uma única coluna chamada id do tipo LongType, que
consiste basicamente de uma sequência de números. Os intervalos dessa sequência
podem ser definidos e são argumentos do método.

49
Leitura e escrita de dados

Nesta seção serão apresentados os dispositivos de leitura e escrita de dados


nativos do Apache Spark, assim como exemplos com dados de diferentes formatos e as
principais configurações utilizadas.

Para realizar essas operações, é necessário acessar os objetos


DataFrameReader e DataFrameWriter, que estão disponíveis na
SparkSession como uma forma de acessar as funcionalidades de leitura e escrita,
respectivamente.

DataFrameReader

Existem duas maneiras de especificar a operação de leitura:

Figura 30 – Formas de leitura.

Observe que o DataFrameReader é acessado pelo atributo


SparkSession.read. As duas formas realizam a mesma tarefa de ler dados no
formato parquet, mas a forma como a primeira está escrita não permite parametrizar a
leitura em termos da fonte de dados utilizada. Isso significa que em uma aplicação de
processamento, caso o formato dos arquivos sendo lidos mudassem, seria necessário
mudar diretamente o código, enquanto se a segunda forma fosse utilizada, bastaria
mudar um dos argumentos da função. Assim, essa é a forma preferível e mais utilizada.

50
Além de especificar o formato dos dados sendo lidos, é possível passar o
schema, o caminho dos dados e as configurações extras de leitura, que são passadas
pelo método .option() – conforme discutido anteriormente. Os argumentos desse
método devem ser um único par de chave/valor, que denotam uma única opção de
leitura sendo configurada, e ambos os argumentos devem estar no formato de strings.
Esse método é bastante utilizado ao trabalhar com dados originados de arquivos JSON
ou CSV, uma vez que existem diversas formas de configurar a leitura de arquivos nesses
formatos. Também é possível usar .options(), uma alternativa que permite
especificar todas as configurações empregadas em uma mesma chamada:

Figura 31 – Usando o método options.

51
Obs.: usar o dicionário como no exemplo acima só funciona em Python, pois é
uma característica nativa da linguagem.

DataFrameWriter

Para a escrita, existem também alguns padrões de uso:

Figura 32 – Formas de escrita.

De forma semelhante à leitura, as duas primeiras formas da Figura 32 diferem


na possibilidade de parametrizar o formato no qual os dados são salvos. A última forma
indica a escrita de uma tabela, assunto que será abordado mais à frente.

A principal configuração da escrita é o mode, argumento que indica qual o


comportamento do Spark caso ele encontre dados já existentes no diretório indicado
como destino dos dados. As opções são as seguintes:

• append: anexa o conteúdo do DataFrame aos dados existentes.

• overwrite: sobrescreve dados existentes.

• ignore: ignora silenciosamente essa operação se os dados já existirem.

• error ou errorifexists (default): retorna erro se os dados já existirem.

Também estão disponíveis opções de escrita para particionar ainda mais os


dados, tema que será abordado no Capítulo 6.

52
Uma característica importante do dispositivo de escrita do Spark é que o
número de arquivos ao final de uma operação de salvamento de dados está diretamente
relacionado ao número de partições do DataFrame. Isso significa que se um DataFrame
está dividido em 200 partições – que como já visto, são unidades de processamento –
os dados serão salvos em 200 arquivos, cada um com uma parte desses dados.

Uma consequência dessa característica é que o caminho de destino dos dados


não precisa ser especificado com a extensão característica do formato em que se deseja
salvar, mas ao invés disso representa o diretório em que os arquivos serão salvos. Por
exemplo, ao executar o seguinte código:

Figura 33 – Salvando arquivos particionados.

Os dados finais serão salvos da seguinte forma:

53
Figura 34 – Exemplo de diretório após escrita dos dados.

Cada um desses arquivos está salvo no formato parquet numa pasta chamada
“df_notas”, criada no diretório de trabalho em que o código foi executado. Cada uma
das partições representa um “pedaço” dos dados originais.

Agora serão apresentadas as fontes de dados nativas do Spark, suas principais


configurações e exemplos de leitura e escrita.

Parquet

O parquet é a fonte de dados padrão do Spark, e é altamente utilizado no


contexto de Big Data por ser um formato muito eficiente e versátil. Algumas das
vantagens do parquet são:

• Armazenamento colunar, em contraste com o CSV, que armazena baseado


nas linhas. Assim, quando uma query é realizada é possível ignorar os dados
não relevantes de maneira rápida e fácil, resultando em operações bem mais
eficientes.

54
• Preservação de metadados, incluindo os tipos das colunas, o que garante
eficiência e praticidade na escrita e leitura (não é necessário especificar
schemas para arquivos parquet).

• Suporte a dados estruturados de forma aninhada, como listas.

• Otimizado para processar dados particionados com volume na casa dos


gigabytes para cada arquivo.

• Compressão de dados na escrita, de forma a ocupar menos espaço.

• Integração com ferramentas como AWS Athena, Amazon Redshift


Spectrum, Google BigQuery e Google Dataproc.

Figura 35 – Comparação Parquet e CSV.

Fonte: Databricks.

55
A principal opção de leitura e escrita de arquivos parquet é o modo de
compressão, denotado pelo argumento compression. Como dito anteriormente, o
fato desse formato de arquivo ser salvo de forma comprimida proporciona uma grande
economia de espaço em disco, e escolher a forma mais adequada de comprimir os dados
pode ajudar a melhorar a eficiência das operações de input/output. O default é a
compressão utilizando snappy, mas há algumas outras opções disponíveis.

Em linhas gerais, é recomendado utilizar arquivos parquet em cargas de


trabalhos processadas com o Apache Spark sempre que possível.

JSON

O formato JSON é também bastante popular e se faz presente em diversos


contextos e aplicações, pois é o resultado de uma consulta à uma API. Entre suas
vantagens, estão o fato de suas operações de input/output serem leves e eficientes e
sua versatilidade, já que mesmo tendo se originado no JavaScript, é independente da
linguagem utilizada.

Para ler arquivos JSON basta utilizar “json” no método format():

Figura 36 – Lendo arquivos JSON.

56
Escrever arquivos JSON também é simples. De forma semelhante à escrita,
basta especificar corretamente os argumentos e métodos e o caminho do diretório de
destino:

Figura 37 – Escrevendo arquivos JSON.

As principais configurações disponíveis no DataFrameReader e


DataFrameWriter para arquivos JSON são descritas na tabela abaixo:

Figura 38 – Opções para arquivos JSON.

Fonte: Learning Spark (Cap. 4, p. 101).

57
CSV

Arquivos CSV são uma das formas mais comuns de se compartilhar e


administrar dados. Nesses arquivos, os dados são organizados de forma tabular e o valor
de cada uma das colunas é separado por um delimitador, usualmente uma vírgula. A
maior parte dos softwares de manipulação de planilhas e geração de relatórios tem a
capacidade de disponibilizar os dados como CSV's e, por isso, o formato se tornou
bastante popular entre analistas de dados e de negócios.

Para ler arquivos CSV basta utilizar “csv” no método format():

Figura 39 – Lendo arquivos CSV.

58
Escrever arquivos CSV também é simples. De forma semelhante à escrita, basta
especificar corretamente os argumentos, métodos e o caminho do diretório de destino:

Figura 40 – Escrevendo arquivos CSV.

As principais configurações disponíveis no DataFrameReader e


DataFrameWriter para arquivos CSV são descritas na tabela abaixo:

Figura 41 – Opções para arquivos CSV.

Fonte: Learning Spark (Cap. 4, p. 104)

59
Vale destacar ainda a opção encoding, que permite mudar a representação
de strings internamente. Essa opção muitas vezes corrige erros em que caracteres de
colunas de texto vêm faltando ou são lidos com erro. Para saber mais sobre os diferentes
tipos de encoding, acesse esse link.

Obs.: ao trabalhar com arquivos CSV, é bastante comum haver situações em


que o output de um processamento deve ser um arquivo não particionado, para servir
de consumo de usuários em ferramentas como o Microsoft Excel. Nesses casos, existem
duas alternativas:

1. Transformar o Spark DataFrame em um pandas DataFrame:

Nessa situação, é utilizado o dispositivo do pandas para salvar os dados em


somente um arquivo, com o custo de ter que colocar todo o DataFrame na memória
para realizar essa operação. Por isso, deve ser usado somente quando o output do
processamento tiver um volume pequeno de dados, como um relatório gerencial ou
dados agregados. Essa alternativa só está disponível na linguagem Python.

2. Reparticionar o Spark DataFrame

60
No caso acima, o Spark internamente reduz o DataFrame a somente uma
partição, de forma que os dados estejam concentrados em uma única unidade de
processamento. Assim, quando os dados forem salvos, eles serão escritos em somente
um arquivo. Essa alternativa está disponível independente da linguagem, mas requer
um ponto de atenção: como dito anteriormente, não é possível escolher o nome do
arquivo final, somente o nome da pasta em que o Spark salvará esse arquivo, e por isso
não é necessário colocar a extensão “.csv” ao final do caminho de destino.

ORC

No formato ORC, semelhante ao parquet, os dados são armazenados de forma


colunar objetivando alcançar maior eficiência. Desenvolvido para cargas de trabalho
Hadoop, os arquivos ORC também podem ser lidos no Spark a partir da versão 2.0. A
principal diferença entre o uso de arquivos ORC e arquivos parquet é que o Spark
implementa otimizações específicas para o uso do segundo, o que o torna preferível.

Para ler arquivos ORC basta utilizar “orc” no método format():

61
Figura 42 – Lendo arquivos ORC.

Escrever arquivos ORC também é simples. De forma semelhante à escrita, basta


especificar corretamente os argumentos e métodos e o caminho do diretório de destino:

Figura 43 – Escrevendo arquivos ORC.

Diferente dos outros formatos apresentados, o Spark não implementa


nenhuma opção extra de leitura ou escrita para arquivos ORC.

Operações básicas com DataFrames

O primeiro passo para realizar operações no Spark, é entender como funcionam


as unidades básicas de manipulação dos dados: as colunas. Todas as transformações

62
definidas agem sobre as colunas para alterar o DataFrame de alguma forma, de modo
que todo o processo de manipulação tem de ser desenvolvido com a ideia de que as
operações são colunares. Programadores sem muita experiência com bancos de dados
relacionais ou a linguagem SQL muitas vezes encontram dificuldades, mas pensar dessa
forma é crucial para que seja desenvolvido um processo consistente e eficiente.

Colunas e expressões

As colunas são uma coleção de registros de um mesmo tipo, identificados por


um nome. Um conjunto de colunas compõem linhas, que por sua vez compõem um
DataFrame. É possível entender as colunas como um vetor do R ou uma Series do
pandas, no sentido de que as operações definidas sobre as colunas são vetoriais: as
operações são aplicadas sobre cada um dos elementos que formam a coluna, um por
vez. Por exemplo:

Figura 44 – Operações nas colunas.

63
Observe que ao adicionar o valor 5 à coluna “nota”, o valor foi adicionado sobre
cada um dos elementos da coluna. Esse é o comportamento padrão da maior parte das
funções e operações que agem sobre colunas no Spark. A função col, que foi utilizada
para fazer referência à coluna, faz parte do módulo pyspark.sql.functions
(Python) e org.apache.spark.sql.functions (Scala), que concentra todas as
funções utilizadas para transformar colunas. As colunas podem ser referenciadas das
seguintes formas:

• col(“column”) / column(“column”)

• df[“colum”]

• df.column

• df.col(“column”)

Todas as formas apresentadas são equivalentes, mas é recomendado utilizar o


primeiro ou segundo método, pois os dois últimos não permitem fazer a referência de
colunas criadas a partir do encadeamento de operações. A diferença entre usar
col(“column”) e df.col(“column”) é que o segundo evita que o Spark tenha
de validar os nomes das colunas durante a fase de análise do Catalyst Optimizer, o que
gera pequenos ganhos em termos de performance.

Usando Scala, devido às características nativas da linguagem, também é


possível fazer referência a colunas de mais duas formas, alcançando o mesmo resultado
dos anteriores:

• $”column”

• ‘column

64
O último aspecto das colunas que é importante entender, antes de apresentar
as operações de manipulação de DataFrames, é o fato de que as colunas, nesse caso os
objetos de manipulação, nada mais são do que expressões. As expressões são
transformações sobre as colunas físicas do DataFrame, que tem como input os nomes
das colunas a serem transformadas e algum tipo de operação a ser realizada sobre elas,
seja uma operação matemática ou alguma função implementada. No caso mais simples,
uma expressão referencia uma coluna da mesma forma que a função col(). Para
acessar as expressões, deve-se utilizar a função expr(), presente no mesmo módulo
de funções. Alguns exemplos:

Figura 45 – Colunas e Expressões.

Todas as funções presentes no módulo de funções podem ser usadas operando


sobre colunas ou dentro de expressões. Vale ressaltar também que, para as operações
realmente surtirem efeito, elas devem estar definidas dentro de métodos do
DataFrame, que possibilitam criar colunas, como vai ser mostrado adiante.

Seleção de Colunas

Na linguagem SQL, a cláusula SELECT é a forma mais básica de acessar o


conteúdo de uma tabela, por meio da qual é possível selecionar e/ou criar colunas. De

65
maneira semelhante, essa operação pode ser realizada no Apache Spark por meio do
método select():

Figura 46 – Seleção básica.

É possível utilizar as colunas e expressões vistas anteriormente para criar novas


colunas ou renomear colunas já existentes:

Figura 47 – Criando Colunas na Seleção.

66
A função alias() é um método das colunas e pode ser usado em qualquer
momento em que uma coluna for retornada. Também fica claro com o exemplo acima
que a ordem em que as colunas são escritas na função é a mesma ordem em que elas
serão exibidas no DataFrame resultante.

Em Python, as colunas que servem como argumento do select() podem ser


passadas como strings, referências de colunas ou uma lista com um desses dois, mas
não é possível misturar referências individuais com listas. O seguinte código, por
exemplo, retornaria um erro:

df.select(“col1”, [“col2”, “col3”])

Uma alternativa é utilizar o operador *, que serve para indicar ao compilador


que cada um dos elementos de uma lista deve ser visto como um argumento da função.
Assim, o seguinte código é válido:

df.select(“col1”, *[“col2”, “col3”])

Por fim, é possível utilizar o método select() em conjunto com o método


distinct(), para selecionar valores distintos de uma coluna:

Figura 48 – Selecionando Valores Distintos.

67
Obs.: o método distinct() retorna um DataFrame com os valores distintos
a nível de linha, ou seja, a função irá retornar linhas únicas que devem diferir em pelo
menos uma coluna. Dessa forma, esse método pode ser utilizado para remover valores
duplicados, o que também pode ser feito com dropDuplicates().

Renomeação de Colunas

Apesar de ser possível renomear colunas usando o método select()


apresentado anteriormente, essa operação dispõe de um método reservado que pode
ser útil no processamento. Para renomear colunas é utilizada a função
withColumnRenamed(), da seguinte forma:

df.withColumnRenamed(“nome_antigo”, “nome_novo”)

Uma aplicação bastante comum é quando há necessidade de acrescentar um


sufixo ao nome de todas as colunas:

Figura 49 – Acrescentando Sufixo aos Nomes de Colunas.

68
Filtragem de Linhas

Para filtrar linhas, é necessário denotar uma expressão que ao ser avaliada
retorna valores booleanos: true (verdadeiro) ou false (falso). Ela pode ser construída por
meio de um string - que pode usar sintaxe SQL e funciona da mesma maneira que a
função expr() - ou uma série de manipulações de colunas. Definida a expressão, ela
deve ser passada para o método filter() ou where(), que são basicamente
idênticos e não apresentam diferenças de performance. Exemplo:

Figura 50 – Filtro Básico de Linhas.

Também é possível realizar filtros com mais de uma condição, utilizando


operadores lógicos. No entanto, nem sempre isso é útil, porque o Spark executa todos
os filtros ao mesmo tempo independente da ordem em que são escritos, o que faz com
que filtros do tipo “e” sejam desnecessários e possam ser escritos com múltiplas
chamadas ao método:

69
Figura 51 – Filtro Múltiplo.

Observações

Quando nos referimos às colunas por meio da função col(), temos acesso a
diversos métodos das colunas que podem ser utilizados para auxiliar na filtragem do
DataFrame. Alguns deles são:

• isin(): checa se a coluna contém os valores listados na função.

• contains(): utilizado para verificar se uma coluna de texto contém


algum padrão especificado (não aceita regex). Aceita uma outra coluna de
texto.

70
• like(): utilizado para verificar se uma coluna de texto contém algum
padrão especificado (não aceita regex). Funciona de forma similar ao "LIKE"
do SQL.

• rlike(): utilizado para verificar se uma coluna de texto contém algum


padrão especificado (aceita regex). Funciona de forma similar ao "RLIKE" do
SQL.

• startswith(): utilizado para verificar se uma coluna de texto começa


com algum padrão especificado (aceita regex).

• endswith(): utilizado para verificar se uma coluna de texto termina com


algum padrão especificado (aceita regex).

• between(): checa se os valores da coluna estão dentro do intervalo


especificado. Os dois lados do intervalo são inclusivos.

• isNull(): retorna true se o valor da coluna é nulo;

• isNotNull(): retorna true se o valor da coluna não é nulo.

Outros métodos úteis:

• alias()/name(): usado para renomear as colunas em operações como


select() e agg();

• astype()/cast(): usado para mudar o tipo das colunas. Aceita tanto


um string como um tipo especificado pelo módulo pyspark.sql.types

• substr(): utilizado para cortar um string com base em índices dos


caracteres.

71
Utilizando expressões, a maioria desses métodos estarão acessíveis por meio
dos seus equivalentes em SQL.

Ordenação do DataFrame

A ordenação do DataFrame pode ser feita utilizando as funções orderBy()


ou sort(). Algumas funções auxiliares importantes para serem usadas nessa
operação:

• asc(): ordena a coluna de forma ascendente (default).

• desc(): ordena a coluna de forma decrescente.

• asc_nulls_first() / desc_nulls_first(): ordena a coluna


de forma ascendente e decrescente, respectivamente, mantendo os campos
nulos primeiro; asc_nulls_first() é o default quando há dados
faltantes.

• asc_nulls_last() / desc_nulls_last(): ordena a coluna de


forma ascendente e decrescente, respectivamente, mantendo os campos
nulos por último.

72
Figura 52 – Ordenação do DataFrame.

Criação de Colunas

Assim como na renomeação de colunas, a criação de colunas pode ser feita de


forma direta e desvinculada da seleção utilizando o método withColumn():

df.withColumn("nome_da_coluna", {expressão})

Então, o DataFrame dos exemplos anteriores pode ser construído com a


seguinte nova sintaxe:

73
Figura 53 – Criação de Colunas.

Fica claro que, até o momento, só é possível criar, alterar e manipular colunas
já existentes em um DataFrame no momento de sua criação. No entanto, caso seja
necessário criar uma coluna a partir de uma constante ou algum elemento presente no
ambiente em que o Spark está sendo executado, é possível utilizar a função lit() para
criar uma coluna que replica este valor para todas as linhas:

Figura 54 – Criação de Colunas a partir de Constantes.

74
Finalmente, é possível criar campos baseados em condições lógicas, de forma
similar à cláusula CASE WHEN do SQL. Usando a manipulação de colunas, é necessário
usar a função abaixo dentro de um método capaz de gerar novas colunas:

when({primeira condição}, {expressão se verdadeiro})


.when({segunda condição}, {expressão se verdadeiro})
(...)
.otherwise({expressão se nenhuma condição verdadeira})

Qualquer expressão lógica é válida como condição, inclusive aquelas usadas


para realizar filtros. Alguns exemplos:

Figura 55 – Colunas Condicionais.

Observe que não é necessário utilizar a função lit() para especificar os


valores de retorno se verdadeiro ou falso. Porém, quando os resultados são colunas,
eles devem ser especificados com as funções col() ou expr() .

75
Trabalhando com diferentes tipos de dados

Uma vez que ficou claro a forma como criar e modificar colunas no Spark, é
possível entender algumas das principais funcionalidades disponíveis para a
manipulação de diferentes tipos de dados. A seguir serão apresentadas funções do
módulo pyspark.sql.functions (Python) e
org.apache.spark.sql.functions (Scala) que são comumente usadas em
uma pipeline de processamento.

Números

Quando se trabalha com valores numéricos, as funções mais utilizadas estão


principalmente relacionadas às transformações matemáticas que podem ser aplicadas
sobre esses valores. Vale destacar também algumas funções úteis para comparação,
como encontrar o maior ou menor valor em um conjunto. Abaixo uma lista das funções
mais usadas:

• monotonically_increasing_id(): retorna um id único para cada


linha, começando em 0.

• rand(): retorna uma amostra independente de uma distribuição uniforme


entre 0 e 1.

• randn(): retorna uma amostra independente de uma distribuição normal


padrão (média 0 e variância 1).

• round(): arredonda o valor.

• ceil(): arredonda o valor para o maior inteiro mais próximo.

• floor(): arredonda o valor para o menor inteiro mais próximo.

76
• sqrt(): retorna a raiz quadrada do valor.

• exp(): retorna a exponencial do valor.

• log(): retorna a logaritmo natural do valor.

• log10(): retorna a logaritmo na base 10 do valor.

• pow(): retorna o valor de uma coluna elevado a potência passada pelo


usuário.

• greatest(): retorna o maior valor dentre os valores das colunas. Análogo


ao max(), mas opera sobre valores de uma mesma linha, ao invés de uma
única coluna.

• least(): retorna o menor valor dentre os valores das colunas. Análogo ao


min(), mas opera sobre valores de uma mesma linha, ao invés de uma
única coluna.

Obs.: funções como max(), sum() e mean() naturalmente são exemplos de


funções que devem ser aplicadas a dados numéricos, e obviamente são bastante
utilizadas em processamentos. No entanto, elas não figuram nessa seção porque são
funções de agregação, um assunto que será abordado no próximo capítulo.

Além das funções apresentadas acima, também é importante considerar os


operadores numéricos disponíveis na linguagem de programação sendo utilizada, que
são utilizados para expressar as operações básicas de soma (+), subtração (-),
multiplicação (*) e divisão (/).

77
Figura 56 – Exemplo com Números.

Strings

A manipulação de texto é uma das tarefas mais comuns em uma pipeline de


processamento de dados, oriundos principalmente de dados cadastrais, tweets,
extratos de redes sociais, documentos e dados coletados da web. Como essas fontes são
caracterizadas principalmente pela digitação humana e uso da linguagem natural, é
esperado que esses dados sejam coletados com bastante sujeira e erros de formatação.

Diante disso, a tarefa mais importante ao lidar com strings é formatá-los de


forma que eles sigam algum padrão estabelecido, e contenham somente as informações
necessárias. Isto é, os caracteres devem ser transformados, e muitas vezes removidos.
As funções a seguir auxiliam nesse propósito:

• upper(): retorna o string em letras maiúsculas.

• lower(): retorna o string em letras minúsculas.

78
• initcap(): retorna a primeira letra de cada palavra no string em letras
maiúsculas.

• trim(): retira os espaços em branco do início e do final do string.

• ltrim() / rtrim(): retira os espaços em branco do início e do final do


string, respectivamente.

• lpad() / rpad(): acrescenta um caractere no início e no final do string,


respectivamente, até que o string tenha um determinado comprimento.

• length(): retorna o comprimento do string, em quantidade de


caracteres.

• split(): quebra o string a partir de um padrão e retorna um array com os


string resultantes.

• concat(): concatena uma ou mais colunas de string.

• concat_ws(): concatena uma ou mais colunas de string, com um


separador entre elas.

• regexp_extract(): retorna um match no string a partir de um padrão


regex.

• regexp_replace(): substitui um match no string a partir de um padrão


regex com outros caracteres passados para a função.

• substring(): retorna os caracteres do string que estão entre os índices


especificados. Análogo a col().substring().

79
Dica: Diante de uma coluna de texto, é de praxe realizar algumas operações
para já “normalizá-la” logo no início. Dessa forma, fica mais fácil encontrar erros e saber
o que esperar do campo sendo trabalhado. Segue o caminho padrão para realizar essa
limpeza:

1. Retirar acentos e caracteres especiais;

2. Retirar espaços em branco no início, no final e espaços em excesso no meio do


string;

3. Converter o string para um padrão de case, seja todas as letras maiúsculas, todas
minúsculas ou todas as palavras com a primeira letra maiúscula.

Exemplo utilizando Python:

Figura 57 – Limpeza Padrão de Strings.

80
Observe que foi utilizada uma biblioteca Python que realiza o trabalho de
remoção de acentos (unidecode) em conjunto com a funcionalidade do Spark de criar
e aplicar no DataFrame, funções definidas pelo usuário. Isso será tratado em mais
detalhes no próximo capítulo.

Datas

Trabalhar com datas é um desafio constante para profissionais da área de


dados. Cada lugar do mundo usa um diferente padrão de armazenamento de dados,
sem contar que diferentes ferramentas usam diferentes formas de armazenar e
computar datas. Somado a isso, diferentes aplicações exigem diferentes formas de
exibição dos dados, o que requer que o usuário esteja preparado para fazer conversões
quando necessário e até mesmo operações como soma e diferença de datas.

Para lidar com todas essas peculiaridades, o Spark implementa algumas


funções bastante úteis para manipular campos de data e tempo:

• add_months(): retorna a data depois de adicionar "x" meses.

• months_between(): retorna a diferença entre duas datas em meses.

• date_add(): retorna a data depois de adicionar "x" dias.

• date_sub(): retorna a data depois de subtrair "x" dias.

• next_day(): retorna o dia seguinte de alguma data.

• datediff(): retorna a diferença entre duas datas em dias.

• current_date(): retorna a data atual.

81
• dayofweek() / dayofmonth() / dayofyear(): retorna o dia
relativo à semana, ao mês e ao ano, respectivamente.

• weekofyear(): retorna a semana relativa ao ano.

• second() / minute() / hour(): retorna os segundos, os minutos


e as horas de uma coluna de datetime, respectivamente.

• month() / year(): retorna o mês e o ano de uma coluna de data,


respectivamente.

• last_day(): retorna o último dia do mês do qual a data considerada


pertence.

• to_date(): transforma a coluna no tipo data (DateType()).

• trunc(): formata a data para a unidade especificada.

‒ year: "{ano}-01-01".

‒ month: "{ano}-{mes}-01".

Como regra geral, é importante se atentar ao formato dos campos de data


durante a leitura/conversão, a fim de evitar possíveis erros. É sempre pior deixar os
formatos das datas serem inferidos implicitamente.

82
Figura 58 – Exemplo com Datas.

Arrays

Os arrays são tipos complexos que conseguem armazenar listas de elementos


de um mesmo tipo, e até mesmo podem conter outros arrays. Os arrays são bastante
utilizados em conjunto com as funções de agregação collect_list() e
collect_set(), como forma de agregar o DataFrame sem perda de informação. As
funções mais interessantes para trabalhar com esse tipo são:

• array(): constrói um array com as colunas selecionadas.

• flatten(): transforma um array de arrays em um único array.

83
• explode(): retorna uma nova linha para cada elemento no array.

• size(): retorna o número de elementos no array.

• sort_array(): ordena os elementos do array, de forma crescente ou


decrescente.

• reverse(): reverte a ordem dos elementos de um array.

• array_distinct(): remove elementos duplicados do array.

• array_contains(): verifica se o array contém o elemento especificado.

• arrays_overlap(): partir de 2 colunas de arrays, verifica se elas têm


algum elemento em comum, retornando True ou False.

• array_union(): a partir de 2 colunas de arrays, retorna um array com


os elementos unidos das duas colunas, sem duplicatas.

• array_except(): a partir de 2 colunas de arrays, retorna um array com


os elementos que estão em uma coluna mas não estão na outra, sem
duplicatas.

• array_intersect(): a partir de 2 colunas de arrays, retorna um array


com os elementos que nas duas colunas, sem duplicatas.

• array_join(): retorna um string após concatenar os elementos do array


usando o delimitador especificado.

• array_max()/array_min(): retorna o máximo e o mínimo valor do


array, respectivamente.

84
• array_remove(): remove todos os elementos do array que são iguais ao
valor especificado.

Uma aplicação interessante de se trabalhar com arrays é ilustrada a seguir, em


que é necessário que algumas instâncias tenham um registro no DataFrame para cada
nível de uma variável (meses, por exemplo):

Figura 59 – Exemplo com Arrays.

85
Valores Nulos

Ao manipular dados, é bastante provável que, em algum momento, seja


necessário lidar com valores faltantes. No contexto de dados, esses valores são
chamados de nulos, uma vez que há total desconhecimento de qual seria aquele valor
e, por isso, as ferramentas não fazem suposição alguma sobre eles. Como lidar com
esses valores, então, fica a critério do profissional, que deve levar em consideração
fatores como a forma de extração, disponibilização, o processamento dos dados até o
momento e o que faz sentido para o negócio em que está inserido.

As principais funcionalidades no Spark para lidar com nulos diz respeito a como
preencher ou remover esses valores, a nível de colunas ou do DataFrame inteiro. Os
métodos a nível de DataFrame são acessados com o atributo df.na, e são:

• drop(): retira do DataFrame as linhas com nulos, com base no que foi
passado para o argumento how.

‒ any (default): retira todas as linhas com pelo menos um valor nulo nas
colunas.

‒ all: somente retira as linhas com todos os valores nulos nas colunas.

• fill(): preenche os valores nulos no DataFrame com uma constante,


passada pelo usuário.

• replace(): substitui o valor (não somente os valores nulos) por algum


outro passado pelo usuário.

Obs.: todas as funções acima aceitam um argumento subset, usado para


identificar em quais colunas deve ser aplicada a função.

86
Figura 60 – Tratando nulos no DataFrame.

Também é possível tratar nulos de forma bastante eficiente com as funções


when() e coalesce(). A primeira pode ser usada para criar uma condição lógica
que tratará os nulos, enquanto a segunda foi criada especificamente para essa tarefa. A
função coalesce() retorna o primeiro elemento não nulo entre as colunas passadas
como argumentos, em que a ordem que as colunas são passadas é a mesma em que a
função será aplicada. Seguem exemplos das duas situações:

87
Figura 61 – Tratando nulos a nível de coluna.

No próximo capítulo, serão apresentadas operações de bancos de dados


relacionais mais complexas, mas primordiais para muitas aplicações e capazes de
resolver uma gama enorme de problemas.

88
Capítulo 4. Manipulando dados com Spark: parte 2

No capítulo anterior, foram abordados os conceitos básicos de operações no


Spark utilizando a API de DataFrames, incluindo os principais componentes de um
DataFrame do Spark, os tipos de dados disponíveis, formas de leitura e de escrita de
dados e algumas operações comuns em contexto de análise e processamento de dados.
Neste capítulo então, serão apresentadas algumas operações mais complexas, mas
igualmente úteis e comuns na manipulação de grandes volumes de dados,
especialmente se tratando do contexto de Big Data.

Agrupamento e Agregação

Agrupamento e agregação são duas operações que normalmente caminham


juntas, mas não necessariamente são dependentes. A agregação diz respeito a qualquer
operação que recebe o banco de dados e retorna um valor escalar, como operações de
contagem, soma, média etc. Já o agrupamento, diz respeito a informar para o
mecanismo de processamento, seja ele o Spark ou outra ferramenta de manipulação de
dados, que as próximas operações realizadas não devem ser aplicadas ao DataFrame
como um todo, mas a grupos de linhas definidos pela combinação única dos elementos
de uma série de colunas. A operação seguinte ao agrupamento pode ou não ser uma
operação de agregação, e, caso não o seja, a operação é chamada de Window function,
algo que será tratado na próxima seção.

No Spark, é possível realizar agregação utilizando o método agg(), que recebe


uma especificação de coluna ou expressão que retorne um valor escalar (o que
caracteriza uma função de agregação). O módulo de funções, abordado anteriormente,
disponibiliza diversas funções de agregação que podem ser utilizadas nesse contexto,
sendo as principais delas:

• sum(): retorna a soma dos valores da coluna;

89
• sumDistinct(): retorna a soma dos valores distintos da coluna;

• min() / max(): retorna o mínimo e o máximo da coluna,


respectivamente;

• avg() / mean(): retorna a média dos valores da coluna;

• percentile_approx(): retorna o percentil da coluna, com


aproximação. Para trazer a mediana exata, usar:
percentile_approx(col('col1'), 0.5, lit(1000000));

• stddev(): retorna o desvio padrão dos valores da coluna;

• count(): retorna a contagem de linhas;

• countDistinct(): retorna a contagem de valores distintos da coluna;

• first() / last(): retorna o primeiro e o último valor da coluna,


respectivamente. Interessante de ser utilizada em conjunto com o
argumento ignoreNulls=True;

• collect_list(): retorna os valores da coluna em uma lista, com


duplicações;

• collect_set(): retorna os valores da coluna em uma lista, sem


duplicações (desordenado);

• expr(): é possível usar a função de criação de expressões, desde que seja


denotada uma operação de agregação por meio dela.

Vale destacar que além de ser uma função, o count() também é chamado de
um método do Spark DataFrame. Isso significa que, quando há necessidade de contar as

90
linhas, esse método pode ser usado diretamente, com maior praticidade para o usuário.
Exemplos:

Figura 62 – Agregações Básicas no Spark.

Além de agg() e count(), existem ainda outros métodos capazes de realizar


agregações. Alguns deles são:

• describe(): computa diversas estatísticas básicas para todas as colunas


numéricas ou de strings no DataFrame. Essas quantidades são a contagem,
média, desvio padrão, mínimo e máximo;

91
• approxQuantile(): calcula um ou mais quantis das colunas numéricas
de um DataFrame;

• corr(): calcula a correlação de Pearson entre duas colunas numéricas de


um DataFrame.

Obs.: O Spark ignora os valores nulos para calcular as agregações, com exceção
da função count().

Para realizar agrupamentos, basta utilizar o método groupBy() anterior à


função de agregação a ser realizada. As colunas passadas podem ser especificações de
colunas, strings com nome das colunas ou uma lista de strings com os nomes das
colunas. Os métodos que podem ser chamados após o agrupamento são:

• agg()

• count()

• sum()

• avg() / mean()

• max()

• min()

92
Figura 63 – Agrupamentos no Spark.

Pivotação

Além daqueles mostrados acima, há ainda o método pivot(), que pode ser
usado em conjunto com groupBy() para realizar uma operação de pivotação dos
dados. A pivotação nada mais é do que transformar cada um dos níveis de uma coluna

93
em colunas individuais, e realizando uma operação de agregação no processo para
preencher as novas células do DataFrame gerado. Assim, essa transformação só pode
ser utilizada após um agrupamento, e deve ser seguida obrigatoriamente por uma
chamada dos métodos agg() ou count(). Incrementando o exemplo anterior,
temos:

Figura 64 – Pivotação.

94
Observe no exemplo como foi calculado um valor da média de vendas para cada
combinação das colunas “grupo” e “anos”, sendo que a coluna “anos” foi transformada
em várias colunas iguais aos valores distintos que ela apresentava. Seria possível obter
resultados semelhantes em termos do resultado da agregação se fosse realizado um
agrupamento com as duas variáveis, do tipo

df.groupby(“grupo”,
“anos”).agg(round(mean(“vendas”)))

O que mostra como a pivotação é apenas uma forma diferente de expressar


um agrupamento. A aplicação mais comum para essa operação é melhorar a forma de
exibição do DataFrame, apesar de ser útil em algumas situações em um processo de ETL.

Window Functions

Conforme mencionado no bloco anterior, window functions são funções que


realizam cálculos similares a uma agregação, mas que não resultam em um DataFrame
agregado. Ao invés disso, os resultados são colocados em uma nova coluna, segundo a
partição de linhas (ou agrupamento) especificada. Um ponto importante a se pensar é
que, ao utilizar um agrupamento comum, cada linha pode pertencer a apenas um grupo,
enquanto em uma window function é possível delimitar intervalos dinâmicos – o que
permitiria que uma linha participasse de um ou mais agrupamentos. Um exemplo claro
disso são as médias móveis, em que uma mesma unidade de tempo é considerada várias
vezes no cálculo da média antes de deixar de ser considerada, segundo a janela
estabelecida.

Tendo isso em vista, o Spark suporta três tipos de window functions: funções
de agregação, funções de ranqueamento e funções analíticas. Porém, antes de
introduzi-las, é necessário mostrar como especificar uma window function. Primeiro é
necessário importar o objeto Window dos módulos import

95
org.apache.spark.sql.expressions (Scala) ou pyspark.sql.window
(Python). A forma padrão de uma janela segue:

Window.partitionBy({columns})
.orderBy({columns})
.rowsBetween({lower}, {upper})

Em que cada um dos métodos é opcional para a definição da janela. Sobre esses
elementos:

• partitionBy(): agrupamento em que os cálculos serão realizados. É


análogo ao groupBy().

• orderBy: funções como row_number() e lag() dependem da


ordenação das linhas do agrupamento. Essa função é usada para especificar
a ordem desejada.

• rowsBetween(): esse método é usado para especificar janelas


deslizantes. A partir dele é possível definir um intervalo de linhas, relativas
à linha atual, em que a função vai ser aplicada. Caso isso não seja
especificado, as operações são realizadas em todo o grupo. Muito útil para
construir médias móveis. Os seguintes objetos ajudam na construção desse
intervalo dinâmico:

‒ Window.currentRow: define a linha atual como um dos limites da


janela

‒ Window.unboundedPreceding: define que não há limites para as


linhas anteriores à linha atual, isto é, a função irá considerar todas as
linhas do grupo que já passaram na janela. Deve ser usado no primeiro
argumento (start).

96
‒ Window.unboundedFollowing: define que não há limites para as
linhas posteriores à linha atual, isto é, a função irá considerar todas as
linhas do grupo que ainda não passaram na janela. Deve ser usado no
segundo argumento (end).

É importante mencionar que existe uma forma alternativa a rowsBetween()


para delimitar janelas deslizantes por meio do método rangeBetween(). Nesse caso,
o intervalo é definido pelo valor das linhas, e não pela sua ordenação no DataFrame. Por
ser uma lógica mais complexa, é recomendado usar a primeira alternativa sempre que
possível.

Uma vez especificada a janela, para indicar que a função deve ser aplicada no
agrupamento deve ser utilizado o método over():

w = Window.partitionBy(“col1”, “col2”)
df.withColumn(“col_window”, function().over(w))

As funções de agregação são basicamente as mesmas apresentadas na seção


de Agrupamento e Agregação, a única diferença é a utilização do método over() para
indicar o cálculo na janela.

As funções de ranqueamento são utilizadas para gerar uma sequência de


valores ao longo das janelas. Para as funções de ranqueamento, é exigido que as
definições de janelas sejam ordenadas. Algumas dessas funções são:

• rank(): retorna o rank das linhas em uma sequência começando em 1. No


caso de empates, essa função deixa “espaços” no ranqueamento, isto é, se
três indivíduos estivessem empatados em segundo lugar, todas eles teriam
rank 2 e o indivíduo em terceiro lugar teria rank 5;

97
• dense_rank(): retorna o rank das linhas em uma sequência começando
em 1. No caso de empates, essa função não deixa “espaços” no
ranqueamento, isto é, se três indivíduos estivessem empatados em segundo
lugar, todas eles teriam rank 2 e o indivíduo em terceiro lugar teria rank 3;

• percent_rank(): retorna o rank relativo (percentil) das linhas em uma


partição;

• row_number(): retorna uma sequência ordenada da janela começando


em 1, sem se importar com empates.

As demais funções disponíveis são enquadradas como funções analíticas, e


estão mostradas abaixo:

• cume_dist(): retorna a distribuição acumulada dos valores na janela,


isto é, a fração de linhas que estão atrás da linha atual;

• lag([offfset]): retorna o valor que está offfset linhas antes da


linha atual igual ao input do usuário. Por exemplo, um offfset igual a 1,
para qualquer ponto da partição a função retornará a linha anterior da
coluna;

• lead([offfset]): retorna o valor da coluna que está offfset linhas


depois da linha atual. Por exemplo, um offfset igual a 1, para qualquer
ponto da partição a função retornará a linha anterior da coluna.

Por fim, seguem alguns exemplos práticos de aplicações de window functions


em pipelines de ETL:

98
Calculando Distintos

Podem existir situações em que é necessário ter uma coluna com uma
contagem de valores distintos. No entanto, a função countDistinct() não pode
ser utilizada como uma window function:

Figura 65 – Erro de Contagem de Distintos com Window Functions.

A solução nesse caso é utilizar uma combinação das funções


collect_set() e size(), de forma a coletar os valores distintos da partição em
um array e posteriormente calcular o tamanho de cada uma delas:

99
Figura 66 – Contando Distintos com Operações de Array.

Evitando Joins

Considerando o DataFrame abaixo:

Figura 67 – Exemplo de Window Function - DataFrame Base.

100
Suponha uma necessidade durante o processamento de que seja preciso filtrar
as linhas de forma a só manter aqueles referentes ao ano mais recente, para cada um
dos grupos. O caminho mais natural seria utilizar um join:

Figura 68 – Exemplo de Window Function – Join.

Uma alternativa possível, sem usar joins, é utilizar window function da seguinte
forma:

Figura 69 – Exemplo de Window Function - Alternativa ao Join.

101
Fica a critério do usuário definir qual a melhor maneira de ser utilizada em cada
pipeline, considerando aspectos como custo computacional, legibilidade e simplicidade
de compreensão do código.

Joins

Em manipulação de dados, os joins (ou junção, em português) são uma classe


de operações de banco de dados usada para relacionar duas tabelas com base em
colunas comuns entre elas. Os joins no pyspark são especificados pela função join(),
da seguinte forma:

df1.join(df2, {colunas_chave}, {tipo_join})

• colunas_chave: colunas que vão ser utilizadas para fazer a junção das
tabelas. Pode ser especificada como

‒ String único: só uma coluna é chave, os nomes devem ser os mesmos em


ambas as tabelas;

‒ Lista de string ou de colunas (usando col()): mais de uma coluna é


chave, os nomes devem ser os mesmos em ambas tabelas;

‒ Nomes de colunas diferentes: com nomes diferentes, é necessário fazer


uma especificação do tipo: col(“col1”) == col(“col2”). Caso
mais de uma coluna for usada como chave, essas especificações devem
ser colocadas em uma lista.

• tipo_join: o tipo de join a ser realizado. As opções são:

‒ inner (default): mantém somente as linhas com chaves iguais nos


DataFrames da esquerda e da direita, juntando as duas linhas

102
‒ outer/full/fullouter/full_outer : mantém as linhas dos
DataFrames da esquerda ou da direita, juntando as duas linhas quando
há igualdade de chaves e empilhando-as quando não há;

‒ left/leftouter/left_outer : mantém linhas do DataFrame da


esquerda, juntando as linhas com o da direita quando as chaves são
iguais;

‒ right/rightouter/right_outer : mantém linhas do DataFrame


da direita, juntando as linhas com o da esquerda quando as chaves são
iguais;

‒ semi/leftsemi/left_semi: mantém somente as linhas do


DataFrame da esquerda que tem uma chave presente no DataFrame da
direita;

‒ anti/leftanti/left_anti: mantém somente as linhas do


DataFrame da esquerda que não tem uma chave presente no DataFrame
da direita.

Não é o objetivo da apostila entrar em muitos detalhes sobre os joins


especificamente, visto que é uma operação amplamente conhecida e utilizada no
processamento de dados. Ao invés disso, será apresentado alguns exemplos de
especificações das chaves e do anti e semi joins:

103
Figura 70 – Exemplos de Joins.

104
O último tipo de junção de tabelas que vale a pena mencionar é o produto
cartesiano, feito por meio do método crossJoin(). Um produto cartesiano junta as
tabelas sem utilizar chaves, mas em um esquema de “todos para todos”: é gerado um
novo DataFrame com todas as combinações possíveis de linhas entre os dois anteriores
DataFrames. Como é de se esperar, essa é uma operação bastante custosa e que tem
potencial grande de gerar erros de falta de memória. Para ilustrar: o produto cartesiano
de dois DataFrames de 10.000 linhas resultaria em um DataFrame com 10.000 x
10.000 = 100.000.000 de linhas, o que muito provavelmente iria requerer um poder
computacional bastante maior do que o alocado inicialmente. Exemplo:

Figura 71 – Produto Cartesiano.

105
Unions

Uma outra forma de unir a informação de duas tabelas é simplesmente


empilhar as duas, tornando-as em uma só. Para realizar essa operação, é necessário
garantir que os dois DataFrames tenham o mesmo número de colunas e o mesmo
schema (ou seja, tipos das colunas), e a operação é feita utilizando um de dois métodos:
union() ou unionByName().

No primeiro, as colunas são concatenadas com base na sua posição no


DataFrame, um comportamento que pode levar a erros inesperados e difíceis de
depurar. O segundo método, por sua vez, resolve esse problema ao concatenar colunas
pelo nome, de forma que o usuário só precisa garantir que as colunas que devam ser
concatenadas tenham o mesmo nome.

Figura 72 – Union com Ordem Errada.

106
Observe no exemplo acima como a ordem errada das colunas no momento da
união gera um comportamento não desejado sem lançar nenhum erro, já que havia
possibilidade de converter os valores numéricos para strings de forma a preservar um
schema aceitável.

Abaixo o mesmo exemplo utilizando unionByName() executa a união da


forma esperada independente da ordem das colunas:

Figura 73 – Union By Name com Ordem Errada.

User Defined Functions (UDF)

Em algumas situações é necessário criar/alterar uma coluna utilizando uma


operação não implementada na biblioteca padrão. Para isso, é possível utilizar funções
definidas pelo usuário (UDFs) por meio da função udf(). Sua especificação se dá
segundo o esquema abaixo:

107
Figura 74 – Padrão de Definição de UDFs.

Em Python ainda é possível utilizar os chamados decorators:

Figura 75 – UDFs em Python usando Decorators.

108
Nos exemplos acima, é importante observar que Type e DataType apenas
denotam que o usuário deve passar o tipo correto de retorno das funções, e não são
tipos de dados reais.

Um exemplo de UDF bastante prática é aquela definida na seção trabalhando


com strings, que faz uso da biblioteca Python unidecode para remover acentos e
caracteres especiais de dados de texto. Abaixo é possível ver novamente seu
funcionamento:

Figura 76 – Exemplo de UDF.

109
Importante: As funções definidas pelo usuário em Python precisam ser
serializadas e requerem que um processo Python seja inicializado, o que pode
representar um grande gargalo na execução.

Criando Métodos Customizados

Em algumas situações, é interessante realizar uma operação não implementada


sobre um DataFrame. Além disso, pode ser que seja necessário (ou do desejo do
desenvolvedor) utilizar essa operação de forma encadeada com outras operações,
formando uma unidade de transformações.

A partir do Spark 3.0 e utilizando a linguagem Python, é possível atacar esse


problema com o método transform(), da seguinte maneira:

1. Definir uma função do Python da seguinte forma:

Figura 77 – Padrão de Função Python do Método Customizado

2. Depois de definida a função, ela pode ser chamada da seguinte forma:

110
Figura 78 – Utilizando o método transform().

Em versões anteriores a 3.0, o desenvolvedor precisaria definir o método por


conta própria, uma vez que ele ainda não havia sido implementado na biblioteca padrão:

Figura 79 – Definindo transform() para o Spark <= 3.0.

Segue um exemplo da criação de um método para renomear colunas de


maneira similar a biblioteca pandas, usando um dicionário:

111
Figura 80 - Métodos Customizados.

112
Capítulo 5. Spark SQL

Neste capítulo, serão abordados os conceitos principais do Spark SQL e seus


principais componentes, como o conceito de databases, o catálogo de metadados e
como criar tabelas e views para realizar operações. Além disso, será mostrada a
interface do Spark para manipulação de dados usando o SQL, que permite a realização
de consultas e execução de comandos de bancos de dados.

Visão Geral do Spark SQL

Em termos de manipulação de dados, até aqui foi apresentado como utilizar a


API dos DataFrames para realizar operações relacionais. Acontece que não por
coincidência, a grande maioria das operações apresentadas são ou foram inspiradas por
funções e cláusulas do SQL, o que é um dos fatores responsáveis pelo fato do Spark ser
uma ferramenta tão acessível e fácil de ser entendida por analistas de dados e outros
profissionais da área. O fato é que o Spark vai além: ele permite que o SQL seja aplicado
diretamente sobre os dados.

O mecanismo responsável por isso é chamado de Spark SQL, e com ele é


possível ter acesso a:

• Realização de ANSI-SQL e HiveQL queries que agem diretamente sobre os


RDDs.

• Criação de database, tabelas e views, como se fosse um banco de dados


relacional.

• Administração dos dados usando o catálogo de metadados do Spark e sua


integração com a Hive metastore.

113
Dessa forma, os usuários podem desfrutar de todo o poder de computação do
Spark por meio dos RDDs, ao mesmo tempo em que utiliza de uma linguagem de
consultas conhecida, simples e consolidada.

É importante destacar que o Spark foi desenvolvido para operar como um


database de processamento analítico online (OLAP), e não um database de
processamento transacional online (OLTP). Isso implica que não existem funções de
modificação, inserção e alteração de dados por enquanto, apesar de que os
desenvolvedores admitem que isso seja uma possibilidade no futuro, ainda mais com o
crescimento em popularidade das tabelas Delta.

Apesar dessa restrição, o Spark SQL é uma ferramenta bastante utilizada como
opção aos DataFrames e até mesmo em conjunto com eles, uma vez que eles são
plenamente intercambiáveis em termos de eficiência e modo de funcionamento
interno.

Importante: ao utilizar o Spark SQL em modo local, é importante instanciar


corretamente a SparkSession, utilizando as seguintes configurações:

Figura 81 – Configurações Sparksession.

114
Em que warehouse_location é uma variável com o caminho onde os
databases e tabelas serão salvos.

Databases e catalog

Como já mencionado anteriormente, o Spark armazena metadados das suas


tabelas e arquivos para conseguir otimizar a execução. Na realidade, o Catalog é o
maior nível de abstração existente no Spark SQL e ele armazena dados não somente de
tabelas, mas também de databases, funções e views. Ele pode ser acessado por meio da
SparkSession, com o seguinte comando

SparkSession.catalog

As principais funções disponíveis no Catalog estão disponíveis por meio dos


métodos a seguir:

• listDatabases(): lista todas os databases disponíveis.

• listTables(): lista todas as tabelas disponíveis em um determinado


database.

• listFucntions(): lista as funções disponíveis em um determinado


database.

• refreshTable(): atualiza os metadados de uma determinada tabela.

• uncacheTable(): remove uma tabela salva em memória.

• clearCache(): remove todas as tabelas salvas em memória.

As funções que permitem remover os dados da memória são normalmente as


mais utilizadas no dia a dia e vamos entender melhor seu propósito no próximo capítulo.

115
Os databases do Spark são uma ferramenta para organizar tabelas. Eles podem
e devem ser vistos como algo muito próximo dos databases de servidores de bancos de
dados relacionais. O Spark utiliza por padrão um database chamado default, que serve
para criar tabelas, views e realizar consultas caso o usuário não tenha definido o seu
próprio. Um ponto importante é que essas estruturas persistem em diferentes sessões:
se o usuário mudar de database, todas as tabelas permanecerão no database anterior e
vão precisar ser consultadas de maneira diferente.

Existem alguns comandos do SQL importantes na hora de se trabalhar com


databases. Else são:

• SHOW DATABASES: lista todas os databases disponíveis, de forma análoga


ao Catalog .

• CREATE DATABASE <nome_do_db>: cria um database.

• USE <nome_do_db>: define o database como o atual para a realização


de queries.

‒ Obs.: ao se mudar de database, é possível acessar tabelas de um


database anterior usando o prefixo “nome_do_db.” antes do nome da
tabela. Exemplo:

USE db2
SELECT * FROM db1.table

• SELECT current_database(): retorna qual o database definido


como o atual.

116
• DROP DATABASE IF EXISTS <nome_do_db>: deleta determinado
database dentre aqueles que foram definidos. Atenção: nunca delete o
database default do Spark.

Observe que até o momento ainda não apresentamos o mecanismo de


realização de consultas onde esses comandos podem ser executados. Isso será tratado
ao final do capítulo.

Por fim, vale ressaltar que todas as operações definidas sobre os dados, seja no
contexto do SQL ou por meio dos DataFrames, são executadas no contexto de um
database.

Criando Tabelas e Views

As tabelas são a unidade de armazenamento de dados dentro de uma


database. Elas são similares aos DataFrames em diversos aspectos, no sentido de que é
possível realizar todas as operações vistas anteriormente - como seleção, filtro,
agregação e junção - também nas tabelas. A diferença crucial é que os DataFrames são
definidos dentro do escopo de uma linguagem de programação, e são acessados pelas
diversas funções e métodos definidos na biblioteca do Spark, enquanto as tabelas são
definidas no escopo de um database e são acessadas e consultadas por meio do SQL.

Existem dois tipos de tabelas no Spark:

• Managed Tables: o Spark administra tanto os dados quanto os metadados


das tabelas, de forma que operações como DROP TABLE afetam também
os dados escritos em disco;

• Unmanaged Tables: o Spark administra somente os metadados da tabela, e


os dados escritos em disco não são alterados em nenhum momento

117
Os comandos abaixo são usados para criar esses dois tipos de tabelas:

Figura 82 – Criando Managed Tables.

Figura 83 – Criando Unmanaged Tables.

Uma observação importante é que o argumento schema nas definições acima


deve ser definido da mesma forma que foi ensinado na seção criando schemas com
linguagem DDL, no capítulo 3. Além disso, sempre use o comando USING para passar o

118
formato do arquivo na criação das unmanaged tables, pois isso otimiza bastante a
performance.

A partir desse ponto, a maioria das funcionalidades do SQL estão disponíveis


para serem utilizadas com essas tabelas. Atenção: o comando DROP TABLE irá deletar
todos os dados de uma managed table, então use-o com cuidado.

Uma vez apresentadas as tabelas, é importante destacar que elas são


permanentes, isto é, uma vez salvas elas irão persistir no decorrer de múltiplas sessões.
No Spark SQL não há o conceito de tabela temporária e para isso é necessário utilizar
views. As views, diferente das tabelas, não escrevem os dados fisicamente em lugar
algum, mas são utilizadas basicamente para salvar um conjunto de transformações
definidos sobre uma tabela ou DataFrame e por isso são em si mesmo uma
transformação. Assim, as views são análogas a criação de um DataFrame a partir de
outro DataFrame, e o usuário pode escolher o tipo de manipulação de sua preferência.

As views podem ser globais ou dependentes de uma sessão. As views globais


podem ser acessadas independente do database utilizado, por várias SparkSession
em uma mesma aplicação do Spark, enquanto as views comuns podem ser acessadas
somente pela SparkSession em que foram definidas. No entanto, ambas são
removidas ao fim da sessão.

Seguem os padrões para criar views utilizando tanto a sintaxe de DataFrames


como usando o SQL:

119
Figura 84 – Criando Views.

De forma parecida com as tabelas, é possível deletar views utilizando o


comando DROP VIEW <nome_da_view> ou então utilizando o Catalog :

spark.catalog.dropGlobalTempView("nome_da_view")
spark.catalog.dropTempView("nome_da_view")

Fazendo queries no Spark SQL

Os comandos SQL apresentados anteriormente precisam ser executados em


algum dispositivo capaz de interpretá-los, uma vez que eles não funcionam na shell
padrão disponível em Python e Scala. O Spark disponibiliza duas formas para realização
de queries interativas: um client interativo que pode ser executado na linha de comando
e uma programática usando o método SparkSession.sql(). Todos os comandos
vistos nas seções anteriores deste capítulo devem ser utilizados no escopo de uma
dessas duas opções.

Para abrir a interface do client SQL, é necessário abrir um terminal de linha de


comando e executar o seguinte:

120
Figura 85 – Abrindo a Interface do Spark SQL.

Nessa shell, é possível acessar databases, tabelas e executar consultas SQL


normalmente, como se fosse um servidor de bancos de dados relacionais, com exceção
das operações típicas de OLTP.

Abaixo é possível ver um exemplo de utilização do Spark SQL utilizando a API


estruturada:

Figura 86 – Spark SQL com API Estruturada.

Todas as operações feitas com a o método SparkSession.sql() retorna


um DataFrame, o que implica no fato de que todas as consultas realizadas usando o
Spark SQL se comportam da mesma formas do que os DataFrames, mantendo todas as

121
propriedades aprendidas anteriormente. Inclusive, é possível utilizar a maior parte das
funções disponíveis dentro do módulo pyspark.sql.functions dentro do
contexto do Spark SQL e é possível checar quais estão disponíveis utilizando o
Catalog.

Esse é o verdadeiro poder do Spark SQL: o usuário é capaz de alternar entre o


Spark SQL e os DataFrames conforme bem entender, de acordo com a sua necessidade,
preferência e praticidade.

Um último tópico importante a ser abordado é o fato de que é possível registrar


UDFs para serem utilizadas nesse ambiente da mesma forma que foram registradas para
serem usadas com DataFrames. A estrutura básica é:

Figura 87 - Registrando UDFs no Spark SQL.

Em que “functionUDF” é o nome pelo qual a função será acessada no ambiente


do Spark SQL e “DataType()” é o tipo de dados do Spark disponível no módulo
pyspark.sql.types.

122
Capítulo 6. Otimizando Aplicações do Spark

Nos capítulos anteriores, foram apresentadas as principais formas de se


manipular dados com o Spark, por meio da API de DataFrames e do Spark SQL. Neste
capítulo, será mostrado como configurar o Spark e ajustá-lo para processamento de
diferentes tamanhos e necessidades, além de demonstrar possibilidades e técnicas de
otimização dos processamentos. Também será abordada uma forma de
particionamento da escrita de dados, e como isso pode implicar diretamente na
performance de uma aplicação.

Como configurar e escalar o Spark

Apesar de as configurações padrão serem suficientes para várias cargas de


trabalho reais e situações de exploração e aprendizado, muitas vezes é necessário fazer
alguns ajustes para adequar a aplicação para o contexto em que está inserida. Para isso,
existem três maneiras de alterar as configurações do Spark:

• Arquivo spark-defaults.conf.

• Linha de comando, usando o spark-submit.

• Durante a criação da SparkSession.

Caso uma mesma configuração seja especificada por mais de um dos métodos
acima, o Spark considera a seguinte ordem de precedência:

Figura 88 – Ordem de Precedência das Configurações.

123
Abaixo uma classificação das possíveis configurações:

• Application properties.

• Runtime environment.

• Shuffle behavior.

• Spark UI.

• Compression and serialization.

• Memory management.

• Execution behavior.

• Networking.

• Scheduling.

• Dynamic allocation.

• Security.

• Encryption.

• Spark SQL.

• Spark streaming.

• SparkR.

spark-defaults.conf

O arquivo spark-defaults.conf está disponível dentro do diretório


SPARK_HOME, na pasta conf. Inicialmente, ele estará salvo como um arquivo .template,

124
que simplesmente indica o formato que o arquivo necessário e alguns exemplos de
configurações:

Figura 89 – Arquivo spark-default.conf.template.

Uma vez definidas as configurações desejadas, basta salvar o arquivo sem a


extensão .template. Dessa forma, todos os próximos clusters e aplicações do Spark
executadas levarão em consideração as configurações definidas nele para serem
inicializadas.

spark-submit

A segunda forma de configurar uma aplicação do Spark é por meio do comando


spark-submit. A função desse comando é simples: executar uma aplicação a partir de
um script (Python) ou JAR (Java/Scala). Uma vez que é usado na linha de comando, os
parâmetros devem ser passados assim:

125
Figura 90 – Configurando com spark-submit.

Os demais parâmetros disponíveis para o spark-submit serão apresentados em


mais detalhes no próximo capítulo.

SparkSession

Por fim, é possível ajustar configurações por meio da API estruturada,


utilizando o momento da criação da SparkSession:

Figura 91 – Configurando com SparkSession.

126
Essas opções são definidas no momento da criação da sessão do Spark e como
dito anteriormente, essa sintaxe toma precedência em relação aos métodos anteriores.
É importante ressaltar que é possível alterar configurações de uma sessão já instanciada,
da seguinte forma:

Figura 92 – Alterando configurações durante a execução.

No entanto, nem todas as configurações podem ser alteradas dessa forma. Para
checar se é possível que alguma delas seja alterada, o método
spark.conf.isModifiable("conf_name") retorna verdadeiro ou falso.
Enfim, pode-se destacar o método spark.conf.get("conf_name") para
verificar o valor atribuído a alguma configuração.

A documentação dispõe de uma lista extensiva de configurações que podem


ser alteradas para ajustar aplicações, mas abaixo serão destacadas algumas das mais
usadas e mais importantes:

127
• spark.master: seleciona o modo de deploy da aplicação Spark. Será
tratado em mais detalhes no capítulo seguinte.

• spark.driver.memory: quantidade de memória atribuída para o driver


da aplicação.

• spark.executor.memory: quantidade de memória atribuída para cada


um dos executores da aplicação.

• spark.serializer: classe utilizada para realizar a serialização de


objetos durante a execução. É recomendado utilizar o valor
org.apache.spark.serializer.KryoSerializer para ganhar em velocidade de
processamento, uma vez que chega a ser até 10x mais rápido que o default.

• spark.executor.heartbeatInterval: intervalo entre


notificações dos executores ao driver. Aumentar esse valor evita que a
aplicação sofra com timeouts.

• spark.sql.adaptive.enabled: habilita o Adaptive Query Execution,


programa que atualiza o plano de execução durante a execução, a partir de
métricas coletadas durante o processo. Ativar essa configuração pode
otimizar processamentos significativamente.

• spark.sql.shuffle.partitions: número padrão de partições


utilizadas em shuffles de operações de junção (joins) e agregações (agg).

• spark.sql.broadcastTimeout: tempo de timeout em segundos


para operações de broadcast join, a serem tratadas no fim do capítulo.

Além dessas configurações que podem auxiliar a otimizar processamentos, é


possível preparar o Spark para aplicações escaláveis utilizando um recurso chamado

128
dynamic allocation, que permite que o programa requisite ou dispense recursos
conforme a necessidade das cargas de trabalho. Isso é bastante interessante em
situações em que as cargas são variáveis em termos de tamanho e imprevisíveis. As
principais configurações relacionadas a esse recurso são:

• spark.dynamicAllocation.enabled: habilita o uso do recurso de


dynamic allocation na aplicação.

• spark.dynamicAllocation.executorIdleTimeout: configura
o tempo máximo de ociosidade de um executor até que o dynamic allocation
o derrube.

• spark.dynamicAllocation.initialExecutors: quantidade
inicial de executores na aplicação ao utilizar o dynamic allocation.

• spark.dynamicAllocation.maxExecutors: quantidade mínima


de executores na aplicação ao utilizar o dynamic allocation.

• spark.dynamicAllocation.minExecutors: quantidade máxima


de executores na aplicação ao utilizar o dynamic allocation.

Persistência de dados na memória

Por utilizar de Lazy Evaluation, o Spark muitas vezes fica com uma fila enorme
de operações para realizar no seu plano de execução. Assim, se for necessário consultar
um determinado DataFrame diversas vezes, pode ocorrer uma perda grande de
performance pelo fato de todas as operações terem de ser executadas novamente em
diferentes consultas.

Uma maneira de mitigar isso é escrever DataFrames intermediários. No


entanto, é possível utilizar as funções cache() e persist() como alternativa, no

129
que é chamado de persistência em memória. Essencialmente, elas fazem a mesma
coisa: salvam o DataFrame resultante das operações na memória, de forma que acessos
futuros a ele sejam muito mais rápidos. A única diferença é que a função persist()
oferece mais opções de armazenamento. As opções disponíveis são:

• cache():

‒ MEMORY_AND_DISK.

• persist():

‒ MEMORY_ONLY;

‒ MEMORY_ONLY_SER;

‒ MEMORY_AND_DISK;

‒ MEMORY_AND_DISK_SER;

‒ OFF_HEAP;

‒ DISK_ONLY.

Importante: as operações devem ser realizadas pelo menos uma vez depois de
o DataFrame ter sido "cacheado" para que ele seja de fato salvo na memória. Por causa
disso, o procedimento mais comum na hora de utilizar essas funções é chamar uma ação
logo em seguida:

130
Figura 93 – Ativando a persistência de dados na memória.

O ganho de performance em utilizar persistência de dados na memória é


bastante significativo, mas é importante ter em mente que esses DataFrames persistidos
ocupam espaço em memória que iria ser usado para processamento, então é
importante observar o tamanho dos dados sendo persistidos para que o processamento
não seja prejudicado.

Existem duas formas de limpar dados persistidos em memória, uma individual


e outra de forma geral:

• df.unpersist(): limpa o DataFrame específico da memória;

• spark.catalog.clearCache(): limpa todos os DataFrames


persistidos na memória.

Estratégias de particionamento de dados

Em muitas situações, é interessante controlar a forma como os dados são


escritos em disco para facilitar o gerenciamento de dados e otimizar consultas futuras
realizadas neles. Dependendo do esquema de particionamento, o Spark é capaz de
filtrar os dados na leitura e otimizar operações que requerem shuffles e ordenação de
dados, como joins. As duas maneiras disponíveis de se particionar dados na leitura são
a bucketização e o particionamento por coluna.

131
Na bucketização, os dados são salvos em n buckets, com base em uma coluna
passada pelo usuário. A vantagem de se utilizar essa estratégia de particionamento dos
dados é que operações que requerem um shuffle de dados são otimizadas, uma vez que
os dados com um mesmo identificador de bucket são organizados fisicamente em um
mesmo local, isto é, os dados são pré particionados, de forma que o Spark sabe
exatamente quais dados estão presentes em quais partições.

Obs.: a opção de bucketizar os dados só está disponível para dados salvos como
tabelas, utilizando o comando saveAsTable().

Para usar o modo de bucketização, é possível usar a função bucketBy()


durante a utilização da classe DataFrameWriter. Exemplo:

Figura 94 – Usando bucketing na escrita.

O particionamento por coluna, por sua vez, particiona os dados escritos em


disco a partir dos valores distintos de uma ou mais colunas, de forma que é criada uma
estrutura de arquivos hierárquica com base na forma como as colunas foram passadas
na função. O objetivo de se utilizar essa estratégia é que uma vez que os dados estão
particionados por suas colunas, o Spark consegue aplicar filtros logo na leitura dos
dados, desde que o filtro seja aplicado a uma das colunas pelo qual o DataFrame foi
particionado. Além disso, esse processo pode ser feito pelo próprio usuário, que ganha
a possibilidade de escolher a partr do DataFrame que o interessa. Assim como na

132
bucketização, essa funcionalidade pode ser acessada pela função partitionBy()
durante a utilização da classe DataFrameWriter, como pode ser visto abaixo:

Figura 95 – Usando particionamento por colunas na escrita.

Reparticionando DataFrames

Além de ser possível particionar dados na escrita em disco, é possível


reparticionar DataFrames durante o seu processamento. Foi apresentado
anteriormente que o Spark quebra fisicamente os dados e os distribui entre os
executores, de forma a conseguir executar as tarefas em paralelo. Acontece que muitas
vezes é interessante reduzir ou aumentar o número de partições dos dados,
dependendo da quantidade de executores disponíveis e do volume de dados sendo
processados. Via de regra, as seguintes situações devem ser levadas em conta:

• Muitas partições: quando os dados são particionados além da capacidade


do driver de administrá-los e dos executores de processá-los, pode haver
uma sobrecarga de gerenciamento dos diretórios e atribuições de tarefas.
Dessa forma, podem acontecer problemas de falta de memória.

• Poucas partições: quando os dados são pouco particionados, o tamanho


dessas partições pode ficar maior do que a capacidade do Spark de processá-

133
los em paralelo. Dessa forma, podem acontecer problemas de falta de
memória.

Os dois métodos disponíveis para controlar o número de partições dos dados


são repartition() e coalesce(). A diferença entre eles é simples: o
coalesce() não realiza shuffle de dados, mas só pode ser usado para reduzir o
número de partições do DataFrame. Vale destacar que para aumentar o número de
partições, sempre deve haver shuffle, já que os dados são fisicamente reparticionados.
Essas funções também podem ser usadas para reparticionar com base em uma coluna,
de forma a otimizar operações como filtros futuramente. Exemplos de uso:

Figura 96 – Reparticionando o DataFrame.

Interessante lembrar que o número de partições do DataFrame interfere


diretamente na quantidade de arquivos a serem salvos em uma operação de escrita, em
que é criado um arquivo para cada partição.

Por fim, é importante reforçar que é recomendado sempre usar o


coalesce() para redução de partições, mantendo o repartition().

134
Escolhendo o melhor tipo de Join

Como uma última maneira (dentre várias outras que ainda existem) de se
otimizar um processamento no Spark, é possível escolher diferentes tipos de algoritmo
para a realização de joins, a fim de escolher os mais adequados para cada situação.
Existem pelo menos três tipos de algoritmos para joins, sendo eles:

• Broadcast Join (BHJ).

• Shuffled Hash Join (SHJ).

• Sort Merge Join (SMJ).

O BHJ é o algoritmo de join mais eficiente do Spark, mas só pode ser usado em
situações específicas. Ele requer que um dos DataFrames seja pequeno o suficiente para
caber na memória do driver e de cada um dos executores, pois a estratégia consiste em
enviar esses dados completos para cada um deles, de forma que só há necessidade de
realizar o shuffle de envio desses dados. O Spark costuma utilizar esse join
automaticamente com base em algumas configurações, como o
spark.sql.autoBroadcastJoinThreshold, que define o tamanho máximo
do menor DataFrame para que esse método seja escolhido, mas é sempre interessante
analisar cada situação e ter autonomia para indicar o seu uso.

Já o SMJ é o algoritmo padrão do Spark, uma vez que o tamanho dos


DataFrames não impacta na viabilidade do algoritmo. Nesse caso, os dados são enviados
entre os executores via shuffle e os posteriormente ordenados, para que os dados
estejam particionados corretamente e na mesma ordem. Por realizar diversos shuffles,
esse algoritmo é bastante custoso, o que pode ser mitigado com o uso de bucketização
dos dados.

135
Finalmente, o SHJ é um algoritmo que também usa shuffles, mas compensa
essa operação com o uso de um mapa de hash que exime a necessidade de ordenação
dos dados. A única condição é que um dos DataFrames seja significativamente menor
do que o outro, mas não tanto quanto o BHJ.

Utilizando a API, é possível indicar o uso dessas estratégias por meio do método
hint(), ou a função broadcast() para o caso do BHJ. Alguns exemplos:

Figura 97 – Especificando a Estratégia de Join.

136
Capítulo 7. Deploy de Aplicações do Spark

Introdução e modos de execução

No Capítulo 2, foi apresentado o modo de execução de uma aplicação do Spark,


em termos de quais são os principais componentes, como o Spark divide as tarefas
recebidas e como acessar as funcionalidades da aplicação por meio da SparkSession. A
seguir, serão introduzidos os modos de execução disponíveis, que dizem respeito a
como o Spark implementa fisicamente a arquitetura vista anteriormente. Os três modos
disponíveis são:

• Cluster mode.
• Client mode.
• Local mode.

Cluster Mode

O deploy em clusters é provavelmente a forma mais comum de executar


aplicações do Spark em produção. Nessa situação, o gerenciador do cluster
(normalmente, uma máquina específica dentro do cluster chamada de master) recebe
o código Spark do usuário e instancia processos dentro de cada um dos nós de workers
(máquinas que de fato realizam a execução), de forma a gerar o driver e os executores
necessários para a arquitetura do Spark. Isso significa que o gerenciador do cluster é
responsável por manter os processos da aplicação em contato direto com o driver do
Spark para lidar com requisições de recursos e outras situações do tipo.

O Spark consegue interagir diretamente com três gerenciadores de clusters: um


gerenciador nativo, YARN e Apache Mesos. Cada uma desses dispositivos dispõe de
diferentes opções configuráveis dentro do Spark e qual deve ser usado irá depender do
contexto e dos recursos disponíveis ao usuário.

137
Client Mode

Esse modo é quase idêntico ao deploy em clusters, com a diferença de que o


driver do Spark fica na máquina de onde foi enviada a aplicação, ou seja, o gerenciador
do cluster é responsável por manter o processo nos executores, enquanto a máquina do
cliente mantém o driver. Isso significa que a aplicação Spark é enviada e mantida a partir
de uma máquina que não faz parte do cluster e as máquinas que de fato fazem são as
responsáveis por executar o código.

Essas máquinas usadas para enviar a aplicação a partir do cliente são


comumente chamadas de gateway machines ou edge nodes.

Local Mode

Bastante diferente dos modos anteriores, no deploy local a aplicação do Spark


é executada integralmente em uma única máquina e nesse caso o paralelismo está nos
threads de uma única máquina. Esse é o modo recomendado para aprender,
experimentar e desenvolver com a ferramenta, uma vez que é simples, rápida e não
implica em custos com clusters de computadores ou configurações específicas. No
entanto, não é a maneira mais eficiente de se utilizar o Spark, e não é recomendado
para o uso em produção.

Desenvolvendo aplicações Spark

Aplicações do Spark são a combinação do driver e executores e o código a ser


executado. Nas seções passadas, foi apresentado vários aspectos desses componentes
da arquitetura e por isso agora serão dadas algumas instruções e melhores práticas com
respeito ao desenvolvimento do código e o envio da aplicação. Será considerada uma
aplicação na linguagem Python, mas é possível desenvolver também nas demais
linguagens disponíveis.

138
A princípio, desenvolver uma aplicação do Spark não é muito diferente de
desenvolver outros programas do Python. A aplicação é composta por vários scripts .py,
sendo que um dele é executado como o principal, dependente ou não dos outros. Nesse
arquivo principal deve ser instanciada a SparkSession e é uma boa prática repassar
esse objeto inicial através da aplicação ao invés de instanciá-la mais de uma vez. Abaixo,
um exemplo desse arquivo, que utiliza os principais conceitos de classes e orientação ao
objeto:

Figura 98 – Exemplo de Aplicação Spark no Python.

139
Para enviar a aplicação para execução deve ser utilizado o comando spark-
submit. Com esse comando é possível indicar a forma de execução, configurar o Spark
(conforme visto no capítulo anterior) e passar o código Spark. O comando deve ser
chamado em um terminal de linha de comando, e tem a seguinte forma:

Figura 99 – Padrão de spark-submit.

Utilizando o Python, é necessário passar o script principal a ser executado, bem


como os demais scripts dos quais a aplicação depende. Os scripts são passados por meio
do argumento --py-files, sendo que para dois ou mais scripts os arquivos devem
ser comprimidos em um .egg ou .zip. Supondo que o código anterior seja salvo em um
arquivo chamado “main.py”, o lançamento da aplicação poderia ser feito por:

Figura 100 – Lançamento de uma Aplicação Spark em Python.

140
Obs.: ao especificar o deploy local no argumento --master, o número
colocado dentro do colchete indica o número de núcleos sendo usados na aplicação.
Colocar “*” indica que devem ser usados todos os núcleos disponíveis.

Executando o Spark em Clusters

Ao executar o Spark em clusters, é interessante entender quais as vantagens e


desvantagens de se utilizar cada um dos gerenciadores de cluster disponíveis no Spark.
Como dito anteriormente, esses gerenciadores são:

• Standalone.

• YARN.

• Apache Mesos.

Abaixo será apresentado a forma de inicialização de um cluster standalone do


Spark, enquanto para os outros gerenciadores fica a cargo do leitor pesquisar mais
informações sobre.

O gerenciador standalone é um dispositivo leve desenvolvido especialmente


para rodar aplicações do Spark. Com ele, é possível executar diversas aplicações do
Spark simultaneamente no mesmo cluster. A desvantagem é que é possível executar
exclusivamente aplicações do Spark, e por isso é talvez a forma mais direta e simples de
se fazer o deploy em clusters quando não há conhecimento sobre o YARN ou Apache
Mesos.

Para inicializar um cluster standalone do Spark, existe uma maneira manual e


uma maneira automatizada. Na maneira manual, é preciso garantir que existam
máquinas disponíveis e que elas possam se comunicar por meio de uma rede. Para
inicializar o master, basta executar o seguinte script:

141
$SPARK_HOME/sbin/start-master.sh

Depois disso, será disponibilizado uma URI do tipo spark://HOST:PORT que


deve ser utilizada para inicializar os executores e para especificar o argumento --
master na chamada ao spark-submit. Quanto aos workers, basta executar o arquivo
abaixo em cada um dos demais computadores:

$SPARK_HOME/sbin/start-slave.sh <master-spark-URI>

A partir desse momento, o cluster está pronto para ser executado. A alternativa
para esse processo, que é bastante manual, é utilizar um arquivo de configuração que
pode inicializar o cluster por meio de scripts. O arquivo criado deve ser conf/slaves,
presente no diretório do Spark e ele deve conter os hostnames de todas as máquinas
em que é desejado inicializar workers do Spark, um por linha. Quando o cluster for
inicializado de fato, o master irá acessar cada uma dessas máquinas via SSH e requer
que o acesso seja feito por meio de uma chave (private key).

Depois de criar esse arquivo, é possível iniciar ou parar o cluster usando os shell
scripts presentes no diretório $SPARK_HOME/sbin:

• start-master.sh: inicia uma instância do master do cluster na máquina em


que o script é executado.

• start-slaves.sh: inicia uma instância em cada máquina especificada no


arquivo conf/slaves.

• start-slave.sh: inicia uma instância de worker do cluster na máquina em que


o script é executado.

• start-all.sh: inicia tanto o master quanto os workers da forma como


apresentado anteriormente.

142
• stop-master.sh: para a máquina master iniciada pelo script start-master.sh;

• stop-slaves.sh: para todos os workers especificados no arquivo conf/slaves.

• stop-all.sh: para tanto o master quanto os workers da forma como


apresentado anteriormente.

De forma geral, para executar uma aplicação com o deploy em clusters é


necessário passar ao spark-submit o URI do master do cluster:

Figura 101 – Exemplo de um Deploy em Cluster Standalone.

Exemplo Prático: lançando uma aplicação localmente

Para finalizar esse capítulo, será demonstrado um exemplo prático de aplicação


Spark na linguagem Python, utilizando o deploy local e ajustando configurações via
spark-submit. A prática será a seguinte: serão utilizados os dados de títulos filmes,
séries, documentários e outras mídias digitais disponibilizados pelo IMDB
(https://datasets.imdbws.com/), mais especificamente as tabelas title_basics, que traz
informações básicas sobre o título como nome, gênero, duração e ano de lançamento e
o title_ratings, que traz informação sobre as notas médias de avaliações de usuários do
site. Abaixo seguem algumas linhas de ambas as tabelas:

143
Figura 102 – Title Basics.

Figura 103 – Title Ratings.

A aplicação desenvolvida irá processar os dados baixados diretamente do site


acima e realizar as seguintes operações:

• Leitura dos dados.

• Conversão de tipos.

• Ajuste de dados nulos.

• Junção das tabelas, mantendo somente filmes avaliados.

• Escrita no formato parquet.

O código ficou concentrado em somente um script principal a ser executado,


seguindo as boas práticas apresentadas nas seções anteriores. O script de limpeza pode

144
ser encontradono repositório apresentado no primeiro capítulo. O script principal a ser
executado segue abaixo:

Figura 104 – Arquivo principal da Aplicação de Exemplo.

Observe a chamada ao método stop() para terminar a SparkSession e


consequentemente a aplicação. Para executar essa aplicação, é necessário comprimir a
pasta em que os códigos estão salvos, chamada nesse caso de src, em um arquivo .zip.
Considerando que a estrutura do repositório é:

Figura 105 – Estrutura do Diretório da aplicação.

Foi executado o seguinte comando no terminal, estando no diretório raiz (root):

145
Figura 106 – Chamada ao spark-submit.

O resultado é exibido abaixo:

Figura 107 – Resultado da Aplicação.

Também é possível conferir que o DataFrame foi salvo como esperado:

146
Figura 108 – DataFrame resultante da aplicação.

Novamente, todo o código está disponível no repositório apresentado no


primeiro capítulo e os dados podem ser baixados gratuitamente no site
https://datasets.imdbws.com/.

147
Capítulo 8. Spark na Nuvem

No capítulo a seguir, será apresentada uma forma de executar o Spark no


ambiente cloud utilizando a plataforma do Google Cloud Products (GCP). Será dada uma
introdução ao Google Cloud Storage (GCS) e ao Dataproc, que são as ferramentas
necessárias para o entendimento do processo e o desenvolvimento básico dentro do
ambiente da nuvem. Então, será demonstrado como criar clusters no Dataproc para
desenvolver as aplicações Spark utilizando Jupyter Notebooks, como submeter uma
aplicação a esses clusters para que seja executada e como acessar os dados do GCS para
consumo.

Introdução ao Dataproc e GCS

O Dataproc é um serviço de clusters que utiliza ambientes Spark e Hadoop para


realizar processamento de dados em lote, consultas interativas, processamento em
streaming e machine learning. A ferramenta facilita a criação e gerenciamento de
clusters, bem como é capaz de controlá-los e até mesmo desativá-los quando não são
mais necessários. Extraídas diretamente da documentação, as principais vantagens do
Dataproc são:

• Baixo custo: o preço do Dataproc é bastante baixo, sendo de apenas um


centavo por hora por CPU virtual no cluster, além dos outros recursos do
GCP sendo utilizados. Em relação às outras clouds, ao invés de arredondar o
uso para a hora mais próxima, o Dataproc cobra apenas o que você
realmente usa, com um faturamento por minuto e um período mínimo de
um minuto.

• Super-rápido: os clusters do Dataproc dispõem de uma inicialização rápida,


levando em média 90 segundos, um valor significativamente menor que os
cinco a 30 minutos de outros serviços similares.

148
• Integrado: o Dataproc tem integração direta com outros serviços do Google
Cloud Platform, como BigQuery, Cloud Storage, Cloud Bigtable, Cloud Logging e
Cloud Monitoring.

• Gerenciado: é possível utilizar os clusters do Spark e Hadoop sem a


necessidade de um administrador ou de um software especial. Além disso,
é possível interagir facilmente com esses clusters e jobs por meio do Console
do Google Cloud, do SDK do Cloud ou da API REST do Dataproc.

• Simples e familiar: não é necessário aprender novas ferramentas ou APIs


para usar o Dataproc, o que facilita mover os projetos existentes para o
Dataproc sem necessidade de refatoração.

Em relação ao seu uso com o Spark, podemos utilizar o Dataproc tanto como a
ferramenta responsável por processar os jobs enviados, bem como a infraestrutura por
trás do desenvolvimento exploratório feito por meio de Jupyter Notebooks.

O Google Cloud Storage (GCS) é o serviço de armazenamento de dados do GCP,


utilizado para armazenar objetos, que são arquivos imutáveis de qualquer formato.
Esses objetos são organizados em contêineres chamados de buckets, que podem
posteriormente ser subdivididos em uma ou mais pastas. Todos os buckets devem ser
organizados em um projeto, que é o componente necessário para a utilização de
qualquer serviço dentro da plataforma de nuvem. Abaixo alguns recursos interessantes
do GCS para a proteção dos dados, novamente extraídos direto da documentação:

• Gerenciamento de identidade e acesso: é possível usar o Identity and


Access Management (IAM) para controlar quem tem acesso aos recursos do
projeto no Google Cloud. Esses recursos incluem buckets e objetos no Cloud
Storage, bem como outras entidades no Google Cloud, como instâncias do
Compute Engine;

149
• Criptografia de dados: por padrão, o Cloud Storage usa a criptografia no
servidor para criptografar seus dados. Também é possível usar opções
complementares de criptografia de dados, como chaves de criptografia
gerenciadas pelo cliente e chaves de criptografia fornecidas pelo cliente;

• Autenticação: é possível verificar se todas as pessoas que acessam os dados


têm as credenciais apropriadas;

• Bloqueio de buckets: é possível determinar por quanto tempo os objetos


nos buckets precisam permanecer retidos especificando uma política de
retenção;

• Controle de versão de objeto: quando a versão ativa de um objeto é


substituída ou excluída, ela pode ser retida como uma versão não atual se
você ativar o controle de versão de objeto.

Assim, o GCS é a principal ferramenta de armazenamento do GCP, e serve como


a fonte de dados para todos os serviços na plataforma.

Criando uma conta no GCP

A seguir, os passos para a criação de uma conta no Google Cloud para a


utilização dos serviços disponíveis. Nos próximos capítulos serão apresentadas as
formas de utilizar o Dataproc e Google Storage e para isso é necessário preparar uma
conta de serviços e criar um projeto dentro da plataforma.

Importante: o uso dos serviços introduzidos implica em uma cobrança. No


entanto, ao criar a conta o Google disponibiliza U$ 300,00 nos primeiros 90 dias para
serem gastos com as ferramentas. Assim, é recomendado estar sempre verificando os
gastos da conta para que não haja cobranças inesperadas.

150
1. Criar uma conta do Google aqui.

2. A partir desse momento, entrar no site e clicar no botão “Comece a usar


gratuitamente”:

Figura 109 – Criando uma conta.

3. Depois disso, preencher as informações necessárias para habilitar o uso de


serviços do GCP:

Figura 110 – Preenchendo informações para criação de conta.

151
Lembrando que é necessário cadastrar um perfil para pagamentos. Assim,
utilize um cartão de crédito com a possibilidade de pagamentos internacionais ou
procure outra forma de pagamento disponibilizada pelo Google. Apesar de habilitar
pagamentos, a ideia é utilizar o limite gratuito disponibilizado.

4. Depois de criar a conta, clique na aba projetos, crie um novo projeto e selecione-
o:

Figura 111 – Criando um novo projeto.

152
Depois de criar o projeto, é necessário configurar a conta de serviço. Para isso:

5. Vá para a aba de contas de serviço:

Figura 112 – Configurando conta de serviço.

153
6. Crie uma conta de serviço:

Figura 113 – Criando conta de serviço.

7. Selecione o papel de proprietário e clique em concluir:

Figura 114 – Selecionando papel de proprietário.

154
Obs.: esse papel só está sendo utilizado para fins de demonstração, uma vez
que não é muito seguro utilizar para desenvolvimento um papel que dá acesso a todos
os componentes.

8. Depois disso, gere as chaves da conta de serviços recém-criada:

Figura 115 – Gerando chaves da conta.

155
Nesse momento, será baixado um arquivo json com as credenciais de acesso
aos serviços do GCP. Guarde essas chaves com muito cuidado e atenção, pois são elas
que dão o acesso ao projeto criado na plataforma. Essas chaves também serão utilizadas
para conectar diretamente no GCS a partir do Spark.

Criando um Cluster do Dataproc

Existem duas formas de criar um cluster no Dataproc: via console do GCP,


acessado pelo site da plataforma e pela linha de comando, utilizando o gcloud. Nessa
seção, serão abordadas ambas as formas, mas no caso do gcloud será introduzida
somente a versão acessada também pelo site. Vale fazer essa ressalva porque existe
uma maneira de acessar os serviços do Google Cloud Platform na linha de comando de
máquinas locais, mas requer a instalação e configuração do SDK do Cloud.

Além disso, será mostrado como criar clusters com a possibilidade de uso dos
componentes web, como Jupyter Notebook, Jupyter Lab, Zeppelin, Anaconda etc., uma
vez que são ferramentas particularmente úteis para desenvolvimento e exploração
usando o Spark.

Utilizando o console do GCP

Após criar a conta e habilitar os serviços do GCP por meio da conta de serviços,
é necessário acessar o Dataproc a partir da aba lateral do console. Desse momento em
diante, basta seguir os passos abaixo:

1) Caso seja o primeiro acesso, é necessário ativar o serviço do Dataproc clicando


no botão ativar da tela abaixo:

156
Figura 116 – Ativando Dataproc.

2) Em seguida, será aberta a janela padrão do Dataproc. Para criar um cluster,


deve-se clicar no botão “criar cluster”:

Figura 117 – Criando cluster

3) Clicando no botão, será visualizada a aba de criação do cluster, onde é possível


realizar todas as configurações das máquinas. No tópico “Configurar cluster”,
aberto inicialmente, é necessário mudar o nome do cluster (opcional), a
região e o local, o tipo de cluster, a possibilidade de acessar componentes e
quais desses componentes devem estar disponíveis. Recomendo seguir as

157
configurações abaixo, especialmente o tipo single node, para não gerar gastos
excessivos nesse momento, e o componente Jupyter Notebook:

118 – Configuração do cluster.

158
4) Na aba de configurar nós, é possível configurar as máquinas do cluster. Nesse
caso, será feita a configuração apenas do nó mestre, já que o cluster criado é
single node:

Figura 119 – Configurando nós.

5) Na área de personalizar o cluster, é possível passar configurações extras


usadas na criação do cluster. Destaque para o campo “Propriedades do
cluster”, que permite passar configurações do Spark já na criação do cluster,
e os campos “Ações de inicialização” e “Metadados de cluster
personalizados”, que podem ser utilizados para instalar componentes extras

159
na criação dos cluster, como bibliotecas do Python. Caso não haja nenhuma
configuração, basta clicar em “criar cluster”:

120 – Configurações extras.

6) O cluster levará cerca de 90seg para ser inicializado. Uma vez criado, o cluster
está disponível para uso. Para acessar os componentes habilitados, como o
Jupyter, basta clicar no nome do cluster e posteriormente em “Interfaces da
Web”. Uma vez nessa aba, basta clicar no nome do componente que se deseja
acessar:

160
Figura 121 – Acessando componentes.

7) Será aberta uma tela com duas pastas “Local Disk” e “GCS”. É recomendado
utilizar a pasta “GCS”, pois desta forma os notebooks serão salvos no Storage.
Uma vez dentro da pasta, é possível criar notebooks de diferentes kernels,
entre eles, pyspark:

161
Figura 122 – Acessando pasta.

8) Ao criar um notebook do pyspark, o Dataproc automaticamente instancia


uma SparkSession para ser usada durante o desenvolvimento:

Figura 123 – instância do Dataproc.

Obs.: as configurações da SparkSession podem ser alteradas na opção


“Propriedades do cluster” durante as configurações do clusters, uma vez que existe uma
propriedade chamada “spark” que é capaz de alterar o arquivo spark-defaults.conf.

Por fim, vale destacar que criar os notebooks na pasta “GCS” implica no
salvamento desses arquivos no bucket especificado pelo caminho abaixo:

162
Figura 124 – Caminho de salvamento.

Ao terminar de usar o cluster, basta selecioná-lo e clicar em excluir.

Utilizando a linha de comando do GCP

Enquanto a criação de cluster pelo console proporciona uma experiência mais


fácil para a maior parte dos usuários, ela não é pode ser também bastante incômoda
para aqueles que devem repetir esse mesmo processo diversas vezes. Por isso, o Google
disponibiliza uma forma de acessar as funcionalidades do Dataproc (e de todos os
serviços do GCP) a partir da linha de comando.

Para isso, primeiro é preciso ativar a linha de comando na interface web do


Google Cloud Plataform:

Figura 125 – Ativando linha de comando.

163
Ao clicar nesse botão, será aberto o Google Cloud Shell, que disponibiliza a linha
de comando. Nesse terminal, será utilizado o gcloud, o SDK do Google para utilização
dos serviços cloud. O comando padrão para criar clusters no Dataproc é visto abaixo:

gcloud dataproc clusters create ${CLUSTER} \


--project=${PROJECT} \
--region=${REGION}

O que está na forma ${} são variáveis dependentes do projeto que devem ser
preenchidas pelo usuário. Também é possível passar diversas opções para replicar a
configuração feita por meio do console, como foi feito no caso a seguir:

gcloud dataproc clusters create cluster-edc-


bootcamp
--project edc-bootcamp
--region us-central1 --zone us-central1-c
--single-node --master-machine-type n1-standard-4
--master-boot-disk-size 500
--image-version 2.0-debian10
--optional-components JUPYTER
--enable-component-gateway

Observe que cada um dos argumentos opcionais especificados acima se refere


a um dos parâmetros preenchidos na criação do cluster pelo console. É recomendado
utilizar sempre que possível esse comando, uma vez que reduz a chance de erros de
preenchimento por parte do usuário.

164
Acessando dados no GCS

Depois de demonstrar como é feita a criação de clusters para processar os


dados, é preciso ensinar como criar buckets, fazer o upload de dados e acessá-los dentro
da plataforma. Para isso, vamos dividir essa seção em duas: como criar buckets e como
fazer o acesso aos dados com o Spark.

Como criar buckets no GCS

O primeiro passo é acessar a interface do Storage no GCP. A partir desse


momento, basta seguir os passos abaixo:

1) Clique em “Criar intervalo”:

2) Escolha um nome para o bucket:

165
3) Escolha onde armazenar os dados. Nesse caso, deve ser utilizado a
mesma região que tem sido usada nas práticas com o Dataproc:

Depois disso, deve-se continuar com as configurações padrão nas outras etapas
e clicar em “Criar bucket”.

Com o bucket criado, é preciso subir dados para que sejam consultados. Para
fazer o upload de dados, siga os passos:

166
1) (Opcional) Crie uma pasta dentro do bucket para organizar os arquivos:

2) Para fazer o upload, deve-se usar ou a opção de “carregar uma pasta” ou


“fazer upload de arquivos”:

3) Depois de selecionada a opção desejada, será aberta uma janela do


explorador de arquivos do computador local para que os dados de upload
sejam selecionados. Depois de escolher os arquivos, o upload será realizado:

167
Assim, já é possível que os dados sejam acessados do GCS a partir de outros
dispositivos, como o Dataproc, ou então a partir do Spark executado localmente, como
será visto adiante.

Obs.: o objetivo desse guia não era ensinar a melhor forma ou as melhores
práticas de criação de buckests no GCP, mas simplesmente mostrar uma forma de
disponibilizar os dados para que sejam acessados da cloud com o Spark. É recomendado
procurar um material especializado para entender a melhor forma de usar essa
ferramenta.

Acessando os dados com o Spark

Como já dito, existem mais de uma forma de acessar os dados no Google


Storage: utilizando o Dataproc em uma conexão direta com o bucket ou a partir do Spark
executado localmente, mas utilizando a chave de acesso do Google.

Com o Dataproc, o acesso é feito de forma bastante simples. Basta criar o


cluster na mesma região em que o bucket foi criado e utilizar a seguinte sintaxe para
especificar o caminho de leitura no DataFrameReader do Spark:

gs://<nome_do_bucket>/<caminho_do_arquivo>

168
Dessa forma, para acessar os dados disponibilizados no GCS na seção anterior,
seria algo assim:

Para acessar de uma máquina local, além de usar o caminho no mesmo formato
do Dataproc, é preciso passar configurações extras para a SparkSession . Primeiro
é preciso entrar nesse site e baixar um conector do Google para a respectiva versão do
Hadoop do Spark instalado:

169
É preciso colocar esse conector em na pasta SPARK_HOME/jars:

Enfim, basta utilizar as seguintes configurações na criação da


SparkSession:

.config("fs.gs.impl",
"com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystem")
.config("fs.AbstractFileSystem.gs.impl",
"com.google.cloud.hadoop.fs.gcs.GoogleHadoopFS")
.config("fs.gs.auth.service.account.enable", "true")
.config("fs.gs.auth.service.account.json.keyfile",
"path/to/keyfile")

170
Em que o “keyfile” é o arquivo .json que contém as chaves geradas durante o
processo de criação da conta de serviço do Google Cloud Platform. Agora, é possível
acessar os dados presentes em um bucket do GCS a partir de uma máquina local:

Submetendo aplicações no GCP

Por fim, para submeter uma aplicação do Spark usando o GCP, é necessário
instanciar um cluster do Dataproc e utilizar o Cloud Shell para enviar um job. Além disso,
é necessário fazer o upload dos scripts para um bucket do Storage. No exemplo a seguir
será utilizado os mesmos scripts do exemplo prático do capítulo anterior, alterando
somente os diretórios dos dados de forma a adequá-los a execução na nuvem. Os
diretórios devem estar especificados em termos dos buckets, seguindo a forma do
capítulo anterior para execução em notebooks do Dataproc. Por causa disso, o código
foi alterado, é necessário agora passar como argumento o modo de execução “local” ou
“cloud”. A versão mais recente está disponível no repositório apresentado no primeiro
capítulo.

171
Os passos para executar a aplicação Spark seguem abaixo:

1) Verificar se os dados estão presentes no bucket:

2) Fazer o upload dos scripts para um bucket, o mesmo dos dados ou não:

172
Obs.: observe que os scripts são os mesmos utilizados no exemplo do capítulo
7, coma adição de um arquivo __init__.py ao diretório.

3) Criar o cluster do Dataproc:

4) É nesse momento que é executado o job do Spark. A forma geral do


comando para executar jobs do PySpark no Dataproc, retirado da própria
documentação, é:

Assim, considerando o exemplo atual, o comando seria:

gcloud dataproc jobs submit pyspark


--region us-central1
--cluster cluster-edc-bootcamp
--py-files gs://edc_bootamp_data/scripts/src.zip
gs://edc_bootamp_data/scripts/spark_app.py
-- cloud

5) Ao final da execução, podemos ver uma mensagem de sucesso no


terminal:

173
E que o DataFrame foi salvo com sucesso:

Ao final da execução do job, é importante lembrar de derrubar o cluster do


Dataproc, para não gerar cobranças indevidas.

174
Encerrando o projeto no GCP

Além de finalizar o cluster do Dataproc, ao final dessa prática é importante


também finalizar o projeto no GCP para que não seja gasto todo o limite gratuito ou pata
não gerar cobranças inesperadas e indesejadas. Para isso, basta:

1) Ir para a página “Painel”:

2) Após isto, clicar em “configurações do projeto”:

175
3) Por fim, clicar em “encerrar projeto”:

176
Capítulo 9. Conclusão

Espero que essa apostila tenha sido um bom guia prático para entender o
contexto de Big Data e suas ferramentas de processamento de dados massivos, em
especial o Apache Spark – talvez a mais importante no mercado atualmente. A área de
Big Data tem se expandido a cada ano e por isso, mais do que ensinar uma ferramenta,
espero que eu tenha passado a motivação do estudo desse campo e as principais ideias
por trás do desenvolvimento de soluções para esse desafio tão complexo e cada vez
mais comum de processar dados massivos e variados.

177
Referências

APACHE Hadoop Yarn Overview. Cloudera, c2020-2021. Disponível em:


<https://docs.cloudera.com/runtime/7.2.7/yarn-overview/topics/yarn-introduction-
yarn.html>. Acesso em: 07 abr. 2022.

BOYD, D.; CRAWFORD, K. Critical Questions for Big Data. Information, Communication
& Society, p. 662-679, 2012.

DAMJI, J.S. et al. Learning Spark: Lightning-Fast Data Analytics. 2. ed. O'Reilly Media, Inc.
2020.

GARTNER GROUP. Information Technology Gartner Glossary, 2012. Disponível em:


<https://www.gartner.com/en/information-technology/glossary/big-data>. Acesso em:
07 abr. 2022.

HADOOP HDFS Archicture Explanation and Assumptions. Data Flair, c2021. Disponível
em: <https://data-flair.training/blogs/hadoop-hdfs-architecture/>. Acesso em: 07 abr.
2022.

HAKKERT, R. Fontes de Dados Demográficos. Belo Horizonte, 1996.

MANYKA, J.; et al. Big data: The next frontier for innovation, competition and
productivity, 2011. Disponível em:
<https://www.mckinsey.com/~/media/McKinsey/Business%20Functions/McKinsey%2
0Digital/Our%20Insights/Big%20data%20The%20next%20frontier%20for%20innovatio
n/MGI_big_data_exec_summary.pdf>. Acesso em: 07 abr. 2022.

O QUE é o Cloud Storage?. Google Cloud, 01 out. 2021. Disponível em:


<https://cloud.google.com/storage/docs/introduction>. Acesso em: 07 abr. 2022.

178
O QUE é o Dataproc?. Google Cloud, 02 set. 2021. Disponível em:
<https://cloud.google.com/dataproc/docs/concepts/overview?hl=pt_br>. Acesso em:
07 abr. 2022.

SINHA, Shubham. Hadoop Ecosystem: Hadoop Tools for Crunching Big Data. Edureka!,
06 out. 2021. Disponível em: <https://www.edureka.co/blog/hadoop-ecosystem>.
Acesso em: 07 abr. 2022.

SINHA, Shubham. What is Hadoop? Introduction to Big Data & Hadoop. Edureka!, 06
out. 2021. Disponível em: <https://www.edureka.co/blog/what-is-hadoop/>. Acesso
em: 07 abr. 2022.

WHITE, T. Hadoop: The Definitive Guide. 3. ed. O'Reilly Media, Inc. 2012.

ZAHARIA, M.; CHAMBERS, B.. Spark: The Definitive Guide. 1. ed. O'Reilly Media, Inc.
2018.

179

Você também pode gostar