ActiveRecordのconnection_poolで怒られる

Whalebirdを実装していた時の話.


h3poteto.hatenablog.com


サーバ側のAPIを実装しているときに,Userstreamタスクを実装したことがあった.

Userstreamは,無限ループに入るタスクである.
それを利用人数分起動しておく必要があり,それは通知を行う上では避けられない道になってしまう.


そのタスクをsidekiqに投げて,sidekiqでいけるところまで投げてみようという実装をした.

そっちの負荷はどうでもよくて,今日はそのときに出くわした,ActiveRecordのconnection_poolの話しである.


人数が増えたら怒られた


最初は順調だった.
しかし10人くらいになると,なぜかsidekiqに投げていたタスクがほとんどfailしてしまった.

ログを見に行くと,

in `block in wait_poll': could not obtain a database connection within 5.000 seconds (waited 5.001 seconds) (ActiveRecord::ConnectionTimeoutError)

と書かれていた.

なぜかDBへの接続がタイムアウトしている.
ちなみにこれは何度リトライしても同じ状態になった.

そもそもRailsActiveRecordは接続をプールしている

ActiveRecrodは,DBへの接続回数を減らすため,1アクションの中で一つのRDB接続だけで完結するように,最初にコネクションを張ったら,それを保持しておく.

ControllerやModelやViewでActiveRecordを呼び出すと,プールされていた接続を引っ張ってきて,RDBを参照して値を取り出してくる.

こうしておかないと,ControllerやViewでモデルが呼ばれるたびに,RDBへの接続が発生してしまい,オーバーヘッドがでかくなってしまう.

rakeタスクやworkerであっても接続はプールされたまま


rakeタスクも同じく,呼び出されると最初にActiveRecordとの接続がプールされる.

でも,どのタイミングで接続プールが発生するのか?
もちろんRDBに一切接続しないControllerや,rakeタスクは存在する.
そういうアクションの中でいちいちActiveRecordが接続を確立はしない.
つまり,アクションの中で最初にActiveRecordを呼び出したとき,そのときの接続を保持するのである.


そして,最初に俺のコードはこのことをまったく考慮していなかった.
以下は,sidekiqに投げるために,Workerとして記述している.

class UserstreamWorker
  include Sidekiq::Worker

  def perform(user_id)
    @user = User.find(user_id)
    client = Twitter::Streaming::Client.new do |config|
      config.consumer_key = ENV["TWITTER_CLIENT_ID"]
      config.consumer_secret = ENV["TWITTER_CLIENT_SECRET"]
      config.access_token = @user.oauth_token
      config.access_token_secret = @user.oauth_token_secret
    end

    client.user do |status|
      # 各ツイートに関する処理の例
      if status.user.screen_name == @user.screen_name
        puts status.user.screen_name
      end
    end
  end
end


client.user doのブロックが無限ループになる.
そして,このWorker自体を,ユーザの数だけ起動させる.

つまり,

User.all.each do |user|
  UserstreamWorker.perform_in(10.seconds, user.id)
end

こんな形ですべてのユーザに対してworkerを起動して使う.

このタスクが起動されたとき,ActiveRecordのconnection_poolはどうなるか?

  def perform(user_id)
    @user = User.find(user_id)

この行で最初のActiveRecord接続が用意され,接続がプールされる.
以降,無限ループ内でも,このプールされた接続を再利用してしまう.

そしてこのアクションは無限ループであるから,終了することはない.つまり,プールされた接続が開放されることはない(´・ω・`)


プールしたままじゃどうしょうもない

こうなると,どうにもこれは実装不可能である.
というか,無限ループで接続がプールされたままというのは,パフォーマンス的にも,RDBの更新的にもよろしい状態ではない.

ましてや,ActiveRecordを通して手に入れたオブジェクトは,インスタンス変数に格納している.
ということは,これ,ループの中でずっと保持されたままで,RDB側が変更されても更新されることがない.
これはヤバイ.

というわけで一回ごとに切ることにした.

    client.user do |status|
      # 各ツイートに関する処理の例
      user = User.find(@user.id)
      if status.user.screen_name == user.screen_name
        puts status.user.screen_name
      end
      ActiveRecord::Base.connection.close
    end

このようにして,ループ毎にActiveRecordの接続を明示的に切断してやる.
で,次のループでは,必ず先頭にActiveRecordの再接続のために,モデルをfindしなおしている.

こうすることで,ループ一回ごとにActiveRecordのコネクションは更新され,見事にエラーは出なくなりました.



connection_poolを増やす


もうひとつ,これを実装しても,やっぱり人数が増えてくると同時にRDBに接続することが多くなってくる.
connection_poolが10件程度だと持たない可能性は十分にあり得る.


そこで,connection_poolを増やそう.

config/database.yml

default: &default
  adapter: mysql2
  encoding: utf8
  pool: 20
  username: root
  password:
  socket:

として,poolを20件くらいに増やしてみました.

ただ,このプール数,あまり増やしすぎるとそれはそれでメモリを食い過ぎる.基本的には,毎回切断することが望ましい.

[番外編]reconnectionというやつもある

default: &default
  adapter: mysql2
  encoding: utf8
  pool: 5
  username: root
  password:
  socket:
  reconnect: true

database.ymlにはreconnectionという設定もある.

これは,先ほどのように接続をプールしておくと,長時間の思いタスクなどの場合は,RDBとの接続が切れる場合がある.
そういったときに自動再接続をさせるかどうか,という設定.しかし,デフォルトではoffになっている.


これは,接続が切れた際に,うまくインサートされなかったりすると,大問題になってしまうからである.接続が切れたのだから,DBにはcommitされていない可能性大である.
にもかかわらず,処理的にはcommitが完了した扱いになってしまう.

参考

d.hatena.ne.jp