Snowflake「Memory limit exceeded」「Query was aborted due to memory exhaustion」エラー原因と解決方法

Snowflake「Memory limit exceeded」「Query was aborted due to memory exhaustion」エラー原因と解決方法のサムネイル Snowflake
この記事をシェアする𝕏B!FacebookLINEPocket

このエラーが出る時、たいてい原因は3つに絞れます。ウェアハウスのサイズが足りていないか、JOINや集計で中間結果が爆発しているか、あるいはUDF/ストアド側でメモリを食う処理を書いているか。最初にこれを言い切ってしまった方が話が早いので、結論から書いていきます。

普段ふつうに動いていたクエリが、ある日突然吐き出してくるあのメッセージ。Memory limit exceeded を見て検索でここに辿り着いた方は、まずクエリ履歴を開いて、本当にデータ量が増えただけなのか、それとも書いたSQL側で何かが「爆発」しているのかを切り分けるところから始めるのが近道です。

どんな時にこのエラーが出るのか

典型的なのは、巨大テーブル同士を結合してウィンドウ関数で集計するような、いわゆる「メモリで殴る」系のクエリです。例えばこんなSQLを XSサイズのウェアハウスで投げるとあっさり死にます。

-- 数億行 × 数億行をJOIN、そのままウィンドウで全件並べる
SELECT
  o.order_id,
  c.customer_name,
  ROW_NUMBER() OVER (PARTITION BY c.region ORDER BY o.amount DESC) AS rn
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2024-01-01';

エラーメッセージは状況によって少しずつ違いますが、よく見るのはこの辺りです。

100069 (54000): Memory limit exceeded for the query
300005 (57014): Operation aborted due to insufficient memory

クエリ履歴(Query History)のプロファイルを見ると、たいてい「Bytes spilled to local storage」または「Bytes spilled to remote storage」が異常な数値になっています。ローカルへのスピル(あふれ)はSSDで吸収できる範囲ですが、リモートスピルが出ているとパフォーマンスが致命的に劣化し、最終的にメモリ不足で落ちます。

本当の原因:Snowflake内部で何が起きているか

Snowflakeのウェアハウスは、サイズごとに割り当てメモリが決まっています。クエリは可能な限りRAMで処理しますが、ハッシュテーブル(JOIN用)やソートバッファ(ORDER BY / ウィンドウ関数用)がRAMに収まらなくなると、まずローカルSSDへ、それでも収まらなければリモートストレージへスピルします。リモートスピルが続くとI/Oが極端に重くなり、最終的にメモリリソースが枯渇してクエリが Memory limit exceeded で殺される、という流れです。

細かい話ですが、エラーメッセージに含まれるオブジェクト名が大文字に化けて表示されるので、もとのSQLでダブルクォートを使っていた場合は探しにくいことがあります。ここでハマって「該当テーブルが見つからない」と数分溶かしたことがあるなら、たぶん同じ罠を踏んでいます。

原因切り分けチェックリスト

頭から順番に確認していくと、だいたい10分以内に犯人が見つかります。

1. ウェアハウスサイズは妥当か

まずは SHOW WAREHOUSES; で現在のサイズを確認します。XSやSで数億行のJOINを叩いていれば、それは単純にサイズ不足です。

2. クエリプロファイルでスピル量を確認

Snowsight左メニューの「Query History」から該当クエリを開き、「Query Profile」タブで Bytes spilled to remote storage を見てください。ここが数GB以上なら、メモリ不足の物理的な証拠です。

3. JOIN条件が壊れていないか

結合キーの片方がNULLだらけだったり、型がミスマッチで暗黙キャストが効いていなかったりすると、ハッシュJOINが実質クロスJOIN化して中間結果が爆発します。EXPLAINで CartesianJoin や異常に多い行数推定が出ていたら要注意です。

4. ウィンドウ関数のPARTITIONが粗すぎないか

OVER (ORDER BY ...) をPARTITIONなしで全件に対して走らせるパターン。これ、地味にメモリを食います。

5. Snowpark / UDFで巨大な配列を組み立てていないか

PythonのUDFやストアドで ARRAY_AGG を全件に対して使うと、1ノードのメモリに全部載せようとして落ちます。

解決方法:パターン別に最短で直す

パターンA:ウェアハウスを一時的に上げる

まずは応急処置。一時的にサイズを上げて通すのが一番速いです。

-- セッション中だけ大きなウェアハウスに切り替える
ALTER WAREHOUSE my_wh SET WAREHOUSE_SIZE = 'LARGE';

-- 重いクエリを実行
SELECT ... ;

-- 元に戻す
ALTER WAREHOUSE my_wh SET WAREHOUSE_SIZE = 'SMALL';

サイズを1段階上げるとメモリは概ね2倍になります。コストも比例して上がりますが、AUTO_SUSPENDを短めにしておけば実害は限定的です。なお、AUTO_SUSPEND直後の最初のクエリだけ妙にレイテンシが乗ることがあるので、ベンチを取る時はそこに注意してください。

