12k
All articles

ORM が誤ったツールとなるとき

ORMがボトルネックになったら、N+1、ウィンドウ関数、CTE、大量更新、そして安全なパラメータ化で生SQLを使うべきです。

OpenReplay Team
OpenReplay Team
ORM が誤ったツールとなるとき

ORM は CRUD におけるデフォルトの正解であり、クエリがオブジェクトアクセスではなくレポートのように見え始めた瞬間から、誤ったツールになります。

その転換点はおそらく心当たりがあるでしょう。ステージングでは問題なかった一覧エンドポイントが本番で 4 秒かかり、クエリログには誰も手書きしていない、ほぼ同一の SELECT が並んでいる。ウィンドウ関数、CTE、複数 JOIN の集計、ベンダー固有の演算子は、まさに ORM が生成する SQL が非効率になるか、そもそも表現不可能になる領域であり、raw SQL に降りることが報われる領域です。本記事はその線引きを正確に行います。オブジェクト関係マッピングが正しいデフォルトである場所、それが静かにボトルネックへ変わる場所、そしてインジェクション安全性を手放さずにその先へ手を伸ばす方法です。

重要なポイント

  • ORM は、単純な CRUD の約 80% においてデフォルトの正解です。ボイラープレートを削減し、入力を自動的にパラメータ化し、データベース非依存を保ちます。
  • raw SQL は本質的に ORM より高速なわけではありません。ORM が生成するクエリがボトルネックとなる場合、すなわちホットパス、バルク操作、N+1 パターンにおいて具体的に優位となります。
  • N+1 問題は、ORM が気付かれないうちに誤ったツールとなる最も一般的な経路です。まずは eager loading で解決し、eager loading 後の形状ですら適切でない場合にのみ raw SQL に手を伸ばしてください。
  • ORM から離れることは、ORM を取り除くことではありません。ORM 自身のエスケープハッチを通じて raw SQL に降りましょう。Django の connection.cursor()Manager.raw()、SQLAlchemy の text()、Prisma の TypedSQL です。
  • raw SQL を書くときは、インジェクション安全性を自ら引き受けることになります。ユーザー入力は必ずプレースホルダ(psycopg では %s、Postgres/SQLx では $1)経由で渡し、クエリ文字列に連結しないでください。

raw SQL、クエリビルダ、ORM:抽象化のスペクトラム

「ORM か raw SQL か」は、そもそも二者択一ではありませんでした。データアクセスは完全な制御から完全な利便性までのスペクトラムであり、多くの比較記事が飛ばしてしまう中間層が存在します。一方の端では、raw SQL が変換レイヤーなしにデータベースのネイティブ言語を提供します。もう一方の端では、Django ORM、ActiveRecord、Hibernate、Prisma、Sequelize、SQLAlchemy といった ORM が行をオブジェクトにマッピングし、SQL を生成してくれます。その間にクエリビルダが位置します。

クエリビルダは、生成する SQL に近い位置を保ちながら、クエリパターンをチェイン可能なメソッドとして形式化します。ほとんどの ORM は、データベースに raw な文字列を渡す手段も公開していますが、これは通常のクエリメソッドが行ってくれるエスケープを外し、SQL インジェクション への扉を再び開いてしまいます。ビルダは別種のツールです。オブジェクトアクセスのふりをせずに、プログラム的に SQL を組み立てます。Knex は活発にメンテナンスされている JavaScript のクエリビルダで、チェンジログ によれば 2026 年 6 月以降 3.3.0 系にあります。JVM では jOOQ が型安全な SQL DSL であり、現在 3.21 系で、Open Source Edition は JDK 21 を対象としています。いずれも ORM ではなく、どちらもパラメータ化を維持します。それこそが要点です。ORM の抽象化が足を引っ張るとき、手書き SQL の前に一段下がる先として、ビルダ層はしばしば適切な選択です。

ORM が誤ったツールとなるのはどんなときか

