はじめに
Railsアプリケーションでロック系エラーが発生し、処理が止まっている事象を確認しました。
テーブルロックの解像度が低い状態だったので、自分の理解度を上げるために調べたことをまとめます。
なにがあったのか
以下のエラーが発生し、処理が止まっていました。
ActiveRecord::Deadlocked: Deadlock found when trying to get lock; try restarting transaction
ActiveRecord::LockWaitTimeout: Lock wait timeout exceeded; try restarting transaction
どのテーブルが原因なのかわからず、再現もできませんでした。とりあえずログを仕込んで、次発生したときに検知できるようにしました。
また、インフラにはAWS Auroraを使用しているため、Performance Insightからもロックの状況が確認できそうです。
2つのエラーの違い
| Deadlock | Lock Wait Timeout | |
|---|---|---|
| 原因 | 2つのトランザクションが互いのロック待ち | 単純にロック待ちが長すぎ |
| 挙動 | 即座に片方がロールバック | 50秒(デフォルト)待ってエラー |
ロックのかかり方
トランザクション内で複数テーブルを操作する場合、各テーブルへのロックは UPDATE/INSERT/DELETEが実行された時点で順次かかり、COMMITまで全て保持されます。
ActiveRecord::Base.transaction do
user = User.find(1) # ロックなし(SELECT)
user.update!(name: "hoge") # ← Userロック取得
order = Order.find(10) # ロックなし(SELECT)
order.update!(status: "paid") # ← Orderロック取得(Userもまだロック中)
Payment.create!(order: order) # ← Paymentロック取得(User, Orderもまだロック中)
end # ← COMMITで全テーブルのロック解放
| 時点 | User | Order | Payment |
|---|---|---|---|
| user.update! | 🔒 | - | - |
| order.update! | 🔒 | 🔒 | - |
| Payment.create! | 🔒 | 🔒 | 🔒 |
| COMMIT | 解放 | 解放 | 解放 |
トランザクションが長くなるほど、最初に触ったテーブルのロック時間も長くなります。
| SQL | ロック |
|---|---|
SELECT |
なし |
SELECT ... FOR UPDATE |
排他ロック |
UPDATE / DELETE / INSERT
|
排他ロック |
インデックスがないカラムでWHEREすると、テーブルスキャンが発生し、スキャンした全行に行ロックがかかります。(実質テーブル全体がロック状態に)
分離レベルとギャップロック
MySQLのデフォルトは REPEATABLE READ です。この設定だとギャップロック(行間のロック)が有効になっています。
READ COMMITTED にするとギャップロックが無効になり、デッドロックが起きにくくなります。
# config/database.yml
production:
variables:
transaction_isolation: 'READ-COMMITTED'
ロック状況の確認方法
rescueでロギング
rescue ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
Rails.logger.error "[LOCK] #{e.class}: #{e.message}"
Rails.logger.error "[LOCK] #{e.backtrace.first(5).join("\n")}"
# InnoDBステータスも取れたら取る
begin
status = ActiveRecord::Base.connection.execute("SHOW ENGINE INNODB STATUS").first
Rails.logger.error "[LOCK] InnoDB: #{status['Status']}"
rescue
end
raise
end
SQL
-- 最後のデッドロック詳細
SHOW ENGINE INNODB STATUS\G
-- 全デッドロックをログに出す
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 現在のロック待ち
SELECT * FROM performance_schema.data_lock_waits;
対策メモ
- トランザクション内で外部API呼ばない(長くなる)
- 複数テーブル更新時は順序を統一する
- WHERE句で使うカラムにインデックス張る
- リトライ処理を入れる(
transaction_retrygem など)
まとめ
今回はロギングを仕込んで次回の検知に備えましたが、本来はそもそもロック系エラーが発生しないようにトランザクションを設計していきたいです。
トランザクションはできるだけ短く、更新順序は統一する。このあたりを意識していこうと思います。