じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Rustにしたのに遅い?─ N+1クエリ問題の発見と解決

はじめに

ある日、APIのレスポンスが突然5秒を超えた。コードを見直してもバグはない。SQLも正しく書けている。途方に暮れながらログを確認すると、1リクエストで300回以上もクエリが発行されていた。原因は、ループ内で著者情報を1件ずつ取得していたこと。これがN+1クエリ問題だ。

本記事では、RustとPostgreSQLで関連データをまとめて取得し、クエリの発行回数を減らす5つの方法を扱う。JOIN、IN句とHashMap、配列・JSON集約、DataLoaderを、取得するデータの関係や結果の組み立て方で比べる。

N+1クエリ問題とは

問題のあるコード

本記事では、RustのSQLクライアントライブラリ「sqlx」を使用します。sqlxは型安全なクエリとasync/awaitをネイティブにサポートするライブラリです。

// アンチパターン: N+1クエリ
async fn get_posts_with_authors(pool: &PgPool) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
    // 1回目のクエリ: 投稿一覧を取得
    let posts = sqlx::query_as!(Post, "SELECT * FROM posts LIMIT 100")
        .fetch_all(pool)
        .await?;

    let mut result = Vec::with_capacity(posts.len());

    for post in posts {
        // N回のクエリ: 各投稿の著者を個別に取得
        let author = sqlx::query_as!(
            User,
            "SELECT * FROM users WHERE user_id = $1",
            post.user_id
        )
        .fetch_one(pool)
        .await?;

        result.push(PostWithAuthor { post, author });
    }

    Ok(result)
}
// 100件の投稿を取得するのに 101回のクエリが発生

このコードは一見正しく見えますが、100件の投稿を取得するのに101回のクエリを発行しています。これが「N+1クエリ問題」の典型例です。「N+1」という名前は、N件のデータに対して1回(一覧取得)+ N回(個別取得)= N+1回のクエリが発生することに由来します。

ここで疑問が浮かぶ。なぜこのパターンを書いてしまうのか。私の経験では、ループ内でfetch_oneを呼ぶコードは「書きやすい」からだ。1件取得する関数がすでにあれば、それをループで回すのは自然な発想に思える。問題は、この「自然さ」がパフォーマンスの問題になることだ。便利すぎるAPIは、時として危険なパターンを誘発する。

では、具体的にどれくらい遅くなるのか。数字で見てみよう。

なぜ遅いのか

N+1クエリが遅い理由を定量的に理解しましょう。

1クエリあたりのオーバーヘッド内訳:

- RustからPostgreSQLライブラリへの呼び出し:0.01ms
- プロトコル処理:0.02ms
- ネットワーク往復(ローカル):0.1-0.5ms
- PostgreSQLクエリパース:0.05ms
- 実行計画生成:0.1-1ms
- 実行(インデックス使用時):0.1-0.5ms
- 結果のシリアライズ:0.02ms

合計:約0.5-2ms/クエリ

計算例

100件のN+1クエリ:
- 最良ケース: 101 × 0.5ms = 50ms
- 最悪ケース: 101 × 2ms = 200ms

JOINで1クエリ:
- 約1-5ms

差: 10-100倍

ネットワークレイテンシが大きい環境(クラウド、リモートDB)では、この差はより広がります。

ここまでの計算で、N+1の影響の大きさは理解できた。では、この問題の根本にあるものは何だろうか。

N+1問題の本質

1回で済むことを、何度もやっていないか。

N+1問題の本質は「ループ内のI/O」だと私は考えている。これは単なるSQLの問題ではなく、プログラミング一般に潜む構造的な課題だ。

// 問題のパターン
for item in collection {
    // 各アイテムごとにI/O(DB、ファイル、HTTP)
    let related = fetch_related(item.id).await?;
}

