akiyoko blog

akiyoko の IT技術系ブログです

Django ORM の select_related, prefetch_related の挙動を詳しく調べてみた

Django ORM の QuerySet には、select_related および prefetch_related というメソッドがありますが、イマイチ使い勝手がよく分からなかったりします。


公式ドキュメントにはこう書いてありますが、

select_related works by creating an SQL join and including the fields of the related object in the SELECT statement. For this reason, select_related gets the related objects in the same database query. However, to avoid the much larger result set that would result from joining across a ‘many’ relationship, select_related is limited to single-valued relationships - foreign key and one-to-one.

prefetch_related, on the other hand, does a separate lookup for each relationship, and does the ‘joining’ in Python. This allows it to prefetch many-to-many and many-to-one objects, which cannot be done using select_related, in addition to the foreign key and one-to-one relationships that are supported by select_related.


select_related は、JOIN 句を作って、SELECT 句に関連するオブジェクトのフィールドを含めることによって動作する。これにより、select_related は 1本のデータベースクエリで関連オブジェクトを取得することができる。しかしながら、多くのリレーション先を辿って JOIN することで大規模な結果セットを取得してしまうことを避けるために、select_related は、foreign key や one-to-one といった「対一」リレーションのみを JOIN する。

一方、prefetch_related は、それぞれのリレーションに対して別々の参照を行い、Python コードで JOIN(相当の処理)を行う。これによって、select_related によって取得できる foreign key や one-to-one リレーションのオブジェクトだけではなく、select_related では取得不可能な many-to-many や many-to-one リレーションのオブジェクトの先取り(prefetch)をすることができる。


(akiyoko 訳)

https://docs.djangoproject.com/ja/1.9/ref/models/querysets/#select-related
https://docs.djangoproject.com/ja/1.9/ref/models/querysets/#prefetch-related


よく分からないですよね。


そこで今回は、select_related, prefetch_related を使うことでどのようなメリットがあるのかを確認するためにその挙動を詳しく調べてみたので、その結果をまとめてみたいと思います。



少し長いので、結論(分かったこと)から先に書きます。


分かったこと

Django ORM について

  • 1)多対一、一対一のリレーション先のオブジェクトはフィールドにアクセスした時点でクエリが発行される
    • クエリ発行後は取得したオブジェクトがキャッシュされるため、それ以降のクエリは発行されない
    • リレーション先のレコードが存在しなくても NotFound は発生しない
  • 2)一対多、多対多のリレーション先のオブジェクト群は、(all や filter で)アクセスする度にクエリが発行される
    • リレーション先のレコードが存在しなくても NotFound は発生しない

select_related について

  • 3)select_related を使うことで、一度のクエリで多対一、一対一のリレーション先のオブジェクトを取得してキャッシュしてくれる
    • null=False(デフォルト) の ForeignKey の場合は INNER JOIN で結合
    • null=True の ForeignKey の場合は LEFT OUTER JOIN で結合
  • 4)select_related の引数を指定しない場合は、多対一、一対一になっているリレーション先を全て取得してくれるのではなく、null=False の ForeignKey のみが取得対象

prefetch_related について

  • 5)prefetch_related を使うことで、先行してクエリを発行して、一対一、多対一、一対多、多対多のリレーション先のオブジェクト(群)を取得してキャッシュしてくれる
    • ただし、クエリの総数は減らない

注意点

  • select_related, prefetch_related の引数は明示的に指定しよう!



 

検証内容

今回は、一対一のリレーションの検証はしていません。 *1

検証環境

  • Ubuntu 14.04.4 LTS
  • Python 2.7.6
  • Django 1.9.8
  • MySQL 5.5.50

サンプルテーブル

ブログの投稿を想定したいくつかのテーブル群を検証に使用します。
ブログ投稿(blogpost)とブログ画像(blogimage)が多対一、ブログ投稿と投稿者(auth_user)が多対一、ブログ投稿とブログカテゴリが多対多のリレーションを持っていると仮定します。

