MyBatisのN+1問題を回避する|JOIN/一括取得

アプリケーションを作成していて「一覧を開くと遅い」「件数が増えたら急に重くなった」など、
この手の問題でよくある原因の1つがN+1問題です。

N+1問題は、言い換えると 「データを取りに行く回数が多すぎる」 状態です。

アプリ側はいつも通り画面を表示しているつもりでも、裏ではSQLが大量に実行されていて、
結果として処理が遅くなってしまいます。

難しい分析をする前に、まずはSQLログで「同じようなSELECTが何回も出ていないか」を見るだけで、
かなり高い確率で当たりを付けられます。

目次

N+1問題の正体と確認方法

N+1が起きる典型パターン

N+1問題は、次の流れで起きます。

  1. まず「親の一覧」を取るSQLが1回走る
  2. 親がN件あると、各親ごとに「子を取るSQL」がN回走る

たとえば、注文一覧(親)に対して、それぞれの注文明細(子)が3件紐づいているケースを考えます。

  • 注文一覧を取るSQL:1回
  • 注文明細を取るSQL:注文1件に対して3明細

注文一覧を1件取得する場合は、合計で 4回 SQLが実行されます。

注文が100件なら 注文一覧を取るSQLが100回、注文明細を取るSQLが300回 になります。これがN+1です。

何が困るのか(遅い/DB負荷/タイムアウト)

Nが増えれば増えるほど、DBへの問い合わせ回数が増えるので負荷が膨大になるります。

DBへの負荷が増えることで次のような問題が発生しやすくなるため、構造の見直しを検討しましょう。

・SQL実行回数が増え、単純に処理が遅くなりやすい(結果としてタイムアウトやスローダウン)
・ネットワーク往復、パース、実行計画などのコストが積みあがる

「N+1問題」のログの見分け方

N+1問題の見分けは、難しい分析よりもログの並びで簡単に分かることが多いです。

  • 一覧取得の直後に、同じようなSELECTが何十回も連続している
  • WHERE句の条件だけが変わっている(IDだけ差し替わっている)
  • 画面を開くたびに、子テーブルへのSELECTが大量に出る

ここまで見えたら、ほぼ「取得構造がN+1」だと思ってOKです。

MyBatisでN+1が起きやすい構造

ネストSELECTの仕組み

MyBatisは、関連を組み立てる方法が大きく分けて2つあります。

  • 親を取得した後に子を別SQLで取得する方式(ネストSELECT)
  • JOINして1回のResultMapで親子を組み立てる方式(ネスト結果)

N+1が起きやすいのは、前者の「別のselectを呼ぶ」方式で、典型例は次のようなMapperです。

<!-- 親:注文 -->
<select id="selectOrders" resultMap="orderMap">
  SELECT id, customer_id, ordered_at
  FROM orders
  WHERE customer_id = #{customerId}
</select>

<resultMap id="orderMap" type="com.example.domain.Order">
  <id property="id" column="id"/>
  <result property="customerId" column="customer_id"/>
  <result property="orderedAt" column="ordered_at"/>

  <!-- 子:注文明細(ここで別SQLを呼ぶとN+1になりやすい) -->
  <collection property="items"
              ofType="com.example.domain.OrderItem"
              column="id"
              select="selectItemsByOrderId"/>
</resultMap>

<select id="selectItemsByOrderId" resultType="com.example.domain.OrderItem">
  SELECT id, order_id, product_name, quantity
  FROM order_items
  WHERE order_id = #{orderId}
</select>

この場合、注文がN件返ると selectItemsByOrderId がNに紐づく子の回数呼ばれます。構造的にN+1です。

1対多が特に危ない理由

1対多のデータを複数件取得する処理は、一覧画面で行うことが多い処理です。
一覧画面だけとは限りませんが、親と子を取得する処理で親が複数になる場合は、
ネストSELECTを行うとN+1の状態になりパフォーマンスが劣化するケースが多いです。

遅延ロードが絡むと気づきにくいパターン

遅延ロード(Lazy Loading)を使っていると、画面を表示したタイミングではSQLが少なく見えて、
後からオブジェクトに触れた瞬間にSQLが大量に走ることがあります。

気づいたら遅くなりやすいため、遅延ロードの有無に関わらず、まずはログでSQL回数を見て把握するのが安全です。

回避策① JOIN + ResultMapで1回にまとめる

サンプル(注文1件+明細複数)で理解する

親と子をJOINして1回で取り、MyBatisのResultMapで親子に組み立てます。

<select id="selectOrdersWithItemsByCustomerId" resultMap="orderWithItemsMap">
  SELECT
    o.id           AS o_id,
    o.customer_id  AS o_customer_id,
    o.ordered_at   AS o_ordered_at,
    i.id           AS i_id,
    i.order_id     AS i_order_id,
    i.product_name AS i_product_name,
    i.quantity     AS i_quantity
  FROM orders o
  LEFT JOIN order_items i ON i.order_id = o.id
  WHERE o.customer_id = #{customerId}
  ORDER BY o.id, i.id
