フルスタックチャンネル
サインアップサインアップ
ログインログイン
利用規約プライバシーポリシーお問い合わせ
Copyright © All rights reserved | FullStackChannel
解決済
多対多のN+1問題
DRF
個人開発
データベース
ごま
2023/06/23 03:33

実現したいこと

  • N+1問題を解決したい

発生している問題

いつも動画・ブログでお世話になっております。 <br />
DRFで多対多の参照先がさらにForeignKeyのフィールドを読み込む場合<br />
(下記の例だとBlog ←→ sponsor → user, price)にviewsでprefetchを指定してもN+1回読み込まれてしまいます。 <br />

ソースコード

※該当する箇所だけ簡略化して書いています。

# models.py
class User(TimeStampedModel):
    name = models.CharField()

class Price(TimeStampedModel):
    price = models.PositiveIntegerField()

## 記事を応援してくれた人という設定
class Sponsor(TimeStampedModel):
    user = models.ForeignKey(User)
    price = models.ForeignKey(Price)

class Blog(TimeStampedModel):
    title = models.CharField()
    ...
    sponsor = models.ManyToManyField(Sponsor, blank=True)

## 記事のいいね機能
class Likes(TimeStampedModel):
    blog = models.ForeignKey(Blog)
    user = models.ForeignKey(User)

# serializer.py
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = '__all__'

class PriceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Price
        fields = '__all__'

class SponsorSerializer(serializers.ModelSerializer):
    user = UserSerializer()
    price = PriceSerializer()
    class Meta:
        model = Sponsor
        fields = '__all__'

class BlogSerializer(serializers.ModelSerializer):
    sponsor = SponsorSerializer(many=True)
    likes = serializers.SerializerMethodField()

    def get_likes(self, obj):
        likes = LikeData.objects.filter(blog=obj).count()
        user = self.context['request'].user
        is_liked = LikeData.objects.filter(user=user, blog=obj).exists()

        return {'likes': likes, 'is_liked': is_liked}    

    class Meta:
        model = Blog
        fields = '__all__'


# views.py
class BlogViewSet(viewsets.ModelViewSet):
    queryset = Blog.objects.all()
    serializer_classes = BlogSerializer

自分で試したこと

1 . querysetに対してPrefetch

class BlogViewSet(viewsets.ModelViewSet):
    queryset = Blog.objects.all().prefetch_related(Prefetch('sponsor', Sponsor.objects.select_related('user', 'price')))

としましたが、UserとPriceが何回も重複して呼ばれてしまいました。

2 . django-auto-prefetchingの導入
SerializerMethodFieldがあると自動で認識してくれず、少し複雑になり、エラーが起こったため諦めました

質問

実際のものとは少し変えて書いてあるので、テーブル構造を変えて対応するのではなく、クエリ側での改善を試みたいです。
Prefetchの理解に誤りがあるのでしょうか?
それともこのケースだと生SQLで対応しないと無理なのでしょうか。
知見をいただきたいです!よろしくお願いいたします。

補足情報

バージョン

  • Django==4.0.1
  • djangorestframework==3.13.1
  • Python 3.9.10
回答 6件
login
回答するにはログインが必要です
ごま
約3年前

ご回答ありがとうございます!

related_nameを設定していない場合は、_setが自動的につくので、下記のようにしてみてはいかがでしょうか。

不勉強で知らなかったです。

ただ質問の際には省略してしまいましたが、実際のモデルだとrelated_nameつけていました・・

## PriceとUserはrelated_nameなし

class Sponsor(TimeStampedModel):
    user = models.ForeignKey(User, related_name='user', on_delete=models.CASCADE)
    price = models.ForeignKey(Price, related_name='price', on_delete=models.CASCADE)

class Blog(TimeStampedModel):
    title = models.CharField()
    ...
    sponsor = models.ManyToManyField(Sponsor, related_name='sponsor', blank=True)

またrelated_nameついている状態で_setを付きのものも試しましたが効果でず(Invalid Parameter)でした。

1
ごま
約3年前

ListAPIViewはpermission_classesをAllowAnyにして、誰でも見れる状態にして、

開発段階で権限周りをすべてAllowAnyにしていましたが、たしかにactionによって分けるべきですね!

分けて実装し直してみます。ご回答がありがとうございました!!

ベストアンサーは質問が2転してしまったため、最初の質問タイトルに最も合致してるものを選ばさせていただきました。

1
はる@講師
約3年前

私であればModelViewSetとListAPIViewクラスを分けて実装します。

ブログを想定している場合は、

ListAPIViewはpermission_classesをAllowAnyにして、誰でも見れる状態にして、

ModelViewSetは、新規登録や編集、削除なので、トークンが必要とする実装をよくします。

1
ごま
約3年前

ご回答ありがとうございます!!
先生のコメントに返信ができなかったのでこちらに失礼します。

sponsorのrelated_nameを外した状態で_setをつけても変わらないでしょうか?

こちらも試してみましたが結果変わらずでした。
ただ、色々と試したところどうやら原因はここではないような気がしてきました。

度々後出しですみませんが、実は上記のviews内でgetとcreateでserializerを変える処理をしていました。

getの際はfieldを展開して表示させ、createの際はidだけでの登録を意図していたためです。

# serializer.py
## Create用
class BlogSerializer(serializers.ModelSerializer):
    class Meta:
        model = Blog
        fields = '__all__'

## list, retrieve用
class BlogRefSerializer(serializers.ModelSerializer):
    sponsor = SponsorSerializer(many=True)
    likes = serializers.SerializerMethodField()
    class Meta:
        model = Blog
        fields = '__all__' 

# views.py
class BlogViewSet(viewsets.ModelViewSet):
    queryset = Blog.objects.all()
    serializer_classes = {
        "create": BlogSerializer
    }
    default_serializer_class = BlogRefSerializer

    def get_serializer_class(self):
        serializer = self.serializer_classes.get(self.action, self.default_serializer_class)
        print(serializer)
        return serializer

django-debug-toolbarでブラウザからアクセスするDRFのページ(urls.pyに登録したパスのもの)を確認していたのですが、どうやらこのページはcreate用のserializerも呼ばるようで、Ref1回, BlogSerializer2回と合計3回も処理が走っているようでした。

prefetchを適用したquerysetはすでにBlogRefSerializerを最適化してくれていたのですが、BlogSerializerは最適化されおらず、裏でBlogSerializerを呼んでいたため見かけのSQLが重くなっているようでした。

別の質問となってしまいすいません。なるべくurlを増やしたくなかったのでこの形式にしていたのですが、generic.ListAPIViewなどでクラスを分けたほうが良いのでしょうか。

それともserializerが複数回呼ばれるのはdrfのページに限ったものなのでviewsはこのままn+1の探し方を変えるだけで問題ないでしょうか。

1
はる@講師
約3年前

sponsorのrelated_nameを外した状態で_setをつけても変わらないでしょうか?

1
はる@講師
約3年前

related_nameを設定していない場合は、_setが自動的につくので、下記のようにしてみてはいかがでしょうか。

queryset = Blog.objects.all().prefetch_related(Prefetch('sponsor_set', queryset=Sponsor.objects.select_related('user', 'price')))
1