Rails Not All Migrations are Equal: Schema vs. Data

hooopo · 2016年05月07日 · 最后由 ibachue 回复于 2016年05月07日 · 2300 次阅读
本帖已被设为精华帖!

之前发过一个帖子「Rails should have data migration」,当时也没太弄清楚 App 和 Schema Migration 之间,以及 Schema Migration 和 Data Migration 之间的关系。直到读完这篇「Not All Migrations are Equal: Schema vs. Data」才茅塞顿开。

本质上 App 依赖 Schema Migration,但 Schema Migration 不应该去依赖 App,Schema Migration 是可以脱离 App 独立存在的。 而 Data Migration 呢,是需要依赖 App 的业务逻辑的。所以呢,Schema 和 Data Migration 是不能放在一起的。

理解了这两个前提,看一下作者举的例子:

Bad Schema Migration

class ChangeAdminDefaultToFalseOnUsers < ActiveRecord::Migration
  def up
    change_column_default(:users, :admin, false)
    User.reset_column_information # 注:这里Migration依赖App里的User model和reset_column_information方法了。

    # Bad: Use of application code that changes over time.
    User.update_null_to_false! 
  end
end

Good Schema Migration

class ChangeAdminDefaultToFalseOnUsers < ActiveRecord::Migration
  # Create empty AR model that will attach to the users table,
  # and isolate migration from application code.
  class User < ActiveRecord::Base; end

  def up
    change_column_default(:users, :admin, false)
    User.where(admin:nil).update_all(admin: false)
  end
end

Better Schema Migration

class ChangeAdminDefaultToFalseOnUsers < ActiveRecord::Migration
  def up
    change_column_default(:users, :admin, false)
    execute "UPDATE users SET admin = false WHERE admin IS NULL"
  end
end

当然 SQL 也不是那么好写,作者又讨(批)论(评)了一下流行的one off rake task方案:

Create a one off rake task? No. Perhaps, but the code will be difficult to test and won’t have mechanisms in place to roll back to changes. Even if you refactor the logic out of the rake task into a separate ruby >class, you will now have to maintain code that is ephemeral in nature. It merely exists for this one off data migration.

最后作者提出Datafixes的架构,其实本质上是基于one off rake task的扩充,包括:

Generator:

> rails g datafix AddValidWholesalePriceToProducts
  create  db/datafixes/20141211143848_add_valid_wholesale_price_to_products.rb
  create  spec/db/datafixes/20141211143848_add_valid_wholesale_price_to_products_spec.rb

像 schema_migration 表一样记录 data migration 是否执行过的机制:

> rake db:datafix
  migrating AddValidWholesalePriceToProducts up

> rake db:datafix:status

  database: somedatabase_development

   Status   Datafix ID            Datafix Name
  --------------------------------------------------
     up    20141211143848       AddValidWholesalePriceToProducts

可以为 Data Migration 写测试:

require "rails_helper"
require Rails.root.join("db", "datafixes", "20141211143848_add_valid_wholesale_price_to_products")

describe Datafixes::AddValidWholesalePriceToProducts do
  describe ".up" do
    # Fill out the describe block
    let!(:product) do
      product = FactoryGirl.build(:product, wholesale_price_cents: nil)
      product.save(validate: false)
      product
    end

    it "should fix the price and be valid" do
      expect(product).to_not be_valid
      subject.migrate('up')
      expect(product.reload).to be_valid
    end
  end
end

Rollback 机制:

rake db:datafix:rollback

另外又提到了和 Datafix 类似的nondestructive_migrations gem:

Update 01/26/2015: Check out the nondestructive_migrations gem. It’s similar to dimroc/datafix but simpler because it leverages existing AR code. It does not however generate specs… yet.

共收到 6 条回复

我平时一直是用 reversible 做 migration 的,效果很好

class SplitNameMigration < ActiveRecord::Migration
  def change
    add_column :users, :first_name, :string
    add_column :users, :last_name, :string

    reversible do |dir|
      User.reset_column_information
      User.all.each do |u|
        dir.up   { u.first_name, u.last_name = u.full_name.split(' ') }
        dir.down { u.full_name = "#{u.first_name} #{u.last_name}" }
        u.save
      end
    end

    revert { add_column :users, :full_name, :string }
  end
end

#1 楼 @ibachue 虽然没用过,但看了一下文档,应该和这个帖子讨论的不是同一个问题...

reversible 解决的问题是 reversible schema migration 的写法问题,如果没有 reversible,你的这段代码应该等价于:

