N+1問題におけるORMの重たさについて

tl;dr

ボトルネックはちゃんと測定して把握しないとダメだよ。

N+1問題

DjangoやRailsなど、ORMを利用するWebフレームワークなどの開発では、よく 「N+1問題」 というのが話題になります。ORMでは、あるモデルが参照している別のモデルを参照するとその時点でSQLが発行されてしまうため、気が付かないうちにパフォーマンスが低下する場合がある、というやつですね。

Django

例えば、Djangoで次のようなモデルがあったとき、

class Table1(models.Model):
    text = models.TextField()

class Table2(models.Model):
    table1 = models.ForeignKey(Table1, on_delete=models.CASCADE)

Table2 の全レコードが参照している Table1 オブジェクトのカラムを出力する場合は、次のような感じになります。

for table2 in Table2.objects.all():
    print(table2.table1.text)

この処理を実行すると、まず

Table2.objects.all()

という問い合わせで Table2 の全レコードを取得するSQL文を実行します。

そして、取得した Table2 オブジェクトごとに table2.table1.text を参照しますが、このとき、Table1 を検索するSQLを実行します。

これまでの処理で、実行されるSQLは:

  1. Table2 の全レコードを取得するSQLを一回
  2. Table1 を取得するSQLを Table2 のレコード数分。

で、Table2 のレコード数が N だとすると、合計で N+1 回SQLを実行することになります。

このように各レコードごとにSQLを実行するのは無駄が多く、パフォーマンス低下の要因となる場合があります。このため、ORMではSQLの発行回数を軽減するための手段が用意されています。

Djangoの場合には、次のように指定すると、一度の呼び出しで必要な Table1 をすべて取り出せるようになっています。

for table2 in models.Table2.objects.prefetch_related("table1").all():
    print(table2.table1.text)

この処理の実行時間は、Table1Table2が それぞれ1000 レコードのとき、次のようになります。(Sqlite3を使用)

処理時間(秒)
N+1 0.3425
prefetch_related 0.0313

SQLAlchemy

同様の処理をSQLAlchemyで実行すると、次のようになります。

# N+1版
session = Session()
for table2 in session.query(Table2).all():
    table2.table1.text
# selectinloadを使用
session = Session()
for t2 in session.query(Table2).options(selectinload(Table2.table1)).all():
    t2.table1.text
処理時間(秒)
N+1 0.1799
selectinload 0.0318

Ruby on Rails

Ruby on Railsでは、次のようになりました。

# N+1版
Table2.all.each do |tbl2|
 tbl2.table1.text
end
# includes を使用
Table2.includes(:table1).all.each do |tbl2|
 tbl2.table1.text
end
処理時間(秒)
N+1 0.2755
includes 0.0931

ORMの重さ

上記の結果から、どのORMでもN+1問題を含んだクエリの場合で約300ms、対処した場合でも数十msかかっていることがわかります。この測定は

Macbook Pro (16-inch, 2019)
CPU 2.4 GHz 8コア Intel Core i9
メモリ 64GByte

というスペックのマシンで実行しました。値段は税込みで ¥450,780- でした。けっこういい値段でしたが、買って半年ほどでApple M1チップ搭載機の噛ませ犬となり、いまではすっかり哀れみの目で見られる身分です。

さて、ある程度RDBを使い慣れている方なら、450780円という高級機でたった1000件のレコードをフェッチするだけで数百ms、という結果に違和感をおぼえるのではないでしょうか。よく「N+1問題は不要なSQLが実行されるために遅くなる」と言われますが、今どきの環境で単純な検索を1000回や2000回実行したぐらいでは、こんなに時間はかかりません。たとえ「コスパ悪い」と揶揄されるインテル製CPUとはいえ、です。

ためしに、ORMを使わず、SQLで同じデータを取得してみましょう。

# N+1版
for id, table1id in cur.execute("select * from table2").fetchall():
    cur.execute("select id, text from table1 where id=?", (id,)).fetchone()
# prefetch_relatedと同じ処理
table2 = c.execute("select * from table2").fetchall()
args = ','.join('?'*len(table2))
table1 = c.execute(f"select text from table1 where id in ({args})", 
                   [table1id for id, table1id in table2]).fetchall()
処理時間(秒)
N+1 0.0142
prefetch_related 0.0020

直接SQLを実行した場合、ORMを利用した場合と比べて、一桁高速です。N+1回のクエリを実行していても、クエリ2回だけのORMよりも高速です。

つまり、この程度の単純なクエリの場合、ORMの処理時間のほとんどはRDBの呼び出しではなく、SQLの組み立てやモデルオブジェクトの構築に費やされているのです。

このような仕組みを理解していれば、この処理をチューニングする場合でも機械的にN+1問題対策として prefetch_related を適用するのではなく、できるだけORMレイヤーの機能を利用せずに必要な値だけを直接取得する、などの手段を思いつくのではないでしょうか。

たとえば、Django ORMであれば、次のようにして同じデータを取得できます。

for values in models.Table2.objects.values('table1__text').all():
    values['table1__text']

この方法では、RDBからフェッチした値からモデルオブジェクトを構築するのではなく、必要なカラムの値だけを取得して結果を辞書で受け取ります。この例のように、特定のカラムの単純なデータを参照するだけであれば、モデルオブジェクトは必要ではありません。

このような使い方であれば、処理時間は

0.0031秒

となり、生SQLに匹敵するパフォーマンスを得られます。

パフォーマンスのチューニングを行うには、システムのどの部分でどういった負荷が生じているのかを理解する必要があります。システムのボトルネックは常にケースバイケースで、正しく把握するには、実験と観測を繰り返すしかありません。

この場合では、負荷の大部分はいろいろなWebサイトに書かれているような「SQLの実行回数が多いから」ではなく、ORMそのものでした。Webサイトを検索すればいろいろな情報が見つかる時代ではありますが、検索結果を過信することなく、自分にとって適切な内容なのかどうか、ちゃんと評価するようにしましょう。

そして「過信しない」とは、「間違ったことを書いてあるページもあるかもしれない」とうたがうのではなく、「検索でひっかかるページの99%はゴミ」という姿勢のことです。適切なサービスを使えば、どんな無知な人間が書いた無価値な情報でも、それっぽく表示できます。我々はそんな時代に生きている、ということを忘れないようにしましょう。

Amazon.co.jpアソシエイト: