← 返回部落格
·12 min 閱讀·專案紀錄

USJ 等待時間即時看板:資料管線設計與實作

詳解我們如何建立環球影城等待時間的即時資料管線,從資料源串接到前端即時更新的完整架構。

資料工程ETL即時資料專案紀錄
GP Wang
GP Wang
GWP4 STUDIO 創辦人 · 軟體 / 資料工程師

為什麼要做這個專案

去過大阪環球影城(USJ)的人都知道,排隊是無可避免的一環。雖然官方 App 有提供等待時間,但使用體驗並不理想——你必須點進每個設施才能看到時間,無法一目瞭然。而且在園區裡手機訊號不穩定的情況下,App 的載入速度更是令人抓狂。

我們想做的是一個簡潔、輕量的看板頁面,讓使用者可以在同一個畫面上快速掌握所有設施的等待狀況,方便規劃遊園路線。不需要安裝 App,打開瀏覽器就能使用,即使在網路狀況不佳的環境下也能快速載入。

目標使用者情境

設計之前,我們先釐清了幾個核心的使用者情境:

  • 入園前規劃:提前查看當天的等待趨勢,決定要先玩哪些設施
  • 園區內即時查看:排隊前快速比較各設施的等待時間,選擇等待較短的
  • 歷史資料參考:查看過去同一天(例如上週六)的等待模式,預測今天的狀況

系統架構概覽

整個系統分為四層:

  1. 資料蒐集層:定期從資料源取得最新的等待時間
  2. 資料處理層:清洗、標準化並儲存時間序列資料
  3. API 層:提供 RESTful API 給前端查詢
  4. 前端展示層:即時顯示各設施的等待時間
[資料源] --每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

結語

這個專案雖然功能看似簡單——就是顯示等待時間——但背後涉及的資料工程環節一點都不少。從穩定的資料蒐集、即時的處理管線,到高效的前端呈現,每一個環節都需要精心設計。

對我們來說,這也是一次很好的實踐:用資料工程的思維,解決日常生活中的小痛點。如果你也有類似的資料專案想法,歡迎透過我們的聯絡頁面交流討論。

關於作者

GP Wang
GP Wang

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

了解更多 →