Проблемы с миграцией данных в 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