休市提醒日股將於 2026-11-03 休市(文化日),距今天還有 5 天。全部公告
文章

WebSocket 重連重複行情:訂閱狀態管理三陷阱

重連產生重複數據是正常的,去重才是應用層的責任。本文拆解三個訂閱狀態陷阱——把連接成功當訂閱成功、重連不清狀態、心跳與訂閱超時混用,並給出可用的重連狀態機與 Python 實現。

長連接斷線重連是行情接入裡最容易被低估的一段代碼。多數團隊會認真實現「斷了就重連」,卻很少處理重連之後的狀態一致性——結果就是下游收到重複的 tick,指標被重複累加,告警被重複觸發,而排查時又很難復現。

這篇文章拆解三個最常見的訂閱狀態陷阱,並給出一個可用的重連狀態機。

為什麼重連一定會產生重複

要先接受一個前提:重連產生重複是正常的,去重才是應用層的責任。

原因在於 WebSocket 的語義。斷線發生時,服務端可能已經推送了若干幀但你沒收到;重連後重新訂閱,服務端會從當前時點繼續推。這兩段區間之間沒有「接著上次繼續」的機制,所以邊界處必然存在重疊。

好消息是 TIQEX 的 tick 幀帶有 at 字段(RFC3339 時間戳),這讓去重有據可依——用 (symbol, at) 作為冪等鍵即可。

陷阱一:把「連接成功」當成「訂閱成功」

這是最常見的錯誤。代碼大致長這樣:

Python
async with websockets.connect(URL, additional_headers=HEADERS) as ws:
    await ws.send(json.dumps({"action": "subscribe", "market": "crypto", "symbols": SYMBOLS}))
    print("已訂閱")          # 錯:這只是「已發送」,不是「已訂閱」
    async for raw in ws:
        handle(raw)

問題在於:send 只保證消息進了本地發送緩衝區。訂閱是否被服務端接受,要看後續的響應幀。TIQEX 會返回一條 type=subscribed 的確認幀,攜帶 market 與 symbols;如果訂閱失敗,則會收到 type=error 幀。

正確做法是等到確認幀再認為訂閱生效。否則在訂閱被拒(例如 MARKET_NOT_ENABLED 或 NO_SUBSCRIPTION)的情況下,代碼會安靜地卡在 async for 上,既不報錯也不推數據,排查起來非常費時。

陷阱二:重連後不清理本地狀態

重連後,很多實現會保留上一次連接累積的狀態:去重緩存、聚合窗口、告警去抖計時器。這些狀態在重連邊界上已經失效了。

具體表現是:

  • 去重緩存不清:如果緩存按「最近 N 條」設計,重連後新數據的鍵可能與緩存裡的舊鍵衝突,導致新 tick 被誤判為重複而丟棄——這比重複更糟,因為它是靜默丟數據。
  • 聚合窗口不清:重連前未完成的分鐘線聚合會與重連後的數據拼接,產生錯誤的 OHLC。
  • 去抖計時器不清:跨越重連的告警計時會異常觸發或漏觸發。

原則很簡單:重連後清空所有「與連接生命週期綁定」的狀態,只保留業務層面的持久狀態。 去重緩存應該帶時間窗(比如只保留最近 5 秒的鍵),這樣重連後自然過期,不需要額外清理邏輯。

陷阱三:心跳與訂閱混在同一個超時裡

心跳超時(多久沒收到任何幀就判定斷線)和訂閱超時(發出 subscribe 後多久沒收到確認幀就判定失敗)是兩件事,混用會導致兩種誤判:

  • 用較長的訂閱超時做心跳:真實斷線要等很久才被發現,期間數據靜默丟失。
  • 用較短的心跳超時做訂閱等待:在冷門市場或低波動時段,可能長時間沒有 tick,於是被誤判為訂閱失敗並反覆重連——而重連握手是計入額度的,這會白白消耗配額。

正確做法是分開兩個計時器:訂閱確認用較短的固定超時(比如 10 秒),心跳用較長的靜默超時(比如 60 秒),並且優先用協議層的 ping/pong 而不是靠數據幀間隔判斷存活。

一個可用的重連狀態機

把上面的原則落到狀態上,重連邏輯就清晰了:

狀態進入條件退出動作
CONNECTING發起連接握手成功 → SUBSCRIBING
SUBSCRIBING已發送 subscribe收到 subscribed → LIVE;超時或 error → BACKOFF
LIVE訂閱已確認斷開或心跳超時 → BACKOFF
BACKOFF等待重連退避結束 → CONNECTING(並清空連接級狀態)

關鍵點是 BACKOFF 狀態承擔了「清理」職責:所有與連接綁定的狀態都在這裡重置,避免重連後帶著過期狀態運行。

Python 實現

Python
import asyncio, json, os, time
from collections import deque
import websockets

URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]
SYMBOLS = ["BTCUSDT", "ETHUSDT", "AAPL"]

SUBSCRIBE_TIMEOUT = 10      # 等待 subscribed 確認幀
IDLE_TIMEOUT = 60           # 靜默超時(心跳)
DEDUP_WINDOW_SEC = 5        # 去重窗口

