巨大なデータセットと向き合うとき、カーソルは「諸刃の剣」である
PostgreSQLを使い始めてしばらくすると、誰もが一度は「数百万行のレコードをループで処理したい」という誘惑に駆られます。そんな時、教科書的に登場するのが`PL/pgSQL`のカーソルですね。
しかし、現場でバリバリとクエリを書いているエンジニアの皆さんなら、もうお気づきのはずです。「安易なカーソル利用はパフォーマンスの劇薬になる」ということに。今日は、カーソルの基本をおさらいしつつ、その裏側にあるアーキテクチャと、なぜ私たちがそこに細心の注意を払わなければならないのかを紐解いていきましょう。
—
カーソルのライフサイクル:基本のキ
PL/pgSQLでのカーソル操作は、宣言からクローズまでの4ステップで完結します。
1. DECLARE: カーソル変数を用意する。
2. OPEN: クエリを実行し、結果セットへのポインタを保持する。
3. FETCH: ポインタを動かし、一行ずつ(あるいは複数行)取得する。
4. CLOSE: リソースを解放する。
DECLARE
curs CURSOR FOR SELECT id, data FROM large_table WHERE status = ‘pending’;
rec RECORD;
BEGIN
OPEN curs;
LOOP
FETCH curs INTO rec;
EXIT WHEN NOT FOUND;
— ここで重いロジックを実行
END LOOP;
CLOSE curs;
END;
一見すると非常に直感的ですが、ここで「待てよ」と立ち止まれるかどうかが、シニアエンジニアの分かれ道です。
—
内部アーキテクチャから見る「コスト」の正体
多くの人が誤解していますが、カーソルは単なる「クエリの結果を一時的に保存しておく箱」ではありません。
カーソルを開いた瞬間、PostgreSQLのバックエンドプロセスは、そのクエリを実行するためのプランナーとエグゼキュータを起動します。そして、結果セットをメモリ(`work_mem`)やディスク(`temp_files`)上のスプールに保持し、ポインタを移動させていくのです。
ここで重要なのは、「カーソルを開いている間、トランザクションのコンテキストが保持され続ける」という点です。
- ロックの問題: トランザクション内でカーソルを使っている場合、カーソルが参照しているテーブルに対して、長時間の共有ロックやMVCCによるスナップショットの維持が発生します。これは、`VACUUM`の遅延や、不要な行の掃除を妨げる原因になり、結果としてテーブルの肥大化を招きます。
- メモリ消費: `FETCH`のサイズを極端に大きくしたり、逆に小さすぎてループ回数が膨大になったりすると、オーバーヘッドが無視できなくなります。特に、複雑な結合を含むクエリをカーソルで回すと、その裏側で作成される一時ファイルのI/Oが、DB全体のパフォーマンスを著しく低下させます。
—
パフォーマンストラブルシューティング:いつカーソルを捨てるべきか
私がコードレビューでカーソルを見つけたとき、必ず自問自答するポイントがあります。
1. 「これは集合演算で解決できないか?」
PostgreSQLはセットベースの処理において世界最高峰のオプティマイザを持っています。一行ずつループで処理するよりも、`UPDATE … FROM` や `INSERT INTO … SELECT` を使って、一度に処理を完結させる方が圧倒的に速いことがほとんどです。
2. 「カーソル内での処理は、本当にDB内でやる必要があるのか?」
カーソル内で複雑なビジネスロジックを実行すると、DBのCPUを占有します。アプリケーション層で処理してバルクインサートする方が、水平スケーリングの観点からは有利かもしれません。
それでもカーソルが必要な場合(例えば、非常にメモリを食う処理を安全に分割したい場合など)、`FETCH NEXT 1000`のように、一度に取得する行数を制御し、小刻みに処理することを強く推奨します。
—
最後に:道具としてのカーソル
カーソルは、大量のデータセットを「制御可能なサイズ」に切り出すための強力なツールです。しかし、それはあくまで最終手段。
「ループで処理すれば簡単だ」という妥協は、時にデータベースの死を招きます。内部で何が起きているのか、ストレージのI/Oがどう動いているのか、そしてトランザクションの生存期間がシステム全体にどのような影響を及ぼすのか。
その想像力を持って初めて、カーソルはあなたの強力な武器になります。皆さんの書くクエリが、今日も効率的に回ることを願っています。
それでは、また。
コメント