一覧ページが遅い — 原因をSQLの本数で切り分けるN+1の話 | GH Media
URLがコピーされました

一覧ページが遅い — 原因をSQLの本数で切り分けるN+1の話

URLがコピーされました
一覧ページが遅い — 原因をSQLの本数で切り分けるN+1の話

半年前に納品された業務システムの、受注一覧を開くのに15秒かかる。詳細画面は一瞬で開く。CSVの出力も速い。遅いのは一覧ページだけで、しかもデータが増えるほど悪化している。

保守を頼んだら「サーバーのスペックが足りていない」と言われ、増強したがほとんど変わらなかった。この経験をした発注担当者は少なくないはずです。

スペックで直らない理由ははっきりしています。1回1回の処理が重いのではなく、処理の回数が多いからです。この形の遅さは、SQLの本数を数えるだけで判別できます。専門知識がなくても、本数の増え方を見れば原因の見当がつきます。

100件表示するのに101本のSQLが出る

典型的な形を示します。記事の一覧に、それぞれの著者名を表示する画面を考えます。

# 1本目: 記事を100件取得する
articles = session.query(Article).limit(100).all()

for article in articles:
    # ループのたびに著者を1件取りに行く → ここで100本
    print(article.author.name)

このコードは、見た目には1回のデータ取得に見えます。実際に発行されるSQLは101本です。最初の1本で記事を100件取り、そのあと著者を引くために1件ずつ100本を追加で投げています。「N件 + 1本」の形になるので、N+1問題と呼ばれます。

厄介なのは、1本あたりのSQLはどれも速いことです。1本0.5ミリ秒で完了していれば、遅いクエリを探すログには何も引っかかりません。それが101本並ぶと、通信の往復時間が積み上がって数秒になります。開発時はデータが10件しかないので11本で済み、誰も気づきません。データが増えたときだけ表面化するのは、この構造のためです。

開発時はデータ10件で11本のSQLだが、本番でデータ100件になると101本に増え、1本あたりは速いままでも合計時間が跳ね上がる構造を示した図

発注側でも確認できる切り分け

原因がN+1かどうかは、コードを読まなくても判定できます。表示件数を変えて、応答時間の変わり方を見るだけです。

  1. 一覧ページの表示件数を10件にして、開くまでの時間を測る
  2. 同じページを100件にして、もう一度測る
  3. 時間の増え方を比べる

件数を10倍にして、時間もおおよそ10倍になるならN+1を疑います。本来、一覧の表示件数を増やしても、取得するデータ量が増えるだけで、処理の往復回数は変わらないはずです。時間が件数に比例している場合、件数ぶんだけ何かを繰り返している可能性が高いということになります。

そのうえで保守担当に聞く質問は、「重いですか」ではなく「この画面で発行されるSQLは何本ですか」です。この聞き方だと、感覚ではなく数字が返ってきます。多くのフレームワークには発行されたSQLを記録する仕組みがあるので、本数を出すこと自体は難しくありません。

直し方は「まとめて取る」

対処は、ループの中で1件ずつ取るのをやめて、まとめて取る形に変えることです。先ほどのコードなら、関連データを先に読む指定を足します。

from sqlalchemy.orm import selectinload

articles = (
    session.query(Article)
    .options(selectinload(Article.author))  # 著者をまとめて取る
    .limit(100)
    .all()
)

for article in articles:
    print(article.author.name)  # 追加のSQLは出ない

これで101本が2本になります。記事を取る1本と、必要な著者をまとめて取る1本です。JOINで1本にまとめる書き方もあり、どちらが速いかはデータの形によります。

ここで使っているのはPythonのSQLAlchemyですが、起きていることはORMが違っても同じです。Rails、Laravel、Prisma、TypeORM、いずれにも同種の指定があります。ORMの選定そのものを検討している場合はDrizzle ORMとPrismaの移行判断TypeORM 1.0とレガシーORMの選択が参考になります。

直したつもりで別の問題を作らないために

修正には、行きすぎたときの副作用があります。

取りすぎて別の遅さを作る

関連データを全部まとめて読む指定を入れると、今度は不要なデータまで巨大なJOINで引いてくるようになります。一覧に表示していない項目まで取得していれば、本数は減っても転送量が増えて別の遅さが出ます。「N+1を潰した」報告が来たら、本数だけでなく応答時間も併せて確認したほうが確実です。

キャッシュで隠しているだけではないか

同じ結果を使い回して速く見せているだけの場合、データを更新した直後に古い値が表示される問題が新しく生まれます。速くなった理由がキャッシュなのか、クエリの本数が減ったのかは、区別して聞いておく価値があります。

修正が効いているかを継続的に見るなら、負荷をかけた状態での計測が要ります。手順はk6による負荷テストとパフォーマンス保証、本番で起きている遅さの追跡はトレース分析によるパフォーマンス調査にまとめています。

受け入れ条件に書いておく

この問題は、納品後に発覚すると追加費用の話になりがちです。開発時のデータ量では再現しないため、検収の場でも見つかりません。契約時に条件として書いておくのが、いちばん安上がりです。

書き方としては、「表示が速いこと」のような主観的な表現ではなく、測れる形にします。

  • 主要な一覧画面について、本番想定件数のデータを投入した状態で応答時間を計測すること
  • 表示件数を10倍にしたとき、応答時間が件数に比例して増えないこと
  • 主要画面で発行されるSQLの本数を提出すること

3つめが特に効きます。本数を出す前提になっていれば、作る側が実装中に気づきます。検収時に発見して直させるより、その方が双方の手間が減ります。

次にやること

いま運用しているシステムで、いちばん遅い一覧画面をひとつ選んでください。表示件数を変えて、時間が比例して増えるかどうかを見る。これだけで、スペック増強にお金を使うべきかどうかの判断が変わります。

比例して増えているなら、増強しても解決しません。保守担当に伝えるべきは「遅い」ではなく「件数に比例して遅くなっている」で、この一言があるだけで調査の方向が定まります。

既存システムのパフォーマンス調査や、新規開発の受け入れ条件の設計については、グリームハブの開発・AI・自動化のご相談で承っています。構成やデータ量によって原因の切り分け方は変わるため、個別にご相談ください。お問い合わせからどうぞ。

Sources

URLがコピーされました

グリームハブ株式会社は、変化の激しい時代において、アイデアを形にし、人がもっと自由に、もっと創造的に生きられる世界を目指しています。

記事を書いた人

鈴木 翔

鈴木 翔

技術の可能性に魅了され、学生時代からプログラミングとデジタルアートの分野に深い関心を持つ

関連記事

「TECH」の記事一覧を見る