class SplitNameMigration < ActiveRecord::Migration
  def up
    add_column :users, :first_name, :string
    add_column :users, :last_name, :string

      User.reset_column_information
      User.all.each do |u|
        u.first_name, u.last_name = u.full_name.split(' ') 
        u.save
      end
   revert { add_column :users, :full_name, :string }
  end

  def down
    revert {add_column :users, :first_name, :string}
    revert {add_column :users, :last_name, :string}
     User.all.each do |u|
        u.full_name = "#{u.first_name} #{u.last_name}" 
        u.save
      end
    add_column :users, :full_name, :string 
  end
end

其实这是一个很好的反例...本质上还是开头提到的Bad Schema Migration。因为你用了 change 这个 api,data migration 和 schema migration 混合的时候就必须引入 reversible 机制。但如果深究这种混合写法的问题呢,还可以发现,如果 User 表大一些呢,这时候 schema migration 就会卡住,后面的 schema migration 一直在等待。这时候 schema 迁移了一半,然后在中间在跑一个耗时的 task,你的 App 是继续运行呢还是要停下呢。data migration 分出来的话就不会出现这种问题。

后者要比前者好一些,前者依赖pg。 现在数据迁移倾向于用 rake,可以打一票 log,在 log 里输这样的进度(进度条是个伟大的发明)。不过用 rake 没有写过 reverible 的内容,目前没有遇到过类似情景。idea 非常好。以后借鉴下。thx

还没有过在 schema migration 做过 app migration。。

#2 楼 @hooopo

哦 对 确实不是一个问题

我看了那个文章,结合以前在 EMC 的经验,感觉作者将 Schema Migration 和 Data Migration 完完全全拆开肯定是不对的,当然 Rails 的做法本来也是不对的。这个问题的本质就是,都太不把 Migration 当回事了。

一般来说,一个 Migration 可能是这样的一个需求:如果是加 Column 同时赋予默认值,再设置一个 NOT NULL 约束,那么如果这几步一起做会导致整个数据库等待很长时间,期间表锁死(前提是已有的数据量很大),这显然是不合适的。因此比较好的做法是:

  1. 添加 Column,但没有默认值,这样会快很多。
  2. 完成之后,再给 Column 设置默认值,这样新的数据在这个 Column 上就有默认值了,之前的旧数据依然没有。
  3. App 代码中已经可以开始使用这个新 Column,但必须能处理旧的数据,也就是该 Column 为 nil 的情况。
  4. Data Migration 开始,为所有已有数据对该 Column 设值,这一过程通常很长,需要脚本满足可以随时暂停重启的需求(这样白天高峰期可以不 migrate)。
  5. Data Migration 结束后,添加 NOT NULL 约束,所有 Column 现在都有值。
  6. App 代码中移除对旧数据的处理逻辑。

作者认为 migration 需要很长时间这个说法肯定是正确的,现有的 migration 代码没有测试也确实不好。但是作者将 Schema Migration 和 Data Migration 完全拆开这个做法肯定不对,事实上,从上面的例子中可以发现,Schema Migration,Data Migration 和 App 代码相互依赖,我恨不得把 App 代码的迁移也写在 Migration 里(否则就要记得去更新代码然后重新部署),怎么可能将其分离?

所以我觉得 Rails 可能的改进点是,多段 migration,例如

class SplitNameMigration < ActiveRecord::Migration
  change 'phrase 1: migration schema' do
    # add column
    # set default value for new data
  end

  change 'phrase 2: data migration', transaction: false do
    up do
      # migrate data one by one
    end
    down do
      # rollback data one by one
    end
  end

  change 'phrase 3: migration schema again' do
    # set 'not null' for the column
  end

这个改进要求 Rails 支持 migration 存在多段并且要在数据库中记录当前 migrate 到了第几段,第二段中的 transaction: false 表示执行这段 migration 时不要自动添加 transaction,脚本自行处理 transaction,避免在中间暂停时之前迁移的数据 rollback。

当然这个改进依然不够,因为一般来说总是等 rake db:migrate 执行完毕后才启动 Rails server,但不可能等待 data migration 全部结束后才启动 Rails server,可能的做法是,在 phrase 1 结束后就自动让 Rails Server 启动,而在 Phrase 3 结束后自动控制 git 版本让它 checkout 出一个包含了对该 column 的 presence 做检查同时移除旧代码的处理逻辑的新版本然后再重启 Rails Server 就比较好了。当然这对 migration 的要求好像太高了?

6楼 已删除
lgn21st 将本帖设为了精华贴 08月17日 00:33
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册