ITエンジニアによるITエンジニアのためのブログ

Railsキャッシュストアを応用したレースコンディション対策

ウェブサイトで、例えばユーザーが同時に複数のタブやウィンドウで同じ操作(例:商品購入、予約)を行ったりボタンをダブルクリックした場合、レースコンディションが発生する可能性があります。これは、複数のリクエストがほぼ同時に処理され、意図しない結果を引き起こす現象です。

この記事では、Ruby on Railsのキャッシュストアを使ってそのようなレースコンディションを防ぐ方法を紹介します。

  1. キャッシュストア
  2. Redisキャッシュストア設定方法
  3. レースコンディション対策

キャッシュストア

Railsではキャッシュ管理のためにActiveSupport::Cache::Storeというクラスを提供していますが、これをレースコンディション対策に応用します。

キャッシュストアで実際にどのバックエンド(Redis、ファイルシステム等)を使うかはユーザーの自由ですが、レースコンディションを防ぐためには、キャッシュストアの操作がアトミックであることが重要です。Redisなどのインメモリデータストアは、アトミックな操作を提供するため、この目的に適しています。ファイルシステムなどのストアでは、アトミック性を保証できないため、この方法でのレースコンディション対策は推奨されません。

この記事ではRedisキャッシュストアを使って解説していきます。

Redisキャッシュストア設定方法

Gemfilegem 'redis'を追加し、bundle installを実行します。

# Gemfile
gem 'redis'

次に、initializerでredisをキャッシュストアに指定します。

# config/initializers/redis.rb
redis_url = ENV['REDIS_URL'] || 'redis:https://engineerjutsu.com:6379/0'
begin
  Rails.application.config.cache_store = :redis_cache_store, { url: redis_url }
rescue Redis::CannotConnectError => e
  Rails.logger.error "Redis connection error: #{e.message}"
  if Rails.env.production?
    Rails.logger.warn "Redisへの接続に失敗したため、キャッシュが無効になり、レースコンディション対策も無効になります。"
    Rails.application.config.cache_store = :null_store
  else
    raise e # 開発環境ではエラーをraise
  end
end

レースコンディション対策

対策方法ですが、HTTPリクエストを処理する前に、もし重複リクエストがあった場合には同じになるようなキーをキャッシュストアに設定し、リクエスト処理後にキーを削除する、というものになります。

具体的な対策方法は以下の通りです。

  1. コントローラーアクションにaroundフィルターを設定。
  2. キャッシュストアに特定のキーが存在すれば重複リクエストなので、エラーを返す。
  3. キーがキャッシュストアに存在しなければ重複リクエストではないので、キーを設定してレースコンディションから保護した上でコントローラーアクションを実行する。
  4. アクション完了後にキーを削除する。

コード例としては以下のようになります。

class ExamplesController < ApplicationController

  around_action :check_race_condition, only: :create

  private

  def check_race_condition
    reservation_time = params[:reservation_time].presence || 'nil_reservation_time'
    place_id = params[:place_id].presence || 'nil_place_id'
    key = "race_condition:reservations:#{reservation_time}-#{place_id}-#{current_user&.id}"

    if Rails.cache.exist?(key)
      render json: { error: '現在処理中のため、しばらくお待ちください。' }, status: :conflict
    else
      begin
        Rails.cache.write(key, 'true', expires_in: 5.seconds)
        yield
      ensure
        Rails.cache.delete(key)
      end     
    end
  end
end

ここで重要なのは、キーは重複リクエスト間で同じものになるように設定する必要があるということです。これは個別アプリケーションによって違いますが、例えば予約サイトだったら上記コード例のように予約時間、予約場所、ユーザーIDの組み合わせなどが考えられます。

また、キーの有無によって重複リクエストかを判断するので、設定する値については重要ではありません。ここの例では単純にtrueという文字列を設定しています。

キーの有効期限はコード例では5秒間(expires_in: 5.seconds)としていますが、この値はアプリケーションの処理時間に合わせて適切に調整する必要があります。処理時間が5秒を超える可能性がある場合は、この値を大きくする必要があります。逆に、処理時間が非常に短い場合は、値を小さくすることで、不要なロック時間を短縮できます。

なお、レースコンディションを防ぐ別の方法としては、DBテーブルへのユニークキー制限付与があります。これはこれで有効なのですが、アプリケーションによってはDB構造が原因で難しい場合があります。また、ユニーク制限は付けたくないがリクエストが完了するまでの数秒間だけ重複リクエストを防ぎたい、という場合もあります。そのような時にキャッシュストアを使った対策方法を知っておくと便利です。