この問題は以下のような場面で発生します。

  1. 1対多の関連データ取得: 投稿とコメント、ユーザーと注文
  2. 多対多の関連データ取得: 投稿とタグ、ユーザーとロール
  3. ネストしたデータ構造: カテゴリ → 投稿 → コメント → ユーザー

問題の構造がわかったところで、解決策を見ていこう。RustとPostgreSQLの組み合わせでは、5つのアプローチがある。シンプルなものから順に紹介する。

解決策1: JOINで一括取得

最もシンプルな解決策は、JOINを使って1回のクエリで全てのデータを取得することです。

多対1の関連:投稿から著者を取得する

#[derive(Debug, sqlx::FromRow)]
struct PostWithAuthor {
    // 投稿の情報
    post_id: Uuid,
    title: String,
    content: String,
    post_created_at: DateTime<Utc>,
    // 著者の情報
    author_id: Uuid,
    author_name: String,
    author_email: String,
}

async fn get_posts_with_authors(pool: &PgPool) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
    sqlx::query_as!(
        PostWithAuthor,
        r#"
        SELECT
            p.post_id,
            p.title,
            p.content,
            p.created_at as post_created_at,
            u.user_id as author_id,
            u.name as author_name,
            u.email as author_email
        FROM posts p
        INNER JOIN users u ON p.user_id = u.user_id
        ORDER BY p.created_at DESC
        LIMIT 100
        "#
    )
    .fetch_all(pool)
    .await
}

JOINの種類と使い分け

この例で使い分けるのはINNER JOINとLEFT JOINです。INNER JOINは結合条件に一致する行を返します。LEFT JOINは、一致する右側の行がなくても左側の行を残し、右側の列をNULLにします。関連データがない投稿も返したいなら、LEFT JOINを選びます。

// LEFT JOIN: 著者がいない投稿も含める
async fn get_posts_with_optional_authors(pool: &PgPool) -> Result<Vec<PostWithOptionalAuthor>, sqlx::Error> {
    sqlx::query_as!(
        PostWithOptionalAuthor,
        r#"
        SELECT
            p.post_id,
            p.title,
            u.user_id as "author_id?",
            u.name as "author_name?"
        FROM posts p
        LEFT JOIN users u ON p.user_id = u.user_id
        ORDER BY p.created_at DESC
        "#
    )
    .fetch_all(pool)
    .await
}

この例は、複数の投稿が同じ著者を参照する多対1の関連だ。投稿から著者を取得するなら、JOINで投稿の行数を増やさずに結合できる。しかし、1対多の関連を取得しようとすると、行が膨張してしまう。投稿1件に対してタグが5つあれば、同じ投稿が5行に複製される。この問題を避けるには、別のアプローチが必要になる。

解決策2: IN句 + HashMap

1対多の関連を効率的に取得する場合、IN句とHashMapを組み合わせる方法が有効です。

use std::collections::HashMap;

async fn get_posts_with_tags(pool: &PgPool) -> Result<Vec<PostWithTags>, sqlx::Error> {
    // 1. 投稿を取得
    let posts = sqlx::query_as!(Post, "SELECT * FROM posts LIMIT 100")
        .fetch_all(pool)
        .await?;

    let post_ids: Vec<Uuid> = posts.iter().map(|p| p.post_id).collect();

    // 2. タグを一括取得(ANY配列演算子を使用)
    let tags: Vec<PostTagRow> = sqlx::query_as!(
        PostTagRow,
        r#"
        SELECT pt.post_id, t.tag_id, t.name
        FROM post_tags pt
        JOIN tags t USING (tag_id)
        WHERE pt.post_id = ANY($1)
        "#,
        &post_ids
    )
    .fetch_all(pool)
    .await?;

    // 3. HashMapでグループ化(O(n))
    let mut tag_map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
    for row in tags {
        tag_map
            .entry(row.post_id)
            .or_default()
            .push(Tag { tag_id: row.tag_id, name: row.name });
    }

    // 4. 結果を組み立て
    let result = posts
        .into_iter()
        .map(|post| {
            let tags = tag_map.remove(&post.post_id).unwrap_or_default();
            PostWithTags { post, tags }
        })
        .collect();

    Ok(result)
}
// **2回のクエリで完了(N+1 → 2)**

