Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Railsの隠れた宝石:ActiveSupportキャッシュのincrement/decrementメソッド

Railsは、特定の状況に役立つ便利なツールが数多く組み込まれた大規模なフレームワークです。このシリーズでは、Railsの膨大なコードベースの中に隠れている、あまり知られていないツールを紹介していきます。

本記事では、Rails.cacheが提供するincrementメソッドとdecrementメソッドについて解説します。

Rails.cacheヘルパーとは

Rails.cacheは、アプリケーションのキャッシュを操作するための入り口です。同時に抽象化レイヤーでもあり、内部で実際にどのキャッシュ「ストア」が使われているかにかかわらず、共通のAPIでアクセスできるようになっています。Railsが標準でサポートしているストアは以下の通りです。

  • FileStore
  • MemoryStore
  • MemCacheStore
  • NullStore
  • RedisCacheStore

Rails.cacheを確認すれば、現在使用しているストアがわかります。

> Rails.cache
=> <#ActiveSupport::Cache::RedisCacheStore options={:namespace=>nil, ...

すべてを詳しく見ることはしませんが、簡単にまとめると以下のようになります。

  • NullStore:何も保存せず、読み出しは常にnilを返します。新しいRailsアプリのデフォルトです。
  • FileStore:キャッシュをハードディスク上のファイルとして保存するため、Railsサーバーを再起動してもキャッシュは残ります。
  • MemoryStore:キャッシュをRAM上に保持するため、Railsサーバーを停止するとキャッシュも消えます。
  • MemCacheStore/RedisCacheStore:外部プログラム(それぞれMemcachedとRedis)を使ってキャッシュを管理します。

さまざまな理由から、最初の3つは主に開発・テスト環境でよく使われます。本番環境ではRedisやMemcachedを使うのが一般的でしょう。

Rails.cacheはこれらのサービス間の違いを抽象化してくれるため、コードを変更することなく環境ごとに異なるストアを使い分けられます。たとえば、開発環境ではNullStore、テスト環境ではMemoryStore、本番環境ではRedisCacheStoreという組み合わせも可能です。

キャッシュされたデータの扱い

RailsのRails.cache.readRails.cache.writeRails.cache.fetchを使えば、任意のデータをキャッシュに簡単に保存・取得できます。これらについては以前の記事で詳しく取り上げましたが、本記事で重要なのは、これらのメソッドにはスレッドセーフ性が組み込まれていないという点です。複数のスレッドからキャッシュ内のカウンターを更新するような場面では、競合状態(レースコンディション)を避けるために、読み書き操作を何らかのロックで囲む必要があります。Redisキャッシュストアを使用している前提で、次の例を見てみましょう。

threads = []
# カウンターの初期値を設定
Rails.cache.write(:test_counter, 0)

4.times do
  threads << Thread.new do
    100.times do 
      current_count = Rails.cache.read(:test_counter)
      current_count += 1
      Rails.cache.write(:test_counter, current_count)
    end
  end
end

threads.map(&:join)

puts Rails.cache.read(:test_counter)

ここでは4つのスレッドが、それぞれキャッシュされた値を100回ずつインクリメントしています。結果は400になるはずですが、実際にはそれよりずっと少ない値になることがほとんどです(筆者のテスト実行では269でした)。これは競合状態が起きているためです。前回の記事で詳しく説明しましたが、簡単にまとめると、スレッドがすべて同じ「共有」データを操作しているため、互いに同期が取れなくなることがあります。たとえば、あるスレッドが値を読み込んだ直後に別のスレッドが処理を引き継ぎ、同じ値を読み込んでインクリメントして保存する、といったことが起こり得ます。その後、最初のスレッドが再開すると、すでに古くなった値を使ってしまうのです。

この問題に対する一般的な解決策は、コードを相互排他ロック(Mutex)で囲み、ロック内のコードを一度に1つのスレッドしか実行できないようにすることです。しかし今回の場合、Rails.cacheにはこのシナリオに対応するためのメソッドが用意されています。

Rails.cacheのincrementとdecrement

Rails.cacheオブジェクトには、カウンターのようなキャッシュデータを直接操作するためのincrementメソッドとdecrementメソッドが備わっています。

threads = []
# カウンターの初期値を設定
Rails.cache.write(:test_counter, 0, raw: true)

4.times do
  threads << Thread.new do
    100.times do 
      Rails.cache.increment(:test_counter)
      # スレッドセーフであることを強調するため、あえて繰り返しています
      Rails.cache.decrement(:test_counter)
      Rails.cache.increment(:test_counter)
    end
  end
end

threads.map(&:join)

puts Rails.cache.read(:test_counter, raw: true)

incrementdecrementを使うには、キャッシュストアに対してその値が「raw」値であることを伝える必要があります(raw: true)。値を読み戻す際にも同様に指定が必要で、忘れるとエラーになります。要するに、「この値は素の整数として保存したいので、increment/decrementを呼び出せるようにしてほしい」とキャッシュに伝えているわけです。なお、expires_inなどの他のキャッシュフラグと併用することも可能です。

