じゃあ、おうちで学べる

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

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

はじめに

Rustは速い。だが、Rustで書けば速くなるわけではない。

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

見えないものは、直せない。

本記事では、この見落とされがちなN+1クエリ問題の本質と、RustとPostgreSQLを使った5つの解決策を解説する。正直に言うと、どの解決策がベストかは状況による。だからこそ、複数のアプローチを知っておく価値があると私は考えている。

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対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の2つです。INNER JOINは両方のテーブルに存在する行のみを返し、関連データが必須の場合に使います。LEFT JOINは左テーブルの全行を返し、右テーブルは一致する行のみを返すため、関連データがオプションの場合に適しています。RIGHT JOINやFULL 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
}

JOINは1対1の関連には最適だ。しかし、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配列集約

PostgreSQLarray_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では単純な値しか集約できませんが、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 バイナリ格納、重複キー削除、インデックス可能

単純な集約にはjson_agg、後で検索や操作をする時はjsonb_aggを使います。

深いネスト構造

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パターンは、複数の個別リクエストを自動的にバッチ化する仕組みです。

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実装を参照してください。

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

解決策の比較

方法 クエリ数 複雑さ 適用場面
JOIN 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検出チェックリスト

コードレビューやPRレビューでは、まずループ内でquery_as!query!を呼んでいないかを確認してください。次に、for文の中に.awaitがあり、DBアクセスをしていないかをチェックします。最後に、APIレスポンスに必要なデータを1-2回のクエリで取得できているかを確認しましょう。

まとめ

Rustにしたのに遅い?—それはRustのせいではない。

N+1クエリ問題は、言語の速さを帳消しにする。どれだけRustが速くても、100回のネットワーク往復は100回のネットワーク往復だ。言語を変えても、アーキテクチャの問題は解決しない。

問題は、気づいた瞬間に半分解決している。

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

  1. ループ内でI/Oを行わない
  2. 必要なデータは一括で取得する
  3. 開発時にクエリ数を監視する

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

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

実測パフォーマンス比較

実際に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

パターン

観測性