Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RailsとRidgepoleの マイグレを120倍早くする

Avatar for Hazumi Ichijo Hazumi Ichijo
August 07, 2025
150

RailsとRidgepoleの マイグレを120倍早くする

Avatar for Hazumi Ichijo

Hazumi Ichijo

August 07, 2025
Tweet

More Decks by Hazumi Ichijo

Transcript

  1. Copyright © Henry, Inc. All rights reserved. Roppongi.rb #33 一條端澄

    (@rerost/hazumirr) RailsとRidgepoleの マイグレを120倍早くする
  2. Copyright © Henry, Inc. All rights reserved. 自己紹介 一條 端澄

    (X: @hazumirr, GitHub: @rerost) 株式会社ヘンリー エンジニア 普段はKotlinを書いています 趣味 • 小さいツールを長くメンテする • CI/CD周りの改善・パフォーマンス改善
  3. Copyright © Henry, Inc. All rights reserved. 意図せず > RidgepoleによるDBスキーマの宣言的定義に移行して便利になりました

    (ちょっと高速化もしました) > https://blog.smartbank.co.jp/entry/2025/08/01/103000 と完全に被りました • PostgreSQL • ConnectionAdaptersを実装する で、微妙にやっていることが違うので温かい目で見てもらえると (一応、僕のほうがちょっとだけGitHubの公開が早かった) 謝罪
  4. Copyright © Henry, Inc. All rights reserved. Railsで手元から本番やQAなどのリモートDBへマイグレーションをしたときに、 謎の待ち時間があった経験ありません? •

    なんかやたらSQLが表示される ◦ SELECT … FROM pg_attribute …みたいな • マイグレーションも終了しており、カラム・テーブルはあるのになぜか待た される...? Railsでのマイグレーション時の問題
  5. Copyright © Henry, Inc. All rights reserved. マイグレーション後にdb:schema:dump を行いdb/schema.rbの更新を行ってい るのが原因

    対処法 • config.active_record.dump_schema_after_migration = false ◦ db:schema:dump をスキップする ◦ https://github.com/rails/rails/issues/38927 • config.active_record.schema_format = :sql ◦ DBのネイティブなツールでdumpする あたりで回避可能 Railsでのマイグレーション時の問題
  6. Copyright © Henry, Inc. All rights reserved. Railsの db/schema.rb を書くと、その状態にDBを変更してくれるくん

    • 添付のように、schema.rbと同じ記法が使える • Railsのmigrationを書きschema.rbが生成される流れと逆。sqldefと類似 • 接続先DBにusersテーブルがないとき => DBにテーブルを作る • 接続先DBにusersテーブルがあるとき => カラムやnull制約などが変 わっていたら、DBに反映 Ridgepoleについて create_table :users, force: true do |t| t.string :name, null: false t.timestamps end
  7. Copyright © Henry, Inc. All rights reserved. だんだんサービス成長し、Ridgepoleでの反映に20分かかっていた。 ローカルだと数秒なので、原因としては •

    GitHub Actionsのマシンスペック • ネットワークレイテンシー • … のどれかなはず Ridgepoleについて
  8. Copyright © Henry, Inc. All rights reserved. Ridgepoleの処理の流れとしては 1. 接続先のDBのスキーマをdump

    • ActiveRecord::SchemaDumper.dumpを使い、接続先のDBのスキーマ をロード 2. 差分計算 3. マイグレーションの実行 Ridgepoleについて
  9. Copyright © Henry, Inc. All rights reserved. Ridgepoleの処理の流れとしては 1. 接続先のDBのスキーマをdump

    • ActiveRecord::SchemaDumper.dumpを使い、接続先のDBのスキーマ をロード <- ここがボトルネック 2. 差分計算 3. マイグレーションの実行 Ridgepoleについて
  10. Copyright © Henry, Inc. All rights reserved. ActiveRecord::SchemaDumperについて def tables(stream)

    sorted_tables = @connection .tables.sort not_ignored_tables = sorted_tables.reject { | table_name| ignored?( table_name) } not_ignored_tables.each_with_index do |table_name, index| table(table_name, stream) stream.puts if index < not_ignored_tables.count - 1 end # dump foreign keys at the end to make sure all dependent tables exist. if @connection .supports_foreign_keys? foreign_keys_stream = StringIO.new not_ignored_tables.each do |tbl| foreign_keys( tbl, foreign_keys_stream) end foreign_keys_string = foreign_keys_stream.string stream.puts if foreign_keys_string.length > 0 stream.print foreign_keys_string end end def table(table, stream) columns = @connection .columns(table) https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/schema_dumper.rb#L134-L159
  11. Copyright © Henry, Inc. All rights reserved. ActiveRecord::SchemaDumperについて def tables(stream)

    sorted_tables = @connection.tables.sort not_ignored_tables = sorted_tables.reject { | table_name| ignored?( table_name) } not_ignored_tables.each_with_index do |table_name, index| table(table_name, stream) stream.puts if index < not_ignored_tables.count - 1 end # dump foreign keys at the end to make sure all dependent tables exist. if @connection.supports_foreign_keys? foreign_keys_stream = StringIO.new not_ignored_tables.each do | tbl| foreign_keys( tbl, foreign_keys_stream) end foreign_keys_string = foreign_keys_stream.string stream.puts if foreign_keys_string.length > 0 stream.print foreign_keys_string end end def table(table, stream) columns = @connection .columns(table) https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/schema_dumper.rb#L134-L159
  12. Copyright © Henry, Inc. All rights reserved. connection.columnsを辿っていくと、PostgreSQLの場合、以下にたどり着く ActiveRecord::SchemaDumperについて def

    column_definitions (table_name) query(<<~SQL, "SCHEMA") SELECT a.attname, format_type(a.atttypid, a.atttypmod), pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, c.collname, col_description(a.attrelid, a.attnum) AS comment, #{supports_identity_columns? ? 'attidentity' : quote('')} AS identity, #{supports_virtual_columns? ? 'attgenerated' : quote('')} as attgenerated FROM pg_attribute a LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum LEFT JOIN pg_type t ON a.atttypid = t.oid LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation WHERE a.attrelid = #{quote(quote_table_name( table_name))}::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum SQL end https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/connection_adapters/postgresq l_adapter.rb#L1034-L1049
  13. Copyright © Henry, Inc. All rights reserved. カラム定義以外にも主キーの判定や外部キー判定でもN+1が発生 高速化方針 •

    Preloadをする(ただしActiveRecordが使えない) • 影響範囲は最小限にする。標準のConnectionAdaptersに手を入れると、 SchemaDumper以外にも問題が出るので 対応方法: 専用のConnectionAdaptersを作成。普通はDBの種類ごとに作られる が、今回はSchemaDumperにユースケースを絞り、高速化したものを作成 • postgresql:///... -> bulk-postgresql://... • adapter: postgresql -> adapter: bulk-postgresql で差し替えられるようにする(Ridgepoleでも利用可能) SchemaDumperのN+1について
  14. Copyright © Henry, Inc. All rights reserved. 再掲 SchemaDumperのN+1について def

    column_definitions (table_name) query(<<~SQL, "SCHEMA") SELECT a.attname, format_type(a.atttypid, a.atttypmod), pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, c.collname, col_description(a.attrelid, a.attnum) AS comment, #{supports_identity_columns? ? 'attidentity' : quote('')} AS identity, #{supports_virtual_columns? ? 'attgenerated' : quote('')} as attgenerated FROM pg_attribute a LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum LEFT JOIN pg_type t ON a.atttypid = t.oid LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation WHERE a.attrelid = #{quote(quote_table_name( table_name))}::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum SQL end https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/connection_adapters/postgresq l_adapter.rb#L1034-L1049
  15. Copyright © Henry, Inc. All rights reserved. 再掲 SchemaDumperのN+1について def

    column_definitions (table_name) query(<<~SQL, "SCHEMA") SELECT a.attname, format_type(a.atttypid, a.atttypmod), pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, c.collname, col_description(a.attrelid, a.attnum) AS comment, #{supports_identity_columns? ? 'attidentity' : quote('')} AS identity, #{supports_virtual_columns? ? 'attgenerated' : quote('')} as attgenerated FROM pg_attribute a LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum LEFT JOIN pg_type t ON a.atttypid = t.oid LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation WHERE a.attrelid = #{quote(quote_table_name( table_name))}::regclass AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum SQL end https://github.com/rails/rails/blob/v8.0.2/activerecord/lib/active_record/connection_adapters/postgresq l_adapter.rb#L1034-L1049
  16. Copyright © Henry, Inc. All rights reserved. 改善後: 以下のようにPreloadを挟む。PreloadするタイミングはAdapter作成時 SchemaDumperのN+1について

    def preload_column_definitions (table_names) table_name_map = ( query(<<~SQL, "SCHEMA") SELECT (a.attrelid::regclass)::text , a.attnum, a.attname, format_type(a.atttypid, a.atttypmod), pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod, c.collname, col_description(a.attrelid, a.attnum) AS comment, #{supports_identity_columns? ? 'attidentity' : quote('')} AS identity, #{supports_virtual_columns? ? 'attgenerated' : quote('')} as attgenerated FROM pg_attribute a LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum LEFT JOIN pg_type t ON a.atttypid = t.oid LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation WHERE a.attnum > 0 AND NOT a.attisdropped SQL ).group_by(&:first) ... https://github.com/rerost/activerecord_bulk_postgresql_adapter/blob/master/lib/activerecord_bulk_po stgresql_adapter.rb#L56-L70
  17. Copyright © Henry, Inc. All rights reserved. 改善の結果 • SchemaDumperのN+1はすべて解消。10種類以上のN+1があった

    • 結果として、20分かかっていたのが10秒程度で終了 感想 • ActiveRecordナシでPreloadするのしんどい • 片っ端からN+1を潰すのは辛いので、Claude Codeにやってもらった ちなみにN+1を潰そうとする動きはある https://github.com/rails/rails/pull/53930 結果
  18. Copyright © Henry, Inc. All rights reserved. https://note.com/henry_app 会社ブログやってます We

    are hiring!! https://henry.jp/ Thank you https://dev.henry.jp/ 技術ブログやってます