giftee Tech Blog

ギフティの開発を支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信しています。

安全にDBマイグレーションを実行する技術 -DDLのロックタイムアウトをRailsで設定する-

eyecatch

こんにちは、エンジニアの toki (@tokai235) です。法人向け eギフトサービス giftee for Business の開発をしています。普段はバックエンドやインフラのお仕事が多いですが、フロントエンドも好きです。

みなさん日々データベースへのマイグレーションをしていることと思いますが、マイグレーション作業って怖いですよね...。今回はそのマイグレーションのリスクを下げる1つの方法として、ロックタイムアウトを Ruby on Rails(以下 Rails) アプリケーションで設定する方法について書いてみようと思います。

なおロックタイムアウトは MySQL では lock_wait_timeout、PostgreSQL では lock_timeout などで設定できますが、今回は MySQL を前提に話を進めます。とはいえ SQL が少し変わるだけなので、楽に読み替えてもらえると思います。(ただし1点だけ注意点があり、MySQL の lock_wait_timeout は秒単位、PostgreSQL の lock_timeout は単位指定なしだとミリ秒単位になります。)

なぜロックタイムアウトを設定するのか

まず前提として、DDL とは何かの話から始めていきましょう。データベースのインデックスを追加したりテーブル構造を変更したりする SQL のことを DDL (Data Definition Language) と言います。もちろんデータベースに SQL 文を直接流し込んでも良いんですが、 Rails では ActiveRecord::Migration によって、直接 SQL を書くことなく DDL を実行することができます。

DDL を実行するとテーブルにメタデータロックがかかります。ここで問題となるのが「ロック待ち」です。スロークエリが流れているタイミングで DDL を実行すると、DDL がそのロックを待ち続け、さらにその後ろに来た SQL も芋づる式に待たされてしまいます。

この「ロック待ち」自体はそんなに珍しいことではないのですが、通常のレコードロックに対してメタデータロックが厄介なのは、そのロック範囲です。レコードロックは名前の通り特定のレコードに対するロックなので、他のレコードには問題なくアクセスできます。ですがメタデータロックではテーブル構造を変更するため、ロックの範囲はテーブルの全レコードに及びます。さらに厄介なのが、メタデータロックでは INSERT や UPDATE だけでなく SELECT クエリも待たされるため、メタデータロックが長く続くと大事故につながりかねません。

じゃあどうするねんとなったときに、対策の基本としては「DDL を流す前にスロークエリが流れていないことを確認する」になるわけですが、これは結構大変なんですよね。というのもアプリケーションは実に多くのクエリが入り乱れていて、しかもクエリのトリガーがユーザーの行動起点であることも多いです。そうした中でスロークエリの実行タイミングをすべてコントロールするのはとても難しいです。かといって DDL を流すたびに深夜メンテはしたくないですよね。

そこで逆に考えるのが、「スロークエリが流れているタイミングで DDL を流してしまったとしても事故らない」という方向性です。これは実際に可能で、DDL を流すときのロックタイムアウトを短く設定しておくことで、ロック待ちが発生したときに素早くタイムアウトさせ、後続クエリが詰まり続けるのを防ぐことができます。タイムアウト時間を短くしておいて気軽に実行できるようになれば、リクエストが少なそうな時間に何度も実行し直すことができます。

ちなみによくある誤解として「タイムアウトを短くしたら DDL が終わる前にタイムアウトしてしまうのでは?」というものがあるんですが、これは大丈夫です!というのもこのロックタイムアウトの対象は「メタデータロックの『取得を待つ』時間」であり、「DDL を『実行する』時間」ではないからです。なので数秒のようなかなり短い時間を設定してもあまり問題にはならないと思います。(この後紹介する Strong Migrations gem ではデフォルトが10秒に設定されていますし、giftee for Business でもそれを参考に同じく10秒に設定しています。)

「じゃあどうやって設定するの?」というのが今回のお話です。

データベースのパラメータグループでグローバルに変更する

一番シンプルな方法がこれです。例えば Aurora MySQL なら、パラメータグループで lock_wait_timeout を変更してしまえばよいです。インフラ側で一括対応できるので、アプリケーションコードに変更を入れる必要がありません。

ただし、すでに運用中のサービスでグローバルな変更を加えるのは難易度が高い場合もあると思います。そういったケースではアプリケーションで自由に変更することもできます。