ANY vs IN の違い

-- IN句: リテラル値のリスト
SELECT * FROM posts WHERE post_id IN ('id1', 'id2', 'id3');

-- ANY: 配列パラメータ
SELECT * FROM posts WHERE post_id = ANY($1);  -- $1 は UUID[]

sqlxでは配列パラメータとしてANYを使う方が便利です。

パフォーマンス特性

IN/ANY句のパフォーマンス:

要素数     | 推奨アプローチ
-----------|------------------
< 100      | IN/ANY で問題なし
100-1000   | IN/ANY + インデックス確認
1000+      | 一時テーブル or UNNEST

大量のIDがある場合の対処法です。

async fn get_posts_with_many_ids(pool: &PgPool, ids: &[Uuid]) -> Result<Vec<Post>, sqlx::Error> {
    // 大量のIDはUNNESTでJOIN
    sqlx::query_as!(
        Post,
        r#"
        SELECT p.*
        FROM unnest($1::uuid[]) WITH ORDINALITY AS t(id, ord)
        JOIN posts p ON p.post_id = t.id
        ORDER BY t.ord
        "#,
        ids
    )
    .fetch_all(pool)
    .await
}

IN句+HashMapは2回のクエリで済み、行の膨張も起きない。ただ、Rustでの組み立て処理が必要になる。もし1回のクエリで完結させたいなら、PostgreSQLの配列集約機能が使える。

解決策3: PostgreSQL配列集約

PostgreSQLのarray_aggを使うと、1回のクエリで1対多の関連をネストした形で取得できます。

#[derive(Debug, sqlx::FromRow)]
struct PostWithTags {
    post_id: Uuid,
    title: String,
    content: String,
    tags: Vec<String>,
}

async fn get_posts_with_tags_aggregated(pool: &PgPool) -> Result<Vec<PostWithTags>, sqlx::Error> {
    sqlx::query_as!(
        PostWithTags,
        r#"
        SELECT
            p.post_id,
            p.title,
            p.content,
            COALESCE(
                array_agg(t.name) FILTER (WHERE t.name IS NOT NULL),
                '{}'
            ) as "tags!: Vec<String>"
        FROM posts p
        LEFT JOIN post_tags pt USING (post_id)
        LEFT JOIN tags t USING (tag_id)
        GROUP BY p.post_id
        ORDER BY p.created_at DESC
        LIMIT 100
        "#
    )
    .fetch_all(pool)
    .await
}
// **1回のクエリで完了**

array_aggの注意点

  1. NULL処理: FILTER (WHERE ... IS NOT NULL) でNULLを除外する
  2. 空配列: COALESCE(..., '{}') で関連がない時は空配列を返す
  3. 重複: 必要に応じて array_agg(DISTINCT ...) を使用する

複数の配列を同時に集約

async fn get_posts_with_tags_and_categories(pool: &PgPool) -> Result<Vec<PostWithTagsAndCategories>, sqlx::Error> {
    sqlx::query_as!(
        PostWithTagsAndCategories,
        r#"
        SELECT
            p.post_id,
            p.title,
            COALESCE(
                array_agg(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL),
                '{}'
            ) as "tags!: Vec<String>",
            COALESCE(
                array_agg(DISTINCT c.name) FILTER (WHERE c.name IS NOT NULL),
                '{}'
            ) as "categories!: Vec<String>"
        FROM posts p
        LEFT JOIN post_tags pt USING (post_id)
        LEFT JOIN tags t USING (tag_id)
        LEFT JOIN post_categories pc USING (post_id)
        LEFT JOIN categories c USING (category_id)
        GROUP BY p.post_id
        "#
    )
    .fetch_all(pool)
    .await
}

