Oracle Database + GoでN+1問題を100倍高速化した話とキャッシュ戦略の選び方

に公開

Oracle Database + GoでN+1問題を100倍高速化した話とキャッシュ戦略の選び方

はじめに

業務システム開発でよく遭遇するN+1問題について、Oracle DatabaseとGoを使って実際に検証してみました。適切な対策により最大102倍の高速化を達成できることがわかりました。

さらに、Redis外部キャッシュとOracle内蔵キャッシュの性能比較も行い、実際の業務でどちらを選ぶべきかの判断基準も明確になりました。

この記事では、実測値を交えながらN+1問題の解決策とキャッシュ戦略について詳しく解説します。

N+1問題とは何か

N+1問題は、データベースアクセスで発生する代表的なパフォーマンス問題です。親データを1回のクエリで取得した後、各親データに対して子データを個別に取得することで、結果的に「1+N回」のクエリが実行されてしまいます。

問題のあるコード例

// ❌ N+1問題が発生するコード
func GetOrdersWithDetails(days int) ([]OrderWithDetails, error) {
    // 1. 受注一覧を取得(1回のクエリ)
    orders, err := GetOrdersByDays(days)
    if err != nil {
        return nil, err
    }

    var result []OrderWithDetails
    // 2. 各受注ごとに明細を取得(N回のクエリ)
    for _, order := range orders {
        details, err := GetDetailsByOrderID(order.OrderID)  // ここでN+1問題発生
        if err != nil {
            return nil, err
        }
        result = append(result, OrderWithDetails{
            Order:   order,
            Details: details,
        })
    }
    return result, nil
}

実測で見るN+1問題のインパクト

実際にOracle Databaseを使って検証した結果、N+1問題の影響は想像以上に深刻でした。

小規模データでの検証結果(受注83件)

アプローチ 実行時間 SQL実行回数 改善率
N+1問題あり 32.16ms 84回 基準
JOIN最適化 2.97ms 1回 10.8倍高速
バッチ取得 4.94ms 2回 6.5倍高速

中規模データでの検証結果(社員1,010件)

アプローチ 実行時間 SQL実行回数 改善率
N+1問題あり 275.27ms 1,011回 基準
JOIN最適化 3.24ms 1回 85.0倍高速
バッチ取得 2.68ms 2回 102.6倍高速

データ量と改善効果の関係

興味深いことに、データ量が増加するほど改善効果が顕著になることがわかりました。

  • データ量が12倍増えると、N+1問題の実行時間は約8.6倍に増加
  • 小規模(83件)では10〜12倍の改善
  • 中規模(1,010件)では85〜103倍の改善

この結果から、N+1問題は単なる「ちょっと遅い」レベルの問題ではなく、システムの拡張性を根本的に阻害する深刻な問題であることがわかります。

解決策1:JOINを使った一括取得

最も効果的な解決策は、JOINを使って一度のクエリで全データを取得することです。

// ✅ JOIN最適化されたコード
func GetOrdersWithDetailsJoin(days int) ([]OrderWithDetails, error) {
    query := `
        SELECT 
            o.order_id, o.customer_id, o.order_date, o.total_amount,
            od.detail_id, od.product_id, od.quantity, od.unit_price
        FROM orders o
        LEFT JOIN order_details od ON o.order_id = od.order_id
        WHERE o.order_date >= SYSDATE - :1
        ORDER BY o.order_id, od.detail_id`

    rows, err := db.Query(query, days)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    // メモリ上でグルーピング処理
    return groupOrdersWithDetails(rows), nil
}

メリット

  • SQL実行回数が1回に削減される
  • 最も高い改善効果が期待できる
  • データベース負荷が最小になる

デメリット

  • メモリ上でのグルーピング処理が必要
  • 複雑なJOINでは実装が困難な場合がある

解決策2:IN句を使ったバッチ取得

JOINが困難な場合の代替案として、IN句を使ったバッチ取得があります。

// ✅ バッチ取得を使ったコード
func GetOrdersWithDetailsBatch(days int) ([]OrderWithDetails, error) {
    // 1. 受注一覧を取得
    orders, err := GetOrdersByDays(days)
    if err != nil {
        return nil, err
    }

    // 2. 受注IDを抽出
    orderIDs := make([]int64, len(orders))
    for i, order := range orders {
        orderIDs[i] = order.OrderID
    }

    // 3. IN句で明細を一括取得
    allDetails, err := GetDetailsByOrderIDs(orderIDs)
    if err != nil {
        return nil, err
    }

    // 4. メモリ上でグルーピング
    return groupOrdersWithDetails(orders, allDetails), nil
}

メリット

  • 複雑なJOINを避けられる
  • 既存のコードを部分的に活用できる
  • 高い改善効果が期待できる

デメリット

  • SQL実行回数は2回になる
  • IN句の要素数制限に注意が必要

キャッシュ戦略の選択肢と実測比較

N+1問題の解決とは別に、キャッシュによる高速化も検討しました。Redis外部キャッシュとOracle内蔵キャッシュの性能を実測で比較した結果は以下の通りです。

