😰

djangoのORMは直感的だけど、クエリめちゃ飛んじゃいます😰な話

2022/05/05に公開1

本題の前に個人的な意見

私は、DjangoのORMは楽に実装できるから好きです!
というのも、私がjava触っていたとき(3年前くらい)はmy batisを使っており、それより遥かに記述量が少なく、モデルベースで直感的だからです。
しかしながら、djangoのORMって人によって好き嫌い分かれる印象です。
その中でも静的型付け言語 → pythonのような流れを経験したエンジニアは結構好まないな〜と感じます。
少し偏ってるかも🤨

THE直観的

例えば、記事とコメントを例にしてみます!

class Article(models.Model):
    title = models.CharField(max_length=100)
    body = models.TextField()

class Comment(models.Model):
    article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='comments')
    body = models.TextField()

記事はコメントに対して、1対他の関係性です。

このような場合はどうでしょう?
コメントを全件取得したい

all_comments = Comment.objects.all()
print(all_comments)

The簡単って感じですね!!

発行されたSQLを確認してみると。。。

SELECT
    project_comment.id,
    project_comment.article_id,
    project_comment.body
FROM
    project_comment;
// アプリ名がprojectだと過程します
// 実際はLIMITで件数が絞られていますが、割愛します

だろな〜って感じですね!

次の場合はどうでしょう?
全てのコメントが紐づく記事を取得したい

all_comments = Comment.objects.all()
for comment in all_comments:
    print(comment.article) # モデルでFK指定しているため、順参照できる

シンプルで、そりゃそうだろって感じで直観的ですね!

発行されたSQLを確認してみると。。。

SELECT project_comment.id, project_comment.article_id, project_comment.body FROM project_comment; # 4件取得できたと過程する
SELECT project_article.id, project_article.title, project_article.body FROM project_article WHERE id = [id=1のコメントのarticle_id];
SELECT project_article.id, project_article.title, project_article.body FROM project_article WHERE id = [id=2のコメントのarticle_id];
SELECT project_article.id, project_article.title, project_article.body FROM project_article WHERE id = [id=3のコメントのarticle_id];
SELECT project_article.id, project_article.title, project_article.body FROM project_article WHERE id = [id=4のコメントのarticle_id];

あれ!?!?
なんか多くね!?!?

耳にタコができるわ😠 N+1問題

まさにこれが N+1問題 ですね 😨😨😨
悲しいことに呼ばれる回数分クエリが飛び、joinされていないんですね。。。

joinしたSQLだと。。。

SELECT
    project_comment.id,
    project_comment.article_id,
    project_comment.body,
    project_article.id,
    project_article.title,
    project_article.body
FROM
    project_comment
    INNER JOIN project_article
    ON project_comment.article_id = project_article.id

この様になると思います!

コードをよくよく分解してみると。。。

all_comments = Comment.objects.all() # クエリ1
for comment in all_comments:
    print(comment.article) # クエリ2
  1. コメントを全件取得するクエリ
  2. commentに紐づくarticleを1件取得するクエリ
    (クエリ2がall_comments分ループされる)

values, values_listなども起きます。
Django REST frameworkだとネストでシリアライズした場合も同じで、見えない箇所でクエリが発行されます。

このN+1問題は知らないとついついハマってしまう落とし穴だと思います。

伝家の宝刀🥷 select_related, prefech_related

これはinner join, outer joinを明示的に宣言することです。
都度クエリ発行してDB接続してしまうため、先にjoinを明示的に宣言し、1回のクエリでガツッと取得します。

all_comments = Comment.objects.select_related('article').all()
for comment in all_comments:
    print(comment.article) # クエリ飛ばない

逆参照の場合はこのようになります
ある記事(id=5)に紐づく全てのコメントを取得したい

article = Article.objects.prefetch_related('comments').get(id=5)
comments = article.comments.all()
for comment in comments:
    print(comment.body)  # 先に取得しているのでクエリは発行されない

ちなみに、Prefetchを使えばどのようにクエリを取得するかも制御できます。

article = Article.objects.prefetch_related(Prefetch(queryset=*クエリ文)).all()

最後に

このようにDjangoのORMを使うには、モデルのリレーション関係とどのようにデータが扱われるかを知っている必要があります。
どのようにデータが使われるかを意識してコードを書くのは少し面倒で、シリアライズされたオブジェクトやdataclassなどに詰めることが一般的に設計されると思います。
意識するレイヤーはmy batisでSQL書くのとあまり変わらないですね😅
直観的にモデルを触れて何でもできるのは良さであり、危うさでもあります。
この危険な部分もしっかり理解することがDjangoのORMを扱う上でとても大切で、良さも悪さも知って正しく使えるエンジニアになることが非常に重要だと考えています!

まだ学習始めのエンジニアにとってのdjangoあるあるな落とし穴なので、この記事を通して伝家の宝刀が頭の片隅にでも残ればなと思います!!!

Discussion