バックプレッシャーパターン — 過負荷を制御する
セール当日の悪夢
「アヤカさん、セールの日に何があったんですか?」
ユウキが入社前の話を聞いた。アヤカは苦い顔をした。
「大型セールを告知して、開始1分でサーバーが全部落ちた。同時アクセスが通常の50倍になって、DBコネクションが枯渇した。RDSが応答しなくなって、Railsワーカーが全部詰まって……メモリが枯れてプロセスがkillされた」
「防げたんですか?」
「バックプレッシャーがあれば。受け取れる以上のリクエストを断る勇気——それがバックプレッシャー」
「断るんですか?」
「そう。全部受け入れてシステムが崩壊するより、一部を丁寧に断う方が、全体のユーザー体験がよくなる。一人ひとりに503を返すより、一部が302で「少し待ってください」と言える方がいい」
バックプレッシャーとは
バックプレッシャーとは、下流のコンポーネントが処理できる量を超えたリクエストを、意図的に制限または拒否するパターン。
水道管の例:蛇口を全開にしても、パイプの直径以上の水は流れない。それ以上流そうとすると圧力が上がって破裂する。バックプレッシャーは「パイプの容量に合わせて蛇口を調節する」仕組み。
INFO
バックプレッシャーの本質は「拒否する」こと。全部受け入れてシステムが崩壊するより、一部を丁寧に断る方が全体のユーザー体験がよくなる。HTTPの 429 (Too Many Requests) と 503 (Service Unavailable) はその手段。
レート制限の実装
Rack Attackによるレート制限
# config/initializers/rack_attack.rb
class Rack::Attack
# 基本的なIPベースの制限(広い保護)
throttle("global/ip", limit: 300, period: 5.minutes) do |req|
req.ip unless req.path.start_with?("/assets/")
end
# APIエンドポイントの厳格な制限
throttle("api/orders/user", limit: 10, period: 1.minute) do |req|
if req.path.start_with?("/api/") && req.post? && req.path.include?("orders")
req.env["HTTP_X_USER_ID"] || req.ip
end
end
# 検索APIの制限(重い処理)
throttle("api/search/user", limit: 30, period: 1.minute) do |req|
if req.path.start_with?("/api/") && req.path.include?("search")
req.env["HTTP_X_USER_ID"] || req.ip
end
end
# ログイン試行の制限(ブルートフォース対策)
throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/api/v1/sessions" && req.post?
end
# 管理API:APIキーごとに制限
throttle("admin/api_key", limit: 1000, period: 1.hour) do |req|
if req.path.start_with?("/api/admin/")
req.env["HTTP_X_API_KEY"]
end
end
# 制限超過時のレスポンス(カスタマイズ)
self.throttled_responder = lambda do |env|
now = Time.current
match_data = env["rack.attack.match_data"]
period = match_data[:period]
limit = match_data[:limit]
count = match_data[:count]
discriminator = match_data[:discriminator]
retry_after = period - (now.to_i % period)
headers = {
"Content-Type" => "application/json",
"X-RateLimit-Limit" => limit.to_s,
"X-RateLimit-Remaining" => [0, limit - count].max.to_s,
"X-RateLimit-Reset" => (now.to_i + retry_after).to_s,
"Retry-After" => retry_after.to_s
}
body = {
error: "Too Many Requests",
message: "リクエストが多すぎます。#{retry_after}秒後に再試行してください。",
retry_after: retry_after,
limit: limit,
period: period
}
[429, headers, [body.to_json]]
end
# ブロック対象のレスポンス
self.blocklisted_responder = lambda do |env|
[
403,
{ "Content-Type" => "application/json" },
[{ error: "Forbidden" }.to_json]
]
end
endスライディングウィンドウレートリミッター
固定ウィンドウより公平なレート制限:
# app/services/sliding_window_rate_limiter.rb
class SlidingWindowRateLimiter
def initialize(key:, limit:, window_seconds:)
@key = "swrl:#{key}"
@limit = limit
@window_seconds = window_seconds
end
def allowed?
now = Time.current.to_f
window_start = now - @window_seconds
# Redis のSortedSetを使ったスライディングウィンドウ
redis = Redis.current
redis.multi do |pipeline|
pipeline.zremrangebyscore(@key, 0, window_start) # 古いエントリを削除
pipeline.zadd(@key, now, "#{now}:#{SecureRandom.hex(4)}") # 現在のリクエストを追加
pipeline.zcard(@key) # 現在のカウント
pipeline.expire(@key, @window_seconds * 2) # TTL設定
end.then { |results|
results[2].to_i <= @limit # カウントが上限以下か
}
end
def remaining
now = Time.current.to_f
window_start = now - @window_seconds
redis = Redis.current
count = redis.zcount(@key, window_start, now).to_i
[@limit - count, 0].max
end
def reset_at
oldest = Redis.current.zrange(@key, 0, 0, with_scores: true).first
return Time.current + @window_seconds unless oldest
Time.at(oldest[1] + @window_seconds)
end
end
# 使用例
class OrdersController < ApplicationController
def create
limiter = SlidingWindowRateLimiter.new(
key: "orders:#{current_user.id}",
limit: 5,
window_seconds: 60
)
unless limiter.allowed?
render json: {
error: "注文は1分間に5件までです",
remaining: limiter.remaining,
retry_after: (limiter.reset_at - Time.current).ceil
}, status: :too_many_requests
return
end
# 通常の注文処理
process_order
end
endキューによるバッファリング
# app/services/order_queue_service.rb
class OrderQueueService
QUEUES = {
vip: "order_queue:vip",
normal: "order_queue:normal",
bulk: "order_queue:bulk"
}.freeze
QUEUE_SIZE_LIMITS = {
vip: 500,
normal: 2000,
bulk: 10000
}.freeze
def enqueue(user_id:, items:, priority: :normal)
queue_key = QUEUES[priority] || QUEUES[:normal]
limit = QUEUE_SIZE_LIMITS[priority] || QUEUE_SIZE_LIMITS[:normal]
queue_size = Redis.current.llen(queue_key)
if queue_size >= limit
# VIPキューが満杯の場合、通常キューを試みる
if priority == :vip
Rails.logger.warn("VIPキューが満杯: フォールバック処理")
return process_vip_immediately(user_id, items)
end
raise QueueFullError, "キューが満杯です(#{queue_size}/#{limit})。しばらく後でお試しください"
end
job_id = SecureRandom.uuid
payload = {
job_id: job_id,
user_id: user_id,
items: items,
priority: priority,
enqueued_at: Time.current.iso8601
}.to_json
Redis.current.rpush(queue_key, payload)
Redis.current.expire(queue_key, 3600) # 1時間後に期限切れ
# 待ち時間の推定
estimated_wait = estimate_wait_time(queue_size, priority)
{
job_id: job_id,
queue_position: queue_size + 1,
estimated_wait_seconds: estimated_wait
}
end
def status(job_id)
key = "order_result:#{job_id}"
result = Redis.current.get(key)
if result
JSON.parse(result, symbolize_names: true)
else
{ status: "pending", message: "処理待ち" }
end
end
def queue_stats
QUEUES.each_with_object({}) do |(name, key), stats|
stats[name] = {
size: Redis.current.llen(key),
limit: QUEUE_SIZE_LIMITS[name],
utilization_pct: (Redis.current.llen(key).to_f / QUEUE_SIZE_LIMITS[name] * 100).round(1)
}
end
end
private
def estimate_wait_time(queue_size, priority)
# ワーカーの処理速度から推定(毎秒10件処理と仮定)
processing_rate = case priority
when :vip then 20
when :normal then 10
when :bulk then 5
end
(queue_size / processing_rate.to_f).ceil
end
def process_vip_immediately(user_id, items)
result = OrderCreationService.new(
user: User.find(user_id),
items: items
).call
{ status: "completed", order_id: result.order&.id }
end
end
class QueueFullError < StandardError; end# app/workers/order_processor_worker.rb
class OrderProcessorWorker
include Sidekiq::Worker
sidekiq_options(
queue: :critical,
retry: 3,
backtrace: true
)
QUEUE_POLL_INTERVAL = 0.1 # 100ms
def perform(queue_name = "normal")
queue_key = OrderQueueService::QUEUES[queue_name.to_sym] || OrderQueueService::QUEUES[:normal]
client = Redis.current
Rails.logger.info("OrderProcessorWorker開始: queue=#{queue_name}")
loop do
# ブロッキングポップ(メッセージが来るまで待機)
payload = client.blpop(queue_key, timeout: 5)
next unless payload
data = JSON.parse(payload[1], symbolize_names: true)
process_order(data)
end
end
private
def process_order(data)
Rails.logger.info("注文処理開始: job_id=#{data[:job_id]}")
start_time = Time.current
user = User.find(data[:user_id])
result = OrderCreationService.new(
user: user,
items: data[:items]
).call
if result.success?
store_result(data[:job_id], {
status: "completed",
order_id: result.order.id,
processed_at: Time.current.iso8601,
processing_time_ms: ((Time.current - start_time) * 1000).round
})
else
store_result(data[:job_id], {
status: "failed",
errors: result.errors,
failed_at: Time.current.iso8601
})
end
rescue => e
Rails.logger.error("注文処理エラー: job_id=#{data[:job_id]}, error=#{e.message}")
store_result(data[:job_id], {
status: "error",
error: e.message,
failed_at: Time.current.iso8601
})
raise # Sidekiqにリトライさせる
end
def store_result(job_id, result)
Redis.current.set(
"order_result:#{job_id}",
result.to_json,
ex: 3600 # 1時間保持
)
end
endSidekiq の並行性制御
# config/sidekiq.yml
:concurrency: 10 # ワーカースレッド数(= DB接続数とほぼ一致)
:timeout: 30 # ジョブのタイムアウト
:max_retries: 3 # 最大リトライ回数
:queues:
- [critical, 4] # 決済・重要処理:高優先度
- [default, 2] # 通常処理
- [low_priority, 1] # メール・通知など
# 各キューのサイズ上限(Sidekiq Proの機能)
:limits:
critical: 100
default: 500
low_priority: 1000# Sidekiqのジョブ投入前にキューサイズをチェック
class ThrottledJob
def self.perform_later(job_class, *args, queue: nil, max_queue_size: 1000)
queue_name = queue || job_class.get_sidekiq_options["queue"] || "default"
current_size = Sidekiq::Queue.new(queue_name).size
if current_size >= max_queue_size
Rails.logger.warn("キューが満杯: #{queue_name} (#{current_size}/#{max_queue_size})")
raise QueueFullError, "#{queue_name}キューが満杯です(#{current_size}件待機中)"
end
job_class.perform_async(*args)
end
endAWS API Gateway でのレート制限
# CloudFormation / CDK でのAPI Gateway設定
Resources:
OrdersApi:
Type: AWS::ApiGateway::RestApi
Properties:
Name: orders-api
Description: 注文管理API
# デフォルトの使用プラン
DefaultUsagePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
UsagePlanName: default-plan
Throttle:
RateLimit: 1000 # 毎秒1000リクエスト(定常状態)
BurstLimit: 2000 # バースト時2000リクエスト(短時間の急増を許容)
Quota:
Limit: 86400 # 1日あたり86400リクエスト
Period: DAY
# VIPユーザー向けプレミアムプラン
PremiumUsagePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
UsagePlanName: premium-plan
Throttle:
RateLimit: 5000
BurstLimit: 10000
Quota:
Limit: 432000 # 5倍
Period: DAYグレースフルデグラデーション
バックプレッシャーを超えた場合も、ユーザーへの影響を最小化する。
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
before_action :authenticate_user!
def create
# バックプレッシャーチェック
check_system_health!
result = OrderQueueService.new.enqueue(
user_id: current_user.id,
items: order_params[:items],
priority: determine_priority(current_user)
)
# 非同期でキューに入れた場合は即座に「受け付けた」と返す
render json: {
message: "注文を受け付けました。処理中です。",
job_id: result[:job_id],
queue_position: result[:queue_position],
estimated_wait_seconds: result[:estimated_wait_seconds],
status_url: order_status_url(job_id: result[:job_id])
}, status: :accepted # 202 Accepted
rescue QueueFullError => e
render json: {
error: "キューが満杯",
message: e.message,
retry_after: 30,
suggestion: "30秒後に再度お試しください"
}, status: :service_unavailable # 503
rescue SystemHealthError => e
render json: {
error: "サービス一時停止",
message: "ただいまメンテナンス中です。しばらくお待ちください。",
retry_after: 60
}, status: :service_unavailable
end
def status
result = OrderQueueService.new.status(params[:job_id])
case result[:status]
when "completed"
render json: result, status: :ok
when "failed", "error"
render json: result, status: :unprocessable_entity
else
render json: result, status: :accepted # まだ処理中
end
end
private
def check_system_health!
# DBコネクション数チェック
active_connections = ActiveRecord::Base.connection_pool.stat[:connections]
pool_size = ActiveRecord::Base.connection_pool.size
if active_connections.to_f / pool_size > 0.9 # 90%以上使用中
raise SystemHealthError, "DBコネクションが逼迫しています"
end
end
def determine_priority(user)
if user.vip?
:vip
elsif user.orders.delivered.count >= 10
:normal
else
:normal
end
end
end
class SystemHealthError < StandardError; end負荷試験とキャリブレーション
# bin/load_test.rb(k6やLocustの設定例をRubyで)
# 実際の測定で閾値を決める
class LoadTestAnalyzer
def analyze_results(results)
{
p50_latency_ms: percentile(results[:latencies], 50),
p95_latency_ms: percentile(results[:latencies], 95),
p99_latency_ms: percentile(results[:latencies], 99),
error_rate: results[:errors].to_f / results[:total_requests],
max_throughput_rps: calculate_max_throughput(results),
recommended_rate_limit: (calculate_max_throughput(results) * 0.8).floor # 80%に設定
}
end
private
def percentile(values, pct)
sorted = values.sort
index = (sorted.size * pct / 100.0).ceil - 1
sorted[index]
end
def calculate_max_throughput(results)
# エラー率が1%未満の最大スループット
results[:throughput_by_rps].select { |_, e| e < 0.01 }.keys.max
end
endモニタリングダッシュボード
# app/services/backpressure_metrics.rb
class BackpressureMetrics
def self.record(event:, details: {})
namespace = "MyApp/Backpressure"
metric_data = case event
when :rate_limited
{ metric_name: "RateLimitedRequests", value: 1 }
when :queue_full
{ metric_name: "QueueFullRejections", value: 1 }
when :queue_depth
{ metric_name: "QueueDepth", value: details[:depth].to_f }
when :processing_time
{ metric_name: "OrderProcessingTime", value: details[:ms].to_f, unit: "Milliseconds" }
end
return unless metric_data
Aws::CloudWatch::Client.new.put_metric_data(
namespace: namespace,
metric_data: [
metric_data.merge(
dimensions: [
{ name: "Environment", value: Rails.env },
{ name: "Queue", value: details[:queue]&.to_s || "default" }
],
timestamp: Time.current
)
]
)
rescue => e
Rails.logger.error("CloudWatchメトリクス送信失敗: #{e.message}")
end
endWARNING
レート制限の閾値は、負荷試験で測定した実際のシステム容量を基準に設定する。「なんとなく100req/s」では高すぎても低すぎても問題。k6やLocustで定期的に負荷試験を行い、閾値を見直す。セール前は必ず負荷試験を実施すること。
まとめ
| 手法 | 実装 | 保護するもの | 効果 |
|---|---|---|---|
| レート制限(IPベース) | Rack::Attack | DDoS・クローラー | 外部からの攻撃を遮断 |
| レート制限(ユーザーベース) | Redis カウンター | 個別ユーザーの過剰使用 | 公平な使用を保証 |
| キューイング | Sidekiq / SQS | 処理スパイク | 平滑化・バッファリング |
| API Gateway 制限 | AWS API Gateway | APIの総スループット | エッジでの制御 |
| 202 Acceptedパターン | 非同期エンドポイント | ユーザー体験 | 応答速度の改善 |
| サーキットブレーカー | 前章参照 | 外部依存 | カスケード障害防止 |
「セールの日に同じことが起きたらどうなりますか?」ユウキが聞いた。
「一部のユーザーが503を見る。でも全員が見るのとは全然違う。大事なのは一部を断って全体を守ること。そして503を見たユーザーには、いつ再試行できるかを伝える。それが誠実な設計」
「パターンを学ぶ前は、断ることが悪いことだと思っていました」
「それが一番の勘違い。容量を超えても全部受け入れようとすると、全員に悪い体験を与える。適切に断ることが、長期的にはユーザーへの責任感の表れだよ」
最終章では、これまで学んだパターンを組み合わせて、現実のシステムを設計する方法を学びます。