</select>

<resultMap id="orderWithItemsMap" type="com.example.domain.Order">
  <!-- 親の一意キーをidで指定するのが重要 -->
  <id property="id" column="o_id"/>
  <result property="customerId" column="o_customer_id"/>
  <result property="orderedAt" column="o_ordered_at"/>

  <collection property="items" ofType="com.example.domain.OrderItem">
    <id property="id" column="i_id"/>
    <result property="orderId" column="i_order_id"/>
    <result property="productName" column="i_product_name"/>
    <result property="quantity" column="i_quantity"/>
  </collection>
</resultMap>

この方式だと、SQLは基本 1回です。N+1の構造にはなりません。

JOINすると、親の情報が子の件数分だけ繰り返されます。MyBatisはこの繰り返しを見て、親を1つにまとめて子を詰めます。

そのため、親側の <id> が正しくないと「親が増える」「子が欠ける」などの事故が起きやすいです。JOIN方式では 親の<id>は必須と考えるのが安全です。

JOIN方式の注意点

JOIN方式は分かりやすい反面、注意点があります。

  • 子が多いと返ってくる行数が増える(親10件でも子100件なら100行)
  • 一覧でLIMIT/OFFSETを使うと、親単位のページングが崩れることがある

「一覧で必ず子が必要」「件数がそこまで大きくない」ならJOIN方式が向いています。

回避策② 2回クエリで一括取得して組み立てる

N+1を1+1に減らす

N+1を1 + 1 に減らす考え方です。

  1. 親一覧を取得(1回)
  2. 親IDを集める
  3. 子を IN で一括取得(1回)
  4. Java側で親IDごとに子を紐づける

JOINで行数が膨らみすぎる場合や、ページングと相性を取りたい場合に強いです。

MyBatisのforeachでIN句を作る

<select id="selectItemsByOrderIds" resultType="com.example.domain.OrderItem">
  SELECT id, order_id, product_name, quantity
  FROM order_items
  WHERE order_id IN
  <foreach item="id" collection="orderIds" open="(" separator="," close=")">
    #{id}
  </foreach>
  ORDER BY order_id, id
</select>

Service側で詰め替える最小例

public List<Order> fetchOrdersWithItems(Long customerId) {
    // 親を取る(1回)
    List<Order> orders = orderMapper.selectOrdersByCustomerId(customerId);

    // 親IDを集める
    List<Long> orderIds = orders.stream()
            .map(Order::getId)
            .toList();

    if (orderIds.isEmpty()) {
        return orders;
    }

    // 子を一括取得(1回)
    List<OrderItem> items = orderItemMapper.selectItemsByOrderIds(orderIds);

    // order_idごとにグルーピングして親へ詰め替える
    Map<Long, List<OrderItem>> itemsByOrderId = items.stream()
            .collect(Collectors.groupingBy(OrderItem::getOrderId));

    for (Order order : orders) {
        order.setItems(itemsByOrderId.getOrDefault(order.getId(), List.of()));
    }
    return orders;
}

この方式はSQLが2回になりますが、件数が増えてもSQL回数は増えないので負荷が安定しやすいです。

使い分けの結論と落とし穴

JOINと2回クエリ、どっちを選ぶべきか

迷ったら、次の軸で決めるとシンプルです。

  • 子の件数が少ない、一覧で必ず必要 → JOIN
  • 子の件数が多い、ページングが絡む、負荷を読みやすくしたい → 2回クエリ

よくある失敗(ネストSELECTの増殖/ページング破綻/過剰取得)

ありがちな失敗はこのあたりです。

  • とりあえず <collection select="..."> を増やして、いつの間にかN+1が常態化
  • JOIN方式でLIMIT/OFFSETして、親単位のページングが崩れる
  • なんでもJOINして過剰取得になり、逆に遅くなる

最低限の運用ルール

運用ルールはシンプルな方が回ります。

  • 一覧で子要素を表示する場合、ネストSELECTは原則使わない
  • JOINか2回クエリのどちらかに寄せる
  • SQLログで「一覧アクセス時のSQL回数」を一度は確認する

まとめ

N+1問題は、アプリのコードが複雑だから起きるのではなく、データの取り方の構造で発生します。

  • 親一覧のあとに、親の数だけ子取得が走るとN+1
  • MyBatisでは <collection> / <association>select がN+1の原因になりやすい
  • 回避策は JOINで1回にまとめるか、親と子を2回で一括取得して組み立てるのどちらかに寄せる

迷ったら、まずSQLログで回数を見て「SQLが何回実行されているか」を確認してみましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次