USJ 等待時間即時看板:資料管線設計與實作
詳解我們如何建立環球影城等待時間的即時資料管線,從資料源串接到前端即時更新的完整架構。
為什麼要做這個專案
去過大阪環球影城(USJ)的人都知道,排隊是無可避免的一環。雖然官方 App 有提供等待時間,但使用體驗並不理想——你必須點進每個設施才能看到時間,無法一目瞭然。而且在園區裡手機訊號不穩定的情況下,App 的載入速度更是令人抓狂。
我們想做的是一個簡潔、輕量的看板頁面,讓使用者可以在同一個畫面上快速掌握所有設施的等待狀況,方便規劃遊園路線。不需要安裝 App,打開瀏覽器就能使用,即使在網路狀況不佳的環境下也能快速載入。
目標使用者情境
設計之前,我們先釐清了幾個核心的使用者情境:
- 入園前規劃:提前查看當天的等待趨勢,決定要先玩哪些設施
- 園區內即時查看:排隊前快速比較各設施的等待時間,選擇等待較短的
- 歷史資料參考:查看過去同一天(例如上週六)的等待模式,預測今天的狀況
系統架構概覽
整個系統分為四層:
- 資料蒐集層:定期從資料源取得最新的等待時間
- 資料處理層:清洗、標準化並儲存時間序列資料
- API 層:提供 RESTful API 給前端查詢
- 前端展示層:即時顯示各設施的等待時間
[資料源] --每5分鐘--> [Python 蒐集程式]
|
[資料清洗 & 標準化]
|
[PostgreSQL 儲存]
|
[Next.js API Routes]
|
[React 前端即時看板]
資料蒐集
我們每 5 分鐘執行一次資料蒐集任務,將最新的等待時間寫入資料庫。蒐集程式用 Python 撰寫,部署在 Docker 容器中,由 cron job 觸發。
每次蒐集的資料包含:
- 設施名稱(日文 / 英文)
- 目前等待時間(分鐘)
- 設施運作狀態(營運中 / 暫停 / 維護中)
- 資料取得時間戳記
蒐集程式的可靠性設計
資料蒐集是整個系統的生命線,如果蒐集中斷,後面所有環節都會停擺。因此我們特別注重可靠性:
class WaitTimeCollector:
def __init__(self):
self.max_retries = 3
self.retry_delay = 10 # 秒
def collect(self) -> list[dict]:
"""蒐集所有設施的等待時間,含重試機制"""
for attempt in range(self.max_retries):
try:
data = self._fetch_wait_times()
validated = self._validate(data)
return validated
except RequestException as e:
logger.warning(f"蒐集失敗 (第 {attempt + 1} 次): {e}")
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay)
# 所有重試都失敗,發送告警
self._send_alert("資料蒐集連續失敗")
return []
def _validate(self, data: list[dict]) -> list[dict]:
"""過濾不合理的資料"""
return [
item for item in data
if 0 <= item['wait_minutes'] <= 400
and item['ride_name'].strip()
]
關鍵設計:
- 重試機制:網路請求失敗時自動重試 3 次,避免因暫時性錯誤導致資料缺漏
- 資料驗證:過濾掉不合理的數值(負數、超過 400 分鐘等),避免髒資料進入資料庫
- 告警通知:連續失敗時主動通知,讓我們能及時排查問題
資料處理與儲存
原始資料進來後,需要經過幾道處理:
設施名稱標準化
同一個設施可能有不同的名稱變體(例如「ハリー・ポッター・アンド・ザ・フォービドゥン・ジャーニー」、「Harry Potter and the Forbidden Journey」、「哈利波特禁忌之旅」)。我們建立了一個對照表,將所有變體映射到統一的識別碼:
RIDE_ALIASES = {
"harry-potter": [
"ハリー・ポッター",
"Harry Potter and the Forbidden Journey",
"哈利波特禁忌之旅",
],
"mario-kart": [
"マリオカート",
"Mario Kart: Koopa's Challenge",
"瑪利歐賽車",
],
# ...更多設施
}
def normalize_ride_name(raw_name: str) -> str | None:
"""將設施名稱映射到標準識別碼"""
for ride_id, aliases in RIDE_ALIASES.items():
if any(alias in raw_name for alias in aliases):
return ride_id
logger.warning(f"未知設施名稱: {raw_name}")
return None
異常值偵測
偶爾會出現不合理的等待時間(例如負數或超過 500 分鐘),這些需要被標記並過濾。除了基本的範圍檢查,我們還實作了基於統計的異常偵測——如果某筆資料偏離該設施歷史平均值超過 3 個標準差,會被標記為可疑資料。
時間序列儲存
我們使用 PostgreSQL 儲存歷史資料,每筆紀錄包含設施 ID、等待時間和時間戳記:
CREATE TABLE wait_times (
id SERIAL PRIMARY KEY,
ride_id VARCHAR(50) NOT NULL,
wait_minutes INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_wait_times_ride_recorded
ON wait_times (ride_id, recorded_at DESC);
索引設計上,(ride_id, recorded_at DESC) 的複合索引可以高效地查詢「某設施最近 N 筆紀錄」,這是前端最常用的查詢模式。
API 設計
API 提供兩個主要端點:
GET /api/rides/current:取得所有設施的最新等待時間GET /api/rides/:id/history?date=YYYY-MM-DD:取得特定設施的歷史資料
回傳格式設計得盡量精簡,減少傳輸量:
{
"rides": [
{
"id": "harry-potter",
"name": "哈利波特禁忌之旅",
"wait": 75,
"status": "operating",
"updatedAt": "2025-01-18T10:05:00+09:00"
}
],
"parkStatus": "open",
"lastSync": "2025-01-18T10:05:00+09:00"
}
快取策略
由於等待時間每 5 分鐘才更新一次,API 回應可以安全地加上短時間的快取:
current端點:Cache-Control 設定為 60 秒history端點:Cache-Control 設定為 5 分鐘(歷史資料不會改變)
這樣即使流量突然增加(例如假日早上大家同時查看),資料庫也不會承受過大壓力。
前端即時更新
前端使用 Next.js 開發,頁面載入時先透過 Server Components 取得初始資料(避免白畫面),之後在客戶端透過輪詢機制每 60 秒更新一次。
為什麼用輪詢而不是 WebSocket?
這是一個常見的技術選型問題。我們選擇輪詢的原因:
- 更新頻率低:等待時間每 5 分鐘才變一次,60 秒的輪詢間隔已經綽綽有餘
- 連線成本:WebSocket 需要維持持久連線,對伺服器的記憶體消耗較大
- 簡單可靠:輪詢的實作和除錯都比 WebSocket 簡單,出問題時更容易定位原因
- 離線容錯:輪詢在斷線後會自動在下一個週期重新請求,不需要額外的重連邏輯
使用者體驗細節
- 色彩編碼:等待時間以顏色區分——綠色(< 30 分鐘)、黃色(30-60 分鐘)、紅色(> 60 分鐘)
- 排序功能:可以按等待時間、設施名稱排序
- 狀態標示:暫停營運的設施會灰色顯示,並標註原因
- 最後更新時間:頁面底部顯示資料的最後同步時間,讓使用者知道資料的新鮮度
資料洞察
累積了一段時間的歷史資料後,我們發現了一些有趣的模式:
- 開園後第一個小時是等待時間最短的黃金時段,大部分設施的等待時間不超過 20 分鐘
- 下午 1 點到 3 點是等待高峰,熱門設施平均要等 90 分鐘以上
- 平日與假日的差異巨大:假日的平均等待時間是平日的 2-3 倍
- 季節性活動期間(如萬聖節、聖誕節)整體等待時間會上升 30-50%
- 新設施效應:新開幕的設施在前三個月的等待時間通常是其他設施的 2-4 倍
這些洞察也被整合到前端,以圖表的方式呈現給使用者參考,幫助他們更聰明地規劃遊園行程。
維運經驗
監控
我們設定了幾個關鍵指標的告警:
- 資料蒐集連續失敗超過 3 次
- API 回應時間超過 2 秒
- 資料新鮮度超過 15 分鐘(表示蒐集可能中斷)
- 資料庫儲存空間使用率超過 80%
資料庫管理
時間序列資料會持續增長,我們設定了自動清理機制,只保留最近 90 天的詳細資料(每 5 分鐘一筆)。更早的資料會被聚合為每小時的平均值,大幅節省儲存空間,同時保留長期趨勢分析的能力。
-- 聚合超過 90 天的資料為每小時平均
INSERT INTO wait_times_hourly (ride_id, avg_wait, max_wait, min_wait, hour)
SELECT
ride_id,
AVG(wait_minutes),
MAX(wait_minutes),
MIN(wait_minutes),
date_trunc('hour', recorded_at)
FROM wait_times
WHERE recorded_at < NOW() - INTERVAL '90 days'
GROUP BY ride_id, date_trunc('hour', recorded_at);
成本控制
整個系統的月費用控制在很低的範圍:
- 伺服器:使用 Docker 容器部署在雲端,以最小規格運行
- 資料庫:PostgreSQL 使用量不大,基本免費額度就足夠
- 無外部 API 費用:資料蒐集不依賴付費 API
結語
這個專案雖然功能看似簡單——就是顯示等待時間——但背後涉及的資料工程環節一點都不少。從穩定的資料蒐集、即時的處理管線,到高效的前端呈現,每一個環節都需要精心設計。
對我們來說,這也是一次很好的實踐:用資料工程的思維,解決日常生活中的小痛點。如果你也有類似的資料專案想法,歡迎透過我們的聯絡頁面交流討論。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。