0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【まとめてみた】Webアプリが遅くなる原因を「計算量・N+1・INDEX・I/O」でざっくり理解する

0
Posted at

開発環境ではサクサク動いていたのに、本番データが入った途端に画面が重くなる。Webアプリを作っていると、一度はこの状況にぶつかると思います。

原因の多くは、次の4つのどれかに当てはまります。

Webアプリが遅くなる4つの原因

この記事では、それぞれを「ざっくりした意味」と「よくある例」だけに絞って説明します。厳密な定義は置いておいて、コードを見たときに「これは危ないかも」と気づけるところまでを目指します。

O記法は「データが増えたらどれくらい重くなるか」の目安

O記法(オーダー記法)は、データ量 n が増えたときに処理量がどう増えるかを表す書き方です。

すごく雑に言うと「データが10倍になったら、処理は何倍になる?」の答えにあたります。Webアプリ開発ならこの感覚があれば十分で、数式を覚える必要はありません。

ここからは、速い順に7種類を見ていきます。

O(1):データが増えても速さが変わらない

辞書(dict)のキー検索が代表例です。

users = {1: "Alice", 2: "Bob", 3: "Carol"}

name = users[2]  # 何件あっても一発で取れる

O(1) キーを指定して直接取り出す

10件でも100万件でも、平均的にはほぼ1回で目的のデータにたどり着けます。キャッシュからの取得もこのタイプに入ります。

O(log n):データが増えても少ししか重くならない

毎回、探す範囲を半分に絞り込むような処理がこれです。紙の辞書で単語を引くとき、真ん中あたりを開いて「前か後ろか」を判断していくのと同じ考え方です。

DBのINDEX(索引)を使った検索がこれに当たります。

CREATE INDEX idx_users_email ON users(email);

SELECT * FROM users WHERE email = 'test@example.com';

O(log n) 範囲を半分ずつ絞り込む

半分ずつ絞り込む二分探索なら、100万件あっても20回程度で見つかります。

O(n):データ量に比例して重くなる

1件ずつ順番に調べる処理です。

for user in users:
    if user.id == target_id:
        return user

O(n) 先頭から1件ずつ確認する

1,000件なら最大1,000回、100万件なら最大100万回チェックすることになります。DBでINDEXが効かず、テーブルを頭から全部読む Seq Scan(シーケンシャルスキャン。PostgreSQLでの呼び名で、MySQLではフルテーブルスキャンと呼びます)も同じイメージで考えて大丈夫です。

O(n log n):ソートでよく出てくる

並べ替えの処理はだいたいこれです。

users.sort(key=lambda u: u.name)

O(n log n) マージソートの流れ

データを細かく分けてから、並べながらまとめ直していきます。まとめ直す段数が log n 段、1段ごとに n 件を処理するので n log n になります。

SQLの ORDER BY も、INDEXが使えない場合はDBが内部でこのソート処理を行います。O(n) よりは重いものの、O(n²) と比べればずっと軽いので、普通に使う分には気にしすぎなくて大丈夫です。

O(n²):二重ループは要注意

Webアプリで一番気をつけたいのがここです。

for user in users:
    for order in orders:
        if user.id == order.user_id:
            print(user.name, order.id)

正確には、ユーザー数 n × 注文数 m 回の比較になります。両方が同じくらいのペースで増えるなら n × n、つまり O(n²) と考えて構いません。ユーザーも注文も10倍になると、処理は100倍に膨らみます。

n = 100    →       10,000回
n = 1,000  →    1,000,000回
n = 10,000 →  100,000,000回

開発環境の数十件では気づかず、本番で一気に遅くなる典型パターンです。

多くの場合、dictを使えば O(n + m)、つまりデータ量に比例する形に書き換えられます。

from collections import defaultdict

orders_by_user = defaultdict(list)
for order in orders:
    orders_by_user[order.user_id].append(order)

for user in users:
    for order in orders_by_user[user.id]:  # O(1) で取り出せる
        print(user.name, order.id)

先に「ユーザーIDごとの注文リスト」を作っておけば、ユーザーごとに orders 全体をなめる必要がなくなります。

このコードも for が入れ子になっていますが、内側のループは「そのユーザーの注文だけ」を回すので、合計しても注文数ぶんしか回りません。for が2つ並んでいるかどうかではなく、内側で毎回全件を回しているかどうかを見るのがポイントです。

complexity-on2.png

O(2^n) と O(n!) は存在だけ知っておけばOK

O(2^n) は、入力が1増えるたびに処理が掛け算で増えていくタイプです。工夫のない再帰で書いたフィボナッチ数が有名です。フィボナッチの場合、正確には1増えるごとに約1.6倍で、2^n はその上限の見積もりにあたります。

def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)  # 同じ計算を何度も繰り返す

O(2^n) fib(5) の呼び出しツリー

図のとおり、fib(5) を求めるだけで fib(2) を3回も計算し直しています。一度計算した結果を覚えておく(メモ化)と、この無駄が消えます。Pythonなら functools.lru_cache を付けるだけです。

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)  # 各 n について1回だけ計算される

