RailsのキャッシュストアであるActiveSupport::Cache::Storeには:race_condition_ttl
というオプションが存在する。
筆者はmemcachedを対象としたキャッシュまわりのライブラリを書いている。そのため、このオプションがmemcachedのキャッシュストアであるActiveSupport::Cache::MemCacheStore
にも登場する。これはなんのためのオプションなのだろうか?と気になっていた。
このオプションについて、RailsのドキュメントであるRailsGuidesを参照すると「dog pile effect」として知られていることを回避するためのオプションとなっている。
:race_condition_ttl – This option is used in conjunction with the :expires_in option. It will prevent race conditions when cache entries expire by preventing multiple processes from simultaneously regenerating the same entry (also known as the dog pile effect). This option sets the number of seconds that an expired entry can be reused while a new value is being regenerated. It’s a good practice to set this value if you use the :expires_in option.
そこで「dog pile effect」とはどういったことなのか?日本語での解説資料が見つからなかったので、記事にすることにした。
dog pile とは?
まずはこの画像を見てください。
これが dog pile という状態です。語源は犬が積み重なって寝ることを指しているようです。人間が積み重なっていることも指しているようで、野球の乱闘状態などもdog pileというようです。
dog pile effect とは?
「dog pile」というのはここまででなんとなくわかりましたが、WEBアプリケーションの世界での「dog pile effect」とはどういったことなのでしょうか?これについて解説してみたいと思います。
「dog pile effect」はmemcachedやredisといったexpireを設定するキャッシュストアを利用しているWEBアプリケーションで発生します。
dog pile effect が発生する構成
dog pile effect が発生する状況について、以下の様なシステムがあると想像してください。
WEBアプリケーションが1つのページをキャッシュを利用して表示されているとします。このページに表示するデータをMySQLなどから取得して、memcachedにキャッシュしている場合、アプリケーションはmemcachedから表示に必要なデータを取得するため、MySQLへSQLクエリーを発生することはありません。
キャッシュのexpireを5分などと設定していれば、5分間キャッシュを利用することができます。一般的にMySQLなどのRDBMSよりもキャッシュデータを入れているmemcachedなどの方が高速にデータを取得でき、MySQLなどへアクセスする必要もないことから大量のアクセスを処理することができるようになります。
dog pile effect が発生する状況
dog pile effect はこのキャッシュが消える5分後に発生する現象です。
このキャッシュがない状態で、アクセスの多いサイトだと同時に100のリクエストが来た場合、100のリクエストは同時にキャッシュがないことを確認してしまうため、MySQLに100リクエスト分のSQLクエリーが発生してしまうことになります。これによってWEBアプリケーション全体のパフォーマンスが低下してしまうことを「dog pile effect」と呼びます。
通常はサーバに届いた最初のリクエストはキャッシュが存在しないことを確認して、MySQLからデータを取得します。そして、新たにキャッシュを作成してページが表示されます。
dog pile effect を回避する方法
回避する方法としては冒頭で述べている通りRailsを使っていれば:race_condition_ttl
のオプションを利用することです。
race_condition_ttl を利用する
これを利用することでどういった挙動をするのかActiveSupport::Cache::Store#fetch
にあるサンプルコードを参照してみましょう。
# Set all values to expire after one minute.
cache = ActiveSupport::Cache::MemoryStore.new(expires_in: 1.minute)
cache.write('foo', 'original value')
val_1 = nil
val_2 = nil
sleep 60
Thread.new do
val_1 = cache.fetch('foo', race_condition_ttl: 10) do
sleep 1
'new value 1'
end
end
Thread.new do
val_2 = cache.fetch('foo', race_condition_ttl: 10) do
'new value 2'
end
end
# val_1 => "new value 1"
# val_2 => "original value"
# sleep 10 # First thread extend the life of cache by another 10 seconds
# cache.fetch('foo') => "new value 1"
最初のcache変数への代入でこのキャッシュストアは1分でキャッシュが消えるexpires_in
オプションを設定しています。
cache.write
でfoo
というキーに'original value'
という値を設定しています。
その後、60秒sleepしているためこのキャッシュは消える直前まで時間が進みます。
最初のval_1
を取得するスレッドはキャッシュから'original value'
という値を取得した瞬間に内部のexpireをrace_condition_ttl
で指定された10秒延長させてキャッシュに再度'original value
を書き込みます。そしてsleepの1秒を処理して、'new value 1
をキャッシュへ書き込みます。
前述のsleepをしている間にval_2
のスレッドが処理されます。この2つ目のスレッドはキャッシュに残っている一番最初にwriteされた値か1つ前のスレッドで再度expireを更新して書き込まれたキャッシュを参照します。よってキャッシュが存在することになるので結果としてval_2
は"original value"
が返り値となります。
val_1
に関してはキャッシュが切れた61秒後に更新をするので、新たな値となるキャッシュに書き込んだ"new value 1"
が返り値になります。この値はrace_condition_ttl
オプションにより10秒expireが伸ばされただけなので、最初に#writeされた時間から70秒後には消えてしまいます。
まとめ
とりあえずrace_condition_ttl
をあらゆるところにつけておけば良い。というものではありません。特にアクセスが多く、appサーバ全台から参照され、DBにクエリーが発生してSQLクエリーが刺さってしまう状態よりは古いキャッシュを返した方が良いケースに利用してみてください。数は少ないですが筆者は思い当たるケースがありました。