無停止で「未来」をリリースする — 時刻バージョニング型アーキテクチャ
価格改定もキャンペーンも「未来の日付」で予約し、当日はシステムを止めずに自動で切り替える。通販基盤 Vegas を支える中核設計。
「毎月1日 0:00、価格改定を一斉反映。深夜にメンテ画面を出し、切替を確認し、問題があれば緊張しながら切り戻す」——通販システムの運用で、こんな夜を過ごしたことはないでしょうか。
ECや通販の裏側では、価格・キャンペーン・送料・商品構成といった「設定」が絶えず入れ替わります。しかもその多くは「特定の日時から一斉に切り替えたい」という要件を持ちます。従来これは、デプロイ作業・メンテナンス停止・人手の確認とセットになりがちでした。Vegas はこの問題を、アプリケーションのバージョンそのものを「時刻」で管理するという発想で解いています。
The Problem「当日切替」はなぜ難しいのか
「7/1 0:00 から新価格」を素朴に実装すると、次のいずれかになりがちです。
- デプロイで切り替える — 切替時刻に人が待機。前後の受注と競合し、切り戻しは別デプロイ。
- アプリ内で日付分岐する — if (today >= "2026-07-01") が増殖し、過去分岐が消せずコードが腐る。
- 事前に本番データを書き換える — 切替まで「未来の値」が本番に混入。プレビューもできない。
いずれも「いつの時点の正解か」をシステムが一級の概念として扱っていないことに起因します。ならば、そこを設計の中心に据えればよい——というのが出発点です。
The Ideaすべてに「有効期間」を持たせる
Vegas では、ルーティングと設定を格納する台帳の全レコードに OpenDate(有効開始) と CloseDate(有効終了) を持たせます。リクエストのたびに previewDate(基準時刻) を受け取り、その時刻に有効なレコードを解決します。
OpenDate ≤ previewDate < CloseDate を満たすレコードのうち、OpenDate が最大(=最新)のものを採用する。
通常のリクエストでは previewDate = 現在時刻。つまり「今の正解」が自動的に選ばれます。デプロイとは、新しい有効期間を持つレコードを1件追加するだけの操作になります。
Resolutionリクエストはこう解決される
台帳への問い合わせは、パスを固定し「OpenDate ≤ previewDate」で範囲検索するだけ。GSIを Path × OpenDate で張れば、1クエリで完結します。
// path と previewDate から「その時刻に有効な実体」を解決 const res = await ddb.query({ IndexName: "Path-OpenDate-index", KeyConditionExpression: "#path = :p AND OpenDate <= :t", FilterExpression: ":t < CloseDate", ExpressionAttributeValues: { ":p": rawPath, ":t": previewDate }, }); // 候補が複数あれば OpenDate 最大(=最新の有効版)を採用 const target = res.Items.sort((a, b) => b.OpenDate - a.OpenDate)[0].Target;
Timeline「時刻の窓」でバージョンを選ぶ
- デプロイ = レコード追加。未来日の OpenDate で1件足すだけ。既存版に触れない。
- 切替 = 時刻の経過。針が次の窓に入った瞬間、全リクエストが自動で新版を選ぶ。人手も停止も不要。
- 切り戻し = 日付の調整。新版の OpenDate を未来へ動かす/CloseDate を締めるだけ。旧版は生きたまま。
Preview「未来」を本番に影響なく確認する
最大の利点はプレビューです。previewDate に未来日を渡すだけで、まだ公開していない設定で画面や見積を確認できます。本番データは1バイトも変わりません——「未来の値」は台帳の中で有効期間を待っているだけだからです。「9/1のキャンペーン価格を8月中に営業と画面で確認する」が、本番リスクゼロで日常的に行えます。
PerformanceValkey で「その時刻の最新」を1発で引く
解決は毎リクエストで走るため、台帳照会の前段に Valkey のキャッシュを置き、パスごとに「OpenDate をスコアにした Sorted Set」で保持します。「previewDate 以前で最大の OpenDate を持つ版」は、逆順レンジ取得1コマンドで O(log N) で求まります。
// スコア=OpenDate。previewDate 以下で最大のメンバ(=最新の有効版)を1件取得 const hit = await valkey.sendCommand([ "ZREVRANGEBYSCORE", `route:${rawPath}`, String(previewDate), "-inf", "LIMIT", "0", "1", ]); // hit があれば即採用。なければ台帳へフォールバックし、結果をキャッシュ。
キャッシュはあくまで高速パスで、正は台帳。ミス時フォールバックとタイムアウト付き参照により、キャッシュ障害時も「遅くなるが止まらない」を担保します。
Trade-offs設計上の割り切り
- 台帳は増え続ける — 版が積み上がるため保持ポリシーが重要。逆に「過去のどの時点でも再現できる」監査性という利点にもなる。
- previewDate の伝播 — 入口で確定した基準時刻を下流の全処理へ一貫して引き回す規律が必要。
- キャッシュ整合 — 台帳更新時の無効化。更新チェックで必要時のみ再構築する。
「いつの時点の正解か」を previewDate という一級の概念に据えるだけで、デプロイ・切替・切り戻し・プレビューが、すべて「時間軸上のデータ操作」に還元される。無停止リリースは特別な仕組みではなく、データモデルの自然な帰結になる。