切り替えのシグナルは感覚ではなく、具体的なものです。次の 5 つのパターンのいずれかに当たったら、ORM の先へ手を伸ばしましょう。

  1. 分析系・レポート形状のクエリ。 ウィンドウ関数、再帰 CTE、GROUP BY ... HAVING によるロールアップ、複数 JOIN のレポートは、生成 SQL が非効率になるか、表現不可能になる領域です。ORM はオブジェクトアクセスに最適化されており、OLAP 形状の出力には向いていません。
  2. ホットパスとバルク操作。 トラフィックの多いエンドポイントや、バッチの UPDATE/INSERT では、行単位の save() 呼び出しと余分なラウンドトリップが積み上がります。単一の集合ベースのステートメントが、数百回の ORM 書き込みを置き換えます。
  3. N+1 クエリの罠。 詳細は後述しますが、最も一般的な ORM のパフォーマンス障害です。
  4. データベース固有の機能。 @>->> のような Postgres の JSONB 演算子tsvector/tsquery を用いた全文検索LATERAL JOIN、PostGIS の地理空間関数は、多くの ORM が完全あるいはイディオマティックに表現できない機能です。一部の ORM はヘルパー(Django の contrib.postgres)を提供しますが、カバー範囲は部分的です。
  5. 不透明な「マジック」な挙動。 ORM が発行する SQL を確認・チューニングできないとき、デバッグとパフォーマンス作業は当て推量になります。これはオブジェクト関係インピーダンスミスマッチが実コストとして表出したものであり、セキュリティ面の側面もあります。ほとんどの ORM が提供する raw クエリメソッドは、自身のエスケープ処理の外側に位置するため、そこに値を補間すると露出が生じます。

N+1 クエリ問題:名前を与え、修正する

N+1 問題は、ORM が静かに誤ったツールとなる最も一般的な経路です。遅延ロードが行ごとに 1 クエリを発行するため、100 件のリストが気付かないうちに 101 回のラウンドトリップになります。ループは無害に見えます。

# One query for authors, then one MORE per author for their books
for author in Author.objects.all():
    print(author.name, author.books.count())

解決策は eager loading であり、raw SQL ではありません。Django の select_relatedprefetch_related は、それらのラウンドトリップを JOIN または単一の IN クエリへ折り畳みます。

# Two queries total, regardless of author count
authors = Author.objects.prefetch_related("books")

まず eager loading で N+1 を修正し、eager loading 後の形状ですら適切でない場合にのみ raw SQL に手を伸ばしてください。たとえば、ORM なら追加のラウンドトリップとして表現してしまうような、著者ごとのウィンドウ集計が必要な場合です。非効率な ORM クエリは、コードの中で自ら名乗り出ることはほとんどありません。API レスポンスの遅延やページ読み込みの遅延として表面化します。OpenReplay のようなセッションリプレイツールは、セッションのタイムライン上に遅いネットワークリクエストを表示し、バックエンドのクエリに手当てが必要なエンドポイントを指し示します。つまり、クエリそのものではなく、症状の発生箇所です。より深いトレードオフについては、OpenReplay の SQL インジェクション防止ガイド を参照してください。

raw SQL を書くと何を手放すことになるのか

raw SQL を書くとき、あなたは ORM が静かに担ってくれていた仕事を引き受けます。インジェクション安全性です。ユーザー入力は必ずパラメータプレースホルダ経由で渡し、クエリ文字列に連結しないでください。Django の raw SQL クエリの実行 ガイドがその仕組みを説明しています。cursor.execute()%s プレースホルダと、値の別リストを受け取り、ドライバが渡す際に各値をエスケープするため、値がステートメントのテキストの一部になることはありません。

# Safe: %s is the psycopg/DB-API placeholder, not string formatting
from django.db import connection
with connection.cursor() as cursor:
    cursor.execute("SELECT * FROM book WHERE author = %s", [user_input])
    rows = cursor.fetchall()

プレースホルダはそのままにしておきましょう。SQL 文字列の中で %s をクォートで囲むと、その保護は失われます。Rust の SQLx はプレースホルダをデータベースから受け取るため、Postgres では $1、MySQL・MariaDB・SQLite では ? になります。インジェクション以外にも、ボイラープレートの増加、単一の SQL ダイアレクトへの強い結合、結果行をオブジェクトへ戻す手動マッピングを引き受けることになります。

ORM を手放すために、セーフティネットまで手放す必要はありません。コンパイル時チェックを行うツールはそれを維持します。SQLx(0.9)はアプリ実行前にスキーマに対してクエリを検証し、公式ドキュメント自身が ORM ではないと述べています。jOOQ(3.21)は JVM 上で同じことを行います。クエリビルダはその中間に位置します。「ORM か raw SQL か」は誤った二者択一です。本当の軸は、個々のクエリにどれだけの抽象化がふさわしいか、です。

