コピペコードで快適生活

明日使えるソースを自分のために

db:migrateで巨大なテーブルへadd_column+default値設定をする

Rails + PostgreSQL環境での話。
数千万行あるような巨大なテーブルに対して、add_column+default設定をまとめて設定すると、サービスを止めてしまうほどに長時間テーブルロックかかってしまう。AccessExclusiveLockなのでSELECTも通らない。
原因は、ALTER_TABLEのテーブルロックかかった中で、カラム追加+デフォルト設定に加えて全レコードに対してUPDATE(デフォルト値で上書き)が走るため。

こんな書き方すると発生する。

class AddXxxIdToBigRecords < ActiveRecord::Migration
  def change
    add_column :big_records, :xxx_id, :integer, null: false, default: 0
  end
end

対応方法としては下記となる。
ただ、テーブルサイズがでかいので、1行ごとUPDATEするのに相当の時間がかかる。

class AddXxxIdToBigRecords < ActiveRecord::Migration
  # まずこれを設定しないと、upメソッド内全部にトランザクションかかってしまう。
  disable_ddl_transaction!

  def up
    # 最初にカラムを足す。AccessExclusiveLockかかるけど一瞬で終わる。
    # (MySQLと違って一瞬で終わる。)
    add_column :big_records, :xxx_id, :integer

    # 次にデフォルト値を設定する。AccessExclusiveLockかかるけど一瞬で終わる。
    # カラムの値は更新されない。
    change_column :big_records, :xxx_id, :integer, default: true

    # 全レコードに対してUPDATEを実行する
    update_all_with_default_value

    # NOT NULLのフタをする
    change_column :big_records, :xxx_id, :integer, default: true, null: false
  end

  def down
    remove_column :big_records, :xxx_id
  end

  #
  # 全レコードにたいして一つずつUPDATEをかけていく。
  # Rails5以上であれば、
  # in_batchedとupdate_allで複数行をまとめて更新かけたほうがいいかも。
  #
  def update_all_with_default_value
    total = BigRecord.count
    return true if total == 0

    start_f = Time.zone.now.to_f
    BigRecord.find_each.with_index do |record, i|
      n = i + 1
      record.update_columns(xxx_id: 0)
      if n % 1000 == 0 || n == total
        STDOUT.puts "-- update_all_with_default_value (#{i+1} / #{total})"
      end
    end
    end_f = Time.zone.now.to_f

    STDOUT.puts "  -> #{end_f - start_f}s"
    return true
  end
end