🔒

strong_migrations gemの導入でRailsマイグレーションのメタデータロック待ちリスクに対処する

2023/08/15に公開

TL;DR

  • strong_migrations gemの導入により、開発者がルールを意識しなくてもRailsマイグレーションのメタデータロック待ちのリスクを低減させることができた
  • タイムアウト時のマイグレーションのリトライ機能や危険なマイグレーションのチェックが便利なため、Railsのマイグレーションを行う際はとりあえず導入するのがおすすめ

導入の経緯

trocco®開発チームではアプリケーションにRails、RDBにMySQLを使用しており、DBスキーマのマイグレーションにはRailsのマイグレーション機能を利用しています。
サービス運用を続けるうちにDBへのスロークエリが徐々に増えてきたため、DDL適用によるメタデータロック待ちのリスク[1]が高まってきました。

例えば最悪の場合、以下のような事態が起こりうります。

  • とあるセッションで数十分レベルのスロークエリが発生
  • そのクエリが共有メタデータロックを取得しているテーブルにDDLを発行するセッションがMySQLシステム変数 lock_wait_timeout の時間だけ排他メタデータロックの獲得を待つ。デフォルトでは31536000秒(1年)なので、変更していない場合はまずタイムアウトしない
  • さらに別セッションで対象のテーブルにSELECTしようとするとロックが競合して待ちが発生、冒頭のスロークエリの実行時間分のダウンタイムが発生🥶

そこで、ユーザーの操作を打ち切ることなくDBにDDLを適用するため、マイグレーションのセッションに適切な秒数の lock_wait_timeout を指定してメタデータロックが獲得できない場合はマイグレーションを失敗させるという方針でマイグレーションを実現する必要が出てきました。

Railsのマイグレーション実行セッションに lock_wait_timeout を設定する方法

まずサービスの要件として、DBのグローバルな lock_wait_timeout 、通常のRailsアプリが使用する環境別の接続設定におけるセッションの lock_wait_timeout 全体を統一して修正することは難しかったです。
そこで最初に考えられたやり方は

  • マイグレーション専用のDB接続設定を用意し、それを利用するように db:migrate タスクをカスタマイズする
  • マイグレーションファイル内で毎回 execute("SET SESSION lock_wait_timeout = #{timeout_sec}" を実行する

といったものでした。

とはいえ、いずれの場合も開発者がそのルールを知っている必要があるので、ふとした時にミスが発生する可能性がありました。また、後者については change メソッド内で execute した場合にロールバックが不可能になってしまいます。開発環境ではロールバックをしたいことも多いので、毎回ロールバック可能な書き方を徹底する必要があります。

また、ロックが獲得できずにマイグレーションが失敗する確率がこれまでより高まるため、自動リトライができれば嬉しいというのもありました。

こういった点を加味して運用負荷が低い方法を検討したところ、セッションのlock_wait_timeoutの変更と自動リトライがいずれもstrong_migrations gemの機能に含まれていたため導入することにしました。

https://github.com/ankane/strong_migrations

なお、私たちのチームでは長時間ロックを獲得する可能性があるDDL操作についてはpt-online-schema-changeを利用するルールになっていますが、今回のケースについては別のセッションが長時間にわたって競合するロックを獲得する場合かつ、わざわざpt-online-schema-changeを使わなくて済むようなDDLを実行したいケースが該当します。

strong_migrationsとは

こちらの記事に日本語の丁寧な解説があります。(導入時には大変参考にさせていただきました)

https://moneyforward-dev.jp/entry/2022/10/13/suggestion-strong-migrations-gem/

主に危険なマイグレーション(カラムの削除など)を検知してマイグレーションの実行時にエラーを出してくれる機能がメインのgemです。
今回は lock_wait_timeout の変更とマイグレーションのリトライ機能を目的に導入しましたが、結果的に危険なマイグレーションを事前にチェックできることでより安全にマイグレーションを実行できるようになりました。

lock_wait_timeoutとリトライの設定方法

config/initializers/strong_migrations.rbに以下のオプションを指定します。

https://github.com/ankane/strong_migrations#migration-timeouts

StrongMigrations.lock_timeout = 10.seconds

ロックの獲得に失敗した場合の自動リトライはExperimentalな機能としてサポートされていました。

StrongMigrations.lock_timeout_retries = 3
StrongMigrations.lock_timeout_retry_delay = 10.seconds

上記の設定でリトライに失敗するようなマイグレーションを実行すると、以下のように3回リトライ後にエラーが発生していることがわかります。

== 20230814100632 AddNewColumnToUsers: migrating ===============================
-- add_column(:users, :new_column, :string, {:null=>false, :default=>"sample"})
-- Lock timeout. Retrying in 10 seconds...
-- add_column(:users, :new_column, :string, {:null=>false, :default=>"sample"})
-- Lock timeout. Retrying in 10 seconds...
-- add_column(:users, :new_column, :string, {:null=>false, :default=>"sample"})
-- Lock timeout. Retrying in 10 seconds...
-- add_column(:users, :new_column, :string, {:null=>false, :default=>"sample"})
ActiveRecord::LockWaitTimeout: Mysql2::Error::TimeoutError: Lock wait timeout exceeded; try restarting transaction
/usr/local/bundle/gems/mysql2-0.5.5/lib/mysql2/client.rb:151:in `_query'

なお、実際に設定されたlock_wait_timeoutが10sec以上になっていると警告が出るようになっているようです。

最後に

strong_migrations gemの導入で開発者がルールを意識しなくてもメタデータロック待ちのリスクを低減することができました。私たちのようにマイグレーションのセッションのみ lock_wait_timeout を変更したいという要件がなくとも、リトライ機能が便利なのでRailsのマイグレーションを行う際はとりあえず入れておくとよさそうという感想を持ちました。

MySQLで似たようなgemは見つけられなかったのですが、PostgreSQL対応のものでは以下のようなgemも見つけることができたので、比較検討してみるとよさそうです。
https://github.com/procore/migration-lock-timeout
https://github.com/doctolib/safe-pg-migrations

脚注
  1. メタデータロックによって起こりうる問題については、こちらの記事がわかりやすいです。 ↩︎

株式会社primeNumber

Discussion