以下は、論理モデルを ER図にしたものです。

f:id:akiyoko:20160803233339p:plain


参考までに、実際に作成したテーブルから、物理モデルの ER図を自動出力したものが以下になります。 *2

f:id:akiyoko:20160803071330p:plain


事前準備

Mezzanine プロジェクトの開発環境を PyCharm で設定する - akiyoko blog
を参考に、Ubuntu に Django アプリを作成します。

$ sudo apt-get update
$ sudo apt-get -y install python-dev git tree
$ sudo apt-get -y install mysql-server
$ sudo apt-get -y install mysql-client libmysqlclient-dev python-mysqldb
$ sudo mysql_install_db
$ sudo vi /etc/mysql/my.cnf
$ sudo service mysql restart
$ sudo mysql_secure_installation
$ mysql -u root -p
mysql> create database myproject character set utf8;
mysql> create user myprojectuser@localhost identified by "myprojectuserpass";
mysql> grant all privileges on myproject.* to myprojectuser@localhost;
mysql> flush privileges;
mysql> exit

$ wget https://bootstrap.pypa.io/get-pip.py
$ sudo -H python get-pip.py
$ sudo -H pip install virtualenv virtualenvwrapper
$ cat << EOF >> ~/.bash_profile

if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi
EOF
$ cat << EOF >> ~/.bashrc

source /usr/local/bin/virtualenvwrapper.sh
export WORKON_HOME=~/.virtualenvs
EOF
$ source ~/.bashrc
$ mkvirtualenv myproject
$ sudo mkdir -p /opt/webapps/myproject
$ sudo chown -R `whoami`. /opt/webapps
$ pip install MySQL-python
$ cd /opt/webapps/myproject/
$ pip install Django
$ django-admin startproject config .
$ python manage.py startapp blog
$ vi config/settings.py
(INSTALLED_APPS に blog を追加、DATABASES の設定を MySQL に変更)

config/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'blog',
]
    ・
    ・
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'myproject',
        'USER': 'myprojectuser',
        'PASSWORD': 'myprojectuserpass',
        'HOST': '',
        'POST': '',
    }
}
$ vi blog/models.py
from __future__ import unicode_literals

from django.db import models
from django.utils.translation import ugettext_lazy as _


class BlogPost(models.Model):
    """
    A blog post.
    """
    class Meta:
        verbose_name = _("Blog post")
        verbose_name_plural = _("Blog posts")

    categories = models.ManyToManyField("BlogCategory", verbose_name=_("Categories"),
                                        blank=True, related_name="blogposts")
    image = models.ForeignKey("BlogImage", verbose_name=_("Featured Image"),
                              related_name="blogposts", blank=True, null=True)
    user = models.ForeignKey("auth.User", verbose_name=_("Author"), related_name="blogposts")
    title = models.CharField(_("Title"), max_length=255)
    content = models.TextField(_("Content"), blank=True)


class BlogCategory(models.Model):
    """
    A category for grouping blog posts into a series.
    """
    class Meta:
        verbose_name = _("Blog Category")
        verbose_name_plural = _("Blog Categories")

    title = models.CharField(_("Title"), max_length=255)


class BlogImage(models.Model):
    """
    A featured image.
    """
    class Meta:
        verbose_name = _("Blog Image")
        verbose_name_plural = _("Blog Images")

    caption = models.CharField(_("Caption"), max_length=255)
    image_url = models.FileField(verbose_name=_("Image URL"), max_length=255, blank=True, null=True)
$ python manage.py makemigrations
$ python manage.py migrate


 

検証

まずは事前準備として、初期データを投入します。

$ python manage.py shell
>>> import logging
>>> l = logging.getLogger('django.db.backends')
>>> l.setLevel(logging.DEBUG)
>>> l.addHandler(logging.StreamHandler())

>>> from blog.models import BlogPost, BlogCategory, BlogImage
>>> from django.contrib.auth.models import User

