13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

環境

Rails 5.1.4
PostgreSQL 10.5

TL;DR

Railsは5.2にアップグレードしよう。

DBのメモリが減っていく、、、

実はいくつか原因がある。

コネクション数の増大

スクリーンショット 2018-12-06 19.42.52.png

上の図はコネクション数が増えるにつれて、DBのメモリが減っていく様子。PostgreSQLはマルチプロセスアーキテクチャなので、基本的に1つのコネクションに対して、1つのプロセス(バックエンドプロセスと呼ばれる)が対応する。つまりコネクションが1つ増えるということはプロセスが新たに1つ割り当てられるということ。ただし、パラレルクエリが発動すると複数プロセスが起動されうる

スロークエリの実行

スロークエリがあると、バックエンドプロセスの作業メモリ(work_mem)は大きくなってしまうことが多い。さらにはshared bufferをふんだんに使ってしまうことも多い。よって、メモリはひっ迫することになる。この場合は単にスロークエリをチューニングするという話になる。

セッション情報の蓄積

スクリーンショット 2018-12-06 20.27.55.png

コネクションプールの実装にもよるが、異なるリクエストの間でセッション情報が共有されることがある。セッション情報が蓄積されると、それはメモリの減少に繋がる。上の図ではコネクション数はほぼ一定だが、メモリは減少していっている。この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はActiveRecordPostgreSQLの実装周りでは使ってないはず。ユーザー独自の変数もきちんとハンドリングされているように見える(つまりコネクションを生成するときにはSETでsetしているが、resetするときにはきちんとDISCARD ALLしている)。さらに、prepared statementは使わないように設定しても、この減少は見られた。うーん、一体何が残ってしまっているんだろう。そもそも、セッション情報ではないかもしれないが、メモリがだんだんと減っていくのはセッション情報の蓄積に見えるんだよなぁ。誰か教えて下さい。

(2019年3月追記)
バックエンドプロセスがローカルに持っているLOCAL LOCK HASHと呼ばれるハッシュテーブルがあるが、一度に大量のテーブルロックを取得すると、このハッシュテーブルがすごく大きなサイズになってしまうのではないか。デバッガを使って、検証する必要があるが、ソースをざっと見た感じだとそんな気がする。そして、この問題に取り組んでいた時は1つのトランザクションが取得するテーブルロックの数は非常に多かった。

#願い
負荷が大きくなって、コネクション数が増えるのは仕方ないが、負荷が小さくなったらコネクションは切れてほしい。また、コネクション数が一定なら、メモリはそのままでいて欲しい。ただ、Rails5.1系ではこの願いはかなわない。それはActiveRecordのコードを読むと分かる。以下で軽く紹介する。

ActiveRecordのコネクションプール周り

コネクションを表すadapter(ここではpostgreSQLを使っているので、posgreのアダプター)、コネクションプールがコネクションプール周りのソースの主な主役。そして、ここで僕たちが着目しなきゃいけないクラスであるReaperは一言で言うならコネクションの掃除屋だ。ConnectionPoolinitializeの中で、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

新たにConnetionPoolflushというメソッドを呼んでいる。これを見てみる。

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系の設定をいじっていたりもしましたが、なぜかコネクション数が激増してしまいうまくいきませんでした。

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?