array_aggは単純な値の配列には便利だ。タグ名やカテゴリ名のようなString型の配列なら、これで十分。しかし、コメントのように複数のフィールドを持つオブジェクトを集約したい時はどうだろうか。そこで登場するのがjson_aggだ。

解決策4: json_aggによる複雑なネスト

array_aggは複合型も集約できます。ただ、APIへ返すJSONの構造をSQLで組み立てたい場合は、json_aggでオブジェクトを配列にまとめる方法が便利です。

use serde::Deserialize;
use sqlx::types::Json;

#[derive(Debug, Deserialize)]
struct CommentJson {
    comment_id: Uuid,
    body: String,
    created_at: DateTime<Utc>,
}

#[derive(Debug, sqlx::FromRow)]
struct PostWithComments {
    post_id: Uuid,
    title: String,
    comments: Json<Vec<CommentJson>>,
}

async fn get_posts_with_comments(pool: &PgPool) -> Result<Vec<PostWithComments>, sqlx::Error> {
    sqlx::query_as!(
        PostWithComments,
        r#"
        SELECT
            p.post_id,
            p.title,
            COALESCE(
                json_agg(
                    json_build_object(
                        'comment_id', c.comment_id,
                        'body', c.body,
                        'created_at', c.created_at
                    )
                    ORDER BY c.created_at DESC
                ) FILTER (WHERE c.comment_id IS NOT NULL),
                '[]'
            ) as "comments!: Json<Vec<CommentJson>>"
        FROM posts p
        LEFT JOIN comments c USING (post_id)
        GROUP BY p.post_id
        ORDER BY p.created_at DESC
        LIMIT 100
        "#
    )
    .fetch_all(pool)
    .await
}

jsonb_agg vs json_agg

関数 特徴
json_agg 結果の型はjson
jsonb_agg 結果の型はjsonb。JSONBの演算で扱える

どちらも配列の要素順が必要なら、集約関数の中にORDER BYを書きます。PostgreSQLの説明のとおり、指定しなければ入力順は保証されません。JSONオブジェクトのキーの扱いと、配列に並ぶ要素の順番は別の話です。

深いネスト構造

async fn get_posts_full_detail(pool: &PgPool) -> Result<Vec<PostFullDetail>, sqlx::Error> {
    sqlx::query_as!(
        PostFullDetail,
        r#"
        SELECT
            p.post_id,
            p.title,
            json_build_object(
                'user_id', u.user_id,
                'name', u.name
            ) as "author!: Json<AuthorJson>",
            COALESCE(
                json_agg(
                    json_build_object(
                        'comment_id', c.comment_id,
                        'body', c.body,
                        'commenter', json_build_object(
                            'user_id', cu.user_id,
                            'name', cu.name
                        )
                    )
                    ORDER BY c.created_at DESC
                ) FILTER (WHERE c.comment_id IS NOT NULL),
                '[]'
            ) as "comments!: Json<Vec<CommentWithCommenterJson>>"
        FROM posts p
        JOIN users u ON p.user_id = u.user_id
        LEFT JOIN comments c USING (post_id)
        LEFT JOIN users cu ON c.user_id = cu.user_id
        GROUP BY p.post_id, u.user_id
        LIMIT 100
        "#
    )
    .fetch_all(pool)
    .await
}

ここまでの解決策はすべて、取得するデータが事前にわかっている場合に有効だ。SQLを書く時点で、どのテーブルをJOINするか、何を集約するかが決まっている。しかし、GraphQLのように「リクエストごとに取得対象が動的に変わる」時はどうだろうか。そこで登場するのがDataLoaderパターンだ。

解決策5: DataLoaderパターン

