Статьи

Ленивая миграция данных в Riak

Первоначально автор Пол Гросс и кросс- пост в блоге Брейнтри

Движение NoSQL принесло нам волну новых хранилищ данных за пределами традиционных реляционных баз данных. Эти хранилища данных имеют свои собственные компромиссы, но они предоставляют невероятные преимущества. В Braintree мы движемся в направлении использования Riak в качестве хранилища данных следующего поколения. Нам нравится его фокус на масштабируемости и доступности. Серверы могут выходить из строя, не вызывая простоев, и мы можем увеличить емкость, просто добавив больше серверов в кластер.

Однако одной из отличительных особенностей реляционных баз данных является согласованность формы данных. Вы знаете, если у вас есть таблица людей, каждая строка имеет одинаковые столбцы. Некоторые поля могут быть нулевыми, но никаких сюрпризов не будет. Кроме того, если вы хотите переименовать или изменить столбец, это простая операция. В случае PostgreSQL и других баз данных переименование происходит практически мгновенно. Мы теряем эту способность с Riak и большинством баз данных NoSQL. Мы можем легко добавить атрибуты (столбцы), но мы не можем легко переименовать их или изменить данные в каждом документе (строке).

Поскольку наши приложения в Braintree постоянно развиваются, нам нужен был способ, чтобы наши данные не отставали от нашего кода. Наше решение — это то, что мы называем отложенной миграцией данных, и мы встроили его в нашу среду репозитория и модели, куратор . Вы можете узнать больше о кураторе в нашем блоге в разделе Untangle Domain и Persistence Logic with Curator .

Эта проблема

Скажем, у нас есть коллекция людей в Риаке. Это аналог таблицы людей в реляционной базе данных. Когда мы впервые создали приложение, мы добавили поля для first_name и last_name:

person = Person.new(:first_name => "Joe", :last_name => "Smith")

Прошло некоторое время, у нашего приложения есть данные, и теперь мы понимаем, что имена — это боль. Что мы делаем со вторыми именами? А как насчет людей с несколькими именами или фамилиями? Мы хотим просто упростить систему и собрать только имя. Мы больше не заботимся об отдельном имени и фамилии. Проблема в том, что у нас есть тонна данных в старом формате. Как нам справиться с тем, что старые записи имеют имя и фамилию, но в дальнейшем нам нужно просто имя?

В реляционной базе данных мы просто напишем миграцию базы данных, которая выглядит следующим образом:

ALTER TABLE people ADD COLUMN name VARCHAR;
UPDATE people SET name = first_name || ' ' || last_name;
ALTER TABLE people DROP COLUMN first_name, DROP COLUMN last_name;

Эта миграция может занять некоторое время, но как только она будет завершена, мы знаем, что все данные были перенесены. Затем мы можем изменить весь наш код, чтобы иметь дело только с именем, зная, что у нас больше нет first_name или last_name.

В базе данных NoSQL, такой как Riak, мы не можем просто изменить схему. Мы должны придумать другое решение. Вот шаги, которые мы прошли, пытаясь найти решение, которое попало в куратор :

Попытка решения 1: разбросанные условия

Первое решение — сделать класс Person достаточно умным, чтобы справиться с обоими случаями.

class Person
attr_accessor :first_name, :last_name, :name
end

Мы можем заполнить любые поля, которые мы получаем из хранилища данных. Затем, когда мы хотим что-то сделать с именем, мы должны использовать такой код:

if person.name
puts "Name is #{person.name}"
else
puts "Name is #{person.first_name} #{person.last_name}"
end

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

Попытка решения 2: Собранные условия

Второе решение состоит в том, чтобы переместить эту логику в место, где мы читаем человека из хранилища данных:

attributes = fetch_from_riak
if attributes[:name]
person = Person.new(:name => attributes[:name])
else
person = Person.new(:name => "#{attributes[:first_name]} #{attributes[:last_name]}")
end




Теперь нам нужно сделать это только один раз, и мы можем изменить наш класс Person, чтобы он знал только имя.

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

Наше решение: отложенная миграция данных

Мы перенесли идею из решения 2 в идею миграции (аналогично миграциям ActiveRecord). Миграции нацелены на данную коллекцию по данной версии. Они выглядят так:

class ConsolidateName < Curator::Migration
def migrate(attributes)
first_name = attributes.delete(:first_name)
last_name = attributes.delete(:last_name)
attributes.merge(:name => "#{first_name} #{last_name}")
end
end

Эта миграция хранится в db / migrate / people / 0001_consolidate_name.rb. Мы также добавили концепцию версии для каждой модели. По умолчанию модели начинаются с версии 0. Когда они читаются из репозитория, атрибуты запускаются через любые миграции, которые имеют более высокую версию (в зависимости от версии в имени файла):

person = PersonRepository.find_by_key("person_id")
person.version #=> 1

Теперь логика миграции изолирована от остальной части приложения. Остальная часть приложения может смело предположить, что все объекты Person имеют только имя:

class Person
current_version 1
attr_accessor :name
end

Мы помечаем класс Person с current_version 1, чтобы указать, что новые экземпляры начинаются с версии 1, поскольку они имеют атрибут name, а не first_name / last_name.

Эти миграции выполняются, когда модели читаются, поэтому они ленивы. Данные будут мигрировать по мере использования и обновляться при сохранении. Это означает, что, в отличие от реляционных баз данных, веб-сайт может работать и обслуживать запросы во время переноса данных.

Если вы хотите принудительно перенести данные (а не ждать, пока все данные будут использованы), вы можете просто найти модели, которые не были перенесены, и сохранить их. Атрибут version индексируется по умолчанию:

PersonRepository.find_by_version(0).each do |person|
PersonRepository.save(person)
end

тестирование

В отличие от миграций ActiveRecord, миграции кураторов не имеют побочных эффектов. Они просто принимают хеш и возвращают новый хеш. Это облегчает вызов из юнит-теста:

require 'spec_helper'
require 'db/migrate/people/0001_consolidate_name'




describe ConsolidateName do
describe "migrate" do
it "concatenates first_name and last_name" do
attributes = {:first_name => "Joe", :last_name => "Smith"}
ConsolidateName.new(1).migrate(attributes)[:name].should == "Joe Smith"
end
end
end

Ограничения

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

Следующие шаги

Вы можете увидеть эти миграции в действии в curator_rails_example .

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