はじめに
240テーブル・数十GB規模の PostgreSQL を Cloud Spanner へ移すSTG検証で、1日のうちに4種類のトラブルを連続で踏み抜きました。なかでも一番印象的だったのが「タイムスタンプが18時間ずれる」という、いかにもタイムゾーン由来っぽくて、よく見ると別物だったバグです。
本記事では、
- Part 1: 18時間ずれの原因究明(仮説2連発)
- Part 2: 240テーブルを PDML で一気に補正するスケール戦略
- Part 3: Dataflow「データが30分入ってこない」事件の調査ポイント
- Part 4: 大量DDLを高速化する2つの判断(DB再作成 / バッチ分割)
を、実戦投入ベースでまとめます。PostgreSQL → Spanner の異種DB移行を控えている方の予習資料になれば幸いです。
Part 1: タイムスタンプ「18時間ずれ」の謎を追う
問題発見: なぜか丸1日近くずれている
データ移行が完走したあと、サンプル抽出で値検証をしていたところ、Spanner側のタイムスタンプが PostgreSQL側より18時間も過去になっていることに気付きました。
PostgreSQL (JST): 2026-03-30 19:22:34
Spanner (UTC): 2026-03-30 01:22:34Z ← 期待値より9時間さらに過去
期待値 (UTC): 2026-03-30 10:22:34Z
JST と UTC の差は9時間。なのに、ずれているのは18時間。**「あれ、もう9時間どこ行った?」**というのが第一印象でした。
第一仮説(外れ): 単純なJST/UTC変換ミス
最初の仮説は「JST→UTC変換が抜けていて、9時間ずれているだけでは?」というものでした。でも実測値の差は18時間ぴったり。9時間で説明がつきません。
別の時刻データもサンプリングして比較しましたが、どれも18時間ジャストずれている。ランダムなずれではなく、何かが2倍適用されている気配がします。
第二仮説(当たり): AT TIME ZONE の二重適用
ここで、スキーマ変換に使っていたシェルスクリプト(generate_alter_columns.sh 相当)を見直しました。PostgreSQL の TIMESTAMP WITHOUT TIME ZONE を Spanner の TIMESTAMP(=UTC基準)に揃えるため、抽出時に AT TIME ZONE 'Asia/Tokyo' AT TIME ZONE 'UTC' のような変換を仕込む、という方針です。
問題のスクリプトでは、抽出フェーズで AT TIME ZONE 変換を1回かけた値に対して、別の整形フェーズでもう一度 AT TIME ZONE をかけるロジックになっていました。9時間 × 2 = 18時間。完全に辻褄が合います。
[期待動作] JST 19:22:34 ─[AT TIME ZONE 1回]─→ UTC 10:22:34Z
[実際] JST 19:22:34 ─[AT TIME ZONE 1回]─→ UTC 10:22:34Z
─[さらにAT TIME ZONE]─→ "UTC" 01:22:34Z (実体は -9h さらにシフト)
AT TIME ZONE は一見「タイムゾーン変換」ですが、入力の型が TIMESTAMP WITHOUT TIME ZONE か TIMESTAMP WITH TIME ZONE かで意味が変わります。TIMESTAMP WITH TIME ZONE に対して AT TIME ZONE を適用すると、その時点で「再度ローカル化」されるため、二重適用すると当初の意図と逆方向にずれていきます。
解決方針: スクリプト修正 + 既存データの補正
修正は2段で進めました。
-
スクリプト側の修正:
AT TIME ZONEの適用箇所を1回に修正。今後の再移行では正しい値が入る -
既存データの補正: STG環境には既に18時間ずれた240テーブル分のデータが入っている。これをやり直すと半日溶けるため、TIMESTAMPカラムに
+9時間のUPDATEをかける補正パスを選択
「なぜ +9時間? ずれは18時間なのに」と一瞬迷いますが、ずれた値は 期待値 - 9時間 ではなく 期待値 - 9時間 × 2。つまり期待値より18時間過去にあります。これに +18時間 を足すと期待値になりますが、実際の運用では「JSTのwall clockをUTCとして格納してしまったケース」の補正と同じ性質なので、ローカルタイム-UTC差分である +9時間(テーブル設計上のオフセット) をPDMLで一括加算する形に整理しました(この設計判断は環境依存なので、自分の環境ではログを見て検算してください)。
教訓: タイムスタンプ移行は「3点セット」でテストデータを組む
この件以降、タイムスタンプを含む移行では必ず次の3点をテストデータに含めるルールにしました。
| データ | 値の例 | 検出できる事象 |
|---|---|---|
| JST一意な値 | 2026-03-30 19:22:34 |
変換ロジックの方向ミス |
| UTC一意な値 | 2026-03-30 10:22:34Z |
二重適用 |
| 差分時間 |
JST - UTC = 9h を満たす値 |
一般化されたずれの検出 |
「件数一致 → PK一致 → 値サンプリング」では拾えない、値の演算過程に潜むずれは、テストデータを差分で組まないと炙り出せません。
Part 2: 240テーブル × 数十GB を PDML で一気に補正する
課題: 全件UPDATEは普通のトランザクションでは無理
補正パスを選んだものの、Spannerは1トランザクションあたりの変更行数に制限があります。数百万〜数千万行を持つテーブルに普通の UPDATE を投げると、トランザクションが破裂します。
解決: PDML(Partitioned DML)
ここで使ったのが PDML(Partitioned DML) です。PDMLはSpannerが内部でクエリをパーティション単位に分割し、それぞれを別トランザクションとして実行する機能で、
- 巨大テーブルの一括UPDATE/DELETE に耐える
- 各パーティションの実行は冪等性が前提
- WHERE 条件は同じ行を複数回マッチさせない設計が必要
という特性があります。「TIMESTAMPカラムに +9時間を加算する」のような単純な式は冪等性こそないものの、実行は1回だけと運用で固定すれば PDML で安全に流せます。
スケール戦略: Spannerノード数を 2 → 10 に一時増強
PDMLは内部的に並列実行されるので、ノード数がそのままスループットに効きます。STG環境は普段はノード数2で運用していましたが、補正バッチの間だけ 10ノードに増強しました。
[ノード数増強の方針]
- 補正バッチ実行直前にノード数を 2 → 10
- 補正完了後、即座にノード数を 10 → 2 に戻す
- コストは「数時間分のノード10」だけに抑える
夜間バッチで実行し、一晩で240テーブル分の補正が完走しました。日中の業務時間には影響を与えていません。
補正スクリプトの設計判断
fix_timestamp_9h.sh 相当の補正スクリプトでは、以下の判断をしました。
-
TIMESTAMPカラムを動的に列挙:
INFORMATION_SCHEMA.COLUMNSからSPANNER_TYPE = 'TIMESTAMP'を引いて対象を生成。テーブルが240もあるとハードコードは事故の元 - テーブル単位でPDMLを順次実行: 同時実行はノードCPU飽和を招くため、テーブルごとに直列。1テーブル内のパーティション並列はSpannerに任せる
- 進捗ログ必須: 240テーブルなのでどこまで進んだか分からないと夜中に対応できない
Part 3: Dataflow「データが30分入ってこない」問題
症状: ジョブは動いている、なのにSpanner側が空
Dataflow flex-template で Avro → Spanner のデータ投入ジョブを起動した直後、ジョブステータスは Running なのに、30分〜1時間半経ってもSpanner側にデータが1行も入りません。
最初は「初回スキーマ解析に時間がかかっているだけかな」と思いきや、1時間半経過してもストリーミングUIのデータ件数が増えない。明らかにおかしい。
調査: 3つの観点から原因を絞る
ジョブのステージ別ログを見て、最終的に3点が悪さしていました。
| # | 観点 | 確認方法 | 今回の状態 |
|---|---|---|---|
| 1 |
inputFilePattern の指定パスが実際の出力先と一致しているか |
パターンを実物の GCS パスと突合 | 一致していた |
| 2 |
streamName が Datastream 側の名前と一致しているか |
gcloud datastream streams list の名前と突合 |
不一致 |
| 3 | Datastream ストリーム自体が RUNNING か |
gcloud datastream streams describe |
RUNNING |
streamName 不一致が主犯でした。Dataflowテンプレ側の指定がタイポっぽいズレを起こしており、Dataflow側は「対応するイベントが来ない」と延々と待っていた、というオチです。
学び: 起動前チェックリスト
それ以来、Dataflow ジョブを起動する前にこの3点チェックリストを機械的に回しています。
-
inputFilePatternのパスが、Datastream 出力先の GCS パスとサフィックスまで含めて一致 -
streamNameが Datastream 上の正式名(プロジェクト&ロケーション含む)と一致 -
Datastream ストリームが
RUNNINGで、エラーカウントがゼロ
3点とも gcloud ワンライナーで取れるので、起動前スクリプトに固定化しました。
加速チューニング: ワーカー1台では CPU が詰まる
ストリーム名を直して再起動したところ、今度は別の問題が顕在化しました。ワーカー1台では CPU 100% に張り付いて処理が追いつかない。
[Before] デフォルトワーカー1台 → CPU 100%、スループット低迷
[After] n2-highmem-4 + autoscaling 有効化
→ CPU 60〜70%で安定、スループット約3倍
ポイントは2つ。
- マシンタイプを
n2-highmem-4に変更。Avro デシリアライズはメモリを食うので、CPU重視より highmem 系のほうが落ち着く -
--additional-experiments=enable_streaming_engineで streaming engine を有効化し、ワーカー側の状態管理負荷を Dataflow バックエンドに逃がす
すでに走っているジョブのワーカー設定は gcloud dataflow jobs update-options でジョブを止めずに変更可能です。これは知っているとリカバリ速度が段違いに変わるテクです。
Part 4: 大量DDLを高速化する2つの判断
最後に、大量テーブル環境で繰り返し効いた「DDL高速化の判断」を2つ。
シナリオA: インデックス作成が遅い → DB再作成で同時適用
Spanner の DDL は1個ずつシリアルに実行されます。空DBであっても、テーブル200個・インデックス400個を順に作ると、待ち時間だけで積み上がります。
逐次 CREATE INDEX を投げる方針から、DBごと削除して再作成時にスキーマとインデックスを同時に流し込む方針に変えたところ、所要時間が劇的に短縮しました。
Spanner は CREATE DATABASE 直後の DDL 一括投入をより効率的に処理するため、後付けで CREATE INDEX を流すよりも、最初のスキーマ定義に含めてしまうほうが圧倒的に速い、というのが教訓です。
シナリオB: shadow テーブル244件のDROP → 50件×5バッチに分割
移行で生成された shadow テーブル(移行ツールが作る一時テーブル)が244件残っており、これを一気に DROP TABLE で流そうとしたところ、Spanner側でタイムアウトしました。
[Before] 244件を1つのDDLバッチで送信 → タイムアウト
[After] 50件 × 5バッチに分割 → 各バッチ数分で完走、全体30分弱
「1バッチ = ノード数で安全に飲める件数」を実測で決めるのがコツです。今回はノード数2の状態で50件が安全圏でした。ノード数を増やせば1バッチあたりの件数も上げられます。
おわりに
1日で踏んだ4つのトラブルから、再利用できる教訓を整理すると次のとおりです。
| 領域 | 教訓 |
|---|---|
| タイムスタンプ移行 | JST一意・UTC一意・差分時間の3点セットでテストデータを組む。9時間ずれと18時間ずれは別物 |
| 大量データ補正 | PDML × 一時的なノード数増強で、再投入せずに済むパスを残す |
| Dataflow 起動 | inputFilePattern / streamName / ストリーム状態の3点チェックリストを起動前に必ず回す |
| 大量DDL | DB再作成での同時適用とバッチ分割。逐次DDLは積み上げで遅くなる |
大規模移行は「個別の機能は知っていても、組み合わせた瞬間に未知のバグが出る」典型です。今回の18時間ずれも、AT TIME ZONE 単体は誰でも知っている関数ですが、変換パイプラインに2回登場した瞬間に化けるわけで。
PostgreSQL → Spanner の移行を控えている方の参考になれば嬉しいです。