6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQL→Cloud Spanner大規模移行トラブル詳解 — 18時間ずれの解消と240テーブルPDML補正、Dataflow高速化の実戦

6
Posted at

はじめに

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 ZONETIMESTAMP WITH TIME ZONE かで意味が変わります。TIMESTAMP WITH TIME ZONE に対して AT TIME ZONE を適用すると、その時点で「再度ローカル化」されるため、二重適用すると当初の意図と逆方向にずれていきます。

解決方針: スクリプト修正 + 既存データの補正

修正は2段で進めました。

  1. スクリプト側の修正: AT TIME ZONE の適用箇所を1回に修正。今後の再移行では正しい値が入る
  2. 既存データの補正: 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 の移行を控えている方の参考になれば嬉しいです。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?