seen = {}                   # (symbol, at) -> 首次見到的時間


def is_duplicate(tick: dict) -> bool:
    """用 (symbol, at) 作為冪等鍵,配合滑動時間窗。"""
    key = (tick.get("symbol"), tick.get("at"))
    now = time.time()
    # 清理過期鍵,重連後無需額外清理邏輯
    for k, ts in list(seen.items()):
        if now - ts > DEDUP_WINDOW_SEC:
            del seen[k]
    if key in seen:
        return True
    seen[key] = now
    return False


async def run_once():
    async with websockets.connect(
        URL, additional_headers={"X-API-Key": API_KEY}, open_timeout=10,
    ) as ws:
        await ws.send(json.dumps({
            "action": "subscribe", "market": "crypto", "symbols": SYMBOLS,
        }))

        # 階段一:等訂閱確認,而不是假設訂閱成功
        deadline = time.monotonic() + SUBSCRIBE_TIMEOUT
        while True:
            raw = await asyncio.wait_for(ws.recv(), timeout=SUBSCRIBE_TIMEOUT)
            frame = json.loads(raw)
            data = frame.get("data") or {}
            if not frame.get("succ"):
                raise RuntimeError(f"訂閱失敗 code={data.get('code')} msg={frame.get('msg')}")
            if data.get("type") == "subscribed":
                print("訂閱已確認:", data.get("symbols"))
                break
            if time.monotonic() > deadline:
                raise TimeoutError("等待訂閱確認幀超時")

        # 階段二:處理數據幀,帶去重與靜默超時
        while True:
            raw = await asyncio.wait_for(ws.recv(), timeout=IDLE_TIMEOUT)
            frame = json.loads(raw)
            data = frame.get("data") or {}
            kind = data.get("type")
            if kind == "tick":
                tick = data["tick"]
                if not is_duplicate(tick):
                    print(tick["symbol"], tick["last"], tick["at"])
            elif kind == "error":
                print("流內錯誤:", data.get("code"), frame.get("msg"))


async def main():
    backoff = 1.0
    while True:
        try:
            await run_once()
            backoff = 1.0                    # 正常結束則重置退避
        except Exception as exc:             # noqa: BLE001
            print(f"連接中斷: {exc}")
            seen.clear()                     # 連接級狀態在此清理
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 60)   # 指數退避,上限 60s
            print(f"{backoff:.0f}s 後重連")


asyncio.run(main())

這段代碼對應了前面三個陷阱的處理:等確認幀、去重帶時間窗、訂閱超時與靜默超時分離。重連退避上限設為 60 秒,避免在服務端故障時用重連握手持續消耗額度。

如何驗證沒有重複

改完之後需要可觀測的驗證手段,而不是靠肉眼看日誌:

  1. 統計唯一鍵比例:記錄收到的總幀數與去重後的唯一 (symbol, at) 數量,比值應接近 1;如果顯著小於 1,說明去重生效但重複量大,需要檢查重連頻率。
  2. 統計重連次數:正常情況下一天應該只有個位數。如果頻繁重連,問題通常在心跳設置或網絡環境。
  3. 對賬下游指標:如果告警系統出現「同一價格同一時間觸發兩次」,說明去重沒覆蓋到下游。

常見問題

為什麼重連後要先收到 subscribed 才能處理數據?

因為 send 只表示消息已發出,不代表服務端接受了訂閱。只有在收到 type=subscribed 確認幀後,才能確認 market 與 symbols 已生效。跳過這一步會讓訂閱失敗變成靜默故障。

去重應該用哪個字段做鍵?

用 (symbol, at)。tick 幀裡的 at 是 RFC3339 時間戳,同一標的同一時刻的 tick 天然唯一,非常適合做冪等鍵。

重連會消耗額度嗎?

會。建連握手計入每日額度與每分鐘速率,握手之後的 tick 推送不計費。所以退避策略是必要的——不要用高頻重連來「保活」。

靜默超時設多少合適?

取決於標的的活躍度。冷門市場或非交易時段可能長時間沒有 tick,靜默超時設得太短會誤判斷線並反覆重連。建議優先依賴協議層 ping/pong 判斷連接存活,把靜默超時設得寬鬆一些(例如 60 秒)。

訂閱被拒時應該怎麼處理?

按錯誤碼區分:MARKET_NOT_ENABLED 或 NO_SUBSCRIPTION 屬於權限問題,重連不會解決,應該立即告警並停止重試;INVALID_SYMBOL 或 UNKNOWN_SYMBOL 屬於參數問題,應該修正符號後重新訂閱。

下一步

把訂閱確認、去重窗口與心跳超時這三件事分開處理,重複數據問題基本就消失了。訂閱協議與錯誤碼的完整定義見 WebSocket 流式接口文檔;重連與心跳的更多實踐可參考 斷線重連指南 與 WebSocket 心跳;如果你還在糾結要不要上流式,可以對比 輪詢與流式 的差異。