🙅‍♂️

【Django】N+1 問題の回避方法 3 選

に公開

はじめに

充分に注意せず、オブジェクトリレーショナルマッピング(ORM)を使ってデータベースクエリを発行すると、N+1 問題 が発生し、アプリケーションの効率を大きく損ねることがあります。
この記事では、Django での N+1 問題を回避したデータベースクエリの発行方法をまとめます。

N+1 問題とは

N+1 問題とは、データベースからデータを取得する際に、1 つのクエリで親データを取得し、その後に子データを個別に取得するために追加のクエリが N 回実行される問題 のことです。

なぜ N+1 問題は問題なのか

ChatGPT 先生に聞いてみました。

N+1 問題が発生すると、以下のような問題が生じます:

  • パフォーマンスの低下: 複数のクエリが発行されることで、データベースの応答時間が増加し、アプリケーションのレスポンスが遅くなります。
  • データベースへの負荷: 不要なクエリがデータベースサーバーに過剰な負荷をかけ、全体的なパフォーマンスが低下します。
  • スケーラビリティの問題: データ量が増加するにつれてクエリの数も増えるため、システムのスケーラビリティが制限されます。

Django の N+1 問題の回避方法 3 選

外部キーがある場合

1. select_relatedを使う

select_related は、一対一(OneToOneField)または多対一(ForeignKey)のリレーションで使います。関連するオブジェクトをJOINを使って一度のクエリで取得します。

例えば、以下のようなモデルに対してのクエリ発行を考えます。

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
before

Bookを取得するのに 1 回、関連するAuthorを取得するのに N 回クエリを発行しています。

books = Book.objects.all()  # クエリ1回
for book in books:
    print(book.author.name)  # クエリN回
after

select_relatedを使えば、BookAuthorの両方の情報をJOINを使って一度で取得できます。

books = Book.objects.select_related('author').all()  # クエリ1回
for book in books:
    print(book.author.name)

select_relatedでは、以下のような SQL が発行されるイメージです。

SELECT * FROM "book"
INNER JOIN "author" ON ("book"."author_id" = "author"."id");

2. prefetch_relatedを使う

prefetch_relatedは、多対多(ManyToManyField)や一対多(ForeignKeyで逆参照)のリレーションで使います。関連オブジェクトを別クエリで取得し、Python 側で関連付けを行います。

例えば、以下のようなモデルに対してのクエリ発行を考えます。

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    title = models.CharField(max_length=100)
    authors = models.ManyToManyField(Author)
before

Bookを取得するのに 1 回、関連するAuthorを取得するのに N 回クエリを発行しています。

books = Book.objects.all()  # クエリ1回
for book in books:
    authors = book.authors.all()  # クエリN回
    for author in authors:
        print(author.name)
after

prefetch_relatedを使えば、Bookの取得に 1 回、関連するAuthorの取得に 1 回、計 2 回のクエリ発行で済みます。

books = Book.objects.prefetch_related('authors').all()  # クエリ2回
for book in books:
    authors = book.authors.all()  # 関連オブジェクトのキャッシュから取得
    for author in authors:
        print(author.name)

prefetch_relatedでは、以下のような処理が行われるイメージです。

(i) 親オブジェクトの取得

1 回目のクエリで、Bookモデルのすべてのインスタンスを取得します。

SELECT * FROM book;
(ii) 関連オブジェクトの取得

2 回目のクエリで、すべての関連するAuthorオブジェクトを一度に取得します。多対多のリレーションの場合、中間テーブルを使って関連付けが行われます。

SELECT * FROM author
JOIN book_authors ON author.id = book_authors.author_id
WHERE book_authors.book_id IN (1, 2, 3, ...);
(iii) Python 側での関連付け

Python のメモリ上で、取得したAuthorオブジェクトと各Bookオブジェクトに関連付けられます。
これにより、authors = book.authors.all()では、実際にクエリを発行せず、Python のメモリ上のキャッシュからオブジェクトを取得できます。

外部キーがない場合

ここでは、テーブル設計を見直すべきかという議論はせず、単にクエリ発行の工夫のみにとどめます。

3. Qオブジェクトを使う

filter()などのキーワード引数クエリはANDで結合されます。
ORなどの、より複雑なクエリを実行したいときに、Qオブジェクトが有効です。

Keyword argument queries – in filter(), etc. – are “AND”ed together. If you need to execute more complex queries (for example, queries with OR statements), you can use Q objects.
(Complex lookups with Q objects | Django documentation)

例えば、以下のようなモデルを考えます。

from django.db import models

class Order(models.Model):
    order_id = models.AutoField(primary_key=True)
    country = models.CharField(max_length=100)
    city = models.CharField(max_length=100)
    order_date = models.DateField()

class Shipment(models.Model):
    shipment_id = models.AutoField(primary_key=True)
    country = models.CharField(max_length=100)
    city = models.CharField(max_length=100)
    shipment_date = models.DateField()

Orderには、注文が行われた国と都市を示すcountrycityフィールドがあります。
Shipmentには、発送先の国と都市を示すcountrycityフィールドがあります。

特定の国の注文の一覧を取得し、その注文に対応する発送があるかを調べます。

before

Orderを取得するのに 1 回、Shipmentを取得するのに N 回クエリを発行しています。

# Orderから、例えば、"Japan"の注文を取得
for order in Order.objects.filter(country="Japan"):  # クエリ1回
    # 条件に一致する発送を取得
    shipments = Shipment.objects.filter(country=order.country, city=order.city)  # クエリN回
    # 結果の表示
    for shipment in shipments:
        print(f"shipment_id: {shipment.shipment_id}, shipment_date: {shipment.shipment_date}")
after

Qオブジェクトを使えば、Orderの取得に 1 回、関連するShipmentの取得に 1 回、計 2 回のクエリ発行で済みます。

from django.db.models import Q

# Orderから、例えば、"Japan"の注文を集める
conditions = Q()
for order in Order.objects.filter(country="Japan"):  # クエリ1回
    conditions |= Q(country=order.country, city=order.city)

# 集めた条件を使って、Shipmentから一致する発送を取得
shipments = Shipment.objects.filter(conditions)  # クエリ1回

# 結果の表示
for shipment in shipments:
    print(f"shipment_id: {shipment.shipment_id}, shipment_date: {shipment.shipment_date}")

以下のような SQL が発行されるイメージです。

SELECT * FROM "shipment"
WHERE ("shipment"."country", "shipment"."city") IN (
    SELECT "order"."country", "order"."city"
    FROM "order"
    WHERE "order"."country" = 'Japan'
)

おわりに

Django の ORM を使って、データベースクエリの代表的なアンチパターンである N+1 問題を回避する方法をまとめてみました。
ORM と SQL を対応づけて、実際に発行されるクエリを眺めてみるのも大切ですね。

参考

Discussion