O(n!) は、すべての並び順を試すような処理を指します。たった10件で約363万通りになります。

O(n!) 3か所を回る順番の全パターン

どちらも、CRUD(登録・参照・更新・削除)が中心の普通のWebアプリではまず出てきません。組み合わせ探索や推薦のロジックを書くときに「データ数が小さいことが保証されているか」を確認する、くらいの意識で大丈夫です。

計算量の早見表

計算量 ざっくり言うと よくある例
O(1) 件数に関係なく一瞬 dictのキー検索
O(log n) 件数が増えてもほぼ平気 DBのINDEX検索
O(n) 件数に比例 全件ループ、Seq Scan
O(n log n) O(n)より少し重い ソート、 ORDER BY
O(n²) 件数の2乗で増える 二重ループ
O(2^n) / O(n!) 爆発的に増える 全組み合わせ・全順列

メモリ使用量(空間計算量)もデータ量で増える

処理時間だけでなく、メモリの使い方も「データ量に比例して増えるか」で考えます。

users = list(User.objects.all())  # 全件をメモリに載せる

空間計算量 全件読み込みと必要な分だけ読み込む場合の比較

数件なら問題ありませんが、数百万件あるとサーバーのメモリを使い切ることがあります。一覧画面ならページネーション(1ページ20件ずつのように分けて取得する仕組み)を入れます。Djangoなら、図の右側のように [:20] でスライスすると、SQLの LIMIT 20 になってDBから20件だけ取ってきます。絞り込みはPython側でやらず、SQLの WHERE に任せます。この2つが基本の対策です。

N+1問題は「SQLを何回投げているか」の問題

ここからはDBの話です。N+1問題は、ここまで数えてきた「アプリの中の処理回数」ではなく「DBに投げるSQLの回数」に注目します。

たとえるなら、スーパーへの買い物です。100品買うのに、1品ごとに家とスーパーを往復する人はいませんよね。買い物リストを作って1回でまとめて買うはずです。N+1問題は、プログラムがこの「1品ずつ往復」をしてしまっている状態です。

どんなコードで起きるか

Djangoでよくある例を見てみます。ユーザー一覧を取ってきて、それぞれのプロフィール名を表示するだけのコードです。

users = User.objects.all()

for user in users:
    print(user.profile.name)

一見なんの問題もなさそうですが、Djangoは裏で次のようなSQLを発行しています。

SELECT * FROM users;                        -- ユーザー一覧を取る(1回)
SELECT * FROM profiles WHERE user_id = 1;   -- 1人目のプロフィール
SELECT * FROM profiles WHERE user_id = 2;   -- 2人目のプロフィール
SELECT * FROM profiles WHERE user_id = 3;   -- 3人目のプロフィール
-- ...ユーザーの人数ぶん続く

ユーザーが100人いると、一覧の1回と、プロフィールの100回で合計101回。ユーザー数を N とすると「N + 1回」になるので、N+1問題と呼ばれています。

なぜこうなるのか

Djangoは、最初の User.objects.all() の時点ではユーザーのデータしか取ってきません。プロフィールは、ループの中で user.profile に触れた瞬間に「まだ持っていないので取りに行こう」とSQLを1本投げます。これを遅延読み込みと呼びます。

コードにSQLが1行も書かれていないので、気づきにくいのが厄介なところです。

まとめて取ってくれば解決する

対策は「最初にまとめて取ってくるよう、Djangoに伝えておく」ことです。関連の種類によって、使うメソッドが2つあります。

関連の種類 例 使うメソッド SQLの回数
相手が1つ(1対1、多対1) ユーザー → プロフィール select_related() 1回
相手が複数(1対多、多対多) ユーザー → 注文 prefetch_related() 2回

プロフィールのように相手が1つなら、 select_related() を使います。JOIN(テーブル結合)で、ユーザーとプロフィールを1回のSQLでまとめて取ってきます。

users = User.objects.select_related("profile")

for user in users:
    print(user.profile.name)  # ここではSQLが発行されない

注文のように相手が複数なら、 prefetch_related() を使います。ユーザー一覧を取ったあと、全員分の注文を WHERE user_id IN (1, 2, 3, ...) のような1本のSQLでまとめて取ってきます。

users = User.objects.prefetch_related("order_set")

for user in users:
    for order in user.order_set.all():  # ここではSQLが発行されない
        print(order.id)

どちらも、ループの中身は書き換えずに済みます。変えるのは最初の1行だけです。ただし prefetch_related() の場合、ループの中で user.order_set.filter(...) のように条件を付けると、まとめて取っておいたデータが使われず、またSQLが発行されてしまいます。ループの中では .all() で取り出すようにしてください。

N+1 ループ内でSQLを投げる場合とまとめて取得する場合の比較

