Timee Product Team Blog

タイミー開発者ブログ

Feature FlagのRedis負荷を90%削減した

こんにちは、タイミーでバックエンドのテックリードをしている新谷 (@euglena1215) です。

タイミーのバックエンドでは、Feature Flagの管理にFlipperを使用しており、そのデータストアとしてRedisを利用しています。本記事では、Flipperの値をインメモリキャッシュする機構を導入することで、Redisへのアクセスを90%削減し、毎時発生していた突発的なスパイクを解消した事例を紹介します。

本記事が、Feature Flagシステムの負荷やパフォーマンスに課題を抱えているチームの参考になれば幸いです。

Redisで突発的なspikeとエラーが発生していた

タイミーのモノリスRailsリポジトリでは、Feature Flagを活用して新機能の段階的なリリースやA/Bテストを実施しています。しかし、運用を続ける中で、いくつかの問題が顕在化してきました。

毎時0分台に発生する突発的なspike

DatadogでRedisのメトリクスを監視していたところ、毎時0分台に突発的なspikeが発生していることが確認されました。このspikeでは、平常時の約5倍ものアクセスが集中していました。

Redisアクセスが毎時0分台に跳ねてました

これはどうやら毎時0分に実行されるcronにより非同期ジョブが大量にenqueueされ、各ジョブの処理でFeature Flagが参照されることによる RedisへのN+1アクセスが発生しているようでした。

そもそもFeature Flagの値はほとんどのケースで変化せず、毎回データストアであるRedisにアクセスする必要はないのではないかと考えました。数秒くらいの期間であれば各プロセスでFlagをキャッシュしておいても、実用上は問題ないはずです。

そこで、プロセス内キャッシュとRedisの値を同期してくれる便利な仕組みを実現しようと考えました。

解決策:Flipper::Adapters::Syncの導入

Flipperには、remoteのデータとlocalのデータをいい感じに同期してくれるFlipper::Adapters::Syncが存在します。このアダプターを使うことで、指定した間隔(interval)でのみremoteからデータを取得し、それ以外はlocalのキャッシュから値を読み取る、という振る舞いを実現できます。

実装の詳細

実装の主なポイントは以下の通りです:

1. Sync Adapterの導入

# config/initializers/flipper.rb

require 'connection_pool'
require 'flipper/adapters/sync'

Flipper.configure do |config|
  redis_pool = ConnectionPool.new(size: 5, timeout: 5) do
    Redis.new(url: REDIS_URL)
  end
  remote_adapter = Flipper::Adapters::Redis.new(redis_pool)
  local_adapter = Flipper::Adapters::Memory.new
  sync_adapter = Flipper::Adapters::Sync.new(local_adapter, remote_adapter, interval: 10)

  config.adapter do
    sync_adapter
  end
end

Sync Adapterは、localのMemory adapterとremoteのRedis adapterを組み合わせて使用します。interval: 10と指定することで、10秒間はlocalのキャッシュから値を読み取り、10秒経過後に再度Redisから最新の値を取得します。

2. Adapter初期化の最適化

重要なポイントとして、Sync Adapterのインスタンスをconfig.adapter do ~ endブロックの外で作成しています。これには理由があります:

  • ブロック内に記述すると、リクエスト毎にアダプターが初期化されてしまう
  • 外で作成することで、プロセス内で単一のインスタンスを共有できる
  • 結果として、プロセス内で同じローカルキャッシュが使われる

3. Redis Connection Poolの導入

gem 'connection_pool'

これまでは毎リクエストでRedis接続を確立していましたが、Sync Adapterを導入することで接続が長期間保持されるようになります。そのため、接続が切れた場合の耐障害性を高めるため、Flipper 1.3.3から導入されたRedis Connection Poolsの機能を使ってconnection poolを持つようにしました。

4. リクエスト毎のメモ化を無効化

Rails.application.configure do
  # Sync adapter の @local (Memory) がプロセス内キャッシュとして機能するため、
  # リクエストごとのメモ化は不要
  config.flipper.memoize = false
end

Flipperではデフォルトでリクエスト毎に同一flagを参照した際にメモ化する最適化が有効になっていますが、これを無効にしました。メモ化が有効になっていると、リクエスト毎にSync adapterのlocalデータが揮発してしまう問題があったためです。Sync adapterが導入されたことで、そもそもメモ化は不要になりました。

導入前後の効果

検証環境での検証結果

本番導入前に、demo環境で動作確認を行いました。社内管理画面に全てのfeature flagに対してFlipper.enabled?を呼び出すテストボタンを用意し、導入前後の挙動を比較しました。

Before:全リクエストでFlag数回のRedisアクセス

全てのリクエストでFlag数回(21回)のRedisアクセスが発生している

After:10秒中の最初のリクエストのみRedisアクセス

同一プロセスにおける10秒中の最初のリクエストを除き、Redisアクセスが発生しなくなった

元の実装では、全てのリクエストでFlag数回(21回)のRedisアクセスが発生していましたが、変更後は同一プロセスにおける10秒中の最初のリクエストを除き、Redisアクセスが発生しなくなったことを確認しました。

本番環境での効果

本番環境に導入した結果、以下の効果が確認できました:

  • 平常時のRedisアクセスが90%削減
    • リクエスト毎のRedisアクセスがほぼ不要になり、大幅なアクセス削減を実現
  • 毎時0分台のspikeが完全に解消
    • 平常時の5倍のアクセスが発生していたspikeが消滅
    • Redisの負荷が安定し、インスタンスサイズダウンの可能性も見えてきた

まとめ

FlipperにFlipper::Adapters::Syncを導入することで、Feature FlagシステムのRedis負荷を90%削減し、毎時発生していた突発的なspikeを解消することができました。

この改善により、以下のメリットが得られました:

  • 安定性の向上:timeout errorの減少、spike解消による安定した動作
  • パフォーマンスの向上:Redis I/Oの削減による応答速度の改善
  • コスト削減の可能性:Redis負荷の削減によるインスタンスサイズダウンの検討

Feature Flagの値が頻繁に変更されない特性を活かし、適切にキャッシュすることで、大きな改善効果を得ることができました。同様の課題を抱えているチームの参考になれば幸いです。

参考資料