【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`を使って一度のクエリで取得します。

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

```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)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
```

##### before

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

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

##### after

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

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

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

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

#### 2. `prefetch_related`を使う

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

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

```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 回クエリを発行しています。

```python
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 回のクエリ発行で済みます。

```python
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`モデルのすべてのインスタンスを取得します。

```sql
SELECT * FROM book;
```

###### (ii) 関連オブジェクトの取得

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

```sql
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](https://docs.djangoproject.com/en/5.0/topics/db/queries/#complex-lookups-with-q))

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

```python
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 回クエリを発行しています。

```python
# 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 回のクエリ発行で済みます。

```python
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 が発行されるイメージです。

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