Publicado: 22 de julho de 2020
por Maxim Gekk, Wenchen Fan e Hyukjin Kwon
Apache Spark é uma ferramenta muito popular para processar dados estruturados e não estruturados. Quando se trata de processar dados estruturados, ele suporta muitos tipos de dados básicos, como inteiro, longo, duplo, string, etc. O Spark também suporta tipos de dados mais complexos, como Date e Timestamp, que muitas vezes são difíceis para os desenvolvedores entenderem. Neste post do blog, vamos mergulhar fundo nos tipos Date e Timestamp para ajudá-lo a entender completamente seu comportamento e como evitar alguns problemas comuns. Em resumo, este blog cobre quatro partes:
A definição de um Date é muito simples: é uma combinação dos campos ano, mês e dia, como (ano=2012, mês=12, dia=31). No entanto, os valores de ano, mês e dia têm restrições, de modo que o valor da data seja um dia válido no mundo real. Por exemplo, o valor do mês deve ser de 1 a 12, o valor do dia deve ser de 1 a 28/29/30/31 (dependendo do ano e do mês), e assim por diante.
Essas restrições são definidas por um de muitos calendários possíveis. Alguns deles são usados apenas em regiões específicas, como o calendário lunar. Alguns deles são usados apenas na história, como o calendário juliano. Neste ponto, o calendário gregoriano é o padrão internacional de fato e é usado em quase todo o mundo para fins civis. Ele foi introduzido em 1582 e também é estendido para suportar datas anteriores a 1582. Este calendário estendido é chamado de calendário gregoriano proléptico.
A partir da versão 3.0, o Spark usa o calendário gregoriano proléptico, que já está sendo usado por outros sistemas de dados como pandas, R e Apache Arrow. Antes do Spark 3.0, ele usava uma combinação dos calendários juliano e gregoriano: para datas anteriores a 1582, o calendário juliano era usado; para datas posteriores a 1582, o calendário gregoriano era usado. Isso é herdado da API legada java.sql.Date, que foi substituída no Java 8 por java.time.LocalDate, que também usa o calendário gregoriano proléptico.
Notavelmente, o tipo Date não considera fusos horários.
O tipo Timestamp estende o tipo Date com novos campos: hora, minuto, segundo (que pode ter uma parte fracionária) e, juntamente com um fuso horário global (com escopo de sessão). Ele define um instante de tempo concreto na Terra. Por exemplo, (ano=2012, mês=12, dia=31, hora=23, minuto=59, segundo=59.123456) com fuso horário de sessão UTC+01:00. Ao gravar valores de timestamp em fontes de dados não textuais como Parquet, os valores são apenas instantes (como timestamp em UTC) que não possuem informações de fuso horário. Se você gravar e ler um valor de timestamp com um fuso horário de sessão diferente, poderá ver valores diferentes de hora/minuto/segundo, mas eles são, na verdade, o mesmo instante de tempo concreto.
Os campos de hora, minuto e segundo têm intervalos padrão: 0–23 para horas e 0–59 para minutos e segundos. O Spark suporta segundos fracionários com precisão de até microssegundos. O intervalo válido para frações é de 0 a 999.999 microssegundos.
Em qualquer instante concreto, podemos observar muitos valores diferentes de relógios de parede, dependendo do fuso horário.
E, inversamente, qualquer valor em relógios de parede pode representar muitos instantes de tempo diferentes. O deslocamento do fuso horário nos permite vincular inequivocamente um timestamp local a um instante de tempo. Geralmente, os deslocamentos de fuso horário são definidos como deslocamentos em horas do Greenwich Mean Time (GMT) ou UTC+0 (Tempo Universal Coordenado). Tal representação de informações de fuso horário elimina a ambiguidade, mas é inconveniente para os usuários finais. Os usuários preferem apontar um local ao redor do globo, como America/Los_Angeles ou Europe/Paris.
Esse nível adicional de abstração dos deslocamentos de zona facilita a vida, mas traz seus próprios problemas. Por exemplo, agora temos que manter um banco de dados especial de fuso horário para mapear nomes de fuso horário para deslocamentos. Como o Spark é executado na JVM, ele delega o mapeamento para a biblioteca padrão do Java, que carrega dados do Internet Assigned Numbers Authority Time Zone Database (IANA TZDB). Além disso, o mecanismo de mapeamento na biblioteca padrão do Java tem algumas nuances que influenciam o comportamento do Spark. Focaremos em algumas dessas nuances abaixo.
Desde o Java 8, o JDK expôs uma nova API para manipulação de data e hora e resolução de deslocamento de fuso horário, e o Spark migrou para essa nova API na versão 3.0. Embora o mapeamento de nomes de fuso horário para deslocamentos tenha a mesma origem, IANA TZDB, ele é implementado de forma diferente no Java 8 e superior em comparação com o Java 7.
Como exemplo, vamos dar uma olhada em um timestamp antes de 1883 no fuso horário America/Los_Angeles: 1883-11-10 00:00:00. Este ano se destaca dos outros porque em 18 de novembro de 1883, todas as ferrovias norte-americanas mudaram para um novo sistema de horário padrão que, a partir de então, regia seus horários.
Usando a API de tempo do Java 7, podemos obter o deslocamento do fuso horário no timestamp local como -08:00:
As funções da API Java 8 retornam um resultado diferente:
Antes de 18 de novembro de 1883, a hora do dia era um assunto local, e a maioria das cidades e vilas usava alguma forma de tempo solar local, mantido por um relógio conhecido (em uma torre de igreja, por exemplo, ou na vitrine de um joalheiro). É por isso que vemos um deslocamento de fuso horário tão estranho.
O exemplo demonstra que as funções do Java 8 são mais precisas e levam em consideração dados históricos do IANA TZDB. Após a mudança para a API de tempo do Java 8, o Spark 3.0 se beneficiou automaticamente da melhoria e se tornou mais preciso na resolução de deslocamentos de fuso horário.
Como mencionamos anteriormente, o Spark 3.0 também mudou para o calendário gregoriano proléptico para o tipo de data. O mesmo vale para o tipo timestamp. O padrão ISO SQL:2016 declara que o intervalo válido para timestamps é de 0001-01-01 00:00:00 a 9999-12-31 23:59:59.999999. O Spark 3.0 está totalmente em conformidade com o padrão e suporta todos os timestamps neste intervalo. Comparado com o Spark 2.4 e anteriores, devemos destacar os seguintes sub-intervalos:
0001-01-01 00:00:00..1582-10-03 23:59:59.999999. O Spark 2.4 usa o calendário juliano e não está em conformidade com o padrão. O Spark 3.0 corrige o problema e aplica o calendário gregoriano proléptico em operações internas de timestamp, como obter ano, mês, dia, etc. Devido a calendários diferentes, algumas datas que existem no Spark 2.4 não existem no Spark 3.0. Por exemplo, 1000-02-29 não é uma data válida porque 1000 não é um ano bissexto no calendário gregoriano. Além disso, o Spark 2.4 resolve nomes de fuso horário para deslocamentos de zona incorretamente para este intervalo de timestamp.1582-10-04 00:00:00..1582-10-14 23:59:59.999999. Este é um intervalo válido de timestamps locais no Spark 3.0, em contraste com o Spark 2.4, onde tais timestamps não existiam.1582-10-15 00:00:00..1899-12-31 23:59:59.999999. O Spark 3.0 resolve deslocamentos de fuso horário corretamente usando dados históricos do IANA TZDB. Comparado ao Spark 3.0, o Spark 2.4 pode resolver deslocamentos de zona a partir de nomes de fuso horário incorretamente em alguns casos, como mostramos acima no exemplo.1900-01-01 00:00:00..2036-12-31 23:59:59.999999. Tanto o Spark 3.0 quanto o Spark 2.4 estão em conformidade com o padrão ANSI SQL e usam o calendário gregoriano em operações de data e hora, como obter o dia do mês.2037-01-01 00:00:00..9999-12-31 23:59:59.999999. O Spark 2.4 pode resolver offsets de fuso horário e, em particular, offsets de horário de verão incorretamente devido ao bug #8073446 do JDK. O Spark 3.0 não sofre com esse defeito.Outro aspecto do mapeamento de nomes de fuso horário para offsets é a sobreposição de timestamps locais que pode ocorrer devido ao horário de verão (DST) ou à mudança para outro offset de fuso horário padrão. Por exemplo, em 3 de novembro de 2019, 02:00:00, os relógios foram atrasados em 1 hora para 01:00:00. O timestamp local
2019-11-03 01:30:00 America/Los_Angeles pode ser mapeado para 2019-11-03 01:30:00 UTC-08:00 ou 2019-11-03 01:30:00 UTC-07:00. Se você não especificar o offset e apenas definir o nome do fuso horário (por exemplo, '2019-11-03 01:30:00 America/Los_Angeles'), o Spark 3.0 usará o offset anterior, geralmente correspondente ao "verão". O comportamento diverge do Spark 2.4, que usa o offset de "inverno". No caso de uma lacuna, onde os relógios avançam, não há offset válido. Para uma mudança típica de horário de verão de uma hora, o Spark moverá tais timestamps para o próximo timestamp válido correspondente ao horário de "verão".Como podemos ver nos exemplos acima, o mapeamento de nomes de fuso horário para offsets é ambíguo e não é um para um. Nos casos em que for possível, recomendamos especificar offsets exatos de fuso horário ao criar timestamps, por exemplo, timestamp '2019-11-03 01:30:00 UTC-07:00'.
Vamos nos afastar do mapeamento de nome de zona para offset e olhar para o padrão ANSI SQL. Ele define dois tipos de timestamps:
TIMESTAMP WITHOUT TIME ZONE ou TIMESTAMP - Timestamp local como (ANO, MÊS, DIA, HORA, MINUTO, SEGUNDO). Esses tipos de timestamps não estão vinculados a nenhum fuso horário e, na verdade, são timestamps de relógio de parede.TIMESTAMP WITH TIME ZONE - Timestamp com fuso horário como (ANO, MÊS, DIA, HORA, MINUTO, SEGUNDO, HORA_FUSO_HORARIO, MINUTO_FUSO_HORARIO). Os timestamps representam um instante no fuso horário UTC + um offset de fuso horário (em horas e minutos) associado a cada valor.O offset de fuso horário de um TIMESTAMP WITH TIME ZONE não afeta o ponto físico no tempo que o timestamp representa, pois ele é totalmente representado pelo instante de tempo UTC fornecido pelos outros componentes do timestamp. Em vez disso, o offset de fuso horário afeta apenas o comportamento padrão de um valor de timestamp para exibição, extração de componentes de data/hora (por exemplo, EXTRACT) e outras operações que exigem o conhecimento de um fuso horário, como adicionar meses a um timestamp.
O Spark SQL define o tipo de timestamp como TIMESTAMP WITH SESSION TIME ZONE, que é uma combinação dos campos (ANO, MÊS, DIA, HORA, MINUTO, SEGUNDO, TZ DA SESSÃO), onde os campos ANO a SEGUNDO identificam um instante de tempo no fuso horário UTC, e onde TZ DA SESSÃO é obtido da configuração SQL spark.sql.session.timeZone. O fuso horário da sessão pode ser definido como:
'(+|-)HH:mm'. Esta forma nos permite definir um ponto físico no tempo de forma inequívoca.'area/city', como 'America/Los_Angeles'. Esta forma de informação de fuso horário sofre com alguns dos problemas que descrevemos acima, como a sobreposição de timestamps locais. No entanto, cada instante de tempo UTC está associado de forma inequívoca a um offset de fuso horário para qualquer ID de região e, como resultado, cada timestamp com um fuso horário baseado em ID de região pode ser convertido de forma inequívoca para um timestamp com um offset de zona.Por padrão, o fuso horário da sessão é definido como o fuso horário padrão da máquina virtual Java.
O TIMESTAMP WITH SESSION TIME ZONE do Spark é diferente de:
TIMESTAMP WITHOUT TIME ZONE, porque um valor desse tipo pode mapear para múltiplos instantes de tempo físicos, mas qualquer valor de TIMESTAMP WITH SESSION TIME ZONE é um instante de tempo físico concreto. O tipo SQL pode ser emulado usando um offset de fuso horário fixo em todas as sessões, por exemplo, UTC+0. Nesse caso, poderíamos considerar timestamps em UTC como timestamps locais.TIMESTAMP WITH TIME ZONE, porque de acordo com o padrão SQL, os valores de coluna desse tipo podem ter diferentes offsets de fuso horário. Isso não é suportado pelo Spark SQL.Devemos notar que timestamps associados a um fuso horário global (escopo de sessão) não são algo recém-inventado pelo Spark SQL. RDBMSs como Oracle fornecem um tipo semelhante para timestamps também: TIMESTAMP WITH LOCAL TIME ZONE.
O Spark SQL fornece alguns métodos para construir valores de data e timestamp:
CURRENT_TIMESTAMP() e CURRENT_DATE().INT, LONG e STRINGdatetime do Python ou classes Java java.time.LocalDate/Instant.A função MAKE_DATE introduzida no Spark 3.0 recebe três parâmetros: ANO, MÊS do ano e DIA do mês e cria um valor DATE. Todos os parâmetros de entrada são implicitamente convertidos para o tipo INT sempre que possível. A função verifica se as datas resultantes são datas válidas no calendário gregoriano proléptico, caso contrário, retorna NULL. Por exemplo, em PySpark:
Para imprimir o conteúdo do DataFrame, vamos chamar a ação show(), que converte datas em strings nos executores e transfere as strings para o driver para exibi-las no console:
Similarmente, podemos criar valores de timestamp através das funções MAKE_TIMESTAMP. Assim como MAKE_DATE, ela realiza a mesma validação para campos de data e, adicionalmente, aceita campos de hora HORA (0-23), MINUTO (0-59) e SEGUNDO (0-60). SEGUNDO tem o tipo Decimal(precisão = 8, escala = 6) porque os segundos podem ser passados com a parte fracionária de até precisão de microssegundo. Por exemplo, em PySpark:
Como fizemos para datas, vamos imprimir o conteúdo do DataFrame ts usando a ação show(). De forma semelhante, show() converte datas em strings, mas agora leva em consideração o fuso horário da sessão definido pela configuração SQL spark.sql.session.timeZone. Veremos isso nos exemplos a seguir.
O Spark não pode criar o último timestamp porque esta data não é válida: 2019 não é um ano bissexto.
Você pode notar que não fornecemos nenhuma informação de fuso horário no exemplo acima. Nesse caso, o Spark pega um fuso horário da configuração SQL spark.sql.session.timeZone e o aplica às invocações de função. Você também pode escolher um fuso horário diferente passando-o como o último parâmetro de MAKE_TIMESTAMP. Aqui está um exemplo em PySpark:
Como o exemplo demonstra, o Spark leva em conta os fusos horários especificados, mas ajusta todos os carimbos de data/hora locais para o fuso horário da sessão. Os fusos horários originais passados para a função MAKE_TIMESTAMP serão perdidos porque o tipo TIMESTAMP WITH SESSION TIME ZONE assume que todos os valores pertencem a um fuso horário e nem sequer armazena um fuso horário para cada valor. De acordo com a definição de TIMESTAMP WITH SESSION TIME ZONE, o Spark armazena carimbos de data/hora locais no fuso horário UTC e usa o fuso horário da sessão ao extrair campos de data/hora ou converter os carimbos de data/hora em strings.
Além disso, carimbos de data/hora podem ser construídos a partir do tipo LONG por meio de casting. Se uma coluna LONG contiver o número de segundos desde a época 1970-01-01 00:00:00Z, ela pode ser convertida para TIMESTAMP do Spark SQL:
Infelizmente, essa abordagem não nos permite especificar a parte fracionária dos segundos. No futuro, o Spark SQL fornecerá funções especiais para criar carimbos de data/hora a partir de segundos, milissegundos e microssegundos desde a época: timestamp_seconds(), timestamp_millis() e timestamp_micros().
Outra maneira é construir datas e carimbos de data/hora a partir de valores do tipo STRING. Podemos criar literais usando palavras-chave especiais:
ou por meio de casting que podemos aplicar a todos os valores em uma coluna:
As strings de carimbo de data/hora de entrada são interpretadas como carimbos de data/hora locais no fuso horário especificado ou no fuso horário da sessão se um fuso horário for omitido na string de entrada. Strings com padrões incomuns podem ser convertidas para carimbo de data/hora usando a função to_timestamp(). Os padrões suportados são descritos em Padrões de Data e Hora para Formatação e Análise:
A função se comporta de forma semelhante ao CAST se você não especificar nenhum padrão.
Para usabilidade, o Spark SQL reconhece valores de string especiais em todos os métodos acima que aceitam uma string e retornam um carimbo de data/hora e data:
'1970-01-01 00:00:00Z'TIMESTAMP ou apenas a data atual para o tipo DATE.DATE.TIMESTAMP.Por exemplo:
Uma das grandes funcionalidades do Spark é criar Datasets a partir de coleções existentes de objetos externos no lado do driver e criar colunas de tipos correspondentes. O Spark converte instâncias de tipos externos em representações internas semanticamente equivalentes. O PySpark permite criar um Dataset com colunas DATE e TIMESTAMP a partir de coleções Python, por exemplo:
O PySpark converte objetos datetime do Python em representações internas do Spark SQL no lado do driver usando o fuso horário do sistema, que pode ser diferente das configurações de fuso horário da sessão do Spark spark.sql.session.timeZone. Os valores internos não contêm informações sobre o fuso horário original. Operações futuras sobre as datas e carimbos de data/hora paralelizados levarão em conta apenas o fuso horário das sessões do Spark SQL de acordo com a definição do tipo TIMESTAMP WITH SESSION TIME ZONE.
De forma semelhante ao que demonstramos acima para coleções Python, o Spark reconhece os seguintes tipos como tipos de data/hora externos nas APIs Java/Scala:
Há uma diferença entre os tipos java.sql.* e java.time.*. O java.time.LocalDate e o java.time.Instant foram adicionados no Java 8, e os tipos são baseados no calendário Gregoriano Proléptico — o mesmo calendário usado pelo Spark a partir da versão 3.0. O java.sql.Date e o java.sql.Timestamp têm outro calendário por baixo — o calendário híbrido (Juliano + Gregoriano desde 1582-10-15), que é o mesmo que o calendário legado usado pelas versões do Spark anteriores à 3.0. Devido aos diferentes sistemas de calendário, o Spark precisa realizar operações adicionais durante as conversões para representações internas do Spark SQL e rebasear datas/carimbos de data/hora de entrada de um calendário para outro. A operação de rebase tem uma pequena sobrecarga para carimbos de data/hora modernos após o ano 1900, e pode ser mais significativa para carimbos de data/hora antigos.
O exemplo abaixo mostra a criação de carimbos de data/hora a partir de coleções Scala. No primeiro exemplo, construímos um objeto java.sql.Timestamp a partir de uma string. O método valueOf interpreta as strings de entrada como um carimbo de data/hora local no fuso horário padrão da JVM, que pode ser diferente do fuso horário da sessão do Spark. Se você precisar construir instâncias de java.sql.Timestamp ou java.sql.Date em um fuso horário específico, recomendamos dar uma olhada em java.text.SimpleDateFormat (e seu método setTimeZone) ou java.util.Calendar.
Da mesma forma, podemos criar uma coluna DATE a partir de coleções de java.sql.Date ou java.LocalDate. A paralelização de instâncias de java.LocalDate é totalmente independente do fuso horário da sessão do Spark ou do fuso horário padrão da JVM, mas não podemos dizer o mesmo sobre a paralelização de instâncias de java.sql.Date. Existem nuances:
java.sql.Date representam datas locais no fuso horário padrão da JVM no driverPara evitar quaisquer problemas relacionados a calendário e fuso horário, recomendamos os tipos Java 8 java.LocalDate/Instant como tipos externos na paralelização de coleções Java/Scala de carimbos de data/hora ou datas.
A operação inversa da paralelização é coletar datas e carimbos de data/hora dos executores de volta para o driver e retornar uma coleção de tipos externos. Para o exemplo acima, podemos trazer o DataFrame de volta para o driver através da ação collect():
O Spark transfere valores internos de colunas de datas e timestamps como instantes de tempo no fuso horário UTC dos executores para o driver, e realiza conversões para objetos datetime do Python no fuso horário do sistema no driver, sem usar o fuso horário da sessão do Spark SQL. collect() é diferente da ação show() descrita na seção anterior. show() usa o fuso horário da sessão ao converter timestamps para strings, e coleta as strings resultantes no driver.
Nas APIs Java e Scala, o Spark realiza as seguintes conversões por padrão:
DATE do Spark SQL são convertidos para instâncias de java.sql.Date.java.sql.Timestamp.Ambas as conversões são realizadas no fuso horário padrão da JVM no driver. Dessa forma, para ter os mesmos campos de data/hora que podemos obter via Date.getDay(), getHour(), etc. e via funções Spark SQL DAY, HOUR, o fuso horário padrão da JVM no driver e o fuso horário da sessão nos executores devem ser os mesmos.
Similarmente a fazer datas/timestamps a partir de java.sql.Date/Timestamp, o Spark 3.0 realiza rebase do calendário Gregoriano Proléptico para o calendário híbrido (Juliano + Gregoriano). Essa operação é quase gratuita para datas modernas (após o ano 1582) e timestamps (após o ano 1900), mas pode trazer algum overhead para datas e timestamps antigos.
Podemos evitar esses problemas relacionados ao calendário e pedir ao Spark para retornar tipos java.time, que foram adicionados desde o Java 8. Se configurarmos a configuração SQL spark.sql.datetime.java8API.enabled para true, a ação Dataset.collect() retornará:
java.time.LocalDate para o tipo DATE do Spark SQLjava.time.Instant para o tipo TIMESTAMP do Spark SQLAgora as conversões não sofrem com problemas relacionados ao calendário porque os tipos Java 8 e Spark SQL 3.0 são ambos baseados no calendário Gregoriano Proléptico. A ação collect() não depende mais do fuso horário padrão da JVM. As conversões de timestamp não dependem de fuso horário algum. Em relação à conversão de datas, ela usa o fuso horário da sessão da configuração SQL spark.sql.session.timeZone. Por exemplo, vamos olhar um Dataset com colunas DATE e TIMESTAMP, configurando o fuso horário padrão da JVM para Europe/Moscow, mas o fuso horário da sessão para America/Los_Angeles.
A ação show() exibe o timestamp no horário da sessão America/Los_Angeles, mas se coletarmos o Dataset, ele será convertido para java.sql.Timestamp e exibido em Europe/Moscow pelo método toString:
Na verdade, o timestamp local 2020-07-01 00:00:00 é 2020-07-01T07:00:00Z em UTC. Podemos observar isso se habilitarmos a API Java 8 e coletarmos o Dataset:
O objeto java.time.Instant pode ser convertido para qualquer timestamp local posteriormente, independentemente do fuso horário global da JVM. Essa é uma das vantagens do java.time.Instant sobre o java.sql.Timestamp. O primeiro requer a alteração da configuração global da JVM, o que influencia outros timestamps na mesma JVM. Portanto, se suas aplicações processam datas ou timestamps em fusos horários diferentes, e as aplicações não devem entrar em conflito umas com as outras ao coletar dados para o driver via API Java/Scala Dataset.collect(), recomendamos a mudança para a API Java 8 usando a configuração SQL spark.sql.datetime.java8API.enabled.
Neste post, descrevemos os tipos DATE e TIMESTAMP do Spark SQL. Mostramos como construir colunas de data e timestamp a partir de outros tipos primitivos do Spark SQL e tipos Java externos, e como coletar colunas de data e timestamp de volta para o driver como tipos Java externos. Desde a versão 3.0, o Spark mudou do calendário híbrido, que combina os calendários Juliano e Gregoriano, para o calendário Gregoriano Proléptico (veja SPARK-26651 para mais detalhes). Isso permitiu ao Spark eliminar muitos problemas, como demonstramos anteriormente. Para compatibilidade com versões anteriores, o Spark ainda retorna timestamps e datas no calendário híbrido (java.sql.Date e java.sql.Timestamp) de ações como collect. Para evitar problemas de resolução de calendário e fuso horário ao usar as ações collect do Java/Scala, a API Java 8 pode ser habilitada através da configuração SQL spark.sql.datetime.java8API.enabled. Experimente hoje mesmo gratuitamente no Databricks como parte do nosso Databricks Runtime 7.0.

Livro O'Reilly Learning Spark
A 2ª Edição gratuita inclui atualizações sobre Spark 3.0, incluindo as novas dicas de tipo Python para Pandas UDFs, nova implementação de data/hora, etc.
(Esta publicação no blog foi traduzida utilizando ferramentas baseadas em inteligência artificial) Publicação original
Acompanhe-nos
Artigos relacionados
Código aberto
22 de julho de 2020/22 min de leitura