N+1問題という人類の敵に立ち向かうための方法について書きます。
開発環境
以下の環境を想定します。
- Django2.0以上
- python3系
N+1問題とは
ここでは詳しく解説しませんので、排除すべき人類の敵と認識してもらえればとりあえず大丈夫です。
根絶やし方法
以下のサイクルを回していくことでできると思います。
テストコード書きつつ開発->テストでN+1問題を検知->見つけ出す->絶やす
用意するもの
以下の二つです
- nplusone N+1問題を検出してくれるライブラリです
- django-debug-toolbar Djangoのアプリに様々なデバッグ情報を表示できるツールバーを用意してくれます
また以前書いた記事のmodel
やらserializer
の大部分をそのまま流用して書いています。横着者ですみません。
準備編
用意するもの2つをインストールします。pip install nplusone django-debug-toolbar
したらばapp/settings.py
に追記したりapp/urls.py
を編集したりしてこれらライブラリを使えるようにしたり、ツールバーを有効にします。
INSTALLED_APPS += [
'nplusone.ext.django',
'debug_toolbar',
]
MIDDLEWARE += [
'nplusone.ext.django.NPlusOneMiddleware',
'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',
]
if DEBUG:
def show_toolbar(request):
return True
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK" : show_toolbar,
}
from .views import SightseeingSpotViewSet
from django.conf.urls import include
from django.conf.urls import url
from django.urls import path
from rest_framework import routers
from django.conf import settings
router = routers.DefaultRouter()
router.register(r'^sightseeingspots', SightseeingSpotViewSet)
urlpatterns = [
url(r'^api/', include(router.urls)),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
簡単に解説しますと、DEBUG
がTrue
の場合にデバッグツールバーを表示する設定と、あとはN+1問題を検知できるようにするための準備をしてます。
それと、テストコードに与える初期データとしてこのJSONをapp/fixtures.json
という名前で使います。
テストコード編
さて、以前に書いた伏見稲荷大社がなんとかという記事にて観光名所に関するAPIを作ったはずです。というわけで/api/sightseeingspots/
で観光地の一覧が取得できるAPIのテストをapp/test.py
に書いてみます。
settings.NPLUSONE_RAISE
にTrue
を設定しているのでN+1問題が発生したら例外がスローされます。
from django.conf import settings
from django.test import TestCase
import json
class SightseeingSpotsTest(TestCase):
fixtures = [
'app/fixtures.json',
]
def setUp(self):
settings.NPLUSONE_RAISE = True
def test_api_sightseeingspots(self):
response = self.client.get('/api/sightseeingspots/')
self.assertEqual(json.loads(response.content), [
{
'id': 1,
'name': '伏見稲荷大社',
'postcode': '612-0882',
'prefecture': '京都府',
'address': '京都市伏見区深草藪之内町68'
},
{
'id': 2,
'name': '広島平和記念資料館',
'postcode': '730-0811',
'prefecture': '広島県',
'address': '広島市中区中島町1-2'
},
{
'id': 3,
'name': '厳島神社',
'postcode': '739-0541',
'prefecture': '広島県',
'address': '廿日市市宮島町1-1'
},
{
'id': 4,
'name': '東大寺',
'postcode': '630-8211',
'prefecture': '奈良県',
'address': '奈良市雑司町406-1'
},
{
'id': 5,
'name': '新宿御苑',
'postcode': '160-0014',
'prefecture': '東京都',
'address': '新宿区内藤町11'
}
])
書いたら実行してみます。
$ python manage.py test
すると、nplusone.core.exceptions.NPlusOneError
という例外がスローされてテストが失敗したと思います。ここからが本番です。
いたぞ!N+1問題だ!○○せ!
さて、nplusone
のおかげでN+1問題があるということは分かりました。これを無くしていくわけですが、方法としては色々考えられそうです。よく訓練されたDjango開発者ならコードを眺めただけで「ここだな」みたいなことができるかもしれませんが私はその境地に達していないので何かしら手がかりが欲しいです。
そこで私の場合はdjango-debug-toolbar
を活用します。これのSQLタブから実行されたSQLを覗くことができます。早速/api/sightseeingspots/
を開いて見てみます。
これを見ると1回全件取得した後にN回SQLが実行されているのが分かります(ここではN=5)。どうやらここをなんとかすれば良さそうです。
解決編
要するにJOINしてprefecture
も全件取得するときに持ってくれば良いわけです。それをDjangoのコードではどう書くのかというとselect_related()
を使います。app/views.py
を以下のように編集します。
from .models import SightseeingSpot
from .serializers import SightseeingSpotSerializer
from rest_framework import viewsets
class SightseeingSpotViewSet(viewsets.ModelViewSet):
queryset = SightseeingSpot.objects.all()
serializer_class = SightseeingSpotSerializer
def get_queryset(self):
return self.queryset.select_related()
では答え合わせします。python manage.py test
でテストを実行して例外がスローされずに無事通過したら、正解と判断します。たぶん通ると思います。
おわり
というわけでこのやり方でDjangoで開発する際にN+1問題を発見して根絶やしにしていけると思います。N+1問題は見つけ次第○○しましょう。では○○○○、○○○○○。また○○○○○○!