GraphQLなどで使われるDataLoaderパターンは、個別の取得要求をまとめ、バッチ処理とキャッシュでアクセス回数を減らす仕組みです。以下はそのうち、一括取得とキャッシュを示す簡略版です。load()を順番に呼んでも、自動では一つのクエリにまとまりません。呼び出し側でIDを集め、load_many()へ渡す必要があります。

use std::collections::HashMap;
use tokio::sync::Mutex;

pub struct UserLoader {
    pool: PgPool,
    cache: Mutex<HashMap<Uuid, User>>,
}

impl UserLoader {
    pub fn new(pool: PgPool) -> Self {
        Self {
            pool,
            cache: Mutex::new(HashMap::new()),
        }
    }

    /// 複数のユーザーIDを一括でロード
    pub async fn load_many(&self, ids: &[Uuid]) -> Result<HashMap<Uuid, User>, sqlx::Error> {
        let mut cache = self.cache.lock().await;

        // キャッシュにないIDを特定
        let missing: Vec<Uuid> = ids
            .iter()
            .filter(|id| !cache.contains_key(id))
            .copied()
            .collect();

        if !missing.is_empty() {
            // 一括でDBから取得
            let users: Vec<User> = sqlx::query_as!(
                User,
                "SELECT * FROM users WHERE user_id = ANY($1)",
                &missing
            )
            .fetch_all(&self.pool)
            .await?;

            // キャッシュに追加
            for user in users {
                cache.insert(user.user_id, user);
            }
        }

        // 結果を構築
        let result = ids
            .iter()
            .filter_map(|id| cache.get(id).cloned().map(|u| (*id, u)))
            .collect();

        Ok(result)
    }

    /// 単一のユーザーをロード(内部的にはバッチ処理可能)
    pub async fn load(&self, id: Uuid) -> Result<Option<User>, sqlx::Error> {
        let map = self.load_many(&[id]).await?;
        Ok(map.into_values().next())
    }

    /// リクエスト終了時にキャッシュをクリア
    pub async fn clear(&self) {
        self.cache.lock().await.clear();
    }
}

DataLoaderの使用例

async fn get_posts_with_authors_dataloader(
    pool: &PgPool,
    loader: &UserLoader,
) -> Result<Vec<PostWithAuthor>, anyhow::Error> {
    let posts = sqlx::query_as!(Post, "SELECT * FROM posts LIMIT 100")
        .fetch_all(pool)
        .await?;

    // 全ユーザーIDを収集
    let user_ids: Vec<Uuid> = posts.iter().map(|p| p.user_id).collect();

    // 一括でロード
    let users = loader.load_many(&user_ids).await?;

    // 結果を組み立て
    let result = posts
        .into_iter()
        .filter_map(|post| {
            // ユーザーが見つからない投稿はスキップ
            // または、Option<User>としてPostWithAuthorを定義する
            users.get(&post.user_id).cloned().map(|author| {
                PostWithAuthor { post, author }
            })
        })
        .collect();

    Ok(result)
}

個別の要求を自動でまとめる実装は、async-graphqlのDataLoaderを参照してください。また、DataLoaderの説明では、キャッシュをリクエスト単位で使うことを勧めています。この例のLoaderも複数ユーザー間で共有せず、リクエストごとに作る前提です。

5つの解決策を見てきた。では、どれを選べばいいのか。それぞれの特徴を整理してみよう。

解決策の比較

方法 クエリ数 複雑さ 適用場面
JOIN 1 低 1対1、多対1、少量の1対多
IN句 + HashMap 2 中 1対多、多対多
array_agg 1 中 単純な値の1対多
json_agg 1 高 複雑なネスト構造
DataLoader 2+ 高 GraphQL、動的なデータ取得

ここで一見すると「シンプルなJOINが最善」と思えるだろう。しかし、そう単純な話ではない。JOINは1対多で行が膨張し、json_aggは可読性を犠牲にする。シンプルさとパフォーマンスは常にトレードオフの関係にある。私の結論は、「まずJOINを試し、問題が出たらIN句+HashMapに移行する」という段階的アプローチだ。最初から複雑な解決策に飛びつく必要はない。

