アプリケーションを作成していて「一覧を開くと遅い」「件数が増えたら急に重くなった」など、
この手の問題でよくある原因の1つがN+1問題です。
N+1問題は、言い換えると 「データを取りに行く回数が多すぎる」 状態です。
アプリ側はいつも通り画面を表示しているつもりでも、裏ではSQLが大量に実行されていて、
結果として処理が遅くなってしまいます。
難しい分析をする前に、まずはSQLログで「同じようなSELECTが何回も出ていないか」を見るだけで、
かなり高い確率で当たりを付けられます。
N+1問題の正体と確認方法
N+1が起きる典型パターン
N+1問題は、次の流れで起きます。
- まず「親の一覧」を取るSQLが1回走る
- 親が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が大量に出る
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つにまとめて子を詰めます。
JOIN方式の注意点
JOIN方式は分かりやすい反面、注意点があります。
- 子が多いと返ってくる行数が増える(親10件でも子100件なら100行)
- 一覧でLIMIT/OFFSETを使うと、親単位のページングが崩れることがある
「一覧で必ず子が必要」「件数がそこまで大きくない」ならJOIN方式が向いています。
回避策② 2回クエリで一括取得して組み立てる
N+1を1+1に減らす
N+1を1 + 1 に減らす考え方です。
- 親一覧を取得(1回)
- 親IDを集める
- 子を
INで一括取得(1回) - 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が何回実行されているか」を確認してみましょう。


コメント