Django、select_related、prefetch_relatedを使う

Python - Django
2018年11月19日2:44に更新(約9時間前)
2018年11月5日23:14に作成(約13日前)

旧ブログ移行記事です。

概要

DjangoのORMは大変便利ですが、データが増えたり、リレーションを多用していると、処理に少し時間が掛かるようになってきます。 今回はQeurysetのメソッドであるselect_relatedprefetch_relatedを使って改善していきます。

django-debug-toolbar

django-debug-toolbarを使うと、どのようなSQLが発行されたかを簡単に確認できます。

インストールをします。

pip install django-debug-toolbar

settings.pyの下にでも、下記を追加します。

if DEBUG:
    INTERNAL_IPS = ['127.0.0.1']
    INSTALLED_APPS += ['debug_toolbar']
    MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
    DEBUG_TOOLBAR_PANELS = [
        'debug_toolbar.panels.versions.VersionsPanel',
        'debug_toolbar.panels.timer.TimerPanel',
        'debug_toolbar.panels.settings.SettingsPanel',
        'debug_toolbar.panels.headers.HeadersPanel',
        'debug_toolbar.panels.request.RequestPanel',
        'debug_toolbar.panels.sql.SQLPanel',
        'debug_toolbar.panels.staticfiles.StaticFilesPanel',
        'debug_toolbar.panels.templates.TemplatesPanel',
        'debug_toolbar.panels.cache.CachePanel',
        'debug_toolbar.panels.signals.SignalsPanel',
        'debug_toolbar.panels.logging.LoggingPanel',
        'debug_toolbar.panels.redirects.RedirectsPanel',
    ]

プロジェクトのurls.pyも、以下のような感じにしておきます。

from django.conf import settings
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('blog.urls')),  # blogアプリケーションをこれから作る
]

if settings.DEBUG:
    import debug_toolbar
    urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns

django-debug-toolbarの準備はこれだけです。簡単ですね。

アプリケーションのソースコード

アプリケーション名を「blog」としています。重要なファイルだけ書いていきます。

models.py

シンプルなモデルを作成します。記事と、カテゴリと、タグです。 カテゴリは1記事につき1つ指定し、タグはいくつでも指定ができます。

from django.db import models


class Category(models.Model):
    """カテゴリー"""
    name = models.CharField('カテゴリ名', max_length=255)

    def __str__(self):
        return self.name


class Tag(models.Model):
    """タグ"""
    name = models.CharField('タグ名', max_length=255)

    def __str__(self):
        return self.name


class Post(models.Model):
    """記事"""
    title = models.CharField('タイトル', max_length=255)
    category = models.ForeignKey(Category, verbose_name='カテゴリ', on_delete=models.PROTECT)
    tag = models.ManyToManyField(Tag, verbose_name='タグ')

    def __str__(self):
        return self.title

urls.py

ページは1つだけです。

from django.urls import path
from . import views

app_name = 'blog'

urlpatterns = [
    path('', views.PostIndex.as_view(), name='post_index'),
]

views.py

ビューは後で改良しますが、今のところはシンプルです。

from django.views import generic
from .models import Post


class PostIndex(generic.ListView):
    model = Post

post_list.html

記事の一覧を作成します。記事名、カテゴリ、タグも表示します。

{% extends 'blog/base.html' %}

{% block content %}
    <h1>記事リスト</h1>
    {% for post in post_list %}
        <p">
            記事タイトル: {{ post.title }}<br>
            カテゴリ: {{ post.category }}<br>
            タグ: {% for tag in post.tag.all %}{{ tag }},{% endfor %}
        </p>
        <hr>
    {% endfor %}
{% endblock %}

SQLを確認する

既にdjango-debug-toolbarは導入していますので、127.0.0.1:8000 にアクセスするだけです。

ページの右側にツールバーがありますね。これがdjango-debug-toolbarが作成したものです。

「SQL」という項目があるのでクリックすると、発行されたSQLが確認できます。

左側の「+」を押すと、どこで発行されたかが見えますし...

右側の「sel」を押すと、実際に取得されたデータが確認できます。  

それでは本題に戻ります。 SQLですが、セッションやログインユーザー等Djangoデフォルトの操作を除けば、発行されたSQLは以下です。多いですね。最初の1行目は全ての記事を取得しているとわかりますが、それ以降はどこで呼び出されているのでしょうか。

SELECT ••• FROM "blog_post"
SELECT ••• FROM "blog_category" WHERE "blog_category"."id" = '1'
SELECT ••• FROM "blog_tag" INNER JOIN "blog_post_tag" ON ("blog_tag"."id" = "blog_post_tag"."tag_id") WHERE "blog_post_tag"."post_id" = '1'
SELECT ••• FROM "blog_category" WHERE "blog_category"."id" = '1'
SELECT ••• FROM "blog_tag" INNER JOIN "blog_post_tag" ON ("blog_tag"."id" = "blog_post_tag"."tag_id") WHERE "blog_post_tag"."post_id" = '2'
SELECT ••• FROM "blog_category" WHERE "blog_category"."id" = '1'
SELECT ••• FROM "blog_tag" INNER JOIN "blog_post_tag" ON ("blog_tag"."id" = "blog_post_tag"."tag_id") WHERE "blog_post_tag"."post_id" = '3'
SELECT ••• FROM "blog_category" WHERE "blog_category"."id" = '1'
SELECT ••• FROM "blog_tag" INNER JOIN "blog_post_tag" ON ("blog_tag"."id" = "blog_post_tag"."tag_id") WHERE "blog_post_tag"."post_id" = '4'
SELECT ••• FROM "blog_category" WHERE "blog_category"."id" = '1'
SELECT ••• FROM "blog_tag" INNER JOIN "blog_post_tag" ON ("blog_tag"."id" = "blog_post_tag"."tag_id") WHERE "blog_post_tag"."post_id" = '5'

テンプレートにてpost.categorypost.tag.allとしていましたが、実はここで毎回SQLが発行されています。この挙動は覚えておきましょう。

            カテゴリ: {{ post.category }}<br>
            タグ: {% for tag in post.tag.all %}{{ tag }},{% endfor %}

ユーザーのアクセス数が増えたり、格納しているデータが多くなってくると、これは問題になる可能性があります。

改善する

問題点もわかったので改善しましょう。views.pyを書き換えます。

from django.views import generic
from .models import Post


class PostIndex(generic.ListView):
    model = Post
    queryset = Post.objects.select_related('category').prefetch_related('tag')

queryset属性を上書きし、select_relatedprefetch_relatedを使います。今回のリレーションの関係ならば、カテゴリはselect_relatedで、タグはprefetch_relatedが使えます。

queryset = Post.objects.select_related('category').prefetch_related('tag')

発行されたSQLを確認してみると、よく動作しているようです。blogアプリケーション内で発行されたのは、下の2つだけですね。

select_relatedを使うと、記事と一緒にカテゴリ情報も一緒に取得されるようになっています。

prefetch_relatedは、各記事に対応するタグを全て取得しています。

今回はあくまで一例で、思いもよらぬ場所で時間がかかっていることはよくあります。 私の旧ブログの場合は右側に最新のコメント10件が表示されており、テンプレートにて

href="{% url 'blog:detail' comment.post.pk %}#comment-area"

のようにして、クリックでその記事のコメント欄に遷移する作りでしたが、urlタグ内のcomment.post で毎回SQLが発行されていました。 django-debug-toolbarを使うと問題箇所も察しがつくので、ぜひ有効活用していきたいですね。

記事にコメントする