選択の判断フローチャート

解決策がわかっても、そもそもN+1が発生していることに気づかなければ意味がない。コードレビューで確認すべき項目をまとめておこう。

N+1検出チェックリスト

コードレビューでは、ループ内のquery_as!やquery!、DBアクセスを待つ.awaitを手掛かりにします。そのうえで、取得件数が増えるとクエリ数も増えるかを確認します。回数を一律に1〜2回へ抑えるより、対象件数、転送量、実行時間を合わせて見て、一括取得の範囲を決めます。

まとめ

Rustで書いたAPIでも、ループ内で1件ずつDBへ問い合わせれば、取得件数に応じてネットワークの往復が増える。今回見直したのは、この問い合わせ回数だ。

N+1クエリ問題は、気づかないうちにパフォーマンスを劣化させる典型的なアンチパターンです。解決の基本原則は以下の3つです。

  1. ループ内で件数に比例してI/Oが増えていないか確認する
  2. 必要なデータを、適切な範囲で一括取得する
  3. 開発時にクエリ数を監視する

「N+1を気にしすぎるとコードが複雑になる」という批判はあるだろう。確かにその通りだ。しかし、私の経験では、N+1問題は本番環境で突然顕在化することが多い。開発環境では10件のデータで問題なく動いていたコードが、本番で1000件になると破綻する。複雑さのコストより、本番障害のコストの方が高い。だからこそ、複数の解決策を知っておくことに価値がある。

RustとPostgreSQLの組み合わせでは、JOIN、IN句、array_agg、json_agg、DataLoaderなど複数の解決策があり、状況に応じて適切な方法を選択できます。

実測パフォーマンス比較

実際にRust + sqlx + PostgreSQLで計測した結果を示します。

方法 クエリ数 実行時間 改善率
N+1パターン(アンチパターン) 51回 27.95ms -
JOIN 1回 1.51ms 18.5倍高速
IN句 + HashMap 2回 1.71ms 16.3倍高速
DataLoader(初回) 2回 1.61ms 17.4倍高速
DataLoader(キャッシュヒット) 0回 0.013ms 2,150倍高速

計測条件:

  • PostgreSQL 17 / Docker環境
  • 50件の記事、10人の著者
  • ローカル接続(ネットワークレイテンシ最小)

リモートDBやクラウド環境ではネットワークレイテンシが加算されるため、N+1の影響はより大きくなります。

問題の深刻さがわかったところで、どうやって検出すればいいのか。開発から本番まで、各段階での対策を整理しておこう。

検出のまとめ

N+1問題の検出には複数のレイヤーで対策を講じるべきです。

段階 手法 特徴
開発時 クエリカウンター、トレーシング 即座にフィードバック
テスト時 assert_max_queries、統合テスト CI/CDで自動検出
コードレビュー チェックリスト、静的解析 ループ内awaitを検出
本番環境 pg_stat_statements、OpenTelemetry 実際の影響を測定

特に重要なのは、開発初期段階での検出です。本番環境で発見されたN+1問題は、すでにユーザー体験に影響を与えており、修正にも時間がかかります。

とはいえ、すべてのN+1を事前に防げるかと言われると、正直なところ難しい。新しいチームメンバーが入ってきたり、時間に追われたリリースがあったりすれば、どこかでN+1パターンが紛れ込む。完璧を目指すより、検出と修正のサイクルを回せる体制を作る方が現実的だと私は考えている。

数えてみろ。数えれば見える。

冒頭で触れた300回クエリの問題は、IN句+HashMapパターンで2回のクエリに削減でき、レスポンスは5秒から200msに改善した。次のコードレビューで、ループ内の.awaitを確認してみてください。

参考資料

syu-m-5151.hatenablog.com

sqlx / Rust

PostgreSQL

パターン

観測性