自分のコードで起きているか確かめたいときは、django-debug-toolbar を入れると、画面ごとに発行されたSQLの本数と中身を一覧で見られます。似たSQLが人数ぶん並んでいたら、N+1を疑ってください。

O(n) との違い

最後に、O(n) との違いを整理しておきます。

数えているもの 1回あたりの重さ
O(n) アプリの中で何回処理するか メモリ上の処理なので軽い
N+1 DBに何回SQLを投げているか 通信の往復があるので重い

N+1では、ループの回数自体は O(n) のままでも、1回ごとにDBとの往復が入ります。そのため、件数が少ないうちから目に見えて遅くなりやすいのです。

INDEXは本の索引と同じ

INDEXがないと、DBは目的の行を探すために全件を順番に調べます(Seq Scan)。本の索引を使わず、1ページ目から目当ての単語を探すようなものです。

CREATE INDEX idx_users_email ON users(email);

INDEXを貼ると、先ほどの O(log n) で探せるようになります。 WHERE 、 JOIN 、 ORDER BY でよく使うカラムが候補です。

ただし、INDEXは多ければいいわけではありません。 INSERT / UPDATE / DELETE のたびにDBがINDEXも更新するので、書き込みが遅くなります。よく検索するカラムに絞って貼りましょう。

INDEXが効いているかは、PostgreSQLなら EXPLAIN ANALYZE で確認できます。

EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'test@example.com';

EXPLAIN ANALYZE はクエリを実際に実行して時間を測ります。 UPDATE や DELETE に付けるとデータが本当に書き換わるので、トランザクションの中で実行して最後に ROLLBACK するか、実行計画を見るだけなら ANALYZE を外した EXPLAIN を使ってください。

結果に Index Scan 、 Index Only Scan 、 Bitmap Index Scan のどれかが出ていればINDEXが使われています。大きなテーブルで Seq Scan と出ていたら、INDEXが足りていない可能性があります。とはいえ、データが少ないときはDBがあえてSeq Scanを選ぶこともあるので、出たら即アウトというわけではありません。

I/Oはループ1回より圧倒的に重い

I/O(入出力)は、DB・外部API・ファイルなど、プログラムの外とやり取りする処理のことです。

メモリ上で100回ループするより、DBに100回アクセスするほうがずっと遅くなります。DBへのアクセスは毎回、ネットワークを通ってDBに届き、DBがSQLを解析してデータを探し、結果をまたネットワークで返します。この往復のコストが、メモリ上の処理とは桁違いに大きいのです。

I/O メモリ上の処理とDB・外部APIアクセスの重さの比較

実際の時間の目安で比べると、100回くり返したときにメモリ上の処理は一瞬で終わりますが、DBアクセスは約0.1秒、外部APIの呼び出しは約10秒かかります。

これでも差が実感しにくいので、図の下段ではすべてを同じ倍率で引き延ばしています。メモリ上の処理1回を「1秒」とすると、DBへのアクセスは約3時間、外部APIの呼び出しは約12日に相当します(実際の時間は環境によって大きく変わるので、あくまで目安です)。ループの中にI/Oが1つ入るだけで、ループ全体の重さがまるで変わってしまいます。

なので、ループの中で外部APIを呼ぶコードは特に注意が必要です。

for user in users:
    call_external_api(user)  # ユーザー数ぶん通信が発生する

APIが一括リクエストに対応していれば、そちらを使うのが基本です。

コードレビューで見るポイント

最後に、ここまでの内容を実際のコードに当てはめてみます。

users = User.objects.all()

for user in users:
    orders = Order.objects.filter(user_id=user.id)

    for order in orders:
        process(order)

一番の問題は、ループの中で Order を検索していることです。ユーザーが1,000人いれば、SQLが1 + 1,000回発行される N+1 になっています。 User.objects.all() で全件を取っているので、ユーザーが増えるほどメモリと時間も食います。

一方で、for が入れ子になっていること自体は問題ではありません。内側のループはそのユーザーの注文だけを回すので、全体でも注文数ぶんしか回りません。O(n²) の節で見たとおり、内側で毎回全件を回していなければ O(n²) にはなりません。

N+1 は prefetch_related() で解消できます。

users = User.objects.prefetch_related("order_set")

for user in users:
    for order in user.order_set.all():  # ここでは追加のSQLが発行されない
        process(order)

これでSQLは2回になります。ユーザー数がかなり多いなら、ページネーションや絞り込みも組み合わせましょう。

レビューのときは、次のチェックリストを上から見ていくと漏れにくくなります。

□ 全件取得や、大量データの一括読み込みをしていないか
□ ループの中が重くないか(内側で毎回全件を回す、SQLや外部APIを呼ぶ)
□ WHERE / JOIN / ORDER BY のカラムにINDEXがあるか
□ データが10倍・100倍になっても耐えられるか

特に最後の「10倍・100倍になったら?」を想像する癖をつけるだけでも、本番で慌てる回数はかなり減るはずです。気になる箇所が見つかったら、 EXPLAIN ANALYZE やログで実際の時間を測って確かめてみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?