>>> User(username='user-1').save()
>>> BlogCategory(title='cat-1').save()
>>> BlogCategory(title='cat-2').save()
>>> BlogImage(caption='image-1', image_url='blog/image1.jpg').save()
>>> user1 = User.objects.get(pk=1)
>>> cat1 = BlogCategory.objects.get(pk=1)
>>> cat2 = BlogCategory.objects.get(pk=2)
>>> image1 = BlogImage.objects.get(pk=1)
>>> BlogPost(image=image1, user=user1, content='post-1').save()
>>> post1 = BlogPost.objects.get(pk=1)
>>> post1.categories = [cat1, cat2]
>>> BlogPost(user=user1, content='post-2').save()
>>> post2 = BlogPost.objects.get(pk=2)


 
ここからが、本番です。

 

1)多対一、一対一のリレーション先のオブジェクトはフィールドにアクセスした時点でクエリが発行される
>>> post1 = BlogPost.objects.filter(pk=1)[0]
(0.002) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)
>>> post1.user
(0.000) SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1; args=(1,)
<User: user-1>
>>> post1.image
(0.001) SELECT `blog_blogimage`.`id`, `blog_blogimage`.`caption`, `blog_blogimage`.`image_url` FROM `blog_blogimage` WHERE `blog_blogimage`.`id` = 1; args=(1,)
<BlogImage: BlogImage object>
1−1)クエリ発行後は取得したオブジェクトがキャッシュされるため、それ以降のクエリは発行されない
>>> post1 = BlogPost.objects.filter(pk=1)[0]
(0.002) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)
>>> post1.user
(0.000) SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1; args=(1,)
<User: user-1>
>>> post1.user
<User: user-1>
>>> post1.image
(0.001) SELECT `blog_blogimage`.`id`, `blog_blogimage`.`caption`, `blog_blogimage`.`image_url` FROM `blog_blogimage` WHERE `blog_blogimage`.`id` = 1; args=(1,)
<BlogImage: BlogImage object>
>>> post1.image
<BlogImage: BlogImage object>
1−2)リレーション先のレコードが存在しなくても NotFound は発生しない
>>> post2 = BlogPost.objects.filter(pk=2)[0]
(0.001) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 2 LIMIT 1; args=(2,)
>>> post2.image
>>> post2.image is None
True


 

2)一対多、多対多のリレーション先のオブジェクト群は、all や filter でアクセスする度にクエリが発行される
>>> post1 = BlogPost.objects.filter(pk=1)[0]
>>> post1.categories
<django.db.models.fields.related_descriptors.ManyRelatedManager object at 0x7f2509080a10>
>>> post1.categories.all()
(0.001) SELECT `blog_blogcategory`.`id`, `blog_blogcategory`.`title` FROM `blog_blogcategory` INNER JOIN `blog_blogpost_categories` ON (`blog_blogcategory`.`id` = `blog_blogpost_categories`.`blogcategory_id`) WHERE `blog_blogpost_categories`.`blogpost_id` = 1 LIMIT 21; args=(1,)
[<BlogCategory: BlogCategory object>, <BlogCategory: BlogCategory object>]
>>> post1.categories.all()
(0.000) SELECT `blog_blogcategory`.`id`, `blog_blogcategory`.`title` FROM `blog_blogcategory` INNER JOIN `blog_blogpost_categories` ON (`blog_blogcategory`.`id` = `blog_blogpost_categories`.`blogcategory_id`) WHERE `blog_blogpost_categories`.`blogpost_id` = 1 LIMIT 21; args=(1,)
[<BlogCategory: BlogCategory object>, <BlogCategory: BlogCategory object>]
2−1)リレーション先のレコードが存在しなくても NotFound は発生しない
>>> post2 = BlogPost.objects.filter(pk=2)[0]
(0.001) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 2 LIMIT 1; args=(2,)
>>> post2.categories.all()
(0.001) SELECT `blog_blogcategory`.`id`, `blog_blogcategory`.`title` FROM `blog_blogcategory` INNER JOIN `blog_blogpost_categories` ON (`blog_blogcategory`.`id` = `blog_blogpost_categories`.`blogcategory_id`) WHERE `blog_blogpost_categories`.`blogpost_id` = 2 LIMIT 21; args=(2,)
[]


 

