Lock записей и шквал «пятисоток»: какие шишки мы набили на миграциях в Django и как вам этого избежать

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Всем привет! Меня зовут Артём. Я бэкенд‑разработчик Яндекс Практикума, занимаюсь продуктовой разработкой нашей платформы. Пришёл в команду почти три года назад, когда Практикум только развивался, так что экспериментировать приходилось много.

Например, в 2021 году мы переработали систему контента, чтобы его можно было версионировать и показывать актуальные учебные материалы тогда, когда они будут готовы целиком. Это был достаточно сложный ход, который вылился в немалый даунтайм для сервиса, но в итоге всё закончилось успешно.

Практикум развивается, поэтому миграций накатывать приходится много. Причём применять их часто нужно к достаточно большим табличкам с щепоткой дата‑миграций. В этой статье я разберу несколько примеров неаккуратной работы с миграциями в Django и поделюсь, как избежать подобных проблем.

Почему вообще существует эта статья

Большая часть бэкенда Практикума написана на Django. На данный момент у нас Python 3.10 и Django 3.2, а в качестве базы данных мы используем PostgreSQL.

Миграции БД — неотъемлемая часть любого развивающегося stateful-бэкенда. Если ваш сервис использует ORM, то вряд ли вы пишете эти миграции руками. Почти каждое ORM имеет для этого свою утилиту: у SQLAlchemy — Alembic, у Tortoise ORM — Aerich. У Django тоже есть такое решение, но не без нюансов.

Подходы, предлагаемые Django ORM, не всегда работают, а в некоторых ситуациях могут устроить вам очень неприятный даунтайм.

Дисклеймер. Может показаться, что я говорю о блокировке как о чём-то плохом, поэтому поясню. Делать SELECT FOR UPDATE — нормально, блокировать записи — нормально. Вопрос в том, на сколько запись будет блокироваться: на миллисекунды или на минуты. Второе в рантайме критично.

Чего стоит добавить новое поле

Допустим, у нас есть стандартный сервис на Django, в том числе моделька пользователя. И пользователей в базе пара десятков миллионов.

Мы добавляем новое поле в таблицу пользователей:

class User(models.Model):

    ...  # some fields go here

	is_test = models.BooleanField(

		default=False,

		verbose_name="test",

	)

После этого запускаем python manage.py makemigrations и получаем скрипт следующего содержания:

from django.db import migrations, models

class Migration(migrations.Migration):
	dependencies = [
		("accounts", "0091_auto_20230316_1337"),
	]

	operations = [
		migrations.AddField(
			model_name="user",
			name="is_test",
			field=models.BooleanField(default=False, verbose_name="test"),
		),
	]

Запускаем python manage.py sqlmigrate и смотрим, какой SQL-запрос генерирует эта миграция:

BEGIN;
ALTER TABLE "accounts_user" ADD COLUMN "is_test" boolean DEFAULT false NOT NULL;
ALTER TABLE "accounts_user" ALTER COLUMN "is_test" DROP DEFAULT;
COMMIT;

В транзакции мы добавляем в таблицу non-null-поле со значением по умолчанию.

Из примечательного: после добавления значения по умолчанию Django делает DROP DEFAULT для всех записей, оставляя за собой проставление значения. Это нужно для обратной совместимости и для того, чтобы в качестве значения по умолчанию можно было использовать callables.

Если вас это не устраивает — можно воспользоваться уже готовым решением django-add-default-value:

AddDefaultValue(
	model_name="user",
	name="is_test",
	value=False,

)

Добавьте это после AlterField, и значение по умолчанию проставится в том числе на уровне базы.

Если версия вашей PostgreSQL внезапно оказалась ниже 11, то PostgreSQL сделает exclusive lock, а затем рерайт всех кортежей в таблице. Всё это время ваш сервис будет «пятисотить».

