【Django】第二回:DjangoでN+1を防いで高速化を行うテクニック

バックエンド担当の山田です。

前回の予告通り引き続き処理負荷軽減テクニックを紹介します。

 

xmart-techblog.hatenablog.com

 

前回はN+1の箇所の見つけ方を紹介したので、今回は具体的に問題箇所に対応する方法… select_related, prefetch_relatedについてです。

 

今回は以下のようなデータと構造を持つModelsがある場合を例とします。

# Model構造
from django.db import models

class Company(models.Model):
    name = models.CharField(max_length=255)

class Staff(models.Model):
    first_name = models.CharField(max_length=255)
    last_name = models.CharField(max_length=255)
    company = models.ForeignKey(Company, related_name='staffs', on_delete=models.DO_NOTHING)
# 投入されているデータ
INSERT INTO `company` (`id`, `name`)
VALUES
	(1, 'クロスマート_本店'),
	(2, 'クロスマート_分店'),
	(3, '肉屋');

INSERT INTO `staff` (`id`, `first_name`, `last_name`, `company_id`)
VALUES
	(1, '一郎', '山田', 1),
	(2, '次郎', '山田', 1),
	(3, '三郎', '山田', 1),
	(4, '千代子', '鈴木', 1),
	(5, '花子', '山田', 2),
	(6, '智子', '佐藤', 2)
	(7, '十兵衛', '山田', 3);

このデータから「companyに”クロスマート”を含む」かつ「staffのlast_nameが”山田”と一致」するものを抽出して、「会社名とフルネームを出力する」という処理を行う場合で実装例を交えて説明します。

何も考えずに取ってくる場合

# from your/models/pass import Company, Staff

staffs = Staff.objects.filter(last_name='山田')
for staff in staffs:
    if 'クロスマート' in staff.company.name:
        print(f'所属:{staff.company.name}, 氏名:{staff.last_name}{staff.first_name}')

おそらく最も良くない書き方の1つです。 この書き方ですとループごとにクエリを発行してしまいます。この場合に発行するクエリは以下のようになります。

# 4行目 staffs の行。
SELECT `staff`.`id`,
       `staff`.`first_name`,
       `staff`.`last_name`,
       `staff`.`company_id`
FROM `staff`
WHERE `staff`.`last_name` = '山田'

# 5行目 ループ内で staff.company を利用している行。
# 上記でヒットした分だけクエリが発行されるため、staffが「山田」のレコードの数だけ同じクエリを実行します。
SELECT `company`.`id`,
       `company`.`name`
FROM `company`
WHERE `company`.`id` = 1

この例の場合、全体で6回クエリが実行されます。

親のテーブルを最初に検索してから子を探す場合

# from your/models/pass import Company, Staff

companies = Company.objects.filter(name__contains='クロスマート')
for c in companies:
    for s in c.staffs.all():
        if s.last_name == '山田':
            print(f'所属:{c.name}, 氏名:{s.last_name}{s.first_name}')
# 4行目 companies の行
SELECT `company`.`id`,
       `company`.`name`
FROM `company`
WHERE `company`.`name` LIKE BINARY '%クロスマート%'

# 5行目 ループ内で c.staffs.all() を実行した行。
# 上記でヒットした分だけクエリがループするのでcompany.nameに"クロスマート"を含むレコードの数だけ同じクエリを実行します。
SELECT `staff`.`id`,
       `staff`.`first_name`,
       `staff`.`last_name`,
       `staff`.`company_id`
FROM `staff`
WHERE `staff`.`company_id` = 1

この場合、クエリの実行数は3回になります。 最も悪い例と比較するとほぼ半分になりました。

そしてDjango始めたての人でよくあるのが、

# from your/models/pass import Company, Staff

companies = Company.objects.filter(name__contains='クロスマート', staffs__last_name='山田')
for c in companies:
    for s in c.staffs.all():
        print(f'所属:{c.name}, 氏名:{s.last_name}{s.first_name}')

という書き方です。

パッと見で「companyに”クロスマート”を含む」AND 「staffのlast_nameが”山田”と一致」となっているように見えますが、これは完全にトラップです。 これを実行すると以下のようなクエリを実行します。