3)select_related を使うことで、一度のクエリで多対一、一対一のリレーション先のオブジェクトを取得してキャッシュしてくれる

なお、

  • null=False(デフォルト) の ForeignKey の場合は INNER JOIN で結合
  • null=True の ForeignKey の場合は LEFT OUTER JOIN で結合

となる。

>>> post1 = BlogPost.objects.filter(pk=1).select_related('user', 'image')[0]
(0.001) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content`, `blog_blogimage`.`id`, `blog_blogimage`.`caption`, `blog_blogimage`.`image_url`, `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `blog_blogpost` LEFT OUTER JOIN `blog_blogimage` ON (`blog_blogpost`.`image_id` = `blog_blogimage`.`id`) INNER JOIN `auth_user` ON (`blog_blogpost`.`user_id` = `auth_user`.`id`) WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)
>>> post1.user
<User: user-1>
>>> post1.image
<BlogImage: BlogImage object>


 

4)select_related の引数を指定しない場合は、多対一、一対一になっているリレーション先を全て取得してくれるのではなく、null=False の ForeignKey のみが取得対象

なので、select_related の引数は明示的に指定しましょう。

>>> post1 = BlogPost.objects.filter(pk=1).select_related()[0]
(0.001) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content`, `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `blog_blogpost` INNER JOIN `auth_user` ON (`blog_blogpost`.`user_id` = `auth_user`.`id`) WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)
>>> post1.user
<User: user-1>
>>> post1.image
(0.000) SELECT `blog_blogimage`.`id`, `blog_blogimage`.`caption`, `blog_blogimage`.`image_url` FROM `blog_blogimage` WHERE `blog_blogimage`.`id` = 1; args=(1,)
<BlogImage: BlogImage object>


 

5)prefetch_related を使うことで、先行してクエリを発行して、一対一、多対一、一対多、多対多のリレーション先のオブジェクト(群)を取得してキャッシュしてくれる

prefetch_related の場合は、JOIN 句を使うのではなく、クエリを別々に発行して、プログラム的にオブジェクト内に結合してくれます。クエリの総数が減ることはありませんので、クエリの発行数を減らすという目的で prefetch_related を使用することはできません。


(多対一の場合)

