SQL Scale Lab
第一部 DB 基礎編 · 第 9 章 · 約 30 分

アプリケーションから使う

データベース接続の仕組み、プレースホルダによる SQL インジェクション対策、ORM が発行する SQL と N+1 問題の正体に迫ります。

この章で答える問い
  1. アプリと DB の間にあるものは何か
  2. ユーザーの入力を SQL に混ぜると何が起きるか
  3. DB のログに出ないのに遅い、はなぜ起きるか

接続の正体: サーバー上のスレッドと通信

自分の接続番号
期待: 1 つの数字。実行するたびに変わることがある
今つながっている全員
期待: 数行。Id 列が接続番号

第 0 章の SHOW PROCESSLIST で確認したように、結果の 1 行がデータベースへの「接続(コネクション)」1 本に対応し、サーバー側では接続ごとに 1 本のスレッドが割り当てられます。CONNECTION_ID() はその識別番号です。 クエリを実行するたびに接続番号が変わることがあるのは、この実行環境が複数の接続をプールして使い回しているためです。これをコネクションプールと呼びます。 新規接続の確立には TCP ハンドシェイクや認証などで数ミリ秒〜数十ミリ秒のオーバーヘッドがかかるため、実務の Web アプリケーションではリクエストごとに毎回接続をつなぎ直すのではなく、事前に確立した接続をプールから一時的に借り受けて再利用します。

// コネクションプールのイメージ(擬似コード)
pool = createPool(接続設定, 最大 10 本)
rows = pool.query("SELECT * FROM customers WHERE id = ?", [42])

「最大 10 本」は、そのアプリケーションの 1 プロセスが同時に保持できる接続数の上限です。 もし API サーバーが 20 台並列稼働していれば、データベースには最大で 200 本の同時接続が押し寄せることになります。 データベース側にも同時に受け入れ可能な接続上限数(max_connections、デフォルトは 151 など)が設定されており、これを超えると新規接続が拒否されてシステム障害となります。この同時接続の限界については第二部 10 章で詳しく検証します。

SQL インジェクション: ユーザー入力がクエリ構造を書き換える脆弱性

たとえば、顧客名での検索機能を「入力文字列をそのまま連結して SQL を組み立てる」形で実装してしまったケースを考えてみましょう。

// 危険な実装例: 文字列連結
sql = "SELECT * FROM customers WHERE name = '" + input + "'"

悪意あるユーザーが入力欄に ' OR '1'='1 と入力した場合、どのような SQL が構築されてしまうでしょうか。実行して何行返ってくるか確かめてみてください。

連結でできた SQL

結果は全 10 名の顧客情報がすべて返ってきます。 ユーザーが入力したシングルクォート ' によって本来の文字列リテラルが途中で閉じられ、後ろに続いた OR '1'='1' が SQL の条件式の一部として解釈されてしまったためです。'1'='1' は常に真となるため、WHERE 条件が形骸化して全行が出力されます。 もしこれがログイン認証処理であれば、パスワードを知らなくても他人のアカウントでログインできてしまいます。これがSQL インジェクションの原理です。

もっと悪い入力
期待: 10 行。-- 以降はコメントになり、後ろに何が付いても無効化される

さらに悪質な例として、入力値に '; DELETE FROM orders; -- などを注入された場合、複数文の実行を許可する設定になっている環境では、第 5 章で学んだ「全件 DELETE」が外部から勝手に実行されてしまいます。

// 安全な実装例: プレースホルダ(バインド機構)
pool.query("SELECT * FROM customers WHERE name = ?", [input])

SQL 文の中で可変部分を ? などのプレースホルダにして値を分離して渡す手法(プリペアドステートメント / バインド機構)を採用すると、入力値は純粋な「データ値」としてのみ扱われ、SQL の構文構造そのものが書き換えられる危険性を完全に排除できます。 現代の ORM を使っていれば通常は自動的にプレースホルダが使用されますが、生 SQL を直接記述するメソッド(raw や query)で文字列結合を行うと、同様の脆弱性が生じてしまいます。

連結: 入力が文の一部になる"… WHERE name = '" + input + "'"入力' OR '1'='1SELECT * FROM customers WHERE name = '' OR '1'='1'← 入力が文の構造になった → 10 行(全員)プレースホルダ: 入力は値のまま届くSELECT * FROM customers WHERE name = ?値(別便)' OR '1'='1DB文: … name = ?値: ' OR '1'='1? に値が収まる入力は ? の位置に値として収まる。文の形は変わらず、名前として比較される。
文字列連結では入力値が SQL 構文の一部として評価されてしまいますが、プレースホルダを使用すれば純粋なパラメータ値として安全に受け渡されます。

ORM が背後で発行している SQL を監視する

現代の多くの Web アプリケーションでは、ORM(Object-Relational Mapping: Prisma, TypeORM, ActiveRecord, Hibernate など)を利用して開発が進められます。 しかし、ORM は「開発者が SQL を手書きする手間を省いてくれる」ツールであって、データベースに対して「SQL を発行しなくて済む」わけではありません。 裏側でどのような SQL が何本発行されているのかを把握していなければ、パフォーマンスのトラブルシューティングは不可能です。

// ORM のコード例(擬似コード)
orders = Order.where(status: "paid").includes(:customer).order(ordered_at: :desc).limit(10)

