オウンドメディアでAI記事生成を回し始めたとき、最初に詰まったのは「品質」より先に「運用の上限」でした。アクセスが伸びる月ほど生成依頼が集中し、同じ記事数を作っているはずなのに、あるタイミングから失敗や待ち時間が増えます。原因は単純で、LLMや画像生成、SEOスコア算定など複数処理が積み重なるのに、入口での制御が弱いからです。私はこの状態を放置すると、プランごとの期待値が崩れ、返金やCSの火消しが先に来ると感じました。そこでRailsで、ピラー記事・クラスター記事の自動生成を前提にしたレートリミットを設計します。Drafityのようなバックグラウンド生成やAPI/CMS連携を想定すると、同期処理だけでなくジョブキュー側の上限も含めて考える必要がありました。プラン別に「何を」「どの単位で」「いつまで」制限するかを決めるところから始めるのが、安定したコンテンツ資産化への近道だと思っています。
なぜRailsでプラン別レートリミットが必要になるのか:AI記事生成SaaSの課金と負荷のズレ
「429 Too Many Requests」がログに出た瞬間、私は課金プランと生成負荷の対応が崩れていると確信しました。AI記事生成は、記事本文だけでなくキーワード設計、ピラー/クラスターの整合、E-E-A-T寄りの品質評価、SEOスコア算定、画像生成など複数処理が直列に積み上がります。ところがUI上は“同じ記事数”でも、実際の実行時間や外部呼び出し回数はリクエストの中身で変動します。結果として、上位プランで想定した同時実行と、実際に集中する生成バーストが噛み合わず、待ち時間や失敗が増えます。ここでRails側にプラン別のレート制御を置くと、負荷の上限を見える形に寄せられます。Drafityのようなバックグラウンド生成を扱うなら、入口での制御が課金の期待値と性能のズレを吸収する鍵になります。
実装方針を固める:リクエスト単位・生成単位・バックグラウンド単位の上限設計
ジョブ投入前に、私は「どの単位を数えるか」を先に決めた。生成処理は実行時間が長く揺れるので、単純なHTTP回数だけでは課金期待値とズレやすい。そこで上限を リクエスト単位 / 生成単位 / バックグラウンド単位 に分け、各プランで上限テーブルを持つ方針にした。
- リクエスト単位:生成開始APIの呼び出し回数(短時間で捌ける)
- 生成単位:ピラー1本・クラスター複数など、成果物の単位(課金に近い)
- バックグラウンド単位:キュー投入された生成ジョブ数(同時実行とワーカー逼迫の防波堤)
実装では、Railsのコントローラで「生成開始前にカウント→超過なら即拒否」、ワーカー側では「実行直前に再カウント→超過なら延期/中断」を二段構えにした。これで、同時リクエストによる競合でも上限が守られる。カウントはDBトランザクションで原子的に行い、期限付きのウィンドウ(例:分/時間)でロールする。
**擬似コード(実在しない実装は避けつつ方針だけ)**
def allow_request?(user, plan, window_key)
RateLimit.transaction do
record = RateLimit.lock.find_or_create_by!(user_id: user.id, key: window_key)
return false if record.count >= plan.request_limit
record.count += 1
record.save!
true
end
end
最後は、拒否時の挙動を「HTTP 429で即時返す」か「キューに積まずに待機させる」かで分岐させ、CS負荷とユーザー体験のトレードオフを見ながら調整した。
Railsでレートリミットを組み立てる:ミドルウェア/ジョブ/DBの役割分担
入口での制御が弱いと、生成処理が積み上がってから初めて詰まり始めます。私はこの手の詰まりを「ミドルウェアで弾く」「ジョブで整流する」「DBで整合を取る」の三層に分けて設計するようにしています。そうすると、同じ課金プランでも“どこで何が起きたか”を追いやすくなります。
- ミドルウェア:同期系の生成依頼は、早い段階で上限超過を返す。ここはレスポンス遅延を増やさないための遮断役で、計測用のメタ情報(プラン、ユーザ、種類、判定結果)だけを残す運用が楽です。
- ジョブ:非同期の生成は、キュー投入時ではなく実行直前に枠を確保する。こうすると「投入はできたが実行が混む」状況でも、実際の処理負荷に合わせて抑制できます。
- DB:最終的な整合性は必ずDBで担保する。アプリ側のカウンタだけだと並列実行で簡単に崩れます。期限付きの集計は、同一キーに対して原子的に増減できる形にしておくのが筋です。
実装イメージとしては、ミドルウェア/ジョブのどちらでも同じ判定関数(ただし実体はDB)を呼びます。例えばDB側で「プラン×ユーザ×種類×時間帯」の集計行をロックし、期限切れを除外してから増やす、という流れです。
**擬似コード(方針のみ)**
def acquire_slot!(user_id:, plan_id:, kind:, bucket_key:)
RateLimit.transaction do
# 期限切れの行は除外/更新
# 対象キーに対してロックし、上限判定→増分→確定
end
end
この分担はトレードオフもあって、DBを強く使うほど整合性は上がる一方でロック競合が増えます。なので私は「同期はミドルウェアで落とす」「非同期はジョブで枠を取り直す」「DBは最小限のキーで原子性を守る」という順で負荷の置き場所を調整します。
ような記事生成で詰まりがちな点:同時実行・再試行・課金対象の整合性
同時実行と再試行が絡むと、課金対象の整合性が崩れて詰まる。私はオウンドメディアでAI記事生成を回し始めたとき、生成自体は同じ設定のはずなのに、しばらくすると待ちが増え、失敗も増えていくのを見た。原因は単純で、生成の完了を課金・上限の基準にしてしまうと、並列ジョブが先に走ってしまい、失敗時の再実行も“未計上”として累積するからだ。さらに、同じテーマでも親子構造(ピラー/クラスター)で生成単位がズレると、上限判定が別の粒度になりやすい。
実務では粒度を揃えるのが効く。私は次のように整理してから安定した。
- カウント基準を「完了」ではなく「生成着手(または外部API呼び出し開始)」に寄せる
- 再試行は“同一生成ID”で冪等化し、再実行しても追加消費しない設計にする
- 課金対象とレート制限の対象を同じキー(ユーザ/プラン/生成種別/親子の関係)で紐づける
- 同時実行はロックや一意制約で“同じキーの重複着手”を潰す
**方針だけの擬似コード(実在しない実装)**
def start_generation!(user_id:, plan_id:, kind:, generation_id:)
# 生成IDで一意に着手を記録
# 既に着手済みなら「再試行」として消費を増やさない
# 着手記録とレート上限の増分を同じ整合性単位で扱う
end
この手当てを入れると、失敗→再試行で上限が暴走せず、同時実行でも課金と実負荷が一致しやすくなる。結果として、運用中に“いつの間にか詰まる”状態を減らせる。
運用で効く改善:メトリクス設計とエラー応答、プラン変更時の挙動検証
プラン変更の瞬間だけレート制限が効かなくなるのでは、と疑ってしまうことがある。私は運用で一番困ったのが「上限は守っているのに、体感だけが悪い」状態で、メトリクスとエラー応答の設計が抜けていたと気づいた。そこで私は、失敗理由を機械的に追える形に揃えた。
- メトリクス:プランID・種別(親子の親/子、生成着手/外部呼び出し開始など)・結果(許可/拒否/外部失敗/タイムアウト)で集計し、拒否は「上限超過」と「鍵競合(同一キーの同時実行)」を分ける
- ログ:拒否時は「キー(ユーザ/プラン/種別/親子関係)」「上限」「残り枠(計算結果)」「ウィンドウの境界」を出す。成功時も同じキーを残して追跡可能にする
- エラー応答:429系は“再試行可能性”を明示するため、応答に期限(いつ枠が戻る見込みか)相当の値を含める。409系は競合として扱い、同一生成IDの冪等性が効いているかを確認できるようにする
次に、プラン変更時の挙動検証で私は「いつから新プランが効くか」を固定した。例えば以下のようにルールを決めるとブレにくい。
- 新プランの適用タイミング:請求状態の更新時刻に追従するのか、生成着手時点で評価するのかを明確化する
- 既に着手済みの扱い:途中でプランが変わっても、同一生成IDは当初の評価結果に従う(二重課金・二重消費の温床を避ける)
- 検証観点:同一ユーザで「短時間にプラン変更→連続生成」「親子の親を先に生成→子で枠不足」「競合時に429/409が混ざる」ケースを再現する
最後に私は、運用で困るのは“制限そのもの”より“説明不能な拒否”だと感じたので、拒否理由を観測できる形まで落とし込むのが効く落としどころだと思う。
「429 Too Many Requests」がログに出始めたとき、私は“制限は入れたのに効いていない”と感じました。振り返ると、レート制限の判定基準と課金の対象がズレている場面があり、同時に走る生成の扱いも曖昧でした。ここまでの設計(着手タイミング寄せ、冪等な再試行、親子関係まで含めたキー設計)を固めると、失敗や待ちが「増える理由」が説明できるようになります。次はプラン変更時の評価時点と既存ジョブの継続ルールを、負荷試験と実データの両方で確認していきたいです。
参考: Drafity