Whalebirdを実装していた時の話.
サーバ側の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への接続がタイムアウトしている.
ちなみにこれは何度リトライしても同じ状態になった.
そもそもRailsのActiveRecordは接続をプールしている
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が完了した扱いになってしまう.