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の使用を検討すればよいと思います。