2
2

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

Last updated at Posted at 2024-08-19

はじめに

充分に注意せず、オブジェクトリレーショナルマッピング(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 を対応づけることで、実際に発行されるデータベースクエリを明瞭に意識することができました。
アプリケーションのパフォーマンス問題を考える上で、実際に発行されるクエリ数を意識することは重要な視点だと感じました。

参考

2
2
2

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
2
2