はじめに
充分に注意せず、オブジェクトリレーショナルマッピング(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
を使えば、Book
とAuthor
の両方の情報を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
には、注文が行われた国と都市を示すcountry
とcity
フィールドがあります。
Shipment
には、発送先の国と都市を示すcountry
とcity
フィールドがあります。
特定の国の注文の一覧を取得し、その注文に対応する発送があるかを調べます。
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 を対応づけることで、実際に発行されるデータベースクエリを明瞭に意識することができました。
アプリケーションのパフォーマンス問題を考える上で、実際に発行されるクエリ数を意識することは重要な視点だと感じました。
参考
-
QuerySet
API reference | Django documentation
https://docs.djangoproject.com/en/5.0/ref/models/querysets/ -
Complex lookups with
Q
objects | Django documentation
https://docs.djangoproject.com/en/5.0/topics/db/queries/#complex-lookups-with-q -
select_related, prefetch_related について
https://qiita.com/OTA_nagisa/items/112a6b0ccd8949c5c980