※この記事は、2022 Speee Advent Calendar10日目の記事です。 昨日の記事はこちら。
初めまして。22新卒エンジニアの長谷川です。現在は、イエウール の開発に携わっています。
今回の記事では、Rails のマイグレーションツールである Ridgepole を使用していた際に感じた疑問に対して、MySQL や Ridgepole の動きについて調べたことについてまとめた内容になります。
Ridgepole とは
イエウールでは Rails を用いての開発をおこなっていますが、そのマイグレーションツールとして Ridgepole を導入しています。
Ridgepole は Rails にデフォルトで搭載されているマイグレーションシステムにかわるライブラリで、マイグレーションファイルを変更するたびに作らずに、単一の Schema ファイルで管理ができます。
疑問に感じたこと
その Ridgepole を使用したマイグレーションの中で、次のような Schema ファイルで実行したところ、一回目のマイグレーションは問題なく成功するのに同じ内容で二回目にマイグレーションを行うと失敗する、ということが起きました。
create_table :table_a, id: :integer, charset: 'utf8mb4', force: :cascade do |t| t.integer "table_b_id", null: false end add_foreign_key "table_a", "table_b", column: "table_b_id"
一回目は成功するのですが、二回目の実行でエラーが発生します。
-- remove_index("table_b", {:name=>"fk_rails_58690fb76e"}) [ERROR] Mysql2::Error: Cannot drop index 'fk_rails_58690fb76e': needed in a foreign key constraint
エラーをみると、外部キー制約があることでインデックスが外せずエラーが発生しているようです。
「そもそもインデックス貼ってないのになぜこのエラーが発生するのか?」「なんで一回目の処理が成功して二回目の処理が失敗するのか?」
このように疑問に感じたため調べました。
用語や知識の整理
そもそもここで出てくる用語を理解していないので整理してみます。
インデックス
インデックスは、特定のカラム値を持つ行を素早く見つけるために使用されます。インデックスがない場合、MySQL は最初の行から始めて、関連する行を見つけるためにテーブル全体を読み込む必要があります。テーブルが大きければ大きいほど、このコストは高くなります。テーブルに該当するカラムのインデックスがある場合、MySQL はすべてのデータを見ることなく、データファイルの途中からシークする位置をすばやく決定することができます。これは、すべての行を順次読み込むよりもはるかに高速です。
あらかじめ特定のカラムを用いたツリー構造をつくることで、検索クエリの高速化などに使われるようです。
外部キー制約
外部キー関係を通じてデータベースの一貫性を維持する制約の一種。他の制約と同様に、データの矛盾が発生する場合、データの挿入や更新を阻止することができます。また、DML操作が行われたとき、FOREIGN KEY制約は、外部キー作成時に指定されたON CASCADEオプションに基づいて、子行内のデータを削除したり、異なる値に変更したり、NULLに設定したりすることができます。
外部キー制約を貼る構文は次の通りです。
[CONSTRAINT [symbol]] FOREIGN KEY [index_name] (index_col_name, ...) REFERENCES tbl_name (index_col_name,...) [ON DELETE reference_option] [ON UPDATE reference_option] reference_option: RESTRICT | CASCADE | SET NULL | NO ACTION
構文をみると、外部キー制約はインデックスに対して貼ることがわかります。
Mysql2::Error: Cannot drop index 'fk_rails_58690fb76e': needed in a foreign key constraint
二回目に発生したエラーも、外部キー制約で使用されているインデックスを消そうとしているため発生していることがわかります。
なぜインデックスをつけずに外部キー制約を定義しているのに一回目の処理が成功するのか
外部キー制約を設定するにはインデックスが必要だということはわかったのですが、ではなぜ一回目の処理が成功するのでしょうか。Ridgepole の --dry-run
オプションを使用して、マイグレーション時に生成されるSQLを確認してみます。
add_foreign_key("table_b", "table_a", **{}) # ALTER TABLE `table_b` ADD CONSTRAINT `fk_rails_58690fb76e` # FOREIGN KEY ( # `table_a_id`) # REFERENCES `table_a` (`id`)
実際にテーブルの情報を見ると、インデックスが自動生成されていることがわかります。
KEY `fk_rails_58690fb76e` (`table_a_id`), CONSTRAINT `fk_rails_58690fb76e` FOREIGN KEY (`table_a_id`) REFERENCES `table_a` (`id`)
また、Ridgepole 実行時のログから、ALTER TABLE ステートメントだけが発行されているので、Rails や Ridgepole によって index を生成する SQL が発行されているわけではなく、あくまで ALTER TABLE ステートメント内部で自動生成されています。
MySQL のドキュメントを確認すると、外部キー制約を設定する際に、インデックスがなければ自動生成されていることがわかります。
MySQL では、外部キーカラムにインデックスを付ける必要があります。外部キー制約を持つテーブルを作成しても、特定のカラムにインデックスがない場合、インデックスが作成されます。
なぜインデックスが自動生成されているのに二回目の処理が失敗するのか
二回目のマイグレーションでは、remove_index が実行され自動生成されたインデックスを削除しようとしてエラーが発生します。
remove_index("table_b", {:name=>"fk_rails_58690fb76e"}) # DROP INDEX `fk_rails_58690fb76e` ON `table_b` [ERROR] Mysql2::Error: Cannot drop index 'fk_rails_58690fb76e': needed in a foreign key constraint
remove_index
(とそれに伴う DROP INDEX)を発行しているのは Ridgepole なので、 Ridgepole のマイグレーションがどのように行われるかを調べると良さそうです。
Ridgepole のソースコード を読んでみると、
- 現在のデータベースの情報と抽出
- 適用したい Schama ファイルをパースして情報を抽出
- それらを比較し、差分を検知
- 差分があればそれに該当する SQL ステートメントを発行する
という流れになっていることがわかります。
そして、実際に --verbose
オプションをつけることで Ridgepole がどのような差分を検知しているかがわかります。実際に失敗したマイグレーションにて Ridgepole が検知した差分を見てみると、次のようになります。
"table_a_id"=>{:options=>{:null=>false}, :type=>:integer}}, :foreign_keys=> {["table_b", "table_a", nil]=>{:options=>{}, :to_table=>"table_a"}}, - :indices=> - {"fk_rails_58690fb76e"=> - {:column_name=>["table_a_id"], :options=>{:name=>"fk_rails_58690fb76e"}}}, :options=>{:charset=>"utf8mb4", :id=>:integer}}
ここでは、外部キーでは差分は検知されておらず、インデックスの部分で差分が検知されていることがことがわかります。 これにより、インデックスを削除する SQL ステートメントが発行されます。
まとめ
Ridgepole を使用したマイグレーションにて、インデックスを貼り忘れて外部キー制約を設定すると、
- 一回目の処理は MySQL によってインデックスが自動生成されるため成功する。
- 二回目以降の処理は自動生成されたインデックスで、データベースの情報と適用したい Schema ファイルに差分が生じるのエラーが発生する
ということになります。
感想をひとこと
Ridgepole でのマイグレーションで、疑問に感じた箇所を調べてみました。
インデックスを貼ったら正常に動くようになる、ということは先輩エンジニアに質問させてもらった際にすぐ解決したことではあるのですが、小さな疑問に対して少し立ち止まって調べたことで、とても勉強になりました。
これまではあまりマイグレーションファイルを書いたりデータベースの動きについて調べたことがなく苦手意識がとても強かったのですが、今回基礎的な知識を整理したことで、解像度が上がり苦手意識が和らいだのが一番の調べて良かったポイントです。
最後に
Speeeでは一緒にサービス開発を推進してくれる仲間を大募集しています!
こちらのFormよりカジュアル面談も気軽にお申し込みいただけます!
Speeeでは様々なポジションで募集中なので「どんなポジションがあるの?」と気になってくれてた方は、こちらチェックしてみてください!もちろんオープンポジション的に上記に限らず積極採用中です!!!