
表面上は「日付と人数を選び、決済する」だけの処理に見える。しかし実装側から見ると、これは単純な商品販売ではない。価格、入場枠、人数区分、在庫確定という複数の状態が、同じ予約画面の上で別々に変化するチケットシステムだ。

リスボン海洋水族館
- ガイドツアー(舞台裏見学付き)combo ticketfrom30 €
- 入場チケット(午前 10:00〜11:30)official ticketfrom29 €
Prices checked on 2026年8月21日 · current price on the partner page.
Official website — oceanario.pt
- Time to spend: 2–3 h
- Best time to visit: early morning
Prices are indicative and may change — confirm the price and availability on the partner page.
Partner link — GetYourGuide ticketsリスボン海洋水族館は、1998年の万博パビリオンを起点とする施設として知られ、500種、8,000匹の展示生物や、500万リットル規模の中央水槽が特徴になっている。もちろん、公開されているチケット情報だけから実在サイトのインフラ構成を断定することはできない。ここで扱うのは、そうした販売モデルを自分で再現するとしたら、Docker環境とRedisをどう組み合わせるかというインフラ構成シミュレーションである。
個人開発の予約システムでは、最初から大規模な分散構成にする必要はない。むしろ問題になるのは、必要以上に複雑な基盤を作ることではなく、価格と在庫をどこまでキャッシュし、どの時点でデータベースを最終判定に戻すかだ。
リスボン海洋水族館のチケット販売モデルとシステム要件
価格、年齢区分、時間枠を分けて考える
このチケットモデルを実装する場合、最初にやるべきことは、画面に見えている項目をそのまま一つの「商品」として扱わないことだ。
予約に関わる状態は、少なくとも次のように分解できる。
- 入場日。営業日かどうか、販売対象日かどうかを判定する。
- 入場時間枠。時間帯ごとの販売単位と最終入場時刻を持つ。
- 年齢区分。大人、子ども、シニア、乳幼児などを別のティアとして扱う。
- 価格。入場日と時間枠、年齢区分の組み合わせから決まる。
- 在庫。時間枠ごとに残数を持つ。人数区分ではなく、入場人数全体で消費する設計が基本になる。
- 仮予約。決済が完了するまで、他の利用者に同じ枠を売らないための一時状態。
- 確定予約。決済結果と結び付いた、後から変更してはいけない記録。
この分解を怠ると、価格キャッシュの更新で在庫まで消したり、在庫の更新に引きずられて料金表示が不安定になったりする。Laravelのモデルを一つ増やすだけで済む話ではない。データの寿命と正しさの条件がそれぞれ違うからだ。
例えば、料金は営業日内に頻繁に変わらないかもしれない。一方で、在庫は予約が一件入るたびに変化する。セッションは利用者の操作中だけ必要で、決済の成否が確定したら役割を終える。この三つを同じキャッシュ設定、同じ有効期限、同じ無効化ルールで管理するのは危険である。
在庫は商品数ではなく時間枠の容量
通常の電子商取引では、商品に対して在庫数を設定する。売れたら一つ減らし、キャンセルされたら一つ戻す。チケット予約でも見た目は似ているが、実際には時間枠という制約が加わる。
午前の入場枠が埋まっていても、午後の枠には空きがあるかもしれない。逆に、同じ日付のチケットだからといって、日単位の残数だけを返すことはできない。利用者は日付だけでなく、どの時間帯に入場できるかを知りたいからだ。
したがって在庫の基本単位は、次のようになる。
入場日 + 時間枠
年齢区分は価格計算には必要だが、施設側の入場枠を消費するという意味では別の軸である。大人一人と子ども一人を購入した場合、在庫を二つ消費する。年齢区分ごとに別在庫を持つ要件がなければ、在庫キーへ年齢ティアを含めるべきではない。
この切り分けは、Redisのキー数を抑えるためだけの工夫ではない。キーの責任範囲を狭くすることで、在庫更新と価格更新を別々に検証できる。予約システムでは、扱う情報を減らすことよりも、一つの情報が何を意味するのかを曖昧にしないことの方が重要だ。
予約の状態遷移を先に決める
チケットシステム構築で見落とされやすいのが、決済中の状態である。利用者が購入ボタンを押した瞬間に確定予約へ進むとは限らない。外部決済サービスの応答を待つ時間があり、途中でブラウザを閉じる人もいる。決済は成功しているのに、アプリ側の応答だけが失われるケースも考えなければならない。
最低限、予約には次のような状態を用意しておきたい。
| 状態 | 意味 | 在庫の扱い |
|---|---|---|
| 仮確保 | 利用者が購入処理を開始した状態 | 一時的に在庫を押さえる |
| 決済待ち | 外部決済の結果を待っている状態 | 仮確保を維持する |
| 確定 | 決済成功を確認した状態 | 確定済みの販売数として記録する |
| 失敗 | 決済失敗、または処理エラーの状態 | 在庫を戻す |
| 期限切れ | 一定時間内に決済が完了しなかった状態 | 在庫を戻す |
| 取消 | 確定後にキャンセルされた状態 | 返金条件に応じて扱う |
ここで大切なのは、Redisの仮データだけを予約の根拠にしないことだ。Redisは高速な排他や一時状態に向いているが、最終的な販売履歴を置く場所ではない。確定予約はデータベースに保存し、Redisに残っている仮確保が消えても、売れた事実を復元できるようにする。
Redisは予約の最終記録ではなく、競合を短時間でさばくための層として使う。
時間帯別価格設定と在庫管理におけるRedisキャッシュの役割
キーの粒度を混ぜない
Redisキャッシュ設計で最初に決めるべきなのは、キーの名前よりも、そのキーが何を表すかである。
例えば、価格を次のように一つへ押し込めるとする。
ticket:{date}
この形は取得自体は簡単だが、時間枠や年齢区分が増えたときに、どの変更で無効化すべきかが分かりにくくなる。一方で、すべての条件をキーへ含めて、
ticket:{date}:{slot_id}:{age_tier}:{quantity}
のようにすると、組み合わせが増えすぎる。人数までキーへ含める必要があるのか、年齢区分別の価格表を返せば十分なのかを考えずに分割すると、キー数だけが膨らみ、更新処理も複雑になる。
個人開発の予約基盤なら、価格と在庫を次のように分けるのが扱いやすい。
- 価格表は、入場日と年齢区分を中心にまとめる。
- 時間枠が価格に影響する場合は、価格表の中に時間枠別の値を持たせる。
- 在庫は、入場日と時間枠をキーにして残数を持つ。
- 仮予約のロックは、予約単位または時間枠単位で別管理する。
- 画面表示用の集約データと、購入確定用の判定データを同一視しない。
この設計なら、料金表示を更新しても在庫キーへ影響しない。反対に、在庫が一つ減ったからといって価格表を作り直す必要もない。
価格キャッシュは長く、在庫キャッシュは短く
料金表は比較的長くキャッシュできる。営業日やキャンペーンの設定が頻繁に変わらないなら、日単位で再生成する構成でも十分に運用できる。ただし、長い有効期限を設定しただけで安全になるわけではない。料金変更を管理画面から行えるなら、変更処理の中で対象キーを明示的に削除する必要がある。
価格キャッシュに向いている考え方は、次のようなものだ。
- 通常の料金表は、営業日や販売期間を単位にまとめる。
- 管理画面で料金を変更したら、該当日の価格キーだけを無効化する。
- キャンペーンや特別料金がある場合は、通常料金とは別の設定として持つ。
- 表示用のキャッシュが古くても、購入確定時にはデータベースの最新設定を確認する。
- 料金のバージョンを予約記録へ保存し、後から価格の根拠を追えるようにする。
在庫は逆に、長時間のキャッシュに向かない。残数は予約のたびに変化するため、画面表示と購入時の判定を分ける必要がある。画面上の「残りわずか」という表示は、あくまで参考情報だ。購入ボタンを押した時点で、Redisの原子的な処理とデータベースの制約によって、もう一度在庫を確認する。
ドラフトでは在庫キャッシュに5〜15分程度の短い有効期限を設定する案を示していたが、ここも用途を分けて考えたい。残数の表示だけなら短いTTLのキャッシュで済む場合がある。しかし、購入処理に使う残数を単純なTTLキャッシュへ任せると、期限切れや再構築のタイミングで競合が起きる。購入用の在庫カウンターは、TTLだけで正しさを担保するのではなく、原子的な減算と復元処理を組み合わせるべきだ。
在庫減算は確認と更新を分離しない
最も危険なのは、次のような処理である。
1. Redisから在庫を読む。
2. 残数が購入人数以上か確認する。
3. 問題なければ在庫を減らす。
4. 予約を作成する。
この処理を複数のリクエストが同時に実行すると、同じ残数を二つのリクエストが読めてしまう。確認と減算の間に別のリクエストが入り込むためだ。
Redisを使うなら、確認と減算を一つの原子的な処理にまとめる。Luaスクリプトを利用すれば、残数を読み、必要数を満たしている場合だけ減算し、結果を返す処理をRedis側で完了できる。LaravelからはRedisの評価機能を呼び出し、アプリケーション側では成功、在庫不足、キー不存在などの結果を分けて扱う。
ただし、Redisで減算できたことと、予約が確定したことは同じではない。Redisの減算後にデータベースへの書き込みが失敗したら、在庫を戻す補償処理が必要になる。補償処理が失敗する可能性もあるため、未処理の仮予約を定期的に調べるジョブを用意しておくと、運用上の回復がしやすい。
ロックは万能ではない
予約処理では、SETNXを使った簡易ロックが紹介されることが多い。しかし、キーを確保した直後に有効期限を設定する二段階の処理には、プロセス停止の隙間がある。キーだけが残れば、後続の処理が同じ予約を進められなくなる。
ロックを使う場合は、取得と有効期限の設定を一つの操作として扱う。Redisの標準機能で有効期限付きのキーを作成し、解放時には自分が取得したロックかどうかを確認してから削除する。所有者を識別する値を持たせずに、処理終了時に無条件でキーを削除する実装も危険である。別の処理が新しくロックを取得した後に、古い処理がそのキーを消してしまう可能性があるからだ。
個人開発の規模であれば、いきなり複雑な分散ロックへ進む必要はない。まずは、ロックの目的を「同じ予約操作の重複防止」に限定する。施設全体の在庫を一つの巨大なロックで囲む設計は避け、できるだけ時間枠や予約単位へ狭める。広いロックは実装が簡単に見えるが、同時購入をすべて直列化してしまい、負荷試験で待ち時間が目立つ。
Docker Composeを用いた予約システムのインフラ構成シミュレーション
最小構成は役割を分けておく
Docker Composeで予約システムを再現するなら、まずアプリケーション、Redis、データベース、リバースプロキシの四つに分ける。個人開発の検証環境としては、これ以上のサービスを最初から増やさない方がよい。
| サービス | 主な役割 | 設計上の注意点 |
|---|---|---|
| アプリケーション | 予約API、価格計算、管理処理 | セッション、キャッシュ、キューの接続先を分けて管理する |
| Redis | キャッシュ、仮予約、キュー | 用途別のキー接頭辞とメモリ上限を設定する |
| MySQL | 予約履歴、価格設定、確定在庫 | 確定処理と一意制約をデータベース側で担保する |
| nginx | リバースプロキシ、静的ファイル配信 | 圧縮とキャッシュヘッダーを適切に分ける |
LaravelのセッションをRedisへ置く構成は、アプリケーションの台数を増やすときに扱いやすい。どのコンテナへリクエストが振り分けられても、セッションを共有できるからだ。ただし、Redisをキャッシュ、セッション、キューのすべてに使う場合は、キーの名前空間を必ず分ける。
例えば、アプリケーションのキャッシュ、セッション、キューが同じデータベース番号や同じ命名規則を使っていると、削除処理の影響範囲を誤りやすい。キャッシュ全削除のつもりでセッションまで消してしまえば、ログイン状態が一斉に失われる。用途ごとの接頭辞、Redisデータベースの分離、削除コマンドの制限を組み合わせておきたい。
起動順と準備完了は別の問題
Composeのdepends_onは、コンテナの起動順を整理するための機能であり、アプリケーションがデータベースを利用できる状態まで保証するものではない。RedisやMySQLのプロセスが起動していても、接続受付や初期化が完了していないことがある。
そのため、Redisには応答確認、MySQLには接続確認のヘルスチェックを用意する。アプリケーション側でも、起動直後に必要なマイグレーションや設定読み込みが失敗した場合に、ただエラーを返し続けるのではなく、ログから原因を追えるようにしておく。
ここでありがちな失敗は、アプリケーションのコンテナだけを再起動して問題が解決したように見せることだ。実際には、Redisの接続が切れていたり、データベースの初期化処理が途中で止まっていたりする。検証環境でも本番に近い障害を起こし、どのログを見れば復旧できるかを確認しておきたい。
永続化するデータと捨ててよいデータ
Redisのデータをすべて永続化すべきだとは限らない。価格表の読み取りキャッシュは、失われてもデータベースから再構築できる。画面表示用の残数キャッシュも、正しい元データがあれば作り直せる。
一方、キューの途中状態や仮予約の扱いは、失われた場合の影響を決めておく必要がある。Redisを再起動したときに仮予約が消えるなら、データベース側に「決済待ち」や「期限切れ候補」の記録が残っているかを確認し、在庫を戻す処理へつなげなければならない。
Dockerのボリュームを使えば、Redisのファイルを再起動後も引き継げる。しかし、開発中にボリュームごと削除する操作を行えば、当然ながらキャッシュも消える。キャッシュが消えること自体は異常ではない。問題は、消えたときにアプリケーションがデータベースへ集中アクセスし、復旧途中のシステムが別の障害を起こすことだ。
したがって、Redisの永続化設定だけを見るのではなく、キャッシュ全消失を前提に次を確認する。
- キャッシュミス時にデータベースへの同時問い合わせを抑えられるか。
- 価格表を安全に再生成できるか。
- 在庫カウンターをどのデータから再構築するか。
- 仮予約の期限切れを検出できるか。
- Redis停止中に購入処理を止めるのか、読み取りだけ許可するのか。
個人開発でも、ここを決めないまま「Redisが落ちたら再起動」とだけ考えると、復旧後に表示と実在庫が食い違う。
高負荷時のボトルネック特定とクエリ最適化の検証
負荷テストは予約画面だけを叩かない
docker 負荷テストを行うとき、予約画面へ一種類のリクエストを大量に送るだけでは、実際の詰まり方は見えにくい。予約システムでは、読み取りと書き込みの比率が大きく違うからだ。
少なくとも次のシナリオを分けて用意する。
1. 入場日の候補と時間枠を閲覧する。
2. 年齢区分を選び、価格表を取得する。
3. 同じ時間枠を複数の利用者が同時に選ぶ。
4. 仮予約を作成する。
5. 決済結果を受けて予約を確定する。
6. 決済失敗やタイムアウトで在庫を戻す。
7. 管理画面から料金や在庫設定を更新する。
このうち、読み取り処理が多いからといって、読み取りだけを最適化すればよいわけではない。書き込み側のロック待ちが長くなれば、予約API全体の応答が遅くなる。逆に、価格表のキャッシュが効いていても、在庫確定処理が一つの行へ集中していれば、データベース側がボトルネックになる。
時間枠の切り替えを重点的に見る
時間枠の境界では、キャッシュの有効期限、画面表示、販売締切、サーバー時刻の扱いが同時に変化する。通常のアクセスでは問題がなくても、境界の直前と直後にアクセスが集中すると、キャッシュミスと在庫確認が重なる。
ここで確認する指標は、単純な平均応答時間では足りない。
- p50、p95、p99の応答時間。
- Redisのヒット率とミス率。
- キャッシュ再生成にかかった時間。
- MySQLのクエリ数と遅いクエリの割合。
- データベースの接続待ち。
- 行ロックやトランザクションの待機時間。
- キューに積まれたジョブの数。
- 仮予約から確定、失敗、期限切れへ進む件数。
- 在庫カウンターと確定予約数の差分。
特に見るべきなのは、平均値ではなく分布である。大半のリクエストが速くても、一部の購入処理だけが長時間待たされていれば、利用者からは「決済が進まないシステム」に見える。
キャッシュの有効期限が同じ時刻に大量に切れる構成も避けたい。価格表や画面表示データに少しずつ揺らぎを加えれば、すべてのキーが同時に失効する事態を緩和できる。重要なのはTTLを長くすることではなく、再構築の集中を分散することだ。
キャッシュの先読みと同時更新の抑制
時間枠が切り替わることを事前に分かっているなら、次の枠のデータをあらかじめ準備できる。Laravelのスケジューラーやキューを使い、販売開始前に価格表や在庫表示用のデータを生成しておく。
ただし、先読みは在庫を未来の状態に固定することではない。予約が入り続ける時間枠であれば、先読みした残数はすぐに古くなる。先読みの対象は、価格や販売可否など比較的変化の少ない情報に限定し、残数の確定判定は購入時に行うのが安全だ。
同じキーへ多数のリクエストが集まったときは、すべてのリクエストが同時にデータベースを参照してキャッシュを作り直す「キャッシュ雪崩」が起こり得る。そこで、更新処理を一つに集約する仕組みを入れる。最初の一件だけが再生成を行い、他のリクエストは短時間待つか、少し古い値を返す設計にする。
価格表示では、短時間だけ古い値を許容できる場合がある。しかし、購入確定に古い価格を使うことは許容できない。表示用のデータと確定用のデータで許容範囲を変えることが、キャッシュ設計では重要になる。
データベース側の最終防衛線
Redisだけで二重販売を完全に防ぐのは難しい。ネットワーク障害、プロセス停止、再起動、補償処理の失敗など、アプリケーションの外側で起きる問題があるからだ。
最終的な予約確定では、データベースのトランザクションと一意制約を使う。例えば、同じ予約番号や同じ外部決済識別子を二重登録できないようにし、決済通知が再送されても同じ予約を二度確定しないようにする。
在庫を「残数」という一つの数字だけで管理する方法にも限界がある。販売履歴や仮予約履歴を別に残し、次のような整合性を確認できるようにすると、障害時の調査がしやすい。
- 確定済み人数の合計。
- 有効な仮予約人数の合計。
- 時間枠の販売上限。
- Redis上の表示残数。
- 期限切れ処理の未完了件数。
- 決済通知を受け取ったが確定処理が終わっていない件数。
この情報を常に画面へ出す必要はない。ただし、管理者が確認できるログや監視項目として用意しておく。予約システムは、正常時の速さだけでなく、失敗した後に状態を説明できることが価値になる。
負荷テストの目的は、速い構成を作ることではない。どの状態をRedisに任せ、どの状態をデータベースへ戻すかを確認することだ。
個人開発におけるスケーラブルなチケット予約基盤の設計指針
最初から分散させない
個人開発でよくある誤解は、将来のアクセス増加を心配して、最初からサービスを細かく分けることだ。予約API、価格計算、在庫管理、決済連携を別サービスに分ければ、理論上は拡張しやすくなる。しかし、サービス間通信、再試行、障害時の整合性、ログの追跡が増える。
まずはLaravel、MySQL、Redis、nginxをDocker Composeで構成し、役割を明確にする。アプリケーションの内部で価格計算、在庫仮確保、予約確定の境界を分ければ、後から処理を切り出す余地は残せる。
初期構成で重視したいのは、次の三点だ。
- 読み取りの多い価格表や営業日情報をRedisへ逃がす。
- 在庫の減算と仮予約の期限管理を原子的に扱う。
- 確定予約と決済結果をMySQLへ残し、一意制約で二重確定を防ぐ。
これだけでも、予約システムとして必要な骨格はできる。最初から複数のRedisクラスタや専用ワーカー群を組むより、失敗時の戻し方と観測方法を決める方が効果は大きい。
Redisを三つの用途で使うときの境界
Redisをキャッシュ、セッション、キューに使う構成は、個人開発では現実的である。管理対象を増やさず、高速な共有ストレージを一つにまとめられるからだ。ただし、三つの用途は障害時の意味が違う。
- キャッシュが消えても、元データから再生成できる。
- セッションが消えると、利用者がログアウトされた状態になる。
- キューが消えると、決済通知や在庫復元の処理が失われる可能性がある。
この違いを無視して、メモリ上限に達したときの削除ポリシーを一つで済ませると事故が起こる。価格キャッシュを削除するのは許容できても、処理中の決済ジョブや仮予約情報を優先的に削除してよいとは限らない。
用途ごとにRedisの領域を分ける、重要なジョブを別の保存先へ置く、キューの処理結果をデータベースへ記録するなど、規模に応じた境界を作る必要がある。難しい機能を増やすより、「このデータは消えても復元できるか」を一つずつ確認する方が、設計の判断材料になる。
監視はCPU使用率だけでは足りない
Docker環境の負荷を見るとき、CPUやメモリの使用率だけでは予約システムの異常を見逃す。CPUが余っていても、データベースの接続数やRedisの待ち時間が限界に達していることがある。
最低限、次のログとメトリクスを追えるようにしておきたい。
- リクエストごとの処理時間とHTTPステータス。
- 時間枠、予約番号、決済識別子を追跡できる相関ID。
- Redisのコマンド遅延、接続エラー、メモリ使用量。
- MySQLの遅いクエリ、ロック待ち、接続数。
- 仮予約の作成、延長、確定、解放の件数。
- 在庫不足とシステムエラーの件数。
- 決済通知の重複や再送の件数。
ログに個人情報や決済情報をそのまま出すのは避ける。予約を追跡するための識別子と、利用者を特定する情報は分離する。検証環境で負荷をかける場合も、本番データをそのままコピーして使わない方がよい。
境界を決めることがスケールにつながる
この構成で一番難しいのは、Redisの設定値でもDockerのサービス数でもない。どの情報を近道させ、どの情報を遠回りさせないかという境界の決定である。
価格表はキャッシュしてよい。営業日の一覧もキャッシュできる。時間枠の表示用残数も、多少の遅延を許容するならキャッシュできる。しかし、購入確定の瞬間に「まだ売れるか」を判断する処理まで、表示用キャッシュだけへ任せてはいけない。
同じように、Redisで在庫を減らせても、確定予約の記録を省略してよいわけではない。アプリケーションが正常に動いている間はRedisが速く見えるが、障害から戻るときに必要なのは、誰に、いつ、どの時間枠を販売したかという履歴である。
リスボン海洋水族館のチケットを題材にDockerとRedisを組み合わせると、予約システムの難しさが見えやすい。時間枠別価格、年齢ティア、最終入場時刻、仮予約、決済確定は、それぞれ異なる有効期限と整合性を持つ。これを一つの巨大なキャッシュや一つのテーブルへ押し込むと、最初は動いても負荷がかかった瞬間に境界が露出する。
個人開発者に必要なのは、複雑さをすべて消すことではない。価格、表示、仮確保、確定予約の責任範囲を分け、失敗したときにどこから復元するかを決めることだ。
コードで防ぐべきなのは、確認と更新の競合、同じ決済通知の二重処理、期限切れた仮予約の放置といった、設計で予測できる問題である。一方、Redisの再起動や外部決済の遅延、想定外のアクセス集中は、監視と復旧手順で受け止める。
この線引きができていれば、LaravelとMySQL、Redis、Docker Composeという小さな構成でも、チケットシステムとして十分に伸びしろを持たせられる。スケーラブルな基盤とは、最初から大きな構成を選ぶことではない。変化する部分と、最後まで正確でなければならない部分を見分けて、必要な場所だけを強くする設計である。
Related reading: ツリートップス・アドベンチャー・キャンベラ チケットのオンライン購入手順と現地での注意点 and Docker・インフラ構築をわかりやすく解説.
Practical info
Currency: EUR
Driving: on the right
Emergency number: 112
Key facts
Built: 1998
Architect: ピーター・シャーメイエフ
Heritage status: ポルトガル国定文化財IIM
Official website: oceanario.pt
よくある質問
チケット予約システムでRedisをどのように活用すべきですか?
在庫管理においてRedisのキャッシュをどのように扱うべきですか?
予約システムで二重販売を防ぐにはどうすればよいですか?
Docker環境でRedisを運用する際の注意点は何ですか?
予約システムでロック処理を実装する際のポイントは?
Photo: Alvesgaspar / CC BY-SA 3.0 — Wikimedia Commons