Как можно достаточно просто (или не очень просто) избежать такой ситуации, если у вас Postgres ниже 11? Если вы хотите добавить булево поле, необязательно нужен default = False. Чтобы не лезть в миграции руками, можно просто написать:

class User(models.Model):

    ...  # some fields go here

	is_test = models.BooleanField(

		null=True,

		default=None,

		verbose_name="test",

	)

В таком решении нет ничего зазорного: вы всё так же сможете использовать выражение вида if user.is_test в своём коде и избавите себя от лишней головной боли. Поле просто будет nullable, и со стороны БД никакие записи при миграции лочиться не станут:

BEGIN;
ALTER TABLE "accounts_user" ADD COLUMN "is_test" boolean NULL;
COMMIT;

Если же вам нужно, чтобы значение по умолчанию проставлялось сразу, или у вас не булево поле, то будет чуть сложнее.

Как это сделать?

В нашем примере ставим в коде default = False:

class User(models.Model):

    ...  # some fields go here

	is_test = models.BooleanField(
		default=False,
		verbose_name="test",
	)

Но при этом слегка изменяем сгенерированную миграцию:

from django.db import migrations, models

def set_is_test(User, limit):
	# [:limit] преобразуется в LIMIT в SQL-запросе
	users = User.objects.filter(is_test__isnull=True)[:limit]
	for user in users:
		user.is_test = False
		User.objects.bulk_update(users, ["is_test"])

	return len(users)

def forward(apps, schema_editor):
	User = apps.get_model("accounts", "User")
	limit = 10000
	while set_is_test(User, limit):
		pass

class Migration(migrations.Migration):
	# мегаважно не потерять эту настройку
	# она означает, что миграция будет гоняться НЕ в транзакции
	atomic = False

	dependencies = [
		("accounts", "0091_auto_20230316_1337"),
	]

	operations = [
		migrations.AddField(
			model_name="user",
			name="is_test",
			field=models.BooleanField(default=None, null=True, verbose_name="test"),
		),
		# делаем дата-миграцию с проставлением False
		migrations.RunPython(forward, migrations.RunPython.noop),
		# безопасно меняем на default = False
		migrations.AlterField(
			model_name="user",
			name="is_test",
			field=models.BooleanField(default=False, null=False, verbose_name="test"),
		),
	]

Очень важно в данной миграции прописать atomic = False — эта настройка говорит, что миграция будет проходить не в транзакции, потому что такой «жирной» транзакцией можно вызвать не меньшие проблемы в продакшене. Учтите, что такая штука применима не всегда. К слову, параметр atomic может использоваться не только во всей миграции, но и в конкретном RunPython.

Также стоит отметить, что где-то между migrations.RunPython и migrations.AlterField у нас могут скопиться пользователи с is_test = None. Но ими можно пренебречь или сделать так:

migrations.RunSQL("ALTER TABLE accounts_user ALTER COLUMN is_test SET DEFAULT false;")

Учтите: если вы хотите оставить и default на уровне базы, то придётся проделать операцию дважды: перед RunPython и после AlterField

Также помните, что если ваше поле будет с параметром null=False, то без AddDefaultValue вы не сможете создавать новые записи между миграцией и выкаткой оставшегося приложения и будете получать ошибку от БД.

В итоге наша простая задача «добавить пользователю новое поле» превратилась в такую замечательную миграцию:

from django.db import migrations, models

def set_is_test(User, limit):
	users = User.objects.filter(is_test__isnull=True)[:limit]
	for user in users:
		user.is_test = False
		User.objects.bulk_update(users, ["is_test"])
	return len(users)

def forward(apps, schema_editor):
	User = apps.get_model("accounts", "User")
	limit = 1000
	while set_is_test(Assessment, limit):
		pass