この 1 行のメソッドチェーンの裏側で、データベースにはおおよそ以下のような SQL が発行されています。

ORM が組み立てる SQL(相当)
期待: 1 行(paid は 1 件)

どの ORM にも「発行された生 SQL をコンソールに出力する」ロギング設定が必ず備わっています。 開発環境では常にこの SQL ログを有効化しておき、「1 回の API リクエストや画面アクセスで、実際に何本の SQL が発行されているか」を日常的に観察する癖をつけることが極めて重要です。

N+1 問題: スロークエリログに出ないサイレントな性能低下

チケットの課題「顧客一覧画面に、各顧客の注文件数を表示する」を素朴なループ処理で実装したとします。

// N+1 が発生するコード例
customers = query("SELECT * FROM customers")            // 1 回(顧客 N 人を取得)
for c in customers:
    c.orderCount = query("SELECT COUNT(*) FROM orders WHERE customer_id = ?", [c.id])   // N 回
N 回のうちの 1 回
期待: 1 行。一瞬で返る

ループ内のクエリ 1 回あたりの所要時間はわずか 0.1 ミリ秒程度で、一瞬で完了します。 そのため、データベース側のスロークエリログには何ひとつ警告が出力されません。 しかし、顧客が 10 人いれば「1 + 10 = 11 回」、顧客が 1 万人に増えれば「1 + 10,000 = 10,001 回」もの通信往復(ネットワークラウンドトリップ)がアプリと DB 間で発生します。 1 回の往復レイテンシがわずか 1ms であっても、1 万回繰り返せばそれだけで 10 秒以上の待機時間が発生してしまいます。 問題なのは個々のクエリの重さではなく「クエリの発行回数」であり、これを N+1 問題 と呼びます。

1 回にまとめる(第 4 章の形)
期待: 10 行。何人いても 1 回
または、まとめて取ってアプリで配る
期待: 注文のある 8 人分の行。ORM の一括読み込みはこの形を発行する

1 回ずつ通信を往復させる場合と、1 回のクエリに集約した場合の通信フローを図で比較してみましょう。

N+1: 往復 11 回アプリDBSELECT * FROM customers10 行COUNT(*) … customer_id = 112… customer_id = 24… customer_id = 38… customer_id = 100⋮← 往復 11 回顧客が 1 万人なら 1 万 1 回。1 回 1ms でも約 10 秒。まとめて 1 回アプリDBLEFT JOIN … GROUP BY c.ididname注文数1佐藤 陽菜122鈴木 大輔4………10加藤 蓮0何人いても往復 1 回または WHERE customer_id IN (1, …, 10)GROUP BY customer_id でも 1 回
1回ごとのクエリが一瞬であっても、往復回数が増加すると通信待ち時間が支配的になります。適切に結合・集約すればデータ量が増えても 1 回の通信で完了します。

現代の ORM には関連データを一括取得する仕組み(Prisma の include、Rails の includes、GraphQL の DataLoader など)が用意されており、これらを使うと自動的に WHERE customer_id IN (…) 形式の 1 本のクエリにまとめられます。 N+1 問題に気づくための最も確実な方法は、アプリケーションの SQL ログを監視し、「同じ構造の SELECT クエリがズラズラと大量に連続出力されていないか」をチェックすることです。

第一部のまとめと、第二部(性能編)への架け橋

第一部を通じて、SQL の基本文法、テーブル結合、データ更新、トランザクションによる整合性の保護、インデックスの概念、そしてアプリケーション連携の基礎を習得しました。 数十行〜数百行の小規模データでは、クエリの書き方の良し悪しが実行速度として顕在化することはありませんでした。 しかし第二部では、同一のショップが急成長し、注文明細データが 3,200 万行 に達した本番規模の巨大データベースを相手にしていきます。

  • 第 2 章の WHERE / ORDER BY 列 → 第二部 1・4・6 章で、インデックスの成否を分ける設計対象になります。
  • 第 3 章の COUNT(*) → 第二部 0 章で、全件集計に数秒を要する現実を目の当たりにします。
  • 第 4 章の JOIN → 第二部 7 章で、読み出し順序の誤りによって 60 秒以上固まる障害を体験します。
  • 第 7 章のトランザクション → 第二部 9 章で、複数トランザクションの更新衝突による「ロック待ち・デッドロック」に直面します。
  • 本章の N+1 と接続数 → 第二部 10 章で、同時アクセス増加によるデータベースの飽和限界を実測します。

アプリケーションとデータベースの間には接続プールが存在し、入力値はプレースホルダで安全に渡し、ORM の発行クエリ数を常に監視して N+1 を防ぎましょう。

確認

DB の遅いクエリのログには何も出ないのに、画面が遅いのはなぜ起きるか。
1 本ずつは一瞬でも、一覧の行ごとに問い合わせると往復の回数が時間を支配するから。これが N+1 で、直すには JOIN や IN で 1 回にまとめる。
ユーザーの入力を文字列連結で SQL に混ぜると、何が起きるか。
入力が値ではなく文の一部として解釈され、条件や別の文に化ける。プレースホルダで値を別便で送れば、入力は値のまま届き、SQL の構造は変えられない。
ORM を使っているから SQL を見なくてよい、と言えないのはなぜか。
ORM は SQL を書かなくていい道具であって、SQL が送られないわけではないから。1 回の画面表示で何本出ているかは、SQL のログを見なければわからない。