は、一覧を1回取得したあと、各行の関連データを1件ずつ問い合わせてしまう構造です。10件なら11回、1000件なら1001回になります。
アプリケーション側の性能問題として最も頻出し、しかも開発中は気づけないという性質を持ちます。この回は、検出・修正・そして直さない判断を扱います。
1. なぜ生まれるのか
意図的に書く人はいません。そう書くのが自然だから生まれます。特に、関連データを属性のように扱えるライブラリを使っていると、問い合わせが発生していること自体が見えません。
# 注文一覧を取得(1回)orders = Order.objects.filter(status="paid")
for order in orders: # order.customer を参照した瞬間に、1件ずつ問い合わせが飛ぶ print(order.id, order.customer.name)
# 注文が1000件あれば、1 + 1000 回の問い合わせになる`order.customer` は属性アクセスの見た目をしていますが、実際にはその場でデータベースに問い合わせています。コードを読んでも回数が見えないため、レビューでも見落とされます。1回あたりは が効いていて数ミリ秒かもしれませんが、それが1000回積み上がれば数秒です。1回の速さと合計の遅さは別の話だという、EP.2 で扱った構造がそのまま現れます。
関連データを透過的に扱える仕組みは、生産性を大きく上げました。その代償が問い合わせ回数の不可視化です。便利さの裏でどれだけの通信が発生しているかは、明示的に測らないと分かりません。
2. 検出する
EP.2 で扱ったとおり、回数を数えるのが基本です。加えて、N+1 に特化した検出方法があります。同じ形のクエリが短時間に何度も実行されていないかを見ます。
import refrom collections import Counter
def normalize(sql: str) -> str: """値の違いを潰して、クエリの「形」だけを取り出す。""" s = re.sub(r"'[^']*'", "?", sql) # 文字列リテラル s = re.sub(r"\b\d+\b", "?", s) # 数値リテラル s = re.sub(r"\s+", " ", s).strip() return s
def detect_n_plus_one(queries, threshold=10): """1リクエスト内で同じ形が閾値以上繰り返されていたら報告する。""" counts = Counter(normalize(q) for q in queries) return [(sql, n) for sql, n in counts.most_common() if n >= threshold]
queries = ["SELECT * FROM orders WHERE status = 'paid'"] + [ f"SELECT * FROM customers WHERE id = {i}" for i in range(1, 1001)]
for sql, n in detect_n_plus_one(queries): print(f" {n} 回: {sql[:60]}")値だけが違う同じ形のクエリが大量に並んでいたら、ほぼ N+1 です。この検出は自動化できるので、開発環境やテストで警告を出す仕組みにしておくと、本番に出る前に気づけます。
3. 直す ── まとめて取る
基本の修正は、関連データを先にまとめて取っておくことです。1回目の問い合わせで必要な範囲を確定させ、2回目でまとめて引きます。合計2回で済みます。
# 1回目: 注文を取得orders = list(Order.objects.filter(status="paid"))
# 2回目: 必要な顧客をまとめて取得(IN句で1回)customer_ids = {o.customer_id for o in orders}customers = { c.id: c for c in Customer.objects.filter(id__in=customer_ids)}
for order in orders: customer = customers[order.customer_id] print(order.id, customer.name)多くのライブラリには、これを1行で指示する機能があります(先読み、事前読み込みなどと呼ばれます)。手で書き直す前に、その機能があるか確認してください。
| 方式 | 問い合わせ回数 | 注意点 |
|---|---|---|
| 1件ずつ(N+1) | 1 + N 回 | 件数に比例して破綻する |
| まとめて取る | 2 回 | IN句が長くなりすぎないよう分割が要る場合がある |
| 結合して1回で取る | 1 回 | 行が重複し、転送量が増えることがある |
3行目の「結合して1回」は最少回数ですが、常に最適とは限りません。1対多の結合では親の情報が子の件数だけ繰り返されます。親のデータが大きい場合、転送量はかえって増えます。
4. まとめて取ることの副作用
N+1 を直すと、一度に扱うデータ量が増えます。これが新しい問題になることがあります。
- メモリの使用量が跳ねる — 1000件分を同時に保持することになる
- IN句が長くなりすぎる — データベース側に上限がある場合、分割が要る
- 使わないデータまで取る — 実際に参照されるのが一部なら、無駄が増える
- 遅延評価が効かなくなる — 途中で打ち切る処理では、先読みが無駄になる
1番目は現実的な問題です。件数に上限がない一覧で先読みすると、N+1 の代わりにメモリ枯渇が起きます。EP.7 で扱いますが、まとめる単位を区切って処理するのが安全な形です。
N+1 でもメモリ枯渇でも、根っこは「件数に上限がないこと」です。一覧を返す処理には、最初から上限を入れてください。上限があれば、どちらの問題も限定された範囲に収まります。
5. 直さないという判断
すべての N+1 を直す必要はありません。直すコストが効果を上回る場合があります。
| 状況 | 判断 | 理由 |
|---|---|---|
| 件数に上限があり、十分小さい | 直さなくてよい | 5件なら6回。誤差の範囲 |
| 実行頻度が極端に低い | 後回し | 月1回のバッチなら全体への影響が小さい |
| コードが著しく複雑になる | 慎重に判断 | 可読性との交換になる |
| 件数に上限がない | 直す | いずれ必ず破綻する |
| 利用者が待つ経路にある | 直す | 体感に直結する |
判断の分かれ目は、やはり件数に上限があるかです。上限があれば最悪値が計算できるので、許容できるかを判断できます。上限がなければ、いま問題がなくても将来必ず問題になるので、直す側に倒します。
なお、N+1 は平均値には現れにくい問題です。データの少ない利用者では速く、多い利用者だけが極端に遅くなるため、EP.1 で扱った を見ていないと存在自体に気づけません。「一部の顧客だけ遅い」という報告が上がったら、まずこの構造を疑う価値があります。
この構造が実際に障害へ発展した事例は データ基盤トラブル事件簿 EP.08「N+1 の連鎖」 にまとめてあります。
6. 再発を防ぐ
N+1 は一度直しても再発します。関連データの参照を1行足すだけで復活するためです。個別に直すだけでなく、気づける仕組みを置く価値があります。
- 1開発環境で問い合わせ回数を表示する — 見えれば気づける
- 2テストで回数に上限をかける — 一定回数を超えたら失敗させる
- 3本番相当の件数で確認する — 少量データでは絶対に顕在化しない
- 4一覧を返す処理には件数の上限を入れる — 被害を限定する
2番目は効果が高い割に実装が軽い方法です。「この処理は問い合わせ10回以内」という をテストに書いておくと、再発した瞬間に落ちます。性能をテストで守る数少ない有効な形です。
N+1 は書きやすさの代償として回数が見えなくなったことから生まれる。検出は同じ形のクエリの繰り返しを見る。直す基本はまとめて取ることだが、メモリ使用量との交換になる。判断の分かれ目は件数に上限があるか。再発防止にはテストで回数に上限をかけるのが効く。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。