# 4行目 companies の行
SELECT `company`.`id`,
       `company`.`name`
FROM `company`
INNER JOIN `staff` ON (`company`.`id` = `staff`.`company_id`)
WHERE (`company`.`name` LIKE BINARY '%クロスマート%'
       AND `staff`.`last_name` = '山田')

# 5行目 ループ内で c.staffs.all() を実行した行。
SELECT `staff`.`id`,
       `staff`.`first_name`,
       `staff`.`last_name`,
       `staff`.`company_id`
FROM `staff`
WHERE `staff`.`company_id` = 1

先程と同じような回数のクエリが発行されると思われますが、2回目移行のクエリで無駄なクエリを連続で実行してしまい、今回の例だと合計5回もクエリを発行します。

「ループに入る前に条件を絞り込んだのにどうして…!?」って考えてしまうのですが、これはあくまでも「1つ目のループを行うための条件における絞っただけ」となっています。

「実はStaffの情報は一切取得していない」ばかりか「下手な絞り込みを行った結果、”山田”以外のスタッフを出力してしまう」という前提条件すら満たしていない状態です。

 

こんなに難しいなら生SQL

SELECT * FROM xxx INNER JOIN xxx

といった生クエリを発行して結果を取得して、それをいい感じに使い倒す。

ということをしたくなりますが、これをDjangoで実施する場合はselect_related, prefetch_relatedを使います。

というわけで本題です。

select_relatedを使う場合

長々やっていますが、結果から言うと最速は select_related が使えるならそれを使うのが最速かつ最も発行回数を少なく出来ます。

# from your/models/pass import Company, Staff

staffs = Staff.objects.select_related('company').filter(last_name='山田')
for staff in staffs:
    if 'クロスマート' in staff.company.name:
        print(f'所属:{staff.company.name}, 氏名:{staff.last_name}{staff.first_name}')

このように記載すると…

# 4行目 staffs の行で1回クエリが実行される。
SELECT `staff`.`id`,
       `staff`.`first_name`,
       `staff`.`last_name`,
       `staff`.`company_id`,
       `company`.`id`,
       `company`.`name`
FROM `staff`
INNER JOIN `company` ON (`staff`.`company_id` = `company`.`id`)
WHERE `staff`.`last_name` = '山田'

なんとわずか1回のクエリ発行しか行いません\(・ω・)/イェーイ

似たような形で親から子を参照する場合にはprefetch_related を用います。 これはprefetchするテーブルの数だけクエリが発行されてしまうので、使った時点で2回以上のクエリ実行が確約されますが、prefetch対象のデータはキャッシュされるため、上手くやればクエリの実行回数をprefetchした回数までに抑えることが出来ます。

prefetch_relatedを使う場合

# from your/models/pass import Company, Staff
from django.db.models import Prefetch

companies = Company.objects.prefetch_related(Prefetch('staffs', queryset=Staff.objects.filter(last_name='山田'))).filter(name__contains='クロスマート')
for c in companies:
    for s in c.staffs.all():
        if s.last_name == '山田':
            print(f'所属:{c.name}, 氏名:{s.last_name}{s.first_name}')
# 5行目 companies で2回のクエリが実行される。
SELECT `company`.`id`,
       `company`.`name`
FROM `company`
WHERE `company`.`name` LIKE BINARY '%クロスマート%';

SELECT `staff`.`id`,
       `staff`.`first_name`,
       `staff`.`last_name`,
       `staff`.`company_id`
FROM `staff`
WHERE (`staff`.`last_name` = '山田'
       AND `staff`.`company_id` IN (1,
                                    2))

と行った具合に、今回の実行回数は2回となりました。

Djangoを使っていてN+1問題に悩まされている方、まずはselect_relatedprefetch_related を使って見てはいかがでしょうか?

 

最後に

クロスマートでは絶賛エンジニア(バックエンド・フロントエンド)募集中です。

このお話を読んでちょっとでも気になった方がいたら

「話を聞きに行きたい」を押してみてください!

www.wantedly.com