Django 3.1 MySQL db_flush() の高速化とTransactionTestCase利用時の注意点
追記: 翔泳社さんからDjangoの書籍を出版するので、ぜひ読んでみてください。
DjangoのMySQL DatabaseOperations Backendのとある処理を最適化するためのpatchを書いていて、それがマージされたのですが、注意点があるため記事にしておこうと思います。 全部読むのが面倒な方向けに結論だけ先に書いておきます。
- MySQLにおいて、TransactionTestCaseのteardown処理が高速になりました。
- またほとんどのユーザーにはそれほど重要ではありませんが、
sqlflush
コマンドも効率的なクエリを生成し、flush
コマンドも高速になります。
- またほとんどのユーザーにはそれほど重要ではありませんが、
- TransactionTestCaseを使ったテストで
AUTO INCREMENT
フィールドの値(デフォルトの主キーなど)に依存しているテストは、そのままだとMySQLではFAILするようになります。
テスト時間が長く困っている方は3.1以降で改善されるかもしれません。
TransactionTestCaseのteardown処理について
厳密には flush
management comandの高速化を行ったのですが、どうしてTransactionTestCaseが速くなるのかを解説します。
これにはTransactionTestCaseがtear down時に何をしているのかを理解する必要があります。
Djangoでテストを書いているという方はご存知のように、通常の TestCase
では、テスト開始時にトランザクションを開始し、終了時にはロールバックすることで、テストメソッド内のDB操作を取り消します。つまり各テストケースで行った操作はロールバックにより切り戻されるため、他のテストケースの実行に影響を与えることはありません。
一方でそれだと困るケースも当然あり、そういうケースでは TransactionTestCase
を使用します。
TrasactionTestCase
は、各テストケースごとに全てのテーブルを初期化した状態にしておかないといけません。
どうやっているかというと、 flush
management commandを call_command()
関数で発行しています。
この関数は、内部で各DatabaseOperations Backendの sql_flush()
メソッドを呼び出し、そのメソッドが返すSQLを実行します。
flush
management commandを普通に利用する人は、ここが少し速くなったところでそれほど嬉しくないと思いますが、何度もteardownで呼び出されるTransactionTestCase
においては速度が重要になります。
sql_flush()
の高速化
MySQL DatabaseOperations backendの sql_flush()
メソッドは、全テーブルに対して TRUNCATE
クエリを返す実装になっていました。
TRUNCATE
クエリはドキュメントにも書いてあるように、 DELETE
クエリや、 DROP TABLE
&& CREATE TABLE
クエリに似ています。
TRUNCATE TABLE empties a table completely. It requires the DROP privilege. Logically, TRUNCATE TABLE is similar to a DELETE statement that deletes all rows, or a sequence of DROP TABLE and CREATE TABLE statements. https://dev.mysql.com/doc/refman/8.0/en/truncate-table.html
ただこのクエリは比較的時間がかかります。厳密にはテーブルのサイズが小さい場合に、DELETE
クエリに比べ時間がかかります。
そこで DELETE
クエリを使いたいのですが、 DELETE
クエリで全ての情報が消えるわけではありません。
問題になるのは AUTO INCREMENT
フィールドのカウンターの値です。
3つのレコードが登録され、その全てをDELETEクエリにより削除した場合、次に作成するレコードの AUTO INCREMENT
フィールドは4から開始します。
そのため別途 ALTER TABLE tablename AUTO_INCREMENT = 1
などを発行してリセットする必要があります。
最初に書いたpatchではそういう処理をしていたのですが、core contributorsやmaintainerからのレビューを通して、カウンター値はリセットしないことになりました。
カウンター値がリセットされなくなることは破壊的変更のように思えますが、実はカウンター値がリセットされるかどうかは未定義動作だったようです。
Djangoのモデルの主キーは、デフォルトだとAUTO INCREMENT
なINTEGERが利用されますが、この主キー値の値などをテストケースでチェックしている場合には、後述するオプションをつけていないとFailします。
TransactionTestCase
の reset_sequences
オプション
先程の問題に対処するためには、ALTER TABLE tablename AUTO_INCREMENT = 1
を合わせて発行する必要があると説明しました。
それを有効にするのが TransactionTestCase
の reset_sequences
オプションです。
詳細は Advanced features of TransactionTestCase に書かれています。
Django 3.0まではMySQL利用時にこれをセットしても変わらなかったのですが、3.1からは必要に応じて reset_sequences=True
を指定してください。
class TestsThatDependsOnPrimaryKeySequences(TransactionTestCase): reset_sequences = True def test_animal_pk(self): ...
細かく確認してないのですが、PostgreSQLのDatabase Operations backendのコードには、このreset_sequencesの処理が実装されていたので、PostgreSQLを使ってTransactionTestCase走らせていた方には常識だったのかもしれません。
ベンチマーク
ベンチマークのコードはこちらです。
GitHub - c-bata/django-fast-mysql-flush: for ticket #31275
number of records on each table | before | after |
---|---|---|
10 | 3.302 sec (+/- 0.076) | 0.517 sec (+/- 0.019) |
100 | 3.323 sec (+/- 0.047) | 0.575 sec (+/- 0.025) |
1000 | 3.577 sec (+/- 0.106) | 1.046 sec (+/- 0.029) |
余談 ( information_schema.tables
の利用)
今回書いたpatchは、もともとヒューリスティックに1000行以下ならDELETE
クエリを発行するように実装していました。
行数が多い場合には性能の改善がなく、むしろ遅くなる可能性もあるからです。
全テーブルに対して SELECT COUNT(*)
で行数を調べていると余計に時間がかかる可能性があるため、 information_schema.tables
のtable_rows
から行数を取り出して判断しました。
この値はおおよその値が返ってくるだけですが(MyISAMを除く)、今回のようにざっくり1000行以上あるかどうかを知りたいときには十分です。
TABLE_ROWS
The number of rows. Some storage engines, such as MyISAM, store the exact count. For other storage engines, such as InnoDB, this value is an approximation, and may vary from the actual value by as much as 40% to 50%. In such cases, use SELECT COUNT(*) to obtain an accurate count.
TABLE_ROWS is NULL for INFORMATION_SCHEMA tables.
For InnoDB tables, the row count is only a rough estimate used in SQL optimization. (This is also true if the InnoDB table is partitioned.) https://dev.mysql.com/doc/refman/8.0/en/tables-table.html
ただ最終的にこの方針はやめることになりました。
自分が用意したベンチマークでは2倍程度高速でしたが、 DELETE
文と ALTER TABLE tablename AUTO INCRMENT = 1
の2つのSQLを発行しているためcore contributorsの方が試したベンチマーク問題では遅くなったりもしたようです。
詳しく見てみようかとも思ったのですが、ヒューリスティックを入れるのはあまり筋がよくないのと、たかだか2倍程度の改善だったのでまぁいいかなと思い今の実装に落ち着きました。
ちなみに information_schema.tables
から auto_increment
を取り出して、それが1より大きい場合のみ TRUNCATE
を呼ぶという実装も試してみたのですが、INSERTをしても AUTO_INCREMENT
の値が更新されず1のままにいることが頻繁にありテストが落ちるため諦めました。ドキュメントを読んでもapproximationとは書かれていないので理由がよくわからないのですが、もし知ってる方いたら教えて下さい。
AUTO_INCREMENT: The next AUTO_INCREMENT value. https://dev.mysql.com/doc/refman/5.7/en/tables-table.html
自分があとから思い出すためのメモでもあったので、雑な記事でしたがこれで終わり。