【完全版】Django高速化、最適化

# 1. はじめに

Djangoのパフォーマンスを改善する方法はいくつかある。この記事では、最初にパフォーマンス改善前に実施する[プロファイリング](#2-プロファイリング)について説明し、その後に以下4つの改善手法について紹介する。

* [DB最適化](#3-db最適化)
* [キャッシング](#4-キャッシング)
* [適切なレイヤーで処理を行う](#5-適切なレイヤーで処理を行う)
* [Pythonコードの最適化](#6-pythonコードの最適化)
* [Djangoの設計思想に従う](#7-djangoの設計思想に従う)

## 対象読者

Python、またはDjangoに興味のある人
Djangoを使っている人

# 2. プロファイリング

Djangoアプリケーションの最適化を図る前に、まずはプロファイリングを行う必要がある。**性能分析**のことである。プロファイラを用いることで、如何なるクエリがどのように動いているかを調べることができる。次に、**何のために何を改善するのか** をはっきりさせる。メモリの使用量を削減するのか、速度を最適化するのか。何も考えずに単に高速化すると、メモリの使用量が膨大になったり、プログラムが複雑になってしまたりすることがある。(参考 : [時間と空間のトレードオフ](https://ja.wikipedia.org/wiki/%E6%99%82%E9%96%93%E3%81%A8%E7%A9%BA%E9%96%93%E3%81%AE%E3%83%88%E3%83%AC%E3%83%BC%E3%83%89%E3%82%AA%E3%83%95))

プロファイリングには、[決定論的プロファイリング](#決定論的プロファイリング) と [統計的プロファイリング](#統計的プロファイリング) という2つのプロファイリング手法がある。

## 決定論的プロファイリング

逐次的にプログラムを実行しながら、測定していく。
トレーシングという手法を使う。パフォーマンスよりも正確さを重視する。
ただし、負荷がかかる。e.g.) cProfile, Profile, Memray

> すべての 関数呼び出し, 関数からのリターン, 例外発生 をモニターし、正確なタイミングを記録することで、イベント間の時間、つまりどの時間にユーザコードが実行されているのかを計測するやり方。(Django公式ドキュメント)

## 統計的プロファイリング

サンプリングという手法で計測する。
負荷が少ない。プロダクションでのパフォーマンスのオーバーヘッドをより低く、より一貫したものにすることができる。実運用に導入しやすい。 e.g.) memory-profiler

> 統計的プロファイリング (このモジュールでこの方法は採用していません) とは、有効なインストラクションポインタからランダムにサンプリングを行い、プログラムのどこで時間が使われているかを推定する方法。(Django公式ドキュメント)

## 主なProfiler

### [django-debug-tools](https://github.com/jazzband/django-debug-toolbar)

Django開発には必須のツール。DjangoアプリケーションのSQLやAPIの実行履歴や速度などを見ることができる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/743c2213-464b-3f91-dfa9-b318e932f890.png)

### [cProfile & Profile](https://docs.python.org/ja/3/library/profile.html)

両方とも決定論的プロファイリングで、cProfileはc言語でProfileを真似たもの。
拡張するならProfileの方がいい。
DjangoではMiddlewareで **django-cprofile-middleware**というものがある。

:::warn
ベンチマーク用途には [timeit](timeit) のほうが正確な計測結果を求められる
:::

**snakeviz** を使えば可視化することも可能。
使い方は [Profiling in Django](https://medium.com/kami-people/profiling-in-django-9f4d403a394f) に書いてある。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/ed96887c-778b-fa66-83aa-45edbf980418.png)

デフォルトでは円グラフで可視化される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/dc8876ef-618b-f34e-fb87-5dc5332bd853.png)

### [Pyinstrument](https://github.com/joerick/pyinstrument)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/6a1771f2-b647-0710-6227-30048734b3eb.png)

### [Django Querycount](https://github.com/bradmontgomery/django-querycount)

djangoには`querycount.middleware.QueryCountMiddleware`というmiddlewareがある。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/5026bec1-bdd9-3b75-9083-2671be3b8ff2.png)

###  [Django Silk](https://github.com/jazzband/django-silk)

Djangoのmiddlewareの名前は`silk.middleware.SilkyMiddleware`である。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/e45aded8-6bc5-600a-53f6-a8983b6c7c58.png)

###  [Line Profiler](https://github.com/pyutils/line_profiler)

処理時間を計測したいファイルの関数に`@profile`をつけて測定する。

```python
import line_profiler
import atexit

profile = line_profiler.LineProfiler()
atexit.register(profile.print_stats)

# 測定したい関数にアノテーションを付ける
@profile
def hoge():
    ・・・・
```

```出力例
Timer unit: 1e-06 s

File: pystone.py
Function: Proc2 at line 149
Total time: 0.606656 s

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   149                                           @profile
   150                                           def Proc2(IntParIO):
   151     50000        82003      1.6     13.5      IntLoc = IntParIO + 10
   152     50000        63162      1.3     10.4      while 1:
   153     50000        69065      1.4     11.4          if Char1Glob == 'A':
   154     50000        66354      1.3     10.9              IntLoc = IntLoc - 1
   155     50000        67263      1.3     11.1              IntParIO = IntLoc - IntGlob
   156     50000        65494      1.3     10.8              EnumLoc = Ident1
   157     50000        68001      1.4     11.2          if EnumLoc == Ident1:
   158     50000        63739      1.3     10.5              break
   159     50000        61575      1.2     10.1      return IntParIO
```

### [memory_profiler](https://pypi.org/project/memory-profiler/)

同じく`@profile()`デコレータをつけることによって使用しているメモリを表示する。

```python
from memory_profiler import profile

# precisionを設定すると精度が上がる
@profile
def hoge(precision=4):
　　　・・・・
```

```:出力例
Line #    Mem usage    Increment  Occurrences   Line Contents
============================================================
     3   38.816 MiB   38.816 MiB           1   @profile
     4                                         def my_func():
     5   46.492 MiB    7.676 MiB           1       a = [1] * (10 ** 6)
     6  199.117 MiB  152.625 MiB           1       b = [2] * (2 * 10 ** 7)
     7   46.629 MiB -152.488 MiB           1       del b
     8   46.629 MiB    0.000 MiB           1       return a
```

### [memray](https://pypi.org/project/memray/)

メモリの使用量を見やすく表示する。実行負荷や余分な命令が増えがちなトレーシング手法を使っているが、極力負荷を減らしたツールとして登場した。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/286ae441-384e-b993-39eb-3750806d7d40.png)

### python -X importtime

`-X importtime`をつけることで、importにどれだけ時間が掛かっているかを調べれる。

```terminal
$ python -X importtime -c 'import django'
import time: self [us] | cumulative | imported package
import time:       136 |        136 |   _io
import time:        20 |         20 |   marshal
import time:       256 |        256 |   posix
import time:       261 |        672 | _frozen_importlib_external
...
import time:       499 |       6898 |     subprocess
import time:       309 |        309 |           weakref
import time:        60 |         60 |               org
import time:        10 |         70 |             org.python
import time:         7 |         77 |           org.python.core
import time:       187 |        572 |         copy
import time:       228 |        799 |       django.utils.functional
import time:       183 |        982 |     django.utils.regex_helper
import time:       340 |      12228 |   django.utils.version
import time:       206 |      12434 | django
```

参考 : [Django: How to profile and improve startup time](https://adamj.eu/tech/2023/03/02/django-profile-and-improve-import-time/)

また、[tuna](https://github.com/nschloe/tuna)を使えば、可視化することができる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/b54109d6-b7c2-7803-0fe9-547ac543b862.png)

## サードパーティのツール

以下のサービスでは、実際のユーザーエクスペリエンスをリモートの HTTP クライアントの観点からシミュレートしてくれる。

* [Yahoo's Yslow](https://yslow.org/)
* [Google PageSpeed](https://pagespeed.web.dev/?hl=ja)

## クエリの表示

### .query

`QuerySet.query`でクエリを確認できる。しかし、`.query`で毎回確認しなければいけない、saveなどの処理はSQLを確認できないといった問題点がある。

```python
>>> print(Book.objects.filter(id=1).query)
SELECT "book"."id", "book"."name" FROM "book" WHERE "book"."id" = 1
```


### django.db.connection.queries

`django.db.connection.queries`を使えば`.query`の問題を解決できる。

```python
from django.db import connection
for q in connection.queries:
	print(q)
```

上記を実行すると、以下のようにクエリが出力され、実行時間も確認できる。

```:実行結果
{'sql': 'SELECT "user"."id", 
    ・・・・
, 'time': '0.000'}
```
### QuerySet.explain()

QuerySet.explain() を使うと、使用されているインデックスや結合など、クエリの実行情報などが得られる。

### DBのログを見る

ログを見ることでどんなSQLが発行されているか調べられる。
設定によって、遅いクエリだけを見ることもできる。

# 3. DB最適化

Djangoは主にDjango ORMを用いてDB操作を行うが、Django ORMによるDBへのアクセスは非常にコストが高い。ここでは以下4つの方法について説明する。

* [Djangoのアルゴリズムの改善](#djangoのアルゴリズムの改善)
* [DBチューニング(全体最適化)](#dbチューニング)
* [SQLチューニング(個別最適化)](#sqlチューニング)
* [別のORMを使用する](#別のormを使用する)

## Djangoのアルゴリズムの改善

### QuerySet Tips

| 速い | 遅い |
| ---- | ---- |
| QuerySet.contains(obj) | if obj in queryset  |
| Queryset.count() | len(queryset) |
| Queryset.exit() | if queryset |

### iterator()

QuerySet の`all()`や`filter()`で大量のオブジェクトを取得する際に、QuerySet でキャッシングするのを防ぐ。メモリ消費量が少なくなる。ただし、使用しているDBによって動作に違いがあり、MySQLでは個別にチャンクする実装を行わなければならない(参考: [大量のデータを取得する際に気を付けること](https://zenn.dev/masako/articles/2fad904fdfc514))

### update()

`Queryset.udpate()`や`Queryset.delete()`を使って一括でupdateする。

```python
Entry.objects.filter(pub_date__year=2007).update(headline="Everything is the same")
```
### 外部キーの取得

DBで確認すれば明らかだが、外部キーは`{テーブル名}_id`と名前で保存されている。

○ `book.user_id`
× `book.user.id`


### bulk処理

bulkで一括で処理を行う。

* bulk_create() 
* bulk_update() 
* add(xxx, xxx, ・・・)
* remove(xxx, xxx, ・・・)

```python
Entry.objects.bulk_create(
    [
        Entry(headline="This is a test"),
        Entry(headline="This is only a test"),
    ]
)
```

ただし、`add()`と`remove()`で以下のような場合がある。

```python
my_pizza.toppings.remove(pepperoni)
your_pizza.toppings.remove(pepperoni, mushroom)
```

この場合`Q`, `through`を用いると一気に処理を行える。

```python
from django.db.models import Q

PizzaToppingRelationship = Pizza.toppings.through
PizzaToppingRelationship.objects.filter(
    Q(pizza=my_pizza, topping=pepperoni)
    | Q(pizza=your_pizza, topping=pepperoni)
    | Q(pizza=your_pizza, topping=mushroom)
).delete()
```

### RawSQL

`RawSQL`を使うことで、SQLを明示的にクエリに追加することができます。。SQLインジェクションなどに注意。

### N+1問題

`select_related`, `prefetch_related`, `prefetch_related_objects`を用いることでN+1問題を解決できる。

参考 : [select_relatedとprefetch_related - Just Python](https://just-python.com/document/django/orm-query/select_related-prefetch_related)

### ぐるぐるSQL

ぐるぐるSQLはN+1問題とは異なる。
例えばテーブル全てを引き出すところをわざわざ1行ずつ取ってくること。
主にpythonのアルゴリズムの問題が原因かと考えられる。

[ぐるぐるSQLという用語は止めてくださいという話](https://qiita.com/abe_masanori/items/1a2b9c1f1069c43237f8)

### インデックスの効いたカラムでフィルタリングする

インデックスとは何かについては、DBの話になるので、ここでは割愛。(次の記事で紹介します)

```python
entry = Entry.objects.get(id=10) # インデックスが効くので高速
entry = Entry.objects.get(headline="News Item Title") #インデックスが効かないので遅い
entry = Entry.objects.get(headline__startswith="News") # 前方一致検索等はさらに遅い
```

## DBチューニング

DBの設定ファイルやパラメータを適切な値にすることで、スループットの向上を図る。
ここでは詳しく述べない。

[MySQLデータベースのパフォーマンスチューニング](https://qiita.com/mm-Genqiita/items/3ef91f6df6c15c620ec6)を参考。

## SQLチューニング

これに関しては説明すると長くなるので、別記事で詳細を書く予定である。(ほぼ完成している)

* `slow_query_log` を確認する
* 適切なインデックスをはる

## 別のORMを使用する

![logo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/156096/9837845e-5c2a-d49c-552d-05c39fcc2e05.png)

従来の **Django ORM** の問題点として、以下のようなものがある。

* 複雑なクエリになると実現が難しい
* **N+1問題** などがよく発生する

**SQLAlchemy**を導入することで、これらの問題を解決することができる。
Djangoに **Aldjemy** を導入すると、**SQLAlchemy** と **Django ORM** を併用できる。

導入方法、使い方　: [Django ORMに苦しむ諸氏に贈る「Aldjemy」のススメ](https://xmart-techblog.hatenablog.com/entry/2023/04/17/113000)

# 4. キャッシング

## QuerySet

QuerySet は速い。

```python
# 速い
users.count()
# オブジェクトを数えてるから遅い 
len(users)
# djangoのテンプレートのfilter
{{ my_bicycles|length }}
```

QuerySet は遅延評価される。

```python
>>> articles = Article.objects.all()  # DBアクセスなし(クエリセットを構築するだけ)
>>> len(articles[1:6])  # キャッシュが空のためDBアクセスあり(クエリセットの部分評価　→ 過去にキャッシュは設定されていないので、ここでもキャッシュは保存されない)
>>> len(articles[1:6])  # キャッシュが空のためDBアクセスあり(クエリセットの部分評価　→ 過去にキャッシュは設定されていないので、ここでもキャッシュは保存されない)
>>> len(articles)  # キャッシュが空のためDBアクセスあり(クエリセットの全部評価 → キャッシュが設定される)
>>> len(articles)  # DBアクセスなし(クエリセットの全部評価 → キャッシュが設定されているので、DBアクセスなし)
>>> len(articles[1:6])  # DBアクセスなし(クエリセットの部分評価 → キャッシュが設定されているので、DBアクセスなし)
```


以下の`foo.py`はキャッシュされている。

```python:foo.py
def foo():
    c = []
    questions = Question.objects.all()
    for qs in questions:
        c.append(qs.id)
foo()
```

```terminal:計測結果
$ time python foo.py
0.011981964111328125
```

以下の`hoge.py`はキャッシュが使われていない。

```python
def hoge():
    c = []
    questions = Question.objects.all()
    time1 = time.time()
    for i in range(1113):
        c.append(questions[i].id)
hoge()
```

```terminal:計測結果
$ time python hoge.py
0.329456090927124
```

## with テンプレートタグ

`{% with ~%}`を使うとQuerySetをキャッシングできる。

複雑な表現の変数の値をキャッシュし、簡単な名前で参照できるようにする。呼出しコストの高いメソッド (例えばDBを操作するようなメソッド) に何度もアクセスするときに有効。

```django
{% with total=business.employees.count %}
    {{ total }} employee{{ total|pluralize }}
{% endwith %}
```

## データベースによるキャッシング

MemcachedやRedisなどを使うことでキャッシュを保存することができる。
使い方は以下のリンクを参考。

[Django's cache framework](https://docs.djangoproject.com/ja/5.0/topics/cache/)

# 5. 適切なレイヤーで処理を行う

低レイヤーの方が高速になる。
例えば、DBはPythonより高速に処理ができ、PythonはTemplate言語より処理が速い。

下記は先ほども紹介した例。

```python
# QuerySetはDBを操作するので高速
book.count()

# 普通のPython
len(book)
```

テンプレート言語で行うと遅すぎる。

```django
{{ book | length }}
```

他にも以下のような例がある。

* フィルタリングはPythonではなく、QuerySetの`filter` や `exclude`, `F expressions`で対象を絞る
* 複雑なソートは、QuerySetの`annotate`を使って`order_by`をする

# 6. Pythonコードの最適化

## アルゴリズムを改善する

クイックソートなどを使って計算量を減らしたりして改善する。

[Pythonプログラマーが知っておくべきコーディングの最適化とリファクタリング方法](https://469matt.com/code-opt/)などを参考。

## 関数のキャッシュ化

主な手法としては、[cachetools](https://self-development.info/%E3%80%90python%E3%82%92%E7%B0%A1%E5%8D%98%E3%81%AB%E9%AB%98%E9%80%9F%E5%8C%96%E3%80%91cachetools%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB/) や [functoolsのlru_cacheやcashデコレータ](https://docs.python.org/ja/3/library/functools.html)がある。

## 並列処理　

並列処理はここでは触れない。以下のサイトを参考にしてください。

[【必読】Pythonの並列処理でタスクを高速化！実例付](https://itc.tokyo/python/python-parallel-process/)

## Pythonバインディング

### ctype

標準ライブラリの**ctypes**を使用する


### C言語でライブラリを作成する


***getattr*** などがCで作られている。

[C や C++ による Python の拡張](https://docs.python.org/ja/3/extending/extending.html)


## バイトコード最適化

**Numba** や **PyPy** を使う

# 7. Djangoの設計思想に従う

[設計思想 | Django ドキュメント](https://docs.djangoproject.com/ja/5.0/misc/design-philosophies/#cache-design-philosophy)

# 8. 参考文献

1. [Pythonプロファイラ](https://docs.python.org/ja/3/library/profile.html#what-is-deterministic-profiling)
1. [データベースアクセスの最適化](https://docs.djangoproject.com/ja/4.2/topics/db/optimization/)
1. [パフォーマンスと最適化](https://man.plustar.jp/django/topics/performance.html)
1. [静的ファイル（CSS、JavaScript、Image）の保存場所や設定について](https://office54.net/python/django/css-javascript-static)
1. [Webパフォーマンスガチ勢が本当に使っている技術](https://www.prime-strategy.co.jp/resource/pdf/DevelopersSummit2020.pdf)
1. [Djangoで静的ファイルとうまくやる](https://tell-k.github.io/djangocongressjp2019/#1)
1. [データベースの最適化](https://docs.djangoproject.com/ja/5.0/topics/db/optimization/)
1. [Profiling Django App](https://kracekumar.com/post/profiling_django/)
1. [Pythonのスタートアップ時間を可視化した](https://www.kurusugawa.jp/show-python-import-start-time/)
1. [Django: How to profile and improve startup time](https://adamj.eu/tech/2023/03/02/django-profile-and-improve-import-time/)
1. [大量のデータを取得する際に気を付けること](https://zenn.dev/masako/articles/2fad904fdfc514)
1. [Martiniでレスポンスをgzip compressする](https://y0m0r.hateblo.jp/entry/20140415/1397577294)