重要なのは、incrementdecrementがアトミック操作(少なくともRedisとMemcachedの場合)を使用しているという点です。つまりスレッドセーフであり、アトミック操作の途中でスレッドが中断されることはありません。

ここでは例で使いませんでしたが、両メソッドとも新しい値を返り値として返す点にも注目してください。そのため、単にカウンターを更新するだけでなく、更新後の値をそのまま利用することもでき、追加のread呼び出しが不要になります。

実務での活用例

一見すると、incrementdecrementは低レベルなヘルパーメソッドのように思え、バックグラウンドジョブ処理gemを実装・保守するような場合でなければ気にかけないかもしれません。しかし、存在を知ってしまえば、意外な場面で役立つことに気づくはずです。

筆者は本番アプリケーションで、予約済みのバックグラウンドジョブが重複して同時実行されるのを防ぐためにこれらのメソッドを使ったことがあります。私たちのケースでは、検索インデックスの更新や放棄カートのマークなど、さまざまな定期ジョブが動いていました。通常は問題なく動作するのですが、一部のジョブ(特に検索インデックス関連)は大量のメモリを消費します。2つが同時に走るとHerokuのdynoの上限を超えてしまい、ワーカーが強制終了されてしまうのです。こうしたジョブが複数あるため、「リトライなし」に設定したりユニークジョブ化したりするだけでは不十分でした。異なる(つまりそれぞれユニークな)ジョブが偶然同時に実行され、ワーカーを落とす可能性があるからです。

そこで対策として、現在実行中のジョブ数をカウントする基底クラスを作成しました。カウントが上限に達していた場合、ジョブは自分自身を再エンキューして待機します。

もう一つの例は、私のサイドプロジェクトでのことです。ユーザーが待っている間に、バックグラウンドジョブ(または複数のジョブ)が何らかの処理を行うケースがありました。ここで課題になるのが、バックグラウンドジョブの進捗状況をユーザーに伝える方法です。解決策はいくつもありますが、実験としてRails.cache.incrementを使ってグローバルに参照できるカウンターを更新してみました。構成は以下の通りです。

  1. まず、カウンターがキャッシュ内に存在することを抽象化する新しいクラスを/app/modelsに追加しました。値へのアクセスはすべてこのクラス経由で行います。ジョブに関連付けられたユニークなキャッシュキーの生成もここで行います。
  2. ジョブ側ではこのモデルのインスタンスを生成し、アイテムの処理が進むごとに更新していきます。
  3. 現在の値を取得するためのシンプルなJSONエンドポイントを作成しました。このエンドポイントもモデルのインスタンスを生成して現在値を返します。
  4. フロントエンドはこのエンドポイントを数秒ごとにポーリングしてUIを更新します。もちろん、ActionCableなどを使ってプッシュ型のより洗練された仕組みにすることもできます。

まとめ

正直に言うと、Rails.cache.incrementは頻繁にお世話になるツールではありません。キャッシュに保存されたデータ(本来、ある程度一時的なもの)を更新したい場面はそう多くないからです。前述の通り、筆者が使うとしたら主にバックグラウンドジョブ関連です。ジョブはすでにデータをRedisに保存しており(少なくとも筆者が関わった多くのアプリではそうでした)、それらも基本的に一時的なものだからです。こうしたケースでは、進捗率などの関連データも、同じ場所に似た短期間の永続性レベルで保存するのが自然に感じられます。

「王道から外れた」機能全般に言えることですが、このようなものをコードベースに導入する際は注意が必要です。少なくとも、将来の開発者のために、なぜこのあまり知られていないメソッドを使っているのかを説明するコメントを残しておくことをおすすめします。

  1. RailsとCarrierWaveを使ったファイルアップロードの実装ガイド

    本記事は「Railsでのアップロード」シリーズの1つです。今回は、Rails向けファイルアップロードソリューションとして最も人気のあるCarrierWaveを紹介します。CarrierWaveは導入が非常に簡単で、標準で豊富な機能を備えている点が魅力です。さらにコミュニティによって多数のノウハウ記事が公開されているため、迷うことなく開発を進められます。 この記事では、以下の内容を学ぶことができます。 RailsアプリへのCarrierWaveの組み込み バリデーションの追加 リクエスト間でのファイル保持 ファイルの削除 サムネイル画像の生成 リモートURLからのファイルアップロード 複数ファ

  2. Windows 11の隠れた便利機能15選!今すぐ使えるヒントと設定を徹底解説

    Windows 11は対応デバイス向けに無料アップグレードとして提供されており、数多くの新機能と改善が含まれています。再デザインされたスタートメニューとタスクバー、Androidアプリに対応した強化されたMicrosoft Store、統合されたMicrosoft Teams、スナップレイアウト、ウィジェットなど、目立つ新機能は多いものの、Redmond(マイクロソフト本社)が公式に発表した大きなニュース以外にも、一見すると気づきにくい小さな変更点がたくさん隠れています。この記事では、知っておくべきWindows 11の隠れた機能を厳選してご紹介します。 スタートメニューを左側に戻す方法 W