キャッシュ性能比較結果

キャッシュ方式 平均実行時間 ヒット率 運用負荷
Redis外部キャッシュ 522.9µs 90.0%
Oracle Result Cache 852.2µs N/A
Oracle Buffer Cache 1.18ms 100.0% 最低
Oracle Function Cache 5.03ms N/A

Redis外部キャッシュの詳細分析

高速な理由

  • 初回実行時:2.64ms(DB + キャッシュ保存)
  • 2回目以降:305µs(キャッシュヒット)
  • 改善率:約9.3倍の高速化

導入時の課題

  • 追加のインフラが必要
  • データ整合性の管理が複雑
  • ネットワーク通信のオーバーヘッド
  • JSONシリアライゼーションのコスト
  • メモリの二重使用(Oracle + Redis)

Oracle内蔵キャッシュの詳細分析

実用的な理由

  • 追加インフラが不要
  • 自動的なキャッシュ無効化
  • データ整合性が保証される
  • 複数アプリケーションでの共有が可能
  • SGAでの統合メモリ管理

性能面の特徴

  • Result Cache:852.2µs(SQL結果のキャッシュ)
  • Buffer Cache:1.18ms(データブロックのキャッシュ)
  • 実行時間の変化でキャッシュ効果を判定可能

外部キャッシュの採用可否判断

実測結果を踏まえ、外部キャッシュ(Redis等)の採用可否を判断する基準をまとめました。

外部キャッシュが有効なケース

マイクロサービス間でのデータ共有

  • 複数のサービスで同一データを参照する場合
  • API Gateway経由での共通キャッシュが必要な場合

外部APIからの取得データ

  • サードパーティAPIの結果をキャッシュする場合
  • レート制限があるAPIの負荷軽減

計算集約的な結果

  • データベース以外の重い処理結果
  • 機械学習の推論結果など

Oracle内蔵キャッシュが推奨されるケース

一般的な業務アプリケーション

  • 社内システムでの通常のデータ参照
  • 単一データベースからのデータ取得

運用負荷を最小化したい場合

  • インフラ管理者が少ない組織
  • シンプルなアーキテクチャを維持したい場合

データ整合性が重要な場合

  • 金融系システムなど厳密性が求められる場合
  • リアルタイム性が重要な業務データ

判断フローチャート

データキャッシュが必要か?
├─ Yes → 複数のサービス・システムで共有するか?
│        ├─ Yes → 外部キャッシュ(Redis等)を検討
│        └─ No → データの種類は?
│                ├─ DB以外の重い処理結果 → 外部キャッシュ
│                └─ DBからの取得データ → Oracle内蔵キャッシュ
└─ No → キャッシュ不要

実践的な推奨事項

開発フェーズでの対策

設計段階

  • データアクセスパターンを明確にする
  • N+1問題が発生しやすい箇所を事前に特定する
  • 適切なインデックス設計を行う

実装段階

  • JOINによる一括取得を優先的に検討する
  • ORMを使用する場合は適切な設定でLazy Loadingを制御する
  • バッチサイズを適切に設定する

運用フェーズでの監視

重要な監視ポイント

  • SQLトレースによる実行クエリの監視
  • 実行計画でのインデックス使用状況確認
  • バッファキャッシュのヒット率監視

早期発見のための仕組み

  • 定期的なパフォーマンステストの実施
  • スロークエリログの分析
  • アプリケーションメトリクスの監視

コードレビューでのチェック項目

// ❌ 避けるべきパターン
for _, item := range items {
    relatedData := repository.GetByID(item.ID)  // ループ内でのDBアクセス
    // 処理...
}

// ✅ 推奨パターン
ids := extractIDs(items)
allRelatedData := repository.GetByIDs(ids)     // 一括取得
relationMap := createRelationMap(allRelatedData)
for _, item := range items {
    relatedData := relationMap[item.ID]
    // 処理...
}

まとめ

今回の検証により、以下のことが明確になりました。

N+1問題の深刻さ

  • 小規模データでも10倍以上の性能差が発生する
  • データ量の増加とともに影響が指数関数的に拡大する
  • 最大102倍の改善が可能である

効果的な解決策

  • JOINによる一括取得が最も効果的
  • バッチ取得も十分な改善効果がある
  • データベース設計時の事前対策が重要

キャッシュ戦略の判断基準

  • Oracle内蔵キャッシュが運用負荷と性能のバランスが良い
  • 外部キャッシュは特定の用途でのみ有効
  • 実行時間の変化でキャッシュ効果を十分に測定可能

実践的な提案

  • まずはN+1問題の根本的解決を優先する
  • キャッシュは補完的な位置づけで検討する
  • 定期的な監視と早期発見の仕組みを整備する

N+1問題は適切な対策により劇的な改善が可能です。特に、データ量が増加することが予想されるシステムでは、設計段階からの対策が極めて重要になります。

今回作成したデモアプリケーションはGitHubで公開していますので、実際のコードを参考にしながら自社システムでの対策を検討していただければと思います。

Discussion