アプリケーション(Rails)で設定する

A. ActiveRecord::Migration を使っている人向け

Rails 標準のマイグレーション機能ですね。ロックタイムアウトを設定する方法はいくつかあります。

Strong Migrations gem を使う

Strong Migrations は安全なマイグレーションを支援する gem で、まさにこのためのといったやつです。ロックタイムアウトの設定ももちろんサポートしています。initializer で設定すると、マイグレーション実行時に自動でロックタイムアウトを適用してくれます。

# config/initializers/strong_migrations.rb
StrongMigrations.lock_timeout = 10.seconds

さらに便利なのが lock_timeout_retries オプションです。ロックタイムアウトが発生した場合に自動でリトライしてくれます。

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

ロックタイムアウトの設定だけでなく、長時間ロックが発生しやすい DDL(大きなテーブルへのインデックス追加など)を検知して警告してくれる機能とかもあってここでは全然紹介しきれないんですが、興味がある方はそちらも試してみてください!

config/database.ymlvariables を追加する

# config/database.yml
default: &default
  variables:
    lock_wait_timeout: 10 # 単位は [sec]

gem でなく自前でやりたいって方も、Rails には接続時にセッション変数を設定するための variables オプションが用意されているので、これを使えば楽に設定できます。 (これは公式ドキュメントに記載がないようで、Trilogy の Issue を漁っていてたまたま見つけました。Rails、なんでもあるな...)

公式ドキュメントにはないものの、この機能自体はかなり昔(少なくとも Rails 4 以前)から維持されているもののようなので、急になくなったりはしないかなと思います。

ただし、この方法だとアプリケーションの通常リクエストにも同じロックタイムアウトが適用されてしまいます。マイグレーション専用の database 設定を切り出すと、マイグレーションのみに絞って適用できてより安全です。

# config/database.yml
default: &default
  # アプリケーション用のデフォルト設定

migrate: &migrate
  # マイグレーション用のデフォルト設定
  <<: *default
  variables:
    lock_wait_timeout: 10

production_app:
  <<: *default
  # ...

production_migrate:
  <<: *migrate
  # ...

なお余談ですが、Mysql2 アダプターを使っている場合はセッション変数だけでなく、素の SQL を実行する init_command オプションも使えます。接続時に指定した SQL が自動実行されます。

# config/database.yml (Mysql2 のみ)
default: &default
  adapter: mysql2
  init_command: 'SET SESSION lock_wait_timeout = 10;'

マイグレーションファイルごとに execute で設定する

class AddSomeIndexToSomeTable < ActiveRecord::Migration[7.0]
  def change
    execute "SET SESSION lock_wait_timeout = 10;"

    # マイグレーション処理
  end
end

マイグレーションごとに書く必要があるので少し手間がかかります。また change を使っていればいいのですが、 up/down で書き分けている場合は両方に書かないといけないですね。基本的には上記2つの方法で良いんじゃないかなと思いますが、より細かくコントロールしたい方向けです。

なお Rails は ConnectionPool を持っているので、execute "SET SESSION..." を実行すると、そのコネクションが ConnectionPool に返却された後もセッション変数が維持されます。マイグレーションは基本的に bin/rails db:migrate などの独立したプロセスで実行されるため問題ありませんが、アプリケーション内で execute "SET SESSION..." を実行する際はご注意ください。

B. Ridgepole を使っている人向け

giftee for Business でも使っている Ridgepole にも触れておきましょう。こちらは --pre-query オプションが用意されていて、実行時にオプションを渡すだけです。

ridgepole --pre-query 'SET SESSION lock_wait_timeout=10;' ...

まとめ

DDL の実行リスクをお手軽に下げる方法として、ロックタイムアウトを設定するための方法を MySQL と Rails のケースでいくつかご紹介しました。

個人的には DDL の実行リスクが高いことでデータベースの変更ハードルが高くなっているような気がしているので、万が一のときのダメージを小さくすることで自信を持ってデータベースの変更ができるようになればみんなハッピーだなと思います。ぜひ活用してみてください。

ギフティではデータベースに詳しいエンジニアもアプリケーションに詳しいエンジニアも大募集しています。興味がある方はぜひカジュアル面談などでお話しましょう!

careers.giftee.co.jp