class Migration(migrations.Migration):
	atomic = False  # мегаважно не потерять эту настройку

	dependencies = [
		("accounts", "0091_auto_20230316_1337"),
	]

	operations = [
		migrations.AddField(
			model_name="user",
			name="is_test",
			field=models.BooleanField(default=None, null=True, verbose_name="test"),
		),

		AddDefaultValue(
			model_name="user",
			name="is_test",
			value=False,
		),

		# делаем дата-миграцию с проставлением False
		migrations.RunPython(forward, migrations.RunPython.noop),

		# безопасно меняем на default = False
		migrations.AlterField(
			model_name="user",
			name="is_test",
			field=models.BooleanField(default=False, null=False, verbose_name="test"),
		),

		AddDefaultValue(
			model_name="user",
			name="is_test",
			value=False,
		),

	]

Выглядит монструозно, но теперь точно ничего не сломается.

В случае с PostgreSQL 11 и выше значение по умолчанию при создании новой колонки сохранится в pg_attribute . Это значит, что такое значение не будет, как ранее, проставляться с рерайтом таблицы, а будет отдаваться при выполнении конкретного запроса.

Если PostgreSQL 11 и выше

Когда Django выполняет DROP DEFAULT, значение по умолчанию остаётся только в pg_attribute, а из pg_attdef удаляется. Поэтому для тех записей, для которых не было выставлено значение по умолчанию, оно появится при ближайшем выполнении функции Select даже несмотря на то, что Django всё же сделает DROP DEFAULT.

Чего стоит изменить поле

Другое дело, когда у вас было nullable-поле, а вы внезапно захотели non-nullable с каким-то значением по умолчанию.

Например, в Яндекс Практикуме долгое время использовали PostgreSQL версии ниже 11, а Django — версии 1.11. И какие-то булевы поля в больших таблицах мы действительно заполняли NULL вместо false, как описано выше под катом.

Рассмотрим такую историю:

class User(models.Model):

    ...  # some fields go here

	is_test = models.BooleanField(
		default=None,
		null=True,
		verbose_name="test",
	)

Допустим, мы заменили default на False. Забавно, но в таком случае миграция будет полностью пустой:

BEGIN;
COMMIT;

Чтобы заменить у всех пользователей is_test на False, вам придётся поменять параметры на null = False.

BEGIN;
ALTER TABLE "accounts_user" ALTER COLUMN "is_test" SET DEFAULT false;
UPDATE "accounts_user" SET "is_test" = false WHERE "is_test" IS NULL;
ALTER TABLE "accounts_user" ALTER COLUMN "is_test" SET NOT NULL;
ALTER TABLE "accounts_user" ALTER COLUMN "is_test" DROP DEFAULT;
COMMIT;

Получается, что записи со всеми пользователями с is_test = NULL мы просто залочим до конца транзакции. Выглядит не очень хорошо.

В таком случае подойдёт дата-миграция, похожая на ту, что я приводил в кейсе о PostgreSQL версии ниже 11:

from django.db import migrations, models


def set_is_test(User, limit):
	# делаем update на небольшой батч пользователей,
	# чтобы это работало быстро
	# [:limit] преобразуется в LIMIT в SQL-запросе

	users = User.objects.filter(is_test__isnull=True)[:limit]
	for user in users:
		user.is_test = False
		User.objects.bulk_update(users, ["is_test"])

	return len(users)

  
def forward(apps, schema_editor):
	User = apps.get_model("accounts", "User")
    
	# будем обновлять значение небольшими батчами
	limit = 1000
	while set_is_test(Assessment, limit):
		pass

class Migration(migrations.Migration):

	# миграция будет гоняться НЕ в транзакции,
	# чтобы она не была слишком большой
	# в случае проставления значения по умолчанию
	# нам транзакция вообще не так важна

	atomic = False

	dependencies = [
		("accounts", "0091_auto_20230316_1337"),
	]

	operations = [
		# новые записи будут создаваться
		# уже с default = False в базе
		AddDefaultValue(
			model_name="user",
			name="is_test",
			value=False,
		),

		# делаем дата-миграцию с проставлением False
		migrations.RunPython(forward, migrations.RunPython.noop)

		# безопасно меняем на default = False
		# тут мы ничего не залочим
		migrations.AlterField(
			model_name="user",
			name="is_test",
			field=models.BooleanField(default=False, null=False, verbose_name="test"),

		),

	]

