環境
Rails 5.1.4
PostgreSQL 10.5
TL;DR
Railsは5.2にアップグレードしよう。
DBのメモリが減っていく、、、
実はいくつか原因がある。
コネクション数の増大
上の図はコネクション数が増えるにつれて、DBのメモリが減っていく様子。PostgreSQLはマルチプロセスアーキテクチャなので、基本的に1つのコネクションに対して、1つのプロセス(バックエンドプロセスと呼ばれる)が対応する。つまりコネクションが1つ増えるということはプロセスが新たに1つ割り当てられるということ。ただし、パラレルクエリが発動すると複数プロセスが起動されうる。
スロークエリの実行
スロークエリがあると、バックエンドプロセスの作業メモリ(work_mem)は大きくなってしまうことが多い。さらにはshared bufferをふんだんに使ってしまうことも多い。よって、メモリはひっ迫することになる。この場合は単にスロークエリをチューニングするという話になる。
セッション情報の蓄積
コネクションプールの実装にもよるが、異なるリクエストの間でセッション情報が共有されることがある。セッション情報が蓄積されると、それはメモリの減少に繋がる。上の図ではコネクション数はほぼ一定だが、メモリは減少していっている。このissueでかなりヘビーに議論されているが、セッション情報は基本的にtemporary tableやユーザー独自の変数(SET TOで設定できる変数)、prepared statementなどで、DISCARD ALL
で消去される情報群と言っても良さそうだ。DISCARD ALL
は以下のコマンドと等価とされる。
SET SESSION AUTHORIZATION DEFAULT;
RESET ALL;
DEALLOCATE ALL;
CLOSE ALL;
UNLISTEN *;
SELECT pg_advisory_unlock_all();
DISCARD PLANS;
DISCARD SEQUENCES;
DISCARD TEMP;
ただ代表的なセッション情報であるtemporary tableはActiveRecord
のPostgreSQL
の実装周りでは使ってないはず。ユーザー独自の変数もきちんとハンドリングされているように見える(つまりコネクションを生成するときにはSET
でsetしているが、resetするときにはきちんとDISCARD ALL
している)。さらに、prepared statementは使わないように設定しても、この減少は見られた。うーん、一体何が残ってしまっているんだろう。そもそも、セッション情報ではないかもしれないが、メモリがだんだんと減っていくのはセッション情報の蓄積に見えるんだよなぁ。誰か教えて下さい。
(2019年3月追記)
バックエンドプロセスがローカルに持っているLOCAL LOCK HASH
と呼ばれるハッシュテーブルがあるが、一度に大量のテーブルロックを取得すると、このハッシュテーブルがすごく大きなサイズになってしまうのではないか。デバッガを使って、検証する必要があるが、ソースをざっと見た感じだとそんな気がする。そして、この問題に取り組んでいた時は1つのトランザクションが取得するテーブルロックの数は非常に多かった。
#願い
負荷が大きくなって、コネクション数が増えるのは仕方ないが、負荷が小さくなったらコネクションは切れてほしい。また、コネクション数が一定なら、メモリはそのままでいて欲しい。ただ、Rails5.1系ではこの願いはかなわない。それはActiveRecord
のコードを読むと分かる。以下で軽く紹介する。
ActiveRecordのコネクションプール周り
コネクションを表すadapter(ここではpostgreSQLを使っているので、posgreのアダプター)、コネクションプールがコネクションプール周りのソースの主な主役。そして、ここで僕たちが着目しなきゃいけないクラスであるReaper
は一言で言うならコネクションの掃除屋だ。ConnectionPool
のinitialize
の中で、new
されて、run
メソッドが呼ばれる。
# スレッドが意図せず死んだりした時にコネクションが残ってしまうのを防ぐ働きをする
class Reaper
attr_reader :pool, :frequency
def initialize(pool, frequency)
@pool = pool
@frequency = frequency # database.ymlのreaping_frequencyで設定できる値
end
def run
return unless frequency
Thread.new(frequency, pool) { |t, p|
loop do
sleep t
p.reap
end
}
end
end
ConnectionPool
のreapというメソッドを一定時間ごとに呼んでいる。reapを見てみる。
def reap
stale_connections = synchronize do
@connections.select do |conn|
# 二番目の条件がtrueになるのは確かにスレッドが死んでいる時
# つまりここがtrueになるのは死んだスレッドに使われているコネクション
conn.in_use? && !conn.owner.alive?
end.each do |conn|
conn.steal!
end
end
# 死んだスレッドのコネクションの配列をループで回している
stale_connections.each do |conn|
if conn.active? # コネクションの生死の確認。たまに`SELECT 1`という謎クエリがセッション一覧で見えるが、こいつがその原因。
# コネクションが生きている時はリセットしてコネクションプールに返却する
conn.reset!
checkin conn
else
# もはやコネクションが使えないなら、コネクションプールにも返却せず削除
remove conn
end
end
end
つまり、一定時間(reaping_frequency
)ごとに死んだスレッドに持たれているコネクションについてハンドリングしますよ、ということ。言い換えるとスレッドが死なないと、Reaper
はもはや何もしてくれないのである。つまりスレッドが死なない限りコネクション数は増えることはあっても減ることはない。さあ、困った。
5.2でこんな感じに変わりました
ただ、ActiveRecord5.2はその辺もきちんとやっている。ちょっと見てみよう。
class Reaper
attr_reader :pool, :frequency
def initialize(pool, frequency)
@pool = pool
@frequency = frequency
end
def run
return unless frequency && frequency > 0
Thread.new(frequency, pool) { |t, p|
loop do
sleep t
p.reap
p.flush
end
}
end
end
新たにConnetionPool
のflush
というメソッドを呼んでいる。これを見てみる。
def flush(minimum_idle = @idle_timeout) # idle_timeoutはdatabase.ymlで設定可能でデフォルト300
return if minimum_idle.nil?
idle_connections = synchronize do
@connections.select do |conn|
# connectionのseconds_idleはConnectionPoolに返却されてからの秒数
# つまりこの条件は誰にも使われていない(コネクションにある)かつ返却されてからminimum_idle秒以上経っているとtrueとなる
!conn.in_use? && conn.seconds_idle >= minimum_idle
end.each do |conn|
conn.lease
# コネクションプールから削除している
@available.delete conn
@connections.delete conn
end
end
# コネクションプールからもはや使われないコネクションについて
idle_connections.each do |conn|
# DBとのコネクションを切断
conn.disconnect!
end
end
こ・れ・だ!
これで負荷が高くなってコネクションの数が一時的に増えても負荷が下がれば切断されるようになります。そして、定期的に切断することでセッション情報が増え続けることにも対応できます。そして、その様子は以下のSQLで確認できます。
SELECT * FROM pg_stat_activity;
これはセッション(コネクションのことです)一覧を様々な情報とともに表示してくれるSQL。
コネクションが切断されない時には、そのセッションで最後に実行されたSQLがいつ実行されたかを表すquery_start
カラムの値が随分と古い時間になっているセッションがとても多く見られましたが、アップグレードした時には古いセッションはもはや存在しません。定期的に実行すると、セッションの数が減るのを確認できます。
なお、コネクションの回転率が高くて、コネクションプールに返却されてidle_timeout
秒も経たないよ、って場合は次のメソッドで強制的に切断可能。
ActiveRecord::Base.connection_pool.flush!
これは上で紹介したflush
メソッドの引数を-1として呼んでいる。つまり、今現在スレッドに使われていないコネクションは切断される。
まとめ
とりあえずRails5.2にアップグレードをお勧めします。5.1系で頑張っていた時には、tcp_keepalives
系の設定をいじっていたりもしましたが、なぜかコネクション数が激増してしまいうまくいきませんでした。