Проблемы с миграцией данных в Rails
Мой список проблем с миграцией данных в Rails. Стандартным механизмом миграций, который доступен из коробки. Под миграцией данных я понимаю изменение данных в базе данных приложения.
Миграции Rails - это самый простой и очевидный способ мигрировать данные (в базе данных) в Rails-приложении. Хотя он и предназначен для миграцию схемы таблиц, его часто используют и для миграции данных.
Пример такой миграции (из Rails Guides):
class AddInitialProducts < ActiveRecord::Migration[7.0]
def up
5.times do |i|
Product.create(name: "Product ##{i}", description: "A product.")
end
end
def down
Product.delete_all
end
end
Плюсы этого подхода:
- Миграция пройдет на всех окружениях (при деплое)
- Rails гарантирует, что она запустится только один раз (в каждом окружении)
- Доступен из коробки
Проблемы
Несмотря на все удобства этого подхода с ростом проекта рано или поздно вылезают проблемы. Кроме того, у меня регулярно появлялись задачи, для которых Rails-миграций уже не хватало. Также из проекта в проект кочуют одни и те же ошибки в миграциях. Давайте перечислим:
- Миграции данных смешаны с миграциями схемы
- Накапливаются много старых миграций
- Миграции легко ломаются
- Миграции не тестируют
- Долгие миграции
Давайте посмотрим, как эти проблемы решают.
Миграции данных смешаны с миграциями схемы
На первый взгляд это вообще не проблема. Но тем не менее это дополнительная когнитивная нагрузка на разработчика, особенно если миграций много. Удобно, когда миграции находятся в разных директориях и можно просмотреть миграции данных отдельно.
Проблема логична и неизбежна с ростом количества миграций. Мы используем инструмент для задачи, для которой он не задумывался. Единственное решение - не использовать Rails-миграции для миграций данных. Частый выбор - одноразовые скрипты или Rake-задачи.
Накапливаются много старых миграций
С годами директория db/migrate
разрастается до многих сотен файлов. С
ними сложно работать. Сложно просматривать. Сложно искать. Если
накопилось много миграций то их объединяют в одну начальную. При
этом миграции данных вообще пропадут, так как начальная миграция
содержит только схему таблиц.
Миграции данных вносят свой вклад в распухание db/migrate
. Часто ими
злоупотребляют и используют для административных задач, если их нельзя
выполнить в админке. Например, изменить данные в справочной таблице.
Еще один симптом такого злоупотребления - миграция данных только для конкретного окружения:
User.find(1).update(email: 'foobar@example.com') if Rails.env.sandbox?
Миграции легко ломаются
Практически в каждом проекте, который я видел, были сломанные миграции. Хотя бы одна. И нельзя накатить миграции на пустую базу. Приходишь в новый проект, начинаешь разворачивать окружение и начинается - комментируешь одну миграцию за другой. Это как лакмусовая бумажка проекта - показывает насколько он ухоженный.
Зачем запускать все миграции с нуля на пустой базе - это отдельный разговор (конечно, я имею в виду миграции схемы). Сломанные старые миграции не мешают бизнесу, пользователям или production‘у. С другой стороны именно Rails-миграции самый надежный источник правды о схеме таблиц. Не схема базы на production‘е. Не закомиченный дамп схемы в
db/schema.rb
. А именно миграции - журнал и лог изменений, который покажет что, когда, кем и зачем менялось.
Миграции обычно ломаются, если используют код приложения (ActiveRecord модели, их ассоциации, named scope‘ы или методы). Эти классы или методы могут изменить, отрефакторить или переименовать. В любом случае миграция незаметно сломается.
Чтобы избежать такой ситуации видел два подхода. Первый - если нужный код приложения простой, то скопировать его в миграцию. Например, если сильно хочется использовать магию ActiveRecord - можно создать свои карманные модели.
class SetTagsForManagerPosts < ActiveRecord::Migration[6.1]
class User < ApplicationRecord
self.table_name = 'users'
has_many :posts
scope :managers, -> { where(role: :manager) }
end
class Post < ApplicationRecord
self.table_name = 'posts'
belongs_to :user
scope :active, -> { where(active: true) }
end
def change
User.managers.each do |manager|
manager.posts.active.update(tags: [:management])
end
end
end
Если кода много - вынести в отдельные классы в особое место, например в
app/lib/migration
. Это явно подчеркивает, что код связан с миграциями
и его случайно не порефакторят. Хотя, конечно, могут. И изменить и
сломать.
Чтобы всегда ловить такие ломающие изменения хорошо бы добавить накатывание миграций на пустую базу как отдельный шаг на CI. Ни разу такого, правда, не видел в проектах.
Часто в миграциях вообще избегают Ruby-кода и магию ActiveRecord - только raw SQL. Это и безопасней (не запустится какой-нибудь callback с отправкой email‘ов) и так сложнее получить N+1 SQL-запрос.
Миграция данных - одноразовая операция. Она не нужна после запуска на
всех окружениях. Поэтому один из вариантов - через 1-2 деплоя удалить
весь файл миграции или просто код из методов up
/down
/change
.
Миграции не тестируются
Никому не придет в голову писать тест на миграции схемы таблиц. Они просты и очевидны, спасибо DSL’ю. А вот миграция данных - дело другое. Тут и бизнес-логика и сложные SQL-запросы. Тут можно ошибиться. Конечно же можно проверить все руками. Но чем сложнее миграция тем больше итераций и ручной работы. В таких ситуациях лучше иметь автоматические тесты. А как написать тест на Rails-миграцию - не очень понятно. Стандартных инструментов или хелперов в Rails из коробки нет.
Не скажу, что написать тесты на миграцию невозможно. Миграция - это
обычный класс. Его можно инстанцировать и вызвать метод up
/change
в
тесте. Но не все так просто. Придется на тестовой базе откатывать назад
все миграции до той, что тестируется. А потом возвращать все обратно и
накатывать последующие миграции, чтобы остальные тесты прошли нормально.
Ни разу не видел, чтобы писали тесты на Rails-миграции. Вот извернуться
и с кучей хаков протестировать Rake-задачу - вот это в каждом втором
проекте.
Оказалось, что Rails-миграции таки тестируют. Как минимум в GitLab. Это описано в Testing Rails migrations at GitLab.
Если хочется покрыть миграцию данных тестами - проще всего вынести всю
логику в отдельный класс (или Rake-задачу - можно будет запускать
миграцию повторно вручную) в специальной директорию (e.g.
app/lib/migration
) и тестировать уже этот класс. Но это делает
миграцию зависимой и нарушает ее изолированность. Хотя раз этот код
находится в специально отведенном месте, то сломать старые миграции
труднее.
Долгие миграции
С долгими миграциями связаны следующие проблемы:
- Миграция запускается синхронно при деплое и откладывает запуск нового кода. Это может быть ОК даже для 1-2 часовой миграции. Но если миграция длится день? Или два? Или неделю?
- Если миграция требует downtime - он может быть дольше чем мы можем себе позволить
- Если много строчек таблицы модифицировать сразу - возникает проблема с table bloating, когда место, которое занимают старые версии измененных строк, не освобождается и не переиспользуется. На таких распухших таблицах запросы сильно проседают по скорости
- Долгая миграция может завершиться неуспешно из-за deadlock‘ов или тайм-аутов
Обычно не используют Rails-миграции для долгих миграций и мигрируют данные простым скриптом или Rake-задачей не привязываясь к деплою.
Если миграция требует слишком большой downtime, то остается только один вариант - нужно его избежать. Значит код приложения должен быть совместимым с форматом данных до и после миграции.
Есть еще практика с постепенной миграцией данных. Вся работа делится на кусочки, которые мигрируются скриптом один за одним. Скрипт запускается по расписанию, например раз в день. Времени между запусками хватит базе данных, чтобы избежать table bloating. Видел еще вариант со scheduled background job‘ами.
Если миграция запускается на базе данных под нагрузкой - появляется ненулевой шанс прерывания длинного SQL-запроса. Вариантов решения много - можно подкрутить настройки базы данных и увеличить тайм-ауты. Чаще просто разделяют данные на части и запускают SQL-запрос для каждой из них. А если SQL-запрос прервался - ловят исключение и перезапускают его.
Видел вариант, когда такие подзадачки оформлялись в background job‘ы и объединялись в один batch job (Sidekiq так умеет). Если выставить concurrency в 1 - то получим все тоже самое что дает скрипт + из коробки механизм перезапуска упавших job‘ов. Если увеличить concurrency - можно ускорить миграцию, правда риск получить тайм-аут увеличится.
PS
Миграция данных настолько стандартная задача, что хотелось бы иметь бОльшую поддержку от Rails. Обоими руками за:
- отдельный механизм для работы с миграциями данных из коробки
- отдельную директорию для миграций данных (где-нибудь в
db/data_migration
) - хелперы для тестирования миграций - как схемы так и данных
- популяризацию тестов на миграции в Rails Guides