『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

DjangoでPyMySQLを使うのそろそろやめよう

はじめに

こんにちは。
今回は DjangoPyMySQL を使うのが主流(私の観測範囲では使ってる人が多い気がする)だと思うんですが、「いや、そろそろ使うのやめようぜ」ということを記事にしたいと思います!

今回の実装したコードは以下です。

bitbucket.org

どうして?

そもそも、 PyMySQL 自体は、Djangoの推奨 API ドライバではない。

docs.djangoproject.com

PyMySQL が選ばれる理由として、私が考える中では、 Pure Python だからインストールに失敗することは(ほぼ)ない。ということかなぁって思いました。
私はそうだった。

実際、 Django が推奨している mysqlclient はインストールバトルが勃発することが多々あった。あった。あったよ。
今回は、その対処方法も後に紹介することにしよう。(なぜ急にこの口調。

人気的にはやっぱり、 PyMySQL のほうが圧倒的!人気!

python.libhunt.com

導入

なぜ、私が PyMySQLDjango で使わなくなったのかを含めて、パフォーマンスが mysqlclient にすることによって上がるのではないかと仮説とともにこの記事を書いていきたいと思います。

検証環境

パフォーマンス検証

  • Docker 18.09.2
    • docker-compose 1.23.2
    • Python 3.7.0
      • pip==19.2
      • pipenv==2018.11.26
      • django~=2.1.0
      • mysqlclient==1.4.2.post1
      • PyMySQL==0.9.3
    • MariaDB 10.2

Dockerの設定はこんな感じにしてます。

ちょいちょい、記事用に簡易的にデバッグをしたいのでDockerを使用していない場合があるかもだけど、Pythonの挙動の検証等なので問題ないかと思います。

mysqlclient インストールバトルの検証

  • macOS Mojave 10.14.6
  • Python 3.7.0
  • pip==19.2
  • pipenv==2018.11.26

母艦のスペックはこんな感じ。最近非力に感じる Mac Pro ゴミ箱世代です。

結構前に仕事で解決したネタなのでエラー内容とかは紹介できません...

PyMySQL を使わなくなった理由

  1. Django の推奨 API ドライバではない
  2. django~=2.2.0 で使用するとエラーが発生する (2019/7/23時点)
  3. 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 に設定しています。
gunicornuwsgi 等でデプロイするときは、 wsgi.py にも同じような操作が必要ですね。

PyMySQL/blob/master/pymysql/init.py#L120-L125

PyMySQL__version__0.9.3 なので、 Django 側の実装のゆらぎかなにか*2はちょっと把握できないのですが、上書きしているライブラリのバージョンを django.core.exceptions.ImproperlyConfigured に繋げているようです。

以下、実際に manage.pyinstall_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 にて検証

最初に 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回手動でやって平均を出すみたいなことしたらよかった。これだとちょっとバラツキが大きそうでわからない。
あと、これもクエリ自体が単純すぎて対して変わらないかもしれない。

検証結果

今回は、 PyMySQLmysqlclient を 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.pypymysql.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
あとは、 OpenSSLLDFLAGS に追加する感じ。

export LDFLAGS=-L/usr/local/opt/openssl/lib

他のモジュールがある場合は、そっちの方を忘れないように設定してあげましょうね。
これで mysqlclient インストールバトルに優勝できるはず!?

$ pip install mysqlclient

まとめ

PyMySQLDjango で使うのをやめようという名目で自分が今後ちゃんと mysqlclient を推せるような言い訳を作るために記事を書いた。

実際、 SELECT で結構な差が出ることがわかったのでいい検証になったと感じた。(いい加減この記事文字数が多い)
あと、普通に Django 2.1 を使う分には現在の時点では問題ないがいつかは Django 2.1 も終了するわけだから Django 2.2 以降になると PyMySQLmysqlclient の互換サポートをしてくれない限り未来がない。
未来がない理由はこの記事でたくさんエラー文をコピペして紹介した。

コンテナ技術が発展した現在、AWS ECS とか AWS Fargate とかでプロダクトを動かす人も多いのかなって思う。
Python の公式 Docker イメージでは mysqlclient のインストール失敗するとかいうことは起きたことがないので問題ないっぽい。
私はそんなにコンテナに頼ろうとは思わないが...

にゃーん(書くことがなくなってきた。というかもう疲れた。19時半過ぎてる。帰って ラブライブ!サンシャイン!! The School Idol Movie Over the Rainbow*3 を観ないといけないのである。)

というわけで、今回はなんか頑張って記事を書いてみた。
良い Django ライフを〜〜ノシ

比較ライブラリ

pypi.org pypi.org

参考

Benchmarking MySQL drivers (Python 3.4) · GitHub