Статьи

Куратор позволяет «ленивым» переносам данных в 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

The problem with this approach is that we have to use branching code like this whenever we want to use the name. It quickly gets messy.

Solution attempt 2: Gathered conditionals

The second solution is to move this logic to the place where we read the Person out of the data store:

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

Now, we only have to do it once and we can change our Person class to only know about name.

This solution works well, but what happens a year down the road when we’ve made lots of data changes to many different models? We don’t want a bunch of conditionals all over our persistence code.

Our solution: Lazy data migrations

We pulled the idea from solution 2 into the idea of a migration (similar to ActiveRecord migrations). Migrations target a given collection at a given version. They look like this:

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

This migration is stored in db/migrate/people/0001_consolidate_name.rb. We’ve also added the concept of a version to each Model. By default, models start at version 0. When they are read from the Repository, the attributes are run through any migrations that are a greater version (based on the version in the filename):

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

Now, the migration logic is isolated from the rest of the application. The rest of the app can safely assume that all Person objects have only a name:

class Person
  current_version 1
  attr_accessor :name
end

We mark the Person class with current_version 1 to signify that new instances start at version 1, since they have a name attribute rather than first_name/last_name.

These migrations run when models are read, so they are lazy. Data will migrate as it’s used, and update when saved. This means that, unlike with relational databases, the website can be up and serving requests while the data is migrated.

If you want to force the data to migrate (and not wait for all data to be used), you can simply find models who haven’t been migrated and save them. The version attribute is indexed by default:

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

Testing

Unlike ActiveRecord migrations, curator migrations have no side effects. They simply accept a hash and return a new hash. This makes them easy to call from a unit test:

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

Limitations

Curator migrations are lazy, so at any given time you might have documents with different versions in the data store. This is not normally a problem since the migrations will run as soon as the objects are read. However, if you add a migration that changes an indexed field, you cannot rely on that index to return all of the correct values until you migrate them all. In this case, you might want to force migration by reading and saving all of the documents.

Next Steps

You can see these migrations in action in the curator_rails_example.

Let us know what you think about lazy data migrations in curator. Feel free to open issues on GitHub, submit pull requests, and help us make it better.