>>> post1 = BlogPost.objects.filter(pk=1).prefetch_related('user')[0]
(0.001) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)
(0.000) SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` IN (1); args=(1,)
>>> post1.user
<User: user-1>
>>> post1.__dict__
{'user_id': 1L, 'title': u'', '_user_cache': <User: user-1>, '_state': <django.db.models.base.ModelState object at 0x7f2509033310>, 'content': u'post-1', 'image_id': 1L, '_prefetched_objects_cache': {}, 'id': 1L}


(一対多の場合)

>>> user1 = User.objects.filter(pk=1).prefetch_related('blogposts')[0]
(0.001) SELECT `auth_user`.`id`, `auth_user`.`password`, `auth_user`.`last_login`, `auth_user`.`is_superuser`, `auth_user`.`username`, `auth_user`.`first_name`, `auth_user`.`last_name`, `auth_user`.`email`, `auth_user`.`is_staff`, `auth_user`.`is_active`, `auth_user`.`date_joined` FROM `auth_user` WHERE `auth_user`.`id` = 1 LIMIT 1; args=(1,)
(0.000) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`user_id` IN (1); args=(1,)
>>> user1.blogposts.all()
[<BlogPost: BlogPost object>, <BlogPost: BlogPost object>]


(多対多の場合)

>>> post1 = BlogPost.objects.filter(pk=1).prefetch_related('categories')[0]
(0.000) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)
(0.000) SELECT (`blog_blogpost_categories`.`blogpost_id`) AS `_prefetch_related_val_blogpost_id`, `blog_blogcategory`.`id`, `blog_blogcategory`.`title` FROM `blog_blogcategory` INNER JOIN `blog_blogpost_categories` ON (`blog_blogcategory`.`id` = `blog_blogpost_categories`.`blogcategory_id`) WHERE `blog_blogpost_categories`.`blogpost_id` IN (1); args=(1,)
>>> post1.categories.all()
[<BlogCategory: BlogCategory object>, <BlogCategory: BlogCategory object>]
>>> post1.categories.__dict__
{'source_field': <django.db.models.fields.related.ForeignKey: blogpost>, 'reverse': False, 'source_field_name': 'blogpost', '_constructor_args': ((<BlogPost: BlogPost object>,), {}), 'creation_counter': 32, 'target_field_name': u'blogcategory', '_inherited': False, '_db': None, 'query_field_name': u'blogposts', '_hints': {}, 'prefetch_cache_name': 'categories', 'instance': <BlogPost: BlogPost object>, 'through': <class 'blog.models.BlogPost_categories'>, 'core_filters': {u'blogposts__id': 1L}, 'symmetrical': False, 'model': <class 'blog.models.BlogCategory'>, 'related_val': (1L,), 'target_field': <django.db.models.fields.related.ForeignKey: blogcategory>, 'name': None}


 
なお、filter() ではクエリが再発行されてしまうようです。all() を先取りしてキャッシュしているのかな。。

>>> post1 = BlogPost.objects.filter(pk=1).prefetch_related('categories')[0]
>>> post1.categories.filter()
(0.000) SELECT `blog_blogcategory`.`id`, `blog_blogcategory`.`title` FROM `blog_blogcategory` INNER JOIN `blog_blogpost_categories` ON (`blog_blogcategory`.`id` = `blog_blogpost_categories`.`blogcategory_id`) WHERE `blog_blogpost_categories`.`blogpost_id` = 1 LIMIT 21; args=(1,)
[<BlogCategory: BlogCategory object>, <BlogCategory: BlogCategory object>]


 
あと、prefetch_related の引数を指定しないと、リレーション先を取得してくれないように見えます。どこまでリレーション先を取ってくるか分からないから、明示的に指定しないと取得しないという仕様なのかもしれません。

なので、prefetch_related の引数は明示的に指定しましょう。

>>> post1 = BlogPost.objects.filter(pk=1).prefetch_related()[0]
(0.000) SELECT `blog_blogpost`.`id`, `blog_blogpost`.`image_id`, `blog_blogpost`.`user_id`, `blog_blogpost`.`title`, `blog_blogpost`.`content` FROM `blog_blogpost` WHERE `blog_blogpost`.`id` = 1 LIMIT 1; args=(1,)

 

まとめ

select_related や prefetch_related の使い道としては主に、後続の処理で何度もアクセスされるオブジェクトを先に取得しておきたいときに使うのがよい と思います。特に、一側のオブジェクトを取得する場合でクエリの本数を減らしたいなら select_related を、多側のオブジェクト群を取得したいなら prefetch_related を検討すればよいでしょう。


Django ORM は、クエリ(SQL)をあまり意識せずに使えて便利な半面、何も分からずに使っていると、クエリの本数や実行速度がボトルネックになって、応答速度の遅い処理を作ってしまいがちです。

クエリの実行を含む処理の応答速度が遅い場合は、まずはクエリを確認し、select_related や prefetch_related を使って実行速度を改善することも検討すべきです。

*1:一対一リレーションの挙動は、多対一リレーションの場合とほぼ同じでした。双方から ForeignKey が付与されていると考えればよいのかも。

*2:PyCharm Professional のデータベース機能を使いました。http://akiyoko.hatenablog.jp/entry/2016/03/13/141600 を参照