Djangoに限りませんが、Webアプリケーションを継続的に開発をしていくとデータベースのテーブル定義の変更が幾度となく起こり、migrationファイルが増えてきます。
migrationファイルが増えてくると、テスト実行用のデータベースを作るときに時間がかかるようになり $ python manage.py test
を打ってからテストが実行されるまで暫く待つ必要がでてきます。
ちなみに scout.lapras.com のアプリケーションは 459 migrationファイルあり、テスト実行までに2分10秒かかります。(Docker for Macの上で動かしているのでそれがいくらかオーバーヘッドになっていますが)
Djangoには squashmigrations
というコマンドがあり、migrationsファイルを1ファイルにまとめることができます。その際に賢くまとめてくれるので、例えば同じカラムに対する複数回の変更があっても、migrationファイルに記載されるのは最終的なカラムの情報1つだけになります。それによりALTER TABLE
文の発行回数が減り migrate を高速化することに繋がります。
公式ドキュメント: https://docs.djangoproject.com/en/2.2/topics/migrations/#squashing-migrations
シンプルなアプリケーションで計測してみた
本当はこの記事を書くまでに↑のプロダクトのmigrationsをsquashしてこれだけ早くなったよ!をやりたかったのですが残念ながら間に合わなかったので、シンプルなアプリケーションを用意して計測してみました。
適当なBookモデルを用意して
from django.db import models
class Book(models.Model):
name = models.CharField(max_length=128)
author = models.CharField(max_length=128)
price = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
これに対して
price = models.IntegerField(default=0)
のカラムの追加削除を繰り返すだけのmigrationファイルを50用意しました。(DBはsqlite3です)
.
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_remove_book_price.py
│ ├── 0003_book_price.py
│ ├── 0004_remove_book_price.py
│ ├── 0005_book_price.py
│ ├── 0006_remove_book_price.py
│ ├── 0007_book_price.py
│ ├── 0008_remove_book_price.py
│ ├── 0009_book_price.py
│ ├── 0010_remove_book_price.py
│ ├── 0011_book_price.py
│ ├── 0012_remove_book_price.py
│ ├── 0013_book_price.py
│ ├── 0014_remove_book_price.py
│ ├── 0015_book_price.py
│ ├── 0016_remove_book_price.py
│ ├── 0017_book_price.py
│ ├── 0018_remove_book_price.py
│ ├── 0019_book_price.py
│ ├── 0020_remove_book_price.py
│ ├── 0021_book_price.py
│ ├── 0022_remove_book_price.py
│ ├── 0023_book_price.py
│ ├── 0024_remove_book_price.py
│ ├── 0025_book_price.py
│ ├── 0026_remove_book_price.py
│ ├── 0027_book_price.py
│ ├── 0028_remove_book_price.py
│ ├── 0029_book_price.py
│ ├── 0030_remove_book_price.py
│ ├── 0031_book_price.py
│ ├── 0032_remove_book_price.py
│ ├── 0033_book_price.py
│ ├── 0034_remove_book_price.py
│ ├── 0035_book_price.py
│ ├── 0036_remove_book_price.py
│ ├── 0037_book_price.py
│ ├── 0038_remove_book_price.py
│ ├── 0039_book_price.py
│ ├── 0040_remove_book_price.py
│ ├── 0041_book_price.py
│ ├── 0042_remove_book_price.py
│ ├── 0043_book_price.py
│ ├── 0044_remove_book_price.py
│ ├── 0045_book_price.py
│ ├── 0046_remove_book_price.py
│ ├── 0047_book_price.py
│ ├── 0048_remove_book_price.py
│ ├── 0049_book_price.py
│ ├── 0050_remove_book_price.py
│ └── __init__.py
├── models.py
└── tests.py
これに対してテストを実行すると…
$ time python ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
Destroying test database for alias 'default'...
python ./manage.py test 0.73s user 0.20s system 73% cpu 1.261 total
なんと0.73秒で終わってしまいました。。。
僕の想像ではこれで少なくとも5秒ぐらいかかって、squashすると0.5秒ぐらいになる想定だったのですが、、、
先行きが怪しくなります。。
squashしてみます。
$ python manage.py squashmigrations squash_test 0050_remove_book_price
Will squash the following migrations:
- 0001_initial
- 0002_remove_book_price
- 0003_book_price
(略)
- 0050_remove_book_price
Do you wish to proceed? [yN] y
Optimizing...
Optimized from 50 operations to 1 operations.
Created new squashed migration /django-squash-test/app/squash_test/migrations/0001_squashed_0050_remove_book_price.py
You should commit this migration but leave the old ones in place;
the new migration will be used for new installs. Once you are sure
all instances of the codebase have applied the migrations you squashed,
you can delete them.
これですべてが詰まった1つのmigrationファイルができました。
前後を比較すると
before
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 12:13
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('author', models.CharField(max_length=128)),
('price', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 12:14
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('squash_test', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='price',
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 12:14
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('squash_test', '0002_remove_book_price'),
]
operations = [
migrations.AddField(
model_name='book',
name='price',
field=models.IntegerField(default=0),
preserve_default=False,
),
]
(以下、2と3と同じファイルの繰り返し)
が
after
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2019-12-05 13:53
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [('squash_test', '0001_initial'), ('squash_test', '0002_remove_book_price'), ('squash_test', '0003_book_price'), ('squash_test', '0004_remove_book_price'), ('squash_test', '0005_book_price'), ('squash_test', '0006_remove_book_price'), ('squash_test', '0007_book_price'), ('squash_test', '0008_remove_book_price'), ('squash_test', '0009_book_price'), ('squash_test', '0010_remove_book_price'), ('squash_test', '0011_book_price'), ('squash_test', '0012_remove_book_price'), ('squash_test', '0013_book_price'), ('squash_test', '0014_remove_book_price'), ('squash_test', '0015_book_price'), ('squash_test', '0016_remove_book_price'), ('squash_test', '0017_book_price'), ('squash_test', '0018_remove_book_price'), ('squash_test', '0019_book_price'), ('squash_test', '0020_remove_book_price'), ('squash_test', '0021_book_price'), ('squash_test', '0022_remove_book_price'), ('squash_test', '0023_book_price'), ('squash_test', '0024_remove_book_price'), ('squash_test', '0025_book_price'), ('squash_test', '0026_remove_book_price'), ('squash_test', '0027_book_price'), ('squash_test', '0028_remove_book_price'), ('squash_test', '0029_book_price'), ('squash_test', '0030_remove_book_price'), ('squash_test', '0031_book_price'), ('squash_test', '0032_remove_book_price'), ('squash_test', '0033_book_price'), ('squash_test', '0034_remove_book_price'), ('squash_test', '0035_book_price'), ('squash_test', '0036_remove_book_price'), ('squash_test', '0037_book_price'), ('squash_test', '0038_remove_book_price'), ('squash_test', '0039_book_price'), ('squash_test', '0040_remove_book_price'), ('squash_test', '0041_book_price'), ('squash_test', '0042_remove_book_price'), ('squash_test', '0043_book_price'), ('squash_test', '0044_remove_book_price'), ('squash_test', '0045_book_price'), ('squash_test', '0046_remove_book_price'), ('squash_test', '0047_book_price'), ('squash_test', '0048_remove_book_price'), ('squash_test', '0049_book_price'), ('squash_test', '0050_remove_book_price')]
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('author', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
になりました。
実行時間は
$ time python ./manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
Destroying test database for alias 'default'...
python ./manage.py test 0.52s user 0.09s system 98% cpu 0.611 total
0.52秒で、50ファイルあったときの71%になりました! (微妙ですが減ってくれてよかった…)
squash された migration ファイルの解説
公式ドキュメント https://docs.djangoproject.com/en/2.2/topics/migrations/#squashing-migrations にも書かれているので、より詳細な情報は原文を読んで頂くのがよいですが、簡単に説明します。
operations
の部分は見慣れた構文と同じですが、普段のmigrationファイルでは見ない replaces
という変数が新たに定義されています。
これはどのmigrationsファイルたちをまとめてこのmigrationファイルを作ったかという情報になります。
注意点として、squashされたmigrationファイルができたからといってsquash元のmigrationファイルたちをすぐには削除できません。すでに部分的にmigrationが当たっている環境、 例えば、0010_remove_book_price
までは実行済みの環境があった場合、残りの 0011_book_price
から 0050_remove_book_price
までを当てる必要があり、この場合にはsquashしたmigrationファイルだけでは対応できないためです。
replaces
の中に含まれているmigrationファイルすべてが未適用な環境であれば個別のmigrationファイルの代わりにsquashされたmigrationファイルが使用されより高速にmigrateすることができます。
自動でsquashできないケース
Djangoでは複数アプリケーションを一つのプロジェクトとして管理できます。
大規模なDjangoアプリケーションではドメインごとにアプリケーションを分けて、数十のアプリケーションを1プロジェクトで持つような形がベストプラクティスの一つとしてあります。
このような場合にはアプリケーションを跨いで外部キー制約をつけることが多々発生し、migrationファイル同士の依存関係もかなり複雑になります。
必ずその依存先のmigrationファイルの後でなければ動かないというわけではないのに、djangoが自動で張った依存関係の影響でスマートにsquashできない場合があります。
文字だと難しいので例を出すと
- app1.Book 作成:
0001_app1
- app2.Category 作成:
0001_app2
- app1.Magazine 作成:
0002_app1
(0001_app1
に依存) - app2.Category のカラム変更:
0002_app2
(0001_app2
に依存) - app1.Book から Category へのF-Key追加:
0003_app1
(0002_app1
と0002_app2
に依存)
という状況でsquashすることを考えた場合に、squashmigrations
をすると 0001_app1
と 0002_app1
、0001_app2
と 0002_app2
がまとめられ、0003_app1
はそのまま残り合計3ファイルになります。
ただこれは最適解ではなくて、人間が考えれば 0001_app2
と 0002_app2
をまとめて、0001_app1
と 0002_app2
と 0003_app1
をまとめて、後者が前者に依存することで合計2ファイルにまとめられることがわかります。
そして大規模なアプリケーションになると往々にしてこのような問題が起き、自動で squashmigrations
するだけではあまりmigrationファイルが減らず、人間が頭を捻りながら微調整をしていく必要が出てきてしまいます。
微量な高速化のために人間がどれだけコストを払うのかはよく考えましょう。
最後に
僕は高速化が趣味なのでsquashしてこれぐらい早くなった〜!と速度比較をするのが大好きでsquashしたあとはスカッとします。
ちなみに、最近はスポーツのスカッシュにもハマっていて、来週末には初めて大会に出場することになりました。そこでも勝利してスカッとできるといいなと思っています。