この記事では、私がデータ移行・バッチ処理のプロジェクトで遭遇した「Foreign Data Wrapper(以下、FDW)を介したクエリ実行時におけるパフォーマンス低下(タイムアウト)とデータ欠落」という問題について、解決に至るまでの過程を共有します。
今回の構成では、新システム側のAmazon Aurora PostgreSQL(バージョン16.8)から、既存システム側のMicrosoft SQL Server(以下、SQL Server)を参照するために、tds_fdw を利用していました。
FDWは、PostgreSQLから外部データソースを通常のテーブルのように扱うための仕組みです。今回使用した tds_fdw は、TDS(Tabular Data Stream)プロトコルを介してSQL Serverへ接続する拡張機能です。なお、tds_fdw は書き込み操作とJOIN pushdownには対応していません。一方、match_column_names オプションが有効な場合は、WHERE条件と列のpushdownに対応しています。本記事では、SQL Server上のデータを参照する用途で利用しています。 異種データベース間の連携では、単に接続できるだけでなく、データ型、日付条件、JOIN条件、実行計画の違いを意識する必要があります。
この記事が、同じようにAurora PostgreSQLとSQL Serverを連携する方の参考になれば幸いです。
私が新システムのバッチ処理から、既存システムであるSQL Server上のデータを参照し、結合して抽出する処理をテスト実行していた際、以下のような事象が発生しました。
statement_timeout)が発生。【発生状況の整理と初期対応】
まず、以下の観点で状況を整理しました。
EXPLAIN による実行計画の確認その結果、Aurora PostgreSQL側から tds_fdw を使ってSQL Server上の外部テーブルを参照し、その外部テーブルを直接JOINしている箇所で問題が発生していることが分かりました。
まず、チームが採用しているサーバー連携アーキテクチャの基本構成は以下の通りです。
(図1:Aurora PostgreSQL内の tds_fdw extensionが、TDS通信でSQL Serverを参照する構成。Aurora PostgreSQL側の外部テーブルはメタデータ定義であり、実データはSQL Server側に存在する)
図1のポイントは、FDWが独立した中間サーバーとして存在するわけではないという点です。
Aurora PostgreSQL側には、外部サーバー、ユーザーマッピング、外部テーブル定義などのメタデータが存在します。一方、実際の行データはAurora PostgreSQL側には保存されておらず、SQL Server側のテーブルに存在します。
アプリケーションやバッチ処理から外部テーブルを参照すると、Aurora PostgreSQL側の tds_fdw がSQL Serverへ問い合わせを行い、クエリ実行時に結果セットを取得します。
この構成を前提に、外部テーブル定義、抽出条件、JOIN条件を確認した結果、主に以下の3点が問題になっていました。
SQL Server側ではint(数値型)として定義されているカラムを、Aurora PostgreSQL側の外部テーブル定義や検索条件では文字列型として扱っている箇所がありました。
単純なSQLであれば、DB側が暗黙的に型変換してくれる場合もあります。しかし、FDW越しのクエリでは、外部テーブル定義、検索条件、SQL Server側の実カラム型がずれていると、以下のような問題が起きる可能性があります。
特にFDW経由の参照では、条件がSQL Server側で処理されるか、Aurora PostgreSQL側で処理されるかによって、ネットワークを通過するデータ量やクエリの実行時間が変わります。
今回のケースでは、PostgreSQL側の外部テーブル定義とSQL Server側のDDLでデータ型が一致していなかったため、型の対応関係を見直す必要がありました。
今回の抽出条件は「今日を含む直近10日間」という日付単位の条件でした。しかし、SQLの中で日付型と日時型が混在していたため、境界日の扱いが曖昧になっていました。
Aurora PostgreSQLでは、date 型に整数の日数を加減した場合は date 型になりますが、interval を加減した場合は timestamp 型になります。
そのため、抽出条件の中で interval を使用すると、日付単位で処理するつもりでも、比較式の中に日時型が含まれることになります。
型が異なるだけで必ずデータが欠落するわけではありません。しかし、今回のSQLには次の2つの書き方が混在しており、どのような単位で比較しているのかがコード上から判断しにくい状態になっていました。
date 型として日付単位で比較している箇所interval を使い、timestamp 型として開始・終了時刻を含めて比較している箇所さらに、対象カラムがSQL Server側の datetime 相当である場合、終了日時、すなわち境界日の当日分を含めるのか含めないのかもSQL上に明示されていませんでした。
この2点が重なることで、境界日のデータが抽出対象に含まれるかどうかを、SQLを読んだだけでは判断できない状態になっていました。
両テーブルに対応する行があるデータだけを取得する要件にもかかわらず、LEFT JOIN を使用していました。
LEFT JOIN は、右表に一致する行が存在しない場合でも左表の行を残し、右表側のカラムをNULLとして補います。
しかし、その後に右表側の主キーやNOT NULL列を使って IS NOT NULL の条件を指定すると、未一致によってNULLになった行はWHERE句で除外されます。
そのため、この条件では最終的な結果が INNER JOIN と同等になります。
必ずしもデータベースが巨大な中間結果を物理的に生成するとは限らず、オプティマイザによって結合方法が変換される場合もあります。一方で、FDW越しの外部テーブル参照では、実行計画によっては不要な行の取得やAurora PostgreSQL側での結合処理が増える可能性があります。
今回のSQLでは、本来 INNER JOIN で十分な要件にもかかわらず LEFT JOIN を使用していたため、SQLに記述された意図と実際に必要な結果が一致していない状態になっていました。
原因を整理したうえで、型、日付条件、JOIN条件の3点を修正しました。
SQL Server側で数値型として定義されているカラムについて、Aurora PostgreSQL側の外部テーブル定義を数値型に合わせ、検索条件の値も同じ数値型として扱うように修正しました。
-- 修正前のイメージ:数値カラムを文字列リテラルで比較
WHERE status_code = '1'
-- 修正後のイメージ:対象カラムと同じ数値型で比較
WHERE status_code = 1
この修正により、型不一致による暗黙変換や条件pushdownの不安定さを減らすことができ、実行時間が改善しました。
日付単位で処理するカラムでは、interval を使う日時型の計算ではなく、日数を整数で加減する形に統一しました。
-- 修正前のイメージ
-- 開始条件だけであり、「今日を含む直近10日間」を正確に表現できていない
WHERE target_date >= CURRENT_DATE - INTERVAL '10 days'
-- 修正後のイメージ
-- 9日前から今日までの10日間を抽出
WHERE target_date >= CURRENT_DATE - 9
AND target_date < CURRENT_DATE + 1
対象カラムが timestamp やSQL Server側の datetime 相当である場合は、開始日時を含み、終了日時を含まない半開区間として指定しました。
-- 今日を含む直近10日間
-- 9日前の0時以上、翌日の0時未満を抽出
WHERE target_datetime >= CURRENT_DATE - 9
AND target_datetime < CURRENT_DATE + 1
本記事では、「直近10日間」を「今日を含む直近10日間」と定義しています。そのため、開始日時を9日前の0時、終了日時を翌日の0時として、終了日時を含まない半開区間で指定しました。
これにより、9日前から今日までの10日間という抽出範囲がSQL上で明確になりました。
両テーブルに対応する行が存在するデータだけを取得する要件だったため、LEFT JOIN と後続のNULL除外を組み合わせるのではなく、最初から INNER JOIN を使用するように変更しました。
-- 修正前のイメージ
FROM table_a a
LEFT JOIN foreign_table_b b
ON a.id = b.a_id
WHERE b.management_no IS NOT NULL
-- 修正後のイメージ
FROM table_a a
INNER JOIN foreign_table_b b
ON a.id = b.a_id
修正前のSQLでは、LEFT JOIN によって残した未一致行を、後続のWHERE句で除外していました。
修正後は、必要な行だけを結合する意図がSQL上でも明確になりました。今回の実行計画では、不要な外部テーブル参照やローカル側の結合処理が抑えられ、実行時間が改善しました。
上記の修正によって問題は改善しましたが、型変換、日付条件、JOIN条件を各バッチ処理のSQLに個別に書いていくと、別のバッチで同じようなミスが再発する可能性があります。
そこで、外部テーブルとバッチ処理の間にVIEW層を設け、以下の処理をVIEW側に集約しました。
型の正規化 外部テーブルから取得した値の型や比較条件を統一し、アプリケーション側へ一貫した型で公開する。
日付境界条件の統一 日付単位の抽出条件を統一し、境界値の欠落を防ぐ。
JOIN条件の明確化 両テーブルに対応行があるデータだけを取得する場合は INNER JOIN を使用し、LEFT JOIN 後に WHERE でNULL行を除外するような書き方を避ける。
通常のVIEWは結果データを物理的に保存するものではありません。参照時にVIEW定義のSQLが展開され、実行されます。
そのため、VIEWを作っただけで自動的に高速化されるわけではありません。今回のポイントは、型変換、日付条件、JOIN条件の修正方針をVIEW層に集約し、バッチ処理側からは整理済みのデータ構造として参照できるようにしたことです。
図2では、各コンポーネントを以下の3層に整理しています。
第1層:外部生データ & 外部テーブル層 SQL Server側には実データが存在します。Aurora PostgreSQL側には、SQL Serverのテーブルを参照するための外部テーブル定義が存在します。
第2層:抽象化レイヤー / VIEW層 外部テーブルを直接バッチ処理から参照するのではなく、VIEWを挟みます。このVIEWに、型の正規化、日付条件の統一、JOIN条件の明確化を集約します。
第3層:アプリケーション / バッチ処理層 バッチ処理や分析用途のSQLでは、複雑な外部テーブル定義や型差異を意識せず、整理済みのVIEWを参照します。
この構成により、バッチ処理側のSQLをシンプルに保ちつつ、異種DB連携に伴う型や日付条件の差異を一箇所で管理しやすくなりました。
今回の検証では、Aurora PostgreSQLから tds_fdw を使ってSQL Serverを参照する構成において、以下の点がパフォーマンス低下やデータ欠落の原因になり得ることを確認しました。
INNER JOIN で十分な結合に対する LEFT JOIN と、後続の WHERE 句によるNULL行除外最終的には、型の正規化、日付境界条件の統一、JOIN条件の明確化をVIEW層に集約し、バッチ処理側からは整理済みのVIEWを参照する構成にしました。
VIEW自体が必ず性能を改善するわけではありませんが、複雑な補正処理を一箇所に集約することで、SQLの見通しを良くし、同じ問題の再発を防ぎやすくなります。
異種データベース間の連携は、単に接続できれば終わりではありません。 特にFDW越しの外部テーブル参照では、ローカルDBとリモートDBの型、日付、JOIN、実行計画の違いを意識する必要があります。
今回の経験を通じて、FDWの便利さだけでなく、外部DB参照ならではの注意点も学ぶことができました。
今後も、実行計画やデータ定義を丁寧に確認しながら、より安全で保守しやすいデータ連携基盤の構築に貢献していきたいと思います。