В таком случае мы сделаем всё безопасно и удобно — и для себя, и для пользователей.

Важно: не стоит увлекаться atomic = False как средством для решения любых ваших проблем. Авария во время такой миграции может очень подпортить вам жизнь.

Чего стоит удалить новое поле

Иная ситуация: через полгода вы поняли, что поле is_test у вашего пользователя рудиментарно и не несёт никакой смысловой нагрузки. Браво взяв инициативу в свои руки, вы решаете его удалить. После генерации миграций получаете следующее:

import django.db.models.deletion

from django.db import migrations, models

class Migration(migrations.Migration):
	dependencies = [
		("accounts", "0092_add_is_test"),
	]

	operations = [
		migrations.RemoveField(
			model_name="user",
			name="is_test",
		),

	]

С такой же радостью, как при добавлении полгода назад, вы катите это на продакшен и внезапно получаете шквал «пятисоток» с ошибкой: колонки is_test у пользователя не существует.

Всё потому, что код API у вас может быть всё ещё старый, а миграция это поле уже удалила. Часто CI устроен так, что миграции накатываются первостепенно, потому что иначе изменения в базе могут быть несовместимы с кодом, который обращается к новым таблицам или колонкам. А вот при удалении чего-либо — наоборот.

Так как же быть, особенно когда в релизе возможны и миграции, удаляющие поля, и миграции, добавляющие поля?

Зачем удалять, если можно...

…сделать deprecated! Есть замечательный пакет django-deprecate-fields:

from django_deprecate_fields import deprecate_field

class User(models.Model):

    ...  # some fields go here

	is_test = deprecate_field(
		models.BooleanField(
			default=False,
			verbose_name="test",
		)
	)

Если ваше поле не было nullable, то в этот момент команда makemigrations добавит ему параметр null=True, который делает поле nullable. Теперь при использовании этого поля вам будет всегда возвращаться None, а также кидаться предупреждение, что вы используете deprecated-поле.

После этого можно безопасно удалить поле через RemoveField.

Чего стоит добавить индекс

Удалив поле is_test, вы внезапно заметили, что в поле username не хватает индекса:

class User(models.Model):

    ...  # some fields go here

	username = models.CharField(
		max_length=255,
		verbose_name="Имя пользователя",
	)

И вы добавляете db_index=True:

class User(models.Model):

    ...  # some fields go here

	username = models.CharField(
		max_length=255,
		verbose_name="Имя пользователя",
		db_index=True,
	)

На самом деле и тут всё не так очевидно: по умолчанию ваша миграция будет гоняться в транзакции, в которой залочится вся таблица пользователей.

Чтобы избежать этого, можно попробовать следующее:

  • AddIndexConcurrently для Django ≥ 3.0;

  • RunSQL для Django < 3:

from django.db import migrations, models


CREATE_USERNAME_INDEX = """
CREATE INDEX CONCURRENTLY IF NOT EXISTS "accounts_user_username_af3e1068" ON "accounts_user" ("phone_number");
"""

DROP_USERNAME_INDEX = """
DROP INDEX "accounts_user_username_af3e1068";
"""

CREATE_USERNAME_INDEX_LIKE = """
CREATE INDEX CONCURRENTLY IF NOT EXISTS "accounts_user_username_af3e1068_like" ON "accounts_user" ("phone_number" varchar_pattern_ops);
"""

DROP_USERNAME_INDEX_LIKE = """
DROP INDEX "accounts_user_username_af3e1068_like";
"""

