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