ORM か raw SQL か:実務的な結論

単純な CRUD の約 80% には ORM を使い、それに値する特定のクエリについては ORM 自身のエスケープハッチを通じて raw SQL に降りましょう。ORM から離れることは、ORM を取り除くことではありません。Django は 3 つの経路を文書化しています。パラメータ化されたフラグメントを ORM クエリに差し込む RawSQL、raw クエリでありながらモデルインスタンスを返す Manager.raw()、そしてモデルレイヤーを完全にバイパスする connection.cursor() です。SQLAlchemy は text() を公開しています。Prisma は現在プレビュー機能である TypedSQL と、型なしアクセス用の $queryRaw を提供しています。

判断は短い表に集約されます。

状況選ぶべきもの
CRUD、フォーム、標準的なリレーションORM
ダイアレクト間の可搬性が重要ORM またはクエリビルダ
複数 JOIN のレポート、ウィンドウ関数、CTEraw SQL
ホットなエンドポイント、またはバルク書き込みraw SQL
一覧ビューでの N+1まず eager loading、必要なら raw SQL
ORM が表現できないベンダー機能raw SQL

raw SQL は書き換えではありません。生成 SQL がボトルネックになっている少数のクエリのための、狙いを定めたエスケープハッチです。ORM をデフォルトとして維持し、遅いエンドポイントをプロファイリングし、クエリプランがそれを正当化すると証明できた箇所にだけ、手書きのパラメータ化された SQL を差し込みましょう。それ以外の場所では行わないことです。

FAQ

raw SQL は実際に ORM より高速なのか?

本質的にはそうではありません。よく書かれた ORM クエリとよく書かれた raw クエリは同じクエリプランナに到達するため、raw SQL が自動的に高速になるわけではありません。raw SQL が優位となるのは、ORM が生成するクエリがボトルネックとなる場合に限られます。すなわち、余分なラウンドトリップ、N+1 パターン、範囲を限定しない広範な SELECT、あるいは集合ベースのステートメントが行単位の書き込みを置き換えるホットパスです。速度上の優位は、raw SQL 自体ではなく、質の悪い生成 SQL を修正することから生まれます。

クエリビルダと ORM の違いは何か?

クエリビルダは、生成する SQL に近い位置を保ちながら、チェイン可能なメソッドを通じてプログラム的に SQL を組み立てます。行をオブジェクトにマッピングはしません。ORM はデータベースの行を言語のオブジェクトにマッピングし、SQL を完全に隠蔽します。Knex は JavaScript 向けのクエリビルダ、jOOQ は JVM 向けの型安全な SQL DSL であり、いずれも ORM ではありません。どちらもパラメータ化を維持するため、インジェクション安全性を失うことなくオブジェクトマッピングの抽象化を手放せます。

アプリを SQL インジェクションに晒さずに raw SQL を書く方法は?

ユーザー由来のすべての値をパラメータプレースホルダ経由で渡し、入力をクエリ文字列に連結しないことです。psycopg を用いた Django ではプレースホルダは %s であり、データベースドライバがパラメータを自動的にエスケープします。Rust の SQLx はプレースホルダをデータベースから受け取るため、PostgreSQL では $1、MySQL・MariaDB・SQLite では ? になります。SQL 文字列内でプレースホルダをクォートで囲まないでください。SQLx のようなコンパイル時チェックを行うツールは、アプリ実行前にスキーマに対してクエリを検証し、さらなる安全レイヤーを追加します。

ORM を取り除かずに、ORM の中で raw SQL を使えるか?

はい。主要な ORM はいずれも、ORM をデフォルトとして維持したまま raw SQL を実行できるエスケープハッチを提供しています。Django は直接実行のための connection.cursor()、モデルインスタンスを返す Manager.raw()、ORM クエリ内にパラメータ化されたフラグメントを差し込む RawSQL を提供します。SQLAlchemy は text() を公開しています。Prisma はプレビュー機能として TypedSQL を、型なしアクセス用に $queryRaw を提供しています。標準的な CRUD には ORM を使い、生成 SQL がボトルネックとなる特定のクエリに限って ORM を通じて raw SQL に手を伸ばしましょう。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.