class Migration(migrations.Migration):
	atomic = False
	dependencies = [
		("accounts", "0092_some_name"),
	]

	operations = [
		migrations.SeparateDatabaseAndState(
			# определяем state, который приняла бы БД при выполнении данной миграции
			state_operations=[
				migrations.AlterField(
					model_name="user",
					name="username",
					field=models.CharField(
						blank=False,
						db_index=True,
						max_length=255,
						verbose_name="Имя пользователя",
					),
				),
			],

			# записываем команды, которые будут выполнены в запросе в БД при миграции
			database_operations=[
				migrations.RunSQL(
					sql=CREATE_USERNAME_INDEX,
					reverse_sql=DROP_USERNAME_INDEX,
				),
				migrations.RunSQL(
					sql=CREATE_USERNAME_INDEX_LIKE,
					reverse_sql=DROP_USERNAME_INDEX_LIKE,
				),
			],
		)
	]
  

Во всех случаях не стоит забывать об atomic = False — без этого миграция просто не запустится.

Как можно валидировать миграции

Хотя бы частично миграции можно валидировать с помощью линтеров. Хороший пример — django-migration-linter. Он прост в использовании и запускается такой командой:

python manage.py lintmigrations

Выдаёт понятные ошибки, если миграция кажется ему подозрительной. Например, кейс с RemoveField подсветит с комментарием, что миграция не будет обратно совместимой. Если вы до этого применяли deprecate_field, как описано выше, то такие ошибки можно игнорировать:

from django.db import migrations, models
import django_migration_linter as linter


class Migration(migrations.Migration):

	dependencies = [
		("accounts", "0092_add_is_test"),
	]

	operations = [
		# поле больше нигде не используется
		linter.IgnoreMigration(),
		migrations.RemoveField(
			model_name="user",
			name="is_test",
		),
	]

Без IgnoreMigration() линтер выдаст:

(accounts, 0093_remove_user_is_test)... ERR

	DROPPING columns (table: accounts_user, column: is_test)

Также под раздачу могут попасть, например, миграции на добавление новых констрейнтов или индексов:

(model_name, 0002_migration_name)... ERR
	ADDING unique constraint
	CREATE INDEX locks table

…и многие другие опасные действия в БД.

Однако стоит учитывать, что линтер не панацея. Миграции в первую очередь нужно проверять «глазами» — самостоятельно и на код-ревью.

Заключение

Если у вас большая таблица и большой сервис, любая работа с миграциями должна быть очень аккуратной и кропотливой.

Ничего хитрого в том, что описано в статье, нет. Это лишь набор практик, выработанных на граблях, иногда даже на одних и тех же.

Облегчить работу с миграциями могут сторонние линтеры: в статье я привёл в качестве примера django-migration-linter. Но помните, что они не провалидируют вам RunPython, поэтому никогда не заменят критический взгляд ваших коллег. Мигрируйте свою БД аккуратно — и статей о набитых шишках будет меньше

Источник: https://habr.com/ru/companies/yandex/articles/745534/


Интересные статьи

Интересные статьи

В период с 18 января по 18 июля на сайте Kaggle проходило соревнование Ubiquant Market Prediction от китайской компании Ubiquant Investment. Я поучаствовал в этом соревновании и мой опыт участия оказа...
Всем привет! Как инженер-конструктор, я хочу рассказать вам о своем опыте и участии в проекте посвященному разработке системы информирования на основе автомобиля Hyundai Solaris 2.
Будут ли востребованы CMS, и стоит ли их изучать начинающим веб-разработчикам? Вопросы стали актуальными, так как появилось много интересных платформ, где создавать сайты...
Наверняка, вы слышали выражение: “перегорел на работе”. Возможно, так говорили и о вас. Если долгожданный отдых не восполняет силы, а мысли о работе вызывают только раздр...
By Kjetil Ree — Own work, CC BY-SA 3.0 Основатель Microsoft уже давно отошел от управления бизнесом и оставил себе лишь 1,36% акций компании. Последние годы Гейтс, чье состояни...