ActiveRecord::LockWaitTimeout, ActiveRecord::Deadlocked, ActiveRecord::ConnectionTimeoutError が起きた時に原因調査に役立つ情報を表示する gem を作った
それなりの規模のサービスを運用していると、不可解なエラーに遭遇することはよくあるものです。その中でもデータベース関連のエラーは一見難解な問題に見えるかもしれませんが、原因調査に役立つ情報をさえ出力すればたいていの場合は容易に原因を特定できるものです。というわけで、Rails でよく遭遇するエラーの調査に役立つ情報を出力する gem を作成しました。
現在次のエラーをサポートしています。
ActiveRecord::LockWaitTimeout
(MySQL のみ)ActiveRecord::Deadlocked
(MySQL のみ)ActiveRecord::ConnectionTimeoutError
以下、具体的な例を用いてどのような情報が表示されるか説明します。
ActiveRecord::LockWaitTimeout
ActiveRecord::LockWaitTimeout
が起きた場合、大きく次の 2 つの可能性が考えられます。
- 特定のレコードのロックを取ったトランザクションの処理が長時間かかっている
- 特定のレコードのロックを取ったトランザクションを開始したプロセスが死んでいる
いずれのケースにせよ、長時間かかっているトランザクションと、それを実行しているクライアントの情報があればかなりの手がかりになります。なので、activerecord-debug_errors では ActiveRecord::LockWaitTimeout
が起きたら SHOW ENGINE INNODB STATUS
の TRANSACTIONS セクションの情報と SHOW FULL PROCESSLIST
の結果を表示します。
例えば次のスクリプトを実行してみます。
require 'active_record'
require 'activerecord-debug_errors'
ActiveRecord::Base.establish_connection(
adapter: 'mysql2',
host: ENV['MYSQL_HOST'],
port: ENV['MYSQL_PORT'],
username: ENV['MYSQL_USERNAME'],
password: ENV['MYSQL_PASSWORD'],
database: ENV['MYSQL_DATABASE'],
variables: {
innodb_lock_wait_timeout: 5,
},
)
ActiveRecord::Base.logger = Logger.new($stdout).tap do |l|
l.formatter = ActiveSupport::Logger::SimpleFormatter.new
l.level = Logger::WARN
end
class User < ActiveRecord::Base; end
unless ActiveRecord::Base.connection.table_exists?('users')
ActiveRecord::Base.connection.create_table('users') do |t|
t.string 'name'
t.index ['name'], name: 'ux_name', unique: true
end
User.create!(name: 'foo')
User.create!(name: 'bar')
end
Array.new(2) do
Thread.new do
User.transaction do
User.lock.find_by!(name: 'foo')
sleep 7
end
end
end.each(&:join)
そうすると次のような出力が得られます。
ActiveRecord::LockWaitTimeout occurred:
------------
TRANSACTIONS
------------
Trx id counter 1511907
Purge done for trx's n:o < 1511588 undo n:o < 0 state: running but idle
History list length 42
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 421781578717880, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 1511906, ACTIVE 5 sec
1 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 1016, OS thread handle 123145454059520, query id 62521 localhost 127.0.0.1 root starting
SHOW ENGINE INNODB STATUS
---TRANSACTION 1511905, ACTIVE 5 sec
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 1015, OS thread handle 123145453502464, query id 62520 localhost 127.0.0.1 root
-----------
PROCESSLIST
-----------
695 root localhost:60956 information_schema Sleep 9
1014 root localhost:55971 test Sleep 5
1015 root localhost:55973 test Sleep 5
1016 root localhost:55974 test Query 0 starting SHOW FULL PROCESSLIST
上記の結果から、thread id 1015 と thread id 1016 のトランザクションが 5 秒かかっており、前者が localhost:55973、後者が localhost:55974 からのリクエストであることがわかります。この例だとわかりにくいですが、プロセスリストのユーザ情報、クライアントのホスト・ポート情報、SQL 情報から、ロックを保持したままのトランザクションを実行しているアプリケーションを特定できることもあるでしょう。例えば ECS を利用している場合、ECS agent のログをクライアントの IP で検索すればタスクを特定することができます。
ActiveRecord::Deadlocked
MySQL では SHOW ENGINE INNODB STATUS
を実行すると LATEST DETECTED DEADLOCK というセクションが表示されるので、このセクションを確認することでデッドロックが起きた原因を調べることができます。なので、activerecord-debug_errors では ActiveRecord::Deadlocked
が起きたら LATEST DETECTED DEADLOCK セクションの情報をログに出すようにしています。
例えば次のスクリプトを実行してみます。
require 'active_record'
require 'activerecord-debug_errors'
ActiveRecord::Base.establish_connection(
adapter: 'mysql2',
host: ENV['MYSQL_HOST'],
port: ENV['MYSQL_PORT'],
username: ENV['MYSQL_USERNAME'],
password: ENV['MYSQL_PASSWORD'],
database: ENV['MYSQL_DATABASE'],
)
ActiveRecord::Base.logger = Logger.new($stdout).tap do |l|
l.formatter = ActiveSupport::Logger::SimpleFormatter.new
l.level = Logger::WARN
end
class User < ActiveRecord::Base; end
unless ActiveRecord::Base.connection.table_exists?('users')
ActiveRecord::Base.connection.create_table('users') do |t|
t.string 'name'
t.index ['name'], name: 'ux_name', unique: true
end
User.create!(name: 'foo')
User.create!(name: 'bar')
end
ths = []
ths << Thread.new do
User.transaction do
User.lock.find_by!(name: 'foo')
sleep 0.1
User.lock.find_by!(name: 'bar')
end
end
ths << Thread.new do
User.transaction do
User.lock.find_by!(name: 'bar')
sleep 0.1
User.lock.find_by!(name: 'foo')
end
end
ths.each(&:join)
そうすると次のような出力が得られます。
ActiveRecord::Deadlocked occurred:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-10-26 06:28:53 0x70000920c000
*** (1) TRANSACTION:
TRANSACTION 1511899, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 1006, OS thread handle 123145455173632, query id 62434 localhost 127.0.0.1 root statistics
SELECT `users`.* FROM `users` WHERE `users`.`name` = 'foo' LIMIT 1 FOR UPDATE
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6659 page no 4 n bits 72 index ux_name of table `test`.`users` trx id 1511899 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 666f6f; asc foo;;
1: len 8; hex 8000000000000001; asc ;;
*** (2) TRANSACTION:
TRANSACTION 1511900, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 1007, OS thread handle 123145455452160, query id 62435 localhost 127.0.0.1 root statistics
SELECT `users`.* FROM `users` WHERE `users`.`name` = 'bar' LIMIT 1 FOR UPDATE
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 6659 page no 4 n bits 72 index ux_name of table `test`.`users` trx id 1511900 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 666f6f; asc foo;;
1: len 8; hex 8000000000000001; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6659 page no 4 n bits 72 index ux_name of table `test`.`users` trx id 1511900 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 3; hex 626172; asc bar;;
1: len 8; hex 8000000000000002; asc ;;
*** WE ROLL BACK TRANSACTION (2)
上記の出力結果から次のようなことがわかります。
- 1 つ目のトランザクションでは
SELECT `users`.* FROM `users` WHERE `users`.`name` = 'foo' LIMIT 1 FOR UPDATE
を実行している - 1 つ目のトランザクションでは ux_name の name = ‘foo’ のインデックスレコードの排他ロックが取得できるのを待っている
- 2 つ目のトランザクションでは
SELECT `users`.* FROM `users` WHERE `users`.`name` = 'bar' LIMIT 1 FOR UPDATE
を実行している - 2 つ目のトランザクションでは ux_name の name = ‘foo’ のインデックスレコードの排他ロックを保持している
- 2 つ目のトランザクションでは ux_name の name = ‘bar’ のインデックスレコードの排他ロックを取得できるのを待っている
LATEST DETECTED DEADLOCK の見方については InnoDB の行レベルロックについて解説してみるが参考になると思います。ただ、Gap Locks の範囲についての説明は間違っているかもしれないので注意してください…。MySQL のコードをいじったりしつつ実験した結果(rec1 <= x < rec2)と結論付けたんですが、ドキュメントの内容的にも「ギャップ」という名前的にも正しくは (rec1 < x < rec2)の気がします。
ActiveRecord::ConnectionTimeoutError
ActiveRecord のコネクションプールはデフォルトサイズが 5 で、一般的な使い方をしていれば 1 スレッドに対して 1 コネクションが割り当てられ、コネクションが足りなくなった場合は終了したスレッドが使っていたコネクションが使い回されるようになっています。よって、この理解が正しければ、DB に接続するスレッドが同時に 5 つを超えて存在しない限り ActiveRecord::ConnectionTimeoutError
が起きることはありません。
ところがどういうわけか ActiveRecord::ConnectionTimeoutError
に遭遇することもあることでしょう。activerecord-debug_errors ではコネクションを所有しているスレッドの情報を表示することで原因の特定に貢献します。
例えば次のスクリプトを実行してみます。
require 'active_record'
require 'activerecord-debug_errors'
ActiveRecord::Base.establish_connection(
adapter: 'mysql2',
host: ENV['MYSQL_HOST'],
port: ENV['MYSQL_PORT'],
username: ENV['MYSQL_USERNAME'],
password: ENV['MYSQL_PASSWORD'],
database: ENV['MYSQL_DATABASE'],
)
ActiveRecord::Base.logger = Logger.new($stdout).tap do |l|
l.formatter = ActiveSupport::Logger::SimpleFormatter.new
l.level = Logger::WARN
end
def foo
ActiveRecord::Base.connection.execute("select 1")
sleep 1
end
ActiveRecord::Base.connection.execute("select 1")
ths = []
ths << Thread.new(&method(:foo))
ths << Thread.new do
ActiveRecord::Base.connection.execute("select 1")
sleep 1
end
ths << Thread.new(&method(:foo))
ths << Thread.new do
ActiveRecord::Base.connection.execute("select 1")
sleep 1
end
ths << Thread.new do
ActiveRecord::Base.connection.execute("select 1")
sleep 1
end
ths.each(&:join)
すると次のような出力が得られます。
ActiveRecord::ConnectionTimeoutError occured:
Thread #<Thread:0x00007fe51c05fa88 sleep_forever> status=sleep priority=0
/tmp/script.rb:40:in `join'
/tmp/script.rb:40:in `each'
/tmp/script.rb:40:in `<main>'
Thread #<Thread:0x00007fe51c1f2788 /tmp/script.rb:18 dead> status=false priority=0
Thread #<Thread:0x00007fe51c1f2648 /tmp/script.rb:26 dead> status=false priority=0
Thread #<Thread:0x00007fe51c1f24b8 /tmp/script.rb:18 dead> status=false priority=0
Thread #<Thread:0x00007fe51c1f2418 /tmp/script.rb:31 dead> status=false priority=0
上記の情報から、次のようなスレッドがコネクションを所有していることがわかります。
- 40 行目で
join
を実行しているスレッド(メインスレッド) - 18 行目に定義されている
foo
メソッドを実行していた 2 スレッド - 26 行目のブロックを実行していた 1 スレッド
- 31 行目のブロックを実行していた 1 スレッド
この例ではメインスレッド以外のスレッドは処理を終了しているので ActiveRecord::ConnectionTimeoutError
にはならないでほしいものですが、ActiveRecord では明示的にコネクションを解放しない限り、acquire_connection
を実行した時にしかコネクションを解放しないのでエラーになっています。
よって、例えば次のようにすれば ActiveRecord::ConnectionTimeoutError
は避けられます。
def foo
ActiveRecord::Base.connection.execute("select 1")
ActiveRecord::Base.connection_pool.release_connection
sleep 1
end
ActiveRecord::Base.connection.execute("select 1")
ths = []
ths << Thread.new(&method(:foo))
ths << Thread.new do
ActiveRecord::Base.connection.execute("select 1")
ActiveRecord::Base.connection_pool.release_connection
sleep 1
end
ths << Thread.new(&method(:foo))
ths << Thread.new do
ActiveRecord::Base.connection.execute("select 1")
ActiveRecord::Base.connection_pool.release_connection
sleep 1
end
ths << Thread.new do
ActiveRecord::Base.connection.execute("select 1")
ActiveRecord::Base.connection_pool.release_connection
sleep 1
end
ths.each(&:join)
以上、デバッグが捗りますね!