パターンB:クエリを書き直してメモリ使用量を減らす

本質的にはこちらが正解です。よくやる手は、フィルタを先に効かせて中間結果を小さくすること。

-- BAD: JOIN後にWHEREでフィルタ
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE c.region = 'JP'
  AND o.order_date >= '2024-01-01';

-- GOOD: サブクエリで先に絞ってからJOIN
SELECT *
FROM (SELECT * FROM orders WHERE order_date >= '2024-01-01') o
JOIN (SELECT * FROM customers WHERE region = 'JP') c
  ON o.customer_id = c.customer_id;

オプティマイザが賢いとは言え、明示的に絞ってあげた方が安定します。それから、SELECT * をやめて必要なカラムだけにするだけでもスピル量がだいぶ減ります。Snowflakeは列指向なので、ここの効きは想像以上です。

パターンC:DISTINCT / GROUP BY / ウィンドウを見直す

SELECT DISTINCT を雑に乗せているクエリは、ほぼ全てメモリを食う方向に作用します。本当に重複排除が必要なのか、JOIN前に QUALIFY ROW_NUMBER() で代替できないかを検討してください。

-- 最新1件だけ欲しい時はQUALIFYが省メモリ
SELECT *
FROM events
QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) = 1;

パターンD:タイムアウトと合わせて調整

メモリ不足の前にタイムアウトで殺されるケースもあります。タイムアウト周りは Snowflake「Statement reached its statement timeout」エラーの原因とタイムアウト調整方法 に詳しくまとめてあるので、合わせて見ておくと切り分けが楽になります。

再発防止のための運用Tips

その場しのぎで終わらせず、次に同じエラーを出さない仕組みを作っておくと安心です。

まず、用途別にウェアハウスを分けます。BI参照用とETL用とアドホック分析用が同じウェアハウスを使っていると、一人の重いクエリで全員が巻き込まれます。命名の付け方は Snowflake命名規則ベストプラクティス|ウェアハウス・ユーザー・ロール にまとめた考え方が使えます。

次に、定期的に ACCOUNT_USAGE.QUERY_HISTORY を眺めて、BYTES_SPILLED_TO_REMOTE_STORAGE が大きいクエリTOP10を洗い出す習慣を付けると、エラーが出る前に異常を捕まえられます。監査ログの読み方は Snowflake監査ログ入門|Account UsageとInformation Schemaの違いをやさしく解説 を参考にしてください。

-- 直近7日でリモートスピルが多いクエリTOP20
SELECT
  query_id,
  user_name,
  warehouse_name,
  warehouse_size,
  bytes_spilled_to_remote_storage / 1024 / 1024 / 1024 AS gb_spilled_remote,
  total_elapsed_time / 1000 AS elapsed_sec,
  LEFT(query_text, 200) AS query_text
FROM snowflake.account_usage.query_history
WHERE start_time >= DATEADD(day, -7, CURRENT_TIMESTAMP())
  AND bytes_spilled_to_remote_storage > 0
ORDER BY bytes_spilled_to_remote_storage DESC
LIMIT 20;

あとは、STATEMENT_TIMEOUT_IN_SECONDSSTATEMENT_QUEUED_TIMEOUT_IN_SECONDS を妥当な値に設定しておくこと。暴走クエリで全クレジットを溶かす事故を防げます。

関連エラー:「Query was aborted due to memory exhaustion」との違い

同じくメモリ不足を示すエラーに「Query was aborted due to memory exhaustion」があります。「Memory limit exceeded」が個別クエリの使用メモリが上限を超えたことを示すのに対し、「memory exhaustion」はウェアハウス全体のメモリが枯渇してクエリが中断された状態を表します。前者は単一の重たいクエリが原因のことが多く、後者は同時実行クエリが多すぎることが原因のことが多いです。

切り分けの目安としては、同じクエリを1人で実行して再現するなら Memory limit exceeded 系(クエリの設計や JOIN・並び替えのコストが過剰)、本番ピーク時にだけ発生する・複数ユーザーが同時に動かしている場合は memory exhaustion 系(同時実行制御やウェアハウスサイズの問題) の可能性が高いです。対処の打ち手としては、本記事の「解決方法:パターン別に最短で直す」のセクションがそのまま両エラーに通用します。マルチクラスタウェアハウス化や同時実行クエリ数の調整は memory exhaustion 系で特に効きます。

まとめ

Memory limit exceeded は「Snowflakeが壊れた」のではなく、ほぼ確実にクエリかウェアハウスサイズのどちらかに無理があるサインです。まずはQuery Profileでスピル量を確認、次にSQLを絞り込めるか検討、それでもダメなら一段階だけサイズを上げる。この順番でやれば、コストを跳ね上げずに復旧できることが多いはずです。

慣れてくると、エラーが出る前に「あ、これスピル出るやつだな」と書きながら気付けるようになります。そこまで来たら勝ちと覚えておいて損はありません。

参考リンク

関連記事

この記事をシェアする𝕏B!FacebookLINEPocket