はじめに
こんにちは。
今回は Django で PyMySQL
を使うのが主流(私の観測範囲では使ってる人が多い気がする)だと思うんですが、「いや、そろそろ使うのやめようぜ」ということを記事にしたいと思います!
今回の実装したコードは以下です。
どうして?
そもそも、 PyMySQL
自体は、Djangoの推奨 API ドライバではない。
PyMySQL
が選ばれる理由として、私が考える中では、 Pure Python だからインストールに失敗することは(ほぼ)ない。ということかなぁって思いました。
私はそうだった。
実際、 Django が推奨している mysqlclient
はインストールバトルが勃発することが多々あった。あった。あったよ。
今回は、その対処方法も後に紹介することにしよう。(なぜ急にこの口調。
人気的にはやっぱり、 PyMySQL
のほうが圧倒的!人気!
導入
なぜ、私が PyMySQL
を Django で使わなくなったのかを含めて、パフォーマンスが mysqlclient
にすることによって上がるのではないかと仮説とともにこの記事を書いていきたいと思います。
検証環境
パフォーマンス検証
- Docker 18.09.2
Dockerの設定はこんな感じにしてます。
ちょいちょい、記事用に簡易的にデバッグをしたいのでDockerを使用していない場合があるかもだけど、Pythonの挙動の検証等なので問題ないかと思います。
mysqlclient
インストールバトルの検証
母艦のスペックはこんな感じ。最近非力に感じる Mac Pro ゴミ箱世代です。
結構前に仕事で解決したネタなのでエラー内容とかは紹介できません...
PyMySQL
を使わなくなった理由
- Django の推奨 API ドライバではない
- django~=2.2.0 で使用するとエラーが発生する (2019/7/23時点)
mysqlclient
は C を Python でラップする形*1なので高速に動作を行える見込みがある(検証したい)
Django の推奨 API ドライバではない
先述通り。
なるべく公式が推奨しているもの使っていきたいよね。っていうだけ。
django~=2.2.0 で使用するとエラーが発生する
pymysql_app | Traceback (most recent call last): pymysql_app | File "manage.py", line 25, in <module> pymysql_app | main() pymysql_app | File "manage.py", line 21, in main pymysql_app | execute_from_command_line(sys.argv) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line pymysql_app | utility.execute() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/__init__.py", line 357, in execute pymysql_app | django.setup() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/__init__.py", line 24, in setup pymysql_app | apps.populate(settings.INSTALLED_APPS) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/apps/registry.py", line 114, in populate pymysql_app | app_config.import_models() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/apps/config.py", line 211, in import_models pymysql_app | self.models_module = import_module(models_module_name) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/importlib/__init__.py", line 127, in import_module pymysql_app | return _bootstrap._gcd_import(name[level:], package, level) pymysql_app | File "<frozen importlib._bootstrap>", line 1006, in _gcd_import pymysql_app | File "<frozen importlib._bootstrap>", line 983, in _find_and_load pymysql_app | File "<frozen importlib._bootstrap>", line 967, in _find_and_load_unlocked pymysql_app | File "<frozen importlib._bootstrap>", line 677, in _load_unlocked pymysql_app | File "<frozen importlib._bootstrap_external>", line 728, in exec_module pymysql_app | File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/contrib/auth/models.py", line 2, in <module> pymysql_app | from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/contrib/auth/base_user.py", line 47, in <module> pymysql_app | class AbstractBaseUser(models.Model): pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/models/base.py", line 117, in __new__ pymysql_app | new_class.add_to_class('_meta', Options(meta, app_label)) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/models/base.py", line 321, in add_to_class pymysql_app | value.contribute_to_class(cls, name) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/models/options.py", line 204, in contribute_to_class pymysql_app | self.db_table = truncate_name(self.db_table, connection.ops.max_name_length()) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/__init__.py", line 28, in __getattr__ pymysql_app | return getattr(connections[DEFAULT_DB_ALIAS], item) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/utils.py", line 201, in __getitem__ pymysql_app | backend = load_backend(db['ENGINE']) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/utils.py", line 110, in load_backend pymysql_app | return import_module('%s.base' % backend_name) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/importlib/__init__.py", line 127, in import_module pymysql_app | return _bootstrap._gcd_import(name[level:], package, level) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/mysql/base.py", line 36, in <module> pymysql_app | raise ImproperlyConfigured('mysqlclient 1.3.13 or newer is required; you have %s.' % Database.__version__) pymysql_app | django.core.exceptions.ImproperlyConfigured: mysqlclient 1.3.13 or newer is required; you have 0.9.3.
ここは、以下の Django のソースのところで PyMySQL (as mysqlclient)
のバージョンが古いぽよぉ〜って言われて終了されているんですね。
django/db/backends/mysql/base.py#L35-L37
ちなみに PyMySQL
には mysqlclient
に互換性をもたせる install_as_MySQLdb()
が実装されているので、 manage.py
に設定しています。
gunicorn
や uwsgi
等でデプロイするときは、 wsgi.py
にも同じような操作が必要ですね。
PyMySQL/blob/master/pymysql/init.py#L120-L125
PyMySQL
の __version__
は 0.9.3
なので、 Django 側の実装のゆらぎかなにか*2はちょっと把握できないのですが、上書きしているライブラリのバージョンを django.core.exceptions.ImproperlyConfigured
に繋げているようです。
以下、実際に manage.py
に install_as_MySQLdb()
を設定している場合です。
>>> import pymysql >>> pymysql.version_info (1, 3, 12, 'final', 0) >>> pymysql.version_info < (1, 3, 13) True >>> import sys >>> sys.modules['MySQLdb'] <module 'pymysql' from '/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/pymysql/__init__.py'> >>> import MySQLdb as Database >>> Database.version_info (1, 3, 12, 'final', 0) >>> Database.__version__ '0.9.3'
まだ、書くかって言う感じなんですが、
pymysql.version_info = (1, 4, 2, 'post', 1)
とか小作な真似をしてみたところ、エラーが発生した。治すのは面倒なのでもうしない。
pymysql_app | Traceback (most recent call last): pymysql_app | File "manage.py", line 26, in <module> pymysql_app | main() pymysql_app | File "manage.py", line 22, in main pymysql_app | execute_from_command_line(sys.argv) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/__init__.py", line 381, in execute_from_command_line pymysql_app | utility.execute() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/__init__.py", line 375, in execute pymysql_app | self.fetch_command(subcommand).run_from_argv(self.argv) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/base.py", line 323, in run_from_argv pymysql_app | self.execute(*args, **cmd_options) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/base.py", line 361, in execute pymysql_app | self.check() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/base.py", line 390, in check pymysql_app | include_deployment_checks=include_deployment_checks, pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/management/commands/migrate.py", line 64, in _run_checks pymysql_app | issues = run_checks(tags=[Tags.database]) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/checks/registry.py", line 72, in run_checks pymysql_app | new_errors = check(app_configs=app_configs) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/core/checks/database.py", line 10, in check_database_backends pymysql_app | issues.extend(conn.validation.check(**kwargs)) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/mysql/validation.py", line 9, in check pymysql_app | issues.extend(self._check_sql_mode(**kwargs)) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/mysql/validation.py", line 13, in _check_sql_mode pymysql_app | with self.connection.cursor() as cursor: pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/base/base.py", line 256, in cursor pymysql_app | return self._cursor() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/base/base.py", line 233, in _cursor pymysql_app | self.ensure_connection() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/base/base.py", line 217, in ensure_connection pymysql_app | self.connect() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/base/base.py", line 197, in connect pymysql_app | self.init_connection_state() pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/mysql/base.py", line 231, in init_connection_state pymysql_app | if self.features.is_sql_auto_is_null_enabled: pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/utils/functional.py", line 80, in __get__ pymysql_app | res = instance.__dict__[self.name] = self.func(instance) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/mysql/features.py", line 82, in is_sql_auto_is_null_enabled pymysql_app | cursor.execute('SELECT @@SQL_AUTO_IS_NULL') pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/utils.py", line 103, in execute pymysql_app | sql = self.db.ops.last_executed_query(self.cursor, sql, params) pymysql_app | File "/root/.local/share/virtualenvs/src-NVTF7jWz/lib/python3.7/site-packages/django/db/backends/mysql/operations.py", line 146, in last_executed_query pymysql_app | query = query.decode(errors='replace') pymysql_app | AttributeError: 'str' object has no attribute 'decode'
mysqlclient
は C を Python でラップする形なので高速に動作を行える見込みがある
検証方法
- 単純な
INSERT
,SELECT
,UPDATE
,DELETE
について検証する django-extensions
の./manage.py shell_plus --ipython
- 1回ごとに
./manage.py flush --no-input
の実行 IPython
の%timeit
を使用- DELETE のみ
%time
にて検証
- DELETE のみ
最初に INSERT を見てみます。
それぞれのプロジェクトに同じクラスメソッド以下2つを追加しました。
@classmethod def create_tasks_by_loop(cls, count=1000): for _ in range(count): Task().save() @classmethod def create_tasks_by_bulk(cls, count=1000): Task.objects.bulk_create([Task() for _ in range(count)])
1つ目は本来ならまぁしないかなぁって思うのですが、1つずつ INSERT を発行するやつ。
2つ目は一括 INSERT です。一旦、 batch_size
は無視で。
1つずつ (1,000) | 一括 (1,000) | |
---|---|---|
PyMySQL | 2.31 s ± 106 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 87 ms ± 3.19 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) |
mysqlclient | 2.06 s ± 23.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 77.9 ms ± 1.27 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) |
1つずつ (10,000) | 一括 (10,000) | |
---|---|---|
PyMySQL | 22.4 s ± 358 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 847 ms ± 61.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) |
mysqlclient | 20.5 s ± 168 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 754 ms ± 14.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) |
次に、 SELECT の検証。
@classmethod def query_tasks(cls, count): for _ in range(count): tasks = Task.objects.select_related('owner').all() assert isinstance(len(tasks), int)
メソッド内で assert
を使用しているのは、 Django の QuerySet は何かしら内部のプロパティにアクセスをしないとクエリが発行されないので使用をしている。
当然、 QuerySet のデータ数のみを取得したい場合は、 len()
ではなく .count()
を使用すべきである。
isinstance(len(tasks), int)
をすることでオーバーヘッドが発生しまくりまくりんぐすると思うが、今回はそれは無視していこう。ソースコードは完璧に同じなわけなので。
それぞれデータ件数は1,000件です。
取得回数: 1,000 | 取得回数: 10,000 | |
---|---|---|
PyMySQL | 1min 1s ± 3.6 s per loop (mean ± std. dev. of 7 runs, 1 loop each) | 9min 58s ± 7.95 s per loop (mean ± std. dev. of 7 runs, 1 loop each) |
mysqlclient | 23.9 s ± 129 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 4min 25s ± 1.08 s per loop (mean ± std. dev. of 7 runs, 1 loop each) |
まぁ、普通では到底ありえない件数を取得するやつでの実験だった。差が結構顕著にあらわれた。
1,000件を10,000回取得するやつ、 PyMySQL で %time
で10分以上かかってたのに %timeit
を始めて10分以上ボッーと何もせずに待ってたんだけど単純計算で7回試行されたら1時間以上かかるのが無駄すぎた。勉強を始める。
一切関係ないけど、今現在読んでる本は以下!!
続いて、UPDATE の検証をします!
@classmethod def update_tasks_by_loop(cls): for task in Task.objects.select_related('owner').all(): task.title = 'edited' task.save() @classmethod def update_tasks_by_bulk(cls): Task.objects.select_related('owner').all().update(title='edited')
ここでは、特に INNER JOIN
使わなくてもいいかと思うんですけど、一応 N+1 問題があるなぁって言うことを考慮しながらの計測をしたいのでわざと .select_related('owner')
をつけました。
つけない場合は、以下の結果がちょっと速くなるかと思います。
1つずつ (1,000) | 一括 (1,000) | |
---|---|---|
PyMySQL | 1.79 s ± 26.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 2.69 ms ± 46.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) |
mysqlclient | 1.62 s ± 32.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 2.55 ms ± 48.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) |
1つずつ (10,000) | 一括 (10,000) | |
---|---|---|
PyMySQL | 17.9 s ± 86.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 12.5 ms ± 199 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) |
mysqlclient | 16.1 s ± 429 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 12.4 ms ± 151 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) |
UPDATE の一括更新はクエリが単純すぎてもしかしたら指標にならないかもしれないなぁ...
もうちょっとマシな SQL 書いたら良かったな。発行される SQL は
UPDATE `app_task` SET `title` = 'edited';
なわけだし...
最後に、 DELETE の検証
@classmethod def delete_tasks_by_loop(cls): for task in Task.objects.all(): task.delete() @classmethod def delete_task_by_bulk(cls): Task.objects.all().delete()
1つずつ (1,000) | 一括 (1,000) | |
---|---|---|
PyMySQL | CPU times: user 210 ms, sys: 190 ms, total: 400 ms Wall time: 2.6 s |
CPU times: user 0 ns, sys: 0 ns, total: 0 ns Wall time: 9.07 ms |
mysqlclient | CPU times: user 50 ms, sys: 190 ms, total: 240 ms Wall time: 2.23 s |
CPU times: user 0 ns, sys: 0 ns, total: 0 ns Wall time: 12.9 ms |
1つずつ (10,000) | 一括 (10,000) | |
---|---|---|
PyMySQL | CPU times: user 3.11 s, sys: 1.54 s, total: 4.65 s Wall time: 24.4 s |
CPU times: user 10 ms, sys: 0 ns, total: 10 ms Wall time: 79.2 ms |
mysqlclient | CPU times: user 40 ms, sys: 1.18 s, total: 1.22 s Wall time: 24.2 s |
CPU times: user 0 ns, sys: 0 ns, total: 0 ns Wall time: 56.2 ms |
DELETE はちょっと面倒になっちゃったので、 %timeit
なしで。
せめて %time
を10回手動でやって平均を出すみたいなことしたらよかった。これだとちょっとバラツキが大きそうでわからない。
あと、これもクエリ自体が単純すぎて対して変わらないかもしれない。
検証結果
今回は、 PyMySQL
と mysqlclient
を CPython + Django 環境下で使用する前提でパフォーマンス検証を行った!
2日間かかった...これは、検証コード作成と怠慢。
結果としては、 mysqlclient
に変更することで概ねパフォーマンスアップが期待できる!という感じでしょうか。
先程も書きましたが、どうやら SELECT を行うと結構な差がでてきそうですね。もうちょっと現実的な数でもう1回計測します。
データは1,000件あります。20件取得します。
@classmethod def query_tasks_with_length(cls, count, length): if count == 1: tasks = Task.objects.select_related('owner').all()[:length] assert isinstance(len(tasks), int) return for _ in range(count): tasks = Task.objects.select_related('owner').all()[:length] assert isinstance(len(tasks), int)
こんなコードを追加しました。本当は何かしら WHERE を入れたほうがいいんでしょうけどいいやつが思いつかない。
あとインナー関数とか作ればよかったなぁ〜とか思ったけど別にそれが目的ではないのでパス
取得回数: 1 | 取得回数: 1,000 | 取得回数: 10,000 | |
---|---|---|---|
PyMySQL | 2.83 ms ± 39.6 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) | 2.89 s ± 95 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 28.9 s ± 1.24 s per loop (mean ± std. dev. of 7 runs, 1 loop each) |
mysqlclient | 1.74 ms ± 52.6 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each) | 1.67 s ± 44 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) | 17 s ± 225 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) |
なるほどなるほど。結構いい感じに差が出た気がする。
1,000件中20件取得するだけでも結構な差がついてしまってるのが結構気になった。(というか全体的に ORDER
つけ忘れてちょっと不安)
あと、 PyMySQL
を使う上で manage.py
とか wsgi.py
に pymysql.install_as_MySQLdb()
を呼び出さなきゃいけなかったのが、 mysqlclient
だとその必要がないというもの嬉しい。
どうでもいいけど mysqlclient
をインストールして使うときはモジュール名が MySQLdb
になってどれだよってなる。なった。
PyPy を使えば、参考にした Gist のように PyMySQL
のような Pure Python なプロジェクトの方が高速に動作するらしいが、今回は調査を行わないものとする。
mysqlclient
インストールバトル
今回は、再現とかできなかったとか諸々の理由で、 macOS 限定で書きます。
Note about bug of MySQL Connector/C on macOS というメモがリポジトリにあるのでまずはこれをやろう!
それでも失敗する場合はとりあえず、 Homebrew
等で OpenSSL
を入れます。 brew install openssl
あとは、 OpenSSL
を LDFLAGS
に追加する感じ。
export LDFLAGS=-L/usr/local/opt/openssl/lib
他のモジュールがある場合は、そっちの方を忘れないように設定してあげましょうね。
これで mysqlclient
インストールバトルに優勝できるはず!?
$ pip install mysqlclient
まとめ
PyMySQL
を Django で使うのをやめようという名目で自分が今後ちゃんと mysqlclient
を推せるような言い訳を作るために記事を書いた。
実際、 SELECT で結構な差が出ることがわかったのでいい検証になったと感じた。(いい加減この記事文字数が多い)
あと、普通に Django 2.1 を使う分には現在の時点では問題ないがいつかは Django 2.1 も終了するわけだから Django 2.2 以降になると PyMySQL
は mysqlclient
の互換サポートをしてくれない限り未来がない。
未来がない理由はこの記事でたくさんエラー文をコピペして紹介した。
コンテナ技術が発展した現在、AWS ECS とか AWS Fargate とかでプロダクトを動かす人も多いのかなって思う。
Python の公式 Docker イメージでは mysqlclient
のインストール失敗するとかいうことは起きたことがないので問題ないっぽい。
私はそんなにコンテナに頼ろうとは思わないが...
にゃーん(書くことがなくなってきた。というかもう疲れた。19時半過ぎてる。帰って ラブライブ!サンシャイン!! The School Idol Movie Over the Rainbow*3 を観ないといけないのである。)
というわけで、今回はなんか頑張って記事を書いてみた。
良い Django ライフを〜〜ノシ