Apache Spark は、構造化データと非構造化データの処理に使用される非常に一般的なツールです。構造化データの処理に関しては、整数、LONG、DOUBLE、STRING といった多くの基本的なデータ型をサポートしています。Spark は、開発者が理解するのが難しいことが多い DATE
や TIMESTAMP
などの複雑なデータ型もサポートしています。このブログでは、日付型とタイムスタンプ型について深く掘り下げ、その動作と一般的な問題を回避する方法を解説します。主に、次の 4 つの部分をカバーしています。
- 日付型と関連する暦法の定義と Spark 3.0 から適用された暦法の変更について
- タイムスタンプ型の定義とタイムゾーンとの関係(タイムゾーンオフセットの解消に関する詳細と、Spark 3.0 で使用される Java 8 の新しい Time API の若干の動作変更についても説明します。)
- Spark で日付値とタイムスタンプ値を作成するための共通 API
- Spark ドライバで日付とタイムスタンプのオブジェクトを収集する際のよくある落とし穴とベストプラクティス
日付と暦法
日付型(DATE
)の定義は非常にシンプルで、YEAR = 2012、MONTH = 12、DAY = 31 というように年、月、日のフィールドの組み合わせです。ただし、年、月、日の各フィールド値には制約があり、日付値は現実世界で有効な日付になります。例えば、MONTH 値は 1~12 でなければならず、DAY 値は 1~28/29/30/31(年と月によって異なる)である必要があります。
これらの制約は、使用可能な多くの暦法のいづれかによって定義されます。太陰暦のような特定の地域でのみ使用されるものや、ユリウス暦のように歴史の中でのみ使用されているものもあります。現在用いられているグレゴリオ暦 は事実上の国際標準であり、世界中のほぼ全ての場所で民間目的で使用されています。グレゴリオ暦は 1582 年に導入され、1582 年以前の日付にも適用されています。この 1582 年以前の日付に適用した暦法は、先発グレゴリオ暦と呼ばれています。
Spark 3.0 以降では、pandas、R、Apache Arrow などの他のデータシステムで既に用いられている先発グレゴリオ暦を使用しています。Spark 3.0 以前では、1582 年より前の日付ではユリウス暦、1582 年より後の日付にはグレゴリオ暦というように、ユリウス暦とグレゴリオ暦の組み合わせを使用していました。これは、レガシー java.sql.Date
API から継承され、Java 8 では先発グレゴリオ暦を使用する java.time.LocalDate
に置き換えられました。
日付型はタイムゾーンを考慮しないことは注目すべき点です。
タイムスタンプとタイムゾーン
タイムスタンプ型(TIMESTAMP
)は、日付型を新しいフィールドを用いて拡張します。フィールド値には、時、分、秒(小数部分を保有)と、グローバル(セッションスコープ)タイムゾーンがあります。これにより、地球上の絶対的な時刻を定義します。例えば、YEAR = 2012、MONTH = 12、DAY = 31、HOUR = 23、MINUTE = 59、SECOND = 59.123456 と、セッションタイムゾーンが UTC+01:00 というように表します。タイムスタンプ値を Parquet のような非テキストのデータソースに書き出す場合、その値は UTC のタイムスタンプなどのタイムゾーン情報を持たない単なる瞬間です。異なるセッションタイムゾーンでタイムスタンプ値を読み書きすると、時、分、秒フィールド値が異なることがありますが、実際には同じ絶対的な時刻です。
時、分、秒の各フィールドの標準範囲は、時間の場合は 0~23、分と秒の場合は 0~59 です。Spark では、最大マイクロ秒の精度で秒の小数部分をサポートします。小数部分の有効範囲は、0 から 999,999 マイクロ秒です。
どんな絶対的な時刻でも、タイムゾーンに応じたさまざまな値をウォールクロック(実時間)で観察できます。
反対に、ウォールクロックの値は、多くの異なる時刻を表すことも可能です。タイムゾーンのオフセットを使用すると、ローカルタイムスタンプを時刻に明確に結び付けます。通常、タイムゾーンのオフセットは、グリニッジ標準時(GMT)または UTC+0(協定世界時)からの時間単位のオフセットとして定義されます。このようなタイムゾーン情報の表現はあいまいさを排除しますが、エンドユーザーにとっては不便です。ユーザーは、America/Los_Angeles
や Europe/Paris
などの地域で示すことを好みます。
ゾーンのオフセットを用いた追加レベルの抽象化は、作業を容易にしますが、新たな問題をもたらします。例えば、タイムゾーン名をオフセットにマッピングするための特別なタイムゾーンデータベースを維持する必要があります。Spark は JVM(Java 仮想マシン)上で実行されるため、マッピングを Java 標準ライブラリに委任し、Java 標準ライブラリは インターネット番号割当機関のタイムゾーンデータベース(IANA TZDB) からデータをロードします。さらに、Java の標準ライブラリのマッピングメカニズムには、Spark の動作に影響を与える微妙な差異があります。以下では、Spark の動作に影響を与える差異のいくつかをご紹介します。
Java 8 以降、JDK(Java 開発キット)が日時の操作とタイムゾーンのオフセットを解消するための新しい API を公開しており、Spark 3.0 でこの新しい API に移行しました。タイムゾーン名からオフセットへのマッピングのソースが IANA TZDB であることは同じですが、Java 8 以降と Java 7 では実装方法が異なります。
As an example, let’s take a look at a timestamp before the year 1883 in the America/Los_Angeles
time zone: 1883-11-10 00:00:00
. This year stands out from others because on November 18, 1883, all North American railroads switched to a new standard time system that henceforth governed their timetables.
Using the Java 7 time API, we can obtain time zone offset at the local timestamp as -08:00:
しかし、Java 8 の API 関数は、異なる結果を返します。
1883年11月18日以前において、時刻は地域の問題でした。ほとんどの都市や町では、何らかの形でその地域の太陽時を使用しており、教会の尖塔や宝石商の窓といったよく知られた時計によって維持されていました。そのため、このような奇妙なタイムゾーンのオフセットが生じます。
この例は、Java 8 の関数がより正確で、IANA TZDB からの履歴データを考慮に入れていることを示しています。Java 8 の日時 API に切り替えたあと、Spark 3.0 は自動的に改善の恩恵を受け、タイムゾーンのオフセットを解消する方法がさらに正確になりました。
前述したように、Spark 3.0 では、日付型が先発グレゴリオ暦に切り替わりました。タイムスタンプ型についても同じことが言えます。ISO SQL:2016 標準では、タイムスタンプの有効範囲が 0001-01-01 00:00:00
から 9999-12-31 23:59:59.999999
であると宣言されています。Spark 3.0 は標準に完全に準拠しており、この範囲の全てのタイムスタンプをサポートします。Spark 2.4 以前と比較すると、次のサブ範囲に注意する必要があります。
0001-01-01 00:00:00..1582-10-03 23:59:59.999999
Spark 2.4 ではユリウス暦が使用され、標準に準拠していません。Spark 3.0 ではこの問題が修正され、年、月、日といったタイムスタンプの内部操作に先発グレゴリオ暦が適用されます。暦法が異なるため、Spark 2.4 に存在する一部の日付は、Spark 3.0 には存在しません。例えば、グレゴリオ暦において 1000 年はうるう年ではないため、1000-02-29 は無効な日付だと言えます。また、Spark 2.4 では、このタイムスタンプの範囲において、タイムゾーン名がゾーンのオフセットに誤って解消されてしまいます。1582-10-04 00:00:00..1582-10-14 23:59:59.999999
これは、Spark 3.0 においてローカルタイムスタンプの有効な範囲であり、このようなタイムスタンプが存在しなかった Spark 2.4 とは異なります。1582-10-15 00:00:00..1899-12-31 23:59:59.999999
Spark 3.0 は、IANA TZDB からの履歴データを使用してタイムゾーンのオフセットを正しく解消します。Spark 3.0 と比較すると、上記の例で示したように、Spark 2.4 ではタイムゾーン名からのゾーンのオフセットが正しく解消されない場合があります。1900-01-01 00:00:00..2036-12-31 23:59:59.999999
Spark 3.0 と Spark 2.4 は、どちらも ANSI SQL 標準に準拠しており、月の特定の日を取得するといった日時の操作においてグレゴリオ暦を使用します。2037-01-01 00:00:00..9999-12-31 23:59:59.999999
Spark 2.4 では、JDK のバグ(#8073446)が原因で、タイムゾーンのオフセット、特に夏時間のオフセットが正しく解消されないことがあります。Spark 3.0 には、この問題はありません。
One more aspect of mapping time zone names to offsets is overlapping of local timestamps that can happen due to daylight saving time (DST) or switching to another standard time zone offset. For instance, on 3 November 2019, 02:00:00
clocks were turned backward 1 hour to 01:00:00
. The local timestamp 2019-11-03 01:30:00
America/Los_Angeles can be mapped either to 2019-11-03 01:30:00 UTC-08:00
or 2019-11-03 01:30:00 UTC-07:00
. If you don’t specify the offset and just set the time zone name (e.g., '2019-11-03 01:30:00 America/Los_Angeles'
), Spark 3.0 will take the earlier offset, typically corresponding to “summer.” The behavior diverges from Spark 2.4 which takes the “winter” offset. In the case of a gap, where clocks jump forward, there is no valid offset. For a typical one-hour daylight saving time change, Spark will move such timestamps to the next valid timestamp corresponding to “summer” time.
上記の例からわかるように、タイムゾーン名からオフセットへのマッピングはあいまいであり、1 対 1 ではありません。可能な場合には、タイムスタンプを作成する際に、TIMESTAMP
'2019-11-03 01:30:00 UTC-07:00'
のように正確なタイムゾーンのオフセットを指定することをお勧めします。
次に、ANSI SQL 標準について見てみましょう。ANSI SQL 標準では、次の 2 つのタイムスタンプ型を定義します。
TIMESTAMP WITHOUT TIME ZONE
またはTIMESTAMP
:年、月、日、時、分、秒でのローカルタイムスタンプ。これらのタイムスタンプ型は、どのタイムゾーンにも結び付けられず、実際にはウォールクロックのタイムスタンプです。TIMESTAMP WITH TIME ZONE
:年、月、日、時、分、秒、TIMEZONE_HOUR、TIMEZONE_MINUTE でのゾーンタイムスタンプ。タイムスタンプは、 UTC タイムゾーンの時刻 + 各値に関連付けられたタイムゾーンのオフセット(時間と分単位)を表します。
TIMESTAMP WITH TIME ZONE
のタイムゾーンオフセットは、タイムスタンプが表す物理的な時点には影響しません。これは、他のタイムスタンプのコンポーネントによって指定された UTC の時刻によって完全に表されるためです。代わりに、タイムゾーンのオフセットは、表示用タイムスタンプ値の初期設定の動作、日付/時刻コンポーネントの抽出(例:EXTRACT
)、およびタイムスタンプへの月の追加というようなタイムゾーンの把握を必要とするその他の操作にのみ影響します。
Spark SQL は、タイムスタンプ型を TIMESTAMP WITH SESSION TIME ZONE
として定義します。フィールド(YEAR
、MONTH
、DAY
、HOUR
、MINUTE
、SECOND
、SESSION TZ
)の組み合わせであり、YEAR
から SECOND
フィールドは、UTC タイムゾーンにおける時刻を識別し、SESSION TZ
は、SQL 設定 spark.sql.session.timeZone
から取得されます。セッションタイムゾーンは、次のように設定できます。
- ゾーンのオフセット
'(+|-)HH:mm'
:この形式により、物理的な時点を明確に定義できます。 - 地域 ID
'area/city'
形式のタイムゾーン名(例:'America/Los_Angeles'
):この形式のタイムゾーン情報には、ローカルタイムスタンプの重複などの上述したいくつかの問題があります。ただし、各 UTC の時刻は、任意の地域 ID の 1 つのタイムゾーンのオフセットに明確に関連付けられているため、地域 ID ベースのタイムゾーンを持つ各タイムスタンプは、ゾーンのオフセットを持つタイムスタンプに明確に変換できます。
初期設定では、セッションタイムゾーンは、Java 仮想マシンの既定のタイムゾーンに設定されます。
Spark の TIMESTAMP WITH SESSION TIME ZONE
は、以下とは異なります。
TIMESTAMP WITHOUT TIME ZONE
:このタイプの値は複数の物理的な時刻にマッピングできますが、TIMESTAMP WITH SESSION TIME ZONE
値は、絶対的な物理的時刻になります。SQL 型は、全てのセッションで UTC+0 などの 1 つの固定タイムゾーンのオフセットを使用してエミュレートできます。その場合、UTC のタイムスタンプをローカルタイムスタンプとみなすことができます。TIMESTAMP WITH TIME ZONE
:SQL 標準によると、この型の列値は異なるタイムゾーンのオフセットを持つ可能性があります。これは、Spark SQL ではサポートされていません。
グローバル(セッションスコープ)タイムゾーンに関連付けられているタイムスタンプは、Spark SQL によって新たに発明されたものではないことに注意してください。Oracle などの RDBMS(リレーショナルデータベース管理システム)では、タイムスタンプにも同様の型(TIMESTAMP WITH LOCAL TIME ZONE)を提供します。