Djangoのselect_relatedとN+1問題

webアプリではオブジェクトの一覧を表示する画面がよくでてきます。
一覧系の画面が妙に遅い場合、SQLが大量に発行されている(N+1問題)場合があります。


例えばDjangoで、以下のように関連しているモデルがあるとします。(Django 1.4)


books/models.py

from django.db import models


class City(models.Model):
    name = models.CharField(max_length=30)


class Person(models.Model):
    hometown = models.ForeignKey(City)
    name = models.CharField(max_length=100)


class Book(models.Model):
    author = models.ForeignKey(Person)
    title = models.CharField(max_length=100)

Book -> Person -> Cityと関連しています。


Bookの一覧を表示する画面を考えてみます。


GenericViewを使って一覧画面と、詳細画面を定義します

books/urls.py

from django.conf.urls import patterns, url
from books.models import Book
from django.views.generic import DetailView, ListView

urlpatterns = patterns('',
       url(r'^$',
           ListView.as_view(
               queryset=Book.objects.all()[:5],
               context_object_name='latest_book_list',
               template_name='books/index.html'),
           name='book_list'
           ),
       url(r'^(?P<pk>\d+)/$',
           DetailView.as_view(
               model=Book,
               template_name='books/show.html'),
           name='book_show'
       ),
)


リスト表示のhtmlはこんな感じ

templates/books/index.html

<html>
<head>
    <title></title>
</head>
<body>
{% if latest_book_list %}
    <ul>
        {% for book in latest_book_list %}
            <li>{{ book.title }} - {{ book.author.name }} (live in  {{ book.author.hometown.name }})
                <a href="{% url book_show book.id %}">show</a></li>
        {% endfor %}
    </ul>
{% else %}
    <p>No books are available.</p>
{% endif %}
</body>
</html>


この状態でBookのレコードが5つある場合、Bookの一覧を表示しようとすると、画面を表示するためにSQLが11回発行されます。



ひとつは一覧を取得するためのSQL

SELECT "books_book"."id", "books_book"."author_id", "books_book"."title" FROM "books_book" LIMIT 5


あとはBookオブジェクト1つにつき2回ずつ発行されるので 1 + (5*2)で11回になります。

SELECT "books_person"."id", "books_person"."hometown_id", "books_person"."name" FROM "books_person" WHERE "books_person"."id" = 1
SELECT "books_city"."id", "books_city"."name" FROM "books_city" WHERE "books_city"."id" = 1

この問題に対処するため、DjangoのQuerySetにはselect_relatedという関数が用意されています。


urls.pyで設定しているquerysetを修正します。

  queryset=Book.objects.select_related().all()[:5],
  #queryset=Book.objects.all()[:5],


select_relatedを使うと、11回発行されていたSQLが以下の1回になります。


SELECT "books_book"."id", "books_book"."author_id", "books_book"."title", "books_person"."id", "books_person"."hometown_id", "books_person"."name", "books_city"."id", "books_city"."name" 
FROM "books_book" 
INNER JOIN "books_person" ON ("books_book"."author_id" = "books_person"."id")
INNER JOIN "books_city" ON ("books_person"."hometown_id" = "books_city"."id")
LIMIT 5


とはいえ、モデルの関連が深すぎると、SQLが巨大になりすぎて、かえってパフォーマンスが劣化することもあるようです。
そんな場合はselect_relatedにキーワード引数としてdepthを指定すると、追跡する関連の深さを指定することができます。

queryset=Book.objects.select_related(depth=1).all()[:5],

この状態だとSQLの発行は6回になります。

一覧の取得

SELECT "books_book"."id", "books_book"."author_id", "books_book"."title", "books_person"."id", "books_person"."hometown_id", "books_person"."name" 
FROM "books_book" 
INNER JOIN "books_person" ON ("books_book"."author_id" = "books_person"."id") 
LIMIT 5

一覧の項目1つについて以下が1回ずつ。計6回。

SELECT "books_city"."id", "books_city"."name" FROM "books_city" WHERE "books_city"."id" = 1


一覧系画面が重い場合、django_debug_toolbarとかで発行されるSQLをチェックして、select_relatedの使用を検討すればよいと思います。